summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/e2e/.eslintrc.yaml24
-rw-r--r--tests/e2e/README.md220
-rw-r--r--tests/e2e/actions.test.e2e.js79
-rw-r--r--tests/e2e/commit-graph-branch-selector.test.e2e.js25
-rw-r--r--tests/e2e/dashboard-ci-status.test.e2e.js21
-rw-r--r--tests/e2e/debugserver_test.go32
-rw-r--r--tests/e2e/declare_repos_test.go87
-rw-r--r--tests/e2e/e2e_test.go129
-rw-r--r--tests/e2e/example.test.e2e.js50
-rw-r--r--tests/e2e/explore.test.e2e.js40
-rw-r--r--tests/e2e/issue-comment.test.e2e.js63
-rw-r--r--tests/e2e/issue-sidebar.test.e2e.js226
-rw-r--r--tests/e2e/markdown-editor.test.e2e.js177
-rw-r--r--tests/e2e/markup.test.e2e.js14
-rw-r--r--tests/e2e/org-settings.test.e2e.js24
-rw-r--r--tests/e2e/profile_actions.test.e2e.js41
-rw-r--r--tests/e2e/reaction-selectors.test.e2e.js65
-rw-r--r--tests/e2e/release.test.e2e.js76
-rw-r--r--tests/e2e/repo-code.test.e2e.js86
-rw-r--r--tests/e2e/repo-migrate.test.e2e.js32
-rw-r--r--tests/e2e/repo-settings.test.e2e.js48
-rw-r--r--tests/e2e/repo-wiki.test.e2e.js16
-rw-r--r--tests/e2e/right-settings-button.test.e2e.js128
-rw-r--r--tests/e2e/shared/forms.js44
-rw-r--r--tests/e2e/utils_e2e.js82
-rw-r--r--tests/e2e/utils_e2e_test.go56
-rw-r--r--tests/e2e/webauthn.test.e2e.js60
-rw-r--r--tests/fuzz/fuzz_test.go40
-rw-r--r--tests/gitea-lfs-meta/0b/8d/8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351bin0 -> 107 bytes
-rw-r--r--tests/gitea-lfs-meta/2e/cc/db43825d2a49d99d542daa20075cff1d97d9d2349a8977efe9c03661737cbin0 -> 2048 bytes
-rw-r--r--tests/gitea-lfs-meta/7b/6b/2c88dba9f760a1a58469b67fee2b698ef7e9399c4ca4f34a14ccbe39f6231
-rw-r--r--tests/gitea-lfs-meta/9d/17/2e5c64b4f0024b9901ec6afe9ea052f3c9b6ff9f4b07956d8c48c86fca821
-rw-r--r--tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/config6
-rw-r--r--tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/description1
-rw-r--r--tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/objects/74/8bf557dfc9c6457998b5118a6c8b2129f56c30bin0 -> 43 bytes
-rw-r--r--tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/objects/a5/46f86c7dd182592b96639045e176dde8df76efbin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/objects/b8/95782bd271fdd266dd06e5880ea4abdc3a0dc7bin0 -> 782 bytes
-rw-r--r--tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/config6
-rw-r--r--tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/description1
-rw-r--r--tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/objects/21/2f14c8b713de38bd0b3fb23bd288369b01668abin0 -> 43 bytes
-rw-r--r--tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/objects/90/e402c3937a4639725fcc59ca1f529e7dc8506fbin0 -> 783 bytes
-rw-r--r--tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/objects/ed/d9c1000cd1444efd63e153e3554c8d5656bf65bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/config7
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/description1
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/hooks/post-checkout3
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/hooks/post-commit3
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/hooks/post-merge3
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/hooks/pre-push3
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/indexbin0 -> 305 bytes
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/lfs/objects/d6/f1/d6f175817f886ec6fbbc1515326465fa96c3bfd54a4ea06cfd6dbbd8340e01521
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/lfs/objects/fb/8f/fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab0411
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/objects/54/6244003622c64b2fc3c2cd544d7a29882c8383bin0 -> 128 bytes
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/objects/6a/6ccf5d874fec134ee712572cc03a0f2dd7afecbin0 -> 51 bytes
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/objects/a6/7134b8484c2abe9fa954e1fd83b39b271383edbin0 -> 121 bytes
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/objects/b7/01ed6ffe410f0c3ac204b929ea47cfec6cef54bin0 -> 122 bytes
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/objects/f2/07b74f55cd7f9e800b7550d587cbc488f6eaf1bin0 -> 120 bytes
-rw-r--r--tests/gitea-repositories-meta/migration/lfs-test.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/COMMITMESSAGE0
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/COMMIT_EDITMSG1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/config10
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/config.backup7
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/description1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/indexbin0 -> 137 bytes
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/logs/HEAD2
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/logs/refs/heads/branch11
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/logs/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/objects/ba/ea7d6e6b7773a80bcede323cfb21dfe9d4b855bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/objects/c2/a1ad4c931cebe27c7e39176fe7119b5557c9ebbin0 -> 60 bytes
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/objects/cd/aca8cf1d36e1e4e508a940f6e157e239beccfa3
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/refs/heads/branch11
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/COMMITMESSAGE0
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/COMMIT_EDITMSG1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/config10
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/config.backup7
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/description1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/indexbin0 -> 137 bytes
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/logs/HEAD2
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/logs/refs/heads/branch11
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/logs/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/objects/ba/ea7d6e6b7773a80bcede323cfb21dfe9d4b855bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/objects/c2/a1ad4c931cebe27c7e39176fe7119b5557c9ebbin0 -> 60 bytes
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/objects/cd/aca8cf1d36e1e4e508a940f6e157e239beccfa3
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/refs/heads/branch11
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/COMMITMESSAGE0
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/COMMIT_EDITMSG1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/config10
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/config.backup7
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/description1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/indexbin0 -> 137 bytes
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/logs/HEAD2
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/logs/refs/heads/branch11
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/logs/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/objects/ba/ea7d6e6b7773a80bcede323cfb21dfe9d4b855bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/objects/c2/a1ad4c931cebe27c7e39176fe7119b5557c9ebbin0 -> 60 bytes
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/objects/cd/aca8cf1d36e1e4e508a940f6e157e239beccfa3
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/refs/heads/branch11
-rw-r--r--tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/org3/repo3.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/org3/repo3.git/config6
-rw-r--r--tests/gitea-repositories-meta/org3/repo3.git/description1
-rwxr-xr-xtests/gitea-repositories-meta/org3/repo3.git/hooks/post-receive7
-rwxr-xr-xtests/gitea-repositories-meta/org3/repo3.git/hooks/post-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/org3/repo3.git/hooks/pre-receive7
-rwxr-xr-xtests/gitea-repositories-meta/org3/repo3.git/hooks/pre-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/org3/repo3.git/hooks/update7
-rwxr-xr-xtests/gitea-repositories-meta/org3/repo3.git/hooks/update.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/org3/repo3.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/org3/repo3.git/objects/20/ade30d25e0ecaeec84e7f542a8456900858240bin0 -> 84 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo3.git/objects/27/74debeea6dc742cc4971a92db0e08b95b60588bin0 -> 51 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo3.git/objects/2a/47ca4b614a9f5a43abbd5ad851a54a616ffee6bin0 -> 760 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo3.git/objects/2f/9b22fd3159a43b7b4e5dd806fcd544edf8716fbin0 -> 37 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo3.git/objects/d2/2b4d4daa5be07329fcef6ed458f00cf3392da0bin0 -> 814 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo3.git/objects/d5/6a3073c1dbb7b15963110a049d50cdb5db99fcbin0 -> 42 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo3.git/objects/ec/f0db3c1ec806522de4b491fb9a3c7457398c61bin0 -> 62 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo3.git/objects/ee/16d127df463aa491e08958120f2108b02468dfbin0 -> 84 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo3.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/org3/repo3.git/refs/heads/test_branch1
-rw-r--r--tests/gitea-repositories-meta/org3/repo5.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/org3/repo5.git/config6
-rw-r--r--tests/gitea-repositories-meta/org3/repo5.git/description1
-rwxr-xr-xtests/gitea-repositories-meta/org3/repo5.git/hooks/post-receive7
-rwxr-xr-xtests/gitea-repositories-meta/org3/repo5.git/hooks/post-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/org3/repo5.git/hooks/pre-receive7
-rwxr-xr-xtests/gitea-repositories-meta/org3/repo5.git/hooks/pre-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/org3/repo5.git/hooks/update7
-rwxr-xr-xtests/gitea-repositories-meta/org3/repo5.git/hooks/update.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/org3/repo5.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/org3/repo5.git/objects/20/ade30d25e0ecaeec84e7f542a8456900858240bin0 -> 84 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo5.git/objects/27/74debeea6dc742cc4971a92db0e08b95b60588bin0 -> 51 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo5.git/objects/2a/47ca4b614a9f5a43abbd5ad851a54a616ffee6bin0 -> 760 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo5.git/objects/2f/9b22fd3159a43b7b4e5dd806fcd544edf8716fbin0 -> 37 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo5.git/objects/d2/2b4d4daa5be07329fcef6ed458f00cf3392da0bin0 -> 814 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo5.git/objects/d5/6a3073c1dbb7b15963110a049d50cdb5db99fcbin0 -> 42 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo5.git/objects/ec/f0db3c1ec806522de4b491fb9a3c7457398c61bin0 -> 62 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo5.git/objects/ee/16d127df463aa491e08958120f2108b02468dfbin0 -> 84 bytes
-rw-r--r--tests/gitea-repositories-meta/org3/repo5.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/org3/repo5.git/refs/heads/test_branch1
-rw-r--r--tests/gitea-repositories-meta/org41/repo61.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/org41/repo61.git/config6
-rw-r--r--tests/gitea-repositories-meta/org41/repo61.git/description1
-rw-r--r--tests/gitea-repositories-meta/org41/repo61.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/config6
-rw-r--r--tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/description1
-rw-r--r--tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/objects/6e/75c9f89da9a9b93f4f36e61ed092f7a1625ba0bin0 -> 785 bytes
-rw-r--r--tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/objects/7f/eb6f9dd600e17a04f48a76cfa0a56a3f30e2c1bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/objects/b7/91b41c0ae8cb3c4b12f3fd8c3709c2481d9e37bin0 -> 43 bytes
-rw-r--r--tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/config6
-rw-r--r--tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/description1
-rw-r--r--tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/objects/04/f99c528b643b9175a4b156cdfc13aba6b43853bin0 -> 53 bytes
-rw-r--r--tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/objects/86/de16d8658f5c0a17ec6aa313871295d7072f78bin0 -> 43 bytes
-rw-r--r--tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/objects/bf/19fd4707acb403c4aca44f126ab69142ac59cebin0 -> 785 bytes
-rw-r--r--tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user12/repo10.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user12/repo10.git/config4
-rw-r--r--tests/gitea-repositories-meta/user12/repo10.git/description1
-rwxr-xr-xtests/gitea-repositories-meta/user12/repo10.git/hooks/post-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user12/repo10.git/hooks/post-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user12/repo10.git/hooks/pre-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user12/repo10.git/hooks/pre-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user12/repo10.git/hooks/update7
-rwxr-xr-xtests/gitea-repositories-meta/user12/repo10.git/hooks/update.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user12/repo10.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user12/repo10.git/info/refs1
-rw-r--r--tests/gitea-repositories-meta/user12/repo10.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user12/repo10.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349fbin0 -> 42 bytes
-rw-r--r--tests/gitea-repositories-meta/user12/repo10.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4dbin0 -> 150 bytes
-rw-r--r--tests/gitea-repositories-meta/user12/repo10.git/objects/info/packs1
-rw-r--r--tests/gitea-repositories-meta/user12/repo10.git/refs/heads/DefaultBranch1
-rw-r--r--tests/gitea-repositories-meta/user12/repo10.git/refs/heads/develop1
-rw-r--r--tests/gitea-repositories-meta/user12/repo10.git/refs/heads/feature/11
-rw-r--r--tests/gitea-repositories-meta/user12/repo10.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user12/repo10.git/refs/tags/v1.11
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/config4
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/description1
-rwxr-xr-xtests/gitea-repositories-meta/user13/repo11.git/hooks/post-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user13/repo11.git/hooks/post-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user13/repo11.git/hooks/pre-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user13/repo11.git/hooks/pre-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user13/repo11.git/hooks/update7
-rwxr-xr-xtests/gitea-repositories-meta/user13/repo11.git/hooks/update.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/info/refs1
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/objects/0a/bcb056019adb8336cf9db3ad9d9cf80cd4b141bin0 -> 818 bytes
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349fbin0 -> 42 bytes
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4dbin0 -> 150 bytes
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/objects/75/d1afd00e111c8dbd9e3d96a27b431ac5ae6d74bin0 -> 44 bytes
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/objects/ed/447543e0c85d628b91f7f466f4921908f4c5eabin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/objects/info/packs1
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/refs/heads/DefaultBranch1
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/refs/heads/branch21
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/refs/heads/develop1
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/refs/heads/feature/11
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user13/repo11.git/refs/tags/v1.11
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/config8
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/description1
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/0a/8499a22ad32a80beda9d75efe15f9f945824682
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/0c/cf1fcd4d1717c22de0707619a5577ea0acebf0bin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/3e/a192a6466793d4b7cd8641801ca0c6bec3979cbin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/3f/6594f108842b7c50772510e53ce113d3583c4abin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/58/e97d1a24fb9e1599d8a467ec409430f3d3569ebin0 -> 154 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/67/68c1fc1d9448422f05cc84267d94ee62085fe8bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/6e/8eabd9a7f8d6acd2a1219facfd37415564b1442
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/85/f46d747a68adf79cc01e2c25ba6a56932d298dbin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/8d/dd8d1ad1fdc21ab629e906711fa9bc27aa1c52bin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/95/fd0c4138480e4b3913e7cf71a90623fb926fe8bin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/98/00fe78cabf4fe774fcf376f97fa2a0ed06987bbin0 -> 149 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/9f/cdb7d53bdef786d2e5577948a0c0d4b321fe5abin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/c2/0caf78b5f9dd2d0d183876c5cd0e761c13f7f82
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/c5/2ba74685f5c8c593efbbb38f62fe024110adefbin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/objects/d6/ae8023a10ff446b6a4e7f441554834008e99c3bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/packed-refs2
-rw-r--r--tests/gitea-repositories-meta/user2/commits_search_test.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/description1
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/info/refs3
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/logs/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/logs/refs/heads/branch11
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/logs/refs/heads/main1
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/0a/6dda431c72a6a4aac05b98e319972a1a55e01cbin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/0c/396a509b64fd4e2e55649d100b86e8b96cc0e5bin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/10/0ef49565829e7bd83057d2dab88f58b00db831bin0 -> 271 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/10/9ab1c0b84e088d7edcf018379518b49361f285bin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/19/78192d98bb1b65e11c2cf37da854fbf94bffd6bin0 -> 162 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/1e/67d753ac1f9097eff26f9d33eb80182344b72cbin0 -> 87 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/23/576dd018294e476c06e569b6b0f170d05587052
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/28/16bffda09c0f23775ea4be279de004d28a3803bin0 -> 245 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/35/f03b5e176ee6d24c86b5cca7009a5b0ba2a026bin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/38/cdad2e40c989aabab3f2d0a27faf0f7be617d5bin0 -> 167 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/3e/64625bd6eb5bcba69ac97de6c8f507402df861bin0 -> 162 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/4b/860706d3eec5858324d4ba00db0423ca4cbf50bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/4c/a8bcaf27e28504df7bf996819665986b01c847bin0 -> 163 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/52/84ca7f5757816e67c098224a8367aa2544222dbin0 -> 193 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/53/9a24812705f77484568e6ad7db84764c1903c8bin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/70/8605e8984e7fb9be58818e0e6d9f21bcefd63ebin0 -> 33 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/74/7ddb3506a4fa04a7747808eb56ae16f9e933dcbin0 -> 162 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/7e/d1d42eda9110676d5c3a7721965d6ed1afe83cbin0 -> 324 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/81/1d46c7e518f4f180afb862c0db5cb8c80529ce2
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/83/2d33e438d2b4a86fba81cb67b32d1d61a828cbbin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/83/7d5c8125633d7d258f93b998e867eab01455203
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/87/cdc1333f5f117a92f3cef78ebe0301114b36102
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/92/70b08497106eaa65fce8aa91f37c4780f76909bin0 -> 140 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/96/cef4a7b72b3c208340ae6f0cf55a93e9077c93bin0 -> 163 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/97/0c5deb117526983f554eaaa1b59102d3e3e0f7bin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/c5/626fc9eff57eb1bb7b796b01d4d0f2f3f792a22
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/c7/04db5794097441aa2d9dd834d5b7e2f8f08108bin0 -> 163 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/cb/ff181af4c9c7fee3cf6c106699e07d9a3f54e6bin0 -> 128 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d1/8e427f4011e74e96a31823c938be26eebab53bbin0 -> 114 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d2/5795e38fbc1b4839697e834b957d61c83d994fbin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d6/6f456f0813a5841fbc03e5f1c47304dc675695bin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/e1/7e0fa20f3d2125916f2fb2f51f19240678cb83bin0 -> 219 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/ec/d9fdda5c814055ee619513e1c388ba1bbcb280bin0 -> 32 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/objects/info/packs1
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/branch11
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/main1
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user2/commitsonpr.git/refs/pull/1/head1
-rw-r--r--tests/gitea-repositories-meta/user2/git_hooks_test.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/git_hooks_test.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/git_hooks_test.git/description1
-rwxr-xr-xtests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/post-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/post-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive.d/pre-receive3
-rwxr-xr-xtests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/update7
-rwxr-xr-xtests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/update.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user2/git_hooks_test.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/git_hooks_test.git/info/refs1
-rw-r--r--tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349fbin0 -> 42 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4dbin0 -> 150 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/info/packs1
-rw-r--r--tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/DefaultBranch1
-rw-r--r--tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/develop1
-rw-r--r--tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/feature/11
-rw-r--r--tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/tags/v1.11
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/description1
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/objects/48/06cb9df135782b818c968c2fadbd2c150d23d6bin0 -> 21 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/objects/59/fee614e09d1f1cd1e15e4b2a7e9c8873a81498bin0 -> 34 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/objects/7c/8ac2f8d82a1eb5f6aaece6629ff11015f91eb4bin0 -> 21 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/objects/8e/592e636d27ac144f92f7fe8c33631cbdea594dbin0 -> 78 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/objects/95/aff026f99a9ab76fbd01decb63dd3dbc03e498bin0 -> 34 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/objects/ae/d1ffed24cc3cf9b80490795e893cae4bddd684bin0 -> 108 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/objects/bf/d6a6583f9a9ac59bd726c1df26c64a89427edebin0 -> 21 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/objects/c8/eb3b6c767ccb68411d0a1f6c769be69fb4d95a1
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/objects/de/6be43fe8eb19ca3f4e934cb8b9a9a0b20fe865bin0 -> 50 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/objects/ef/6b814b610d8e7717aa0f71fbe5842bcf8146972
-rw-r--r--tests/gitea-repositories-meta/user2/glob.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/objects/15/2de0f78bc6815b58cd9f08aebe3f66fb0f172ebin0 -> 228 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/objects/23/10e4a07f9314a1a92fdfbdcd3d2884f01e96abbin0 -> 123 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/objects/2b/6c6c4eaefa24b22f2092c3d54b263ff26feb58bin0 -> 122 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/objects/30/77e1c4c8964613df72c37d14275c1eda5228a92
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/objects/6b/bc79965141058b0026f2064dfb6d2eae3c4540bin0 -> 259 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/objects/73/cf03db6ece34e12bf91e8853dc58f678f2f82d2
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/objects/74/21a018a7e3f15ee5691f162d0ed87dc19882f0bin0 -> 123 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/objects/82/76d2a29779af982c0afa976bdb793b52d442a8bin0 -> 38 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/objects/b0/89e97ee59224e8c5676673c096ee4b6a8b9342bin0 -> 123 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/objects/bc/e50ea8f203ee923d5a640d05208abf3206486ebin0 -> 92 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/objects/d4/a41a0d4db4949e129bd22f871171ea988103efbin0 -> 123 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/objects/d7/ce0013ced38b0696dd2d68d69a5d8b652f7148bin0 -> 55 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/objects/df/d8105b264d304c49ed9f1d56bd90189ecdf833bin0 -> 75 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/objects/e9/c32647bab825977942598c0efa415de300304bbin0 -> 170 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/lfs.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/info/refs21
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/12/11481f7314efbfe4e44703170d96c8fac8172bbin0 -> 169 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/17/2343566bf11fc71ba4acf8d2ea70d12bc1d037bin0 -> 214 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/1a/48cae3f18ccd9c929e6608f67087dbaac3cf9ebin0 -> 167 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/1e/1e08102cf1b1fc01c069c88ee75445974363abbin0 -> 83 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/21/470f9b3e8ff24e0fa083d2dbc447f4c34013552
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/23/65bfe0c5714e2e3f2d53bb302b10d8d5b4fc7dbin0 -> 175 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/38/9d08c6a71d024a91f14089007cd789cd977ca6bin0 -> 48 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/3a/a8f4e0e1a535f0f9e0ae40e6ec1bce42642bc4bin0 -> 140 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/3b/23d7f1a9cb904cb46f5f2272bfa5ed5f871fb91
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/50/6ff7310f420e878595b4bc8f11688e3f0ae14ebin0 -> 166 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/58/3eb775c596858380273492759d39081d65d029bin0 -> 169 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/60/ea618ae7d4ecbe9c1962591c7da1b05bb1a5c83
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/6a/b05db4c52530726c1856eb558228e9d1949e7fbin0 -> 169 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/71/60a063b5544b5a78131b94f47bfd200046eda2bin0 -> 167 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/75/6c70c97047d8aeb11ca3c71edd9fb76cefee9cbin0 -> 28 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/7f/2b9f991d99362eb827b67f4ae2f5fbc5fa2271bin0 -> 211 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/7f/792e709143fb0f021da2371e5f40d1bcc284fdbin0 -> 166 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/82/817856dadc7f6b944633e1b77d5b6e302dde06bin0 -> 51 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/8b/4149e7dede3cd53ba11c64c88b057c5fe2c200bin0 -> 169 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/93/54813d81053c14afe878a9f056b937ec42bb48bin0 -> 28 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/9c/72c10e55e7d6ea21f591aa424e2625e8ad8094bin0 -> 136 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/a3/cd04bb110e17591ac04e156c7df2c2f5c96fa6bin0 -> 82 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/b0/e851a5619e2d6cee1da25a15ab67305f0861ecbin0 -> 76 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/b4/4c8eb00bdaf0522de61e591fee5f66851ef4b5bin0 -> 112 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/b8/eaa80ad86072e1f23d2638842154ce9aceff8dbin0 -> 77 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/d5/34f914944c3c943a6bdb677d869ac54934928dbin0 -> 31 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/e2/f9904cd97b4045feecfffef5a426e9461bee70bin0 -> 117 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/e3/a6fd8fe49e323ee10017f72b777a53fbd8076f3
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/e7/bf02fcfa7a86f7fe9e8158b55d58ddf9d877ecbin0 -> 171 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/ea/57c91ddb8b4ac705b5ac4c34c7a48f2d0fc180bin0 -> 77 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/fe/495ea336f079ef2bed68648d0ba9a37cdbd4aabin0 -> 197 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/info/commit-graphbin0 -> 2612 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/info/packs2
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/pack/pack-8933bd634b76f8154310cccb52537a0195e43166.bitmapbin0 -> 1642 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/pack/pack-8933bd634b76f8154310cccb52537a0195e43166.idxbin0 -> 4012 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/objects/pack/pack-8933bd634b76f8154310cccb52537a0195e43166.packbin0 -> 7854 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/packed-refs22
-rw-r--r--tests/gitea-repositories-meta/user2/readme-test.git/refs/heads/fallbacks-broken-symlinks1
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/description1
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/08/9ba8b2f324d89b74f6853374a0476b312a46f6bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/18/4288e5acffbcb17160b990e8efe83b12dfaababin0 -> 127 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/24/3cdd85d09ce4104855edf219c05b74c65350fcbin0 -> 85 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/43/80f99290b2b3922733ff82c57afad915ace907bin0 -> 158 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/6d/fe48a18ce2fb47d3a75e13c7ab35f935077535bin0 -> 50 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/71/97b56fdc75b453f47c9110938cb46a303579fdbin0 -> 153 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/79/f9d88f1b054d650f88da0bd658e21f7b0cf6ecbin0 -> 156 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/7c/055ef1678b03b831bbe7b9ca5aed33b1a8dea0bin0 -> 53 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/80/abeef37c96b85b83a916f5f295f04f4d380a42bin0 -> 85 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/a8/a700e8c644c783ba2c6e742bb81bf91e244bffbin0 -> 153 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/bc/7068d1eb2f93a04e3ec73521473444ceec0961bin0 -> 58 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/c4/a4e1a72a2098d687b4280e7c6972280c1f9c39bin0 -> 166 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/cd/7f28e1b404377eadbe0d54234ba861883e6930bin0 -> 96 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/ce/f06e48f2642cd0dc9597b4bea09f4b3f74aad6bin0 -> 159 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/d0/718fe871fbb54da104ff201f75f62a6ced2e29bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/d4/613f8dad1fa61e415922f6eb33244358fca85dbin0 -> 84 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/dd/5488178fc8a5c62430b3fb3017203b917b95abbin0 -> 38 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391bin0 -> 15 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/ef/3c849ed54b22bb1f500da91b789c40cb0915dabin0 -> 97 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/f3/f1c90ac949aa1b0f129d30f338d408663c8a832
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/objects/f9/3e3a1a1525fb5b91020da86e44810c87a2d7bcbin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/refs/heads/main1
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/refs/tags/v1.01
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/refs/tags/v1.11
-rw-r--r--tests/gitea-repositories-meta/user2/repo-release.git/refs/tags/v2.01
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/description1
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo1.git/hooks/post-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo1.git/hooks/post-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo1.git/hooks/pre-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo1.git/hooks/pre-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo1.git/hooks/proc-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo1.git/hooks/proc-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo1.git/hooks/update7
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo1.git/hooks/update.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/info/refs3
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/00/750edc07d6415dcc07ae0351e9397b0222b7babin0 -> 17 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/16/633238d370a441f98dca532e4296a619c4c85fbin0 -> 47 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/3f/a2f829675543ecfc16b2891aebe8bf0608a8f4bin0 -> 138 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/40/3d76c604cb569323864e06a07b85d466924802bin0 -> 68 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/46/49299398e4d39a5c09eb4f534df6f1e1eb87cc4
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/4a/357436d925b5c974181ff12a994538ddc5a269bin0 -> 840 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349fbin0 -> 42 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/5c/050d3b6d2db231ab1f64e324f1b6b9a0b181c2bin0 -> 833 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/5f/22f7d0d95d614d25a5b68592adb345a4b5c7fdbin0 -> 185 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/62/fb502a7172d4453f0322a2cc85bddffa57f07abin0 -> 839 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4dbin0 -> 150 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/6a/a3a5385611c5eb8986c9961a9c34a93cbaadfbbin0 -> 86 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/78/fb907e3a3309eae4fe8fef030874cebbf1cd5ebin0 -> 158 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/7c/4df115542e05c700f297519e906fd63c9c9804bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/90/dcd07da077d1e7cd6032b52d1f79ae2b5f19b22
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/94/922e1295c678267de1193b7b84ad8a086c27f9bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/98/5f0301dba5e7b34be866819cd15ad3d8f508eebin0 -> 842 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/a6/9277c81e90b98a7c0ab25b042a6e296da8eb9abin0 -> 76 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/a7/57c0ea621e63d0fd6fc353a175fdc7199e5d1dbin0 -> 61 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/b2/60587271671842af0b036e4fe643c9d45b7dddbin0 -> 20 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/d4/a1a6dcf7bd42891f264d484e80dac7e66b5410bin0 -> 34 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/d7/bd5b8cfb680f460e37b6fd7cf74c284e059118bin0 -> 85 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/dc/7a8ba127fee870dd683310ce660dfe59333a1bbin0 -> 78 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/dd/59742c0f6672911f2b64cba5711ac00593ed32bin0 -> 118 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/f3/fa0f5cc797fc4c02a1b8bec9de4b2072fcdbdfbin0 -> 53 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/objects/info/packs1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/refs/heads/DefaultBranch1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/refs/heads/branch21
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/refs/heads/develop1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/refs/heads/feature/11
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/refs/heads/home-md-img-check1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/refs/heads/pr-to-update1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/refs/heads/sub-home-md-img-check1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/refs/notes/commits1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/refs/pull/2/head1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/refs/pull/3/head1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/refs/pull/5/head1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.git/refs/tags/v1.11
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/description1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/0c/f15c3f66ec8384480ed9c3cf87c9e97fbb0ec32
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/0d/ca5bd9b5d7ef937710e056f575e86c0184ba85bin0 -> 820 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/2c/54faec6c45d31c1abfaecdab471eac6633738abin0 -> 131 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/32/5dc4f8e9344e6668f21536a69d5f1d4ed53ca3bin0 -> 63 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/42/3313fbd38093bb10d0c8387db9105409c6f196bin0 -> 830 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/71/911bf48766c7181518c1070911019fbb00b1fc1
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/72/fc6251cc648e914c10009d31431fa2e38b9a20bin0 -> 94 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/74/d5a0d73db9b9ef7aa9978eb7a099b08f54d45ebin0 -> 53 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/7c/d7c8fa852973c72c66eb120a6677c54a8697f7bin0 -> 95 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/89/43a1d5f93c00439d5ffc0f8e36f5d60abae46cbin0 -> 206 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/c1/0d10b7e655b3dab1f53176db57c8219a5488d62
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/c4/b38c3e1395393f75bbbc2ed10c7eeb577d3b64bin0 -> 189 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/e5/3d079e581fbfdea1075a54d5b621eab0090e52bin0 -> 52 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/ea/82fc8777a24b07c26b3a4bf4e2742c03733eabbin0 -> 44 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/f5/05ec9b5c7a45a10259c1dda7f18434e5d55940bin0 -> 157 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo1.wiki.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user2/repo15.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/repo15.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/repo15.git/description1
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo15.git/hooks/post-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo15.git/hooks/post-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo15.git/hooks/pre-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo15.git/hooks/pre-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo15.git/hooks/update7
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo15.git/hooks/update.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user2/repo15.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/description1
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/objects/0c/3d59dea27b97aa3cb66072745d7a2c51a7a8b1bin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/objects/24/f83a471f77579fea57bac7255d6e64e70fce1cbin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/objects/27/566bd5738fc8b4e3fef3c5e72cce608537bd95bin0 -> 575 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/objects/3b/2b54fe3d9a8279d5b926124dccdf279b8eff2f1
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/objects/45/8121ce9a6b855c9733bae62093caf3f39685debin0 -> 26 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/objects/50/99b81332712fe655e34e8dd63574f503f618112
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/objects/69/554a64c1e6030f051e5c3f94bfbd773cd6a324bin0 -> 158 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/objects/a4/3476a501516e065c5a82f05fd58fd319598bc1bin0 -> 57 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/objects/e9/4083fcdf1f10c545e9253a23c5e44a2ff68aacbin0 -> 524 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/objects/f2/7c2b2b03dcab38beaf89b0ab4ff61f6de63441bin0 -> 522 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/objects/f9/0451c72ef61a7645293d17b47be7a8e983da57bin0 -> 27 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/refs/heads/good-sign1
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/refs/heads/good-sign-not-yet-validated1
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user2/repo16.git/refs/heads/not-signed1
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/description1
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/info/refs1
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/0a/7d8b41ae9763e9a1743917396839d1791d49d0bin0 -> 188 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/0c/f15c3f66ec8384480ed9c3cf87c9e97fbb0ec32
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/10/32bbf17fbc0d9c95bb5418dabe8f8c992787002
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/1c/887eaa8d81fa86da7695d8f635cf17813eb4221
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/26/f842bcad37fa40a1bb34cbb5ee219ee35d863dbin0 -> 75 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/32/5dc4f8e9344e6668f21536a69d5f1d4ed53ca3bin0 -> 63 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/36/fff01c8c9f722d49d53186abd27b5be8d85338bin0 -> 155 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/42/3313fbd38093bb10d0c8387db9105409c6f196bin0 -> 830 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/71/911bf48766c7181518c1070911019fbb00b1fc1
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/72/fc6251cc648e914c10009d31431fa2e38b9a20bin0 -> 94 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/74/d5a0d73db9b9ef7aa9978eb7a099b08f54d45ebin0 -> 53 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/7c/d7c8fa852973c72c66eb120a6677c54a8697f7bin0 -> 95 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/ba/1aed4e2ea2443d76cec241b96be4ec990852ecbin0 -> 117 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/c1/0d10b7e655b3dab1f53176db57c8219a5488d62
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/c4/b38c3e1395393f75bbbc2ed10c7eeb577d3b64bin0 -> 189 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/f5/05ec9b5c7a45a10259c1dda7f18434e5d55940bin0 -> 157 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/info/commit-graphbin0 -> 1212 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/info/packs2
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/pack/pack-a2f7ad943b3d857eb3ebdb4b35eeef38f63cf5d2.bitmapbin0 -> 248 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/pack/pack-a2f7ad943b3d857eb3ebdb4b35eeef38f63cf5d2.idxbin0 -> 1240 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/objects/pack/pack-a2f7ad943b3d857eb3ebdb4b35eeef38f63cf5d2.packbin0 -> 637 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/packed-refs2
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user2/repo2.git/refs/tags/v1.11
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/description1
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo20.git/hooks/post-receive15
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo20.git/hooks/post-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo20.git/hooks/pre-receive15
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo20.git/hooks/pre-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo20.git/hooks/update14
-rwxr-xr-xtests/gitea-repositories-meta/user2/repo20.git/hooks/update.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/info/refs1
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/02/15cbe13d2695a2c3464ab5e59f47f37c3ff5d5bin0 -> 21 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/05/81d7edf45206787ff93956ea892e8a2ae77604bin0 -> 47 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/07/0b2e783a6b3e521a23fdead377a3e41a04410dbin0 -> 128 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/1b/271d83842d348b1ee71d8e6ead400aaeb4d1b5bin0 -> 19 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/29/5ba6ac57fdd46f62a51272f40e60b6dea697b2bin0 -> 21 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/2c/ec0f7069ed09d934e904c49f414d8bdf818ce4bin0 -> 49 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/41/4a282859758ba7b159bfbd9c2b193eb8f135eebin0 -> 18 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/79/adb592126eddce5f656f56db797910db025af0bin0 -> 165 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/80/8038d2f71b0ab020991439cffd24309c7bc530bin0 -> 138 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/83/70977f63979e140b6b58992b1fdb4098b24cd9bin0 -> 104 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/8b/abce967f21b9dfa6987f943b91093dac58a4f01
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/8c/e1dee41e1a3700819a9a309f275f8dc7b7e0b6bin0 -> 154 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/a4/202876cd8bbc3f38b7d99594edbe1bb7f97a6fbin0 -> 191 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/b0/246d5964a3630491bd06c756be46513e3d7035bin0 -> 21 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/b6/7e43a07d48243a5f670ace063acd5e13f719dfbin0 -> 173 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/ba/3aeafe10402c6b29535a58d91def7e43638d9dbin0 -> 22 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/c5/0ac6b9e25abb8200bb377755367d7265c581cfbin0 -> 75 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/c8/e31bc7688741a5287fcde4fbb8fc129ca070272
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/ce/013625030ba8dba906f756967f9e9ca394464abin0 -> 21 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/cf/e3b3c1fd36fba04f9183287b106497e1afe9863
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/db/89c972fc57862eae378f45b74aca228037d415bin0 -> 18 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/ea/f5f7510320b6a327fb308379de2f94d8859a54bin0 -> 30 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/objects/info/packs1
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/refs/heads/add-csv1
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/refs/heads/remove-files-a1
-rw-r--r--tests/gitea-repositories-meta/user2/repo20.git/refs/heads/remove-files-b1
-rw-r--r--tests/gitea-repositories-meta/user2/repo59.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/repo59.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/repo59.git/description1
-rw-r--r--tests/gitea-repositories-meta/user2/repo59.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/repo59.git/objects/info/commit-graphbin0 -> 1292 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo59.git/objects/info/packs2
-rw-r--r--tests/gitea-repositories-meta/user2/repo59.git/objects/pack/pack-6dd3a6fe138f1d77e14c2e6b8e6c41e5ae242adf.idxbin0 -> 1660 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo59.git/objects/pack/pack-6dd3a6fe138f1d77e14c2e6b8e6c41e5ae242adf.packbin0 -> 6316 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo59.git/objects/pack/pack-6dd3a6fe138f1d77e14c2e6b8e6c41e5ae242adf.revbin0 -> 136 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/repo59.git/packed-refs4
-rw-r--r--tests/gitea-repositories-meta/user2/test_commit_revert.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/test_commit_revert.git/config8
-rw-r--r--tests/gitea-repositories-meta/user2/test_commit_revert.git/description1
-rw-r--r--tests/gitea-repositories-meta/user2/test_commit_revert.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/test_commit_revert.git/objects/pack/pack-91200c8e6707636a6cc3e0d8101fba08b19dcb91.idxbin0 -> 1268 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/test_commit_revert.git/objects/pack/pack-91200c8e6707636a6cc3e0d8101fba08b19dcb91.packbin0 -> 609 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/test_commit_revert.git/packed-refs3
-rw-r--r--tests/gitea-repositories-meta/user2/test_commit_revert.git/refs/heads/main1
-rw-r--r--tests/gitea-repositories-meta/user2/test_workflows.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/test_workflows.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/test_workflows.git/description1
-rw-r--r--tests/gitea-repositories-meta/user2/test_workflows.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/test_workflows.git/objects/26/c8f930a36802d9cfb9785ca88704b1f52347aabin0 -> 51 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/7f57e0a452699a5d2da0e42dcb2375de546c0abin0 -> 62 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/89b2afa3e19e924330b4307a181714a4179010bin0 -> 423 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/test_workflows.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904bin0 -> 15 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/test_workflows.git/objects/77/4f93df12d14931ea93259ae93418da4482fcc1bin0 -> 333 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/test_workflows.git/objects/96/63cd4783a54f3e57b2dd908b077cf8126c826cbin0 -> 50 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/test_workflows.git/packed-refs3
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/config4
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/description1
-rwxr-xr-xtests/gitea-repositories-meta/user2/utf8.git/hooks/post-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user2/utf8.git/hooks/post-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user2/utf8.git/hooks/pre-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user2/utf8.git/hooks/pre-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user2/utf8.git/hooks/update7
-rwxr-xr-xtests/gitea-repositories-meta/user2/utf8.git/hooks/update.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/info/refs9
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/14/c42687126acae9d1ad41d7bdb528f811065a6abin0 -> 39 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/1d/5e00f305a7ca6a8a94e65456820a6d260adab8bin0 -> 127 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/28/d579e4920fbf4f66e71dab3e779d9fbf41422a3
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/3a/810dbf6b96afaa8c5f69a8b6ec1dabfca7368bbin0 -> 176 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/3a/a73c3499bff049a352b4e265575373e964b89abin0 -> 137 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/3a/c6084110205f98174c4f1ec7e78cb21a15dfc2bin0 -> 23 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/4c/61dd0a799e0830e77edfe6c74f7c349bc8e62abin0 -> 40 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/50/4d9fe743979d4e9785a25a363c7007293f0838bin0 -> 40 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/56/92bcf9f7c9eacb1ad68442161f2573877f96f4bin0 -> 49 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/59/e2c41e8f5140bb0182acebec17c8ad9831cc62bin0 -> 847 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/64/89894ad11093fdc49c0ed857d80682344a7264bin0 -> 39 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/6d/0c79ce3401c67d1ad522e61c47083a9fdee16cbin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/84/7c6d93c6860dd377651245711b7fbcd34a18d4bin0 -> 41 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/9b/9cc8f558d1c4f815592496fa24308ba2a9c824bin0 -> 47 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/a4/f1bb3f2f8c6a0e840e935812ef4903ce515dadbin0 -> 394 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/a9/a61830fbf4e84999d3b20cf178954366701fe5bin0 -> 129 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/c7/85b65bf16928b58567cb23669125c0ccd25a4fbin0 -> 44 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/e9/63733b8a355cf860c465b4af7b236a6ef08783bin0 -> 47 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/eb/f146f803fccbc1471ef01d8fa0fe12c14e61a51
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/ee/9686cb562f492f64381bff7f298b2a1c67a141bin0 -> 88 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/f4/02ff67c0b3161c3988dbf6188e6e0df257fd75bin0 -> 52 bytes
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/objects/info/packs1
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/refs/heads/Grüßen1
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/refs/heads/Plus+Is+Not+Space1
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/refs/heads/ГлавнаÑВетка1
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/refs/heads/а/б/в1
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/refs/heads/ブランãƒ1
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/refs/tags/Ð/人1
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/refs/tags/ТÑг1
-rw-r--r--tests/gitea-repositories-meta/user2/utf8.git/refs/tags/ã‚¿ã‚°1
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/config6
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/description1
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/hooks/post-receive15
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/hooks/post-receive.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/hooks/pre-receive15
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/hooks/pre-receive.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/hooks/update14
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/hooks/update.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/info/refs1
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/objects/47/34b1f84a367fa1b81c31aa4234a5bad11cafa3bin0 -> 84 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/objects/4d/31f3a12656368a8d9180f431d40d0fc408be2dbin0 -> 29 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/objects/51/f84af231345367fd5d61ceb89efb3b6d757061bin0 -> 121 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/objects/79/3aa682b06ae032641abf70c5dfeade28c07c52bin0 -> 28 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/objects/aa/cbdfe9e1c4b47f60abe81849045fa4e96f1d75bin0 -> 154 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/objects/dd/392e939ea4936b2459219c9c9a1f25547ccaebbin0 -> 53 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/objects/f2/8eeca3df7614fd4f10c1030f13feb418ef3c6fbin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/objects/info/packs1
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user27/repo49.git/refs/heads/test/archive1
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/config6
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/description1
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/hooks/post-receive15
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/hooks/post-receive.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/hooks/pre-receive15
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/hooks/pre-receive.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/hooks/update14
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/hooks/update.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/info/refs1
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/2a/83b349fa234131fc5db6f2a0498d3f4d3d60382
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/3d/0bc64f2521cfc7ffce6c175c1c846c88eb6df7bin0 -> 192 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/47/34b1f84a367fa1b81c31aa4234a5bad11cafa3bin0 -> 84 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/4d/31f3a12656368a8d9180f431d40d0fc408be2dbin0 -> 29 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/51/f84af231345367fd5d61ceb89efb3b6d757061bin0 -> 121 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/79/3aa682b06ae032641abf70c5dfeade28c07c52bin0 -> 28 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/83/77b2196e99ac8635aae79df3db76959ccd1094bin0 -> 53 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/99/45b93bcb5b70af06e0322bd2caa6180680991fbin0 -> 28 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/aa/cbdfe9e1c4b47f60abe81849045fa4e96f1d75bin0 -> 154 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/af/f5b10402b4e0479d1e76bc41a42d29fe7f28fabin0 -> 106 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/b9/04864fd6cd0c8e9054351fd39a980bfd214229bin0 -> 90 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/c5/10abf4c7c3e0dc4bf07db9344c61c4e6ee7cbcbin0 -> 50 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/dd/392e939ea4936b2459219c9c9a1f25547ccaebbin0 -> 53 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391bin0 -> 15 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/f2/8eeca3df7614fd4f10c1030f13feb418ef3c6fbin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/objects/info/packs1
-rw-r--r--tests/gitea-repositories-meta/user27/template1.git/refs/heads/master1
-rw-r--r--tests/gitea-repositories-meta/user30/empty.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user30/empty.git/config6
-rw-r--r--tests/gitea-repositories-meta/user30/renderer.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user30/renderer.git/config6
-rw-r--r--tests/gitea-repositories-meta/user30/renderer.git/description1
-rw-r--r--tests/gitea-repositories-meta/user30/renderer.git/hooks/post-receive15
-rw-r--r--tests/gitea-repositories-meta/user30/renderer.git/hooks/post-receive.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user30/renderer.git/hooks/pre-receive15
-rw-r--r--tests/gitea-repositories-meta/user30/renderer.git/hooks/pre-receive.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user30/renderer.git/hooks/update14
-rw-r--r--tests/gitea-repositories-meta/user30/renderer.git/hooks/update.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user30/renderer.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user30/renderer.git/objects/06/0d5c2acd8bf4b6f14010acd1a73d73392ec46ebin0 -> 56 bytes
-rw-r--r--tests/gitea-repositories-meta/user30/renderer.git/objects/45/14a93050edb2c3165bdd0a3c03be063e879e68bin0 -> 50 bytes
-rw-r--r--tests/gitea-repositories-meta/user30/renderer.git/objects/c9/61cc4d1ba6b7ee1ba228a9a02b00b7746d8033bin0 -> 789 bytes
-rw-r--r--tests/gitea-repositories-meta/user30/renderer.git/packed-refs2
-rw-r--r--tests/gitea-repositories-meta/user40/repo60.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user40/repo60.git/config6
-rw-r--r--tests/gitea-repositories-meta/user40/repo60.git/description1
-rw-r--r--tests/gitea-repositories-meta/user40/repo60.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user5/repo4.git/HEAD1
-rw-r--r--tests/gitea-repositories-meta/user5/repo4.git/config4
-rw-r--r--tests/gitea-repositories-meta/user5/repo4.git/description1
-rwxr-xr-xtests/gitea-repositories-meta/user5/repo4.git/hooks/post-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user5/repo4.git/hooks/post-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user5/repo4.git/hooks/pre-receive7
-rwxr-xr-xtests/gitea-repositories-meta/user5/repo4.git/hooks/pre-receive.d/gitea2
-rwxr-xr-xtests/gitea-repositories-meta/user5/repo4.git/hooks/update7
-rwxr-xr-xtests/gitea-repositories-meta/user5/repo4.git/hooks/update.d/gitea2
-rw-r--r--tests/gitea-repositories-meta/user5/repo4.git/info/exclude6
-rw-r--r--tests/gitea-repositories-meta/user5/repo4.git/objects/16/dfebd1ed3905d78d7e061e945fc9c34afe4e81bin0 -> 24 bytes
-rw-r--r--tests/gitea-repositories-meta/user5/repo4.git/objects/c1/202ad022ae7d3a6d2474dc76d5a0c8e87cdc0fbin0 -> 54 bytes
-rw-r--r--tests/gitea-repositories-meta/user5/repo4.git/objects/c7/cd3cd144e6d23c9d6f3d07e52b2c1a956e0338bin0 -> 818 bytes
-rw-r--r--tests/gitea-repositories-meta/user5/repo4.git/refs/heads/master1
-rw-r--r--tests/integration/README.md123
-rw-r--r--tests/integration/actions_commit_status_test.go49
-rw-r--r--tests/integration/actions_route_test.go184
-rw-r--r--tests/integration/actions_trigger_test.go445
-rw-r--r--tests/integration/admin_config_test.go23
-rw-r--r--tests/integration/admin_user_test.go91
-rw-r--r--tests/integration/api_actions_artifact_test.go378
-rw-r--r--tests/integration/api_actions_artifact_v4_test.go404
-rw-r--r--tests/integration/api_activitypub_actor_test.go50
-rw-r--r--tests/integration/api_activitypub_person_test.go116
-rw-r--r--tests/integration/api_activitypub_repository_test.go249
-rw-r--r--tests/integration/api_admin_org_test.go91
-rw-r--r--tests/integration/api_admin_test.go420
-rw-r--r--tests/integration/api_block_test.go228
-rw-r--r--tests/integration/api_branch_test.go268
-rw-r--r--tests/integration/api_comment_attachment_test.go278
-rw-r--r--tests/integration/api_comment_test.go467
-rw-r--r--tests/integration/api_feed_plain_text_titles_test.go43
-rw-r--r--tests/integration/api_feed_user_test.go89
-rw-r--r--tests/integration/api_forgejo_root_test.go21
-rw-r--r--tests/integration/api_forgejo_version_test.go59
-rw-r--r--tests/integration/api_fork_test.go110
-rw-r--r--tests/integration/api_gitignore_templates_test.go53
-rw-r--r--tests/integration/api_gpg_keys_test.go279
-rw-r--r--tests/integration/api_health_test.go25
-rw-r--r--tests/integration/api_helper_for_declarative_test.go463
-rw-r--r--tests/integration/api_httpsig_test.go142
-rw-r--r--tests/integration/api_issue_attachment_test.go244
-rw-r--r--tests/integration/api_issue_config_test.go211
-rw-r--r--tests/integration/api_issue_label_test.go309
-rw-r--r--tests/integration/api_issue_milestone_test.go86
-rw-r--r--tests/integration/api_issue_pin_test.go190
-rw-r--r--tests/integration/api_issue_reaction_test.go165
-rw-r--r--tests/integration/api_issue_stopwatch_test.go96
-rw-r--r--tests/integration/api_issue_subscription_test.go82
-rw-r--r--tests/integration/api_issue_templates_test.go115
-rw-r--r--tests/integration/api_issue_test.go578
-rw-r--r--tests/integration/api_issue_tracked_time_test.go131
-rw-r--r--tests/integration/api_keys_test.go213
-rw-r--r--tests/integration/api_label_templates_test.go62
-rw-r--r--tests/integration/api_license_templates_test.go55
-rw-r--r--tests/integration/api_nodeinfo_test.go39
-rw-r--r--tests/integration/api_notification_test.go216
-rw-r--r--tests/integration/api_oauth2_apps_test.go175
-rw-r--r--tests/integration/api_org_avatar_test.go77
-rw-r--r--tests/integration/api_org_test.go228
-rw-r--r--tests/integration/api_packages_alpine_test.go502
-rw-r--r--tests/integration/api_packages_arch_test.go409
-rw-r--r--tests/integration/api_packages_cargo_test.go447
-rw-r--r--tests/integration/api_packages_chef_test.go562
-rw-r--r--tests/integration/api_packages_composer_test.go222
-rw-r--r--tests/integration/api_packages_conan_test.go793
-rw-r--r--tests/integration/api_packages_conda_test.go275
-rw-r--r--tests/integration/api_packages_container_cleanup_sha256_test.go238
-rw-r--r--tests/integration/api_packages_container_test.go759
-rw-r--r--tests/integration/api_packages_cran_test.go236
-rw-r--r--tests/integration/api_packages_debian_test.go267
-rw-r--r--tests/integration/api_packages_generic_test.go245
-rw-r--r--tests/integration/api_packages_goproxy_test.go167
-rw-r--r--tests/integration/api_packages_helm_test.go168
-rw-r--r--tests/integration/api_packages_maven_test.go256
-rw-r--r--tests/integration/api_packages_npm_test.go332
-rw-r--r--tests/integration/api_packages_nuget_test.go763
-rw-r--r--tests/integration/api_packages_pub_test.go182
-rw-r--r--tests/integration/api_packages_pypi_test.go183
-rw-r--r--tests/integration/api_packages_rpm_test.go462
-rw-r--r--tests/integration/api_packages_rubygems_test.go398
-rw-r--r--tests/integration/api_packages_swift_test.go327
-rw-r--r--tests/integration/api_packages_test.go647
-rw-r--r--tests/integration/api_packages_vagrant_test.go172
-rw-r--r--tests/integration/api_private_serv_test.go154
-rw-r--r--tests/integration/api_pull_commits_test.go46
-rw-r--r--tests/integration/api_pull_review_test.go610
-rw-r--r--tests/integration/api_pull_test.go311
-rw-r--r--tests/integration/api_push_mirror_test.go291
-rw-r--r--tests/integration/api_quota_management_test.go846
-rw-r--r--tests/integration/api_quota_use_test.go1436
-rw-r--r--tests/integration/api_releases_test.go473
-rw-r--r--tests/integration/api_repo_activities_test.go88
-rw-r--r--tests/integration/api_repo_archive_test.go65
-rw-r--r--tests/integration/api_repo_avatar_test.go81
-rw-r--r--tests/integration/api_repo_branch_test.go131
-rw-r--r--tests/integration/api_repo_collaborator_test.go138
-rw-r--r--tests/integration/api_repo_compare_test.go38
-rw-r--r--tests/integration/api_repo_edit_test.go368
-rw-r--r--tests/integration/api_repo_file_create_test.go312
-rw-r--r--tests/integration/api_repo_file_delete_test.go167
-rw-r--r--tests/integration/api_repo_file_get_test.go52
-rw-r--r--tests/integration/api_repo_file_helpers.go61
-rw-r--r--tests/integration/api_repo_file_update_test.go275
-rw-r--r--tests/integration/api_repo_files_change_test.go311
-rw-r--r--tests/integration/api_repo_get_contents_list_test.go172
-rw-r--r--tests/integration/api_repo_get_contents_test.go196
-rw-r--r--tests/integration/api_repo_git_blobs_test.go80
-rw-r--r--tests/integration/api_repo_git_commits_test.go233
-rw-r--r--tests/integration/api_repo_git_hook_test.go196
-rw-r--r--tests/integration/api_repo_git_notes_test.go46
-rw-r--r--tests/integration/api_repo_git_ref_test.go39
-rw-r--r--tests/integration/api_repo_git_tags_test.go88
-rw-r--r--tests/integration/api_repo_git_trees_test.go77
-rw-r--r--tests/integration/api_repo_hook_test.go45
-rw-r--r--tests/integration/api_repo_languages_test.go50
-rw-r--r--tests/integration/api_repo_lfs_locks_test.go181
-rw-r--r--tests/integration/api_repo_lfs_migrate_test.go55
-rw-r--r--tests/integration/api_repo_lfs_test.go487
-rw-r--r--tests/integration/api_repo_raw_test.go40
-rw-r--r--tests/integration/api_repo_secrets_test.go112
-rw-r--r--tests/integration/api_repo_tags_test.go123
-rw-r--r--tests/integration/api_repo_teams_test.go82
-rw-r--r--tests/integration/api_repo_test.go766
-rw-r--r--tests/integration/api_repo_topic_test.go194
-rw-r--r--tests/integration/api_repo_variables_test.go149
-rw-r--r--tests/integration/api_settings_test.go64
-rw-r--r--tests/integration/api_team_test.go321
-rw-r--r--tests/integration/api_team_user_test.go49
-rw-r--r--tests/integration/api_token_test.go565
-rw-r--r--tests/integration/api_twofa_test.go60
-rw-r--r--tests/integration/api_user_avatar_test.go77
-rw-r--r--tests/integration/api_user_email_test.go129
-rw-r--r--tests/integration/api_user_follow_test.go121
-rw-r--r--tests/integration/api_user_heatmap_test.go39
-rw-r--r--tests/integration/api_user_info_test.go70
-rw-r--r--tests/integration/api_user_org_perm_test.go153
-rw-r--r--tests/integration/api_user_orgs_test.go132
-rw-r--r--tests/integration/api_user_search_test.go145
-rw-r--r--tests/integration/api_user_secrets_test.go101
-rw-r--r--tests/integration/api_user_star_test.go118
-rw-r--r--tests/integration/api_user_variables_test.go144
-rw-r--r--tests/integration/api_user_watch_test.go88
-rw-r--r--tests/integration/api_wiki_test.go411
-rw-r--r--tests/integration/archived_labels_display_test.go71
-rw-r--r--tests/integration/attachment_test.go138
-rw-r--r--tests/integration/auth_ldap_test.go566
-rw-r--r--tests/integration/auth_token_test.go164
-rw-r--r--tests/integration/avatar.pngbin0 -> 7787 bytes
-rw-r--r--tests/integration/benchmarks_test.go69
-rw-r--r--tests/integration/block_test.go454
-rw-r--r--tests/integration/branches_test.go58
-rw-r--r--tests/integration/change_default_branch_test.go40
-rw-r--r--tests/integration/cmd_admin_test.go147
-rw-r--r--tests/integration/cmd_forgejo_actions_test.go215
-rw-r--r--tests/integration/cmd_forgejo_f3_test.go137
-rw-r--r--tests/integration/cmd_keys_test.go54
-rw-r--r--tests/integration/codeowner_test.go140
-rw-r--r--tests/integration/compare_test.go293
-rw-r--r--tests/integration/cors_test.go94
-rw-r--r--tests/integration/create_no_session_test.go112
-rw-r--r--tests/integration/csrf_test.go34
-rw-r--r--tests/integration/db_collation_test.go149
-rw-r--r--tests/integration/delete_user_test.go63
-rw-r--r--tests/integration/doctor_packages_nuget_test.go122
-rw-r--r--tests/integration/download_test.go93
-rw-r--r--tests/integration/dump_restore_test.go329
-rw-r--r--tests/integration/easymde_test.go25
-rw-r--r--tests/integration/editor_test.go519
-rw-r--r--tests/integration/empty_repo_test.go138
-rw-r--r--tests/integration/eventsource_test.go88
-rw-r--r--tests/integration/explore_code_test.go31
-rw-r--r--tests/integration/explore_repos_test.go31
-rw-r--r--tests/integration/explore_user_test.go44
-rw-r--r--tests/integration/fixtures/TestAdminDeleteUser/issue.yml16
-rw-r--r--tests/integration/fixtures/TestAdminDeleteUser/issue_index.yml3
-rw-r--r--tests/integration/fixtures/TestAdminDeleteUser/repository.yml30
-rw-r--r--tests/integration/fixtures/TestAdminDeleteUser/user.yml73
-rw-r--r--tests/integration/fixtures/TestBlockActions/comment.yml9
-rw-r--r--tests/integration/fixtures/TestBlockActions/issue.yml17
-rw-r--r--tests/integration/fixtures/TestBlockedNotifications/issue.yml16
-rw-r--r--tests/integration/fixtures/TestCommitRefComment/comment.yml17
-rw-r--r--tests/integration/fixtures/TestGetContentHistory/issue_content_history.yml17
-rw-r--r--tests/integration/fixtures/TestXSSReviewDismissed/comment.yml9
-rw-r--r--tests/integration/fixtures/TestXSSReviewDismissed/review.yml8
-rw-r--r--tests/integration/forgejo_confirmation_repo_test.go184
-rw-r--r--tests/integration/forgejo_git_test.go137
-rw-r--r--tests/integration/git_clone_wiki_test.go52
-rw-r--r--tests/integration/git_helper_for_declarative_test.go211
-rw-r--r--tests/integration/git_push_test.go288
-rw-r--r--tests/integration/git_smart_http_test.go69
-rw-r--r--tests/integration/git_test.go1124
-rw-r--r--tests/integration/goget_test.go61
-rw-r--r--tests/integration/gpg_git_test.go304
-rw-r--r--tests/integration/html_helper.go92
-rw-r--r--tests/integration/incoming_email_test.go244
-rw-r--r--tests/integration/integration_test.go687
-rw-r--r--tests/integration/issue_subscribe_test.go44
-rw-r--r--tests/integration/issue_test.go1303
-rw-r--r--tests/integration/issues_comment_labels_test.go197
-rw-r--r--tests/integration/last_updated_time_test.go71
-rw-r--r--tests/integration/lfs_getobject_test.go228
-rw-r--r--tests/integration/lfs_local_endpoint_test.go113
-rw-r--r--tests/integration/lfs_view_test.go180
-rw-r--r--tests/integration/linguist_test.go269
-rw-r--r--tests/integration/links_test.go251
-rw-r--r--tests/integration/markup_external_test.go40
-rw-r--r--tests/integration/markup_test.go72
-rw-r--r--tests/integration/migrate_test.go130
-rw-r--r--tests/integration/migration-test/forgejo-v1.19.0.mysql.sql.gzbin0 -> 164 bytes
-rw-r--r--tests/integration/migration-test/forgejo-v1.19.0.postgres.sql.gzbin0 -> 645 bytes
-rw-r--r--tests/integration/migration-test/forgejo-v1.19.0.sqlite3.sql.gzbin0 -> 200 bytes
-rw-r--r--tests/integration/migration-test/gitea-v1.6.4.mysql.sql.gzbin0 -> 9423 bytes
-rw-r--r--tests/integration/migration-test/gitea-v1.6.4.postgres.sql.gzbin0 -> 17517 bytes
-rw-r--r--tests/integration/migration-test/gitea-v1.6.4.sqlite3.sql.gzbin0 -> 3995 bytes
-rw-r--r--tests/integration/migration-test/gitea-v1.7.0.mysql.sql.gzbin0 -> 9682 bytes
-rw-r--r--tests/integration/migration-test/gitea-v1.7.0.postgres.sql.gzbin0 -> 17831 bytes
-rw-r--r--tests/integration/migration-test/gitea-v1.7.0.sqlite3.sql.gzbin0 -> 8165 bytes
-rw-r--r--tests/integration/migration-test/migration_test.go323
-rw-r--r--tests/integration/milestone_test.go25
-rw-r--r--tests/integration/mirror_pull_test.go104
-rw-r--r--tests/integration/mirror_push_test.go325
-rw-r--r--tests/integration/new_org_test.go37
-rw-r--r--tests/integration/nonascii_branches_test.go214
-rw-r--r--tests/integration/oauth_test.go1323
-rw-r--r--tests/integration/opengraph_test.go150
-rw-r--r--tests/integration/org_count_test.go149
-rw-r--r--tests/integration/org_nav_test.go62
-rw-r--r--tests/integration/org_project_test.go63
-rw-r--r--tests/integration/org_team_invite_test.go379
-rw-r--r--tests/integration/org_test.go271
-rw-r--r--tests/integration/private-testing.key81
-rw-r--r--tests/integration/privateactivity_test.go418
-rw-r--r--tests/integration/proctected_branch_test.go87
-rw-r--r--tests/integration/project_test.go84
-rw-r--r--tests/integration/pull_commit_test.go34
-rw-r--r--tests/integration/pull_compare_test.go28
-rw-r--r--tests/integration/pull_create_test.go558
-rw-r--r--tests/integration/pull_diff_test.go58
-rw-r--r--tests/integration/pull_icon_test.go257
-rw-r--r--tests/integration/pull_merge_test.go1152
-rw-r--r--tests/integration/pull_reopen_test.go216
-rw-r--r--tests/integration/pull_request_task_test.go109
-rw-r--r--tests/integration/pull_review_test.go511
-rw-r--r--tests/integration/pull_status_test.go167
-rw-r--r--tests/integration/pull_summary_test.go65
-rw-r--r--tests/integration/pull_test.go67
-rw-r--r--tests/integration/pull_update_test.go175
-rw-r--r--tests/integration/pull_wip_convert_test.go59
-rw-r--r--tests/integration/quota_use_test.go1147
-rw-r--r--tests/integration/release_test.go353
-rw-r--r--tests/integration/remote_test.go206
-rw-r--r--tests/integration/rename_branch_test.go176
-rw-r--r--tests/integration/repo_activity_test.go216
-rw-r--r--tests/integration/repo_archive_test.go34
-rw-r--r--tests/integration/repo_archive_text_test.go78
-rw-r--r--tests/integration/repo_badges_test.go252
-rw-r--r--tests/integration/repo_branch_test.go206
-rw-r--r--tests/integration/repo_citation_test.go81
-rw-r--r--tests/integration/repo_collaborator_test.go37
-rw-r--r--tests/integration/repo_commits_search_test.go44
-rw-r--r--tests/integration/repo_commits_test.go206
-rw-r--r--tests/integration/repo_delete_test.go74
-rw-r--r--tests/integration/repo_flags_test.go391
-rw-r--r--tests/integration/repo_fork_test.go240
-rw-r--r--tests/integration/repo_generate_test.go137
-rw-r--r--tests/integration/repo_issue_title_test.go162
-rw-r--r--tests/integration/repo_mergecommit_revert_test.go38
-rw-r--r--tests/integration/repo_migrate_test.go57
-rw-r--r--tests/integration/repo_migration_ui_test.go116
-rw-r--r--tests/integration/repo_pagination_test.go84
-rw-r--r--tests/integration/repo_search_test.go135
-rw-r--r--tests/integration/repo_settings_hook_test.go63
-rw-r--r--tests/integration/repo_settings_test.go370
-rw-r--r--tests/integration/repo_signed_tag_test.go107
-rw-r--r--tests/integration/repo_starwatch_test.go108
-rw-r--r--tests/integration/repo_tag_test.go165
-rw-r--r--tests/integration/repo_test.go1379
-rw-r--r--tests/integration/repo_topic_test.go81
-rw-r--r--tests/integration/repo_view_test.go230
-rw-r--r--tests/integration/repo_watch_test.go24
-rw-r--r--tests/integration/repo_webhook_test.go473
-rw-r--r--tests/integration/repo_wiki_test.go91
-rw-r--r--tests/integration/repofiles_change_test.go495
-rw-r--r--tests/integration/schemas/nodeinfo_2.1.json188
-rw-r--r--tests/integration/session_test.go38
-rw-r--r--tests/integration/setting_test.go158
-rw-r--r--tests/integration/signin_test.go95
-rw-r--r--tests/integration/signout_test.go24
-rw-r--r--tests/integration/signup_test.go209
-rw-r--r--tests/integration/size_translations_test.go116
-rw-r--r--tests/integration/ssh_key_test.go208
-rw-r--r--tests/integration/timetracking_test.go81
-rw-r--r--tests/integration/user_avatar_test.go94
-rw-r--r--tests/integration/user_count_test.go175
-rw-r--r--tests/integration/user_dashboard_test.go30
-rw-r--r--tests/integration/user_profile_activity_test.go112
-rw-r--r--tests/integration/user_profile_follows_test.go132
-rw-r--r--tests/integration/user_profile_test.go67
-rw-r--r--tests/integration/user_test.go821
-rw-r--r--tests/integration/version_test.go62
-rw-r--r--tests/integration/view_test.go212
-rw-r--r--tests/integration/webfinger_test.go84
-rw-r--r--tests/integration/webhook_test.go189
-rw-r--r--tests/integration/xss_test.go129
-rw-r--r--tests/mysql.ini.tmpl115
-rw-r--r--tests/pgsql.ini.tmpl129
-rw-r--r--tests/sqlite.ini.tmpl115
-rw-r--r--tests/test_utils.go452
-rw-r--r--tests/testdata/data/attachments/a/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a221
1027 files changed, 63726 insertions, 0 deletions
diff --git a/tests/e2e/.eslintrc.yaml b/tests/e2e/.eslintrc.yaml
new file mode 100644
index 0000000..1486431
--- /dev/null
+++ b/tests/e2e/.eslintrc.yaml
@@ -0,0 +1,24 @@
+plugins:
+ - eslint-plugin-playwright
+
+extends:
+ - ../../.eslintrc.yaml
+ - plugin:playwright/recommended
+
+parserOptions:
+ sourceType: module
+ ecmaVersion: latest
+
+env:
+ browser: true
+
+rules:
+ playwright/no-conditional-in-test: [0]
+ playwright/no-conditional-expect: [0]
+ playwright/no-networkidle: [0]
+ playwright/no-skipped-test: [2, {allowConditional: true}]
+ playwright/prefer-comparison-matcher: [2]
+ playwright/prefer-equality-matcher: [2]
+ playwright/prefer-to-contain: [2]
+ playwright/prefer-to-have-length: [2]
+ playwright/require-to-throw-message: [2]
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
new file mode 100644
index 0000000..6552011
--- /dev/null
+++ b/tests/e2e/README.md
@@ -0,0 +1,220 @@
+# End to end tests
+
+Thank you for your effort to provide good software tests for Forgejo.
+Please also read the general testing instructions in the
+[Forgejo contributor documentation](https://forgejo.org/docs/next/contributor/testing/)
+and make sure to also check the
+[Playwright documentation](https://playwright.dev/docs/intro)
+for further information.
+
+This file is meant to provide specific information for the integration tests
+as well as some tips and tricks you should know.
+
+Feel free to extend this file with more instructions if you feel like you have something to share!
+
+
+## How to run the tests?
+
+Before running any tests, please ensure you perform a clean frontend build:
+
+```
+make clean frontend
+```
+
+Whenever you modify frontend code (i.e. JavaScript and CSS files),
+you need to create a new frontend build.
+
+For tests that require interactive Git repos,
+you also need to ensure a Forgejo binary is ready to be used by Git hooks.
+For this, you additionally need to run
+
+~~~
+make TAGS="sqlite sqlite_unlock_notify" backend
+~~~
+
+### Install dependencies
+
+Browsertesting is performed by playwright.
+You need certain system libraries and playwright will download required browsers.
+Playwright takes care of this when you run:
+
+```
+npx playwright install-deps
+```
+
+> **Note**
+> On some operating systems, the installation of missing libraries can complicate testing certain browsers.
+> It is often not necessary to test with all browsers locally.
+> Choosing either Firefox or Chromium is fine.
+
+
+### Run all tests
+
+If you want to run the full test suite, you can use
+
+```
+make test-e2e-sqlite
+```
+
+### Interactive testing
+
+We recommend that you use interactive testing for the development.
+After you performed the required builds,
+you should use one shell to start the debugserver (and leave it running):
+
+```
+make test-e2e-debugserver
+```
+
+It allows you to explore the test data in your local browser,
+and playwright to perform tests on it.
+
+> **Note**
+> The modifications persist while the debugserver is running.
+> If you modified things, it might be useful to restart it to get back to a fresh state.
+> While writing playwright tests, you either
+> need to ensure they are resilient against repeated runs
+> (e.g. when only creating new content),
+> or that they restore the initial state for the next browser run.
+
+#### With the playwright UI:
+
+Playwright ships with an integrated UI mode which allows you to
+run individual tests and to debug them by seeing detailed traces of what playwright does.
+Launch it with:
+
+```
+npx playwright test --ui
+```
+
+#### Running individual tests
+
+```
+npx playwright test actions.test.e2e.js:9
+```
+
+First, specify the complete test filename,
+and after the colon you can put the linenumber where the test is defined.
+
+
+#### With VSCodium or VSCode
+
+To debug a test, you can also use "Playwright Test" for
+[VScodium](https://open-vsx.org/extension/ms-playwright/playwright)
+or [VSCode](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright).
+
+
+### Run all tests via local act_runner
+
+If you have a [forgejo runner](https://code.forgejo.org/forgejo/runner/),
+you can use it to run the test jobs:
+
+```
+forgejo-runner exec -W .forgejo/workflows/e2e.yml --event=pull_request
+```
+
+### Run e2e tests with another database
+
+This approach is not currently used,
+neither in the CI/CD nor by core contributors on their lcoal machines.
+It is still documented for the sake of completeness:
+You can also perform e2e tests using MariaDB/MySQL or PostgreSQL if you want.
+
+Setup a MySQL database inside docker
+```
+docker run -e "MYSQL_DATABASE=test" -e "MYSQL_ALLOW_EMPTY_PASSWORD=yes" -p 3306:3306 --rm --name mysql mysql:latest #(just ctrl-c to stop db and clean the container)
+docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" --rm --name elasticsearch elasticsearch:7.6.0 #(in a second terminal, just ctrl-c to stop db and clean the container)
+```
+Start tests based on the database container
+```
+TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=root TEST_MYSQL_PASSWORD='' make test-e2e-mysql
+```
+
+Setup a pgsql database inside docker
+```
+docker run -e "POSTGRES_DB=test" -p 5432:5432 --rm --name pgsql postgres:latest #(just ctrl-c to stop db and clean the container)
+```
+Start tests based on the database container
+```
+TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=test TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-e2e-pgsql
+```
+
+### Running individual tests
+
+Example command to run `example.test.e2e.js` test file:
+
+> **Note**
+> Unlike integration tests, this filtering is at the file level, not function
+
+For SQLite:
+
+```
+make test-e2e-sqlite#example
+```
+
+### Visual testing
+
+> **Warning**
+> This is not currently used by most Forgejo contributors.
+> Your help to improve the situation and allow for visual testing is appreciated.
+
+Although the main goal of e2e is assertion testing, we have added a framework for visual regress testing. If you are working on front-end features, please use the following:
+ - Check out `main`, `make clean frontend`, and run e2e tests with `VISUAL_TEST=1` to generate outputs. This will initially fail, as no screenshots exist. You can run the e2e tests again to assert it passes.
+ - Check out your branch, `make clean frontend`, and run e2e tests with `VISUAL_TEST=1`. You should be able to assert you front-end changes don't break any other tests unintentionally.
+
+VISUAL_TEST=1 will create screenshots in tests/e2e/test-snapshots. The test will fail the first time this is enabled (until we get visual test image persistence figured out), because it will be testing against an empty screenshot folder.
+
+ACCEPT_VISUAL=1 will overwrite the snapshot images with new images.
+
+
+## Tips and tricks
+
+If you know noteworthy tests that can act as an inspiration for new tests,
+please add some details here.
+
+### Run tests very selectively
+
+Browser testing can take some time.
+If you want to iterate fast,
+save your time and only run very selected tests.
+Use only one browser.
+
+### Skip Safari if it doesn't work
+
+Many contributors have issues getting Safari (webkit)
+and especially Safari Mobile to work.
+
+At the top of your test function, you can use:
+
+~~~javascript
+test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile.');
+~~~
+
+### Don't forget the formatting.
+
+When writing tests without modifying other frontend code,
+it is easy to forget that the JavaScript test files also need formatting.
+
+Run `make lint-frontend-fix`.
+
+### Define new repos
+
+Take a look at `declare_repos_test.go` to see how to add your repositories.
+Feel free to improve the logic used there if you need more advanced functionality
+(it is a simplified version of the code used in the integration tests).
+
+### Accessibility testing
+
+If you can, perform automated accessibility testing using
+[AxeCore](https://github.com/dequelabs/axe-core-npm/blob/develop/packages/playwright/README.md).
+
+Take a look at `shared/forms.js` and some other places for inspiration.
+
+### List related files coverage
+
+If you think your playwright tests covers an important aspect of some template, CSS or backend files,
+consider adding the paths to `.forgejo/workflows/e2e.yml` in the path filter.
+
+It ensures that future modifications to this file will be tested as well.
+
+Currently, we do not run the e2e tests on all changes.
diff --git a/tests/e2e/actions.test.e2e.js b/tests/e2e/actions.test.e2e.js
new file mode 100644
index 0000000..b049a93
--- /dev/null
+++ b/tests/e2e/actions.test.e2e.js
@@ -0,0 +1,79 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+const workflow_trigger_notification_text = 'This workflow has a workflow_dispatch event trigger.';
+
+test('workflow dispatch present', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ /** @type {import('@playwright/test').Page} */
+ const page = await context.newPage();
+
+ await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
+
+ await expect(page.getByText(workflow_trigger_notification_text)).toBeVisible();
+
+ const run_workflow_btn = page.locator('#workflow_dispatch_dropdown>button');
+ await expect(run_workflow_btn).toBeVisible();
+
+ const menu = page.locator('#workflow_dispatch_dropdown>.menu');
+ await expect(menu).toBeHidden();
+ await run_workflow_btn.click();
+ await expect(menu).toBeVisible();
+});
+
+test('workflow dispatch error: missing inputs', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383');
+
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ /** @type {import('@playwright/test').Page} */
+ const page = await context.newPage();
+
+ await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
+ await page.waitForLoadState('networkidle');
+
+ await page.locator('#workflow_dispatch_dropdown>button').click();
+
+ // Remove the required attribute so we can trigger the error message!
+ await page.evaluate(() => {
+ const elem = document.querySelector('input[name="inputs[string2]"]');
+ elem?.removeAttribute('required');
+ });
+
+ await page.locator('#workflow-dispatch-submit').click();
+ await page.waitForLoadState('networkidle');
+
+ await expect(page.getByText('Require value for input "String w/o. default".')).toBeVisible();
+});
+
+test('workflow dispatch success', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky behaviour on mobile safari; see https://codeberg.org/forgejo/forgejo/pulls/3334#issuecomment-2033383');
+
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ /** @type {import('@playwright/test').Page} */
+ const page = await context.newPage();
+
+ await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
+ await page.waitForLoadState('networkidle');
+
+ await page.locator('#workflow_dispatch_dropdown>button').click();
+
+ await page.type('input[name="inputs[string2]"]', 'abc');
+ await page.locator('#workflow-dispatch-submit').click();
+ await page.waitForLoadState('networkidle');
+
+ await expect(page.getByText('Workflow run was successfully requested.')).toBeVisible();
+
+ await expect(page.locator('.run-list>:first-child .run-list-meta', {hasText: 'now'})).toBeVisible();
+});
+
+test('workflow dispatch box not available for unauthenticated users', async ({page}) => {
+ await page.goto('/user2/test_workflows/actions?workflow=test-dispatch.yml&actor=0&status=0');
+ await page.waitForLoadState('networkidle');
+
+ await expect(page.locator('body')).not.toContainText(workflow_trigger_notification_text);
+});
diff --git a/tests/e2e/commit-graph-branch-selector.test.e2e.js b/tests/e2e/commit-graph-branch-selector.test.e2e.js
new file mode 100644
index 0000000..db84932
--- /dev/null
+++ b/tests/e2e/commit-graph-branch-selector.test.e2e.js
@@ -0,0 +1,25 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('Switch branch', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+ const response = await page.goto('/user2/repo1/graph');
+ await expect(response?.status()).toBe(200);
+
+ await page.click('#flow-select-refs-dropdown');
+ const input = page.locator('#flow-select-refs-dropdown');
+ await input.pressSequentially('develop', {delay: 50});
+ await input.press('Enter');
+
+ await page.waitForLoadState('networkidle');
+
+ await expect(page.locator('#loading-indicator')).toBeHidden();
+ await expect(page.locator('#rel-container')).toBeVisible();
+ await expect(page.locator('#rev-container')).toBeVisible();
+});
diff --git a/tests/e2e/dashboard-ci-status.test.e2e.js b/tests/e2e/dashboard-ci-status.test.e2e.js
new file mode 100644
index 0000000..1ff68b6
--- /dev/null
+++ b/tests/e2e/dashboard-ci-status.test.e2e.js
@@ -0,0 +1,21 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('Correct link and tooltip', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+ const response = await page.goto('/?repo-search-query=test_workflows');
+ await expect(response?.status()).toBe(200);
+
+ await page.waitForLoadState('networkidle');
+
+ const repoStatus = page.locator('.dashboard-repos .repo-owner-name-list > li:nth-child(1) > a:nth-child(2)');
+
+ await expect(repoStatus).toHaveAttribute('href', '/user2/test_workflows/actions', {timeout: 10000});
+ await expect(repoStatus).toHaveAttribute('data-tooltip-content', 'Failure');
+});
diff --git a/tests/e2e/debugserver_test.go b/tests/e2e/debugserver_test.go
new file mode 100644
index 0000000..49461fa
--- /dev/null
+++ b/tests/e2e/debugserver_test.go
@@ -0,0 +1,32 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// This "test" is meant to be run with `make test-e2e-debugserver` and will just
+// keep open a gitea instance in a test environment (with the data from
+// `models/fixtures`) on port 3000. This is useful for debugging e2e tests, for
+// example with the playwright vscode extension.
+
+//nolint:forbidigo
+package e2e
+
+import (
+ "fmt"
+ "net/url"
+ "os"
+ "os/signal"
+ "syscall"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func TestDebugserver(t *testing.T) {
+ done := make(chan os.Signal, 1)
+ signal.Notify(done, syscall.SIGINT, syscall.SIGTERM)
+
+ onForgejoRun(t, func(*testing.T, *url.URL) {
+ defer DeclareGitRepos(t)()
+ fmt.Println(setting.AppURL)
+ <-done
+ })
+}
diff --git a/tests/e2e/declare_repos_test.go b/tests/e2e/declare_repos_test.go
new file mode 100644
index 0000000..7057b26
--- /dev/null
+++ b/tests/e2e/declare_repos_test.go
@@ -0,0 +1,87 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package e2e
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// first entry represents filename
+// the following entries define the full file content over time
+type FileChanges [][]string
+
+// put your Git repo declarations in here
+// feel free to amend the helper function below or use the raw variant directly
+func DeclareGitRepos(t *testing.T) func() {
+ cleanupFunctions := []func(){
+ newRepo(t, 2, "diff-test", FileChanges{
+ {"testfile", "hello", "hallo", "hola", "native", "ubuntu-latest", "- runs-on: ubuntu-latest", "- runs-on: debian-latest"},
+ }),
+ // add your repo declarations here
+ }
+
+ return func() {
+ for _, cleanup := range cleanupFunctions {
+ cleanup()
+ }
+ }
+}
+
+func newRepo(t *testing.T, userID int64, repoName string, fileChanges FileChanges) func() {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
+ somerepo, _, cleanupFunc := tests.CreateDeclarativeRepo(t, user, repoName,
+ []unit_model.Type{unit_model.TypeCode, unit_model.TypeIssues}, nil,
+ nil,
+ )
+
+ for _, file := range fileChanges {
+ changeLen := len(file)
+ for i := 1; i < changeLen; i++ {
+ operation := "create"
+ if i != 1 {
+ operation = "update"
+ }
+ resp, err := files_service.ChangeRepoFiles(git.DefaultContext, somerepo, user, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{{
+ Operation: operation,
+ TreePath: file[0],
+ ContentReader: strings.NewReader(file[i]),
+ }},
+ Message: fmt.Sprintf("Patch: %s-%s", file[0], strconv.Itoa(i)),
+ OldBranch: "main",
+ NewBranch: "main",
+ Author: &files_service.IdentityOptions{
+ Name: user.Name,
+ Email: user.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: user.Name,
+ Email: user.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, resp)
+ }
+ }
+
+ return cleanupFunc
+}
diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go
new file mode 100644
index 0000000..44a6897
--- /dev/null
+++ b/tests/e2e/e2e_test.go
@@ -0,0 +1,129 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+// This is primarily coped from /tests/integration/integration_test.go
+// TODO: Move common functions to shared file
+
+//nolint:forbidigo
+package e2e
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/testlogger"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/tests"
+)
+
+var testE2eWebRoutes *web.Route
+
+func TestMain(m *testing.M) {
+ defer log.GetManager().Close()
+
+ managerCtx, cancel := context.WithCancel(context.Background())
+ graceful.InitManager(managerCtx)
+ defer cancel()
+
+ tests.InitTest(true)
+ testE2eWebRoutes = routers.NormalRoutes()
+
+ os.Unsetenv("GIT_AUTHOR_NAME")
+ os.Unsetenv("GIT_AUTHOR_EMAIL")
+ os.Unsetenv("GIT_AUTHOR_DATE")
+ os.Unsetenv("GIT_COMMITTER_NAME")
+ os.Unsetenv("GIT_COMMITTER_EMAIL")
+ os.Unsetenv("GIT_COMMITTER_DATE")
+
+ err := unittest.InitFixtures(
+ unittest.FixturesOptions{
+ Dir: filepath.Join(filepath.Dir(setting.AppPath), "models/fixtures/"),
+ },
+ )
+ if err != nil {
+ fmt.Printf("Error initializing test database: %v\n", err)
+ os.Exit(1)
+ }
+
+ exitVal := m.Run()
+
+ if err := testlogger.WriterCloser.Reset(); err != nil {
+ fmt.Printf("testlogger.WriterCloser.Reset: error ignored: %v\n", err)
+ }
+ if err = util.RemoveAll(setting.Indexer.IssuePath); err != nil {
+ fmt.Printf("util.RemoveAll: %v\n", err)
+ os.Exit(1)
+ }
+ if err = util.RemoveAll(setting.Indexer.RepoPath); err != nil {
+ fmt.Printf("Unable to remove repo indexer: %v\n", err)
+ os.Exit(1)
+ }
+
+ os.Exit(exitVal)
+}
+
+// TestE2e should be the only test e2e necessary. It will collect all "*.test.e2e.js" files in this directory and build a test for each.
+func TestE2e(t *testing.T) {
+ // Find the paths of all e2e test files in test directory.
+ searchGlob := filepath.Join(filepath.Dir(setting.AppPath), "tests", "e2e", "*.test.e2e.js")
+ paths, err := filepath.Glob(searchGlob)
+ if err != nil {
+ t.Fatal(err)
+ } else if len(paths) == 0 {
+ t.Fatal(fmt.Errorf("No e2e tests found in %s", searchGlob))
+ }
+
+ runArgs := []string{"npx", "playwright", "test"}
+
+ // To update snapshot outputs
+ if _, set := os.LookupEnv("ACCEPT_VISUAL"); set {
+ runArgs = append(runArgs, "--update-snapshots")
+ }
+ if project := os.Getenv("PLAYWRIGHT_PROJECT"); project != "" {
+ runArgs = append(runArgs, "--project="+project)
+ }
+
+ // Create new test for each input file
+ for _, path := range paths {
+ _, filename := filepath.Split(path)
+ testname := filename[:len(filename)-len(filepath.Ext(path))]
+
+ t.Run(testname, func(t *testing.T) {
+ // Default 2 minute timeout
+ onForgejoRun(t, func(*testing.T, *url.URL) {
+ defer DeclareGitRepos(t)()
+ thisTest := runArgs
+ thisTest = append(thisTest, path)
+ cmd := exec.Command(runArgs[0], thisTest...)
+ cmd.Env = os.Environ()
+ cmd.Env = append(cmd.Env, fmt.Sprintf("GITEA_URL=%s", setting.AppURL))
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ err := cmd.Run()
+ if err != nil {
+ // Currently colored output is conflicting. Using Printf until that is resolved.
+ fmt.Printf("%v", stdout.String())
+ fmt.Printf("%v", stderr.String())
+ log.Fatal("Playwright Failed: %s", err)
+ }
+
+ fmt.Printf("%v", stdout.String())
+ })
+ })
+ }
+}
diff --git a/tests/e2e/example.test.e2e.js b/tests/e2e/example.test.e2e.js
new file mode 100644
index 0000000..86abdf6
--- /dev/null
+++ b/tests/e2e/example.test.e2e.js
@@ -0,0 +1,50 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, save_visual} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('Load Homepage', async ({page}) => {
+ const response = await page.goto('/');
+ await expect(response?.status()).toBe(200); // Status OK
+ await expect(page).toHaveTitle(/^Forgejo: Beyond coding. We Forge.\s*$/);
+ await expect(page.locator('.logo')).toHaveAttribute('src', '/assets/img/logo.svg');
+});
+
+test('Register Form', async ({page}, workerInfo) => {
+ const response = await page.goto('/user/sign_up');
+ await expect(response?.status()).toBe(200); // Status OK
+ await page.type('input[name=user_name]', `e2e-test-${workerInfo.workerIndex}`);
+ await page.type('input[name=email]', `e2e-test-${workerInfo.workerIndex}@test.com`);
+ await page.type('input[name=password]', 'test123test123');
+ await page.type('input[name=retype]', 'test123test123');
+ await page.click('form button.ui.primary.button:visible');
+ // Make sure we routed to the home page. Else login failed.
+ await expect(page.url()).toBe(`${workerInfo.project.use.baseURL}/`);
+ await expect(page.locator('.secondary-nav span>img.ui.avatar')).toBeVisible();
+ await expect(page.locator('.ui.positive.message.flash-success')).toHaveText('Account was successfully created. Welcome!');
+
+ save_visual(page);
+});
+
+// eslint-disable-next-line playwright/no-skipped-test
+test.describe.skip('example with different viewports (not actually run)', () => {
+ // only necessary when the default web / mobile devices are not enough.
+ // If you need to use a single fixed viewport, you can also use:
+ // test.use({viewport: {width: 400, height: 800}});
+ // also see https://playwright.dev/docs/test-parameterize
+ for (const width of [400, 1000]) {
+ // do not actually run (skip) this test
+ test(`Do x on width: ${width}px`, async ({page}) => {
+ await page.setViewportSize({
+ width,
+ height: 800,
+ });
+ // do something, then check that an element is fully in viewport
+ // (i.e. not overflowing)
+ await expect(page.locator('#my-element')).toBeInViewport({ratio: 1});
+ });
+ }
+});
diff --git a/tests/e2e/explore.test.e2e.js b/tests/e2e/explore.test.e2e.js
new file mode 100644
index 0000000..9603443
--- /dev/null
+++ b/tests/e2e/explore.test.e2e.js
@@ -0,0 +1,40 @@
+// @ts-check
+// document is a global in evaluate, so it's safe to ignore here
+// eslint playwright/no-conditional-in-test: 0
+import {expect} from '@playwright/test';
+import {test} from './utils_e2e.js';
+
+test('Explore view taborder', async ({page}) => {
+ await page.goto('/explore/repos');
+
+ const l1 = page.locator('[href="https://forgejo.org"]');
+ const l2 = page.locator('[href="/assets/licenses.txt"]');
+ const l3 = page.locator('[href*="/stars"]').first();
+ const l4 = page.locator('[href*="/forks"]').first();
+ let res = 0;
+ const exp = 15; // 0b1111 = four passing tests
+
+ for (let i = 0; i < 150; i++) {
+ await page.keyboard.press('Tab');
+ if (await l1.evaluate((node) => document.activeElement === node)) {
+ res |= 1;
+ continue;
+ }
+ if (await l2.evaluate((node) => document.activeElement === node)) {
+ res |= 1 << 1;
+ continue;
+ }
+ if (await l3.evaluate((node) => document.activeElement === node)) {
+ res |= 1 << 2;
+ continue;
+ }
+ if (await l4.evaluate((node) => document.activeElement === node)) {
+ res |= 1 << 3;
+ continue;
+ }
+ if (res === exp) {
+ break;
+ }
+ }
+ await expect(res).toBe(exp);
+});
diff --git a/tests/e2e/issue-comment.test.e2e.js b/tests/e2e/issue-comment.test.e2e.js
new file mode 100644
index 0000000..ee2e3a4
--- /dev/null
+++ b/tests/e2e/issue-comment.test.e2e.js
@@ -0,0 +1,63 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, login} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('Hyperlink paste behaviour', async ({browser}, workerInfo) => {
+ test.skip(['Mobile Safari', 'Mobile Chrome', 'webkit'].includes(workerInfo.project.name), 'Mobile clients seem to have very weird behaviour with this test, which I cannot confirm with real usage');
+ const page = await login({browser}, workerInfo);
+ await page.goto('/user2/repo1/issues/new');
+ await page.locator('textarea').click();
+ // same URL
+ await page.locator('textarea').fill('https://codeberg.org/forgejo/forgejo#some-anchor');
+ await page.locator('textarea').press('Shift+Home');
+ await page.locator('textarea').press('ControlOrMeta+c');
+ await page.locator('textarea').press('ControlOrMeta+v');
+ await expect(page.locator('textarea')).toHaveValue('https://codeberg.org/forgejo/forgejo#some-anchor');
+ // other text
+ await page.locator('textarea').fill('Some other text');
+ await page.locator('textarea').press('ControlOrMeta+a');
+ await page.locator('textarea').press('ControlOrMeta+v');
+ await expect(page.locator('textarea')).toHaveValue('[Some other text](https://codeberg.org/forgejo/forgejo#some-anchor)');
+ // subset of URL
+ await page.locator('textarea').fill('https://codeberg.org/forgejo/forgejo#some');
+ await page.locator('textarea').press('ControlOrMeta+a');
+ await page.locator('textarea').press('ControlOrMeta+v');
+ await expect(page.locator('textarea')).toHaveValue('https://codeberg.org/forgejo/forgejo#some-anchor');
+ // superset of URL
+ await page.locator('textarea').fill('https://codeberg.org/forgejo/forgejo#some-anchor-on-the-page');
+ await page.locator('textarea').press('ControlOrMeta+a');
+ await page.locator('textarea').press('ControlOrMeta+v');
+ await expect(page.locator('textarea')).toHaveValue('https://codeberg.org/forgejo/forgejo#some-anchor');
+ // completely separate URL
+ await page.locator('textarea').fill('http://example.com');
+ await page.locator('textarea').press('ControlOrMeta+a');
+ await page.locator('textarea').press('ControlOrMeta+v');
+ await expect(page.locator('textarea')).toHaveValue('https://codeberg.org/forgejo/forgejo#some-anchor');
+});
+
+test('Always focus edit tab first on edit', async ({browser}, workerInfo) => {
+ const page = await login({browser}, workerInfo);
+ const response = await page.goto('/user2/repo1/issues/1');
+ await expect(response?.status()).toBe(200);
+
+ // Switch to preview tab and save
+ await page.click('#issue-1 .comment-container .context-menu');
+ await page.click('#issue-1 .comment-container .menu>.edit-content');
+ await page.locator('#issue-1 .comment-container a[data-tab-for=markdown-previewer]').click();
+ await page.click('#issue-1 .comment-container .save');
+
+ await page.waitForLoadState('networkidle');
+
+ // Edit again and assert that edit tab should be active (and not preview tab)
+ await page.click('#issue-1 .comment-container .context-menu');
+ await page.click('#issue-1 .comment-container .menu>.edit-content');
+ const editTab = page.locator('#issue-1 .comment-container a[data-tab-for=markdown-writer]');
+ const previewTab = page.locator('#issue-1 .comment-container a[data-tab-for=markdown-previewer]');
+
+ await expect(editTab).toHaveClass(/active/);
+ await expect(previewTab).not.toHaveClass(/active/);
+});
diff --git a/tests/e2e/issue-sidebar.test.e2e.js b/tests/e2e/issue-sidebar.test.e2e.js
new file mode 100644
index 0000000..61d3281
--- /dev/null
+++ b/tests/e2e/issue-sidebar.test.e2e.js
@@ -0,0 +1,226 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, login} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+// belongs to test: Pull: Toggle WIP
+const prTitle = 'pull5';
+
+async function click_toggle_wip({page}) {
+ await page.locator('.toggle-wip>a').click();
+ await page.waitForLoadState('networkidle');
+}
+
+async function check_wip({page}, is) {
+ const elemTitle = '#issue-title-display';
+ const stateLabel = '.issue-state-label';
+ await expect(page.locator(elemTitle)).toContainText(prTitle);
+ await expect(page.locator(elemTitle)).toContainText('#5');
+ if (is) {
+ await expect(page.locator(elemTitle)).toContainText('WIP');
+ await expect(page.locator(stateLabel)).toContainText('Draft');
+ } else {
+ await expect(page.locator(elemTitle)).not.toContainText('WIP');
+ await expect(page.locator(stateLabel)).toContainText('Open');
+ }
+}
+
+test('Pull: Toggle WIP', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636');
+ const page = await login({browser}, workerInfo);
+ const response = await page.goto('/user2/repo1/pulls/5');
+ await expect(response?.status()).toBe(200); // Status OK
+ // initial state
+ await check_wip({page}, false);
+ // toggle to WIP
+ await click_toggle_wip({page});
+ await check_wip({page}, true);
+ // remove WIP
+ await click_toggle_wip({page});
+ await check_wip({page}, false);
+
+ // manually edit title to another prefix
+ await page.locator('#issue-title-edit-show').click();
+ await page.locator('#issue-title-editor input').fill(`[WIP] ${prTitle}`);
+ await page.getByText('Save').click();
+ await page.waitForLoadState('networkidle');
+ await check_wip({page}, true);
+ // remove again
+ await click_toggle_wip({page});
+ await check_wip({page}, false);
+ // check maximum title length is handled gracefully
+ const maxLenStr = prTitle + 'a'.repeat(240);
+ await page.locator('#issue-title-edit-show').click();
+ await page.locator('#issue-title-editor input').fill(maxLenStr);
+ await page.getByText('Save').click();
+ await page.waitForLoadState('networkidle');
+ await click_toggle_wip({page});
+ await check_wip({page}, true);
+ await click_toggle_wip({page});
+ await check_wip({page}, false);
+ await expect(page.locator('h1')).toContainText(maxLenStr);
+ // restore original title
+ await page.locator('#issue-title-edit-show').click();
+ await page.locator('#issue-title-editor input').fill(prTitle);
+ await page.getByText('Save').click();
+ await check_wip({page}, false);
+});
+
+test('Issue: Labels', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636');
+ const page = await login({browser}, workerInfo);
+ // select label list in sidebar only
+ const labelList = page.locator('.issue-content-right .labels-list a');
+ const response = await page.goto('/user2/repo1/issues/1');
+ await expect(response?.status()).toBe(200);
+ // preconditions
+ await expect(labelList.filter({hasText: 'label1'})).toBeVisible();
+ await expect(labelList.filter({hasText: 'label2'})).toBeHidden();
+ // add label2
+ await page.locator('.select-label').click();
+ // label search could be tested this way:
+ // await page.locator('.select-label input').fill('label2');
+ await page.locator('.select-label .item').filter({hasText: 'label2'}).click();
+ await page.locator('.select-label').click();
+ await page.waitForLoadState('networkidle');
+ await expect(labelList.filter({hasText: 'label2'})).toBeVisible();
+ // test removing label again
+ await page.locator('.select-label').click();
+ await page.locator('.select-label .item').filter({hasText: 'label2'}).click();
+ await page.locator('.select-label').click();
+ await page.waitForLoadState('networkidle');
+ await expect(labelList.filter({hasText: 'label2'})).toBeHidden();
+ await expect(labelList.filter({hasText: 'label1'})).toBeVisible();
+});
+
+test('Issue: Assignees', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636');
+ const page = await login({browser}, workerInfo);
+ // select label list in sidebar only
+ const assigneesList = page.locator('.issue-content-right .assignees.list .selected .item a');
+
+ const response = await page.goto('/org3/repo3/issues/1');
+ await expect(response?.status()).toBe(200);
+ // preconditions
+ await expect(assigneesList.filter({hasText: 'user2'})).toBeVisible();
+ await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
+ await expect(page.locator('.ui.assignees.list .item.no-select')).toBeHidden();
+
+ // Clear all assignees
+ await page.locator('.select-assignees-modify.dropdown').click();
+ await page.locator('.select-assignees-modify.dropdown .no-select.item').click();
+ await expect(assigneesList.filter({hasText: 'user2'})).toBeHidden();
+ await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
+ await expect(page.locator('.ui.assignees.list .item.no-select')).toBeVisible();
+ await expect(page.locator('.select-assign-me')).toBeVisible();
+
+ // Assign other user (with searchbox)
+ await page.locator('.select-assignees-modify.dropdown').click();
+ await page.type('.select-assignees-modify .menu .search input', 'user4');
+ await expect(page.locator('.select-assignees-modify .menu .item').filter({hasText: 'user2'})).toBeHidden();
+ await expect(page.locator('.select-assignees-modify .menu .item').filter({hasText: 'user4'})).toBeVisible();
+ await page.locator('.select-assignees-modify .menu .item').filter({hasText: 'user4'}).click();
+ await page.locator('.select-assignees-modify.dropdown').click();
+ await expect(assigneesList.filter({hasText: 'user4'})).toBeVisible();
+
+ // remove user4
+ await page.locator('.select-assignees-modify.dropdown').click();
+ await page.locator('.select-assignees-modify .menu .item').filter({hasText: 'user4'}).click();
+ await page.locator('.select-assignees-modify.dropdown').click();
+ await expect(page.locator('.ui.assignees.list .item.no-select')).toBeVisible();
+ await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
+
+ // Test assign me
+ await page.locator('.ui.assignees .select-assign-me').click();
+ await expect(assigneesList.filter({hasText: 'user2'})).toBeVisible();
+ await expect(page.locator('.ui.assignees.list .item.no-select')).toBeHidden();
+});
+
+test('New Issue: Assignees', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636');
+ const page = await login({browser}, workerInfo);
+ // select label list in sidebar only
+ const assigneesList = page.locator('.issue-content-right .assignees.list .selected .item');
+
+ const response = await page.goto('/org3/repo3/issues/new');
+ await expect(response?.status()).toBe(200);
+ // preconditions
+ await expect(page.locator('.ui.assignees.list .item.no-select')).toBeVisible();
+ await expect(assigneesList.filter({hasText: 'user2'})).toBeHidden();
+ await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
+
+ // Assign other user (with searchbox)
+ await page.locator('.select-assignees.dropdown').click();
+ await page.type('.select-assignees .menu .search input', 'user4');
+ await expect(page.locator('.select-assignees .menu .item').filter({hasText: 'user2'})).toBeHidden();
+ await expect(page.locator('.select-assignees .menu .item').filter({hasText: 'user4'})).toBeVisible();
+ await page.locator('.select-assignees .menu .item').filter({hasText: 'user4'}).click();
+ await page.locator('.select-assignees.dropdown').click();
+ await expect(assigneesList.filter({hasText: 'user4'})).toBeVisible();
+
+ // remove user4
+ await page.locator('.select-assignees.dropdown').click();
+ await page.locator('.select-assignees .menu .item').filter({hasText: 'user4'}).click();
+ await page.locator('.select-assignees.dropdown').click();
+ await expect(page.locator('.ui.assignees.list .item.no-select')).toBeVisible();
+ await expect(assigneesList.filter({hasText: 'user4'})).toBeHidden();
+
+ // Test assign me
+ await page.locator('.ui.assignees .select-assign-me').click();
+ await expect(assigneesList.filter({hasText: 'user2'})).toBeVisible();
+ await expect(page.locator('.ui.assignees.list .item.no-select')).toBeHidden();
+
+ await page.locator('.select-assignees.dropdown').click();
+ await page.fill('.select-assignees .menu .search input', '');
+ await page.locator('.select-assignees.dropdown .no-select.item').click();
+ await expect(page.locator('.select-assign-me')).toBeVisible();
+});
+
+test('Issue: Milestone', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636');
+ const page = await login({browser}, workerInfo);
+
+ const response = await page.goto('/user2/repo1/issues/1');
+ await expect(response?.status()).toBe(200);
+
+ const selectedMilestone = page.locator('.issue-content-right .select-milestone.list');
+ const milestoneDropdown = page.locator('.issue-content-right .select-milestone.dropdown');
+ await expect(selectedMilestone).toContainText('No milestone');
+
+ // Add milestone.
+ await milestoneDropdown.click();
+ await page.getByRole('option', {name: 'milestone1'}).click();
+ await expect(selectedMilestone).toContainText('milestone1');
+ await expect(page.locator('.timeline-item.event').last()).toContainText('user2 added this to the milestone1 milestone');
+
+ // Clear milestone.
+ await milestoneDropdown.click();
+ await page.getByText('Clear milestone', {exact: true}).click();
+ await expect(selectedMilestone).toContainText('No milestone');
+ await expect(page.locator('.timeline-item.event').last()).toContainText('user2 removed this from the milestone1 milestone');
+});
+
+test('New Issue: Milestone', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Unable to get tests working on Safari Mobile, see https://codeberg.org/forgejo/forgejo/pulls/3445#issuecomment-1789636');
+ const page = await login({browser}, workerInfo);
+
+ const response = await page.goto('/user2/repo1/issues/new');
+ await expect(response?.status()).toBe(200);
+
+ const selectedMilestone = page.locator('.issue-content-right .select-milestone.list');
+ const milestoneDropdown = page.locator('.issue-content-right .select-milestone.dropdown');
+ await expect(selectedMilestone).toContainText('No milestone');
+
+ // Add milestone.
+ await milestoneDropdown.click();
+ await page.getByRole('option', {name: 'milestone1'}).click();
+ await expect(selectedMilestone).toContainText('milestone1');
+
+ // Clear milestone.
+ await milestoneDropdown.click();
+ await page.getByText('Clear milestone', {exact: true}).click();
+ await expect(selectedMilestone).toContainText('No milestone');
+});
diff --git a/tests/e2e/markdown-editor.test.e2e.js b/tests/e2e/markdown-editor.test.e2e.js
new file mode 100644
index 0000000..4a3b414
--- /dev/null
+++ b/tests/e2e/markdown-editor.test.e2e.js
@@ -0,0 +1,177 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, load_logged_in_context, login_user} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('markdown indentation', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+
+ const initText = `* first\n* second\n* third\n* last`;
+
+ const page = await context.newPage();
+ const response = await page.goto('/user2/repo1/issues/new');
+ await expect(response?.status()).toBe(200);
+
+ const textarea = page.locator('textarea[name=content]');
+ const tab = ' ';
+ const indent = page.locator('button[data-md-action="indent"]');
+ const unindent = page.locator('button[data-md-action="unindent"]');
+ await textarea.fill(initText);
+ await textarea.click(); // Tab handling is disabled until pointer event or input.
+
+ // Indent, then unindent first line
+ await textarea.focus();
+ await textarea.evaluate((it) => it.setSelectionRange(0, 0));
+ await indent.click();
+ await expect(textarea).toHaveValue(`${tab}* first\n* second\n* third\n* last`);
+ await unindent.click();
+ await expect(textarea).toHaveValue(initText);
+
+ // Indent second line while somewhere inside of it
+ await textarea.focus();
+ await textarea.press('ArrowDown');
+ await textarea.press('ArrowRight');
+ await textarea.press('ArrowRight');
+ await indent.click();
+ await expect(textarea).toHaveValue(`* first\n${tab}* second\n* third\n* last`);
+
+ // Subsequently, select a chunk of 2nd and 3rd line and indent both, preserving the cursor position in relation to text
+ await textarea.focus();
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('hird')));
+ await indent.click();
+ const lines23 = `* first\n${tab}${tab}* second\n${tab}* third\n* last`;
+ await expect(textarea).toHaveValue(lines23);
+ await expect(textarea).toHaveJSProperty('selectionStart', lines23.indexOf('cond'));
+ await expect(textarea).toHaveJSProperty('selectionEnd', lines23.indexOf('hird'));
+
+ // Then unindent twice, erasing all indents.
+ await unindent.click();
+ await expect(textarea).toHaveValue(`* first\n${tab}* second\n* third\n* last`);
+ await unindent.click();
+ await expect(textarea).toHaveValue(initText);
+
+ // Indent and unindent with cursor at the end of the line
+ await textarea.focus();
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('cond')));
+ await textarea.press('End');
+ await indent.click();
+ await expect(textarea).toHaveValue(`* first\n${tab}* second\n* third\n* last`);
+ await unindent.click();
+ await expect(textarea).toHaveValue(initText);
+
+ // Check that Tab does work after input
+ await textarea.focus();
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length));
+ await textarea.press('Shift+Enter'); // Avoid triggering the prefix continuation feature
+ await textarea.pressSequentially('* least');
+ await indent.click();
+ await expect(textarea).toHaveValue(`* first\n* second\n* third\n* last\n${tab}* least`);
+
+ // Check that partial indents are cleared
+ await textarea.focus();
+ await textarea.fill(initText);
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('* second'), it.value.indexOf('* second')));
+ await textarea.pressSequentially(' ');
+ await unindent.click();
+ await expect(textarea).toHaveValue(initText);
+});
+
+test('markdown list continuation', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+
+ const initText = `* first\n* second\n* third\n* last`;
+
+ const page = await context.newPage();
+ const response = await page.goto('/user2/repo1/issues/new');
+ await expect(response?.status()).toBe(200);
+
+ const textarea = page.locator('textarea[name=content]');
+ const tab = ' ';
+ const indent = page.locator('button[data-md-action="indent"]');
+ await textarea.fill(initText);
+
+ // Test continuation of '* ' prefix
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.indexOf('cond'), it.value.indexOf('cond')));
+ await textarea.press('End');
+ await textarea.press('Enter');
+ await textarea.pressSequentially('middle');
+ await expect(textarea).toHaveValue(`* first\n* second\n* middle\n* third\n* last`);
+
+ // Test continuation of ' * ' prefix
+ await indent.click();
+ await textarea.press('Enter');
+ await textarea.pressSequentially('muddle');
+ await expect(textarea).toHaveValue(`* first\n* second\n${tab}* middle\n${tab}* muddle\n* third\n* last`);
+
+ // Test breaking in the middle of a line
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.lastIndexOf('ddle'), it.value.lastIndexOf('ddle')));
+ await textarea.pressSequentially('tate');
+ await textarea.press('Enter');
+ await textarea.pressSequentially('me');
+ await expect(textarea).toHaveValue(`* first\n* second\n${tab}* middle\n${tab}* mutate\n${tab}* meddle\n* third\n* last`);
+
+ // Test not triggering when Shift held
+ await textarea.fill(initText);
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length));
+ await textarea.press('Shift+Enter');
+ await textarea.press('Enter');
+ await textarea.pressSequentially('...but not least');
+ await expect(textarea).toHaveValue(`* first\n* second\n* third\n* last\n\n...but not least`);
+
+ // Test continuation of ordered list
+ await textarea.fill(`1. one\n2. two`);
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length));
+ await textarea.press('Enter');
+ await textarea.pressSequentially('three');
+ await expect(textarea).toHaveValue(`1. one\n2. two\n3. three`);
+
+ // Test continuation of alternative ordered list syntax
+ await textarea.fill(`1) one\n2) two`);
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length));
+ await textarea.press('Enter');
+ await textarea.pressSequentially('three');
+ await expect(textarea).toHaveValue(`1) one\n2) two\n3) three`);
+
+ // Test continuation of blockquote
+ await textarea.fill(`> knowledge is power`);
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length));
+ await textarea.press('Enter');
+ await textarea.pressSequentially('france is bacon');
+ await expect(textarea).toHaveValue(`> knowledge is power\n> france is bacon`);
+
+ // Test continuation of checklists
+ await textarea.fill(`- [ ] have a problem\n- [x] create a solution`);
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length));
+ await textarea.press('Enter');
+ await textarea.pressSequentially('write a test');
+ await expect(textarea).toHaveValue(`- [ ] have a problem\n- [x] create a solution\n- [ ] write a test`);
+
+ // Test all conceivable syntax (except ordered lists)
+ const prefixes = [
+ '- ', // A space between the bullet and the content is required.
+ ' - ', // I have seen single space in front of -/* being used and even recommended, I think.
+ '* ',
+ '+ ',
+ ' ',
+ ' ',
+ ' - ',
+ '\t',
+ '\t\t* ',
+ '> ',
+ '> > ',
+ '- [ ] ',
+ '- [ ]', // This does seem to render, so allow.
+ '* [ ] ',
+ '+ [ ] ',
+ ];
+ for (const prefix of prefixes) {
+ await textarea.fill(`${prefix}one`);
+ await textarea.evaluate((it) => it.setSelectionRange(it.value.length, it.value.length));
+ await textarea.press('Enter');
+ await textarea.pressSequentially('two');
+ await expect(textarea).toHaveValue(`${prefix}one\n${prefix}two`);
+ }
+});
diff --git a/tests/e2e/markup.test.e2e.js b/tests/e2e/markup.test.e2e.js
new file mode 100644
index 0000000..920537d
--- /dev/null
+++ b/tests/e2e/markup.test.e2e.js
@@ -0,0 +1,14 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test} from './utils_e2e.js';
+
+test('markup with #xyz-mode-only', async ({page}) => {
+ const response = await page.goto('/user2/repo1/issues/1');
+ await expect(response?.status()).toBe(200);
+ await page.waitForLoadState('networkidle');
+
+ const comment = page.locator('.comment-body>.markup', {hasText: 'test markup light/dark-mode-only'});
+ await expect(comment).toBeVisible();
+ await expect(comment.locator('[src$="#gh-light-mode-only"]')).toBeVisible();
+ await expect(comment.locator('[src$="#gh-dark-mode-only"]')).toBeHidden();
+});
diff --git a/tests/e2e/org-settings.test.e2e.js b/tests/e2e/org-settings.test.e2e.js
new file mode 100644
index 0000000..5ff0975
--- /dev/null
+++ b/tests/e2e/org-settings.test.e2e.js
@@ -0,0 +1,24 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, login} from './utils_e2e.js';
+import {validate_form} from './shared/forms.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('org team settings', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Cannot get it to work - as usual');
+ const page = await login({browser}, workerInfo);
+ const response = await page.goto('/org/org3/teams/team1/edit');
+ await expect(response?.status()).toBe(200);
+
+ await page.locator('input[name="permission"][value="admin"]').click();
+ await expect(page.locator('.hide-unless-checked')).toBeHidden();
+
+ await page.locator('input[name="permission"][value="read"]').click();
+ await expect(page.locator('.hide-unless-checked')).toBeVisible();
+
+ // we are validating the form here to include the part that could be hidden
+ await validate_form({page});
+});
diff --git a/tests/e2e/profile_actions.test.e2e.js b/tests/e2e/profile_actions.test.e2e.js
new file mode 100644
index 0000000..dcec0cd
--- /dev/null
+++ b/tests/e2e/profile_actions.test.e2e.js
@@ -0,0 +1,41 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test('Follow actions', async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+
+ await page.goto('/user1');
+ await page.waitForLoadState('networkidle');
+
+ // Check if following and then unfollowing works.
+ // This checks that the event listeners of
+ // the buttons aren't disappearing.
+ const followButton = page.locator('.follow');
+ await expect(followButton).toContainText('Follow');
+ await followButton.click();
+ await expect(followButton).toContainText('Unfollow');
+ await followButton.click();
+ await expect(followButton).toContainText('Follow');
+
+ // Simple block interaction.
+ await expect(page.locator('.block')).toContainText('Block');
+
+ await page.locator('.block').click();
+ await expect(page.locator('#block-user')).toBeVisible();
+ await page.locator('#block-user .ok').click();
+ await expect(page.locator('.block')).toContainText('Unblock');
+ await expect(page.locator('#block-user')).toBeHidden();
+
+ // Check that following the user yields in a error being shown.
+ await followButton.click();
+ const flashMessage = page.locator('#flash-message');
+ await expect(flashMessage).toBeVisible();
+ await expect(flashMessage).toContainText('You cannot follow this user because you have blocked this user or this user has blocked you.');
+
+ // Unblock interaction.
+ await page.locator('.block').click();
+ await expect(page.locator('.block')).toContainText('Block');
+});
diff --git a/tests/e2e/reaction-selectors.test.e2e.js b/tests/e2e/reaction-selectors.test.e2e.js
new file mode 100644
index 0000000..2a9c62b
--- /dev/null
+++ b/tests/e2e/reaction-selectors.test.e2e.js
@@ -0,0 +1,65 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+const assertReactionCounts = (comment, counts) =>
+ expect(async () => {
+ await expect(comment.locator('.reactions')).toBeVisible();
+
+ const reactions = Object.fromEntries(
+ await Promise.all(
+ (
+ await comment
+ .locator(`.reactions [role=button][data-reaction-content]`)
+ .all()
+ ).map(async (button) => [
+ await button.getAttribute('data-reaction-content'),
+ parseInt(await button.locator('.reaction-count').textContent()),
+ ]),
+ ),
+ );
+ return expect(reactions).toStrictEqual(counts);
+ }).toPass();
+
+async function toggleReaction(menu, reaction) {
+ await menu.evaluateAll((menus) => menus[0].focus());
+ await menu.locator('.add-reaction').click();
+ await menu.locator(`[role=menuitem][data-reaction-content="${reaction}"]`).click();
+}
+
+test('Reaction Selectors', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+
+ const response = await page.goto('/user2/repo1/issues/1');
+ await expect(response?.status()).toBe(200);
+
+ const comment = page.locator('.comment#issuecomment-2').first();
+
+ const topPicker = comment.locator('.actions [role=menu].select-reaction');
+ const bottomPicker = comment.locator('.reactions').getByRole('menu');
+
+ await assertReactionCounts(comment, {'laugh': 2});
+
+ await toggleReaction(topPicker, '+1');
+ await assertReactionCounts(comment, {'laugh': 2, '+1': 1});
+
+ await toggleReaction(bottomPicker, '+1');
+ await assertReactionCounts(comment, {'laugh': 2});
+
+ await toggleReaction(bottomPicker, '-1');
+ await assertReactionCounts(comment, {'laugh': 2, '-1': 1});
+
+ await toggleReaction(topPicker, '-1');
+ await assertReactionCounts(comment, {'laugh': 2});
+
+ await comment.locator('.reactions [role=button][data-reaction-content=laugh]').click();
+ await assertReactionCounts(comment, {'laugh': 1});
+
+ await toggleReaction(topPicker, 'laugh');
+ await assertReactionCounts(comment, {'laugh': 2});
+});
diff --git a/tests/e2e/release.test.e2e.js b/tests/e2e/release.test.e2e.js
new file mode 100644
index 0000000..ac1e101
--- /dev/null
+++ b/tests/e2e/release.test.e2e.js
@@ -0,0 +1,76 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, save_visual, load_logged_in_context} from './utils_e2e.js';
+import {validate_form} from './shared/forms.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test.describe.configure({
+ timeout: 30000,
+});
+
+test('External Release Attachments', async ({browser, isMobile}, workerInfo) => {
+ test.skip(isMobile);
+
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ /** @type {import('@playwright/test').Page} */
+ const page = await context.newPage();
+
+ // Click "New Release"
+ await page.goto('/user2/repo2/releases');
+ await page.click('.button.small.primary');
+
+ // Fill out form and create new release
+ await expect(page).toHaveURL('/user2/repo2/releases/new');
+ await validate_form({page}, 'fieldset');
+ await page.fill('input[name=tag_name]', '2.0');
+ await page.fill('input[name=title]', '2.0');
+ await page.click('#add-external-link');
+ await page.click('#add-external-link');
+ await page.fill('input[name=attachment-new-name-2]', 'Test');
+ await page.fill('input[name=attachment-new-exturl-2]', 'https://forgejo.org/');
+ await page.click('.remove-rel-attach');
+ save_visual(page);
+ await page.click('.button.small.primary');
+
+ // Validate release page and click edit
+ await expect(page).toHaveURL('/user2/repo2/releases');
+ await expect(page.locator('.download[open] li')).toHaveCount(3);
+ await expect(page.locator('.download[open] li:nth-of-type(3)')).toContainText('Test');
+ await expect(page.locator('.download[open] li:nth-of-type(3) a')).toHaveAttribute('href', 'https://forgejo.org/');
+ save_visual(page);
+ await page.locator('.octicon-pencil').first().click();
+
+ // Validate edit page and edit the release
+ await expect(page).toHaveURL('/user2/repo2/releases/edit/2.0');
+ await validate_form({page}, 'fieldset');
+ await expect(page.locator('.attachment_edit:visible')).toHaveCount(2);
+ await expect(page.locator('.attachment_edit:visible').nth(0)).toHaveValue('Test');
+ await expect(page.locator('.attachment_edit:visible').nth(1)).toHaveValue('https://forgejo.org/');
+ await page.locator('.attachment_edit:visible').nth(0).fill('Test2');
+ await page.locator('.attachment_edit:visible').nth(1).fill('https://gitea.io/');
+ await page.click('#add-external-link');
+ await expect(page.locator('.attachment_edit:visible')).toHaveCount(4);
+ await page.locator('.attachment_edit:visible').nth(2).fill('Test3');
+ await page.locator('.attachment_edit:visible').nth(3).fill('https://gitea.com/');
+ save_visual(page);
+ await page.click('.button.small.primary');
+
+ // Validate release page and click edit
+ await expect(page).toHaveURL('/user2/repo2/releases');
+ await expect(page.locator('.download[open] li')).toHaveCount(4);
+ await expect(page.locator('.download[open] li:nth-of-type(3)')).toContainText('Test2');
+ await expect(page.locator('.download[open] li:nth-of-type(3) a')).toHaveAttribute('href', 'https://gitea.io/');
+ await expect(page.locator('.download[open] li:nth-of-type(4)')).toContainText('Test3');
+ await expect(page.locator('.download[open] li:nth-of-type(4) a')).toHaveAttribute('href', 'https://gitea.com/');
+ save_visual(page);
+ await page.locator('.octicon-pencil').first().click();
+
+ // Delete release
+ await expect(page).toHaveURL('/user2/repo2/releases/edit/2.0');
+ await page.click('.delete-button');
+ await page.click('.button.ok');
+ await expect(page).toHaveURL('/user2/repo2/releases');
+});
diff --git a/tests/e2e/repo-code.test.e2e.js b/tests/e2e/repo-code.test.e2e.js
new file mode 100644
index 0000000..62c4f55
--- /dev/null
+++ b/tests/e2e/repo-code.test.e2e.js
@@ -0,0 +1,86 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+async function assertSelectedLines(page, nums) {
+ const pageAssertions = async () => {
+ expect(
+ await Promise.all((await page.locator('tr.active [data-line-number]').all()).map((line) => line.getAttribute('data-line-number'))),
+ )
+ .toStrictEqual(nums);
+
+ // the first line selected has an action button
+ if (nums.length > 0) await expect(page.locator(`#L${nums[0]} .code-line-button`)).toBeVisible();
+ };
+
+ await pageAssertions();
+
+ // URL has the expected state
+ expect(new URL(page.url()).hash)
+ .toEqual(nums.length === 0 ? '' : nums.length === 1 ? `#L${nums[0]}` : `#L${nums[0]}-L${nums.at(-1)}`);
+
+ // test selection restored from URL hash
+ await page.reload();
+ return pageAssertions();
+}
+
+test('Line Range Selection', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+
+ const filePath = '/user2/repo1/src/branch/master/README.md?display=source';
+
+ const response = await page.goto(filePath);
+ await expect(response?.status()).toBe(200);
+
+ await assertSelectedLines(page, []);
+ await page.locator('span#L1').click();
+ await assertSelectedLines(page, ['1']);
+ await page.locator('span#L3').click({modifiers: ['Shift']});
+ await assertSelectedLines(page, ['1', '2', '3']);
+ await page.locator('span#L2').click();
+ await assertSelectedLines(page, ['2']);
+ await page.locator('span#L1').click({modifiers: ['Shift']});
+ await assertSelectedLines(page, ['1', '2']);
+
+ // out-of-bounds end line
+ await page.goto(`${filePath}#L1-L100`);
+ await assertSelectedLines(page, ['1', '2', '3']);
+});
+
+test('Readable diff', async ({page}, workerInfo) => {
+ // remove this when the test covers more (e.g. accessibility scans or interactive behaviour)
+ test.skip(workerInfo.project.name !== 'firefox', 'This currently only tests the backend-generated HTML code and it is not necessary to test with multiple browsers.');
+ const expectedDiffs = [
+ {id: 'testfile-2', removed: 'e', added: 'a'},
+ {id: 'testfile-3', removed: 'allo', added: 'ola'},
+ {id: 'testfile-4', removed: 'hola', added: 'native'},
+ {id: 'testfile-5', removed: 'native', added: 'ubuntu-latest'},
+ {id: 'testfile-6', added: '- runs-on: '},
+ {id: 'testfile-7', removed: 'ubuntu', added: 'debian'},
+ ];
+ for (const thisDiff of expectedDiffs) {
+ const response = await page.goto('/user2/diff-test/commits/branch/main');
+ await expect(response?.status()).toBe(200); // Status OK
+ await page.getByText(`Patch: ${thisDiff.id}`).click();
+ if (thisDiff.removed) {
+ await expect(page.getByText(thisDiff.removed, {exact: true})).toHaveClass(/removed-code/);
+ await expect(page.getByText(thisDiff.removed, {exact: true})).toHaveCSS('background-color', 'rgb(252, 165, 165)');
+ }
+ if (thisDiff.added) {
+ await expect(page.getByText(thisDiff.added, {exact: true})).toHaveClass(/added-code/);
+ await expect(page.getByText(thisDiff.added, {exact: true})).toHaveCSS('background-color', 'rgb(134, 239, 172)');
+ }
+ }
+});
+
+test('Commit graph overflow', async ({page}) => {
+ await page.goto('/user2/diff-test/graph');
+ await expect(page.getByRole('button', {name: 'Mono'})).toBeInViewport({ratio: 1});
+ await expect(page.getByRole('button', {name: 'Color'})).toBeInViewport({ratio: 1});
+ await expect(page.locator('.selection.search.dropdown')).toBeInViewport({ratio: 1});
+});
diff --git a/tests/e2e/repo-migrate.test.e2e.js b/tests/e2e/repo-migrate.test.e2e.js
new file mode 100644
index 0000000..63328e0
--- /dev/null
+++ b/tests/e2e/repo-migrate.test.e2e.js
@@ -0,0 +1,32 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(({browser}, workerInfo) => login_user(browser, workerInfo, 'user2'));
+
+test('Migration Progress Page', async ({page: unauthedPage, browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Flaky actionability checks on Mobile Safari');
+
+ const page = await (await load_logged_in_context(browser, workerInfo, 'user2')).newPage();
+
+ await expect((await page.goto('/user2/invalidrepo'))?.status(), 'repo should not exist yet').toBe(404);
+
+ await page.goto('/repo/migrate?service_type=1');
+
+ const form = page.locator('form');
+ await form.getByRole('textbox', {name: 'Repository Name'}).fill('invalidrepo');
+ await form.getByRole('textbox', {name: 'Migrate / Clone from URL'}).fill('https://codeberg.org/forgejo/invalidrepo');
+ await form.locator('button.primary').click({timeout: 5000});
+ await expect(page).toHaveURL('user2/invalidrepo');
+
+ await expect((await unauthedPage.goto('/user2/invalidrepo'))?.status(), 'public migration page should be accessible').toBe(200);
+ await expect(unauthedPage.locator('#repo_migrating_progress')).toBeVisible();
+
+ await page.reload();
+ await expect(page.locator('#repo_migrating_failed')).toBeVisible();
+ await page.getByRole('button', {name: 'Delete this repository'}).click();
+ const deleteModal = page.locator('#delete-repo-modal');
+ await deleteModal.getByRole('textbox', {name: 'Confirmation string'}).fill('user2/invalidrepo');
+ await deleteModal.getByRole('button', {name: 'Delete repository'}).click();
+ await expect(page).toHaveURL('/');
+});
diff --git a/tests/e2e/repo-settings.test.e2e.js b/tests/e2e/repo-settings.test.e2e.js
new file mode 100644
index 0000000..b7b0884
--- /dev/null
+++ b/tests/e2e/repo-settings.test.e2e.js
@@ -0,0 +1,48 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, login} from './utils_e2e.js';
+import {validate_form} from './shared/forms.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test('repo webhook settings', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Cannot get it to work - as usual');
+ const page = await login({browser}, workerInfo);
+ const response = await page.goto('/user2/repo1/settings/hooks/forgejo/new');
+ await expect(response?.status()).toBe(200);
+
+ await page.locator('input[name="events"][value="choose_events"]').click();
+ await expect(page.locator('.hide-unless-checked')).toBeVisible();
+
+ // check accessibility including the custom events (now visible) part
+ await validate_form({page}, 'fieldset');
+
+ await page.locator('input[name="events"][value="push_only"]').click();
+ await expect(page.locator('.hide-unless-checked')).toBeHidden();
+ await page.locator('input[name="events"][value="send_everything"]').click();
+ await expect(page.locator('.hide-unless-checked')).toBeHidden();
+});
+
+test('repo branch protection settings', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Cannot get it to work - as usual');
+ const page = await login({browser}, workerInfo);
+ const response = await page.goto('/user2/repo1/settings/branches/edit');
+ await expect(response?.status()).toBe(200);
+
+ await validate_form({page}, 'fieldset');
+
+ // verify header is new
+ await expect(page.locator('h4')).toContainText('new');
+ await page.locator('input[name="rule_name"]').fill('testrule');
+ await page.getByText('Save rule').click();
+ // verify header is in edit mode
+ await page.waitForLoadState('networkidle');
+ await page.getByText('Edit').click();
+ await expect(page.locator('h4')).toContainText('Protection rules for branch');
+ // delete the rule for the next test
+ await page.goBack();
+ await page.getByText('Delete rule').click();
+ await page.getByText('Yes').click();
+});
diff --git a/tests/e2e/repo-wiki.test.e2e.js b/tests/e2e/repo-wiki.test.e2e.js
new file mode 100644
index 0000000..4599fbd
--- /dev/null
+++ b/tests/e2e/repo-wiki.test.e2e.js
@@ -0,0 +1,16 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test} from './utils_e2e.js';
+
+test(`Search for long titles and test for no overflow`, async ({page}, workerInfo) => {
+ test.skip(workerInfo.project.name === 'Mobile Safari', 'Fails as always, see https://codeberg.org/forgejo/forgejo/pulls/5326#issuecomment-2313275');
+ await page.goto('/user2/repo1/wiki');
+ await page.waitForLoadState('networkidle');
+ await page.getByPlaceholder('Search wiki').fill('spaces');
+ await page.getByPlaceholder('Search wiki').click();
+ // workaround: HTMX listens on keyup events, playwright's fill only triggers the input event
+ // so we manually "type" the last letter
+ await page.getByPlaceholder('Search wiki').dispatchEvent('keyup');
+ // timeout is necessary because HTMX search could be slow
+ await expect(page.locator('#wiki-search a[href]')).toBeInViewport({ratio: 1});
+});
diff --git a/tests/e2e/right-settings-button.test.e2e.js b/tests/e2e/right-settings-button.test.e2e.js
new file mode 100644
index 0000000..4f2b09b
--- /dev/null
+++ b/tests/e2e/right-settings-button.test.e2e.js
@@ -0,0 +1,128 @@
+// @ts-check
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user2');
+});
+
+test.describe('desktop viewport', () => {
+ test.use({viewport: {width: 1920, height: 300}});
+
+ test('Settings button on right of repo header', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+
+ await page.goto('/user2/repo1');
+
+ const settingsBtn = page.locator('.overflow-menu-items>#settings-btn');
+ await expect(settingsBtn).toBeVisible();
+ await expect(settingsBtn).toHaveClass(/right/);
+
+ await expect(page.locator('.overflow-menu-button')).toHaveCount(0);
+ });
+
+ test('Settings button on right of repo header also when add more button is shown', async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user12');
+ const context = await load_logged_in_context(browser, workerInfo, 'user12');
+ const page = await context.newPage();
+
+ await page.goto('/user12/repo10');
+
+ const settingsBtn = page.locator('.overflow-menu-items>#settings-btn');
+ await expect(settingsBtn).toBeVisible();
+ await expect(settingsBtn).toHaveClass(/right/);
+
+ await expect(page.locator('.overflow-menu-button')).toHaveCount(0);
+ });
+
+ test('Settings button on right of org header', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+
+ await page.goto('/org3');
+
+ const settingsBtn = page.locator('.overflow-menu-items>#settings-btn');
+ await expect(settingsBtn).toBeVisible();
+ await expect(settingsBtn).toHaveClass(/right/);
+
+ await expect(page.locator('.overflow-menu-button')).toHaveCount(0);
+ });
+
+ test('User overview overflow menu should not be influenced', async ({page}) => {
+ await page.goto('/user2');
+
+ await expect(page.locator('.overflow-menu-items>#settings-btn')).toHaveCount(0);
+
+ await expect(page.locator('.overflow-menu-button')).toHaveCount(0);
+ });
+});
+
+test.describe('small viewport', () => {
+ test.use({viewport: {width: 800, height: 300}});
+
+ test('Settings button in overflow menu of repo header', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+
+ await page.goto('/user2/repo1');
+
+ await expect(page.locator('.overflow-menu-items>#settings-btn')).toHaveCount(0);
+
+ await expect(page.locator('.overflow-menu-button')).toBeVisible();
+
+ await page.click('.overflow-menu-button');
+ await expect(page.locator('.tippy-target>#settings-btn')).toBeVisible();
+
+ // Verify that we have no duplicated items
+ const shownItems = await page.locator('.overflow-menu-items>a').all();
+ expect(shownItems).not.toHaveLength(0);
+ const overflowItems = await page.locator('.tippy-target>a').all();
+ expect(overflowItems).not.toHaveLength(0);
+
+ const items = shownItems.concat(overflowItems);
+ expect(Array.from(new Set(items))).toHaveLength(items.length);
+ });
+
+ test('Settings button in overflow menu of org header', async ({browser}, workerInfo) => {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ const page = await context.newPage();
+
+ await page.goto('/org3');
+
+ await expect(page.locator('.overflow-menu-items>#settings-btn')).toHaveCount(0);
+
+ await expect(page.locator('.overflow-menu-button')).toBeVisible();
+
+ await page.click('.overflow-menu-button');
+ await expect(page.locator('.tippy-target>#settings-btn')).toBeVisible();
+
+ // Verify that we have no duplicated items
+ const shownItems = await page.locator('.overflow-menu-items>a').all();
+ expect(shownItems).not.toHaveLength(0);
+ const overflowItems = await page.locator('.tippy-target>a').all();
+ expect(overflowItems).not.toHaveLength(0);
+
+ const items = shownItems.concat(overflowItems);
+ expect(Array.from(new Set(items))).toHaveLength(items.length);
+ });
+
+ test('User overview overflow menu should not be influenced', async ({page}) => {
+ await page.goto('/user2');
+
+ await expect(page.locator('.overflow-menu-items>#settings-btn')).toHaveCount(0);
+
+ await expect(page.locator('.overflow-menu-button')).toBeVisible();
+ await page.click('.overflow-menu-button');
+ await expect(page.locator('.tippy-target>#settings-btn')).toHaveCount(0);
+
+ // Verify that we have no duplicated items
+ const shownItems = await page.locator('.overflow-menu-items>a').all();
+ expect(shownItems).not.toHaveLength(0);
+ const overflowItems = await page.locator('.tippy-target>a').all();
+ expect(overflowItems).not.toHaveLength(0);
+
+ const items = shownItems.concat(overflowItems);
+ expect(Array.from(new Set(items))).toHaveLength(items.length);
+ });
+});
diff --git a/tests/e2e/shared/forms.js b/tests/e2e/shared/forms.js
new file mode 100644
index 0000000..0ffd6ee
--- /dev/null
+++ b/tests/e2e/shared/forms.js
@@ -0,0 +1,44 @@
+import {expect} from '@playwright/test';
+import {AxeBuilder} from '@axe-core/playwright';
+
+export async function validate_form({page}, scope) {
+ scope ??= 'form';
+ const accessibilityScanResults = await new AxeBuilder({page})
+ // disable checking for link style - should be fixed, but not now
+ .disableRules('link-in-text-block')
+ .include(scope)
+ // exclude automated tooltips from accessibility scan, remove when fixed
+ .exclude('span[data-tooltip-content')
+ // exclude weird non-semantic HTML disabled content
+ .exclude('.disabled')
+ .analyze();
+ expect(accessibilityScanResults.violations).toEqual([]);
+
+ // assert CSS properties that needed to be overriden for forms (ensure they remain active)
+ const boxes = page.getByRole('checkbox').or(page.getByRole('radio'));
+ for (const b of await boxes.all()) {
+ await expect(b).toHaveCSS('margin-left', '0px');
+ await expect(b).toHaveCSS('margin-top', '0px');
+ await expect(b).toHaveCSS('vertical-align', 'baseline');
+ }
+
+ // assert no (trailing) colon is used in labels
+ // might be necessary to adjust in case colons are strictly necessary in help text
+ for (const l of await page.locator('label').all()) {
+ const str = await l.textContent();
+ await expect(str.split('\n')[0]).not.toContain(':');
+ }
+
+ // check that multiple help text are correctly aligned to each other
+ // used for example to separate read/write permissions in team permission matrix
+ for (const l of await page.locator('label:has(.help + .help)').all()) {
+ const helpLabels = await l.locator('.help').all();
+ const boxes = await Promise.all(helpLabels.map((help) => help.boundingBox()));
+ for (let i = 1; i < boxes.length; i++) {
+ // help texts vertically aligned on top of each other
+ await expect(boxes[i].x).toBe(boxes[0].x);
+ // help texts don't horizontally intersect each other
+ await expect(boxes[i].y + boxes[i].height).toBeGreaterThanOrEqual(boxes[i - 1].y + boxes[i - 1].height);
+ }
+ }
+}
diff --git a/tests/e2e/utils_e2e.js b/tests/e2e/utils_e2e.js
new file mode 100644
index 0000000..98d762f
--- /dev/null
+++ b/tests/e2e/utils_e2e.js
@@ -0,0 +1,82 @@
+import {expect, test as baseTest} from '@playwright/test';
+
+export const test = baseTest.extend({
+ context: async ({browser}, use) => {
+ return use(await test_context(browser));
+ },
+});
+
+async function test_context(browser, options) {
+ const context = await browser.newContext(options);
+
+ context.on('page', (page) => {
+ page.on('pageerror', (err) => expect(err).toBeUndefined());
+ });
+
+ return context;
+}
+
+const ARTIFACTS_PATH = `tests/e2e/test-artifacts`;
+const LOGIN_PASSWORD = 'password';
+
+// log in user and store session info. This should generally be
+// run in test.beforeAll(), then the session can be loaded in tests.
+export async function login_user(browser, workerInfo, user) {
+ test.setTimeout(60000);
+ // Set up a new context
+ const context = await test_context(browser);
+ const page = await context.newPage();
+
+ // Route to login page
+ // Note: this could probably be done more quickly with a POST
+ const response = await page.goto('/user/login');
+ await expect(response?.status()).toBe(200); // Status OK
+
+ // Fill out form
+ await page.type('input[name=user_name]', user);
+ await page.type('input[name=password]', LOGIN_PASSWORD);
+ await page.click('form button.ui.primary.button:visible');
+
+ await page.waitForLoadState('networkidle');
+
+ await expect(page.url(), {message: `Failed to login user ${user}`}).toBe(`${workerInfo.project.use.baseURL}/`);
+
+ // Save state
+ await context.storageState({path: `${ARTIFACTS_PATH}/state-${user}-${workerInfo.workerIndex}.json`});
+
+ return context;
+}
+
+export async function load_logged_in_context(browser, workerInfo, user) {
+ let context;
+ try {
+ context = await test_context(browser, {storageState: `${ARTIFACTS_PATH}/state-${user}-${workerInfo.workerIndex}.json`});
+ } catch (err) {
+ if (err.code === 'ENOENT') {
+ throw new Error(`Could not find state for '${user}'. Did you call login_user(browser, workerInfo, '${user}') in test.beforeAll()?`);
+ }
+ }
+ return context;
+}
+
+export async function login({browser}, workerInfo) {
+ const context = await load_logged_in_context(browser, workerInfo, 'user2');
+ return await context.newPage();
+}
+
+export async function save_visual(page) {
+ // Optionally include visual testing
+ if (process.env.VISUAL_TEST) {
+ await page.waitForLoadState('networkidle');
+ // Mock page/version string
+ await page.locator('footer div.ui.left').evaluate((node) => node.innerHTML = 'MOCK');
+ await expect(page).toHaveScreenshot({
+ fullPage: true,
+ timeout: 20000,
+ mask: [
+ page.locator('.secondary-nav span>img.ui.avatar'),
+ page.locator('.ui.dropdown.jump.item span>img.ui.avatar'),
+ ],
+ });
+ }
+}
diff --git a/tests/e2e/utils_e2e_test.go b/tests/e2e/utils_e2e_test.go
new file mode 100644
index 0000000..cfd3ff9
--- /dev/null
+++ b/tests/e2e/utils_e2e_test.go
@@ -0,0 +1,56 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package e2e
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "net/url"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/require"
+)
+
+func onForgejoRunTB(t testing.TB, callback func(testing.TB, *url.URL), prepare ...bool) {
+ if len(prepare) == 0 || prepare[0] {
+ defer tests.PrepareTestEnv(t, 1)()
+ }
+ s := http.Server{
+ Handler: testE2eWebRoutes,
+ }
+
+ u, err := url.Parse(setting.AppURL)
+ require.NoError(t, err)
+ listener, err := net.Listen("tcp", u.Host)
+ i := 0
+ for err != nil && i <= 10 {
+ time.Sleep(100 * time.Millisecond)
+ listener, err = net.Listen("tcp", u.Host)
+ i++
+ }
+ require.NoError(t, err)
+ u.Host = listener.Addr().String()
+
+ defer func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ s.Shutdown(ctx)
+ cancel()
+ }()
+
+ go s.Serve(listener)
+ // Started by config go ssh.Listen(setting.SSH.ListenHost, setting.SSH.ListenPort, setting.SSH.ServerCiphers, setting.SSH.ServerKeyExchanges, setting.SSH.ServerMACs)
+
+ callback(t, u)
+}
+
+func onForgejoRun(t *testing.T, callback func(*testing.T, *url.URL), prepare ...bool) {
+ onForgejoRunTB(t, func(t testing.TB, u *url.URL) {
+ callback(t.(*testing.T), u)
+ }, prepare...)
+}
diff --git a/tests/e2e/webauthn.test.e2e.js b/tests/e2e/webauthn.test.e2e.js
new file mode 100644
index 0000000..e11c17c
--- /dev/null
+++ b/tests/e2e/webauthn.test.e2e.js
@@ -0,0 +1,60 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+// @ts-check
+
+import {expect} from '@playwright/test';
+import {test, login_user, load_logged_in_context} from './utils_e2e.js';
+
+test.beforeAll(async ({browser}, workerInfo) => {
+ await login_user(browser, workerInfo, 'user40');
+});
+
+test('WebAuthn register & login flow', async ({browser}, workerInfo) => {
+ test.skip(workerInfo.project.name !== 'chromium', 'Uses Chrome protocol');
+ const context = await load_logged_in_context(browser, workerInfo, 'user40');
+ const page = await context.newPage();
+
+ // Register a security key.
+ let response = await page.goto('/user/settings/security');
+ await expect(response?.status()).toBe(200);
+
+ // https://github.com/microsoft/playwright/issues/7276#issuecomment-1516768428
+ const cdpSession = await page.context().newCDPSession(page);
+ await cdpSession.send('WebAuthn.enable');
+ await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
+ options: {
+ protocol: 'ctap2',
+ ctap2Version: 'ctap2_1',
+ hasUserVerification: true,
+ transport: 'usb',
+ automaticPresenceSimulation: true,
+ isUserVerified: true,
+ backupEligibility: true,
+ },
+ });
+
+ await page.locator('input#nickname').fill('Testing Security Key');
+ await page.getByText('Add security key').click();
+
+ // Logout.
+ await page.locator('div[aria-label="Profile and settings…"]').click();
+ await page.getByText('Sign Out').click();
+ await page.waitForURL(`${workerInfo.project.use.baseURL}/`);
+
+ // Login.
+ response = await page.goto('/user/login');
+ await expect(response?.status()).toBe(200);
+
+ await page.getByLabel('Username or email address').fill('user40');
+ await page.getByLabel('Password').fill('password');
+ await page.getByRole('button', {name: 'Sign in'}).click();
+ await page.waitForURL(`${workerInfo.project.use.baseURL}/user/webauthn`);
+ await page.waitForURL(`${workerInfo.project.use.baseURL}/`);
+
+ // Cleanup.
+ response = await page.goto('/user/settings/security');
+ await expect(response?.status()).toBe(200);
+ await page.getByRole('button', {name: 'Remove'}).click();
+ await page.getByRole('button', {name: 'Yes'}).click();
+ await page.waitForURL(`${workerInfo.project.use.baseURL}/user/settings/security`);
+});
diff --git a/tests/fuzz/fuzz_test.go b/tests/fuzz/fuzz_test.go
new file mode 100644
index 0000000..25a6ed8
--- /dev/null
+++ b/tests/fuzz/fuzz_test.go
@@ -0,0 +1,40 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package fuzz
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "testing"
+
+ "code.gitea.io/gitea/modules/markup"
+ "code.gitea.io/gitea/modules/markup/markdown"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+var renderContext = markup.RenderContext{
+ Ctx: context.Background(),
+ Links: markup.Links{
+ Base: "https://example.com/go-gitea/gitea",
+ },
+ Metas: map[string]string{
+ "user": "go-gitea",
+ "repo": "gitea",
+ },
+}
+
+func FuzzMarkdownRenderRaw(f *testing.F) {
+ f.Fuzz(func(t *testing.T, data []byte) {
+ setting.AppURL = "http://localhost:3000/"
+ markdown.RenderRaw(&renderContext, bytes.NewReader(data), io.Discard)
+ })
+}
+
+func FuzzMarkupPostProcess(f *testing.F) {
+ f.Fuzz(func(t *testing.T, data []byte) {
+ setting.AppURL = "http://localhost:3000/"
+ markup.PostProcess(&renderContext, bytes.NewReader(data), io.Discard)
+ })
+}
diff --git a/tests/gitea-lfs-meta/0b/8d/8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351 b/tests/gitea-lfs-meta/0b/8d/8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351
new file mode 100644
index 0000000..71911bf
--- /dev/null
+++ b/tests/gitea-lfs-meta/0b/8d/8b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351
Binary files differ
diff --git a/tests/gitea-lfs-meta/2e/cc/db43825d2a49d99d542daa20075cff1d97d9d2349a8977efe9c03661737c b/tests/gitea-lfs-meta/2e/cc/db43825d2a49d99d542daa20075cff1d97d9d2349a8977efe9c03661737c
new file mode 100644
index 0000000..1e16a25
--- /dev/null
+++ b/tests/gitea-lfs-meta/2e/cc/db43825d2a49d99d542daa20075cff1d97d9d2349a8977efe9c03661737c
Binary files differ
diff --git a/tests/gitea-lfs-meta/7b/6b/2c88dba9f760a1a58469b67fee2b698ef7e9399c4ca4f34a14ccbe39f623 b/tests/gitea-lfs-meta/7b/6b/2c88dba9f760a1a58469b67fee2b698ef7e9399c4ca4f34a14ccbe39f623
new file mode 100644
index 0000000..378de49
--- /dev/null
+++ b/tests/gitea-lfs-meta/7b/6b/2c88dba9f760a1a58469b67fee2b698ef7e9399c4ca4f34a14ccbe39f623
@@ -0,0 +1 @@
+# Testing documents in LFS
diff --git a/tests/gitea-lfs-meta/9d/17/2e5c64b4f0024b9901ec6afe9ea052f3c9b6ff9f4b07956d8c48c86fca82 b/tests/gitea-lfs-meta/9d/17/2e5c64b4f0024b9901ec6afe9ea052f3c9b6ff9f4b07956d8c48c86fca82
new file mode 100644
index 0000000..7eb6670
--- /dev/null
+++ b/tests/gitea-lfs-meta/9d/17/2e5c64b4f0024b9901ec6afe9ea052f3c9b6ff9f4b07956d8c48c86fca82
@@ -0,0 +1 @@
+# Testing READMEs in LFS
diff --git a/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/HEAD b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/config b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/config
new file mode 100644
index 0000000..e6da231
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+ ignorecase = true
+ precomposeunicode = true
diff --git a/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/description b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/info/exclude b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/objects/74/8bf557dfc9c6457998b5118a6c8b2129f56c30 b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/objects/74/8bf557dfc9c6457998b5118a6c8b2129f56c30
new file mode 100644
index 0000000..980093a
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/objects/74/8bf557dfc9c6457998b5118a6c8b2129f56c30
Binary files differ
diff --git a/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/objects/a5/46f86c7dd182592b96639045e176dde8df76ef b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/objects/a5/46f86c7dd182592b96639045e176dde8df76ef
new file mode 100644
index 0000000..b8b1449
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/objects/a5/46f86c7dd182592b96639045e176dde8df76ef
Binary files differ
diff --git a/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/objects/b8/95782bd271fdd266dd06e5880ea4abdc3a0dc7 b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/objects/b8/95782bd271fdd266dd06e5880ea4abdc3a0dc7
new file mode 100644
index 0000000..6b6d55b
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/objects/b8/95782bd271fdd266dd06e5880ea4abdc3a0dc7
Binary files differ
diff --git a/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/refs/heads/master b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/refs/heads/master
new file mode 100644
index 0000000..70132dd
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/private_repo_on_limited_org.git/refs/heads/master
@@ -0,0 +1 @@
+b895782bd271fdd266dd06e5880ea4abdc3a0dc7
diff --git a/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/HEAD b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/config b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/config
new file mode 100644
index 0000000..e6da231
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+ ignorecase = true
+ precomposeunicode = true
diff --git a/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/description b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/info/exclude b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/objects/21/2f14c8b713de38bd0b3fb23bd288369b01668a b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/objects/21/2f14c8b713de38bd0b3fb23bd288369b01668a
new file mode 100644
index 0000000..45a0732
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/objects/21/2f14c8b713de38bd0b3fb23bd288369b01668a
Binary files differ
diff --git a/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/objects/90/e402c3937a4639725fcc59ca1f529e7dc8506f b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/objects/90/e402c3937a4639725fcc59ca1f529e7dc8506f
new file mode 100644
index 0000000..b73b5a5
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/objects/90/e402c3937a4639725fcc59ca1f529e7dc8506f
Binary files differ
diff --git a/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/objects/ed/d9c1000cd1444efd63e153e3554c8d5656bf65 b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/objects/ed/d9c1000cd1444efd63e153e3554c8d5656bf65
new file mode 100644
index 0000000..f690063
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/objects/ed/d9c1000cd1444efd63e153e3554c8d5656bf65
Binary files differ
diff --git a/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/refs/heads/master b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/refs/heads/master
new file mode 100644
index 0000000..9de5b8d
--- /dev/null
+++ b/tests/gitea-repositories-meta/limited_org/public_repo_on_limited_org.git/refs/heads/master
@@ -0,0 +1 @@
+90e402c3937a4639725fcc59ca1f529e7dc8506f
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/HEAD b/tests/gitea-repositories-meta/migration/lfs-test.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/config b/tests/gitea-repositories-meta/migration/lfs-test.git/config
new file mode 100644
index 0000000..3f8f41b
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/config
@@ -0,0 +1,7 @@
+[core]
+ bare = false
+ repositoryformatversion = 0
+ filemode = false
+ symlinks = false
+ ignorecase = true
+ logallrefupdates = true
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/description b/tests/gitea-repositories-meta/migration/lfs-test.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/hooks/post-checkout b/tests/gitea-repositories-meta/migration/lfs-test.git/hooks/post-checkout
new file mode 100644
index 0000000..cab40f2
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/hooks/post-checkout
@@ -0,0 +1,3 @@
+#!/bin/sh
+command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/post-checkout.\n"; exit 2; }
+git lfs post-checkout "$@"
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/hooks/post-commit b/tests/gitea-repositories-meta/migration/lfs-test.git/hooks/post-commit
new file mode 100644
index 0000000..9443f41
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/hooks/post-commit
@@ -0,0 +1,3 @@
+#!/bin/sh
+command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/post-commit.\n"; exit 2; }
+git lfs post-commit "$@"
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/hooks/post-merge b/tests/gitea-repositories-meta/migration/lfs-test.git/hooks/post-merge
new file mode 100644
index 0000000..828b708
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/hooks/post-merge
@@ -0,0 +1,3 @@
+#!/bin/sh
+command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/post-merge.\n"; exit 2; }
+git lfs post-merge "$@"
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/hooks/pre-push b/tests/gitea-repositories-meta/migration/lfs-test.git/hooks/pre-push
new file mode 100644
index 0000000..81a9cc6
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/hooks/pre-push
@@ -0,0 +1,3 @@
+#!/bin/sh
+command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/pre-push.\n"; exit 2; }
+git lfs pre-push "$@"
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/index b/tests/gitea-repositories-meta/migration/lfs-test.git/index
new file mode 100644
index 0000000..13f8e26
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/index
Binary files differ
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/lfs/objects/d6/f1/d6f175817f886ec6fbbc1515326465fa96c3bfd54a4ea06cfd6dbbd8340e0152 b/tests/gitea-repositories-meta/migration/lfs-test.git/lfs/objects/d6/f1/d6f175817f886ec6fbbc1515326465fa96c3bfd54a4ea06cfd6dbbd8340e0152
new file mode 100644
index 0000000..e9b0a4e
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/lfs/objects/d6/f1/d6f175817f886ec6fbbc1515326465fa96c3bfd54a4ea06cfd6dbbd8340e0152
@@ -0,0 +1 @@
+dummy2 \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/lfs/objects/fb/8f/fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab041 b/tests/gitea-repositories-meta/migration/lfs-test.git/lfs/objects/fb/8f/fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab041
new file mode 100644
index 0000000..71676cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/lfs/objects/fb/8f/fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab041
@@ -0,0 +1 @@
+dummy1 \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/objects/54/6244003622c64b2fc3c2cd544d7a29882c8383 b/tests/gitea-repositories-meta/migration/lfs-test.git/objects/54/6244003622c64b2fc3c2cd544d7a29882c8383
new file mode 100644
index 0000000..0db52af
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/objects/54/6244003622c64b2fc3c2cd544d7a29882c8383
Binary files differ
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/objects/6a/6ccf5d874fec134ee712572cc03a0f2dd7afec b/tests/gitea-repositories-meta/migration/lfs-test.git/objects/6a/6ccf5d874fec134ee712572cc03a0f2dd7afec
new file mode 100644
index 0000000..8a96927
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/objects/6a/6ccf5d874fec134ee712572cc03a0f2dd7afec
Binary files differ
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/objects/a6/7134b8484c2abe9fa954e1fd83b39b271383ed b/tests/gitea-repositories-meta/migration/lfs-test.git/objects/a6/7134b8484c2abe9fa954e1fd83b39b271383ed
new file mode 100644
index 0000000..122f87e
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/objects/a6/7134b8484c2abe9fa954e1fd83b39b271383ed
Binary files differ
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/objects/b7/01ed6ffe410f0c3ac204b929ea47cfec6cef54 b/tests/gitea-repositories-meta/migration/lfs-test.git/objects/b7/01ed6ffe410f0c3ac204b929ea47cfec6cef54
new file mode 100644
index 0000000..554b7f0
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/objects/b7/01ed6ffe410f0c3ac204b929ea47cfec6cef54
Binary files differ
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/objects/f2/07b74f55cd7f9e800b7550d587cbc488f6eaf1 b/tests/gitea-repositories-meta/migration/lfs-test.git/objects/f2/07b74f55cd7f9e800b7550d587cbc488f6eaf1
new file mode 100644
index 0000000..ae6fdce
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/objects/f2/07b74f55cd7f9e800b7550d587cbc488f6eaf1
Binary files differ
diff --git a/tests/gitea-repositories-meta/migration/lfs-test.git/refs/heads/master b/tests/gitea-repositories-meta/migration/lfs-test.git/refs/heads/master
new file mode 100644
index 0000000..cd602fb
--- /dev/null
+++ b/tests/gitea-repositories-meta/migration/lfs-test.git/refs/heads/master
@@ -0,0 +1 @@
+546244003622c64b2fc3c2cd544d7a29882c8383
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/COMMITMESSAGE b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/COMMITMESSAGE
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/COMMITMESSAGE
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/COMMIT_EDITMSG b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/COMMIT_EDITMSG
new file mode 100644
index 0000000..5852f44
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/COMMIT_EDITMSG
@@ -0,0 +1 @@
+Initial commit
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/HEAD b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/HEAD
new file mode 100644
index 0000000..4568acf
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/branch1
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/config b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/config
new file mode 100644
index 0000000..2768a20
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/config
@@ -0,0 +1,10 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = false
+ logallrefupdates = true
+ symlinks = false
+ ignorecase = true
+[user]
+ name = user2
+ email = user2@example.com
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/config.backup b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/config.backup
new file mode 100644
index 0000000..d545cda
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/config.backup
@@ -0,0 +1,7 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = false
+ logallrefupdates = true
+ symlinks = false
+ ignorecase = true
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/description b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/index b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/index
new file mode 100644
index 0000000..450ee42
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/index
Binary files differ
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/info/exclude b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/logs/HEAD b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/logs/HEAD
new file mode 100644
index 0000000..19ba979
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/logs/HEAD
@@ -0,0 +1,2 @@
+0000000000000000000000000000000000000000 cdaca8cf1d36e1e4e508a940f6e157e239beccfa user2 <user2@example.com> 1575491734 +0100 commit (initial): Initial commit
+cdaca8cf1d36e1e4e508a940f6e157e239beccfa cdaca8cf1d36e1e4e508a940f6e157e239beccfa user2 <user2@example.com> 1575491742 +0100 checkout: moving from master to branch1
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/logs/refs/heads/branch1 b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/logs/refs/heads/branch1
new file mode 100644
index 0000000..0501061
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/logs/refs/heads/branch1
@@ -0,0 +1 @@
+0000000000000000000000000000000000000000 cdaca8cf1d36e1e4e508a940f6e157e239beccfa user2 <user2@example.com> 1575491742 +0100 branch: Created from cdaca8cf1d36e1e4e508a940f6e157e239beccfa
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/logs/refs/heads/master b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/logs/refs/heads/master
new file mode 100644
index 0000000..b67741e
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/logs/refs/heads/master
@@ -0,0 +1 @@
+0000000000000000000000000000000000000000 cdaca8cf1d36e1e4e508a940f6e157e239beccfa user2 <user2@example.com> 1575491734 +0100 commit (initial): Initial commit
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/objects/ba/ea7d6e6b7773a80bcede323cfb21dfe9d4b855 b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/objects/ba/ea7d6e6b7773a80bcede323cfb21dfe9d4b855
new file mode 100644
index 0000000..fefe858
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/objects/ba/ea7d6e6b7773a80bcede323cfb21dfe9d4b855
Binary files differ
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/objects/c2/a1ad4c931cebe27c7e39176fe7119b5557c9eb b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/objects/c2/a1ad4c931cebe27c7e39176fe7119b5557c9eb
new file mode 100644
index 0000000..c53ae2e
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/objects/c2/a1ad4c931cebe27c7e39176fe7119b5557c9eb
Binary files differ
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/objects/cd/aca8cf1d36e1e4e508a940f6e157e239beccfa b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/objects/cd/aca8cf1d36e1e4e508a940f6e157e239beccfa
new file mode 100644
index 0000000..5dce64c
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/objects/cd/aca8cf1d36e1e4e508a940f6e157e239beccfa
@@ -0,0 +1,3 @@
+x•K
+Â0@]ç³$“ß´ âÖcL’)#1oѸzðàñR«µ À࣋@daÊAB$"Ë“ŽI²XcÓ æEæìâä½âmÜ[‡í%ÝÀù‹«¼¹>W9¥V/€ž¼›‘¬ƒ£F­Õn÷Õ¿"u{”Qx…_­>ÿ
+6 \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/refs/heads/branch1 b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/refs/heads/branch1
new file mode 100644
index 0000000..35f8462
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/refs/heads/branch1
@@ -0,0 +1 @@
+cdaca8cf1d36e1e4e508a940f6e157e239beccfa
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker.git/refs/heads/master b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/refs/heads/master
new file mode 100644
index 0000000..35f8462
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker.git/refs/heads/master
@@ -0,0 +1 @@
+cdaca8cf1d36e1e4e508a940f6e157e239beccfa
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/COMMITMESSAGE b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/COMMITMESSAGE
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/COMMITMESSAGE
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/COMMIT_EDITMSG b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/COMMIT_EDITMSG
new file mode 100644
index 0000000..5852f44
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/COMMIT_EDITMSG
@@ -0,0 +1 @@
+Initial commit
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/HEAD b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/HEAD
new file mode 100644
index 0000000..4568acf
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/branch1
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/config b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/config
new file mode 100644
index 0000000..2768a20
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/config
@@ -0,0 +1,10 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = false
+ logallrefupdates = true
+ symlinks = false
+ ignorecase = true
+[user]
+ name = user2
+ email = user2@example.com
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/config.backup b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/config.backup
new file mode 100644
index 0000000..d545cda
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/config.backup
@@ -0,0 +1,7 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = false
+ logallrefupdates = true
+ symlinks = false
+ ignorecase = true
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/description b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/index b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/index
new file mode 100644
index 0000000..450ee42
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/index
Binary files differ
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/info/exclude b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/logs/HEAD b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/logs/HEAD
new file mode 100644
index 0000000..19ba979
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/logs/HEAD
@@ -0,0 +1,2 @@
+0000000000000000000000000000000000000000 cdaca8cf1d36e1e4e508a940f6e157e239beccfa user2 <user2@example.com> 1575491734 +0100 commit (initial): Initial commit
+cdaca8cf1d36e1e4e508a940f6e157e239beccfa cdaca8cf1d36e1e4e508a940f6e157e239beccfa user2 <user2@example.com> 1575491742 +0100 checkout: moving from master to branch1
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/logs/refs/heads/branch1 b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/logs/refs/heads/branch1
new file mode 100644
index 0000000..0501061
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/logs/refs/heads/branch1
@@ -0,0 +1 @@
+0000000000000000000000000000000000000000 cdaca8cf1d36e1e4e508a940f6e157e239beccfa user2 <user2@example.com> 1575491742 +0100 branch: Created from cdaca8cf1d36e1e4e508a940f6e157e239beccfa
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/logs/refs/heads/master b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/logs/refs/heads/master
new file mode 100644
index 0000000..b67741e
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/logs/refs/heads/master
@@ -0,0 +1 @@
+0000000000000000000000000000000000000000 cdaca8cf1d36e1e4e508a940f6e157e239beccfa user2 <user2@example.com> 1575491734 +0100 commit (initial): Initial commit
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/objects/ba/ea7d6e6b7773a80bcede323cfb21dfe9d4b855 b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/objects/ba/ea7d6e6b7773a80bcede323cfb21dfe9d4b855
new file mode 100644
index 0000000..fefe858
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/objects/ba/ea7d6e6b7773a80bcede323cfb21dfe9d4b855
Binary files differ
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/objects/c2/a1ad4c931cebe27c7e39176fe7119b5557c9eb b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/objects/c2/a1ad4c931cebe27c7e39176fe7119b5557c9eb
new file mode 100644
index 0000000..c53ae2e
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/objects/c2/a1ad4c931cebe27c7e39176fe7119b5557c9eb
Binary files differ
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/objects/cd/aca8cf1d36e1e4e508a940f6e157e239beccfa b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/objects/cd/aca8cf1d36e1e4e508a940f6e157e239beccfa
new file mode 100644
index 0000000..5dce64c
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/objects/cd/aca8cf1d36e1e4e508a940f6e157e239beccfa
@@ -0,0 +1,3 @@
+x•K
+Â0@]ç³$“ß´ âÖcL’)#1oѸzðàñR«µ À࣋@daÊAB$"Ë“ŽI²XcÓ æEæìâä½âmÜ[‡í%ÝÀù‹«¼¹>W9¥V/€ž¼›‘¬ƒ£F­Õn÷Õ¿"u{”Qx…_­>ÿ
+6 \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/refs/heads/branch1 b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/refs/heads/branch1
new file mode 100644
index 0000000..35f8462
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/refs/heads/branch1
@@ -0,0 +1 @@
+cdaca8cf1d36e1e4e508a940f6e157e239beccfa
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/refs/heads/master b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/refs/heads/master
new file mode 100644
index 0000000..35f8462
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_alpha.git/refs/heads/master
@@ -0,0 +1 @@
+cdaca8cf1d36e1e4e508a940f6e157e239beccfa
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/COMMITMESSAGE b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/COMMITMESSAGE
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/COMMITMESSAGE
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/COMMIT_EDITMSG b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/COMMIT_EDITMSG
new file mode 100644
index 0000000..5852f44
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/COMMIT_EDITMSG
@@ -0,0 +1 @@
+Initial commit
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/HEAD b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/HEAD
new file mode 100644
index 0000000..4568acf
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/branch1
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/config b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/config
new file mode 100644
index 0000000..2768a20
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/config
@@ -0,0 +1,10 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = false
+ logallrefupdates = true
+ symlinks = false
+ ignorecase = true
+[user]
+ name = user2
+ email = user2@example.com
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/config.backup b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/config.backup
new file mode 100644
index 0000000..d545cda
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/config.backup
@@ -0,0 +1,7 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = false
+ logallrefupdates = true
+ symlinks = false
+ ignorecase = true
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/description b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/index b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/index
new file mode 100644
index 0000000..450ee42
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/index
Binary files differ
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/info/exclude b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/logs/HEAD b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/logs/HEAD
new file mode 100644
index 0000000..19ba979
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/logs/HEAD
@@ -0,0 +1,2 @@
+0000000000000000000000000000000000000000 cdaca8cf1d36e1e4e508a940f6e157e239beccfa user2 <user2@example.com> 1575491734 +0100 commit (initial): Initial commit
+cdaca8cf1d36e1e4e508a940f6e157e239beccfa cdaca8cf1d36e1e4e508a940f6e157e239beccfa user2 <user2@example.com> 1575491742 +0100 checkout: moving from master to branch1
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/logs/refs/heads/branch1 b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/logs/refs/heads/branch1
new file mode 100644
index 0000000..0501061
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/logs/refs/heads/branch1
@@ -0,0 +1 @@
+0000000000000000000000000000000000000000 cdaca8cf1d36e1e4e508a940f6e157e239beccfa user2 <user2@example.com> 1575491742 +0100 branch: Created from cdaca8cf1d36e1e4e508a940f6e157e239beccfa
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/logs/refs/heads/master b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/logs/refs/heads/master
new file mode 100644
index 0000000..b67741e
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/logs/refs/heads/master
@@ -0,0 +1 @@
+0000000000000000000000000000000000000000 cdaca8cf1d36e1e4e508a940f6e157e239beccfa user2 <user2@example.com> 1575491734 +0100 commit (initial): Initial commit
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/objects/ba/ea7d6e6b7773a80bcede323cfb21dfe9d4b855 b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/objects/ba/ea7d6e6b7773a80bcede323cfb21dfe9d4b855
new file mode 100644
index 0000000..fefe858
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/objects/ba/ea7d6e6b7773a80bcede323cfb21dfe9d4b855
Binary files differ
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/objects/c2/a1ad4c931cebe27c7e39176fe7119b5557c9eb b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/objects/c2/a1ad4c931cebe27c7e39176fe7119b5557c9eb
new file mode 100644
index 0000000..c53ae2e
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/objects/c2/a1ad4c931cebe27c7e39176fe7119b5557c9eb
Binary files differ
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/objects/cd/aca8cf1d36e1e4e508a940f6e157e239beccfa b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/objects/cd/aca8cf1d36e1e4e508a940f6e157e239beccfa
new file mode 100644
index 0000000..5dce64c
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/objects/cd/aca8cf1d36e1e4e508a940f6e157e239beccfa
@@ -0,0 +1,3 @@
+x•K
+Â0@]ç³$“ß´ âÖcL’)#1oѸzðàñR«µ À࣋@daÊAB$"Ë“ŽI²XcÓ æEæìâä½âmÜ[‡í%ÝÀù‹«¼¹>W9¥V/€ž¼›‘¬ƒ£F­Õn÷Õ¿"u{”Qx…_­>ÿ
+6 \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/refs/heads/branch1 b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/refs/heads/branch1
new file mode 100644
index 0000000..35f8462
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/refs/heads/branch1
@@ -0,0 +1 @@
+cdaca8cf1d36e1e4e508a940f6e157e239beccfa
diff --git a/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/refs/heads/master b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/refs/heads/master
new file mode 100644
index 0000000..35f8462
--- /dev/null
+++ b/tests/gitea-repositories-meta/org26/repo_external_tracker_numeric.git/refs/heads/master
@@ -0,0 +1 @@
+cdaca8cf1d36e1e4e508a940f6e157e239beccfa
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/HEAD b/tests/gitea-repositories-meta/org3/repo3.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/config b/tests/gitea-repositories-meta/org3/repo3.git/config
new file mode 100644
index 0000000..e6da231
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+ ignorecase = true
+ precomposeunicode = true
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/description b/tests/gitea-repositories-meta/org3/repo3.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/hooks/post-receive b/tests/gitea-repositories-meta/org3/repo3.git/hooks/post-receive
new file mode 100755
index 0000000..4b3d452
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/hooks/post-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/post-receive.d"`; do
+ sh "$SHELL_FOLDER/post-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/hooks/post-receive.d/gitea b/tests/gitea-repositories-meta/org3/repo3.git/hooks/post-receive.d/gitea
new file mode 100755
index 0000000..43a948d
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/hooks/post-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" post-receive
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/hooks/pre-receive b/tests/gitea-repositories-meta/org3/repo3.git/hooks/pre-receive
new file mode 100755
index 0000000..4127013
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/hooks/pre-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/pre-receive.d"`; do
+ sh "$SHELL_FOLDER/pre-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/hooks/pre-receive.d/gitea b/tests/gitea-repositories-meta/org3/repo3.git/hooks/pre-receive.d/gitea
new file mode 100755
index 0000000..49d0940
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/hooks/pre-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" pre-receive
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/hooks/update b/tests/gitea-repositories-meta/org3/repo3.git/hooks/update
new file mode 100755
index 0000000..c186fe4
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/hooks/update
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/update.d"`; do
+ sh "$SHELL_FOLDER/update.d/$i" $1 $2 $3
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/hooks/update.d/gitea b/tests/gitea-repositories-meta/org3/repo3.git/hooks/update.d/gitea
new file mode 100755
index 0000000..38101c2
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/hooks/update.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" update $1 $2 $3
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/info/exclude b/tests/gitea-repositories-meta/org3/repo3.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/objects/20/ade30d25e0ecaeec84e7f542a8456900858240 b/tests/gitea-repositories-meta/org3/repo3.git/objects/20/ade30d25e0ecaeec84e7f542a8456900858240
new file mode 100644
index 0000000..9f3ffe5
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/objects/20/ade30d25e0ecaeec84e7f542a8456900858240
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/objects/27/74debeea6dc742cc4971a92db0e08b95b60588 b/tests/gitea-repositories-meta/org3/repo3.git/objects/27/74debeea6dc742cc4971a92db0e08b95b60588
new file mode 100644
index 0000000..5d9226f
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/objects/27/74debeea6dc742cc4971a92db0e08b95b60588
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/objects/2a/47ca4b614a9f5a43abbd5ad851a54a616ffee6 b/tests/gitea-repositories-meta/org3/repo3.git/objects/2a/47ca4b614a9f5a43abbd5ad851a54a616ffee6
new file mode 100644
index 0000000..ca60d23
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/objects/2a/47ca4b614a9f5a43abbd5ad851a54a616ffee6
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/objects/2f/9b22fd3159a43b7b4e5dd806fcd544edf8716f b/tests/gitea-repositories-meta/org3/repo3.git/objects/2f/9b22fd3159a43b7b4e5dd806fcd544edf8716f
new file mode 100644
index 0000000..e98d752
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/objects/2f/9b22fd3159a43b7b4e5dd806fcd544edf8716f
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/objects/d2/2b4d4daa5be07329fcef6ed458f00cf3392da0 b/tests/gitea-repositories-meta/org3/repo3.git/objects/d2/2b4d4daa5be07329fcef6ed458f00cf3392da0
new file mode 100644
index 0000000..e319f8c
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/objects/d2/2b4d4daa5be07329fcef6ed458f00cf3392da0
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/objects/d5/6a3073c1dbb7b15963110a049d50cdb5db99fc b/tests/gitea-repositories-meta/org3/repo3.git/objects/d5/6a3073c1dbb7b15963110a049d50cdb5db99fc
new file mode 100644
index 0000000..eff3c98
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/objects/d5/6a3073c1dbb7b15963110a049d50cdb5db99fc
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/objects/ec/f0db3c1ec806522de4b491fb9a3c7457398c61 b/tests/gitea-repositories-meta/org3/repo3.git/objects/ec/f0db3c1ec806522de4b491fb9a3c7457398c61
new file mode 100644
index 0000000..ed431f7
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/objects/ec/f0db3c1ec806522de4b491fb9a3c7457398c61
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/objects/ee/16d127df463aa491e08958120f2108b02468df b/tests/gitea-repositories-meta/org3/repo3.git/objects/ee/16d127df463aa491e08958120f2108b02468df
new file mode 100644
index 0000000..e177f69
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/objects/ee/16d127df463aa491e08958120f2108b02468df
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/refs/heads/master b/tests/gitea-repositories-meta/org3/repo3.git/refs/heads/master
new file mode 100644
index 0000000..ccee722
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/refs/heads/master
@@ -0,0 +1 @@
+2a47ca4b614a9f5a43abbd5ad851a54a616ffee6
diff --git a/tests/gitea-repositories-meta/org3/repo3.git/refs/heads/test_branch b/tests/gitea-repositories-meta/org3/repo3.git/refs/heads/test_branch
new file mode 100644
index 0000000..dfe0c6a
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo3.git/refs/heads/test_branch
@@ -0,0 +1 @@
+d22b4d4daa5be07329fcef6ed458f00cf3392da0
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/HEAD b/tests/gitea-repositories-meta/org3/repo5.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/config b/tests/gitea-repositories-meta/org3/repo5.git/config
new file mode 100644
index 0000000..e6da231
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+ ignorecase = true
+ precomposeunicode = true
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/description b/tests/gitea-repositories-meta/org3/repo5.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/hooks/post-receive b/tests/gitea-repositories-meta/org3/repo5.git/hooks/post-receive
new file mode 100755
index 0000000..4b3d452
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/hooks/post-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/post-receive.d"`; do
+ sh "$SHELL_FOLDER/post-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/hooks/post-receive.d/gitea b/tests/gitea-repositories-meta/org3/repo5.git/hooks/post-receive.d/gitea
new file mode 100755
index 0000000..43a948d
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/hooks/post-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" post-receive
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/hooks/pre-receive b/tests/gitea-repositories-meta/org3/repo5.git/hooks/pre-receive
new file mode 100755
index 0000000..4127013
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/hooks/pre-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/pre-receive.d"`; do
+ sh "$SHELL_FOLDER/pre-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/hooks/pre-receive.d/gitea b/tests/gitea-repositories-meta/org3/repo5.git/hooks/pre-receive.d/gitea
new file mode 100755
index 0000000..49d0940
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/hooks/pre-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" pre-receive
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/hooks/update b/tests/gitea-repositories-meta/org3/repo5.git/hooks/update
new file mode 100755
index 0000000..c186fe4
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/hooks/update
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/update.d"`; do
+ sh "$SHELL_FOLDER/update.d/$i" $1 $2 $3
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/hooks/update.d/gitea b/tests/gitea-repositories-meta/org3/repo5.git/hooks/update.d/gitea
new file mode 100755
index 0000000..38101c2
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/hooks/update.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" update $1 $2 $3
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/info/exclude b/tests/gitea-repositories-meta/org3/repo5.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/objects/20/ade30d25e0ecaeec84e7f542a8456900858240 b/tests/gitea-repositories-meta/org3/repo5.git/objects/20/ade30d25e0ecaeec84e7f542a8456900858240
new file mode 100644
index 0000000..9f3ffe5
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/objects/20/ade30d25e0ecaeec84e7f542a8456900858240
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/objects/27/74debeea6dc742cc4971a92db0e08b95b60588 b/tests/gitea-repositories-meta/org3/repo5.git/objects/27/74debeea6dc742cc4971a92db0e08b95b60588
new file mode 100644
index 0000000..5d9226f
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/objects/27/74debeea6dc742cc4971a92db0e08b95b60588
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/objects/2a/47ca4b614a9f5a43abbd5ad851a54a616ffee6 b/tests/gitea-repositories-meta/org3/repo5.git/objects/2a/47ca4b614a9f5a43abbd5ad851a54a616ffee6
new file mode 100644
index 0000000..ca60d23
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/objects/2a/47ca4b614a9f5a43abbd5ad851a54a616ffee6
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/objects/2f/9b22fd3159a43b7b4e5dd806fcd544edf8716f b/tests/gitea-repositories-meta/org3/repo5.git/objects/2f/9b22fd3159a43b7b4e5dd806fcd544edf8716f
new file mode 100644
index 0000000..e98d752
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/objects/2f/9b22fd3159a43b7b4e5dd806fcd544edf8716f
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/objects/d2/2b4d4daa5be07329fcef6ed458f00cf3392da0 b/tests/gitea-repositories-meta/org3/repo5.git/objects/d2/2b4d4daa5be07329fcef6ed458f00cf3392da0
new file mode 100644
index 0000000..e319f8c
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/objects/d2/2b4d4daa5be07329fcef6ed458f00cf3392da0
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/objects/d5/6a3073c1dbb7b15963110a049d50cdb5db99fc b/tests/gitea-repositories-meta/org3/repo5.git/objects/d5/6a3073c1dbb7b15963110a049d50cdb5db99fc
new file mode 100644
index 0000000..eff3c98
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/objects/d5/6a3073c1dbb7b15963110a049d50cdb5db99fc
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/objects/ec/f0db3c1ec806522de4b491fb9a3c7457398c61 b/tests/gitea-repositories-meta/org3/repo5.git/objects/ec/f0db3c1ec806522de4b491fb9a3c7457398c61
new file mode 100644
index 0000000..ed431f7
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/objects/ec/f0db3c1ec806522de4b491fb9a3c7457398c61
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/objects/ee/16d127df463aa491e08958120f2108b02468df b/tests/gitea-repositories-meta/org3/repo5.git/objects/ee/16d127df463aa491e08958120f2108b02468df
new file mode 100644
index 0000000..e177f69
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/objects/ee/16d127df463aa491e08958120f2108b02468df
Binary files differ
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/refs/heads/master b/tests/gitea-repositories-meta/org3/repo5.git/refs/heads/master
new file mode 100644
index 0000000..ccee722
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/refs/heads/master
@@ -0,0 +1 @@
+2a47ca4b614a9f5a43abbd5ad851a54a616ffee6
diff --git a/tests/gitea-repositories-meta/org3/repo5.git/refs/heads/test_branch b/tests/gitea-repositories-meta/org3/repo5.git/refs/heads/test_branch
new file mode 100644
index 0000000..dfe0c6a
--- /dev/null
+++ b/tests/gitea-repositories-meta/org3/repo5.git/refs/heads/test_branch
@@ -0,0 +1 @@
+d22b4d4daa5be07329fcef6ed458f00cf3392da0
diff --git a/tests/gitea-repositories-meta/org41/repo61.git/HEAD b/tests/gitea-repositories-meta/org41/repo61.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/org41/repo61.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/org41/repo61.git/config b/tests/gitea-repositories-meta/org41/repo61.git/config
new file mode 100644
index 0000000..64280b8
--- /dev/null
+++ b/tests/gitea-repositories-meta/org41/repo61.git/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = true
+ symlinks = false
+ ignorecase = true
diff --git a/tests/gitea-repositories-meta/org41/repo61.git/description b/tests/gitea-repositories-meta/org41/repo61.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/org41/repo61.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/org41/repo61.git/info/exclude b/tests/gitea-repositories-meta/org41/repo61.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/org41/repo61.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/HEAD b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/config b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/config
new file mode 100644
index 0000000..e6da231
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+ ignorecase = true
+ precomposeunicode = true
diff --git a/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/description b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/info/exclude b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/objects/6e/75c9f89da9a9b93f4f36e61ed092f7a1625ba0 b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/objects/6e/75c9f89da9a9b93f4f36e61ed092f7a1625ba0
new file mode 100644
index 0000000..9db794c
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/objects/6e/75c9f89da9a9b93f4f36e61ed092f7a1625ba0
Binary files differ
diff --git a/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/objects/7f/eb6f9dd600e17a04f48a76cfa0a56a3f30e2c1 b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/objects/7f/eb6f9dd600e17a04f48a76cfa0a56a3f30e2c1
new file mode 100644
index 0000000..c219deb
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/objects/7f/eb6f9dd600e17a04f48a76cfa0a56a3f30e2c1
Binary files differ
diff --git a/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/objects/b7/91b41c0ae8cb3c4b12f3fd8c3709c2481d9e37 b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/objects/b7/91b41c0ae8cb3c4b12f3fd8c3709c2481d9e37
new file mode 100644
index 0000000..60b507e
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/objects/b7/91b41c0ae8cb3c4b12f3fd8c3709c2481d9e37
Binary files differ
diff --git a/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/refs/heads/master b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/refs/heads/master
new file mode 100644
index 0000000..64e4073
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/private_repo_on_private_org.git/refs/heads/master
@@ -0,0 +1 @@
+6e75c9f89da9a9b93f4f36e61ed092f7a1625ba0
diff --git a/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/HEAD b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/config b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/config
new file mode 100644
index 0000000..e6da231
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+ ignorecase = true
+ precomposeunicode = true
diff --git a/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/description b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/info/exclude b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/objects/04/f99c528b643b9175a4b156cdfc13aba6b43853 b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/objects/04/f99c528b643b9175a4b156cdfc13aba6b43853
new file mode 100644
index 0000000..e97437c
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/objects/04/f99c528b643b9175a4b156cdfc13aba6b43853
Binary files differ
diff --git a/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/objects/86/de16d8658f5c0a17ec6aa313871295d7072f78 b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/objects/86/de16d8658f5c0a17ec6aa313871295d7072f78
new file mode 100644
index 0000000..169f196
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/objects/86/de16d8658f5c0a17ec6aa313871295d7072f78
Binary files differ
diff --git a/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/objects/bf/19fd4707acb403c4aca44f126ab69142ac59ce b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/objects/bf/19fd4707acb403c4aca44f126ab69142ac59ce
new file mode 100644
index 0000000..d04c6cb
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/objects/bf/19fd4707acb403c4aca44f126ab69142ac59ce
Binary files differ
diff --git a/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/refs/heads/master b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/refs/heads/master
new file mode 100644
index 0000000..56af246
--- /dev/null
+++ b/tests/gitea-repositories-meta/privated_org/public_repo_on_private_org.git/refs/heads/master
@@ -0,0 +1 @@
+bf19fd4707acb403c4aca44f126ab69142ac59ce
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/HEAD b/tests/gitea-repositories-meta/user12/repo10.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/config b/tests/gitea-repositories-meta/user12/repo10.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/description b/tests/gitea-repositories-meta/user12/repo10.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/hooks/post-receive b/tests/gitea-repositories-meta/user12/repo10.git/hooks/post-receive
new file mode 100755
index 0000000..4b3d452
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/hooks/post-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/post-receive.d"`; do
+ sh "$SHELL_FOLDER/post-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/hooks/post-receive.d/gitea b/tests/gitea-repositories-meta/user12/repo10.git/hooks/post-receive.d/gitea
new file mode 100755
index 0000000..43a948d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/hooks/post-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" post-receive
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/hooks/pre-receive b/tests/gitea-repositories-meta/user12/repo10.git/hooks/pre-receive
new file mode 100755
index 0000000..4127013
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/hooks/pre-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/pre-receive.d"`; do
+ sh "$SHELL_FOLDER/pre-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/hooks/pre-receive.d/gitea b/tests/gitea-repositories-meta/user12/repo10.git/hooks/pre-receive.d/gitea
new file mode 100755
index 0000000..49d0940
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/hooks/pre-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" pre-receive
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/hooks/update b/tests/gitea-repositories-meta/user12/repo10.git/hooks/update
new file mode 100755
index 0000000..c186fe4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/hooks/update
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/update.d"`; do
+ sh "$SHELL_FOLDER/update.d/$i" $1 $2 $3
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/hooks/update.d/gitea b/tests/gitea-repositories-meta/user12/repo10.git/hooks/update.d/gitea
new file mode 100755
index 0000000..38101c2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/hooks/update.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" update $1 $2 $3
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/info/exclude b/tests/gitea-repositories-meta/user12/repo10.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/info/refs b/tests/gitea-repositories-meta/user12/repo10.git/info/refs
new file mode 100644
index 0000000..ca1df85
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/info/refs
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/master
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6 b/tests/gitea-repositories-meta/user12/repo10.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6
new file mode 100644
index 0000000..0994add
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6
Binary files differ
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349f b/tests/gitea-repositories-meta/user12/repo10.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349f
new file mode 100644
index 0000000..700a138
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349f
Binary files differ
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4d b/tests/gitea-repositories-meta/user12/repo10.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4d
new file mode 100644
index 0000000..de48ba7
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/objects/info/packs b/tests/gitea-repositories-meta/user12/repo10.git/objects/info/packs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/objects/info/packs
@@ -0,0 +1 @@
+
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/refs/heads/DefaultBranch b/tests/gitea-repositories-meta/user12/repo10.git/refs/heads/DefaultBranch
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/refs/heads/DefaultBranch
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/refs/heads/develop b/tests/gitea-repositories-meta/user12/repo10.git/refs/heads/develop
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/refs/heads/develop
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/refs/heads/feature/1 b/tests/gitea-repositories-meta/user12/repo10.git/refs/heads/feature/1
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/refs/heads/feature/1
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/refs/heads/master b/tests/gitea-repositories-meta/user12/repo10.git/refs/heads/master
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/refs/heads/master
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user12/repo10.git/refs/tags/v1.1 b/tests/gitea-repositories-meta/user12/repo10.git/refs/tags/v1.1
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user12/repo10.git/refs/tags/v1.1
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/HEAD b/tests/gitea-repositories-meta/user13/repo11.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/config b/tests/gitea-repositories-meta/user13/repo11.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/description b/tests/gitea-repositories-meta/user13/repo11.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/hooks/post-receive b/tests/gitea-repositories-meta/user13/repo11.git/hooks/post-receive
new file mode 100755
index 0000000..4b3d452
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/hooks/post-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/post-receive.d"`; do
+ sh "$SHELL_FOLDER/post-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/hooks/post-receive.d/gitea b/tests/gitea-repositories-meta/user13/repo11.git/hooks/post-receive.d/gitea
new file mode 100755
index 0000000..43a948d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/hooks/post-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" post-receive
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/hooks/pre-receive b/tests/gitea-repositories-meta/user13/repo11.git/hooks/pre-receive
new file mode 100755
index 0000000..4127013
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/hooks/pre-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/pre-receive.d"`; do
+ sh "$SHELL_FOLDER/pre-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/hooks/pre-receive.d/gitea b/tests/gitea-repositories-meta/user13/repo11.git/hooks/pre-receive.d/gitea
new file mode 100755
index 0000000..49d0940
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/hooks/pre-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" pre-receive
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/hooks/update b/tests/gitea-repositories-meta/user13/repo11.git/hooks/update
new file mode 100755
index 0000000..c186fe4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/hooks/update
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/update.d"`; do
+ sh "$SHELL_FOLDER/update.d/$i" $1 $2 $3
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/hooks/update.d/gitea b/tests/gitea-repositories-meta/user13/repo11.git/hooks/update.d/gitea
new file mode 100755
index 0000000..38101c2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/hooks/update.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" update $1 $2 $3
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/info/exclude b/tests/gitea-repositories-meta/user13/repo11.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/info/refs b/tests/gitea-repositories-meta/user13/repo11.git/info/refs
new file mode 100644
index 0000000..ca1df85
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/info/refs
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/master
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/objects/0a/bcb056019adb8336cf9db3ad9d9cf80cd4b141 b/tests/gitea-repositories-meta/user13/repo11.git/objects/0a/bcb056019adb8336cf9db3ad9d9cf80cd4b141
new file mode 100644
index 0000000..63ba4ed
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/objects/0a/bcb056019adb8336cf9db3ad9d9cf80cd4b141
Binary files differ
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6 b/tests/gitea-repositories-meta/user13/repo11.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6
new file mode 100644
index 0000000..0994add
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6
Binary files differ
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349f b/tests/gitea-repositories-meta/user13/repo11.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349f
new file mode 100644
index 0000000..700a138
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349f
Binary files differ
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4d b/tests/gitea-repositories-meta/user13/repo11.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4d
new file mode 100644
index 0000000..de48ba7
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/objects/75/d1afd00e111c8dbd9e3d96a27b431ac5ae6d74 b/tests/gitea-repositories-meta/user13/repo11.git/objects/75/d1afd00e111c8dbd9e3d96a27b431ac5ae6d74
new file mode 100644
index 0000000..609b1c0
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/objects/75/d1afd00e111c8dbd9e3d96a27b431ac5ae6d74
Binary files differ
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/objects/ed/447543e0c85d628b91f7f466f4921908f4c5ea b/tests/gitea-repositories-meta/user13/repo11.git/objects/ed/447543e0c85d628b91f7f466f4921908f4c5ea
new file mode 100644
index 0000000..b3a1046
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/objects/ed/447543e0c85d628b91f7f466f4921908f4c5ea
Binary files differ
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/objects/info/packs b/tests/gitea-repositories-meta/user13/repo11.git/objects/info/packs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/objects/info/packs
@@ -0,0 +1 @@
+
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/DefaultBranch b/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/DefaultBranch
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/DefaultBranch
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/branch2 b/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/branch2
new file mode 100644
index 0000000..d413449
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/branch2
@@ -0,0 +1 @@
+0abcb056019adb8336cf9db3ad9d9cf80cd4b141
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/develop b/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/develop
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/develop
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/feature/1 b/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/feature/1
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/feature/1
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/master b/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/master
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/refs/heads/master
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user13/repo11.git/refs/tags/v1.1 b/tests/gitea-repositories-meta/user13/repo11.git/refs/tags/v1.1
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user13/repo11.git/refs/tags/v1.1
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/HEAD b/tests/gitea-repositories-meta/user2/commits_search_test.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/config b/tests/gitea-repositories-meta/user2/commits_search_test.git/config
new file mode 100644
index 0000000..bfbada5
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/config
@@ -0,0 +1,8 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+[remote "origin"]
+ url = /home/mura/go/src/code.gitea.io/gitea/tests/gitea-repositories-meta/user2/commits_search_test/
+ fetch = +refs/*:refs/*
+ mirror = true
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/description b/tests/gitea-repositories-meta/user2/commits_search_test.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/info/exclude b/tests/gitea-repositories-meta/user2/commits_search_test.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/0a/8499a22ad32a80beda9d75efe15f9f94582468 b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/0a/8499a22ad32a80beda9d75efe15f9f94582468
new file mode 100644
index 0000000..c258fcd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/0a/8499a22ad32a80beda9d75efe15f9f94582468
@@ -0,0 +1,2 @@
+x…ŽÍ
+Â0„=÷)ö.Ê&›Ÿ-ˆè;øq³ÁBcK‰àãÄ»—™aàF–Z§i×6UÈ!)£¥d°çÂ=$§±'ã½crˆ¬ã(4¬iÓg±(©D¾û2æl3fÃÄ1ˆ—ŒƒC%Ò«=– nK…S—‹¾S]g=ÊRÏÐÇç@daŒ8ô¶ßjºÁužDáôµ?Ð:Ðð?‹>9 \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/0c/cf1fcd4d1717c22de0707619a5577ea0acebf0 b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/0c/cf1fcd4d1717c22de0707619a5577ea0acebf0
new file mode 100644
index 0000000..25b730d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/0c/cf1fcd4d1717c22de0707619a5577ea0acebf0
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/3e/a192a6466793d4b7cd8641801ca0c6bec3979c b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/3e/a192a6466793d4b7cd8641801ca0c6bec3979c
new file mode 100644
index 0000000..69b09b5
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/3e/a192a6466793d4b7cd8641801ca0c6bec3979c
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/3f/6594f108842b7c50772510e53ce113d3583c4a b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/3f/6594f108842b7c50772510e53ce113d3583c4a
new file mode 100644
index 0000000..18a2af0
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/3f/6594f108842b7c50772510e53ce113d3583c4a
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/58/e97d1a24fb9e1599d8a467ec409430f3d3569e b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/58/e97d1a24fb9e1599d8a467ec409430f3d3569e
new file mode 100644
index 0000000..585bb66
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/58/e97d1a24fb9e1599d8a467ec409430f3d3569e
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/67/68c1fc1d9448422f05cc84267d94ee62085fe8 b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/67/68c1fc1d9448422f05cc84267d94ee62085fe8
new file mode 100644
index 0000000..2860a7c
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/67/68c1fc1d9448422f05cc84267d94ee62085fe8
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/6e/8eabd9a7f8d6acd2a1219facfd37415564b144 b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/6e/8eabd9a7f8d6acd2a1219facfd37415564b144
new file mode 100644
index 0000000..4fb1314
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/6e/8eabd9a7f8d6acd2a1219facfd37415564b144
@@ -0,0 +1,2 @@
+x•[
+1 Eýî*ò/J2é+ ¢KédL© \¾EÜ€_.œ{´Õºv  ‡¾›[!™Jô1&áÅÏI—=e$-¨q6eI¢®¼ú£ípßV5¸|q³w©ÏÍÎÚêu\òÄ'8bFtc©nI?ëDîêç1â \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/85/f46d747a68adf79cc01e2c25ba6a56932d298d b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/85/f46d747a68adf79cc01e2c25ba6a56932d298d
new file mode 100644
index 0000000..f276d2f
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/85/f46d747a68adf79cc01e2c25ba6a56932d298d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/8d/dd8d1ad1fdc21ab629e906711fa9bc27aa1c52 b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/8d/dd8d1ad1fdc21ab629e906711fa9bc27aa1c52
new file mode 100644
index 0000000..00c3a45
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/8d/dd8d1ad1fdc21ab629e906711fa9bc27aa1c52
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/95/fd0c4138480e4b3913e7cf71a90623fb926fe8 b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/95/fd0c4138480e4b3913e7cf71a90623fb926fe8
new file mode 100644
index 0000000..2dd5fc2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/95/fd0c4138480e4b3913e7cf71a90623fb926fe8
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/98/00fe78cabf4fe774fcf376f97fa2a0ed06987b b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/98/00fe78cabf4fe774fcf376f97fa2a0ed06987b
new file mode 100644
index 0000000..42a85f1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/98/00fe78cabf4fe774fcf376f97fa2a0ed06987b
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/9f/cdb7d53bdef786d2e5577948a0c0d4b321fe5a b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/9f/cdb7d53bdef786d2e5577948a0c0d4b321fe5a
new file mode 100644
index 0000000..125bc6c
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/9f/cdb7d53bdef786d2e5577948a0c0d4b321fe5a
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/c2/0caf78b5f9dd2d0d183876c5cd0e761c13f7f8 b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/c2/0caf78b5f9dd2d0d183876c5cd0e761c13f7f8
new file mode 100644
index 0000000..83e681f
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/c2/0caf78b5f9dd2d0d183876c5cd0e761c13f7f8
@@ -0,0 +1,2 @@
+xŽK
+1]çÙ‹’ÎoÒ0ˆx“NÒ‚1ÃÁã›…póE¼Ò[{ ˆ‡±3ël¦ÅÇ$”T:–œ³K­°±ÀPeQíü:rbÊi‘T#•j , P‘ê!DŸÁ{Eïq﻾õ¬×9WþPÛž|.½]ôÔÀ#¢³úh’1jÒykðßÁ¯8YõE=Î \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/c5/2ba74685f5c8c593efbbb38f62fe024110adef b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/c5/2ba74685f5c8c593efbbb38f62fe024110adef
new file mode 100644
index 0000000..eef09bf
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/c5/2ba74685f5c8c593efbbb38f62fe024110adef
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/d6/ae8023a10ff446b6a4e7f441554834008e99c3 b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/d6/ae8023a10ff446b6a4e7f441554834008e99c3
new file mode 100644
index 0000000..99f8189
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/objects/d6/ae8023a10ff446b6a4e7f441554834008e99c3
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/packed-refs b/tests/gitea-repositories-meta/user2/commits_search_test.git/packed-refs
new file mode 100644
index 0000000..7675cfd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/packed-refs
@@ -0,0 +1,2 @@
+# pack-refs with: peeled fully-peeled
+9800fe78cabf4fe774fcf376f97fa2a0ed06987b refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/commits_search_test.git/refs/heads/master b/tests/gitea-repositories-meta/user2/commits_search_test.git/refs/heads/master
new file mode 100644
index 0000000..81ba7e7
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commits_search_test.git/refs/heads/master
@@ -0,0 +1 @@
+9800fe78cabf4fe774fcf376f97fa2a0ed06987b
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/HEAD b/tests/gitea-repositories-meta/user2/commitsonpr.git/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/config b/tests/gitea-repositories-meta/user2/commitsonpr.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/description b/tests/gitea-repositories-meta/user2/commitsonpr.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/info/exclude b/tests/gitea-repositories-meta/user2/commitsonpr.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/info/refs b/tests/gitea-repositories-meta/user2/commitsonpr.git/info/refs
new file mode 100644
index 0000000..0a1e147
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/info/refs
@@ -0,0 +1,3 @@
+1978192d98bb1b65e11c2cf37da854fbf94bffd6 refs/heads/branch1
+cbff181af4c9c7fee3cf6c106699e07d9a3f54e6 refs/heads/main
+1978192d98bb1b65e11c2cf37da854fbf94bffd6 refs/pull/1/head
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/logs/HEAD b/tests/gitea-repositories-meta/user2/commitsonpr.git/logs/HEAD
new file mode 100644
index 0000000..913799a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/logs/HEAD
@@ -0,0 +1 @@
+0000000000000000000000000000000000000000 cbff181af4c9c7fee3cf6c106699e07d9a3f54e6 Gitea <gitea@fake.local> 1688672318 +0200
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/logs/refs/heads/branch1 b/tests/gitea-repositories-meta/user2/commitsonpr.git/logs/refs/heads/branch1
new file mode 100644
index 0000000..cf96195
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/logs/refs/heads/branch1
@@ -0,0 +1 @@
+0000000000000000000000000000000000000000 1978192d98bb1b65e11c2cf37da854fbf94bffd6 Gitea <gitea@fake.local> 1688672383 +0200 push
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/logs/refs/heads/main b/tests/gitea-repositories-meta/user2/commitsonpr.git/logs/refs/heads/main
new file mode 100644
index 0000000..a503f2f
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/logs/refs/heads/main
@@ -0,0 +1 @@
+0000000000000000000000000000000000000000 cbff181af4c9c7fee3cf6c106699e07d9a3f54e6 root <sauer.sebastian@gmail.com> 1688672317 +0200 push
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/0a/6dda431c72a6a4aac05b98e319972a1a55e01c b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/0a/6dda431c72a6a4aac05b98e319972a1a55e01c
new file mode 100644
index 0000000..f545a47
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/0a/6dda431c72a6a4aac05b98e319972a1a55e01c
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/0c/396a509b64fd4e2e55649d100b86e8b96cc0e5 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/0c/396a509b64fd4e2e55649d100b86e8b96cc0e5
new file mode 100644
index 0000000..73b080e
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/0c/396a509b64fd4e2e55649d100b86e8b96cc0e5
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/10/0ef49565829e7bd83057d2dab88f58b00db831 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/10/0ef49565829e7bd83057d2dab88f58b00db831
new file mode 100644
index 0000000..4e0b7d3
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/10/0ef49565829e7bd83057d2dab88f58b00db831
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/10/9ab1c0b84e088d7edcf018379518b49361f285 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/10/9ab1c0b84e088d7edcf018379518b49361f285
new file mode 100644
index 0000000..1b9636b
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/10/9ab1c0b84e088d7edcf018379518b49361f285
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/19/78192d98bb1b65e11c2cf37da854fbf94bffd6 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/19/78192d98bb1b65e11c2cf37da854fbf94bffd6
new file mode 100644
index 0000000..5a3f7f7
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/19/78192d98bb1b65e11c2cf37da854fbf94bffd6
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/1e/67d753ac1f9097eff26f9d33eb80182344b72c b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/1e/67d753ac1f9097eff26f9d33eb80182344b72c
new file mode 100644
index 0000000..de870a8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/1e/67d753ac1f9097eff26f9d33eb80182344b72c
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/23/576dd018294e476c06e569b6b0f170d0558705 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/23/576dd018294e476c06e569b6b0f170d0558705
new file mode 100644
index 0000000..be4db7c
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/23/576dd018294e476c06e569b6b0f170d0558705
@@ -0,0 +1,2 @@
+x¥ŽA
+Â0E]ç³$™´™D¼ƒ'˜If´`­´éý­ OàêÁƒ÷ùež¦±váÐUˆC©\Q;_ò™…%VÏHÆæDS Ú»7/újPú„ÉJV³žT‚ å$>Ô®zC‹Foí1/pSáµü‚oºÀyýâ´þôõ>ñø<•yº@HÃ#E8zôÞív?Ûöì¯×tmйJÝNê \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/28/16bffda09c0f23775ea4be279de004d28a3803 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/28/16bffda09c0f23775ea4be279de004d28a3803
new file mode 100644
index 0000000..8cc25ec
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/28/16bffda09c0f23775ea4be279de004d28a3803
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/35/f03b5e176ee6d24c86b5cca7009a5b0ba2a026 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/35/f03b5e176ee6d24c86b5cca7009a5b0ba2a026
new file mode 100644
index 0000000..8983aca
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/35/f03b5e176ee6d24c86b5cca7009a5b0ba2a026
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/38/cdad2e40c989aabab3f2d0a27faf0f7be617d5 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/38/cdad2e40c989aabab3f2d0a27faf0f7be617d5
new file mode 100644
index 0000000..c9aa9ae
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/38/cdad2e40c989aabab3f2d0a27faf0f7be617d5
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/3e/64625bd6eb5bcba69ac97de6c8f507402df861 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/3e/64625bd6eb5bcba69ac97de6c8f507402df861
new file mode 100644
index 0000000..2efd367
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/3e/64625bd6eb5bcba69ac97de6c8f507402df861
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/4b/860706d3eec5858324d4ba00db0423ca4cbf50 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/4b/860706d3eec5858324d4ba00db0423ca4cbf50
new file mode 100644
index 0000000..164d71d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/4b/860706d3eec5858324d4ba00db0423ca4cbf50
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/4c/a8bcaf27e28504df7bf996819665986b01c847 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/4c/a8bcaf27e28504df7bf996819665986b01c847
new file mode 100644
index 0000000..e491bd2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/4c/a8bcaf27e28504df7bf996819665986b01c847
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/52/84ca7f5757816e67c098224a8367aa2544222d b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/52/84ca7f5757816e67c098224a8367aa2544222d
new file mode 100644
index 0000000..aef46ae
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/52/84ca7f5757816e67c098224a8367aa2544222d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/53/9a24812705f77484568e6ad7db84764c1903c8 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/53/9a24812705f77484568e6ad7db84764c1903c8
new file mode 100644
index 0000000..f95fa74
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/53/9a24812705f77484568e6ad7db84764c1903c8
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/70/8605e8984e7fb9be58818e0e6d9f21bcefd63e b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/70/8605e8984e7fb9be58818e0e6d9f21bcefd63e
new file mode 100644
index 0000000..ef14818
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/70/8605e8984e7fb9be58818e0e6d9f21bcefd63e
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/74/7ddb3506a4fa04a7747808eb56ae16f9e933dc b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/74/7ddb3506a4fa04a7747808eb56ae16f9e933dc
new file mode 100644
index 0000000..c4bddac
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/74/7ddb3506a4fa04a7747808eb56ae16f9e933dc
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/7e/d1d42eda9110676d5c3a7721965d6ed1afe83c b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/7e/d1d42eda9110676d5c3a7721965d6ed1afe83c
new file mode 100644
index 0000000..1d56d68
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/7e/d1d42eda9110676d5c3a7721965d6ed1afe83c
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/81/1d46c7e518f4f180afb862c0db5cb8c80529ce b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/81/1d46c7e518f4f180afb862c0db5cb8c80529ce
new file mode 100644
index 0000000..684a008
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/81/1d46c7e518f4f180afb862c0db5cb8c80529ce
@@ -0,0 +1,2 @@
+x¥ŽM
+Â0F]ç³Êä§ ˆx‡ž`ÒL´`­´éý OàêƒßãMë²Ì\°§º©‚c;¤R²`œ°8OÔ«„¤ŽbVÄ‹gôæ-›¾*LÔXê)Œ‚q9æÌ>ä>‘ºÂÙ"9êcÝ`Ô${壺ÁeÿN·ÿðí¾Èüì¦u¹‚˜j ÎèM£-¶¶Û_Su¯@æ¼DLâ \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/83/2d33e438d2b4a86fba81cb67b32d1d61a828cb b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/83/2d33e438d2b4a86fba81cb67b32d1d61a828cb
new file mode 100644
index 0000000..33d49b7
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/83/2d33e438d2b4a86fba81cb67b32d1d61a828cb
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/83/7d5c8125633d7d258f93b998e867eab0145520 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/83/7d5c8125633d7d258f93b998e867eab0145520
new file mode 100644
index 0000000..29f1136
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/83/7d5c8125633d7d258f93b998e867eab0145520
@@ -0,0 +1,3 @@
+x¥ŽA
+Â0E]ç³ÊL'MRñ=Á$™hÁZiÓûžÀՇǟŸÖe™+ô–NuS…àSNÄÌe(D^ƾpÒâƒFEF"²‘¡y˦¯
+Þúœ#èÄA+¾‘€­>8QreÔ‘9'#G}¬Le¯³¼`’C7¸ìßèö¾Ý™Ÿ]Z—+ Áùž=Ã{DÓh;[›ö׌©ºWÍȵM \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/87/cdc1333f5f117a92f3cef78ebe0301114b3610 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/87/cdc1333f5f117a92f3cef78ebe0301114b3610
new file mode 100644
index 0000000..6b797fe
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/87/cdc1333f5f117a92f3cef78ebe0301114b3610
@@ -0,0 +1,2 @@
+x+)JMU067`040031QrutñuÕËMa¸–ïšÏ!¼´E~óÓGŠYM…**I-.1Ô+©(axsóï­˜F‡Ðw‰S…îØ%½gS’"#°"ˬ€Ù)ýôBSæ
+p·½Ø™sà)’"c°¢KáS÷ï‘ö°Ì¬köžZ›xÂv¦?’"°¢é<±¯KÕf؇ú­Z¸u"ÓåǾ#)2+2ý`'ž÷ì’OÛÖ3ËfEs/Z †¤È ¬¨Y×ø‰Å¥-+òw5žN߬+›¸Bã4’"s°¢àY*ê¬ßKZÂú²®ßn)ó‘d>¤È¬ˆ+÷–³LѲ%«DÏx,9]K*ô ’"K°"Yìðãè­»óAÂ|ªÄ–ɉŸZvÛ“G \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/92/70b08497106eaa65fce8aa91f37c4780f76909 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/92/70b08497106eaa65fce8aa91f37c4780f76909
new file mode 100644
index 0000000..5e40c5d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/92/70b08497106eaa65fce8aa91f37c4780f76909
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/96/cef4a7b72b3c208340ae6f0cf55a93e9077c93 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/96/cef4a7b72b3c208340ae6f0cf55a93e9077c93
new file mode 100644
index 0000000..155c0c9
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/96/cef4a7b72b3c208340ae6f0cf55a93e9077c93
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/97/0c5deb117526983f554eaaa1b59102d3e3e0f7 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/97/0c5deb117526983f554eaaa1b59102d3e3e0f7
new file mode 100644
index 0000000..09bea18
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/97/0c5deb117526983f554eaaa1b59102d3e3e0f7
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/c5/626fc9eff57eb1bb7b796b01d4d0f2f3f792a2 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/c5/626fc9eff57eb1bb7b796b01d4d0f2f3f792a2
new file mode 100644
index 0000000..4792994
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/c5/626fc9eff57eb1bb7b796b01d4d0f2f3f792a2
@@ -0,0 +1,2 @@
+x¥ŽA
+Â0E]ç³Ê4I3ñ=Á$L´`¬4éý­ OàêÁƒ÷ùy­ué`ýxê›*°%L=ÓˆAEÂT²F‹£ì)b¡ÀÈæ-›¾:pÈZ¼P"›\¶GÑP0—ivÊH”ÙÙûcÝ`Ö$­/ò‚YvÝàÒ¾ÚOßîU–ç×z…1ÄÈ:rpF‹h{œíGö׌éÚ:8ó•ELÇ \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/c7/04db5794097441aa2d9dd834d5b7e2f8f08108 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/c7/04db5794097441aa2d9dd834d5b7e2f8f08108
new file mode 100644
index 0000000..2dfb6fd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/c7/04db5794097441aa2d9dd834d5b7e2f8f08108
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/cb/ff181af4c9c7fee3cf6c106699e07d9a3f54e6 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/cb/ff181af4c9c7fee3cf6c106699e07d9a3f54e6
new file mode 100644
index 0000000..47d3fac
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/cb/ff181af4c9c7fee3cf6c106699e07d9a3f54e6
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d1/8e427f4011e74e96a31823c938be26eebab53b b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d1/8e427f4011e74e96a31823c938be26eebab53b
new file mode 100644
index 0000000..fe9758a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d1/8e427f4011e74e96a31823c938be26eebab53b
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d2/5795e38fbc1b4839697e834b957d61c83d994f b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d2/5795e38fbc1b4839697e834b957d61c83d994f
new file mode 100644
index 0000000..76878a9
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d2/5795e38fbc1b4839697e834b957d61c83d994f
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d6/6f456f0813a5841fbc03e5f1c47304dc675695 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d6/6f456f0813a5841fbc03e5f1c47304dc675695
new file mode 100644
index 0000000..6e8ce4c
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/d6/6f456f0813a5841fbc03e5f1c47304dc675695
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/e1/7e0fa20f3d2125916f2fb2f51f19240678cb83 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/e1/7e0fa20f3d2125916f2fb2f51f19240678cb83
new file mode 100644
index 0000000..fbaa98d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/e1/7e0fa20f3d2125916f2fb2f51f19240678cb83
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/ec/d9fdda5c814055ee619513e1c388ba1bbcb280 b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/ec/d9fdda5c814055ee619513e1c388ba1bbcb280
new file mode 100644
index 0000000..9d5b06f
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/ec/d9fdda5c814055ee619513e1c388ba1bbcb280
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/info/packs b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/info/packs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/objects/info/packs
@@ -0,0 +1 @@
+
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/branch1 b/tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/branch1
new file mode 100644
index 0000000..357fc9d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/branch1
@@ -0,0 +1 @@
+1978192d98bb1b65e11c2cf37da854fbf94bffd6
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/main b/tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/main
new file mode 100644
index 0000000..596912b
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/main
@@ -0,0 +1 @@
+cbff181af4c9c7fee3cf6c106699e07d9a3f54e6
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/master b/tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/master
new file mode 100644
index 0000000..596912b
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/refs/heads/master
@@ -0,0 +1 @@
+cbff181af4c9c7fee3cf6c106699e07d9a3f54e6
diff --git a/tests/gitea-repositories-meta/user2/commitsonpr.git/refs/pull/1/head b/tests/gitea-repositories-meta/user2/commitsonpr.git/refs/pull/1/head
new file mode 100644
index 0000000..357fc9d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/commitsonpr.git/refs/pull/1/head
@@ -0,0 +1 @@
+1978192d98bb1b65e11c2cf37da854fbf94bffd6
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/HEAD b/tests/gitea-repositories-meta/user2/git_hooks_test.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/config b/tests/gitea-repositories-meta/user2/git_hooks_test.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/description b/tests/gitea-repositories-meta/user2/git_hooks_test.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/post-receive b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/post-receive
new file mode 100755
index 0000000..4b3d452
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/post-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/post-receive.d"`; do
+ sh "$SHELL_FOLDER/post-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/post-receive.d/gitea b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/post-receive.d/gitea
new file mode 100755
index 0000000..43a948d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/post-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" post-receive
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive
new file mode 100755
index 0000000..4127013
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/pre-receive.d"`; do
+ sh "$SHELL_FOLDER/pre-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive.d/gitea b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive.d/gitea
new file mode 100755
index 0000000..49d0940
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" pre-receive
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive.d/pre-receive b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive.d/pre-receive
new file mode 100755
index 0000000..b26a3b9
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/pre-receive.d/pre-receive
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+echo Hello, World!
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/update b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/update
new file mode 100755
index 0000000..c186fe4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/update
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/update.d"`; do
+ sh "$SHELL_FOLDER/update.d/$i" $1 $2 $3
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/update.d/gitea b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/update.d/gitea
new file mode 100755
index 0000000..38101c2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/hooks/update.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" update $1 $2 $3
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/info/exclude b/tests/gitea-repositories-meta/user2/git_hooks_test.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/info/refs b/tests/gitea-repositories-meta/user2/git_hooks_test.git/info/refs
new file mode 100644
index 0000000..ca1df85
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/info/refs
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6 b/tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6
new file mode 100644
index 0000000..0994add
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349f b/tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349f
new file mode 100644
index 0000000..700a138
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349f
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4d b/tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4d
new file mode 100644
index 0000000..de48ba7
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/info/packs b/tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/info/packs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/objects/info/packs
@@ -0,0 +1 @@
+
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/DefaultBranch b/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/DefaultBranch
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/DefaultBranch
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/develop b/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/develop
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/develop
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/feature/1 b/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/feature/1
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/feature/1
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/master b/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/master
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/heads/master
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/tags/v1.1 b/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/tags/v1.1
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/git_hooks_test.git/refs/tags/v1.1
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user2/glob.git/HEAD b/tests/gitea-repositories-meta/user2/glob.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/glob.git/config b/tests/gitea-repositories-meta/user2/glob.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/glob.git/description b/tests/gitea-repositories-meta/user2/glob.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/glob.git/info/exclude b/tests/gitea-repositories-meta/user2/glob.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/glob.git/objects/48/06cb9df135782b818c968c2fadbd2c150d23d6 b/tests/gitea-repositories-meta/user2/glob.git/objects/48/06cb9df135782b818c968c2fadbd2c150d23d6
new file mode 100644
index 0000000..a393a43
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/objects/48/06cb9df135782b818c968c2fadbd2c150d23d6
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/glob.git/objects/59/fee614e09d1f1cd1e15e4b2a7e9c8873a81498 b/tests/gitea-repositories-meta/user2/glob.git/objects/59/fee614e09d1f1cd1e15e4b2a7e9c8873a81498
new file mode 100644
index 0000000..a55c8cc
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/objects/59/fee614e09d1f1cd1e15e4b2a7e9c8873a81498
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/glob.git/objects/7c/8ac2f8d82a1eb5f6aaece6629ff11015f91eb4 b/tests/gitea-repositories-meta/user2/glob.git/objects/7c/8ac2f8d82a1eb5f6aaece6629ff11015f91eb4
new file mode 100644
index 0000000..d5176e6
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/objects/7c/8ac2f8d82a1eb5f6aaece6629ff11015f91eb4
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/glob.git/objects/8e/592e636d27ac144f92f7fe8c33631cbdea594d b/tests/gitea-repositories-meta/user2/glob.git/objects/8e/592e636d27ac144f92f7fe8c33631cbdea594d
new file mode 100644
index 0000000..8034110
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/objects/8e/592e636d27ac144f92f7fe8c33631cbdea594d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/glob.git/objects/95/aff026f99a9ab76fbd01decb63dd3dbc03e498 b/tests/gitea-repositories-meta/user2/glob.git/objects/95/aff026f99a9ab76fbd01decb63dd3dbc03e498
new file mode 100644
index 0000000..0883f2b
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/objects/95/aff026f99a9ab76fbd01decb63dd3dbc03e498
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/glob.git/objects/ae/d1ffed24cc3cf9b80490795e893cae4bddd684 b/tests/gitea-repositories-meta/user2/glob.git/objects/ae/d1ffed24cc3cf9b80490795e893cae4bddd684
new file mode 100644
index 0000000..03fa05d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/objects/ae/d1ffed24cc3cf9b80490795e893cae4bddd684
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/glob.git/objects/bf/d6a6583f9a9ac59bd726c1df26c64a89427ede b/tests/gitea-repositories-meta/user2/glob.git/objects/bf/d6a6583f9a9ac59bd726c1df26c64a89427ede
new file mode 100644
index 0000000..9475433
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/objects/bf/d6a6583f9a9ac59bd726c1df26c64a89427ede
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/glob.git/objects/c8/eb3b6c767ccb68411d0a1f6c769be69fb4d95a b/tests/gitea-repositories-meta/user2/glob.git/objects/c8/eb3b6c767ccb68411d0a1f6c769be69fb4d95a
new file mode 100644
index 0000000..2b6297f
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/objects/c8/eb3b6c767ccb68411d0a1f6c769be69fb4d95a
@@ -0,0 +1 @@
+x+)JMU03d040031QHÒ+©(a¨é:ô㆖ÜÖo«Þ<KšÿQ@ô§Ü P¨dè‹ÔKÎU_#â?éû¿ãd™½¯"}•‡ \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/glob.git/objects/de/6be43fe8eb19ca3f4e934cb8b9a9a0b20fe865 b/tests/gitea-repositories-meta/user2/glob.git/objects/de/6be43fe8eb19ca3f4e934cb8b9a9a0b20fe865
new file mode 100644
index 0000000..ece04b3
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/objects/de/6be43fe8eb19ca3f4e934cb8b9a9a0b20fe865
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/glob.git/objects/ef/6b814b610d8e7717aa0f71fbe5842bcf814697 b/tests/gitea-repositories-meta/user2/glob.git/objects/ef/6b814b610d8e7717aa0f71fbe5842bcf814697
new file mode 100644
index 0000000..264cf5a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/objects/ef/6b814b610d8e7717aa0f71fbe5842bcf814697
@@ -0,0 +1,2 @@
+x­Î9Â0@QjŸb.@ä8“MB*Z®àxÆÁÂŽ#/·'â ´¯øú&†à
+(ÕžJbÍÔZˤИÎØy™$Îrœ{žæÎhÆ…ˆ† …®å<ªóžSˆðLz#—õ'»zßjæ”›-&Þý§Y]yÕ¥11\¡í‡q@D…p–”âÐc£ðß‚âN…së<gñ¿ŽK© \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/glob.git/refs/heads/master b/tests/gitea-repositories-meta/user2/glob.git/refs/heads/master
new file mode 100644
index 0000000..bca1628
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/glob.git/refs/heads/master
@@ -0,0 +1 @@
+ef6b814b610d8e7717aa0f71fbe5842bcf814697
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/HEAD b/tests/gitea-repositories-meta/user2/lfs.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/config b/tests/gitea-repositories-meta/user2/lfs.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/objects/15/2de0f78bc6815b58cd9f08aebe3f66fb0f172e b/tests/gitea-repositories-meta/user2/lfs.git/objects/15/2de0f78bc6815b58cd9f08aebe3f66fb0f172e
new file mode 100644
index 0000000..d665777
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/objects/15/2de0f78bc6815b58cd9f08aebe3f66fb0f172e
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/objects/23/10e4a07f9314a1a92fdfbdcd3d2884f01e96ab b/tests/gitea-repositories-meta/user2/lfs.git/objects/23/10e4a07f9314a1a92fdfbdcd3d2884f01e96ab
new file mode 100644
index 0000000..16a791a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/objects/23/10e4a07f9314a1a92fdfbdcd3d2884f01e96ab
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/objects/2b/6c6c4eaefa24b22f2092c3d54b263ff26feb58 b/tests/gitea-repositories-meta/user2/lfs.git/objects/2b/6c6c4eaefa24b22f2092c3d54b263ff26feb58
new file mode 100644
index 0000000..d8d55b1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/objects/2b/6c6c4eaefa24b22f2092c3d54b263ff26feb58
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/objects/30/77e1c4c8964613df72c37d14275c1eda5228a9 b/tests/gitea-repositories-meta/user2/lfs.git/objects/30/77e1c4c8964613df72c37d14275c1eda5228a9
new file mode 100644
index 0000000..c2dc6e5
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/objects/30/77e1c4c8964613df72c37d14275c1eda5228a9
@@ -0,0 +1,2 @@
+xKÊÉOR0´0`pö÷ òt
+ ñôs×ËMQHËÌ)I-²ÍI+VHÉLK3rS‹ÒSÁ,Ý’ÔŠ.-½¬‚t"U&eæ¥23¯,1'“8ûØæAÅ \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/objects/6b/bc79965141058b0026f2064dfb6d2eae3c4540 b/tests/gitea-repositories-meta/user2/lfs.git/objects/6b/bc79965141058b0026f2064dfb6d2eae3c4540
new file mode 100644
index 0000000..97455cb
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/objects/6b/bc79965141058b0026f2064dfb6d2eae3c4540
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/objects/73/cf03db6ece34e12bf91e8853dc58f678f2f82d b/tests/gitea-repositories-meta/user2/lfs.git/objects/73/cf03db6ece34e12bf91e8853dc58f678f2f82d
new file mode 100644
index 0000000..5eee31d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/objects/73/cf03db6ece34e12bf91e8853dc58f678f2f82d
@@ -0,0 +1,2 @@
+x•Q
+B!Eûv³bÆ—£AD¿ý×Ô7’ðÌ0µü„VÐßá¹'ÖRr²´éMÈèY0Y";2Á¸8:/A¦Äœ&²Z”_û½6¸½¤Áõ]á¸Ògùøò\dk9±%æi¶hÕXG­å?O]¹g¿Àï@}97 \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/objects/74/21a018a7e3f15ee5691f162d0ed87dc19882f0 b/tests/gitea-repositories-meta/user2/lfs.git/objects/74/21a018a7e3f15ee5691f162d0ed87dc19882f0
new file mode 100644
index 0000000..cbbde81
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/objects/74/21a018a7e3f15ee5691f162d0ed87dc19882f0
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/objects/82/76d2a29779af982c0afa976bdb793b52d442a8 b/tests/gitea-repositories-meta/user2/lfs.git/objects/82/76d2a29779af982c0afa976bdb793b52d442a8
new file mode 100644
index 0000000..cbee9fb
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/objects/82/76d2a29779af982c0afa976bdb793b52d442a8
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/objects/b0/89e97ee59224e8c5676673c096ee4b6a8b9342 b/tests/gitea-repositories-meta/user2/lfs.git/objects/b0/89e97ee59224e8c5676673c096ee4b6a8b9342
new file mode 100644
index 0000000..33ab64e
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/objects/b0/89e97ee59224e8c5676673c096ee4b6a8b9342
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/objects/bc/e50ea8f203ee923d5a640d05208abf3206486e b/tests/gitea-repositories-meta/user2/lfs.git/objects/bc/e50ea8f203ee923d5a640d05208abf3206486e
new file mode 100644
index 0000000..83e8159
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/objects/bc/e50ea8f203ee923d5a640d05208abf3206486e
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/objects/d4/a41a0d4db4949e129bd22f871171ea988103ef b/tests/gitea-repositories-meta/user2/lfs.git/objects/d4/a41a0d4db4949e129bd22f871171ea988103ef
new file mode 100644
index 0000000..01f6e7b
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/objects/d4/a41a0d4db4949e129bd22f871171ea988103ef
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/objects/d7/ce0013ced38b0696dd2d68d69a5d8b652f7148 b/tests/gitea-repositories-meta/user2/lfs.git/objects/d7/ce0013ced38b0696dd2d68d69a5d8b652f7148
new file mode 100644
index 0000000..53d1fe2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/objects/d7/ce0013ced38b0696dd2d68d69a5d8b652f7148
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/objects/df/d8105b264d304c49ed9f1d56bd90189ecdf833 b/tests/gitea-repositories-meta/user2/lfs.git/objects/df/d8105b264d304c49ed9f1d56bd90189ecdf833
new file mode 100644
index 0000000..11940b8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/objects/df/d8105b264d304c49ed9f1d56bd90189ecdf833
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/objects/e9/c32647bab825977942598c0efa415de300304b b/tests/gitea-repositories-meta/user2/lfs.git/objects/e9/c32647bab825977942598c0efa415de300304b
new file mode 100644
index 0000000..f513e2a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/objects/e9/c32647bab825977942598c0efa415de300304b
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/lfs.git/refs/heads/master b/tests/gitea-repositories-meta/user2/lfs.git/refs/heads/master
new file mode 100644
index 0000000..487a433
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/lfs.git/refs/heads/master
@@ -0,0 +1 @@
+e9c32647bab825977942598c0efa415de300304b
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/HEAD b/tests/gitea-repositories-meta/user2/readme-test.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/config b/tests/gitea-repositories-meta/user2/readme-test.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/info/exclude b/tests/gitea-repositories-meta/user2/readme-test.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/info/refs b/tests/gitea-repositories-meta/user2/readme-test.git/info/refs
new file mode 100644
index 0000000..fd5f1b9
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/info/refs
@@ -0,0 +1,21 @@
+ea9ef877d1d88af76682d8798418081264f10cfc refs/heads/fallbacks
+0d4c14db927c9ffba01fa7e126cc748b5c02c01e refs/heads/fallbacks2
+c66d5b07c2063d3268707f22226c708b589574ef refs/heads/fallbacks3
+89f8426e9eb5eff35c09b3565836c8f8e15d0ce9 refs/heads/fallbacks4
+b0e902496eae435ad03c92a5d479f916ef2d4893 refs/heads/fallbacks5
+84a5500b5cc040b11daf53fc42c542a99589dc76 refs/heads/fallbacks6
+cf406a96e416d7de5c4c1bbfffdd672300c822bf refs/heads/fallbacks7
+0d6ac644b969e9199915a492da9dba08c179fd23 refs/heads/fallbacks8
+5038febc0c57215beb3748d7ae4091a25a4acc93 refs/heads/fallbacks9
+9134e1f178ca4cccf1a197142646f2d7627e8cd5 refs/heads/i18n
+744d2441e55bc0010d6b340d303f0106a627ad29 refs/heads/master
+3c492566170b057e962c025515ab38bbd7444077 refs/heads/plain
+3882d6373a0882a6739b3cd9b24d21c630621234 refs/heads/sp-ace
+bf5ed898252eaa50dcc01108ed4417c3ea98a294 refs/heads/special-subdir-.gitea
+c03543573ab088ce1cf7090a387d2be621426234 refs/heads/special-subdir-.github
+e75957ad9b7e6ed16dda183529ec283db0bbc5fe refs/heads/special-subdir-docs
+46f5d5ab33d701642e08c713fab42af89fdd4fea refs/heads/special-subdir-nested
+9c0f872256b839c2b97ec22fd348d87b14045513 refs/heads/subdir
+d7a854fff61e45b98234d7aa79ecbcb1619cd3dd refs/heads/symlink
+30b9c0ed4b1039dbd99f3fb537b84ca507e0549d refs/heads/symlink-loop
+41489b7be5c2244d2b7b524dcb31caf3bd1f9ccc refs/heads/txt
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/12/11481f7314efbfe4e44703170d96c8fac8172b b/tests/gitea-repositories-meta/user2/readme-test.git/objects/12/11481f7314efbfe4e44703170d96c8fac8172b
new file mode 100644
index 0000000..b9009e3
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/12/11481f7314efbfe4e44703170d96c8fac8172b
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/17/2343566bf11fc71ba4acf8d2ea70d12bc1d037 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/17/2343566bf11fc71ba4acf8d2ea70d12bc1d037
new file mode 100644
index 0000000..c7a4dec
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/17/2343566bf11fc71ba4acf8d2ea70d12bc1d037
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/1a/48cae3f18ccd9c929e6608f67087dbaac3cf9e b/tests/gitea-repositories-meta/user2/readme-test.git/objects/1a/48cae3f18ccd9c929e6608f67087dbaac3cf9e
new file mode 100644
index 0000000..d8522ae
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/1a/48cae3f18ccd9c929e6608f67087dbaac3cf9e
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/1e/1e08102cf1b1fc01c069c88ee75445974363ab b/tests/gitea-repositories-meta/user2/readme-test.git/objects/1e/1e08102cf1b1fc01c069c88ee75445974363ab
new file mode 100644
index 0000000..d4152e5
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/1e/1e08102cf1b1fc01c069c88ee75445974363ab
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/21/470f9b3e8ff24e0fa083d2dbc447f4c3401355 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/21/470f9b3e8ff24e0fa083d2dbc447f4c3401355
new file mode 100644
index 0000000..8aabb78
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/21/470f9b3e8ff24e0fa083d2dbc447f4c3401355
@@ -0,0 +1,2 @@
+xŽ;Â0 @™s
+_€*NÒÄH±±1q×u(¢?•tàöTˆ0¾á==™†áQÀ·+‹*4¤Ìd¹¥h“SÌη.z¢à°¢Í™Z3ó¢ctˆ0'As“5hÉzL¶=D¡ÌB˜\cx-Ý´Àõ!O¸¬›ÚéÇqÃêþÃó<õï¡ô•ð 0¦T×装½­­5ò=-›õÃÜôU sß7,Oó#ÆMÜ \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/23/65bfe0c5714e2e3f2d53bb302b10d8d5b4fc7d b/tests/gitea-repositories-meta/user2/readme-test.git/objects/23/65bfe0c5714e2e3f2d53bb302b10d8d5b4fc7d
new file mode 100644
index 0000000..7159776
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/23/65bfe0c5714e2e3f2d53bb302b10d8d5b4fc7d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/38/9d08c6a71d024a91f14089007cd789cd977ca6 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/38/9d08c6a71d024a91f14089007cd789cd977ca6
new file mode 100644
index 0000000..c5929ae
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/38/9d08c6a71d024a91f14089007cd789cd977ca6
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/3a/a8f4e0e1a535f0f9e0ae40e6ec1bce42642bc4 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/3a/a8f4e0e1a535f0f9e0ae40e6ec1bce42642bc4
new file mode 100644
index 0000000..106393d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/3a/a8f4e0e1a535f0f9e0ae40e6ec1bce42642bc4
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/3b/23d7f1a9cb904cb46f5f2272bfa5ed5f871fb9 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/3b/23d7f1a9cb904cb46f5f2272bfa5ed5f871fb9
new file mode 100644
index 0000000..c36705b
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/3b/23d7f1a9cb904cb46f5f2272bfa5ed5f871fb9
@@ -0,0 +1 @@
+xÎM †aל‚ Ø 0&ƸsçÊ ئô'•.¼½Õx—ïâùòñ4 ]•õ®.)ÉDÖñQÅ|@b6Xbdƒì}Ö2+b¦%Tè ƒI>g 27QÇÀˆ. (c­ µ¶Ó"o÷òºn´M‹<[6_^橼†Z¦³Tç¬Uƹ øû´nêÿ qOÏ*3•ˆ{ñ™²N\ \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/50/6ff7310f420e878595b4bc8f11688e3f0ae14e b/tests/gitea-repositories-meta/user2/readme-test.git/objects/50/6ff7310f420e878595b4bc8f11688e3f0ae14e
new file mode 100644
index 0000000..a7f4501
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/50/6ff7310f420e878595b4bc8f11688e3f0ae14e
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/58/3eb775c596858380273492759d39081d65d029 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/58/3eb775c596858380273492759d39081d65d029
new file mode 100644
index 0000000..a2dadac
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/58/3eb775c596858380273492759d39081d65d029
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/60/ea618ae7d4ecbe9c1962591c7da1b05bb1a5c8 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/60/ea618ae7d4ecbe9c1962591c7da1b05bb1a5c8
new file mode 100644
index 0000000..4367c41
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/60/ea618ae7d4ecbe9c1962591c7da1b05bb1a5c8
@@ -0,0 +1,3 @@
+xŽ;Â0 @™s
+_€*N뤕bccâŽëЊþTÒÛS!NÀø†÷ôdÇ>ƒ«Ü!¯ª­Ö„Lu­UlÙ#qô¡´”líQÅ,¼ê”¡Œ®lCBn$6¶’XùDɹàbbÒ–R0ÅÆð–»y…[/O¸n»Úé
+§iÇâñÃË2ï1…ðЇ@„ep´d­‘ïiÞ­ÿ殯 ‰‡!²<Í™ÜN— \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/6a/b05db4c52530726c1856eb558228e9d1949e7f b/tests/gitea-repositories-meta/user2/readme-test.git/objects/6a/b05db4c52530726c1856eb558228e9d1949e7f
new file mode 100644
index 0000000..3e4c824
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/6a/b05db4c52530726c1856eb558228e9d1949e7f
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/71/60a063b5544b5a78131b94f47bfd200046eda2 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/71/60a063b5544b5a78131b94f47bfd200046eda2
new file mode 100644
index 0000000..477d5b1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/71/60a063b5544b5a78131b94f47bfd200046eda2
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/75/6c70c97047d8aeb11ca3c71edd9fb76cefee9c b/tests/gitea-repositories-meta/user2/readme-test.git/objects/75/6c70c97047d8aeb11ca3c71edd9fb76cefee9c
new file mode 100644
index 0000000..3ef1796
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/75/6c70c97047d8aeb11ca3c71edd9fb76cefee9c
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/7f/2b9f991d99362eb827b67f4ae2f5fbc5fa2271 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/7f/2b9f991d99362eb827b67f4ae2f5fbc5fa2271
new file mode 100644
index 0000000..4e39c03
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/7f/2b9f991d99362eb827b67f4ae2f5fbc5fa2271
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/7f/792e709143fb0f021da2371e5f40d1bcc284fd b/tests/gitea-repositories-meta/user2/readme-test.git/objects/7f/792e709143fb0f021da2371e5f40d1bcc284fd
new file mode 100644
index 0000000..90fae23
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/7f/792e709143fb0f021da2371e5f40d1bcc284fd
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/82/817856dadc7f6b944633e1b77d5b6e302dde06 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/82/817856dadc7f6b944633e1b77d5b6e302dde06
new file mode 100644
index 0000000..0428af4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/82/817856dadc7f6b944633e1b77d5b6e302dde06
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/8b/4149e7dede3cd53ba11c64c88b057c5fe2c200 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/8b/4149e7dede3cd53ba11c64c88b057c5fe2c200
new file mode 100644
index 0000000..64542d3
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/8b/4149e7dede3cd53ba11c64c88b057c5fe2c200
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/93/54813d81053c14afe878a9f056b937ec42bb48 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/93/54813d81053c14afe878a9f056b937ec42bb48
new file mode 100644
index 0000000..8542b45
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/93/54813d81053c14afe878a9f056b937ec42bb48
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/9c/72c10e55e7d6ea21f591aa424e2625e8ad8094 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/9c/72c10e55e7d6ea21f591aa424e2625e8ad8094
new file mode 100644
index 0000000..b53d42a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/9c/72c10e55e7d6ea21f591aa424e2625e8ad8094
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/a3/cd04bb110e17591ac04e156c7df2c2f5c96fa6 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/a3/cd04bb110e17591ac04e156c7df2c2f5c96fa6
new file mode 100644
index 0000000..6d9bc2a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/a3/cd04bb110e17591ac04e156c7df2c2f5c96fa6
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/b0/e851a5619e2d6cee1da25a15ab67305f0861ec b/tests/gitea-repositories-meta/user2/readme-test.git/objects/b0/e851a5619e2d6cee1da25a15ab67305f0861ec
new file mode 100644
index 0000000..2f88dbd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/b0/e851a5619e2d6cee1da25a15ab67305f0861ec
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/b4/4c8eb00bdaf0522de61e591fee5f66851ef4b5 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/b4/4c8eb00bdaf0522de61e591fee5f66851ef4b5
new file mode 100644
index 0000000..55bb849
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/b4/4c8eb00bdaf0522de61e591fee5f66851ef4b5
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/b8/eaa80ad86072e1f23d2638842154ce9aceff8d b/tests/gitea-repositories-meta/user2/readme-test.git/objects/b8/eaa80ad86072e1f23d2638842154ce9aceff8d
new file mode 100644
index 0000000..84ab568
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/b8/eaa80ad86072e1f23d2638842154ce9aceff8d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/d5/34f914944c3c943a6bdb677d869ac54934928d b/tests/gitea-repositories-meta/user2/readme-test.git/objects/d5/34f914944c3c943a6bdb677d869ac54934928d
new file mode 100644
index 0000000..7bb4344
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/d5/34f914944c3c943a6bdb677d869ac54934928d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/e2/f9904cd97b4045feecfffef5a426e9461bee70 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/e2/f9904cd97b4045feecfffef5a426e9461bee70
new file mode 100644
index 0000000..9654f87
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/e2/f9904cd97b4045feecfffef5a426e9461bee70
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/e3/a6fd8fe49e323ee10017f72b777a53fbd8076f b/tests/gitea-repositories-meta/user2/readme-test.git/objects/e3/a6fd8fe49e323ee10017f72b777a53fbd8076f
new file mode 100644
index 0000000..2307ba8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/e3/a6fd8fe49e323ee10017f72b777a53fbd8076f
@@ -0,0 +1,3 @@
+xÎM
+1 †a×=E. ¤mšŽ âÎwh›¨ƒó#µ"ÞÞA<Ëgñ~|eǾ#»jU:×ÙØ–$%ž9o‰Ø{µ9F ™Õ£QdsOU§‘H‘ÕrA´(œ=¡xôçEœØÅ$nkÒ³]ç
+§¾Üàø\Ò«VØM 7—÷yxmØ”´Ë1ê-¬1 šò}Ú–êÿ 3Ì/­%=úf>&L¨ \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/e7/bf02fcfa7a86f7fe9e8158b55d58ddf9d877ec b/tests/gitea-repositories-meta/user2/readme-test.git/objects/e7/bf02fcfa7a86f7fe9e8158b55d58ddf9d877ec
new file mode 100644
index 0000000..9f898ef
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/e7/bf02fcfa7a86f7fe9e8158b55d58ddf9d877ec
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/ea/57c91ddb8b4ac705b5ac4c34c7a48f2d0fc180 b/tests/gitea-repositories-meta/user2/readme-test.git/objects/ea/57c91ddb8b4ac705b5ac4c34c7a48f2d0fc180
new file mode 100644
index 0000000..d1eff52
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/ea/57c91ddb8b4ac705b5ac4c34c7a48f2d0fc180
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/fe/495ea336f079ef2bed68648d0ba9a37cdbd4aa b/tests/gitea-repositories-meta/user2/readme-test.git/objects/fe/495ea336f079ef2bed68648d0ba9a37cdbd4aa
new file mode 100644
index 0000000..48c0b5a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/fe/495ea336f079ef2bed68648d0ba9a37cdbd4aa
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/info/commit-graph b/tests/gitea-repositories-meta/user2/readme-test.git/objects/info/commit-graph
new file mode 100644
index 0000000..9bb0976
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/info/commit-graph
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/info/packs b/tests/gitea-repositories-meta/user2/readme-test.git/objects/info/packs
new file mode 100644
index 0000000..aad1086
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/info/packs
@@ -0,0 +1,2 @@
+P pack-8933bd634b76f8154310cccb52537a0195e43166.pack
+
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/pack/pack-8933bd634b76f8154310cccb52537a0195e43166.bitmap b/tests/gitea-repositories-meta/user2/readme-test.git/objects/pack/pack-8933bd634b76f8154310cccb52537a0195e43166.bitmap
new file mode 100644
index 0000000..db39955
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/pack/pack-8933bd634b76f8154310cccb52537a0195e43166.bitmap
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/pack/pack-8933bd634b76f8154310cccb52537a0195e43166.idx b/tests/gitea-repositories-meta/user2/readme-test.git/objects/pack/pack-8933bd634b76f8154310cccb52537a0195e43166.idx
new file mode 100644
index 0000000..561e0f2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/pack/pack-8933bd634b76f8154310cccb52537a0195e43166.idx
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/objects/pack/pack-8933bd634b76f8154310cccb52537a0195e43166.pack b/tests/gitea-repositories-meta/user2/readme-test.git/objects/pack/pack-8933bd634b76f8154310cccb52537a0195e43166.pack
new file mode 100644
index 0000000..6f5bf1f
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/objects/pack/pack-8933bd634b76f8154310cccb52537a0195e43166.pack
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/packed-refs b/tests/gitea-repositories-meta/user2/readme-test.git/packed-refs
new file mode 100644
index 0000000..2399a80
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/packed-refs
@@ -0,0 +1,22 @@
+# pack-refs with: peeled fully-peeled sorted
+ea9ef877d1d88af76682d8798418081264f10cfc refs/heads/fallbacks
+0d4c14db927c9ffba01fa7e126cc748b5c02c01e refs/heads/fallbacks2
+c66d5b07c2063d3268707f22226c708b589574ef refs/heads/fallbacks3
+89f8426e9eb5eff35c09b3565836c8f8e15d0ce9 refs/heads/fallbacks4
+b0e902496eae435ad03c92a5d479f916ef2d4893 refs/heads/fallbacks5
+84a5500b5cc040b11daf53fc42c542a99589dc76 refs/heads/fallbacks6
+cf406a96e416d7de5c4c1bbfffdd672300c822bf refs/heads/fallbacks7
+0d6ac644b969e9199915a492da9dba08c179fd23 refs/heads/fallbacks8
+5038febc0c57215beb3748d7ae4091a25a4acc93 refs/heads/fallbacks9
+9134e1f178ca4cccf1a197142646f2d7627e8cd5 refs/heads/i18n
+744d2441e55bc0010d6b340d303f0106a627ad29 refs/heads/master
+3c492566170b057e962c025515ab38bbd7444077 refs/heads/plain
+3882d6373a0882a6739b3cd9b24d21c630621234 refs/heads/sp-ace
+bf5ed898252eaa50dcc01108ed4417c3ea98a294 refs/heads/special-subdir-.gitea
+c03543573ab088ce1cf7090a387d2be621426234 refs/heads/special-subdir-.github
+e75957ad9b7e6ed16dda183529ec283db0bbc5fe refs/heads/special-subdir-docs
+46f5d5ab33d701642e08c713fab42af89fdd4fea refs/heads/special-subdir-nested
+9c0f872256b839c2b97ec22fd348d87b14045513 refs/heads/subdir
+d7a854fff61e45b98234d7aa79ecbcb1619cd3dd refs/heads/symlink
+30b9c0ed4b1039dbd99f3fb537b84ca507e0549d refs/heads/symlink-loop
+41489b7be5c2244d2b7b524dcb31caf3bd1f9ccc refs/heads/txt
diff --git a/tests/gitea-repositories-meta/user2/readme-test.git/refs/heads/fallbacks-broken-symlinks b/tests/gitea-repositories-meta/user2/readme-test.git/refs/heads/fallbacks-broken-symlinks
new file mode 100644
index 0000000..cf36865
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/readme-test.git/refs/heads/fallbacks-broken-symlinks
@@ -0,0 +1 @@
+fe495ea336f079ef2bed68648d0ba9a37cdbd4aa
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/HEAD b/tests/gitea-repositories-meta/user2/repo-release.git/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/config b/tests/gitea-repositories-meta/user2/repo-release.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/description b/tests/gitea-repositories-meta/user2/repo-release.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/info/exclude b/tests/gitea-repositories-meta/user2/repo-release.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/08/9ba8b2f324d89b74f6853374a0476b312a46f6 b/tests/gitea-repositories-meta/user2/repo-release.git/objects/08/9ba8b2f324d89b74f6853374a0476b312a46f6
new file mode 100644
index 0000000..20e0ea8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/08/9ba8b2f324d89b74f6853374a0476b312a46f6
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/18/4288e5acffbcb17160b990e8efe83b12dfaaba b/tests/gitea-repositories-meta/user2/repo-release.git/objects/18/4288e5acffbcb17160b990e8efe83b12dfaaba
new file mode 100644
index 0000000..7c12e34
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/18/4288e5acffbcb17160b990e8efe83b12dfaaba
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/24/3cdd85d09ce4104855edf219c05b74c65350fc b/tests/gitea-repositories-meta/user2/repo-release.git/objects/24/3cdd85d09ce4104855edf219c05b74c65350fc
new file mode 100644
index 0000000..c4552ee
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/24/3cdd85d09ce4104855edf219c05b74c65350fc
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/43/80f99290b2b3922733ff82c57afad915ace907 b/tests/gitea-repositories-meta/user2/repo-release.git/objects/43/80f99290b2b3922733ff82c57afad915ace907
new file mode 100644
index 0000000..48690be
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/43/80f99290b2b3922733ff82c57afad915ace907
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/6d/fe48a18ce2fb47d3a75e13c7ab35f935077535 b/tests/gitea-repositories-meta/user2/repo-release.git/objects/6d/fe48a18ce2fb47d3a75e13c7ab35f935077535
new file mode 100644
index 0000000..5343706
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/6d/fe48a18ce2fb47d3a75e13c7ab35f935077535
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/71/97b56fdc75b453f47c9110938cb46a303579fd b/tests/gitea-repositories-meta/user2/repo-release.git/objects/71/97b56fdc75b453f47c9110938cb46a303579fd
new file mode 100644
index 0000000..f62b08d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/71/97b56fdc75b453f47c9110938cb46a303579fd
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/79/f9d88f1b054d650f88da0bd658e21f7b0cf6ec b/tests/gitea-repositories-meta/user2/repo-release.git/objects/79/f9d88f1b054d650f88da0bd658e21f7b0cf6ec
new file mode 100644
index 0000000..e1fd864
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/79/f9d88f1b054d650f88da0bd658e21f7b0cf6ec
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/7c/055ef1678b03b831bbe7b9ca5aed33b1a8dea0 b/tests/gitea-repositories-meta/user2/repo-release.git/objects/7c/055ef1678b03b831bbe7b9ca5aed33b1a8dea0
new file mode 100644
index 0000000..5ed6e57
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/7c/055ef1678b03b831bbe7b9ca5aed33b1a8dea0
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/80/abeef37c96b85b83a916f5f295f04f4d380a42 b/tests/gitea-repositories-meta/user2/repo-release.git/objects/80/abeef37c96b85b83a916f5f295f04f4d380a42
new file mode 100644
index 0000000..f1c547f
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/80/abeef37c96b85b83a916f5f295f04f4d380a42
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/a8/a700e8c644c783ba2c6e742bb81bf91e244bff b/tests/gitea-repositories-meta/user2/repo-release.git/objects/a8/a700e8c644c783ba2c6e742bb81bf91e244bff
new file mode 100644
index 0000000..06e5d24
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/a8/a700e8c644c783ba2c6e742bb81bf91e244bff
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/bc/7068d1eb2f93a04e3ec73521473444ceec0961 b/tests/gitea-repositories-meta/user2/repo-release.git/objects/bc/7068d1eb2f93a04e3ec73521473444ceec0961
new file mode 100644
index 0000000..1464422
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/bc/7068d1eb2f93a04e3ec73521473444ceec0961
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/c4/a4e1a72a2098d687b4280e7c6972280c1f9c39 b/tests/gitea-repositories-meta/user2/repo-release.git/objects/c4/a4e1a72a2098d687b4280e7c6972280c1f9c39
new file mode 100644
index 0000000..27c3726
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/c4/a4e1a72a2098d687b4280e7c6972280c1f9c39
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/cd/7f28e1b404377eadbe0d54234ba861883e6930 b/tests/gitea-repositories-meta/user2/repo-release.git/objects/cd/7f28e1b404377eadbe0d54234ba861883e6930
new file mode 100644
index 0000000..05712c8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/cd/7f28e1b404377eadbe0d54234ba861883e6930
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/ce/f06e48f2642cd0dc9597b4bea09f4b3f74aad6 b/tests/gitea-repositories-meta/user2/repo-release.git/objects/ce/f06e48f2642cd0dc9597b4bea09f4b3f74aad6
new file mode 100644
index 0000000..5d5a01d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/ce/f06e48f2642cd0dc9597b4bea09f4b3f74aad6
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/d0/718fe871fbb54da104ff201f75f62a6ced2e29 b/tests/gitea-repositories-meta/user2/repo-release.git/objects/d0/718fe871fbb54da104ff201f75f62a6ced2e29
new file mode 100644
index 0000000..81ea78c
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/d0/718fe871fbb54da104ff201f75f62a6ced2e29
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/d4/613f8dad1fa61e415922f6eb33244358fca85d b/tests/gitea-repositories-meta/user2/repo-release.git/objects/d4/613f8dad1fa61e415922f6eb33244358fca85d
new file mode 100644
index 0000000..07fdeac
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/d4/613f8dad1fa61e415922f6eb33244358fca85d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/dd/5488178fc8a5c62430b3fb3017203b917b95ab b/tests/gitea-repositories-meta/user2/repo-release.git/objects/dd/5488178fc8a5c62430b3fb3017203b917b95ab
new file mode 100644
index 0000000..da44370
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/dd/5488178fc8a5c62430b3fb3017203b917b95ab
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 b/tests/gitea-repositories-meta/user2/repo-release.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
new file mode 100644
index 0000000..7112238
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/ef/3c849ed54b22bb1f500da91b789c40cb0915da b/tests/gitea-repositories-meta/user2/repo-release.git/objects/ef/3c849ed54b22bb1f500da91b789c40cb0915da
new file mode 100644
index 0000000..c04c066
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/ef/3c849ed54b22bb1f500da91b789c40cb0915da
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/f3/f1c90ac949aa1b0f129d30f338d408663c8a83 b/tests/gitea-repositories-meta/user2/repo-release.git/objects/f3/f1c90ac949aa1b0f129d30f338d408663c8a83
new file mode 100644
index 0000000..b364558
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/f3/f1c90ac949aa1b0f129d30f338d408663c8a83
@@ -0,0 +1,2 @@
+x•ŽA
+à »öî ÅoŒšRJ¯òÕ÷i 6Áèñ›+t;0ÃäµÖ¹kKñÒ ‹ 1¤4ºÂdœˆ5$aoÙg ;©>]St6FŒœERNÈ›4MgeHd‹0'V|ô÷Úô±£‘~p) ûN/|¹n ny­OM>RÜäG}5ÖuÒó®ã_O5p©¸ë>÷êRäE­ \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/objects/f9/3e3a1a1525fb5b91020da86e44810c87a2d7bc b/tests/gitea-repositories-meta/user2/repo-release.git/objects/f9/3e3a1a1525fb5b91020da86e44810c87a2d7bc
new file mode 100644
index 0000000..91fccd4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/objects/f9/3e3a1a1525fb5b91020da86e44810c87a2d7bc
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/refs/heads/main b/tests/gitea-repositories-meta/user2/repo-release.git/refs/heads/main
new file mode 100644
index 0000000..e2b25a9
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/refs/heads/main
@@ -0,0 +1 @@
+7197b56fdc75b453f47c9110938cb46a303579fd
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/refs/heads/master b/tests/gitea-repositories-meta/user2/repo-release.git/refs/heads/master
new file mode 100644
index 0000000..3329878
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/refs/heads/master
@@ -0,0 +1 @@
+a8a700e8c644c783ba2c6e742bb81bf91e244bff
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/refs/tags/v1.0 b/tests/gitea-repositories-meta/user2/repo-release.git/refs/tags/v1.0
new file mode 100644
index 0000000..3329878
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/refs/tags/v1.0
@@ -0,0 +1 @@
+a8a700e8c644c783ba2c6e742bb81bf91e244bff
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/refs/tags/v1.1 b/tests/gitea-repositories-meta/user2/repo-release.git/refs/tags/v1.1
new file mode 100644
index 0000000..d0def6a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/refs/tags/v1.1
@@ -0,0 +1 @@
+cef06e48f2642cd0dc9597b4bea09f4b3f74aad6
diff --git a/tests/gitea-repositories-meta/user2/repo-release.git/refs/tags/v2.0 b/tests/gitea-repositories-meta/user2/repo-release.git/refs/tags/v2.0
new file mode 100644
index 0000000..e2b25a9
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo-release.git/refs/tags/v2.0
@@ -0,0 +1 @@
+7197b56fdc75b453f47c9110938cb46a303579fd
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/HEAD b/tests/gitea-repositories-meta/user2/repo1.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/config b/tests/gitea-repositories-meta/user2/repo1.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/description b/tests/gitea-repositories-meta/user2/repo1.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/hooks/post-receive b/tests/gitea-repositories-meta/user2/repo1.git/hooks/post-receive
new file mode 100755
index 0000000..4b3d452
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/hooks/post-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/post-receive.d"`; do
+ sh "$SHELL_FOLDER/post-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/hooks/post-receive.d/gitea b/tests/gitea-repositories-meta/user2/repo1.git/hooks/post-receive.d/gitea
new file mode 100755
index 0000000..43a948d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/hooks/post-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" post-receive
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/hooks/pre-receive b/tests/gitea-repositories-meta/user2/repo1.git/hooks/pre-receive
new file mode 100755
index 0000000..4127013
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/hooks/pre-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/pre-receive.d"`; do
+ sh "$SHELL_FOLDER/pre-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/hooks/pre-receive.d/gitea b/tests/gitea-repositories-meta/user2/repo1.git/hooks/pre-receive.d/gitea
new file mode 100755
index 0000000..49d0940
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/hooks/pre-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" pre-receive
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/hooks/proc-receive b/tests/gitea-repositories-meta/user2/repo1.git/hooks/proc-receive
new file mode 100755
index 0000000..af2808b
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/hooks/proc-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/proc-receive.d"`; do
+ sh "$SHELL_FOLDER/proc-receive.d/$i"
+done
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/hooks/proc-receive.d/gitea b/tests/gitea-repositories-meta/user2/repo1.git/hooks/proc-receive.d/gitea
new file mode 100755
index 0000000..97521c6
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/hooks/proc-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" proc-receive
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/hooks/update b/tests/gitea-repositories-meta/user2/repo1.git/hooks/update
new file mode 100755
index 0000000..c186fe4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/hooks/update
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/update.d"`; do
+ sh "$SHELL_FOLDER/update.d/$i" $1 $2 $3
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/hooks/update.d/gitea b/tests/gitea-repositories-meta/user2/repo1.git/hooks/update.d/gitea
new file mode 100755
index 0000000..38101c2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/hooks/update.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" update $1 $2 $3
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/info/exclude b/tests/gitea-repositories-meta/user2/repo1.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/info/refs b/tests/gitea-repositories-meta/user2/repo1.git/info/refs
new file mode 100644
index 0000000..fa30097
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/info/refs
@@ -0,0 +1,3 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d refs/heads/master
+985f0301dba5e7b34be866819cd15ad3d8f508ee refs/heads/branch2
+62fb502a7172d4453f0322a2cc85bddffa57f07a refs/heads/pr-to-update
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/00/750edc07d6415dcc07ae0351e9397b0222b7ba b/tests/gitea-repositories-meta/user2/repo1.git/objects/00/750edc07d6415dcc07ae0351e9397b0222b7ba
new file mode 100644
index 0000000..d3c45d5
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/00/750edc07d6415dcc07ae0351e9397b0222b7ba
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/16/633238d370a441f98dca532e4296a619c4c85f b/tests/gitea-repositories-meta/user2/repo1.git/objects/16/633238d370a441f98dca532e4296a619c4c85f
new file mode 100644
index 0000000..310f0ca
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/16/633238d370a441f98dca532e4296a619c4c85f
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6 b/tests/gitea-repositories-meta/user2/repo1.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6
new file mode 100644
index 0000000..0994add
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/2a/2f1d4670728a2e10049e345bd7a276468beab6
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/3f/a2f829675543ecfc16b2891aebe8bf0608a8f4 b/tests/gitea-repositories-meta/user2/repo1.git/objects/3f/a2f829675543ecfc16b2891aebe8bf0608a8f4
new file mode 100644
index 0000000..892c6bf
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/3f/a2f829675543ecfc16b2891aebe8bf0608a8f4
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/40/3d76c604cb569323864e06a07b85d466924802 b/tests/gitea-repositories-meta/user2/repo1.git/objects/40/3d76c604cb569323864e06a07b85d466924802
new file mode 100644
index 0000000..ea0bf76
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/40/3d76c604cb569323864e06a07b85d466924802
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/46/49299398e4d39a5c09eb4f534df6f1e1eb87cc b/tests/gitea-repositories-meta/user2/repo1.git/objects/46/49299398e4d39a5c09eb4f534df6f1e1eb87cc
new file mode 100644
index 0000000..b32e1d2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/46/49299398e4d39a5c09eb4f534df6f1e1eb87cc
@@ -0,0 +1,4 @@
+xQJÅ0EýÎ*f>¦¤I@DÁ‡_ú!n`šL^‹mòhSîÞ ®ÀÏ÷^Î e]ç
+½3wunˆzr‘,²Ö]ò.6Ô‹îýÀCçƒÎ$uåMrëÒèÑ
+1zaÑI\’„„Îê 㘺(>êT6xŸÃ¼­:ɹáéò‡Oײ|¯u9~l"Öi$cîÑ ªðkZ[ëÿêSö
+S¹ÁÇùùåí¼C;Ûä¼òEv¸M’!•#G˜30ìǘÊÒêy³] \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/4a/357436d925b5c974181ff12a994538ddc5a269 b/tests/gitea-repositories-meta/user2/repo1.git/objects/4a/357436d925b5c974181ff12a994538ddc5a269
new file mode 100644
index 0000000..bf97d00
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/4a/357436d925b5c974181ff12a994538ddc5a269
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349f b/tests/gitea-repositories-meta/user2/repo1.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349f
new file mode 100644
index 0000000..700a138
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/4b/4851ad51df6a7d9f25c979345979eaeb5b349f
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/5c/050d3b6d2db231ab1f64e324f1b6b9a0b181c2 b/tests/gitea-repositories-meta/user2/repo1.git/objects/5c/050d3b6d2db231ab1f64e324f1b6b9a0b181c2
new file mode 100644
index 0000000..c0cb626
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/5c/050d3b6d2db231ab1f64e324f1b6b9a0b181c2
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/5f/22f7d0d95d614d25a5b68592adb345a4b5c7fd b/tests/gitea-repositories-meta/user2/repo1.git/objects/5f/22f7d0d95d614d25a5b68592adb345a4b5c7fd
new file mode 100644
index 0000000..17fdf18
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/5f/22f7d0d95d614d25a5b68592adb345a4b5c7fd
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/62/fb502a7172d4453f0322a2cc85bddffa57f07a b/tests/gitea-repositories-meta/user2/repo1.git/objects/62/fb502a7172d4453f0322a2cc85bddffa57f07a
new file mode 100644
index 0000000..ee494a8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/62/fb502a7172d4453f0322a2cc85bddffa57f07a
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4d b/tests/gitea-repositories-meta/user2/repo1.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4d
new file mode 100644
index 0000000..de48ba7
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/65/f1bf27bc3bf70f64657658635e66094edbcb4d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/6a/a3a5385611c5eb8986c9961a9c34a93cbaadfb b/tests/gitea-repositories-meta/user2/repo1.git/objects/6a/a3a5385611c5eb8986c9961a9c34a93cbaadfb
new file mode 100644
index 0000000..09aed94
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/6a/a3a5385611c5eb8986c9961a9c34a93cbaadfb
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/78/fb907e3a3309eae4fe8fef030874cebbf1cd5e b/tests/gitea-repositories-meta/user2/repo1.git/objects/78/fb907e3a3309eae4fe8fef030874cebbf1cd5e
new file mode 100644
index 0000000..6a25f74
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/78/fb907e3a3309eae4fe8fef030874cebbf1cd5e
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/7c/4df115542e05c700f297519e906fd63c9c9804 b/tests/gitea-repositories-meta/user2/repo1.git/objects/7c/4df115542e05c700f297519e906fd63c9c9804
new file mode 100644
index 0000000..3bf67a2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/7c/4df115542e05c700f297519e906fd63c9c9804
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/90/dcd07da077d1e7cd6032b52d1f79ae2b5f19b2 b/tests/gitea-repositories-meta/user2/repo1.git/objects/90/dcd07da077d1e7cd6032b52d1f79ae2b5f19b2
new file mode 100644
index 0000000..1404abd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/90/dcd07da077d1e7cd6032b52d1f79ae2b5f19b2
@@ -0,0 +1,2 @@
+xe±NÄ0D©#åæŽ4
+JÄAÅ5”Ž³—,—xÑzsVþ‚5„DåÑØ»ž7ý,=®®o.áEå¢áq5J=éˆýÈ rÄ=>4§ú O!óŠýã´ðÐ6ms˜8ƒ¾&\Ea¾tÍT„´I¢z”‰Ô…! ¢dso@a›Ú&ÌK5üB)›r4–”Q¦`YèLÚ¯²b ›¾o`Ûaä3¹@(ÒeýÔ­5 ôÂH—\sÔHÿ9Ÿ9Rª3)Îë@ŽSùã_"§‘4sE0”Rºñ§¤.‘U|/€m¦Û¿]U÷ÌzÀ \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/94/922e1295c678267de1193b7b84ad8a086c27f9 b/tests/gitea-repositories-meta/user2/repo1.git/objects/94/922e1295c678267de1193b7b84ad8a086c27f9
new file mode 100644
index 0000000..60692df
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/94/922e1295c678267de1193b7b84ad8a086c27f9
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/98/5f0301dba5e7b34be866819cd15ad3d8f508ee b/tests/gitea-repositories-meta/user2/repo1.git/objects/98/5f0301dba5e7b34be866819cd15ad3d8f508ee
new file mode 100644
index 0000000..81fd6a5
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/98/5f0301dba5e7b34be866819cd15ad3d8f508ee
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/a6/9277c81e90b98a7c0ab25b042a6e296da8eb9a b/tests/gitea-repositories-meta/user2/repo1.git/objects/a6/9277c81e90b98a7c0ab25b042a6e296da8eb9a
new file mode 100644
index 0000000..8876698
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/a6/9277c81e90b98a7c0ab25b042a6e296da8eb9a
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/a7/57c0ea621e63d0fd6fc353a175fdc7199e5d1d b/tests/gitea-repositories-meta/user2/repo1.git/objects/a7/57c0ea621e63d0fd6fc353a175fdc7199e5d1d
new file mode 100644
index 0000000..c3111a0
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/a7/57c0ea621e63d0fd6fc353a175fdc7199e5d1d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/b2/60587271671842af0b036e4fe643c9d45b7ddd b/tests/gitea-repositories-meta/user2/repo1.git/objects/b2/60587271671842af0b036e4fe643c9d45b7ddd
new file mode 100644
index 0000000..9182ac0
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/b2/60587271671842af0b036e4fe643c9d45b7ddd
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/d4/a1a6dcf7bd42891f264d484e80dac7e66b5410 b/tests/gitea-repositories-meta/user2/repo1.git/objects/d4/a1a6dcf7bd42891f264d484e80dac7e66b5410
new file mode 100644
index 0000000..d7ef93c
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/d4/a1a6dcf7bd42891f264d484e80dac7e66b5410
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/d7/bd5b8cfb680f460e37b6fd7cf74c284e059118 b/tests/gitea-repositories-meta/user2/repo1.git/objects/d7/bd5b8cfb680f460e37b6fd7cf74c284e059118
new file mode 100644
index 0000000..6039ff6
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/d7/bd5b8cfb680f460e37b6fd7cf74c284e059118
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/dc/7a8ba127fee870dd683310ce660dfe59333a1b b/tests/gitea-repositories-meta/user2/repo1.git/objects/dc/7a8ba127fee870dd683310ce660dfe59333a1b
new file mode 100644
index 0000000..7678d67
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/dc/7a8ba127fee870dd683310ce660dfe59333a1b
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/dd/59742c0f6672911f2b64cba5711ac00593ed32 b/tests/gitea-repositories-meta/user2/repo1.git/objects/dd/59742c0f6672911f2b64cba5711ac00593ed32
new file mode 100644
index 0000000..f9137c5
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/dd/59742c0f6672911f2b64cba5711ac00593ed32
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/f3/fa0f5cc797fc4c02a1b8bec9de4b2072fcdbdf b/tests/gitea-repositories-meta/user2/repo1.git/objects/f3/fa0f5cc797fc4c02a1b8bec9de4b2072fcdbdf
new file mode 100644
index 0000000..9b20f8a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/f3/fa0f5cc797fc4c02a1b8bec9de4b2072fcdbdf
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/objects/info/packs b/tests/gitea-repositories-meta/user2/repo1.git/objects/info/packs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/objects/info/packs
@@ -0,0 +1 @@
+
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/DefaultBranch b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/DefaultBranch
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/DefaultBranch
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/branch2 b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/branch2
new file mode 100644
index 0000000..5add725
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/branch2
@@ -0,0 +1 @@
+985f0301dba5e7b34be866819cd15ad3d8f508ee
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/develop b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/develop
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/develop
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/feature/1 b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/feature/1
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/feature/1
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/home-md-img-check b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/home-md-img-check
new file mode 100644
index 0000000..a254e42
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/home-md-img-check
@@ -0,0 +1 @@
+78fb907e3a3309eae4fe8fef030874cebbf1cd5e
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/master b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/master
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/master
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/pr-to-update b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/pr-to-update
new file mode 100644
index 0000000..e0ee44d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/pr-to-update
@@ -0,0 +1 @@
+62fb502a7172d4453f0322a2cc85bddffa57f07a
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/sub-home-md-img-check b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/sub-home-md-img-check
new file mode 100644
index 0000000..dfe1105
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/refs/heads/sub-home-md-img-check
@@ -0,0 +1 @@
+4649299398e4d39a5c09eb4f534df6f1e1eb87cc
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/refs/notes/commits b/tests/gitea-repositories-meta/user2/repo1.git/refs/notes/commits
new file mode 100644
index 0000000..6f83753
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/refs/notes/commits
@@ -0,0 +1 @@
+3fa2f829675543ecfc16b2891aebe8bf0608a8f4
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/refs/pull/2/head b/tests/gitea-repositories-meta/user2/repo1.git/refs/pull/2/head
new file mode 100644
index 0000000..98593d6
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/refs/pull/2/head
@@ -0,0 +1 @@
+4a357436d925b5c974181ff12a994538ddc5a269
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/refs/pull/3/head b/tests/gitea-repositories-meta/user2/repo1.git/refs/pull/3/head
new file mode 100644
index 0000000..33a9eaa
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/refs/pull/3/head
@@ -0,0 +1 @@
+5f22f7d0d95d614d25a5b68592adb345a4b5c7fd
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/refs/pull/5/head b/tests/gitea-repositories-meta/user2/repo1.git/refs/pull/5/head
new file mode 100644
index 0000000..e0ee44d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/refs/pull/5/head
@@ -0,0 +1 @@
+62fb502a7172d4453f0322a2cc85bddffa57f07a
diff --git a/tests/gitea-repositories-meta/user2/repo1.git/refs/tags/v1.1 b/tests/gitea-repositories-meta/user2/repo1.git/refs/tags/v1.1
new file mode 100644
index 0000000..f98a263
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.git/refs/tags/v1.1
@@ -0,0 +1 @@
+65f1bf27bc3bf70f64657658635e66094edbcb4d
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/HEAD b/tests/gitea-repositories-meta/user2/repo1.wiki.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/config b/tests/gitea-repositories-meta/user2/repo1.wiki.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/description b/tests/gitea-repositories-meta/user2/repo1.wiki.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/info/exclude b/tests/gitea-repositories-meta/user2/repo1.wiki.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/0c/f15c3f66ec8384480ed9c3cf87c9e97fbb0ec3 b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/0c/f15c3f66ec8384480ed9c3cf87c9e97fbb0ec3
new file mode 100644
index 0000000..c0314c5
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/0c/f15c3f66ec8384480ed9c3cf87c9e97fbb0ec3
@@ -0,0 +1,2 @@
+x­ŽÑmÄ DóMÛÀY¬Í¢(ùJ©`5ÇÉœ-›K*Ki,Hi!?£Ñ<éiâVki0ZÿÔXH“D(Z6ĨGòSb» 3“JDÞhµó!÷uB¬ÌDaJp¡ íœÙèFôLƹ4+~´ëvÀ;‡£È
+eýäžõç[Nx>KÝäÎü‡_så²q«/€]09MHpѤµêk¿Üä_dê-%¸í’‡Ûžï vÎ_¥]¡Ô^Õ/èI[t \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/0d/ca5bd9b5d7ef937710e056f575e86c0184ba85 b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/0d/ca5bd9b5d7ef937710e056f575e86c0184ba85
new file mode 100644
index 0000000..a46c192
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/0d/ca5bd9b5d7ef937710e056f575e86c0184ba85
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/2c/54faec6c45d31c1abfaecdab471eac6633738a b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/2c/54faec6c45d31c1abfaecdab471eac6633738a
new file mode 100644
index 0000000..4cf6cda
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/2c/54faec6c45d31c1abfaecdab471eac6633738a
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/32/5dc4f8e9344e6668f21536a69d5f1d4ed53ca3 b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/32/5dc4f8e9344e6668f21536a69d5f1d4ed53ca3
new file mode 100644
index 0000000..d52aa8e
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/32/5dc4f8e9344e6668f21536a69d5f1d4ed53ca3
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/42/3313fbd38093bb10d0c8387db9105409c6f196 b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/42/3313fbd38093bb10d0c8387db9105409c6f196
new file mode 100644
index 0000000..bf4ae85
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/42/3313fbd38093bb10d0c8387db9105409c6f196
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/71/911bf48766c7181518c1070911019fbb00b1fc b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/71/911bf48766c7181518c1070911019fbb00b1fc
new file mode 100644
index 0000000..84ade81
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/71/911bf48766c7181518c1070911019fbb00b1fc
@@ -0,0 +1 @@
+xÆM‚@ †á¯MÛ àºré›°6ñœ&&&¬ü9LežÅ›w½Ý×åt<#ÞñÃÍ¡ªmv-·•0w¬b¦¢jyÌ–†¤Ú—~Ý‹[žæÉçý=HÄ ÷.¾"à‚íµÄçÇ= \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/72/fc6251cc648e914c10009d31431fa2e38b9a20 b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/72/fc6251cc648e914c10009d31431fa2e38b9a20
new file mode 100644
index 0000000..052fdf3
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/72/fc6251cc648e914c10009d31431fa2e38b9a20
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/74/d5a0d73db9b9ef7aa9978eb7a099b08f54d45e b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/74/d5a0d73db9b9ef7aa9978eb7a099b08f54d45e
new file mode 100644
index 0000000..bcb0e00
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/74/d5a0d73db9b9ef7aa9978eb7a099b08f54d45e
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/7c/d7c8fa852973c72c66eb120a6677c54a8697f7 b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/7c/d7c8fa852973c72c66eb120a6677c54a8697f7
new file mode 100644
index 0000000..9c26495
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/7c/d7c8fa852973c72c66eb120a6677c54a8697f7
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/89/43a1d5f93c00439d5ffc0f8e36f5d60abae46c b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/89/43a1d5f93c00439d5ffc0f8e36f5d60abae46c
new file mode 100644
index 0000000..062641b
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/89/43a1d5f93c00439d5ffc0f8e36f5d60abae46c
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/c1/0d10b7e655b3dab1f53176db57c8219a5488d6 b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/c1/0d10b7e655b3dab1f53176db57c8219a5488d6
new file mode 100644
index 0000000..8a6345d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/c1/0d10b7e655b3dab1f53176db57c8219a5488d6
@@ -0,0 +1,2 @@
+x­ŽÝmÃ0ƒû¬)nú±t2íSèçÓÙ`ņ¥¶“e‚,VY¡/Hâ#È[)¹EûÒ@NÈq¦è툎Ñr2«)DöÅ0âŒj§C®ìÑLŸœaCÓÃ&š4Bv]$Eßí²ðIÓ‘e…¯¼þP×r¿I…sÍe“zªË³~_
+åõÄ[yã‡è¢v£WíµV=í—›ü˘úH vZ~s»@݉%Á•Š¨?TÊZH \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/c4/b38c3e1395393f75bbbc2ed10c7eeb577d3b64 b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/c4/b38c3e1395393f75bbbc2ed10c7eeb577d3b64
new file mode 100644
index 0000000..6dcfc96
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/c4/b38c3e1395393f75bbbc2ed10c7eeb577d3b64
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/e5/3d079e581fbfdea1075a54d5b621eab0090e52 b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/e5/3d079e581fbfdea1075a54d5b621eab0090e52
new file mode 100644
index 0000000..ecdea7f
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/e5/3d079e581fbfdea1075a54d5b621eab0090e52
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/ea/82fc8777a24b07c26b3a4bf4e2742c03733eab b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/ea/82fc8777a24b07c26b3a4bf4e2742c03733eab
new file mode 100644
index 0000000..42a8258
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/ea/82fc8777a24b07c26b3a4bf4e2742c03733eab
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/f5/05ec9b5c7a45a10259c1dda7f18434e5d55940 b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/f5/05ec9b5c7a45a10259c1dda7f18434e5d55940
new file mode 100644
index 0000000..eaeadae
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/objects/f5/05ec9b5c7a45a10259c1dda7f18434e5d55940
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo1.wiki.git/refs/heads/master b/tests/gitea-repositories-meta/user2/repo1.wiki.git/refs/heads/master
new file mode 100644
index 0000000..38984b1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo1.wiki.git/refs/heads/master
@@ -0,0 +1 @@
+0dca5bd9b5d7ef937710e056f575e86c0184ba85
diff --git a/tests/gitea-repositories-meta/user2/repo15.git/HEAD b/tests/gitea-repositories-meta/user2/repo15.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo15.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/repo15.git/config b/tests/gitea-repositories-meta/user2/repo15.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo15.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/repo15.git/description b/tests/gitea-repositories-meta/user2/repo15.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo15.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/repo15.git/hooks/post-receive b/tests/gitea-repositories-meta/user2/repo15.git/hooks/post-receive
new file mode 100755
index 0000000..4b3d452
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo15.git/hooks/post-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/post-receive.d"`; do
+ sh "$SHELL_FOLDER/post-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo15.git/hooks/post-receive.d/gitea b/tests/gitea-repositories-meta/user2/repo15.git/hooks/post-receive.d/gitea
new file mode 100755
index 0000000..43a948d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo15.git/hooks/post-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" post-receive
diff --git a/tests/gitea-repositories-meta/user2/repo15.git/hooks/pre-receive b/tests/gitea-repositories-meta/user2/repo15.git/hooks/pre-receive
new file mode 100755
index 0000000..4127013
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo15.git/hooks/pre-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/pre-receive.d"`; do
+ sh "$SHELL_FOLDER/pre-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo15.git/hooks/pre-receive.d/gitea b/tests/gitea-repositories-meta/user2/repo15.git/hooks/pre-receive.d/gitea
new file mode 100755
index 0000000..49d0940
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo15.git/hooks/pre-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" pre-receive
diff --git a/tests/gitea-repositories-meta/user2/repo15.git/hooks/update b/tests/gitea-repositories-meta/user2/repo15.git/hooks/update
new file mode 100755
index 0000000..c186fe4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo15.git/hooks/update
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/update.d"`; do
+ sh "$SHELL_FOLDER/update.d/$i" $1 $2 $3
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo15.git/hooks/update.d/gitea b/tests/gitea-repositories-meta/user2/repo15.git/hooks/update.d/gitea
new file mode 100755
index 0000000..38101c2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo15.git/hooks/update.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" update $1 $2 $3
diff --git a/tests/gitea-repositories-meta/user2/repo15.git/info/exclude b/tests/gitea-repositories-meta/user2/repo15.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo15.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/HEAD b/tests/gitea-repositories-meta/user2/repo16.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/config b/tests/gitea-repositories-meta/user2/repo16.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/description b/tests/gitea-repositories-meta/user2/repo16.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/info/exclude b/tests/gitea-repositories-meta/user2/repo16.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/objects/0c/3d59dea27b97aa3cb66072745d7a2c51a7a8b1 b/tests/gitea-repositories-meta/user2/repo16.git/objects/0c/3d59dea27b97aa3cb66072745d7a2c51a7a8b1
new file mode 100644
index 0000000..e62f09a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/objects/0c/3d59dea27b97aa3cb66072745d7a2c51a7a8b1
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/objects/24/f83a471f77579fea57bac7255d6e64e70fce1c b/tests/gitea-repositories-meta/user2/repo16.git/objects/24/f83a471f77579fea57bac7255d6e64e70fce1c
new file mode 100644
index 0000000..2558be6
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/objects/24/f83a471f77579fea57bac7255d6e64e70fce1c
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/objects/27/566bd5738fc8b4e3fef3c5e72cce608537bd95 b/tests/gitea-repositories-meta/user2/repo16.git/objects/27/566bd5738fc8b4e3fef3c5e72cce608537bd95
new file mode 100644
index 0000000..6042481
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/objects/27/566bd5738fc8b4e3fef3c5e72cce608537bd95
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/objects/3b/2b54fe3d9a8279d5b926124dccdf279b8eff2f b/tests/gitea-repositories-meta/user2/repo16.git/objects/3b/2b54fe3d9a8279d5b926124dccdf279b8eff2f
new file mode 100644
index 0000000..13de595
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/objects/3b/2b54fe3d9a8279d5b926124dccdf279b8eff2f
@@ -0,0 +1 @@
+x+)JMU06g040031Q(JMLÉMÕËMaXbR¶”10-&ªéCüÕþË’‘Ý=À, \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/objects/45/8121ce9a6b855c9733bae62093caf3f39685de b/tests/gitea-repositories-meta/user2/repo16.git/objects/45/8121ce9a6b855c9733bae62093caf3f39685de
new file mode 100644
index 0000000..7db2d33
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/objects/45/8121ce9a6b855c9733bae62093caf3f39685de
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/objects/50/99b81332712fe655e34e8dd63574f503f61811 b/tests/gitea-repositories-meta/user2/repo16.git/objects/50/99b81332712fe655e34e8dd63574f503f61811
new file mode 100644
index 0000000..3099763
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/objects/50/99b81332712fe655e34e8dd63574f503f61811
@@ -0,0 +1,2 @@
+xQ
+B!Eûvó„ŽO'!¢M´€Q‡×ƒÌðù ågÔúº‡ çÞTKY:v½‰€N6»…‘b f›¢÷š&—‰19ÃÄÇhoýV\Wi§íyqyÞåj9ƒqõ„FØÒj´ãªË?ÙŸ¤æZ3¬Ëü ßõ*S6# \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/objects/69/554a64c1e6030f051e5c3f94bfbd773cd6a324 b/tests/gitea-repositories-meta/user2/repo16.git/objects/69/554a64c1e6030f051e5c3f94bfbd773cd6a324
new file mode 100644
index 0000000..77ea95d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/objects/69/554a64c1e6030f051e5c3f94bfbd773cd6a324
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/objects/a4/3476a501516e065c5a82f05fd58fd319598bc1 b/tests/gitea-repositories-meta/user2/repo16.git/objects/a4/3476a501516e065c5a82f05fd58fd319598bc1
new file mode 100644
index 0000000..e021b19
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/objects/a4/3476a501516e065c5a82f05fd58fd319598bc1
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/objects/e9/4083fcdf1f10c545e9253a23c5e44a2ff68aac b/tests/gitea-repositories-meta/user2/repo16.git/objects/e9/4083fcdf1f10c545e9253a23c5e44a2ff68aac
new file mode 100644
index 0000000..780affc
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/objects/e9/4083fcdf1f10c545e9253a23c5e44a2ff68aac
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/objects/f2/7c2b2b03dcab38beaf89b0ab4ff61f6de63441 b/tests/gitea-repositories-meta/user2/repo16.git/objects/f2/7c2b2b03dcab38beaf89b0ab4ff61f6de63441
new file mode 100644
index 0000000..7cb8b91
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/objects/f2/7c2b2b03dcab38beaf89b0ab4ff61f6de63441
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/objects/f9/0451c72ef61a7645293d17b47be7a8e983da57 b/tests/gitea-repositories-meta/user2/repo16.git/objects/f9/0451c72ef61a7645293d17b47be7a8e983da57
new file mode 100644
index 0000000..c7627ad
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/objects/f9/0451c72ef61a7645293d17b47be7a8e983da57
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/refs/heads/good-sign b/tests/gitea-repositories-meta/user2/repo16.git/refs/heads/good-sign
new file mode 100644
index 0000000..4750a76
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/refs/heads/good-sign
@@ -0,0 +1 @@
+f27c2b2b03dcab38beaf89b0ab4ff61f6de63441
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/refs/heads/good-sign-not-yet-validated b/tests/gitea-repositories-meta/user2/repo16.git/refs/heads/good-sign-not-yet-validated
new file mode 100644
index 0000000..f68025f
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/refs/heads/good-sign-not-yet-validated
@@ -0,0 +1 @@
+27566bd5738fc8b4e3fef3c5e72cce608537bd95
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/refs/heads/master b/tests/gitea-repositories-meta/user2/repo16.git/refs/heads/master
new file mode 100644
index 0000000..65f9a9f
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/refs/heads/master
@@ -0,0 +1 @@
+69554a64c1e6030f051e5c3f94bfbd773cd6a324
diff --git a/tests/gitea-repositories-meta/user2/repo16.git/refs/heads/not-signed b/tests/gitea-repositories-meta/user2/repo16.git/refs/heads/not-signed
new file mode 100644
index 0000000..65f9a9f
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo16.git/refs/heads/not-signed
@@ -0,0 +1 @@
+69554a64c1e6030f051e5c3f94bfbd773cd6a324
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/HEAD b/tests/gitea-repositories-meta/user2/repo2.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/config b/tests/gitea-repositories-meta/user2/repo2.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/description b/tests/gitea-repositories-meta/user2/repo2.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/info/exclude b/tests/gitea-repositories-meta/user2/repo2.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/info/refs b/tests/gitea-repositories-meta/user2/repo2.git/info/refs
new file mode 100644
index 0000000..044e52e
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/info/refs
@@ -0,0 +1 @@
+205ac761f3326a7ebe416e8673760016450b5cec refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/0a/7d8b41ae9763e9a1743917396839d1791d49d0 b/tests/gitea-repositories-meta/user2/repo2.git/objects/0a/7d8b41ae9763e9a1743917396839d1791d49d0
new file mode 100644
index 0000000..d62e3c6
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/0a/7d8b41ae9763e9a1743917396839d1791d49d0
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/0c/f15c3f66ec8384480ed9c3cf87c9e97fbb0ec3 b/tests/gitea-repositories-meta/user2/repo2.git/objects/0c/f15c3f66ec8384480ed9c3cf87c9e97fbb0ec3
new file mode 100644
index 0000000..c0314c5
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/0c/f15c3f66ec8384480ed9c3cf87c9e97fbb0ec3
@@ -0,0 +1,2 @@
+x­ŽÑmÄ DóMÛÀY¬Í¢(ùJ©`5ÇÉœ-›K*Ki,Hi!?£Ñ<éiâVki0ZÿÔXH“D(Z6ĨGòSb» 3“JDÞhµó!÷uB¬ÌDaJp¡ íœÙèFôLƹ4+~´ëvÀ;‡£È
+eýäžõç[Nx>KÝäÎü‡_så²q«/€]09MHpѤµêk¿Üä_dê-%¸í’‡Ûžï vÎ_¥]¡Ô^Õ/èI[t \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/10/32bbf17fbc0d9c95bb5418dabe8f8c99278700 b/tests/gitea-repositories-meta/user2/repo2.git/objects/10/32bbf17fbc0d9c95bb5418dabe8f8c99278700
new file mode 100644
index 0000000..736e408
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/10/32bbf17fbc0d9c95bb5418dabe8f8c99278700
@@ -0,0 +1,2 @@
+xŽK
+Â0Eg™ %Ÿ×":uä’ôŠ•¦J|‚îÞê¸çpË­ÖQ´ó~% Ð9Ù„à‘G6GÎ ”ͦw(êžæE4}*íÙ{Ç)`YƆƒlŒeêMî—„JO¹Üš>Žµ¾õ©%ˆÞ^¿ÐÝ¿°L˜!]ÆN[v#E½6ÎU~/ÿúê0 ZðîU'õgpJ5 \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/1c/887eaa8d81fa86da7695d8f635cf17813eb422 b/tests/gitea-repositories-meta/user2/repo2.git/objects/1c/887eaa8d81fa86da7695d8f635cf17813eb422
new file mode 100644
index 0000000..34fa593
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/1c/887eaa8d81fa86da7695d8f635cf17813eb422
@@ -0,0 +1 @@
+x+)JMU07b040031QÈ*HM×Ë*Hg(œ(ý¥=í¸„¨ÄAvNAÆù»6þªÉÉÌKÕ+.KgHžº­OþÝn9ŸÔjÿùÙ‹Ò³4l¸é \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/26/f842bcad37fa40a1bb34cbb5ee219ee35d863d b/tests/gitea-repositories-meta/user2/repo2.git/objects/26/f842bcad37fa40a1bb34cbb5ee219ee35d863d
new file mode 100644
index 0000000..c3e7e77
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/26/f842bcad37fa40a1bb34cbb5ee219ee35d863d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/32/5dc4f8e9344e6668f21536a69d5f1d4ed53ca3 b/tests/gitea-repositories-meta/user2/repo2.git/objects/32/5dc4f8e9344e6668f21536a69d5f1d4ed53ca3
new file mode 100644
index 0000000..d52aa8e
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/32/5dc4f8e9344e6668f21536a69d5f1d4ed53ca3
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/36/fff01c8c9f722d49d53186abd27b5be8d85338 b/tests/gitea-repositories-meta/user2/repo2.git/objects/36/fff01c8c9f722d49d53186abd27b5be8d85338
new file mode 100644
index 0000000..fc0c865
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/36/fff01c8c9f722d49d53186abd27b5be8d85338
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/42/3313fbd38093bb10d0c8387db9105409c6f196 b/tests/gitea-repositories-meta/user2/repo2.git/objects/42/3313fbd38093bb10d0c8387db9105409c6f196
new file mode 100644
index 0000000..bf4ae85
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/42/3313fbd38093bb10d0c8387db9105409c6f196
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/71/911bf48766c7181518c1070911019fbb00b1fc b/tests/gitea-repositories-meta/user2/repo2.git/objects/71/911bf48766c7181518c1070911019fbb00b1fc
new file mode 100644
index 0000000..84ade81
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/71/911bf48766c7181518c1070911019fbb00b1fc
@@ -0,0 +1 @@
+xÆM‚@ †á¯MÛ àºré›°6ñœ&&&¬ü9LežÅ›w½Ý×åt<#ÞñÃÍ¡ªmv-·•0w¬b¦¢jyÌ–†¤Ú—~Ý‹[žæÉçý=HÄ ÷.¾"à‚íµÄçÇ= \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/72/fc6251cc648e914c10009d31431fa2e38b9a20 b/tests/gitea-repositories-meta/user2/repo2.git/objects/72/fc6251cc648e914c10009d31431fa2e38b9a20
new file mode 100644
index 0000000..052fdf3
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/72/fc6251cc648e914c10009d31431fa2e38b9a20
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/74/d5a0d73db9b9ef7aa9978eb7a099b08f54d45e b/tests/gitea-repositories-meta/user2/repo2.git/objects/74/d5a0d73db9b9ef7aa9978eb7a099b08f54d45e
new file mode 100644
index 0000000..bcb0e00
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/74/d5a0d73db9b9ef7aa9978eb7a099b08f54d45e
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/7c/d7c8fa852973c72c66eb120a6677c54a8697f7 b/tests/gitea-repositories-meta/user2/repo2.git/objects/7c/d7c8fa852973c72c66eb120a6677c54a8697f7
new file mode 100644
index 0000000..9c26495
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/7c/d7c8fa852973c72c66eb120a6677c54a8697f7
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/ba/1aed4e2ea2443d76cec241b96be4ec990852ec b/tests/gitea-repositories-meta/user2/repo2.git/objects/ba/1aed4e2ea2443d76cec241b96be4ec990852ec
new file mode 100644
index 0000000..add9a3a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/ba/1aed4e2ea2443d76cec241b96be4ec990852ec
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/c1/0d10b7e655b3dab1f53176db57c8219a5488d6 b/tests/gitea-repositories-meta/user2/repo2.git/objects/c1/0d10b7e655b3dab1f53176db57c8219a5488d6
new file mode 100644
index 0000000..8a6345d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/c1/0d10b7e655b3dab1f53176db57c8219a5488d6
@@ -0,0 +1,2 @@
+x­ŽÝmÃ0ƒû¬)nú±t2íSèçÓÙ`ņ¥¶“e‚,VY¡/Hâ#È[)¹EûÒ@NÈq¦è툎Ñr2«)DöÅ0âŒj§C®ìÑLŸœaCÓÃ&š4Bv]$Eßí²ðIÓ‘e…¯¼þP×r¿I…sÍe“zªË³~_
+åõÄ[yã‡è¢v£WíµV=í—›ü˘úH vZ~s»@݉%Á•Š¨?TÊZH \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/c4/b38c3e1395393f75bbbc2ed10c7eeb577d3b64 b/tests/gitea-repositories-meta/user2/repo2.git/objects/c4/b38c3e1395393f75bbbc2ed10c7eeb577d3b64
new file mode 100644
index 0000000..6dcfc96
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/c4/b38c3e1395393f75bbbc2ed10c7eeb577d3b64
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/f5/05ec9b5c7a45a10259c1dda7f18434e5d55940 b/tests/gitea-repositories-meta/user2/repo2.git/objects/f5/05ec9b5c7a45a10259c1dda7f18434e5d55940
new file mode 100644
index 0000000..eaeadae
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/f5/05ec9b5c7a45a10259c1dda7f18434e5d55940
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/info/commit-graph b/tests/gitea-repositories-meta/user2/repo2.git/objects/info/commit-graph
new file mode 100644
index 0000000..67dae50
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/info/commit-graph
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/info/packs b/tests/gitea-repositories-meta/user2/repo2.git/objects/info/packs
new file mode 100644
index 0000000..9eb91c8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/info/packs
@@ -0,0 +1,2 @@
+P pack-a2f7ad943b3d857eb3ebdb4b35eeef38f63cf5d2.pack
+
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/pack/pack-a2f7ad943b3d857eb3ebdb4b35eeef38f63cf5d2.bitmap b/tests/gitea-repositories-meta/user2/repo2.git/objects/pack/pack-a2f7ad943b3d857eb3ebdb4b35eeef38f63cf5d2.bitmap
new file mode 100644
index 0000000..8ecce32
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/pack/pack-a2f7ad943b3d857eb3ebdb4b35eeef38f63cf5d2.bitmap
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/pack/pack-a2f7ad943b3d857eb3ebdb4b35eeef38f63cf5d2.idx b/tests/gitea-repositories-meta/user2/repo2.git/objects/pack/pack-a2f7ad943b3d857eb3ebdb4b35eeef38f63cf5d2.idx
new file mode 100644
index 0000000..c4f3198
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/pack/pack-a2f7ad943b3d857eb3ebdb4b35eeef38f63cf5d2.idx
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/objects/pack/pack-a2f7ad943b3d857eb3ebdb4b35eeef38f63cf5d2.pack b/tests/gitea-repositories-meta/user2/repo2.git/objects/pack/pack-a2f7ad943b3d857eb3ebdb4b35eeef38f63cf5d2.pack
new file mode 100644
index 0000000..9d10156
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/objects/pack/pack-a2f7ad943b3d857eb3ebdb4b35eeef38f63cf5d2.pack
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/packed-refs b/tests/gitea-repositories-meta/user2/repo2.git/packed-refs
new file mode 100644
index 0000000..f785d91
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/packed-refs
@@ -0,0 +1,2 @@
+# pack-refs with: peeled fully-peeled sorted
+205ac761f3326a7ebe416e8673760016450b5cec refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/refs/heads/master b/tests/gitea-repositories-meta/user2/repo2.git/refs/heads/master
new file mode 100644
index 0000000..334d09c
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/refs/heads/master
@@ -0,0 +1 @@
+1032bbf17fbc0d9c95bb5418dabe8f8c99278700
diff --git a/tests/gitea-repositories-meta/user2/repo2.git/refs/tags/v1.1 b/tests/gitea-repositories-meta/user2/repo2.git/refs/tags/v1.1
new file mode 100644
index 0000000..334d09c
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo2.git/refs/tags/v1.1
@@ -0,0 +1 @@
+1032bbf17fbc0d9c95bb5418dabe8f8c99278700
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/HEAD b/tests/gitea-repositories-meta/user2/repo20.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/config b/tests/gitea-repositories-meta/user2/repo20.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/description b/tests/gitea-repositories-meta/user2/repo20.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/hooks/post-receive b/tests/gitea-repositories-meta/user2/repo20.git/hooks/post-receive
new file mode 100755
index 0000000..f1f2709
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/hooks/post-receive
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+test -x "${hook}" && test -f "${hook}" || continue
+echo "${data}" | "${hook}"
+exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+[ ${i} -eq 0 ] || exit ${i}
+done
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/hooks/post-receive.d/gitea b/tests/gitea-repositories-meta/user2/repo20.git/hooks/post-receive.d/gitea
new file mode 100755
index 0000000..43a948d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/hooks/post-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" post-receive
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/hooks/pre-receive b/tests/gitea-repositories-meta/user2/repo20.git/hooks/pre-receive
new file mode 100755
index 0000000..f1f2709
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/hooks/pre-receive
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+test -x "${hook}" && test -f "${hook}" || continue
+echo "${data}" | "${hook}"
+exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+[ ${i} -eq 0 ] || exit ${i}
+done
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/hooks/pre-receive.d/gitea b/tests/gitea-repositories-meta/user2/repo20.git/hooks/pre-receive.d/gitea
new file mode 100755
index 0000000..49d0940
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/hooks/pre-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" pre-receive
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/hooks/update b/tests/gitea-repositories-meta/user2/repo20.git/hooks/update
new file mode 100755
index 0000000..df5bd27
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/hooks/update
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+test -x "${hook}" && test -f "${hook}" || continue
+"${hook}" $1 $2 $3
+exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+[ ${i} -eq 0 ] || exit ${i}
+done
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/hooks/update.d/gitea b/tests/gitea-repositories-meta/user2/repo20.git/hooks/update.d/gitea
new file mode 100755
index 0000000..38101c2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/hooks/update.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" update $1 $2 $3
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/info/exclude b/tests/gitea-repositories-meta/user2/repo20.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/info/refs b/tests/gitea-repositories-meta/user2/repo20.git/info/refs
new file mode 100644
index 0000000..6d83c82
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/info/refs
@@ -0,0 +1 @@
+808038d2f71b0ab020991439cffd24309c7bc530 refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/02/15cbe13d2695a2c3464ab5e59f47f37c3ff5d5 b/tests/gitea-repositories-meta/user2/repo20.git/objects/02/15cbe13d2695a2c3464ab5e59f47f37c3ff5d5
new file mode 100644
index 0000000..17868e9
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/02/15cbe13d2695a2c3464ab5e59f47f37c3ff5d5
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/05/81d7edf45206787ff93956ea892e8a2ae77604 b/tests/gitea-repositories-meta/user2/repo20.git/objects/05/81d7edf45206787ff93956ea892e8a2ae77604
new file mode 100644
index 0000000..10ab94a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/05/81d7edf45206787ff93956ea892e8a2ae77604
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/07/0b2e783a6b3e521a23fdead377a3e41a04410d b/tests/gitea-repositories-meta/user2/repo20.git/objects/07/0b2e783a6b3e521a23fdead377a3e41a04410d
new file mode 100644
index 0000000..7ec6df1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/07/0b2e783a6b3e521a23fdead377a3e41a04410d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/1b/271d83842d348b1ee71d8e6ead400aaeb4d1b5 b/tests/gitea-repositories-meta/user2/repo20.git/objects/1b/271d83842d348b1ee71d8e6ead400aaeb4d1b5
new file mode 100644
index 0000000..01b07ff
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/1b/271d83842d348b1ee71d8e6ead400aaeb4d1b5
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/29/5ba6ac57fdd46f62a51272f40e60b6dea697b2 b/tests/gitea-repositories-meta/user2/repo20.git/objects/29/5ba6ac57fdd46f62a51272f40e60b6dea697b2
new file mode 100644
index 0000000..8a24f2e
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/29/5ba6ac57fdd46f62a51272f40e60b6dea697b2
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/2c/ec0f7069ed09d934e904c49f414d8bdf818ce4 b/tests/gitea-repositories-meta/user2/repo20.git/objects/2c/ec0f7069ed09d934e904c49f414d8bdf818ce4
new file mode 100644
index 0000000..c113af8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/2c/ec0f7069ed09d934e904c49f414d8bdf818ce4
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/41/4a282859758ba7b159bfbd9c2b193eb8f135ee b/tests/gitea-repositories-meta/user2/repo20.git/objects/41/4a282859758ba7b159bfbd9c2b193eb8f135ee
new file mode 100644
index 0000000..c6fb0cf
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/41/4a282859758ba7b159bfbd9c2b193eb8f135ee
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/79/adb592126eddce5f656f56db797910db025af0 b/tests/gitea-repositories-meta/user2/repo20.git/objects/79/adb592126eddce5f656f56db797910db025af0
new file mode 100644
index 0000000..0071ac7
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/79/adb592126eddce5f656f56db797910db025af0
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/80/8038d2f71b0ab020991439cffd24309c7bc530 b/tests/gitea-repositories-meta/user2/repo20.git/objects/80/8038d2f71b0ab020991439cffd24309c7bc530
new file mode 100644
index 0000000..21147c8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/80/8038d2f71b0ab020991439cffd24309c7bc530
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/83/70977f63979e140b6b58992b1fdb4098b24cd9 b/tests/gitea-repositories-meta/user2/repo20.git/objects/83/70977f63979e140b6b58992b1fdb4098b24cd9
new file mode 100644
index 0000000..3a20da8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/83/70977f63979e140b6b58992b1fdb4098b24cd9
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/8b/abce967f21b9dfa6987f943b91093dac58a4f0 b/tests/gitea-repositories-meta/user2/repo20.git/objects/8b/abce967f21b9dfa6987f943b91093dac58a4f0
new file mode 100644
index 0000000..06bf6dc
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/8b/abce967f21b9dfa6987f943b91093dac58a4f0
@@ -0,0 +1 @@
+x•ŽAnÃ0 sÖ+ø”l‘ Ð[_A‹TkIJC>ø÷Õz[,f1›·Zç!øKÛÍ€“èSðL5[,©DÒ‰':aˆRнe·µÁDlã È:^Cg”lHƒdæ‡Â>iqr´ßm‡ïs1ø‚ÛKº­m=?Uæå3oõž˜®òðÑõ¶¿köß{ª‚@wÔʼ˜ûE] \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/8c/e1dee41e1a3700819a9a309f275f8dc7b7e0b6 b/tests/gitea-repositories-meta/user2/repo20.git/objects/8c/e1dee41e1a3700819a9a309f275f8dc7b7e0b6
new file mode 100644
index 0000000..fa58c03
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/8c/e1dee41e1a3700819a9a309f275f8dc7b7e0b6
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/a4/202876cd8bbc3f38b7d99594edbe1bb7f97a6f b/tests/gitea-repositories-meta/user2/repo20.git/objects/a4/202876cd8bbc3f38b7d99594edbe1bb7f97a6f
new file mode 100644
index 0000000..5096e42
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/a4/202876cd8bbc3f38b7d99594edbe1bb7f97a6f
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/b0/246d5964a3630491bd06c756be46513e3d7035 b/tests/gitea-repositories-meta/user2/repo20.git/objects/b0/246d5964a3630491bd06c756be46513e3d7035
new file mode 100644
index 0000000..88d468e
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/b0/246d5964a3630491bd06c756be46513e3d7035
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/b6/7e43a07d48243a5f670ace063acd5e13f719df b/tests/gitea-repositories-meta/user2/repo20.git/objects/b6/7e43a07d48243a5f670ace063acd5e13f719df
new file mode 100644
index 0000000..794a74a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/b6/7e43a07d48243a5f670ace063acd5e13f719df
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/ba/3aeafe10402c6b29535a58d91def7e43638d9d b/tests/gitea-repositories-meta/user2/repo20.git/objects/ba/3aeafe10402c6b29535a58d91def7e43638d9d
new file mode 100644
index 0000000..eeb034d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/ba/3aeafe10402c6b29535a58d91def7e43638d9d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/c5/0ac6b9e25abb8200bb377755367d7265c581cf b/tests/gitea-repositories-meta/user2/repo20.git/objects/c5/0ac6b9e25abb8200bb377755367d7265c581cf
new file mode 100644
index 0000000..7b03dcc
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/c5/0ac6b9e25abb8200bb377755367d7265c581cf
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/c8/e31bc7688741a5287fcde4fbb8fc129ca07027 b/tests/gitea-repositories-meta/user2/repo20.git/objects/c8/e31bc7688741a5287fcde4fbb8fc129ca07027
new file mode 100644
index 0000000..48bb1a4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/c8/e31bc7688741a5287fcde4fbb8fc129ca07027
@@ -0,0 +1,2 @@
+x•ÎA
+Â0@Q×9Å\@™NÒ&wž"“L´Ø4Ò¦‚·×+¸ýðàÇZÊØ€ˆvm`ÉÙ!&ÇuÖŽmò¾÷FKÇl³·aÈê™8t¨]¢l;ÆÀHè}g´9'2}´{*líQ¸}&+Ÿi+unóv¾—0N‡XË ºÁŽ,!Â{Dõ«¿»&ÿ:uI š¬ âú†<N¢¾¨qEo \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a b/tests/gitea-repositories-meta/user2/repo20.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
new file mode 100644
index 0000000..6802d49
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/cf/e3b3c1fd36fba04f9183287b106497e1afe986 b/tests/gitea-repositories-meta/user2/repo20.git/objects/cf/e3b3c1fd36fba04f9183287b106497e1afe986
new file mode 100644
index 0000000..ed40dd0
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/cf/e3b3c1fd36fba04f9183287b106497e1afe986
@@ -0,0 +1,3 @@
+x•ŽAnÂ0EYûs=v2FB v=D5¶';U˜Tâöø
+Ýþ§÷ôóZ묀º‰€%›P(z“—£ŸŠpñDì%8¶!8[Ì/oÒrïR¦1FêpÀHS.¦”┞3÷$’á]ëßïEà—gÙëÚ´í×{åy9åµ~{ ÏŽv°Öôµ¿Sù¯gn²ˆ
+¨¼ô”_À­À2·çÏc6tuI‚ \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/db/89c972fc57862eae378f45b74aca228037d415 b/tests/gitea-repositories-meta/user2/repo20.git/objects/db/89c972fc57862eae378f45b74aca228037d415
new file mode 100644
index 0000000..c627859
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/db/89c972fc57862eae378f45b74aca228037d415
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/ea/f5f7510320b6a327fb308379de2f94d8859a54 b/tests/gitea-repositories-meta/user2/repo20.git/objects/ea/f5f7510320b6a327fb308379de2f94d8859a54
new file mode 100644
index 0000000..5302511
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/ea/f5f7510320b6a327fb308379de2f94d8859a54
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/objects/info/packs b/tests/gitea-repositories-meta/user2/repo20.git/objects/info/packs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/objects/info/packs
@@ -0,0 +1 @@
+
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/refs/heads/add-csv b/tests/gitea-repositories-meta/user2/repo20.git/refs/heads/add-csv
new file mode 100644
index 0000000..c95a517
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/refs/heads/add-csv
@@ -0,0 +1 @@
+c8e31bc7688741a5287fcde4fbb8fc129ca07027
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/refs/heads/master b/tests/gitea-repositories-meta/user2/repo20.git/refs/heads/master
new file mode 100644
index 0000000..66b845c
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/refs/heads/master
@@ -0,0 +1 @@
+808038d2f71b0ab020991439cffd24309c7bc530
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/refs/heads/remove-files-a b/tests/gitea-repositories-meta/user2/repo20.git/refs/heads/remove-files-a
new file mode 100644
index 0000000..138f2f4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/refs/heads/remove-files-a
@@ -0,0 +1 @@
+cfe3b3c1fd36fba04f9183287b106497e1afe986
diff --git a/tests/gitea-repositories-meta/user2/repo20.git/refs/heads/remove-files-b b/tests/gitea-repositories-meta/user2/repo20.git/refs/heads/remove-files-b
new file mode 100644
index 0000000..04270e2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo20.git/refs/heads/remove-files-b
@@ -0,0 +1 @@
+8babce967f21b9dfa6987f943b91093dac58a4f0
diff --git a/tests/gitea-repositories-meta/user2/repo59.git/HEAD b/tests/gitea-repositories-meta/user2/repo59.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo59.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/repo59.git/config b/tests/gitea-repositories-meta/user2/repo59.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo59.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/repo59.git/description b/tests/gitea-repositories-meta/user2/repo59.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo59.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/repo59.git/info/exclude b/tests/gitea-repositories-meta/user2/repo59.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo59.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/repo59.git/objects/info/commit-graph b/tests/gitea-repositories-meta/user2/repo59.git/objects/info/commit-graph
new file mode 100644
index 0000000..d151dc8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo59.git/objects/info/commit-graph
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo59.git/objects/info/packs b/tests/gitea-repositories-meta/user2/repo59.git/objects/info/packs
new file mode 100644
index 0000000..0374746
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo59.git/objects/info/packs
@@ -0,0 +1,2 @@
+P pack-6dd3a6fe138f1d77e14c2e6b8e6c41e5ae242adf.pack
+
diff --git a/tests/gitea-repositories-meta/user2/repo59.git/objects/pack/pack-6dd3a6fe138f1d77e14c2e6b8e6c41e5ae242adf.idx b/tests/gitea-repositories-meta/user2/repo59.git/objects/pack/pack-6dd3a6fe138f1d77e14c2e6b8e6c41e5ae242adf.idx
new file mode 100644
index 0000000..aaa9981
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo59.git/objects/pack/pack-6dd3a6fe138f1d77e14c2e6b8e6c41e5ae242adf.idx
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo59.git/objects/pack/pack-6dd3a6fe138f1d77e14c2e6b8e6c41e5ae242adf.pack b/tests/gitea-repositories-meta/user2/repo59.git/objects/pack/pack-6dd3a6fe138f1d77e14c2e6b8e6c41e5ae242adf.pack
new file mode 100644
index 0000000..ddb8c16
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo59.git/objects/pack/pack-6dd3a6fe138f1d77e14c2e6b8e6c41e5ae242adf.pack
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo59.git/objects/pack/pack-6dd3a6fe138f1d77e14c2e6b8e6c41e5ae242adf.rev b/tests/gitea-repositories-meta/user2/repo59.git/objects/pack/pack-6dd3a6fe138f1d77e14c2e6b8e6c41e5ae242adf.rev
new file mode 100644
index 0000000..81554db
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo59.git/objects/pack/pack-6dd3a6fe138f1d77e14c2e6b8e6c41e5ae242adf.rev
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/repo59.git/packed-refs b/tests/gitea-repositories-meta/user2/repo59.git/packed-refs
new file mode 100644
index 0000000..77fedbf
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/repo59.git/packed-refs
@@ -0,0 +1,4 @@
+# pack-refs with: peeled fully-peeled sorted
+d8f53dfb33f6ccf4169c34970b5e747511c18beb refs/heads/cake-recipe
+80b83c5c8220c3aa3906e081f202a2a7563ec879 refs/heads/master
+d8f53dfb33f6ccf4169c34970b5e747511c18beb refs/tags/v1.0
diff --git a/tests/gitea-repositories-meta/user2/test_commit_revert.git/HEAD b/tests/gitea-repositories-meta/user2/test_commit_revert.git/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_commit_revert.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/tests/gitea-repositories-meta/user2/test_commit_revert.git/config b/tests/gitea-repositories-meta/user2/test_commit_revert.git/config
new file mode 100644
index 0000000..57bbcba
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_commit_revert.git/config
@@ -0,0 +1,8 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+ ignorecase = true
+ precomposeunicode = true
+[remote "origin"]
+ url = https://try.gitea.io/me-heer/test_commit_revert.git
diff --git a/tests/gitea-repositories-meta/user2/test_commit_revert.git/description b/tests/gitea-repositories-meta/user2/test_commit_revert.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_commit_revert.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/test_commit_revert.git/info/exclude b/tests/gitea-repositories-meta/user2/test_commit_revert.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_commit_revert.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/test_commit_revert.git/objects/pack/pack-91200c8e6707636a6cc3e0d8101fba08b19dcb91.idx b/tests/gitea-repositories-meta/user2/test_commit_revert.git/objects/pack/pack-91200c8e6707636a6cc3e0d8101fba08b19dcb91.idx
new file mode 100644
index 0000000..77bcbe7
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_commit_revert.git/objects/pack/pack-91200c8e6707636a6cc3e0d8101fba08b19dcb91.idx
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/test_commit_revert.git/objects/pack/pack-91200c8e6707636a6cc3e0d8101fba08b19dcb91.pack b/tests/gitea-repositories-meta/user2/test_commit_revert.git/objects/pack/pack-91200c8e6707636a6cc3e0d8101fba08b19dcb91.pack
new file mode 100644
index 0000000..7271cda
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_commit_revert.git/objects/pack/pack-91200c8e6707636a6cc3e0d8101fba08b19dcb91.pack
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/test_commit_revert.git/packed-refs b/tests/gitea-repositories-meta/user2/test_commit_revert.git/packed-refs
new file mode 100644
index 0000000..1f546d7
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_commit_revert.git/packed-refs
@@ -0,0 +1,3 @@
+# pack-refs with: peeled fully-peeled sorted
+46aa6ab2c881ae90e15d9ccfc947d1625c892ce5 refs/heads/develop
+deebcbc752e540bab4ce3ee713d3fc8fdc35b2f7 refs/heads/main
diff --git a/tests/gitea-repositories-meta/user2/test_commit_revert.git/refs/heads/main b/tests/gitea-repositories-meta/user2/test_commit_revert.git/refs/heads/main
new file mode 100644
index 0000000..ab80ca3
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_commit_revert.git/refs/heads/main
@@ -0,0 +1 @@
+deebcbc752e540bab4ce3ee713d3fc8fdc35b2f7
diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/HEAD b/tests/gitea-repositories-meta/user2/test_workflows.git/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_workflows.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/config b/tests/gitea-repositories-meta/user2/test_workflows.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_workflows.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/description b/tests/gitea-repositories-meta/user2/test_workflows.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_workflows.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/info/exclude b/tests/gitea-repositories-meta/user2/test_workflows.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_workflows.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/objects/26/c8f930a36802d9cfb9785ca88704b1f52347aa b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/26/c8f930a36802d9cfb9785ca88704b1f52347aa
new file mode 100644
index 0000000..439b74a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/26/c8f930a36802d9cfb9785ca88704b1f52347aa
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/7f57e0a452699a5d2da0e42dcb2375de546c0a b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/7f57e0a452699a5d2da0e42dcb2375de546c0a
new file mode 100644
index 0000000..ac62185
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/7f57e0a452699a5d2da0e42dcb2375de546c0a
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/89b2afa3e19e924330b4307a181714a4179010 b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/89b2afa3e19e924330b4307a181714a4179010
new file mode 100644
index 0000000..156f4ee
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/2d/89b2afa3e19e924330b4307a181714a4179010
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904 b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904
new file mode 100644
index 0000000..adf6411
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/4b/825dc642cb6eb9a060e54bf8d69288fbee4904
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/objects/77/4f93df12d14931ea93259ae93418da4482fcc1 b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/77/4f93df12d14931ea93259ae93418da4482fcc1
new file mode 100644
index 0000000..036a82d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/77/4f93df12d14931ea93259ae93418da4482fcc1
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/objects/96/63cd4783a54f3e57b2dd908b077cf8126c826c b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/96/63cd4783a54f3e57b2dd908b077cf8126c826c
new file mode 100644
index 0000000..c07ce1e
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_workflows.git/objects/96/63cd4783a54f3e57b2dd908b077cf8126c826c
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/test_workflows.git/packed-refs b/tests/gitea-repositories-meta/user2/test_workflows.git/packed-refs
new file mode 100644
index 0000000..24867ee
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/test_workflows.git/packed-refs
@@ -0,0 +1,3 @@
+# pack-refs with: peeled fully-peeled sorted
+774f93df12d14931ea93259ae93418da4482fcc1 refs/heads/main
+774f93df12d14931ea93259ae93418da4482fcc1 refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/HEAD b/tests/gitea-repositories-meta/user2/utf8.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/config b/tests/gitea-repositories-meta/user2/utf8.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/description b/tests/gitea-repositories-meta/user2/utf8.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/hooks/post-receive b/tests/gitea-repositories-meta/user2/utf8.git/hooks/post-receive
new file mode 100755
index 0000000..4b3d452
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/hooks/post-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/post-receive.d"`; do
+ sh "$SHELL_FOLDER/post-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/hooks/post-receive.d/gitea b/tests/gitea-repositories-meta/user2/utf8.git/hooks/post-receive.d/gitea
new file mode 100755
index 0000000..43a948d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/hooks/post-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" post-receive
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/hooks/pre-receive b/tests/gitea-repositories-meta/user2/utf8.git/hooks/pre-receive
new file mode 100755
index 0000000..4127013
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/hooks/pre-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/pre-receive.d"`; do
+ sh "$SHELL_FOLDER/pre-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/hooks/pre-receive.d/gitea b/tests/gitea-repositories-meta/user2/utf8.git/hooks/pre-receive.d/gitea
new file mode 100755
index 0000000..49d0940
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/hooks/pre-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" pre-receive
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/hooks/update b/tests/gitea-repositories-meta/user2/utf8.git/hooks/update
new file mode 100755
index 0000000..c186fe4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/hooks/update
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/update.d"`; do
+ sh "$SHELL_FOLDER/update.d/$i" $1 $2 $3
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/hooks/update.d/gitea b/tests/gitea-repositories-meta/user2/utf8.git/hooks/update.d/gitea
new file mode 100755
index 0000000..38101c2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/hooks/update.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" update $1 $2 $3
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/info/exclude b/tests/gitea-repositories-meta/user2/utf8.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/info/refs b/tests/gitea-repositories-meta/user2/utf8.git/info/refs
new file mode 100644
index 0000000..29eaf9f
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/info/refs
@@ -0,0 +1,9 @@
+ebf146f803fccbc1471ef01d8fa0fe12c14e61a5 refs/heads/Grüßen
+3a810dbf6b96afaa8c5f69a8b6ec1dabfca7368b refs/heads/Plus+Is+Not+Space
+3aa73c3499bff049a352b4e265575373e964b89a refs/heads/master
+ebf146f803fccbc1471ef01d8fa0fe12c14e61a5 refs/heads/ГлавнаÑВетка
+ebf146f803fccbc1471ef01d8fa0fe12c14e61a5 refs/heads/а/б/в
+28d579e4920fbf4f66e71dab3e779d9fbf41422a refs/heads/ブランãƒ
+ebf146f803fccbc1471ef01d8fa0fe12c14e61a5 refs/tags/Ð/人
+ebf146f803fccbc1471ef01d8fa0fe12c14e61a5 refs/tags/ТÑг
+28d579e4920fbf4f66e71dab3e779d9fbf41422a refs/tags/ã‚¿ã‚°
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/14/c42687126acae9d1ad41d7bdb528f811065a6a b/tests/gitea-repositories-meta/user2/utf8.git/objects/14/c42687126acae9d1ad41d7bdb528f811065a6a
new file mode 100644
index 0000000..19fdbf1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/14/c42687126acae9d1ad41d7bdb528f811065a6a
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/1d/5e00f305a7ca6a8a94e65456820a6d260adab8 b/tests/gitea-repositories-meta/user2/utf8.git/objects/1d/5e00f305a7ca6a8a94e65456820a6d260adab8
new file mode 100644
index 0000000..684b457
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/1d/5e00f305a7ca6a8a94e65456820a6d260adab8
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/28/d579e4920fbf4f66e71dab3e779d9fbf41422a b/tests/gitea-repositories-meta/user2/utf8.git/objects/28/d579e4920fbf4f66e71dab3e779d9fbf41422a
new file mode 100644
index 0000000..413ef4c
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/28/d579e4920fbf4f66e71dab3e779d9fbf41422a
@@ -0,0 +1,3 @@
+x•ŽAjÄ0 E»ö)´œR(²ci<PJ{Å–˜)ILo_ÓM×]}xü/×u½uDO½©‚/¤ˆ6!É9 K’KT¦Hœ
+—À(EæäviºuÐÙ|dK8YÎsöñìÕЗd‚¦> ¢ì…œÜûµ6¸Ú¼ý·>dÝ}Íu}OñÄ)xÁ€èi]ÿ%¹ÏRàKvÙôP°Û¢Ð똛lù
+u[¾ád£§ëÑÇ£>»²´QÑ \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/3a/810dbf6b96afaa8c5f69a8b6ec1dabfca7368b b/tests/gitea-repositories-meta/user2/utf8.git/objects/3a/810dbf6b96afaa8c5f69a8b6ec1dabfca7368b
new file mode 100644
index 0000000..4f6634b
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/3a/810dbf6b96afaa8c5f69a8b6ec1dabfca7368b
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/3a/a73c3499bff049a352b4e265575373e964b89a b/tests/gitea-repositories-meta/user2/utf8.git/objects/3a/a73c3499bff049a352b4e265575373e964b89a
new file mode 100644
index 0000000..0fcdfdf
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/3a/a73c3499bff049a352b4e265575373e964b89a
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/3a/c6084110205f98174c4f1ec7e78cb21a15dfc2 b/tests/gitea-repositories-meta/user2/utf8.git/objects/3a/c6084110205f98174c4f1ec7e78cb21a15dfc2
new file mode 100644
index 0000000..6d9e6b3
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/3a/c6084110205f98174c4f1ec7e78cb21a15dfc2
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/4c/61dd0a799e0830e77edfe6c74f7c349bc8e62a b/tests/gitea-repositories-meta/user2/utf8.git/objects/4c/61dd0a799e0830e77edfe6c74f7c349bc8e62a
new file mode 100644
index 0000000..17b3104
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/4c/61dd0a799e0830e77edfe6c74f7c349bc8e62a
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/50/4d9fe743979d4e9785a25a363c7007293f0838 b/tests/gitea-repositories-meta/user2/utf8.git/objects/50/4d9fe743979d4e9785a25a363c7007293f0838
new file mode 100644
index 0000000..25794ae
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/50/4d9fe743979d4e9785a25a363c7007293f0838
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/56/92bcf9f7c9eacb1ad68442161f2573877f96f4 b/tests/gitea-repositories-meta/user2/utf8.git/objects/56/92bcf9f7c9eacb1ad68442161f2573877f96f4
new file mode 100644
index 0000000..36c0db1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/56/92bcf9f7c9eacb1ad68442161f2573877f96f4
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/59/e2c41e8f5140bb0182acebec17c8ad9831cc62 b/tests/gitea-repositories-meta/user2/utf8.git/objects/59/e2c41e8f5140bb0182acebec17c8ad9831cc62
new file mode 100644
index 0000000..736a242
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/59/e2c41e8f5140bb0182acebec17c8ad9831cc62
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/64/89894ad11093fdc49c0ed857d80682344a7264 b/tests/gitea-repositories-meta/user2/utf8.git/objects/64/89894ad11093fdc49c0ed857d80682344a7264
new file mode 100644
index 0000000..87e198a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/64/89894ad11093fdc49c0ed857d80682344a7264
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/6d/0c79ce3401c67d1ad522e61c47083a9fdee16c b/tests/gitea-repositories-meta/user2/utf8.git/objects/6d/0c79ce3401c67d1ad522e61c47083a9fdee16c
new file mode 100644
index 0000000..dab81f8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/6d/0c79ce3401c67d1ad522e61c47083a9fdee16c
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/84/7c6d93c6860dd377651245711b7fbcd34a18d4 b/tests/gitea-repositories-meta/user2/utf8.git/objects/84/7c6d93c6860dd377651245711b7fbcd34a18d4
new file mode 100644
index 0000000..ffea321
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/84/7c6d93c6860dd377651245711b7fbcd34a18d4
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/9b/9cc8f558d1c4f815592496fa24308ba2a9c824 b/tests/gitea-repositories-meta/user2/utf8.git/objects/9b/9cc8f558d1c4f815592496fa24308ba2a9c824
new file mode 100644
index 0000000..8f033d5
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/9b/9cc8f558d1c4f815592496fa24308ba2a9c824
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/a4/f1bb3f2f8c6a0e840e935812ef4903ce515dad b/tests/gitea-repositories-meta/user2/utf8.git/objects/a4/f1bb3f2f8c6a0e840e935812ef4903ce515dad
new file mode 100644
index 0000000..9655a74
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/a4/f1bb3f2f8c6a0e840e935812ef4903ce515dad
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/a9/a61830fbf4e84999d3b20cf178954366701fe5 b/tests/gitea-repositories-meta/user2/utf8.git/objects/a9/a61830fbf4e84999d3b20cf178954366701fe5
new file mode 100644
index 0000000..a2ceb00
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/a9/a61830fbf4e84999d3b20cf178954366701fe5
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/c7/85b65bf16928b58567cb23669125c0ccd25a4f b/tests/gitea-repositories-meta/user2/utf8.git/objects/c7/85b65bf16928b58567cb23669125c0ccd25a4f
new file mode 100644
index 0000000..2cc606b
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/c7/85b65bf16928b58567cb23669125c0ccd25a4f
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/e9/63733b8a355cf860c465b4af7b236a6ef08783 b/tests/gitea-repositories-meta/user2/utf8.git/objects/e9/63733b8a355cf860c465b4af7b236a6ef08783
new file mode 100644
index 0000000..8d16f34
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/e9/63733b8a355cf860c465b4af7b236a6ef08783
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/eb/f146f803fccbc1471ef01d8fa0fe12c14e61a5 b/tests/gitea-repositories-meta/user2/utf8.git/objects/eb/f146f803fccbc1471ef01d8fa0fe12c14e61a5
new file mode 100644
index 0000000..eec8265
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/eb/f146f803fccbc1471ef01d8fa0fe12c14e61a5
@@ -0,0 +1 @@
+x•Ž]jC!…ûì*æ½PtFÇ+”Ò.e4#®¹Ášì¾¶;èÓÃùùÊÑ{›€ž_æPÕÄ—«OXÙÓær­±bÚ2Š+Åyg®2ô2D"ò)­õI(`öŠB iÍù¼%1rŸçcÀý¦áýO>õ!ýºë[9ú¸@‰-!¼Z´Ö,w¡MýWÉ|NPÛ®ðÝæÊs´}o.Òæñûrƒ¡UQóˆM‡ \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/ee/9686cb562f492f64381bff7f298b2a1c67a141 b/tests/gitea-repositories-meta/user2/utf8.git/objects/ee/9686cb562f492f64381bff7f298b2a1c67a141
new file mode 100644
index 0000000..013c499
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/ee/9686cb562f492f64381bff7f298b2a1c67a141
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/f4/02ff67c0b3161c3988dbf6188e6e0df257fd75 b/tests/gitea-repositories-meta/user2/utf8.git/objects/f4/02ff67c0b3161c3988dbf6188e6e0df257fd75
new file mode 100644
index 0000000..4ce3cc4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/f4/02ff67c0b3161c3988dbf6188e6e0df257fd75
Binary files differ
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/objects/info/packs b/tests/gitea-repositories-meta/user2/utf8.git/objects/info/packs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/objects/info/packs
@@ -0,0 +1 @@
+
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/Grüßen b/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/Grüßen
new file mode 100644
index 0000000..abd3364
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/Grüßen
@@ -0,0 +1 @@
+ebf146f803fccbc1471ef01d8fa0fe12c14e61a5
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/Plus+Is+Not+Space b/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/Plus+Is+Not+Space
new file mode 100644
index 0000000..c2850d4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/Plus+Is+Not+Space
@@ -0,0 +1 @@
+59e2c41e8f5140bb0182acebec17c8ad9831cc62
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/master b/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/master
new file mode 100644
index 0000000..560458b
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/master
@@ -0,0 +1 @@
+3aa73c3499bff049a352b4e265575373e964b89a
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/ГлавнаÑВетка b/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/ГлавнаÑВетка
new file mode 100644
index 0000000..abd3364
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/ГлавнаÑВетка
@@ -0,0 +1 @@
+ebf146f803fccbc1471ef01d8fa0fe12c14e61a5
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/а/б/в b/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/а/б/в
new file mode 100644
index 0000000..abd3364
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/а/б/в
@@ -0,0 +1 @@
+ebf146f803fccbc1471ef01d8fa0fe12c14e61a5
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/ブランムb/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/ブランãƒ
new file mode 100644
index 0000000..b0935a9
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/refs/heads/ブランãƒ
@@ -0,0 +1 @@
+28d579e4920fbf4f66e71dab3e779d9fbf41422a
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/refs/tags/Ð/人 b/tests/gitea-repositories-meta/user2/utf8.git/refs/tags/Ð/人
new file mode 100644
index 0000000..abd3364
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/refs/tags/Ð/人
@@ -0,0 +1 @@
+ebf146f803fccbc1471ef01d8fa0fe12c14e61a5
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/refs/tags/ТÑг b/tests/gitea-repositories-meta/user2/utf8.git/refs/tags/ТÑг
new file mode 100644
index 0000000..abd3364
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/refs/tags/ТÑг
@@ -0,0 +1 @@
+ebf146f803fccbc1471ef01d8fa0fe12c14e61a5
diff --git a/tests/gitea-repositories-meta/user2/utf8.git/refs/tags/ã‚¿ã‚° b/tests/gitea-repositories-meta/user2/utf8.git/refs/tags/ã‚¿ã‚°
new file mode 100644
index 0000000..b0935a9
--- /dev/null
+++ b/tests/gitea-repositories-meta/user2/utf8.git/refs/tags/ã‚¿ã‚°
@@ -0,0 +1 @@
+28d579e4920fbf4f66e71dab3e779d9fbf41422a
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/HEAD b/tests/gitea-repositories-meta/user27/repo49.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/config b/tests/gitea-repositories-meta/user27/repo49.git/config
new file mode 100644
index 0000000..64280b8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = true
+ symlinks = false
+ ignorecase = true
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/description b/tests/gitea-repositories-meta/user27/repo49.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/hooks/post-receive b/tests/gitea-repositories-meta/user27/repo49.git/hooks/post-receive
new file mode 100644
index 0000000..f1f2709
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/hooks/post-receive
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+test -x "${hook}" && test -f "${hook}" || continue
+echo "${data}" | "${hook}"
+exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+[ ${i} -eq 0 ] || exit ${i}
+done
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/hooks/post-receive.d/gitea b/tests/gitea-repositories-meta/user27/repo49.git/hooks/post-receive.d/gitea
new file mode 100644
index 0000000..43a948d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/hooks/post-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" post-receive
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/hooks/pre-receive b/tests/gitea-repositories-meta/user27/repo49.git/hooks/pre-receive
new file mode 100644
index 0000000..f1f2709
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/hooks/pre-receive
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+test -x "${hook}" && test -f "${hook}" || continue
+echo "${data}" | "${hook}"
+exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+[ ${i} -eq 0 ] || exit ${i}
+done
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/hooks/pre-receive.d/gitea b/tests/gitea-repositories-meta/user27/repo49.git/hooks/pre-receive.d/gitea
new file mode 100644
index 0000000..49d0940
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/hooks/pre-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" pre-receive
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/hooks/update b/tests/gitea-repositories-meta/user27/repo49.git/hooks/update
new file mode 100644
index 0000000..df5bd27
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/hooks/update
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+test -x "${hook}" && test -f "${hook}" || continue
+"${hook}" $1 $2 $3
+exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+[ ${i} -eq 0 ] || exit ${i}
+done
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/hooks/update.d/gitea b/tests/gitea-repositories-meta/user27/repo49.git/hooks/update.d/gitea
new file mode 100644
index 0000000..38101c2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/hooks/update.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" update $1 $2 $3
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/info/exclude b/tests/gitea-repositories-meta/user27/repo49.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/info/refs b/tests/gitea-repositories-meta/user27/repo49.git/info/refs
new file mode 100644
index 0000000..22f0827
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/info/refs
@@ -0,0 +1 @@
+aacbdfe9e1c4b47f60abe81849045fa4e96f1d75 refs/heads/master
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/objects/47/34b1f84a367fa1b81c31aa4234a5bad11cafa3 b/tests/gitea-repositories-meta/user27/repo49.git/objects/47/34b1f84a367fa1b81c31aa4234a5bad11cafa3
new file mode 100644
index 0000000..b6f121a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/objects/47/34b1f84a367fa1b81c31aa4234a5bad11cafa3
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/objects/4d/31f3a12656368a8d9180f431d40d0fc408be2d b/tests/gitea-repositories-meta/user27/repo49.git/objects/4d/31f3a12656368a8d9180f431d40d0fc408be2d
new file mode 100644
index 0000000..d2f4c1d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/objects/4d/31f3a12656368a8d9180f431d40d0fc408be2d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/objects/51/f84af231345367fd5d61ceb89efb3b6d757061 b/tests/gitea-repositories-meta/user27/repo49.git/objects/51/f84af231345367fd5d61ceb89efb3b6d757061
new file mode 100644
index 0000000..aa34a8a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/objects/51/f84af231345367fd5d61ceb89efb3b6d757061
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/objects/79/3aa682b06ae032641abf70c5dfeade28c07c52 b/tests/gitea-repositories-meta/user27/repo49.git/objects/79/3aa682b06ae032641abf70c5dfeade28c07c52
new file mode 100644
index 0000000..3f9705f
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/objects/79/3aa682b06ae032641abf70c5dfeade28c07c52
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/objects/aa/cbdfe9e1c4b47f60abe81849045fa4e96f1d75 b/tests/gitea-repositories-meta/user27/repo49.git/objects/aa/cbdfe9e1c4b47f60abe81849045fa4e96f1d75
new file mode 100644
index 0000000..74419f4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/objects/aa/cbdfe9e1c4b47f60abe81849045fa4e96f1d75
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/objects/dd/392e939ea4936b2459219c9c9a1f25547ccaeb b/tests/gitea-repositories-meta/user27/repo49.git/objects/dd/392e939ea4936b2459219c9c9a1f25547ccaeb
new file mode 100644
index 0000000..844eb1c
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/objects/dd/392e939ea4936b2459219c9c9a1f25547ccaeb
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/objects/f2/8eeca3df7614fd4f10c1030f13feb418ef3c6f b/tests/gitea-repositories-meta/user27/repo49.git/objects/f2/8eeca3df7614fd4f10c1030f13feb418ef3c6f
new file mode 100644
index 0000000..0699bff
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/objects/f2/8eeca3df7614fd4f10c1030f13feb418ef3c6f
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/objects/info/packs b/tests/gitea-repositories-meta/user27/repo49.git/objects/info/packs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/objects/info/packs
@@ -0,0 +1 @@
+
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/refs/heads/master b/tests/gitea-repositories-meta/user27/repo49.git/refs/heads/master
new file mode 100644
index 0000000..0f13243
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/refs/heads/master
@@ -0,0 +1 @@
+aacbdfe9e1c4b47f60abe81849045fa4e96f1d75
diff --git a/tests/gitea-repositories-meta/user27/repo49.git/refs/heads/test/archive b/tests/gitea-repositories-meta/user27/repo49.git/refs/heads/test/archive
new file mode 100644
index 0000000..0f13243
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/repo49.git/refs/heads/test/archive
@@ -0,0 +1 @@
+aacbdfe9e1c4b47f60abe81849045fa4e96f1d75
diff --git a/tests/gitea-repositories-meta/user27/template1.git/HEAD b/tests/gitea-repositories-meta/user27/template1.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user27/template1.git/config b/tests/gitea-repositories-meta/user27/template1.git/config
new file mode 100644
index 0000000..64280b8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = true
+ symlinks = false
+ ignorecase = true
diff --git a/tests/gitea-repositories-meta/user27/template1.git/description b/tests/gitea-repositories-meta/user27/template1.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user27/template1.git/hooks/post-receive b/tests/gitea-repositories-meta/user27/template1.git/hooks/post-receive
new file mode 100644
index 0000000..f1f2709
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/hooks/post-receive
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+test -x "${hook}" && test -f "${hook}" || continue
+echo "${data}" | "${hook}"
+exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+[ ${i} -eq 0 ] || exit ${i}
+done
diff --git a/tests/gitea-repositories-meta/user27/template1.git/hooks/post-receive.d/gitea b/tests/gitea-repositories-meta/user27/template1.git/hooks/post-receive.d/gitea
new file mode 100644
index 0000000..43a948d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/hooks/post-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" post-receive
diff --git a/tests/gitea-repositories-meta/user27/template1.git/hooks/pre-receive b/tests/gitea-repositories-meta/user27/template1.git/hooks/pre-receive
new file mode 100644
index 0000000..f1f2709
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/hooks/pre-receive
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+test -x "${hook}" && test -f "${hook}" || continue
+echo "${data}" | "${hook}"
+exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+[ ${i} -eq 0 ] || exit ${i}
+done
diff --git a/tests/gitea-repositories-meta/user27/template1.git/hooks/pre-receive.d/gitea b/tests/gitea-repositories-meta/user27/template1.git/hooks/pre-receive.d/gitea
new file mode 100644
index 0000000..49d0940
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/hooks/pre-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" pre-receive
diff --git a/tests/gitea-repositories-meta/user27/template1.git/hooks/update b/tests/gitea-repositories-meta/user27/template1.git/hooks/update
new file mode 100644
index 0000000..df5bd27
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/hooks/update
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+test -x "${hook}" && test -f "${hook}" || continue
+"${hook}" $1 $2 $3
+exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+[ ${i} -eq 0 ] || exit ${i}
+done
diff --git a/tests/gitea-repositories-meta/user27/template1.git/hooks/update.d/gitea b/tests/gitea-repositories-meta/user27/template1.git/hooks/update.d/gitea
new file mode 100644
index 0000000..38101c2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/hooks/update.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" update $1 $2 $3
diff --git a/tests/gitea-repositories-meta/user27/template1.git/info/exclude b/tests/gitea-repositories-meta/user27/template1.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user27/template1.git/info/refs b/tests/gitea-repositories-meta/user27/template1.git/info/refs
new file mode 100644
index 0000000..22f0827
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/info/refs
@@ -0,0 +1 @@
+aacbdfe9e1c4b47f60abe81849045fa4e96f1d75 refs/heads/master
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/2a/83b349fa234131fc5db6f2a0498d3f4d3d6038 b/tests/gitea-repositories-meta/user27/template1.git/objects/2a/83b349fa234131fc5db6f2a0498d3f4d3d6038
new file mode 100644
index 0000000..ab167ce
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/2a/83b349fa234131fc5db6f2a0498d3f4d3d6038
@@ -0,0 +1,2 @@
+xÎAJÅ0€a×9Å\@Ij2™ÂCÞwž"™Ìh±i¥ÞÞ·qïö‡~Þ{_ ¦ ìçæ+cÔ)M•³* rȉSD&’ŠM³û*‡l¥pm*³Ž5fE_ªP 8û˜´D™QCËÉ•aûo?«À+\>ÛèûfÛ¸¾÷²¬O¼÷HH9G"xôÑ{w¯÷;“ÿ8
+iþsîÖœ£ž¶Ø0ï²9Ý/å IH \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/3d/0bc64f2521cfc7ffce6c175c1c846c88eb6df7 b/tests/gitea-repositories-meta/user27/template1.git/objects/3d/0bc64f2521cfc7ffce6c175c1c846c88eb6df7
new file mode 100644
index 0000000..4912a5a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/3d/0bc64f2521cfc7ffce6c175c1c846c88eb6df7
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/47/34b1f84a367fa1b81c31aa4234a5bad11cafa3 b/tests/gitea-repositories-meta/user27/template1.git/objects/47/34b1f84a367fa1b81c31aa4234a5bad11cafa3
new file mode 100644
index 0000000..b6f121a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/47/34b1f84a367fa1b81c31aa4234a5bad11cafa3
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/4d/31f3a12656368a8d9180f431d40d0fc408be2d b/tests/gitea-repositories-meta/user27/template1.git/objects/4d/31f3a12656368a8d9180f431d40d0fc408be2d
new file mode 100644
index 0000000..d2f4c1d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/4d/31f3a12656368a8d9180f431d40d0fc408be2d
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/51/f84af231345367fd5d61ceb89efb3b6d757061 b/tests/gitea-repositories-meta/user27/template1.git/objects/51/f84af231345367fd5d61ceb89efb3b6d757061
new file mode 100644
index 0000000..aa34a8a
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/51/f84af231345367fd5d61ceb89efb3b6d757061
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/79/3aa682b06ae032641abf70c5dfeade28c07c52 b/tests/gitea-repositories-meta/user27/template1.git/objects/79/3aa682b06ae032641abf70c5dfeade28c07c52
new file mode 100644
index 0000000..3f9705f
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/79/3aa682b06ae032641abf70c5dfeade28c07c52
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/83/77b2196e99ac8635aae79df3db76959ccd1094 b/tests/gitea-repositories-meta/user27/template1.git/objects/83/77b2196e99ac8635aae79df3db76959ccd1094
new file mode 100644
index 0000000..6538644
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/83/77b2196e99ac8635aae79df3db76959ccd1094
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/99/45b93bcb5b70af06e0322bd2caa6180680991f b/tests/gitea-repositories-meta/user27/template1.git/objects/99/45b93bcb5b70af06e0322bd2caa6180680991f
new file mode 100644
index 0000000..4af1725
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/99/45b93bcb5b70af06e0322bd2caa6180680991f
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/aa/cbdfe9e1c4b47f60abe81849045fa4e96f1d75 b/tests/gitea-repositories-meta/user27/template1.git/objects/aa/cbdfe9e1c4b47f60abe81849045fa4e96f1d75
new file mode 100644
index 0000000..74419f4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/aa/cbdfe9e1c4b47f60abe81849045fa4e96f1d75
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/af/f5b10402b4e0479d1e76bc41a42d29fe7f28fa b/tests/gitea-repositories-meta/user27/template1.git/objects/af/f5b10402b4e0479d1e76bc41a42d29fe7f28fa
new file mode 100644
index 0000000..5a80075
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/af/f5b10402b4e0479d1e76bc41a42d29fe7f28fa
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/b9/04864fd6cd0c8e9054351fd39a980bfd214229 b/tests/gitea-repositories-meta/user27/template1.git/objects/b9/04864fd6cd0c8e9054351fd39a980bfd214229
new file mode 100644
index 0000000..b5d5d1d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/b9/04864fd6cd0c8e9054351fd39a980bfd214229
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/c5/10abf4c7c3e0dc4bf07db9344c61c4e6ee7cbc b/tests/gitea-repositories-meta/user27/template1.git/objects/c5/10abf4c7c3e0dc4bf07db9344c61c4e6ee7cbc
new file mode 100644
index 0000000..d8ea1e1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/c5/10abf4c7c3e0dc4bf07db9344c61c4e6ee7cbc
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/dd/392e939ea4936b2459219c9c9a1f25547ccaeb b/tests/gitea-repositories-meta/user27/template1.git/objects/dd/392e939ea4936b2459219c9c9a1f25547ccaeb
new file mode 100644
index 0000000..844eb1c
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/dd/392e939ea4936b2459219c9c9a1f25547ccaeb
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391 b/tests/gitea-repositories-meta/user27/template1.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
new file mode 100644
index 0000000..7112238
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/e6/9de29bb2d1d6434b8b29ae775ad8c2e48c5391
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/f2/8eeca3df7614fd4f10c1030f13feb418ef3c6f b/tests/gitea-repositories-meta/user27/template1.git/objects/f2/8eeca3df7614fd4f10c1030f13feb418ef3c6f
new file mode 100644
index 0000000..0699bff
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/f2/8eeca3df7614fd4f10c1030f13feb418ef3c6f
Binary files differ
diff --git a/tests/gitea-repositories-meta/user27/template1.git/objects/info/packs b/tests/gitea-repositories-meta/user27/template1.git/objects/info/packs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/objects/info/packs
@@ -0,0 +1 @@
+
diff --git a/tests/gitea-repositories-meta/user27/template1.git/refs/heads/master b/tests/gitea-repositories-meta/user27/template1.git/refs/heads/master
new file mode 100644
index 0000000..bb42d47
--- /dev/null
+++ b/tests/gitea-repositories-meta/user27/template1.git/refs/heads/master
@@ -0,0 +1 @@
+2a83b349fa234131fc5db6f2a0498d3f4d3d6038
diff --git a/tests/gitea-repositories-meta/user30/empty.git/HEAD b/tests/gitea-repositories-meta/user30/empty.git/HEAD
new file mode 100644
index 0000000..b870d82
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/empty.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/main
diff --git a/tests/gitea-repositories-meta/user30/empty.git/config b/tests/gitea-repositories-meta/user30/empty.git/config
new file mode 100644
index 0000000..7c968c3
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/empty.git/config
@@ -0,0 +1,6 @@
+[core]
+ bare = true
+ repositoryformatversion = 0
+ filemode = false
+ symlinks = false
+ ignorecase = true
diff --git a/tests/gitea-repositories-meta/user30/renderer.git/HEAD b/tests/gitea-repositories-meta/user30/renderer.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/renderer.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user30/renderer.git/config b/tests/gitea-repositories-meta/user30/renderer.git/config
new file mode 100644
index 0000000..e6da231
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/renderer.git/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
+ ignorecase = true
+ precomposeunicode = true
diff --git a/tests/gitea-repositories-meta/user30/renderer.git/description b/tests/gitea-repositories-meta/user30/renderer.git/description
new file mode 100644
index 0000000..04c2397
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/renderer.git/description
@@ -0,0 +1 @@
+The repository will be used to test third-party renderer in TestExternalMarkupRenderer
diff --git a/tests/gitea-repositories-meta/user30/renderer.git/hooks/post-receive b/tests/gitea-repositories-meta/user30/renderer.git/hooks/post-receive
new file mode 100644
index 0000000..f1f2709
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/renderer.git/hooks/post-receive
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+test -x "${hook}" && test -f "${hook}" || continue
+echo "${data}" | "${hook}"
+exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+[ ${i} -eq 0 ] || exit ${i}
+done
diff --git a/tests/gitea-repositories-meta/user30/renderer.git/hooks/post-receive.d/gitea b/tests/gitea-repositories-meta/user30/renderer.git/hooks/post-receive.d/gitea
new file mode 100644
index 0000000..43a948d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/renderer.git/hooks/post-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" post-receive
diff --git a/tests/gitea-repositories-meta/user30/renderer.git/hooks/pre-receive b/tests/gitea-repositories-meta/user30/renderer.git/hooks/pre-receive
new file mode 100644
index 0000000..f1f2709
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/renderer.git/hooks/pre-receive
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+data=$(cat)
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+test -x "${hook}" && test -f "${hook}" || continue
+echo "${data}" | "${hook}"
+exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+[ ${i} -eq 0 ] || exit ${i}
+done
diff --git a/tests/gitea-repositories-meta/user30/renderer.git/hooks/pre-receive.d/gitea b/tests/gitea-repositories-meta/user30/renderer.git/hooks/pre-receive.d/gitea
new file mode 100644
index 0000000..49d0940
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/renderer.git/hooks/pre-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" pre-receive
diff --git a/tests/gitea-repositories-meta/user30/renderer.git/hooks/update b/tests/gitea-repositories-meta/user30/renderer.git/hooks/update
new file mode 100644
index 0000000..df5bd27
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/renderer.git/hooks/update
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+exitcodes=""
+hookname=$(basename $0)
+GIT_DIR=${GIT_DIR:-$(dirname $0)}
+
+for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
+test -x "${hook}" && test -f "${hook}" || continue
+"${hook}" $1 $2 $3
+exitcodes="${exitcodes} $?"
+done
+
+for i in ${exitcodes}; do
+[ ${i} -eq 0 ] || exit ${i}
+done
diff --git a/tests/gitea-repositories-meta/user30/renderer.git/hooks/update.d/gitea b/tests/gitea-repositories-meta/user30/renderer.git/hooks/update.d/gitea
new file mode 100644
index 0000000..38101c2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/renderer.git/hooks/update.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" update $1 $2 $3
diff --git a/tests/gitea-repositories-meta/user30/renderer.git/info/exclude b/tests/gitea-repositories-meta/user30/renderer.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/renderer.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user30/renderer.git/objects/06/0d5c2acd8bf4b6f14010acd1a73d73392ec46e b/tests/gitea-repositories-meta/user30/renderer.git/objects/06/0d5c2acd8bf4b6f14010acd1a73d73392ec46e
new file mode 100644
index 0000000..994f256
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/renderer.git/objects/06/0d5c2acd8bf4b6f14010acd1a73d73392ec46e
Binary files differ
diff --git a/tests/gitea-repositories-meta/user30/renderer.git/objects/45/14a93050edb2c3165bdd0a3c03be063e879e68 b/tests/gitea-repositories-meta/user30/renderer.git/objects/45/14a93050edb2c3165bdd0a3c03be063e879e68
new file mode 100644
index 0000000..b1fff27
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/renderer.git/objects/45/14a93050edb2c3165bdd0a3c03be063e879e68
Binary files differ
diff --git a/tests/gitea-repositories-meta/user30/renderer.git/objects/c9/61cc4d1ba6b7ee1ba228a9a02b00b7746d8033 b/tests/gitea-repositories-meta/user30/renderer.git/objects/c9/61cc4d1ba6b7ee1ba228a9a02b00b7746d8033
new file mode 100644
index 0000000..6648876
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/renderer.git/objects/c9/61cc4d1ba6b7ee1ba228a9a02b00b7746d8033
Binary files differ
diff --git a/tests/gitea-repositories-meta/user30/renderer.git/packed-refs b/tests/gitea-repositories-meta/user30/renderer.git/packed-refs
new file mode 100644
index 0000000..63f8af0
--- /dev/null
+++ b/tests/gitea-repositories-meta/user30/renderer.git/packed-refs
@@ -0,0 +1,2 @@
+# pack-refs with: peeled fully-peeled sorted
+c961cc4d1ba6b7ee1ba228a9a02b00b7746d8033 refs/heads/master
diff --git a/tests/gitea-repositories-meta/user40/repo60.git/HEAD b/tests/gitea-repositories-meta/user40/repo60.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user40/repo60.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user40/repo60.git/config b/tests/gitea-repositories-meta/user40/repo60.git/config
new file mode 100644
index 0000000..64280b8
--- /dev/null
+++ b/tests/gitea-repositories-meta/user40/repo60.git/config
@@ -0,0 +1,6 @@
+[core]
+ repositoryformatversion = 0
+ filemode = false
+ bare = true
+ symlinks = false
+ ignorecase = true
diff --git a/tests/gitea-repositories-meta/user40/repo60.git/description b/tests/gitea-repositories-meta/user40/repo60.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user40/repo60.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user40/repo60.git/info/exclude b/tests/gitea-repositories-meta/user40/repo60.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user40/repo60.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user5/repo4.git/HEAD b/tests/gitea-repositories-meta/user5/repo4.git/HEAD
new file mode 100644
index 0000000..cb089cd
--- /dev/null
+++ b/tests/gitea-repositories-meta/user5/repo4.git/HEAD
@@ -0,0 +1 @@
+ref: refs/heads/master
diff --git a/tests/gitea-repositories-meta/user5/repo4.git/config b/tests/gitea-repositories-meta/user5/repo4.git/config
new file mode 100644
index 0000000..07d359d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user5/repo4.git/config
@@ -0,0 +1,4 @@
+[core]
+ repositoryformatversion = 0
+ filemode = true
+ bare = true
diff --git a/tests/gitea-repositories-meta/user5/repo4.git/description b/tests/gitea-repositories-meta/user5/repo4.git/description
new file mode 100644
index 0000000..498b267
--- /dev/null
+++ b/tests/gitea-repositories-meta/user5/repo4.git/description
@@ -0,0 +1 @@
+Unnamed repository; edit this file 'description' to name the repository.
diff --git a/tests/gitea-repositories-meta/user5/repo4.git/hooks/post-receive b/tests/gitea-repositories-meta/user5/repo4.git/hooks/post-receive
new file mode 100755
index 0000000..4b3d452
--- /dev/null
+++ b/tests/gitea-repositories-meta/user5/repo4.git/hooks/post-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/post-receive.d"`; do
+ sh "$SHELL_FOLDER/post-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user5/repo4.git/hooks/post-receive.d/gitea b/tests/gitea-repositories-meta/user5/repo4.git/hooks/post-receive.d/gitea
new file mode 100755
index 0000000..43a948d
--- /dev/null
+++ b/tests/gitea-repositories-meta/user5/repo4.git/hooks/post-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" post-receive
diff --git a/tests/gitea-repositories-meta/user5/repo4.git/hooks/pre-receive b/tests/gitea-repositories-meta/user5/repo4.git/hooks/pre-receive
new file mode 100755
index 0000000..4127013
--- /dev/null
+++ b/tests/gitea-repositories-meta/user5/repo4.git/hooks/pre-receive
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/pre-receive.d"`; do
+ sh "$SHELL_FOLDER/pre-receive.d/$i"
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user5/repo4.git/hooks/pre-receive.d/gitea b/tests/gitea-repositories-meta/user5/repo4.git/hooks/pre-receive.d/gitea
new file mode 100755
index 0000000..49d0940
--- /dev/null
+++ b/tests/gitea-repositories-meta/user5/repo4.git/hooks/pre-receive.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" pre-receive
diff --git a/tests/gitea-repositories-meta/user5/repo4.git/hooks/update b/tests/gitea-repositories-meta/user5/repo4.git/hooks/update
new file mode 100755
index 0000000..c186fe4
--- /dev/null
+++ b/tests/gitea-repositories-meta/user5/repo4.git/hooks/update
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+ORI_DIR=`pwd`
+SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
+cd "$ORI_DIR"
+for i in `ls "$SHELL_FOLDER/update.d"`; do
+ sh "$SHELL_FOLDER/update.d/$i" $1 $2 $3
+done \ No newline at end of file
diff --git a/tests/gitea-repositories-meta/user5/repo4.git/hooks/update.d/gitea b/tests/gitea-repositories-meta/user5/repo4.git/hooks/update.d/gitea
new file mode 100755
index 0000000..38101c2
--- /dev/null
+++ b/tests/gitea-repositories-meta/user5/repo4.git/hooks/update.d/gitea
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+"$GITEA_ROOT/gitea" hook --config="$GITEA_ROOT/$GITEA_CONF" update $1 $2 $3
diff --git a/tests/gitea-repositories-meta/user5/repo4.git/info/exclude b/tests/gitea-repositories-meta/user5/repo4.git/info/exclude
new file mode 100644
index 0000000..a5196d1
--- /dev/null
+++ b/tests/gitea-repositories-meta/user5/repo4.git/info/exclude
@@ -0,0 +1,6 @@
+# git ls-files --others --exclude-from=.git/info/exclude
+# Lines that start with '#' are comments.
+# For a project mostly in C, the following would be a good set of
+# exclude patterns (uncomment them if you want to use them):
+# *.[oa]
+# *~
diff --git a/tests/gitea-repositories-meta/user5/repo4.git/objects/16/dfebd1ed3905d78d7e061e945fc9c34afe4e81 b/tests/gitea-repositories-meta/user5/repo4.git/objects/16/dfebd1ed3905d78d7e061e945fc9c34afe4e81
new file mode 100644
index 0000000..76d765e
--- /dev/null
+++ b/tests/gitea-repositories-meta/user5/repo4.git/objects/16/dfebd1ed3905d78d7e061e945fc9c34afe4e81
Binary files differ
diff --git a/tests/gitea-repositories-meta/user5/repo4.git/objects/c1/202ad022ae7d3a6d2474dc76d5a0c8e87cdc0f b/tests/gitea-repositories-meta/user5/repo4.git/objects/c1/202ad022ae7d3a6d2474dc76d5a0c8e87cdc0f
new file mode 100644
index 0000000..f63d601
--- /dev/null
+++ b/tests/gitea-repositories-meta/user5/repo4.git/objects/c1/202ad022ae7d3a6d2474dc76d5a0c8e87cdc0f
Binary files differ
diff --git a/tests/gitea-repositories-meta/user5/repo4.git/objects/c7/cd3cd144e6d23c9d6f3d07e52b2c1a956e0338 b/tests/gitea-repositories-meta/user5/repo4.git/objects/c7/cd3cd144e6d23c9d6f3d07e52b2c1a956e0338
new file mode 100644
index 0000000..c8d7c54
--- /dev/null
+++ b/tests/gitea-repositories-meta/user5/repo4.git/objects/c7/cd3cd144e6d23c9d6f3d07e52b2c1a956e0338
Binary files differ
diff --git a/tests/gitea-repositories-meta/user5/repo4.git/refs/heads/master b/tests/gitea-repositories-meta/user5/repo4.git/refs/heads/master
new file mode 100644
index 0000000..5fd26e3
--- /dev/null
+++ b/tests/gitea-repositories-meta/user5/repo4.git/refs/heads/master
@@ -0,0 +1 @@
+c7cd3cd144e6d23c9d6f3d07e52b2c1a956e0338
diff --git a/tests/integration/README.md b/tests/integration/README.md
new file mode 100644
index 0000000..f0fbf94
--- /dev/null
+++ b/tests/integration/README.md
@@ -0,0 +1,123 @@
+# Integration tests
+
+Thank you for your effort to provide good software tests for Forgejo.
+Please also read the general testing instructions in the
+[Forgejo contributor documentation](https://forgejo.org/docs/next/contributor/testing/).
+
+This file is meant to provide specific information for the integration tests
+as well as some tips and tricks you should know.
+
+Feel free to extend this file with more instructions if you feel like you have something to share!
+
+
+## How to run the tests?
+
+Before running any tests, please ensure you perform a clean build:
+
+```
+make clean build
+```
+
+Integration tests can be run with make commands for the
+appropriate backends, namely:
+```shell
+make test-sqlite
+make test-pgsql
+make test-mysql
+```
+
+
+### Run tests via local forgejo runner
+
+If you have a [forgejo runner](https://code.forgejo.org/forgejo/runner/),
+you can use it to run the test jobs:
+
+#### Run all jobs
+
+```
+forgejo-runner exec -W .forgejo/workflows/testing.yml --event=pull_request
+```
+
+Warning: This file defines many jobs, so it will be resource-intensive and therefore not recommended.
+
+#### Run single job
+
+```SHELL
+forgejo-runner exec -W .forgejo/workflows/testing.yml --event=pull_request -j <job_name>
+```
+
+You can list all job names via:
+
+```SHELL
+forgejo-runner exec -W .forgejo/workflows/testing.yml --event=pull_request -l
+```
+
+### Run sqlite integration tests
+Start tests
+```
+make test-sqlite
+```
+
+### Run MySQL integration tests
+Setup a MySQL database inside docker
+```
+docker run -e "MYSQL_DATABASE=test" -e "MYSQL_ALLOW_EMPTY_PASSWORD=yes" -p 3306:3306 --rm --name mysql mysql:latest #(just ctrl-c to stop db and clean the container)
+docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" --rm --name elasticsearch elasticsearch:7.6.0 #(in a second terminal, just ctrl-c to stop db and clean the container)
+```
+Start tests based on the database container
+```
+TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=root TEST_MYSQL_PASSWORD='' make test-mysql
+```
+
+### Run pgsql integration tests
+Setup a pgsql database inside docker
+```
+docker run -e "POSTGRES_DB=test" -p 5432:5432 --rm --name pgsql postgres:latest #(just ctrl-c to stop db and clean the container)
+```
+Start tests based on the database container
+```
+TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=test TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-pgsql
+```
+
+### Running individual tests
+
+Example command to run GPG test:
+
+For SQLite:
+
+```
+make test-sqlite#GPG
+```
+
+For other databases (replace `mysql` to `pgsql`):
+
+```
+TEST_MYSQL_HOST=localhost:1433 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=sa TEST_MYSQL_PASSWORD=MwantsaSecurePassword1 make test-mysql#GPG
+```
+
+## Setting timeouts for declaring long-tests and long-flushes
+
+We appreciate that some testing machines may not be very powerful and
+the default timeouts for declaring a slow test or a slow clean-up flush
+may not be appropriate.
+
+You can either:
+
+* Within the test ini file set the following section:
+
+```ini
+[integration-tests]
+SLOW_TEST = 10s ; 10s is the default value
+SLOW_FLUSH = 5S ; 5s is the default value
+```
+
+* Set the following environment variables:
+
+```bash
+GITEA_SLOW_TEST_TIME="10s" GITEA_SLOW_FLUSH_TIME="5s" make test-sqlite
+```
+
+## Tips and tricks
+
+If you know noteworthy tests that can act as an inspiration for new tests,
+please add some details here.
diff --git a/tests/integration/actions_commit_status_test.go b/tests/integration/actions_commit_status_test.go
new file mode 100644
index 0000000..ace0fbd
--- /dev/null
+++ b/tests/integration/actions_commit_status_test.go
@@ -0,0 +1,49 @@
+// Copyright 20124 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/url"
+ "testing"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/actions"
+ "code.gitea.io/gitea/services/automerge"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestActionsAutomerge(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ assert.True(t, setting.Actions.Enabled, "Actions should be enabled")
+
+ ctx := db.DefaultContext
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
+ job := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRunJob{ID: 292})
+
+ assert.False(t, pr.HasMerged, "PR should not be merged")
+ assert.Equal(t, issues_model.PullRequestStatusMergeable, pr.Status, "PR should be mergeable")
+
+ scheduled, err := automerge.ScheduleAutoMerge(ctx, user, pr, repo_model.MergeStyleMerge, "Dummy")
+
+ require.NoError(t, err, "PR should be scheduled for automerge")
+ assert.True(t, scheduled, "PR should be scheduled for automerge")
+
+ actions.CreateCommitStatus(ctx, job)
+
+ pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
+
+ assert.True(t, pr.HasMerged, "PR should be merged")
+ },
+ )
+}
diff --git a/tests/integration/actions_route_test.go b/tests/integration/actions_route_test.go
new file mode 100644
index 0000000..10618c8
--- /dev/null
+++ b/tests/integration/actions_route_test.go
@@ -0,0 +1,184 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func GetWorkflowRunRedirectURI(t *testing.T, repoURL, workflow string) string {
+ t.Helper()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/actions/workflows/%s/runs/latest", repoURL, workflow))
+ resp := MakeRequest(t, req, http.StatusTemporaryRedirect)
+
+ return resp.Header().Get("Location")
+}
+
+func TestActionsWebRouteLatestWorkflowRun(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ // create the repo
+ repo, _, f := tests.CreateDeclarativeRepo(t, user2, "actionsTestRepo",
+ []unit_model.Type{unit_model.TypeActions}, nil,
+ []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: ".gitea/workflows/workflow-1.yml",
+ ContentReader: strings.NewReader("name: workflow-1\non:\n push:\njobs:\n job-1:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
+ },
+ {
+ Operation: "create",
+ TreePath: ".gitea/workflows/workflow-2.yml",
+ ContentReader: strings.NewReader("name: workflow-2\non:\n push:\njobs:\n job-2:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
+ },
+ },
+ )
+ defer f()
+
+ repoURL := repo.HTMLURL()
+
+ t.Run("valid workflows", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // two runs have been created
+ assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
+
+ // Get the redirect URIs for both workflows
+ workflowOneURI := GetWorkflowRunRedirectURI(t, repoURL, "workflow-1.yml")
+ workflowTwoURI := GetWorkflowRunRedirectURI(t, repoURL, "workflow-2.yml")
+
+ // Verify that the two are different.
+ assert.NotEqual(t, workflowOneURI, workflowTwoURI)
+
+ // Verify that each points to the correct workflow.
+ workflowOne := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, Index: 1})
+ err := workflowOne.LoadAttributes(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, workflowOneURI, workflowOne.HTMLURL())
+
+ workflowTwo := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID, Index: 2})
+ err = workflowTwo.LoadAttributes(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, workflowTwoURI, workflowTwo.HTMLURL())
+ })
+
+ t.Run("check if workflow page shows file name", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Get the redirect URI
+ workflow := "workflow-1.yml"
+ workflowOneURI := GetWorkflowRunRedirectURI(t, repoURL, workflow)
+
+ // Fetch the page that shows information about the run initiated by "workflow-1.yml".
+ // routers/web/repo/actions/view.go: data-workflow-url is constructed using data-workflow-name.
+ req := NewRequest(t, "GET", workflowOneURI)
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ // Verify that URL of the workflow is shown correctly.
+ expectedURL := fmt.Sprintf("/user2/actionsTestRepo/actions?workflow=%s", workflow)
+ htmlDoc.AssertElement(t, fmt.Sprintf("#repo-action-view[data-workflow-url=\"%s\"]", expectedURL), true)
+ })
+
+ t.Run("existing workflow, non-existent branch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/actions/workflows/workflow-1.yml/runs/latest?branch=foobar", repoURL))
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("non-existing workflow", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/actions/workflows/workflow-3.yml/runs/latest", repoURL))
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+}
+
+func TestActionsWebRouteLatestRun(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ // create the repo
+ repo, _, f := tests.CreateDeclarativeRepo(t, user2, "",
+ []unit_model.Type{unit_model.TypeActions}, nil,
+ []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: ".gitea/workflows/pr.yml",
+ ContentReader: strings.NewReader("name: test\non:\n push:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
+ },
+ },
+ )
+ defer f()
+
+ // a run has been created
+ assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
+
+ // Hit the `/actions/runs/latest` route
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/actions/runs/latest", repo.HTMLURL()))
+ resp := MakeRequest(t, req, http.StatusTemporaryRedirect)
+
+ // Verify that it redirects to the run we just created
+ workflow := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
+ err := workflow.LoadAttributes(context.Background())
+ require.NoError(t, err)
+
+ assert.Equal(t, workflow.HTMLURL(), resp.Header().Get("Location"))
+ })
+}
+
+func TestActionsArtifactDeletion(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ // create the repo
+ repo, _, f := tests.CreateDeclarativeRepo(t, user2, "",
+ []unit_model.Type{unit_model.TypeActions}, nil,
+ []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: ".gitea/workflows/pr.yml",
+ ContentReader: strings.NewReader("name: test\non:\n push:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
+ },
+ },
+ )
+ defer f()
+
+ // a run has been created
+ assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
+
+ // Load the run we just created
+ run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: repo.ID})
+ err := run.LoadAttributes(context.Background())
+ require.NoError(t, err)
+
+ // Visit it's web view
+ req := NewRequest(t, "GET", run.HTMLURL())
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ // Assert that the artifact deletion markup exists
+ htmlDoc.AssertElement(t, "[data-locale-confirm-delete-artifact]", true)
+ })
+}
diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go
new file mode 100644
index 0000000..de646f0
--- /dev/null
+++ b/tests/integration/actions_trigger_test.go
@@ -0,0 +1,445 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ actions_module "code.gitea.io/gitea/modules/actions"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ actions_service "code.gitea.io/gitea/services/actions"
+ pull_service "code.gitea.io/gitea/services/pull"
+ release_service "code.gitea.io/gitea/services/release"
+ repo_service "code.gitea.io/gitea/services/repository"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPullRequestTargetEvent(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the base repo
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the forked repo
+
+ // create the base repo
+ baseRepo, _, f := tests.CreateDeclarativeRepo(t, user2, "repo-pull-request-target",
+ []unit_model.Type{unit_model.TypeActions}, nil, nil,
+ )
+ defer f()
+
+ // create the forked repo
+ forkedRepo, err := repo_service.ForkRepositoryAndUpdates(git.DefaultContext, user2, org3, repo_service.ForkRepoOptions{
+ BaseRepo: baseRepo,
+ Name: "forked-repo-pull-request-target",
+ Description: "test pull-request-target event",
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, forkedRepo)
+
+ // add workflow file to the base repo
+ addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user2, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: ".gitea/workflows/pr.yml",
+ ContentReader: strings.NewReader("name: test\non:\n pull_request_target:\n paths:\n - 'file_*.txt'\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
+ },
+ },
+ Message: "add workflow",
+ OldBranch: "main",
+ NewBranch: "main",
+ Author: &files_service.IdentityOptions{
+ Name: user2.Name,
+ Email: user2.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: user2.Name,
+ Email: user2.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, addWorkflowToBaseResp)
+
+ // add a new file to the forked repo
+ addFileToForkedResp, err := files_service.ChangeRepoFiles(git.DefaultContext, forkedRepo, org3, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "file_1.txt",
+ ContentReader: strings.NewReader("file1"),
+ },
+ },
+ Message: "add file1",
+ OldBranch: "main",
+ NewBranch: "fork-branch-1",
+ Author: &files_service.IdentityOptions{
+ Name: org3.Name,
+ Email: org3.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: org3.Name,
+ Email: org3.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, addFileToForkedResp)
+
+ // create Pull
+ pullIssue := &issues_model.Issue{
+ RepoID: baseRepo.ID,
+ Title: "Test pull-request-target-event",
+ PosterID: org3.ID,
+ Poster: org3,
+ IsPull: true,
+ }
+ pullRequest := &issues_model.PullRequest{
+ HeadRepoID: forkedRepo.ID,
+ BaseRepoID: baseRepo.ID,
+ HeadBranch: "fork-branch-1",
+ BaseBranch: "main",
+ HeadRepo: forkedRepo,
+ BaseRepo: baseRepo,
+ Type: issues_model.PullRequestGitea,
+ }
+ err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil)
+ require.NoError(t, err)
+ // if a PR "synchronized" event races the "opened" event by having the same SHA, it must be skipped. See https://codeberg.org/forgejo/forgejo/issues/2009.
+ assert.True(t, actions_service.SkipPullRequestEvent(git.DefaultContext, webhook_module.HookEventPullRequestSync, baseRepo.ID, addFileToForkedResp.Commit.SHA))
+
+ // load and compare ActionRun
+ assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
+ actionRun := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{RepoID: baseRepo.ID})
+ assert.Equal(t, addFileToForkedResp.Commit.SHA, actionRun.CommitSHA)
+ assert.Equal(t, actions_module.GithubEventPullRequestTarget, actionRun.TriggerEvent)
+
+ // add another file whose name cannot match the specified path
+ addFileToForkedResp, err = files_service.ChangeRepoFiles(git.DefaultContext, forkedRepo, org3, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "foo.txt",
+ ContentReader: strings.NewReader("foo"),
+ },
+ },
+ Message: "add foo.txt",
+ OldBranch: "main",
+ NewBranch: "fork-branch-2",
+ Author: &files_service.IdentityOptions{
+ Name: org3.Name,
+ Email: org3.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: org3.Name,
+ Email: org3.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, addFileToForkedResp)
+
+ // create Pull
+ pullIssue = &issues_model.Issue{
+ RepoID: baseRepo.ID,
+ Title: "A mismatched path cannot trigger pull-request-target-event",
+ PosterID: org3.ID,
+ Poster: org3,
+ IsPull: true,
+ }
+ pullRequest = &issues_model.PullRequest{
+ HeadRepoID: forkedRepo.ID,
+ BaseRepoID: baseRepo.ID,
+ HeadBranch: "fork-branch-2",
+ BaseBranch: "main",
+ HeadRepo: forkedRepo,
+ BaseRepo: baseRepo,
+ Type: issues_model.PullRequestGitea,
+ }
+ err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil)
+ require.NoError(t, err)
+
+ // the new pull request cannot trigger actions, so there is still only 1 record
+ assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: baseRepo.ID}))
+ })
+}
+
+func TestSkipCI(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user2")
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ // create the repo
+ repo, _, f := tests.CreateDeclarativeRepo(t, user2, "skip-ci",
+ []unit_model.Type{unit_model.TypeActions}, nil,
+ []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: ".gitea/workflows/pr.yml",
+ ContentReader: strings.NewReader("name: test\non:\n push:\n branches: [main]\n pull_request:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
+ },
+ },
+ )
+ defer f()
+
+ // a run has been created
+ assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
+
+ // add a file with a configured skip-ci string in commit message
+ addFileResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "bar.txt",
+ ContentReader: strings.NewReader("bar"),
+ },
+ },
+ Message: fmt.Sprintf("%s add bar", setting.Actions.SkipWorkflowStrings[0]),
+ OldBranch: "main",
+ NewBranch: "main",
+ Author: &files_service.IdentityOptions{
+ Name: user2.Name,
+ Email: user2.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: user2.Name,
+ Email: user2.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, addFileResp)
+
+ // the commit message contains a configured skip-ci string, so there is still only 1 record
+ assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
+
+ // add file to new branch
+ addFileToBranchResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "test-skip-ci",
+ ContentReader: strings.NewReader("test-skip-ci"),
+ },
+ },
+ Message: "add test file",
+ OldBranch: "main",
+ NewBranch: "test-skip-ci",
+ Author: &files_service.IdentityOptions{
+ Name: user2.Name,
+ Email: user2.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: user2.Name,
+ Email: user2.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, addFileToBranchResp)
+
+ resp := testPullCreate(t, session, "user2", "skip-ci", true, "main", "test-skip-ci", "[skip ci] test-skip-ci")
+
+ // check the redirected URL
+ url := test.RedirectURL(resp)
+ assert.Regexp(t, "^/user2/skip-ci/pulls/[0-9]*$", url)
+
+ // the pr title contains a configured skip-ci string, so there is still only 1 record
+ assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
+ })
+}
+
+func TestCreateDeleteRefEvent(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ // create the repo
+ repo, err := repo_service.CreateRepository(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+ Name: "create-delete-ref-event",
+ Description: "test create delete ref ci event",
+ AutoInit: true,
+ Gitignores: "Go",
+ License: "MIT",
+ Readme: "Default",
+ DefaultBranch: "main",
+ IsPrivate: false,
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, repo)
+
+ // enable actions
+ err = repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{
+ RepoID: repo.ID,
+ Type: unit_model.TypeActions,
+ }}, nil)
+ require.NoError(t, err)
+
+ // add workflow file to the repo
+ addWorkflowToBaseResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: ".gitea/workflows/createdelete.yml",
+ ContentReader: strings.NewReader("name: test\non:\n [create,delete]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
+ },
+ },
+ Message: "add workflow",
+ OldBranch: "main",
+ NewBranch: "main",
+ Author: &files_service.IdentityOptions{
+ Name: user2.Name,
+ Email: user2.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: user2.Name,
+ Email: user2.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, addWorkflowToBaseResp)
+
+ // Get the commit ID of the default branch
+ gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+ branch, err := git_model.GetBranch(db.DefaultContext, repo.ID, repo.DefaultBranch)
+ require.NoError(t, err)
+
+ // create a branch
+ err = repo_service.CreateNewBranchFromCommit(db.DefaultContext, user2, repo, gitRepo, branch.CommitID, "test-create-branch")
+ require.NoError(t, err)
+ run := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+ Title: "add workflow",
+ RepoID: repo.ID,
+ Event: "create",
+ Ref: "refs/heads/test-create-branch",
+ WorkflowID: "createdelete.yml",
+ CommitSHA: branch.CommitID,
+ })
+ assert.NotNil(t, run)
+
+ // create a tag
+ err = release_service.CreateNewTag(db.DefaultContext, user2, repo, branch.CommitID, "test-create-tag", "test create tag event")
+ require.NoError(t, err)
+ run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+ Title: "add workflow",
+ RepoID: repo.ID,
+ Event: "create",
+ Ref: "refs/tags/test-create-tag",
+ WorkflowID: "createdelete.yml",
+ CommitSHA: branch.CommitID,
+ })
+ assert.NotNil(t, run)
+
+ // delete the branch
+ err = repo_service.DeleteBranch(db.DefaultContext, user2, repo, gitRepo, "test-create-branch")
+ require.NoError(t, err)
+ run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+ Title: "add workflow",
+ RepoID: repo.ID,
+ Event: "delete",
+ Ref: "refs/heads/main",
+ WorkflowID: "createdelete.yml",
+ CommitSHA: branch.CommitID,
+ })
+ assert.NotNil(t, run)
+
+ // delete the tag
+ tag, err := repo_model.GetRelease(db.DefaultContext, repo.ID, "test-create-tag")
+ require.NoError(t, err)
+ err = release_service.DeleteReleaseByID(db.DefaultContext, repo, tag, user2, true)
+ require.NoError(t, err)
+ run = unittest.AssertExistsAndLoadBean(t, &actions_model.ActionRun{
+ Title: "add workflow",
+ RepoID: repo.ID,
+ Event: "delete",
+ Ref: "refs/heads/main",
+ WorkflowID: "createdelete.yml",
+ CommitSHA: branch.CommitID,
+ })
+ assert.NotNil(t, run)
+ })
+}
+
+func TestWorkflowDispatchEvent(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ // create the repo
+ repo, sha, f := tests.CreateDeclarativeRepo(t, user2, "repo-workflow-dispatch",
+ []unit_model.Type{unit_model.TypeActions}, nil,
+ []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: ".gitea/workflows/dispatch.yml",
+ ContentReader: strings.NewReader(
+ "name: test\n" +
+ "on: [workflow_dispatch]\n" +
+ "jobs:\n" +
+ " test:\n" +
+ " runs-on: ubuntu-latest\n" +
+ " steps:\n" +
+ " - run: echo helloworld\n",
+ ),
+ },
+ },
+ )
+ defer f()
+
+ gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ workflow, err := actions_service.GetWorkflowFromCommit(gitRepo, "main", "dispatch.yml")
+ require.NoError(t, err)
+ assert.Equal(t, "refs/heads/main", workflow.Ref)
+ assert.Equal(t, sha, workflow.Commit.ID.String())
+
+ inputGetter := func(key string) string {
+ return ""
+ }
+
+ err = workflow.Dispatch(db.DefaultContext, inputGetter, repo, user2)
+ require.NoError(t, err)
+
+ assert.Equal(t, 1, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
+ })
+}
diff --git a/tests/integration/admin_config_test.go b/tests/integration/admin_config_test.go
new file mode 100644
index 0000000..860a92d
--- /dev/null
+++ b/tests/integration/admin_config_test.go
@@ -0,0 +1,23 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAdminConfig(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+ req := NewRequest(t, "GET", "/admin/config")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
+}
diff --git a/tests/integration/admin_user_test.go b/tests/integration/admin_user_test.go
new file mode 100644
index 0000000..8cdaac3
--- /dev/null
+++ b/tests/integration/admin_user_test.go
@@ -0,0 +1,91 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "testing"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAdminViewUsers(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+ req := NewRequest(t, "GET", "/admin/users")
+ session.MakeRequest(t, req, http.StatusOK)
+
+ session = loginUser(t, "user2")
+ req = NewRequest(t, "GET", "/admin/users")
+ session.MakeRequest(t, req, http.StatusForbidden)
+}
+
+func TestAdminViewUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+ req := NewRequest(t, "GET", "/admin/users/1")
+ session.MakeRequest(t, req, http.StatusOK)
+
+ session = loginUser(t, "user2")
+ req = NewRequest(t, "GET", "/admin/users/1")
+ session.MakeRequest(t, req, http.StatusForbidden)
+}
+
+func TestAdminEditUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ testSuccessfullEdit(t, user_model.User{ID: 2, Name: "newusername", LoginName: "otherlogin", Email: "new@e-mail.gitea"})
+}
+
+func testSuccessfullEdit(t *testing.T, formData user_model.User) {
+ makeRequest(t, formData, http.StatusSeeOther)
+}
+
+func makeRequest(t *testing.T, formData user_model.User, headerCode int) {
+ session := loginUser(t, "user1")
+ csrf := GetCSRF(t, session, "/admin/users/"+strconv.Itoa(int(formData.ID))+"/edit")
+ req := NewRequestWithValues(t, "POST", "/admin/users/"+strconv.Itoa(int(formData.ID))+"/edit", map[string]string{
+ "_csrf": csrf,
+ "user_name": formData.Name,
+ "login_name": formData.LoginName,
+ "login_type": "0-0",
+ "email": formData.Email,
+ })
+
+ session.MakeRequest(t, req, headerCode)
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: formData.ID})
+ assert.Equal(t, formData.Name, user.Name)
+ assert.Equal(t, formData.LoginName, user.LoginName)
+ assert.Equal(t, formData.Email, user.Email)
+}
+
+func TestAdminDeleteUser(t *testing.T) {
+ defer tests.AddFixtures("tests/integration/fixtures/TestAdminDeleteUser/")()
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+
+ userID := int64(1000)
+
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{PosterID: userID})
+
+ csrf := GetCSRF(t, session, fmt.Sprintf("/admin/users/%d/edit", userID))
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/admin/users/%d/delete", userID), map[string]string{
+ "_csrf": csrf,
+ "purge": "true",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ assertUserDeleted(t, userID, true)
+ unittest.CheckConsistencyFor(t, &user_model.User{})
+}
diff --git a/tests/integration/api_actions_artifact_test.go b/tests/integration/api_actions_artifact_test.go
new file mode 100644
index 0000000..2798024
--- /dev/null
+++ b/tests/integration/api_actions_artifact_test.go
@@ -0,0 +1,378 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type uploadArtifactResponse struct {
+ FileContainerResourceURL string `json:"fileContainerResourceUrl"`
+}
+
+type getUploadArtifactRequest struct {
+ Type string
+ Name string
+ RetentionDays int64
+}
+
+func TestActionsArtifactUploadSingleFile(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // acquire artifact upload url
+ req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{
+ Type: "actions_storage",
+ Name: "artifact",
+ }).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp := MakeRequest(t, req, http.StatusOK)
+ var uploadResp uploadArtifactResponse
+ DecodeJSON(t, resp, &uploadResp)
+ assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
+
+ // get upload url
+ idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
+ url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=artifact/abc.txt"
+
+ // upload artifact chunk
+ body := strings.Repeat("A", 1024)
+ req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)).
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
+ SetHeader("Content-Range", "bytes 0-1023/1024").
+ SetHeader("x-tfs-filelength", "1024").
+ SetHeader("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body))
+ MakeRequest(t, req, http.StatusOK)
+
+ t.Logf("Create artifact confirm")
+
+ // confirm artifact upload
+ req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName=artifact").
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ MakeRequest(t, req, http.StatusOK)
+}
+
+func TestActionsArtifactUploadInvalidHash(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // artifact id 54321 not exist
+ url := "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts/8e5b948a454515dbabfc7eb718ddddddd/upload?itemPath=artifact/abc.txt"
+ body := strings.Repeat("A", 1024)
+ req := NewRequestWithBody(t, "PUT", url, strings.NewReader(body)).
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
+ SetHeader("Content-Range", "bytes 0-1023/1024").
+ SetHeader("x-tfs-filelength", "1024").
+ SetHeader("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body))
+ resp := MakeRequest(t, req, http.StatusBadRequest)
+ assert.Contains(t, resp.Body.String(), "Invalid artifact hash")
+}
+
+func TestActionsArtifactConfirmUploadWithoutName(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp := MakeRequest(t, req, http.StatusBadRequest)
+ assert.Contains(t, resp.Body.String(), "artifact name is empty")
+}
+
+func TestActionsArtifactUploadWithoutToken(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/1/artifacts", nil)
+ MakeRequest(t, req, http.StatusUnauthorized)
+}
+
+type (
+ listArtifactsResponseItem struct {
+ Name string `json:"name"`
+ FileContainerResourceURL string `json:"fileContainerResourceUrl"`
+ }
+ listArtifactsResponse struct {
+ Count int64 `json:"count"`
+ Value []listArtifactsResponseItem `json:"value"`
+ }
+ downloadArtifactResponseItem struct {
+ Path string `json:"path"`
+ ItemType string `json:"itemType"`
+ ContentLocation string `json:"contentLocation"`
+ }
+ downloadArtifactResponse struct {
+ Value []downloadArtifactResponseItem `json:"value"`
+ }
+)
+
+func TestActionsArtifactDownload(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp := MakeRequest(t, req, http.StatusOK)
+ var listResp listArtifactsResponse
+ DecodeJSON(t, resp, &listResp)
+ assert.Equal(t, int64(1), listResp.Count)
+ assert.Equal(t, "artifact", listResp.Value[0].Name)
+ assert.Contains(t, listResp.Value[0].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
+
+ idx := strings.Index(listResp.Value[0].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
+ url := listResp.Value[0].FileContainerResourceURL[idx+1:] + "?itemPath=artifact"
+ req = NewRequest(t, "GET", url).
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp = MakeRequest(t, req, http.StatusOK)
+ var downloadResp downloadArtifactResponse
+ DecodeJSON(t, resp, &downloadResp)
+ assert.Len(t, downloadResp.Value, 1)
+ assert.Equal(t, "artifact/abc.txt", downloadResp.Value[0].Path)
+ assert.Equal(t, "file", downloadResp.Value[0].ItemType)
+ assert.Contains(t, downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
+
+ idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
+ url = downloadResp.Value[0].ContentLocation[idx:]
+ req = NewRequest(t, "GET", url).
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp = MakeRequest(t, req, http.StatusOK)
+ body := strings.Repeat("A", 1024)
+ assert.Equal(t, resp.Body.String(), body)
+}
+
+func TestActionsArtifactUploadMultipleFile(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ const testArtifactName = "multi-files"
+
+ // acquire artifact upload url
+ req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{
+ Type: "actions_storage",
+ Name: testArtifactName,
+ }).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp := MakeRequest(t, req, http.StatusOK)
+ var uploadResp uploadArtifactResponse
+ DecodeJSON(t, resp, &uploadResp)
+ assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
+
+ type uploadingFile struct {
+ Path string
+ Content string
+ MD5 string
+ }
+
+ files := []uploadingFile{
+ {
+ Path: "abc.txt",
+ Content: strings.Repeat("A", 1024),
+ MD5: "1HsSe8LeLWh93ILaw1TEFQ==",
+ },
+ {
+ Path: "xyz/def.txt",
+ Content: strings.Repeat("B", 1024),
+ MD5: "6fgADK/7zjadf+6cB9Q1CQ==",
+ },
+ }
+
+ for _, f := range files {
+ // get upload url
+ idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
+ url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=" + testArtifactName + "/" + f.Path
+
+ // upload artifact chunk
+ req = NewRequestWithBody(t, "PUT", url, strings.NewReader(f.Content)).
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
+ SetHeader("Content-Range", "bytes 0-1023/1024").
+ SetHeader("x-tfs-filelength", "1024").
+ SetHeader("x-actions-results-md5", f.MD5) // base64(md5(body))
+ MakeRequest(t, req, http.StatusOK)
+ }
+
+ t.Logf("Create artifact confirm")
+
+ // confirm artifact upload
+ req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName="+testArtifactName).
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ MakeRequest(t, req, http.StatusOK)
+}
+
+func TestActionsArtifactDownloadMultiFiles(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ const testArtifactName = "multi-files"
+
+ req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp := MakeRequest(t, req, http.StatusOK)
+ var listResp listArtifactsResponse
+ DecodeJSON(t, resp, &listResp)
+ assert.Equal(t, int64(2), listResp.Count)
+
+ var fileContainerResourceURL string
+ for _, v := range listResp.Value {
+ if v.Name == testArtifactName {
+ fileContainerResourceURL = v.FileContainerResourceURL
+ break
+ }
+ }
+ assert.Contains(t, fileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
+
+ idx := strings.Index(fileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
+ url := fileContainerResourceURL[idx+1:] + "?itemPath=" + testArtifactName
+ req = NewRequest(t, "GET", url).
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp = MakeRequest(t, req, http.StatusOK)
+ var downloadResp downloadArtifactResponse
+ DecodeJSON(t, resp, &downloadResp)
+ assert.Len(t, downloadResp.Value, 2)
+
+ downloads := [][]string{{"multi-files/abc.txt", "A"}, {"multi-files/xyz/def.txt", "B"}}
+ for _, v := range downloadResp.Value {
+ var bodyChar string
+ var path string
+ for _, d := range downloads {
+ if v.Path == d[0] {
+ path = d[0]
+ bodyChar = d[1]
+ break
+ }
+ }
+ value := v
+ assert.Equal(t, path, value.Path)
+ assert.Equal(t, "file", value.ItemType)
+ assert.Contains(t, value.ContentLocation, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
+
+ idx = strings.Index(value.ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
+ url = value.ContentLocation[idx:]
+ req = NewRequest(t, "GET", url).
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp = MakeRequest(t, req, http.StatusOK)
+ body := strings.Repeat(bodyChar, 1024)
+ assert.Equal(t, resp.Body.String(), body)
+ }
+}
+
+func TestActionsArtifactUploadWithRetentionDays(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // acquire artifact upload url
+ req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{
+ Type: "actions_storage",
+ Name: "artifact-retention-days",
+ RetentionDays: 9,
+ }).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp := MakeRequest(t, req, http.StatusOK)
+ var uploadResp uploadArtifactResponse
+ DecodeJSON(t, resp, &uploadResp)
+ assert.Contains(t, uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts")
+ assert.Contains(t, uploadResp.FileContainerResourceURL, "?retentionDays=9")
+
+ // get upload url
+ idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
+ url := uploadResp.FileContainerResourceURL[idx:] + "&itemPath=artifact-retention-days/abc.txt"
+
+ // upload artifact chunk
+ body := strings.Repeat("A", 1024)
+ req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)).
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
+ SetHeader("Content-Range", "bytes 0-1023/1024").
+ SetHeader("x-tfs-filelength", "1024").
+ SetHeader("x-actions-results-md5", "1HsSe8LeLWh93ILaw1TEFQ==") // base64(md5(body))
+ MakeRequest(t, req, http.StatusOK)
+
+ t.Logf("Create artifact confirm")
+
+ // confirm artifact upload
+ req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName=artifact-retention-days").
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ MakeRequest(t, req, http.StatusOK)
+}
+
+func TestActionsArtifactOverwrite(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ {
+ // download old artifact uploaded by tests above, it should 1024 A
+ req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp := MakeRequest(t, req, http.StatusOK)
+ var listResp listArtifactsResponse
+ DecodeJSON(t, resp, &listResp)
+
+ idx := strings.Index(listResp.Value[0].FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
+ url := listResp.Value[0].FileContainerResourceURL[idx+1:] + "?itemPath=artifact"
+ req = NewRequest(t, "GET", url).
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp = MakeRequest(t, req, http.StatusOK)
+ var downloadResp downloadArtifactResponse
+ DecodeJSON(t, resp, &downloadResp)
+
+ idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
+ url = downloadResp.Value[0].ContentLocation[idx:]
+ req = NewRequest(t, "GET", url).
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp = MakeRequest(t, req, http.StatusOK)
+ body := strings.Repeat("A", 1024)
+ assert.Equal(t, resp.Body.String(), body)
+ }
+
+ {
+ // upload same artifact, it uses 4096 B
+ req := NewRequestWithJSON(t, "POST", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts", getUploadArtifactRequest{
+ Type: "actions_storage",
+ Name: "artifact",
+ }).AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp := MakeRequest(t, req, http.StatusOK)
+ var uploadResp uploadArtifactResponse
+ DecodeJSON(t, resp, &uploadResp)
+
+ idx := strings.Index(uploadResp.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
+ url := uploadResp.FileContainerResourceURL[idx:] + "?itemPath=artifact/abc.txt"
+ body := strings.Repeat("B", 4096)
+ req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body)).
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a").
+ SetHeader("Content-Range", "bytes 0-4095/4096").
+ SetHeader("x-tfs-filelength", "4096").
+ SetHeader("x-actions-results-md5", "wUypcJFeZCK5T6r4lfqzqg==") // base64(md5(body))
+ MakeRequest(t, req, http.StatusOK)
+
+ // confirm artifact upload
+ req = NewRequest(t, "PATCH", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts?artifactName=artifact").
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ MakeRequest(t, req, http.StatusOK)
+ }
+
+ {
+ // download artifact again, it should 4096 B
+ req := NewRequest(t, "GET", "/api/actions_pipeline/_apis/pipelines/workflows/791/artifacts").
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp := MakeRequest(t, req, http.StatusOK)
+ var listResp listArtifactsResponse
+ DecodeJSON(t, resp, &listResp)
+
+ var uploadedItem listArtifactsResponseItem
+ for _, item := range listResp.Value {
+ if item.Name == "artifact" {
+ uploadedItem = item
+ break
+ }
+ }
+ assert.Equal(t, "artifact", uploadedItem.Name)
+
+ idx := strings.Index(uploadedItem.FileContainerResourceURL, "/api/actions_pipeline/_apis/pipelines/")
+ url := uploadedItem.FileContainerResourceURL[idx+1:] + "?itemPath=artifact"
+ req = NewRequest(t, "GET", url).
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp = MakeRequest(t, req, http.StatusOK)
+ var downloadResp downloadArtifactResponse
+ DecodeJSON(t, resp, &downloadResp)
+
+ idx = strings.Index(downloadResp.Value[0].ContentLocation, "/api/actions_pipeline/_apis/pipelines/")
+ url = downloadResp.Value[0].ContentLocation[idx:]
+ req = NewRequest(t, "GET", url).
+ AddTokenAuth("8061e833a55f6fc0157c98b883e91fcfeeb1a71a")
+ resp = MakeRequest(t, req, http.StatusOK)
+ body := strings.Repeat("B", 4096)
+ assert.Equal(t, resp.Body.String(), body)
+ }
+}
diff --git a/tests/integration/api_actions_artifact_v4_test.go b/tests/integration/api_actions_artifact_v4_test.go
new file mode 100644
index 0000000..f55250f
--- /dev/null
+++ b/tests/integration/api_actions_artifact_v4_test.go
@@ -0,0 +1,404 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/xml"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/routers/api/actions"
+ actions_service "code.gitea.io/gitea/services/actions"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/encoding/protojson"
+ "google.golang.org/protobuf/reflect/protoreflect"
+ "google.golang.org/protobuf/types/known/timestamppb"
+ "google.golang.org/protobuf/types/known/wrapperspb"
+)
+
+func toProtoJSON(m protoreflect.ProtoMessage) io.Reader {
+ resp, _ := protojson.Marshal(m)
+ buf := bytes.Buffer{}
+ buf.Write(resp)
+ return &buf
+}
+
+func uploadArtifact(t *testing.T, body string) string {
+ token, err := actions_service.CreateAuthorizationToken(48, 792, 193)
+ require.NoError(t, err)
+
+ // acquire artifact upload url
+ req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{
+ Version: 4,
+ Name: "artifact",
+ WorkflowRunBackendId: "792",
+ WorkflowJobRunBackendId: "193",
+ })).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var uploadResp actions.CreateArtifactResponse
+ protojson.Unmarshal(resp.Body.Bytes(), &uploadResp)
+ assert.True(t, uploadResp.Ok)
+ assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact")
+
+ // get upload url
+ idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/")
+ url := uploadResp.SignedUploadUrl[idx:] + "&comp=block"
+
+ // upload artifact chunk
+ req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body))
+ MakeRequest(t, req, http.StatusCreated)
+
+ t.Logf("Create artifact confirm")
+
+ sha := sha256.Sum256([]byte(body))
+
+ // confirm artifact upload
+ req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{
+ Name: "artifact",
+ Size: 1024,
+ Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sha[:])),
+ WorkflowRunBackendId: "792",
+ WorkflowJobRunBackendId: "193",
+ })).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var finalizeResp actions.FinalizeArtifactResponse
+ protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp)
+ assert.True(t, finalizeResp.Ok)
+ return token
+}
+
+func TestActionsArtifactV4UploadSingleFile(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ body := strings.Repeat("A", 1024)
+ uploadArtifact(t, body)
+}
+
+func TestActionsArtifactV4UploadSingleFileWrongChecksum(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ token, err := actions_service.CreateAuthorizationToken(48, 792, 193)
+ require.NoError(t, err)
+
+ // acquire artifact upload url
+ req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{
+ Version: 4,
+ Name: "artifact-invalid-checksum",
+ WorkflowRunBackendId: "792",
+ WorkflowJobRunBackendId: "193",
+ })).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var uploadResp actions.CreateArtifactResponse
+ protojson.Unmarshal(resp.Body.Bytes(), &uploadResp)
+ assert.True(t, uploadResp.Ok)
+ assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact")
+
+ // get upload url
+ idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/")
+ url := uploadResp.SignedUploadUrl[idx:] + "&comp=block"
+
+ // upload artifact chunk
+ body := strings.Repeat("B", 1024)
+ req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body))
+ MakeRequest(t, req, http.StatusCreated)
+
+ t.Logf("Create artifact confirm")
+
+ sha := sha256.Sum256([]byte(strings.Repeat("A", 1024)))
+
+ // confirm artifact upload
+ req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{
+ Name: "artifact-invalid-checksum",
+ Size: 1024,
+ Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sha[:])),
+ WorkflowRunBackendId: "792",
+ WorkflowJobRunBackendId: "193",
+ })).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusInternalServerError)
+}
+
+func TestActionsArtifactV4UploadSingleFileWithRetentionDays(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ token, err := actions_service.CreateAuthorizationToken(48, 792, 193)
+ require.NoError(t, err)
+
+ // acquire artifact upload url
+ req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{
+ Version: 4,
+ ExpiresAt: timestamppb.New(time.Now().Add(5 * 24 * time.Hour)),
+ Name: "artifactWithRetentionDays",
+ WorkflowRunBackendId: "792",
+ WorkflowJobRunBackendId: "193",
+ })).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var uploadResp actions.CreateArtifactResponse
+ protojson.Unmarshal(resp.Body.Bytes(), &uploadResp)
+ assert.True(t, uploadResp.Ok)
+ assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact")
+
+ // get upload url
+ idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/")
+ url := uploadResp.SignedUploadUrl[idx:] + "&comp=block"
+
+ // upload artifact chunk
+ body := strings.Repeat("A", 1024)
+ req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body))
+ MakeRequest(t, req, http.StatusCreated)
+
+ t.Logf("Create artifact confirm")
+
+ sha := sha256.Sum256([]byte(body))
+
+ // confirm artifact upload
+ req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{
+ Name: "artifactWithRetentionDays",
+ Size: 1024,
+ Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sha[:])),
+ WorkflowRunBackendId: "792",
+ WorkflowJobRunBackendId: "193",
+ })).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var finalizeResp actions.FinalizeArtifactResponse
+ protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp)
+ assert.True(t, finalizeResp.Ok)
+}
+
+func TestActionsArtifactV4UploadSingleFileWithPotentialHarmfulBlockID(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ token, err := actions_service.CreateAuthorizationToken(48, 792, 193)
+ require.NoError(t, err)
+
+ // acquire artifact upload url
+ req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{
+ Version: 4,
+ Name: "artifactWithPotentialHarmfulBlockID",
+ WorkflowRunBackendId: "792",
+ WorkflowJobRunBackendId: "193",
+ })).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var uploadResp actions.CreateArtifactResponse
+ protojson.Unmarshal(resp.Body.Bytes(), &uploadResp)
+ assert.True(t, uploadResp.Ok)
+ assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact")
+
+ // get upload urls
+ idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/")
+ url := uploadResp.SignedUploadUrl[idx:] + "&comp=block&blockid=%2f..%2fmyfile"
+ blockListURL := uploadResp.SignedUploadUrl[idx:] + "&comp=blocklist"
+
+ // upload artifact chunk
+ body := strings.Repeat("A", 1024)
+ req = NewRequestWithBody(t, "PUT", url, strings.NewReader(body))
+ MakeRequest(t, req, http.StatusCreated)
+
+ // verify that the exploit didn't work
+ _, err = storage.Actions.Stat("myfile")
+ require.Error(t, err)
+
+ // upload artifact blockList
+ blockList := &actions.BlockList{
+ Latest: []string{
+ "/../myfile",
+ },
+ }
+ rawBlockList, err := xml.Marshal(blockList)
+ require.NoError(t, err)
+ req = NewRequestWithBody(t, "PUT", blockListURL, bytes.NewReader(rawBlockList))
+ MakeRequest(t, req, http.StatusCreated)
+
+ t.Logf("Create artifact confirm")
+
+ sha := sha256.Sum256([]byte(body))
+
+ // confirm artifact upload
+ req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{
+ Name: "artifactWithPotentialHarmfulBlockID",
+ Size: 1024,
+ Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sha[:])),
+ WorkflowRunBackendId: "792",
+ WorkflowJobRunBackendId: "193",
+ })).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var finalizeResp actions.FinalizeArtifactResponse
+ protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp)
+ assert.True(t, finalizeResp.Ok)
+}
+
+func TestActionsArtifactV4UploadSingleFileWithChunksOutOfOrder(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ token, err := actions_service.CreateAuthorizationToken(48, 792, 193)
+ require.NoError(t, err)
+
+ // acquire artifact upload url
+ req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact", toProtoJSON(&actions.CreateArtifactRequest{
+ Version: 4,
+ Name: "artifactWithChunksOutOfOrder",
+ WorkflowRunBackendId: "792",
+ WorkflowJobRunBackendId: "193",
+ })).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var uploadResp actions.CreateArtifactResponse
+ protojson.Unmarshal(resp.Body.Bytes(), &uploadResp)
+ assert.True(t, uploadResp.Ok)
+ assert.Contains(t, uploadResp.SignedUploadUrl, "/twirp/github.actions.results.api.v1.ArtifactService/UploadArtifact")
+
+ // get upload urls
+ idx := strings.Index(uploadResp.SignedUploadUrl, "/twirp/")
+ block1URL := uploadResp.SignedUploadUrl[idx:] + "&comp=block&blockid=block1"
+ block2URL := uploadResp.SignedUploadUrl[idx:] + "&comp=block&blockid=block2"
+ blockListURL := uploadResp.SignedUploadUrl[idx:] + "&comp=blocklist"
+
+ // upload artifact chunks
+ bodyb := strings.Repeat("B", 1024)
+ req = NewRequestWithBody(t, "PUT", block2URL, strings.NewReader(bodyb))
+ MakeRequest(t, req, http.StatusCreated)
+
+ bodya := strings.Repeat("A", 1024)
+ req = NewRequestWithBody(t, "PUT", block1URL, strings.NewReader(bodya))
+ MakeRequest(t, req, http.StatusCreated)
+
+ // upload artifact blockList
+ blockList := &actions.BlockList{
+ Latest: []string{
+ "block1",
+ "block2",
+ },
+ }
+ rawBlockList, err := xml.Marshal(blockList)
+ require.NoError(t, err)
+ req = NewRequestWithBody(t, "PUT", blockListURL, bytes.NewReader(rawBlockList))
+ MakeRequest(t, req, http.StatusCreated)
+
+ t.Logf("Create artifact confirm")
+
+ sha := sha256.Sum256([]byte(bodya + bodyb))
+
+ // confirm artifact upload
+ req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/FinalizeArtifact", toProtoJSON(&actions.FinalizeArtifactRequest{
+ Name: "artifactWithChunksOutOfOrder",
+ Size: 2048,
+ Hash: wrapperspb.String("sha256:" + hex.EncodeToString(sha[:])),
+ WorkflowRunBackendId: "792",
+ WorkflowJobRunBackendId: "193",
+ })).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var finalizeResp actions.FinalizeArtifactResponse
+ protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp)
+ assert.True(t, finalizeResp.Ok)
+}
+
+func TestActionsArtifactV4DownloadSingle(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ token, err := actions_service.CreateAuthorizationToken(48, 792, 193)
+ require.NoError(t, err)
+
+ // acquire artifact upload url
+ req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", toProtoJSON(&actions.ListArtifactsRequest{
+ NameFilter: wrapperspb.String("artifact"),
+ WorkflowRunBackendId: "792",
+ WorkflowJobRunBackendId: "193",
+ })).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var listResp actions.ListArtifactsResponse
+ protojson.Unmarshal(resp.Body.Bytes(), &listResp)
+ assert.Len(t, listResp.Artifacts, 1)
+
+ // confirm artifact upload
+ req = NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL", toProtoJSON(&actions.GetSignedArtifactURLRequest{
+ Name: "artifact",
+ WorkflowRunBackendId: "792",
+ WorkflowJobRunBackendId: "193",
+ })).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var finalizeResp actions.GetSignedArtifactURLResponse
+ protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp)
+ assert.NotEmpty(t, finalizeResp.SignedUrl)
+
+ req = NewRequest(t, "GET", finalizeResp.SignedUrl)
+ resp = MakeRequest(t, req, http.StatusOK)
+ body := strings.Repeat("A", 1024)
+ assert.Equal(t, "bytes", resp.Header().Get("accept-ranges"))
+ assert.Equal(t, body, resp.Body.String())
+
+ // Download artifact via user-facing URL
+ req = NewRequest(t, "GET", "/user5/repo4/actions/runs/188/artifacts/artifact")
+ resp = MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, "bytes", resp.Header().Get("accept-ranges"))
+ assert.Equal(t, body, resp.Body.String())
+
+ // Partial artifact download
+ req = NewRequest(t, "GET", "/user5/repo4/actions/runs/188/artifacts/artifact").SetHeader("range", "bytes=0-99")
+ resp = MakeRequest(t, req, http.StatusPartialContent)
+ body = strings.Repeat("A", 100)
+ assert.Equal(t, "bytes 0-99/1024", resp.Header().Get("content-range"))
+ assert.Equal(t, body, resp.Body.String())
+}
+
+func TestActionsArtifactV4DownloadRange(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ bstr := strings.Repeat("B", 100)
+ body := strings.Repeat("A", 100) + bstr
+ token := uploadArtifact(t, body)
+
+ // Download (Actions API)
+ req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/GetSignedArtifactURL", toProtoJSON(&actions.GetSignedArtifactURLRequest{
+ Name: "artifact",
+ WorkflowRunBackendId: "792",
+ WorkflowJobRunBackendId: "193",
+ })).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var finalizeResp actions.GetSignedArtifactURLResponse
+ protojson.Unmarshal(resp.Body.Bytes(), &finalizeResp)
+ assert.NotEmpty(t, finalizeResp.SignedUrl)
+
+ req = NewRequest(t, "GET", finalizeResp.SignedUrl).SetHeader("range", "bytes=100-199")
+ resp = MakeRequest(t, req, http.StatusPartialContent)
+ assert.Equal(t, "bytes 100-199/200", resp.Header().Get("content-range"))
+ assert.Equal(t, bstr, resp.Body.String())
+
+ // Download (user-facing API)
+ req = NewRequest(t, "GET", "/user5/repo4/actions/runs/188/artifacts/artifact").SetHeader("range", "bytes=100-199")
+ resp = MakeRequest(t, req, http.StatusPartialContent)
+ assert.Equal(t, "bytes 100-199/200", resp.Header().Get("content-range"))
+ assert.Equal(t, bstr, resp.Body.String())
+}
+
+func TestActionsArtifactV4Delete(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ token, err := actions_service.CreateAuthorizationToken(48, 792, 193)
+ require.NoError(t, err)
+
+ // delete artifact by name
+ req := NewRequestWithBody(t, "POST", "/twirp/github.actions.results.api.v1.ArtifactService/DeleteArtifact", toProtoJSON(&actions.DeleteArtifactRequest{
+ Name: "artifact",
+ WorkflowRunBackendId: "792",
+ WorkflowJobRunBackendId: "193",
+ })).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var deleteResp actions.DeleteArtifactResponse
+ protojson.Unmarshal(resp.Body.Bytes(), &deleteResp)
+ assert.True(t, deleteResp.Ok)
+}
diff --git a/tests/integration/api_activitypub_actor_test.go b/tests/integration/api_activitypub_actor_test.go
new file mode 100644
index 0000000..7506c78
--- /dev/null
+++ b/tests/integration/api_activitypub_actor_test.go
@@ -0,0 +1,50 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+
+ ap "github.com/go-ap/activitypub"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestActivityPubActor(t *testing.T) {
+ defer test.MockVariableValue(&setting.Federation.Enabled, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ req := NewRequest(t, "GET", "/api/v1/activitypub/actor")
+ resp := MakeRequest(t, req, http.StatusOK)
+ body := resp.Body.Bytes()
+ assert.Contains(t, string(body), "@context")
+
+ var actor ap.Actor
+ err := actor.UnmarshalJSON(body)
+ require.NoError(t, err)
+
+ assert.Equal(t, ap.ApplicationType, actor.Type)
+ assert.Equal(t, setting.Domain, actor.PreferredUsername.String())
+ keyID := actor.GetID().String()
+ assert.Regexp(t, "activitypub/actor$", keyID)
+ assert.Regexp(t, "activitypub/actor/outbox$", actor.Outbox.GetID().String())
+ assert.Regexp(t, "activitypub/actor/inbox$", actor.Inbox.GetID().String())
+
+ pubKey := actor.PublicKey
+ assert.NotNil(t, pubKey)
+ publicKeyID := keyID + "#main-key"
+ assert.Equal(t, pubKey.ID.String(), publicKeyID)
+
+ pubKeyPem := pubKey.PublicKeyPem
+ assert.NotNil(t, pubKeyPem)
+ assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem)
+ })
+}
diff --git a/tests/integration/api_activitypub_person_test.go b/tests/integration/api_activitypub_person_test.go
new file mode 100644
index 0000000..55935e4
--- /dev/null
+++ b/tests/integration/api_activitypub_person_test.go
@@ -0,0 +1,116 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/activitypub"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/routers"
+
+ ap "github.com/go-ap/activitypub"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestActivityPubPerson(t *testing.T) {
+ setting.Federation.Enabled = true
+ testWebRoutes = routers.NormalRoutes()
+ defer func() {
+ setting.Federation.Enabled = false
+ testWebRoutes = routers.NormalRoutes()
+ }()
+
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ userID := 2
+ username := "user2"
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/user-id/%v", userID))
+ resp := MakeRequest(t, req, http.StatusOK)
+ body := resp.Body.Bytes()
+ assert.Contains(t, string(body), "@context")
+
+ var person ap.Person
+ err := person.UnmarshalJSON(body)
+ require.NoError(t, err)
+
+ assert.Equal(t, ap.PersonType, person.Type)
+ assert.Equal(t, username, person.PreferredUsername.String())
+ keyID := person.GetID().String()
+ assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v$", userID), keyID)
+ assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v/outbox$", userID), person.Outbox.GetID().String())
+ assert.Regexp(t, fmt.Sprintf("activitypub/user-id/%v/inbox$", userID), person.Inbox.GetID().String())
+
+ pubKey := person.PublicKey
+ assert.NotNil(t, pubKey)
+ publicKeyID := keyID + "#main-key"
+ assert.Equal(t, pubKey.ID.String(), publicKeyID)
+
+ pubKeyPem := pubKey.PublicKeyPem
+ assert.NotNil(t, pubKeyPem)
+ assert.Regexp(t, "^-----BEGIN PUBLIC KEY-----", pubKeyPem)
+ })
+}
+
+func TestActivityPubMissingPerson(t *testing.T) {
+ setting.Federation.Enabled = true
+ testWebRoutes = routers.NormalRoutes()
+ defer func() {
+ setting.Federation.Enabled = false
+ testWebRoutes = routers.NormalRoutes()
+ }()
+
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ req := NewRequest(t, "GET", "/api/v1/activitypub/user-id/999999999")
+ resp := MakeRequest(t, req, http.StatusNotFound)
+ assert.Contains(t, resp.Body.String(), "user does not exist")
+ })
+}
+
+func TestActivityPubPersonInbox(t *testing.T) {
+ setting.Federation.Enabled = true
+ testWebRoutes = routers.NormalRoutes()
+ defer func() {
+ setting.Federation.Enabled = false
+ testWebRoutes = routers.NormalRoutes()
+ }()
+
+ srv := httptest.NewServer(testWebRoutes)
+ defer srv.Close()
+
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ appURL := setting.AppURL
+ setting.AppURL = srv.URL + "/"
+ defer func() {
+ setting.Database.LogSQL = false
+ setting.AppURL = appURL
+ }()
+ username1 := "user1"
+ ctx := context.Background()
+ user1, err := user_model.GetUserByName(ctx, username1)
+ require.NoError(t, err)
+ user1url := fmt.Sprintf("%s/api/v1/activitypub/user-id/1#main-key", srv.URL)
+ cf, err := activitypub.GetClientFactory(ctx)
+ require.NoError(t, err)
+ c, err := cf.WithKeys(db.DefaultContext, user1, user1url)
+ require.NoError(t, err)
+ user2inboxurl := fmt.Sprintf("%s/api/v1/activitypub/user-id/2/inbox", srv.URL)
+
+ // Signed request succeeds
+ resp, err := c.Post([]byte{}, user2inboxurl)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusNoContent, resp.StatusCode)
+
+ // Unsigned request fails
+ req := NewRequest(t, "POST", user2inboxurl)
+ MakeRequest(t, req, http.StatusBadRequest)
+ })
+}
diff --git a/tests/integration/api_activitypub_repository_test.go b/tests/integration/api_activitypub_repository_test.go
new file mode 100644
index 0000000..737f580
--- /dev/null
+++ b/tests/integration/api_activitypub_repository_test.go
@@ -0,0 +1,249 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/forgefed"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/activitypub"
+ forgefed_modules "code.gitea.io/gitea/modules/forgefed"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/routers"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestActivityPubRepository(t *testing.T) {
+ setting.Federation.Enabled = true
+ testWebRoutes = routers.NormalRoutes()
+ defer func() {
+ setting.Federation.Enabled = false
+ testWebRoutes = routers.NormalRoutes()
+ }()
+
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ repositoryID := 2
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID))
+ resp := MakeRequest(t, req, http.StatusOK)
+ body := resp.Body.Bytes()
+ assert.Contains(t, string(body), "@context")
+
+ var repository forgefed_modules.Repository
+ err := repository.UnmarshalJSON(body)
+ require.NoError(t, err)
+
+ assert.Regexp(t, fmt.Sprintf("activitypub/repository-id/%v$", repositoryID), repository.GetID().String())
+ })
+}
+
+func TestActivityPubMissingRepository(t *testing.T) {
+ setting.Federation.Enabled = true
+ testWebRoutes = routers.NormalRoutes()
+ defer func() {
+ setting.Federation.Enabled = false
+ testWebRoutes = routers.NormalRoutes()
+ }()
+
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ repositoryID := 9999999
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/activitypub/repository-id/%v", repositoryID))
+ resp := MakeRequest(t, req, http.StatusNotFound)
+ assert.Contains(t, resp.Body.String(), "repository does not exist")
+ })
+}
+
+func TestActivityPubRepositoryInboxValid(t *testing.T) {
+ setting.Federation.Enabled = true
+ testWebRoutes = routers.NormalRoutes()
+ defer func() {
+ setting.Federation.Enabled = false
+ testWebRoutes = routers.NormalRoutes()
+ }()
+
+ srv := httptest.NewServer(testWebRoutes)
+ defer srv.Close()
+
+ federatedRoutes := http.NewServeMux()
+ federatedRoutes.HandleFunc("/.well-known/nodeinfo",
+ func(res http.ResponseWriter, req *http.Request) {
+ // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/.well-known/nodeinfo
+ responseBody := fmt.Sprintf(`{"links":[{"href":"http://%s/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`, req.Host)
+ t.Logf("response: %s", responseBody)
+ // TODO: as soon as content-type will become important: content-type: application/json;charset=utf-8
+ fmt.Fprint(res, responseBody)
+ })
+ federatedRoutes.HandleFunc("/api/v1/nodeinfo",
+ func(res http.ResponseWriter, req *http.Request) {
+ // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/nodeinfo
+ responseBody := fmt.Sprintf(`{"version":"2.1","software":{"name":"forgejo","version":"1.20.0+dev-3183-g976d79044",` +
+ `"repository":"https://codeberg.org/forgejo/forgejo.git","homepage":"https://forgejo.org/"},` +
+ `"protocols":["activitypub"],"services":{"inbound":[],"outbound":["rss2.0"]},` +
+ `"openRegistrations":true,"usage":{"users":{"total":14,"activeHalfyear":2}},"metadata":{}}`)
+ fmt.Fprint(res, responseBody)
+ })
+ federatedRoutes.HandleFunc("/api/v1/activitypub/user-id/15",
+ func(res http.ResponseWriter, req *http.Request) {
+ // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/2
+ responseBody := fmt.Sprintf(`{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1"],` +
+ `"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/15","type":"Person",` +
+ `"icon":{"type":"Image","mediaType":"image/png","url":"https://federated-repo.prod.meissa.de/avatars/1bb05d9a5f6675ed0272af9ea193063c"},` +
+ `"url":"https://federated-repo.prod.meissa.de/stargoose1","inbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/15/inbox",` +
+ `"outbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/15/outbox","preferredUsername":"stargoose1",` +
+ `"publicKey":{"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/15#main-key","owner":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/15",` +
+ `"publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEA18H5s7N6ItZUAh9tneII\nIuZdTTa3cZlLa/9ejWAHTkcp3WLW+/zbsumlMrWYfBy2/yTm56qasWt38iY4D6ul\n` +
+ `CPiwhAqX3REvVq8tM79a2CEqZn9ka6vuXoDgBg/sBf/BUWqf7orkjUXwk/U0Egjf\nk5jcurF4vqf1u+rlAHH37dvSBaDjNj6Qnj4OP12bjfaY/yvs7+jue/eNXFHjzN4E\n` +
+ `T2H4B/yeKTJ4UuAwTlLaNbZJul2baLlHelJPAsxiYaziVuV5P+IGWckY6RSerRaZ\nAkc4mmGGtjAyfN9aewe+lNVfwS7ElFx546PlLgdQgjmeSwLX8FWxbPE5A/PmaXCs\n` +
+ `nx+nou+3dD7NluULLtdd7K+2x02trObKXCAzmi5/Dc+yKTzpFqEz+hLNCz7TImP/\ncK//NV9Q+X67J9O27baH9R9ZF4zMw8rv2Pg0WLSw1z7lLXwlgIsDapeMCsrxkVO4\n` +
+ `LXX5AQ1xQNtlssnVoUBqBrvZsX2jUUKUocvZqMGuE4hfAgMBAAE=\n-----END PUBLIC KEY-----\n"}}`)
+ fmt.Fprint(res, responseBody)
+ })
+ federatedRoutes.HandleFunc("/api/v1/activitypub/user-id/30",
+ func(res http.ResponseWriter, req *http.Request) {
+ // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/3
+ responseBody := fmt.Sprintf(`{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/v1"],` +
+ `"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/30","type":"Person",` +
+ `"icon":{"type":"Image","mediaType":"image/png","url":"https://federated-repo.prod.meissa.de/avatars/9c03f03d1c1f13f21976a22489326fe1"},` +
+ `"url":"https://federated-repo.prod.meissa.de/stargoose2","inbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/30/inbox",` +
+ `"outbox":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/30/outbox","preferredUsername":"stargoose2",` +
+ `"publicKey":{"id":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/30#main-key","owner":"https://federated-repo.prod.meissa.de/api/v1/activitypub/user-id/30",` +
+ `"publicKeyPem":"-----BEGIN PUBLIC KEY-----\nMIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAyv5NytsfqpWXSrwuk8a3\n0W1zE13QJioXb/e3opgN2CfKZkdm3hb+4+mGKoU/rCqegnL9/AO0Aw+R8fCHXx44\n` +
+ `iNkdVpdY8Dzq+tQ9IetPWbyVIBvSzGgvpqfS05JuVPsy8cBX9wByODjr5kq7k1/v\nY1G7E3uh0a/XJc+mZutwGC3gPgR93NSrqsvTPN4wdhCCu9uj02S8OBoKuSYaPkU+\n` +
+ `tZ4CEDpnclAOw/eNiH4x2irMvVtruEgtlTA5K2I4YJrmtGLidus47FCyc8/zEKUh\nAeiD8KWDvqsQgOhUwcQgRxAnYVCoMD9cnE+WFFRHTuQecNlmdNFs3Cr0yKcWjDde\n` +
+ `trvnehW7LfPveGb0tHRHPuVAJpncTOidUR5h/7pqMyvKHzuAHWomm9rEaGUxd/7a\nL1CFjAf39+QIEgu0Anj8mIc7CTiz+DQhDz+0jBOsQ0iDXc5GeBz7X9Xv4Jp966nq\n` +
+ `MUR0GQGXvfZQN9IqMO+WoUVy10Ddhns1EWGlA0x4fecnAgMBAAE=\n-----END PUBLIC KEY-----\n"}}`)
+ fmt.Fprint(res, responseBody)
+ })
+ federatedRoutes.HandleFunc("/",
+ func(res http.ResponseWriter, req *http.Request) {
+ t.Errorf("Unhandled request: %q", req.URL.EscapedPath())
+ })
+ federatedSrv := httptest.NewServer(federatedRoutes)
+ defer federatedSrv.Close()
+
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ appURL := setting.AppURL
+ setting.AppURL = srv.URL + "/"
+ defer func() {
+ setting.Database.LogSQL = false
+ setting.AppURL = appURL
+ }()
+ actionsUser := user.NewActionsUser()
+ repositoryID := 2
+ cf, err := activitypub.GetClientFactory(db.DefaultContext)
+ require.NoError(t, err)
+ c, err := cf.WithKeys(db.DefaultContext, actionsUser, "not used")
+ require.NoError(t, err)
+ repoInboxURL := fmt.Sprintf(
+ "%s/api/v1/activitypub/repository-id/%v/inbox",
+ srv.URL, repositoryID)
+
+ timeNow := time.Now().UTC()
+
+ activity1 := []byte(fmt.Sprintf(
+ `{"type":"Like",`+
+ `"startTime":"%s",`+
+ `"actor":"%s/api/v1/activitypub/user-id/15",`+
+ `"object":"%s/api/v1/activitypub/repository-id/%v"}`,
+ timeNow.Format(time.RFC3339),
+ federatedSrv.URL, srv.URL, repositoryID))
+ t.Logf("activity: %s", activity1)
+ resp, err := c.Post(activity1, repoInboxURL)
+
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusNoContent, resp.StatusCode)
+
+ federationHost := unittest.AssertExistsAndLoadBean(t, &forgefed.FederationHost{HostFqdn: "127.0.0.1"})
+ federatedUser := unittest.AssertExistsAndLoadBean(t, &user.FederatedUser{ExternalID: "15", FederationHostID: federationHost.ID})
+ unittest.AssertExistsAndLoadBean(t, &user.User{ID: federatedUser.UserID})
+
+ // A like activity by a different user of the same federated host.
+ activity2 := []byte(fmt.Sprintf(
+ `{"type":"Like",`+
+ `"startTime":"%s",`+
+ `"actor":"%s/api/v1/activitypub/user-id/30",`+
+ `"object":"%s/api/v1/activitypub/repository-id/%v"}`,
+ // Make sure this activity happens later then the one before
+ timeNow.Add(time.Second).Format(time.RFC3339),
+ federatedSrv.URL, srv.URL, repositoryID))
+ t.Logf("activity: %s", activity2)
+ resp, err = c.Post(activity2, repoInboxURL)
+
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusNoContent, resp.StatusCode)
+
+ federatedUser = unittest.AssertExistsAndLoadBean(t, &user.FederatedUser{ExternalID: "30", FederationHostID: federationHost.ID})
+ unittest.AssertExistsAndLoadBean(t, &user.User{ID: federatedUser.UserID})
+
+ // The same user sends another like activity
+ otherRepositoryID := 3
+ otherRepoInboxURL := fmt.Sprintf(
+ "%s/api/v1/activitypub/repository-id/%v/inbox",
+ srv.URL, otherRepositoryID)
+ activity3 := []byte(fmt.Sprintf(
+ `{"type":"Like",`+
+ `"startTime":"%s",`+
+ `"actor":"%s/api/v1/activitypub/user-id/30",`+
+ `"object":"%s/api/v1/activitypub/repository-id/%v"}`,
+ // Make sure this activity happens later then the ones before
+ timeNow.Add(time.Second*2).Format(time.RFC3339),
+ federatedSrv.URL, srv.URL, otherRepositoryID))
+ t.Logf("activity: %s", activity3)
+ resp, err = c.Post(activity3, otherRepoInboxURL)
+
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusNoContent, resp.StatusCode)
+
+ federatedUser = unittest.AssertExistsAndLoadBean(t, &user.FederatedUser{ExternalID: "30", FederationHostID: federationHost.ID})
+ unittest.AssertExistsAndLoadBean(t, &user.User{ID: federatedUser.UserID})
+
+ // Replay activity2.
+ resp, err = c.Post(activity2, repoInboxURL)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusNotAcceptable, resp.StatusCode)
+ })
+}
+
+func TestActivityPubRepositoryInboxInvalid(t *testing.T) {
+ setting.Federation.Enabled = true
+ testWebRoutes = routers.NormalRoutes()
+ defer func() {
+ setting.Federation.Enabled = false
+ testWebRoutes = routers.NormalRoutes()
+ }()
+
+ srv := httptest.NewServer(testWebRoutes)
+ defer srv.Close()
+
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ appURL := setting.AppURL
+ setting.AppURL = srv.URL + "/"
+ defer func() {
+ setting.Database.LogSQL = false
+ setting.AppURL = appURL
+ }()
+ actionsUser := user.NewActionsUser()
+ repositoryID := 2
+ cf, err := activitypub.GetClientFactory(db.DefaultContext)
+ require.NoError(t, err)
+ c, err := cf.WithKeys(db.DefaultContext, actionsUser, "not used")
+ require.NoError(t, err)
+ repoInboxURL := fmt.Sprintf("%s/api/v1/activitypub/repository-id/%v/inbox",
+ srv.URL, repositoryID)
+
+ activity := []byte(`{"type":"Wrong"}`)
+ resp, err := c.Post(activity, repoInboxURL)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusNotAcceptable, resp.StatusCode)
+ })
+}
diff --git a/tests/integration/api_admin_org_test.go b/tests/integration/api_admin_org_test.go
new file mode 100644
index 0000000..a29d0ba
--- /dev/null
+++ b/tests/integration/api_admin_org_test.go
@@ -0,0 +1,91 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIAdminOrgCreate(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin)
+
+ org := api.CreateOrgOption{
+ UserName: "user2_org",
+ FullName: "User2's organization",
+ Description: "This organization created by admin for user2",
+ Website: "https://try.gitea.io",
+ Location: "Shanghai",
+ Visibility: "private",
+ }
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/users/user2/orgs", &org).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var apiOrg api.Organization
+ DecodeJSON(t, resp, &apiOrg)
+
+ assert.Equal(t, org.UserName, apiOrg.Name)
+ assert.Equal(t, org.FullName, apiOrg.FullName)
+ assert.Equal(t, org.Description, apiOrg.Description)
+ assert.Equal(t, org.Website, apiOrg.Website)
+ assert.Equal(t, org.Location, apiOrg.Location)
+ assert.Equal(t, org.Visibility, apiOrg.Visibility)
+
+ unittest.AssertExistsAndLoadBean(t, &user_model.User{
+ Name: org.UserName,
+ LowerName: strings.ToLower(org.UserName),
+ FullName: org.FullName,
+ })
+ })
+}
+
+func TestAPIAdminOrgCreateBadVisibility(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin)
+
+ org := api.CreateOrgOption{
+ UserName: "user2_org",
+ FullName: "User2's organization",
+ Description: "This organization created by admin for user2",
+ Website: "https://try.gitea.io",
+ Location: "Shanghai",
+ Visibility: "notvalid",
+ }
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/users/user2/orgs", &org).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+}
+
+func TestAPIAdminOrgCreateNotAdmin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ nonAdminUsername := "user2"
+ session := loginUser(t, nonAdminUsername)
+ token := getTokenForLoggedInUser(t, session)
+ org := api.CreateOrgOption{
+ UserName: "user2_org",
+ FullName: "User2's organization",
+ Description: "This organization created by admin for user2",
+ Website: "https://try.gitea.io",
+ Location: "Shanghai",
+ Visibility: "public",
+ }
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/users/user2/orgs", &org).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+}
diff --git a/tests/integration/api_admin_test.go b/tests/integration/api_admin_test.go
new file mode 100644
index 0000000..5f8d360
--- /dev/null
+++ b/tests/integration/api_admin_test.go
@@ -0,0 +1,420 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/gobwas/glob"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIAdminCreateAndDeleteSSHKey(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ // user1 is an admin user
+ session := loginUser(t, "user1")
+ keyOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin)
+ urlStr := fmt.Sprintf("/api/v1/admin/users/%s/keys", keyOwner.Name)
+ req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
+ "key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n",
+ "title": "test-key",
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var newPublicKey api.PublicKey
+ DecodeJSON(t, resp, &newPublicKey)
+ unittest.AssertExistsAndLoadBean(t, &asymkey_model.PublicKey{
+ ID: newPublicKey.ID,
+ Name: newPublicKey.Title,
+ Fingerprint: newPublicKey.Fingerprint,
+ OwnerID: keyOwner.ID,
+ })
+
+ req = NewRequestf(t, "DELETE", "/api/v1/admin/users/%s/keys/%d", keyOwner.Name, newPublicKey.ID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ unittest.AssertNotExistsBean(t, &asymkey_model.PublicKey{ID: newPublicKey.ID})
+}
+
+func TestAPIAdminDeleteMissingSSHKey(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // user1 is an admin user
+ token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteAdmin)
+ req := NewRequestf(t, "DELETE", "/api/v1/admin/users/user1/keys/%d", unittest.NonexistentID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+}
+
+func TestAPIAdminDeleteUnauthorizedKey(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminUsername := "user1"
+ normalUsername := "user2"
+ token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
+
+ urlStr := fmt.Sprintf("/api/v1/admin/users/%s/keys", adminUsername)
+ req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
+ "key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n",
+ "title": "test-key",
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var newPublicKey api.PublicKey
+ DecodeJSON(t, resp, &newPublicKey)
+
+ token = getUserToken(t, normalUsername)
+ req = NewRequestf(t, "DELETE", "/api/v1/admin/users/%s/keys/%d", adminUsername, newPublicKey.ID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+}
+
+func TestAPISudoUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminUsername := "user1"
+ normalUsername := "user2"
+ token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeReadUser)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user?sudo=%s", normalUsername)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var user api.User
+ DecodeJSON(t, resp, &user)
+
+ assert.Equal(t, normalUsername, user.UserName)
+}
+
+func TestAPISudoUserForbidden(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminUsername := "user1"
+ normalUsername := "user2"
+
+ token := getUserToken(t, normalUsername, auth_model.AccessTokenScopeReadAdmin)
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user?sudo=%s", adminUsername)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+}
+
+func TestAPIListUsers(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminUsername := "user1"
+ token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeReadAdmin)
+
+ req := NewRequest(t, "GET", "/api/v1/admin/users").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var users []api.User
+ DecodeJSON(t, resp, &users)
+
+ found := false
+ for _, user := range users {
+ if user.UserName == adminUsername {
+ found = true
+ }
+ }
+ assert.True(t, found)
+ numberOfUsers := unittest.GetCount(t, &user_model.User{}, "type = 0")
+ assert.Len(t, users, numberOfUsers)
+}
+
+func TestAPIListUsersNotLoggedIn(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequest(t, "GET", "/api/v1/admin/users")
+ MakeRequest(t, req, http.StatusUnauthorized)
+}
+
+func TestAPIListUsersNonAdmin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ nonAdminUsername := "user2"
+ token := getUserToken(t, nonAdminUsername)
+ req := NewRequest(t, "GET", "/api/v1/admin/users").
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+}
+
+func TestAPICreateUserInvalidEmail(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminUsername := "user1"
+ token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
+ req := NewRequestWithValues(t, "POST", "/api/v1/admin/users", map[string]string{
+ "email": "invalid_email@domain.com\r\n",
+ "full_name": "invalid user",
+ "login_name": "invalidUser",
+ "must_change_password": "true",
+ "password": "password",
+ "send_notify": "true",
+ "source_id": "0",
+ "username": "invalidUser",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+}
+
+func TestAPICreateAndDeleteUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminUsername := "user1"
+ token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
+
+ req := NewRequestWithValues(
+ t,
+ "POST",
+ "/api/v1/admin/users",
+ map[string]string{
+ "email": "deleteme@domain.com",
+ "full_name": "delete me",
+ "login_name": "deleteme",
+ "must_change_password": "true",
+ "password": "password",
+ "send_notify": "true",
+ "source_id": "0",
+ "username": "deleteme",
+ },
+ ).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequest(t, "DELETE", "/api/v1/admin/users/deleteme").
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+}
+
+func TestAPIEditUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminUsername := "user1"
+ token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
+ urlStr := fmt.Sprintf("/api/v1/admin/users/%s", "user2")
+
+ fullNameToChange := "Full Name User 2"
+ req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{
+ "full_name": fullNameToChange,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: "user2"})
+ assert.Equal(t, fullNameToChange, user2.FullName)
+
+ empty := ""
+ req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{
+ Email: &empty,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusBadRequest)
+
+ errMap := make(map[string]any)
+ json.Unmarshal(resp.Body.Bytes(), &errMap)
+ assert.EqualValues(t, "e-mail invalid [email: ]", errMap["message"].(string))
+
+ user2 = unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: "user2"})
+ assert.False(t, user2.IsRestricted)
+ bTrue := true
+ req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{
+ Restricted: &bTrue,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ user2 = unittest.AssertExistsAndLoadBean(t, &user_model.User{LoginName: "user2"})
+ assert.True(t, user2.IsRestricted)
+}
+
+func TestAPIEditUserWithLoginName(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ adminUsername := "user1"
+ token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
+ urlStr := fmt.Sprintf("/api/v1/admin/users/%s", "user2")
+
+ loginName := "user2"
+ loginSource := int64(0)
+
+ t.Run("login_name only", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{
+ LoginName: &loginName,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+
+ t.Run("source_id only", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{
+ SourceID: &loginSource,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+
+ t.Run("login_name & source_id", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{
+ LoginName: &loginName,
+ SourceID: &loginSource,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ })
+}
+
+func TestAPICreateRepoForUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminUsername := "user1"
+ token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
+
+ req := NewRequestWithJSON(
+ t,
+ "POST",
+ fmt.Sprintf("/api/v1/admin/users/%s/repos", adminUsername),
+ &api.CreateRepoOption{
+ Name: "admincreatedrepo",
+ },
+ ).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+}
+
+func TestAPIRenameUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminUsername := "user1"
+ token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
+ urlStr := fmt.Sprintf("/api/v1/admin/users/%s/rename", "user2")
+ req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
+ // required
+ "new_name": "User2",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ urlStr = fmt.Sprintf("/api/v1/admin/users/%s/rename", "User2")
+ req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
+ // required
+ "new_name": "User2-2-2",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
+ // required
+ "new_name": "user1",
+ }).AddTokenAuth(token)
+ // the old user name still be used by with a redirect
+ MakeRequest(t, req, http.StatusTemporaryRedirect)
+
+ urlStr = fmt.Sprintf("/api/v1/admin/users/%s/rename", "User2-2-2")
+ req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
+ // required
+ "new_name": "user1",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ req = NewRequestWithValues(t, "POST", urlStr, map[string]string{
+ // required
+ "new_name": "user2",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+}
+
+func TestAPICron(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // user1 is an admin user
+ session := loginUser(t, "user1")
+
+ t.Run("List", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadAdmin)
+
+ req := NewRequest(t, "GET", "/api/v1/admin/cron").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "28", resp.Header().Get("X-Total-Count"))
+
+ var crons []api.Cron
+ DecodeJSON(t, resp, &crons)
+ assert.Len(t, crons, 28)
+ })
+
+ t.Run("Execute", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ now := time.Now()
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteAdmin)
+ // Archive cleanup is harmless, because in the test environment there are none
+ // and is thus an NOOP operation and therefore doesn't interfere with any other
+ // tests.
+ req := NewRequest(t, "POST", "/api/v1/admin/cron/archive_cleanup").
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Check for the latest run time for this cron, to ensure it has been run.
+ req = NewRequest(t, "GET", "/api/v1/admin/cron").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var crons []api.Cron
+ DecodeJSON(t, resp, &crons)
+
+ for _, cron := range crons {
+ if cron.Name == "archive_cleanup" {
+ assert.True(t, now.Before(cron.Prev))
+ }
+ }
+ })
+}
+
+func TestAPICreateUser_NotAllowedEmailDomain(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.Service.EmailDomainAllowList = []glob.Glob{glob.MustCompile("example.org")}
+ defer func() {
+ setting.Service.EmailDomainAllowList = []glob.Glob{}
+ }()
+
+ adminUsername := "user1"
+ token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
+
+ req := NewRequestWithValues(t, "POST", "/api/v1/admin/users", map[string]string{
+ "email": "allowedUser1@example1.org",
+ "login_name": "allowedUser1",
+ "username": "allowedUser1",
+ "password": "allowedUser1_pass",
+ "must_change_password": "true",
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ assert.Equal(t, "the domain of user email allowedUser1@example1.org conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", resp.Header().Get("X-Gitea-Warning"))
+
+ req = NewRequest(t, "DELETE", "/api/v1/admin/users/allowedUser1").AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+}
+
+func TestAPIEditUser_NotAllowedEmailDomain(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.Service.EmailDomainAllowList = []glob.Glob{glob.MustCompile("example.org")}
+ defer func() {
+ setting.Service.EmailDomainAllowList = []glob.Glob{}
+ }()
+
+ adminUsername := "user1"
+ token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeWriteAdmin)
+ urlStr := fmt.Sprintf("/api/v1/admin/users/%s", "user2")
+
+ newEmail := "user2@example1.com"
+ req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{
+ Email: &newEmail,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, "the domain of user email user2@example1.com conflicts with EMAIL_DOMAIN_ALLOWLIST or EMAIL_DOMAIN_BLOCKLIST", resp.Header().Get("X-Gitea-Warning"))
+
+ originalEmail := "user2@example.com"
+ req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditUserOption{
+ Email: &originalEmail,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+}
diff --git a/tests/integration/api_block_test.go b/tests/integration/api_block_test.go
new file mode 100644
index 0000000..a69ee9b
--- /dev/null
+++ b/tests/integration/api_block_test.go
@@ -0,0 +1,228 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIUserBlock(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := "user4"
+ session := loginUser(t, user)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+
+ t.Run("BlockUser", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", "/api/v1/user/block/user2").AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 4, BlockID: 2})
+ })
+
+ t.Run("ListBlocked", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/list_blocked").AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ // One user just got blocked and the other one is defined in the fixtures.
+ assert.Equal(t, "2", resp.Header().Get("X-Total-Count"))
+
+ var blockedUsers []api.BlockedUser
+ DecodeJSON(t, resp, &blockedUsers)
+ assert.Len(t, blockedUsers, 2)
+ assert.EqualValues(t, 1, blockedUsers[0].BlockID)
+ assert.EqualValues(t, 2, blockedUsers[1].BlockID)
+ })
+
+ t.Run("UnblockUser", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", "/api/v1/user/unblock/user2").AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: 2})
+ })
+
+ t.Run("Organization as target", func(t *testing.T) {
+ org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26, Type: user_model.UserTypeOrganization})
+
+ t.Run("Block", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/%s", org.Name)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: org.ID})
+ })
+
+ t.Run("Unblock", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/unblock/%s", org.Name)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+ })
+}
+
+func TestAPIOrgBlock(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := "user5"
+ org := "org6"
+ session := loginUser(t, user)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
+
+ t.Run("BlockUser", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2", org)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2})
+ })
+
+ t.Run("ListBlocked", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked", org)).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "1", resp.Header().Get("X-Total-Count"))
+
+ var blockedUsers []api.BlockedUser
+ DecodeJSON(t, resp, &blockedUsers)
+ assert.Len(t, blockedUsers, 1)
+ assert.EqualValues(t, 2, blockedUsers[0].BlockID)
+ })
+
+ t.Run("UnblockUser", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/user2", org)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2})
+ })
+
+ t.Run("Organization as target", func(t *testing.T) {
+ targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26, Type: user_model.UserTypeOrganization})
+
+ t.Run("Block", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/%s", org, targetOrg.Name)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 4, BlockID: targetOrg.ID})
+ })
+
+ t.Run("Unblock", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/%s", org, targetOrg.Name)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+ })
+
+ t.Run("Read scope token", func(t *testing.T) {
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization)
+
+ t.Run("Write action", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2", org)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+
+ unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 6, BlockID: 2})
+ })
+
+ t.Run("Read action", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked", org)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ })
+ })
+
+ t.Run("Not as owner", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ org := "org3"
+ user := "user4" // Part of org team with write perms.
+
+ session := loginUser(t, user)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
+
+ t.Run("Block user", func(t *testing.T) {
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/block/user2", org)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+
+ unittest.AssertNotExistsBean(t, &user_model.BlockedUser{UserID: 3, BlockID: 2})
+ })
+
+ t.Run("Unblock user", func(t *testing.T) {
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/orgs/%s/unblock/user2", org)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+
+ t.Run("List blocked users", func(t *testing.T) {
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/list_blocked", org)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+ })
+}
+
+// TestAPIBlock_AddCollaborator ensures that the doer and blocked user cannot
+// add each others as collaborators via the API.
+func TestAPIBlock_AddCollaborator(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user1 := "user10"
+ user2 := "user2"
+ perm := "write"
+ collabOption := &api.AddCollaboratorOption{Permission: &perm}
+
+ // User1 blocks User2.
+ session := loginUser(t, user1)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
+
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/block/%s", user2)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ unittest.AssertExistsAndLoadBean(t, &user_model.BlockedUser{UserID: 10, BlockID: 2})
+
+ t.Run("BlockedUser Add Doer", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: 2})
+ session := loginUser(t, user2)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s", user2, repo.Name, user1), collabOption).AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusForbidden)
+ })
+
+ t.Run("Doer Add BlockedUser", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 7, OwnerID: 10})
+ session := loginUser(t, user1)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s", user1, repo.Name, user2), collabOption).AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusForbidden)
+ })
+}
diff --git a/tests/integration/api_branch_test.go b/tests/integration/api_branch_test.go
new file mode 100644
index 0000000..63159f3
--- /dev/null
+++ b/tests/integration/api_branch_test.go
@@ -0,0 +1,268 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ "code.gitea.io/gitea/modules/git"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func testAPIGetBranch(t *testing.T, branchName string, exists bool) {
+ token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
+ req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/branches/%s", branchName).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, NoExpectedStatus)
+ if !exists {
+ assert.EqualValues(t, http.StatusNotFound, resp.Code)
+ return
+ }
+ assert.EqualValues(t, http.StatusOK, resp.Code)
+ var branch api.Branch
+ DecodeJSON(t, resp, &branch)
+ assert.EqualValues(t, branchName, branch.Name)
+ assert.True(t, branch.UserCanPush)
+ assert.True(t, branch.UserCanMerge)
+}
+
+func testAPIGetBranchProtection(t *testing.T, branchName string, expectedHTTPStatus int) *api.BranchProtection {
+ token := getUserToken(t, "user2", auth_model.AccessTokenScopeReadRepository)
+ req := NewRequestf(t, "GET", "/api/v1/repos/user2/repo1/branch_protections/%s", branchName).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, expectedHTTPStatus)
+
+ if resp.Code == http.StatusOK {
+ var branchProtection api.BranchProtection
+ DecodeJSON(t, resp, &branchProtection)
+ assert.EqualValues(t, branchName, branchProtection.RuleName)
+ return &branchProtection
+ }
+ return nil
+}
+
+func testAPICreateBranchProtection(t *testing.T, branchName string, expectedHTTPStatus int) {
+ token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/branch_protections", &api.BranchProtection{
+ RuleName: branchName,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, expectedHTTPStatus)
+
+ if resp.Code == http.StatusCreated {
+ var branchProtection api.BranchProtection
+ DecodeJSON(t, resp, &branchProtection)
+ assert.EqualValues(t, branchName, branchProtection.RuleName)
+ }
+}
+
+func testAPIEditBranchProtection(t *testing.T, branchName string, body *api.BranchProtection, expectedHTTPStatus int) {
+ token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestWithJSON(t, "PATCH", "/api/v1/repos/user2/repo1/branch_protections/"+branchName, body).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, expectedHTTPStatus)
+
+ if resp.Code == http.StatusOK {
+ var branchProtection api.BranchProtection
+ DecodeJSON(t, resp, &branchProtection)
+ assert.EqualValues(t, branchName, branchProtection.RuleName)
+ }
+}
+
+func testAPIDeleteBranchProtection(t *testing.T, branchName string, expectedHTTPStatus int) {
+ token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestf(t, "DELETE", "/api/v1/repos/user2/repo1/branch_protections/%s", branchName).
+ AddTokenAuth(token)
+ MakeRequest(t, req, expectedHTTPStatus)
+}
+
+func testAPIDeleteBranch(t *testing.T, branchName string, expectedHTTPStatus int) {
+ token := getUserToken(t, "user2", auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestf(t, "DELETE", "/api/v1/repos/user2/repo1/branches/%s", branchName).
+ AddTokenAuth(token)
+ MakeRequest(t, req, expectedHTTPStatus)
+}
+
+func TestAPIGetBranch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ for _, test := range []struct {
+ BranchName string
+ Exists bool
+ }{
+ {"master", true},
+ {"master/doesnotexist", false},
+ {"feature/1", true},
+ {"feature/1/doesnotexist", false},
+ } {
+ testAPIGetBranch(t, test.BranchName, test.Exists)
+ }
+}
+
+func TestAPICreateBranch(t *testing.T) {
+ onGiteaRun(t, testAPICreateBranches)
+}
+
+func testAPICreateBranches(t *testing.T, giteaURL *url.URL) {
+ forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) {
+ ctx := NewAPITestContext(t, "user2", "my-noo-repo-"+objectFormat.Name(), auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ giteaURL.Path = ctx.GitPath()
+
+ t.Run("CreateRepo", doAPICreateRepository(ctx, false, objectFormat))
+ testCases := []struct {
+ OldBranch string
+ NewBranch string
+ ExpectedHTTPStatus int
+ }{
+ // Creating branch from default branch
+ {
+ OldBranch: "",
+ NewBranch: "new_branch_from_default_branch",
+ ExpectedHTTPStatus: http.StatusCreated,
+ },
+ // Creating branch from master
+ {
+ OldBranch: "master",
+ NewBranch: "new_branch_from_master_1",
+ ExpectedHTTPStatus: http.StatusCreated,
+ },
+ // Trying to create from master but already exists
+ {
+ OldBranch: "master",
+ NewBranch: "new_branch_from_master_1",
+ ExpectedHTTPStatus: http.StatusConflict,
+ },
+ // Trying to create from other branch (not default branch)
+ // ps: it can't test the case-sensitive behavior here: the "BRANCH_2" can't be created by git on a case-insensitive filesystem, it makes the test fail quickly before the database code.
+ // Suppose some users are running Gitea on a case-insensitive filesystem, it seems that it's unable to support case-sensitive branch names.
+ {
+ OldBranch: "new_branch_from_master_1",
+ NewBranch: "branch_2",
+ ExpectedHTTPStatus: http.StatusCreated,
+ },
+ // Trying to create from a branch which does not exist
+ {
+ OldBranch: "does_not_exist",
+ NewBranch: "new_branch_from_non_existent",
+ ExpectedHTTPStatus: http.StatusNotFound,
+ },
+ // Trying to create a branch with UTF8
+ {
+ OldBranch: "master",
+ NewBranch: "test-👀",
+ ExpectedHTTPStatus: http.StatusCreated,
+ },
+ }
+ for _, test := range testCases {
+ session := ctx.Session
+ t.Run(test.NewBranch, func(t *testing.T) {
+ testAPICreateBranch(t, session, ctx.Username, ctx.Reponame, test.OldBranch, test.NewBranch, test.ExpectedHTTPStatus)
+ })
+ }
+ })
+}
+
+func testAPICreateBranch(t testing.TB, session *TestSession, user, repo, oldBranch, newBranch string, status int) bool {
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestWithJSON(t, "POST", "/api/v1/repos/"+user+"/"+repo+"/branches", &api.CreateBranchRepoOption{
+ BranchName: newBranch,
+ OldBranchName: oldBranch,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, status)
+
+ var branch api.Branch
+ DecodeJSON(t, resp, &branch)
+
+ if resp.Result().StatusCode == http.StatusCreated {
+ assert.EqualValues(t, newBranch, branch.Name)
+ }
+
+ return resp.Result().StatusCode == status
+}
+
+func TestAPIBranchProtection(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // Branch protection on branch that not exist
+ testAPICreateBranchProtection(t, "master/doesnotexist", http.StatusCreated)
+ // Get branch protection on branch that exist but not branch protection
+ testAPIGetBranchProtection(t, "master", http.StatusNotFound)
+
+ testAPICreateBranchProtection(t, "master", http.StatusCreated)
+ // Can only create once
+ testAPICreateBranchProtection(t, "master", http.StatusForbidden)
+
+ // Can't delete a protected branch
+ testAPIDeleteBranch(t, "master", http.StatusForbidden)
+
+ testAPIGetBranchProtection(t, "master", http.StatusOK)
+ testAPIEditBranchProtection(t, "master", &api.BranchProtection{
+ EnablePush: true,
+ }, http.StatusOK)
+
+ // enable status checks, require the "test1" check to pass
+ testAPIEditBranchProtection(t, "master", &api.BranchProtection{
+ EnableStatusCheck: true,
+ StatusCheckContexts: []string{"test1"},
+ }, http.StatusOK)
+ bp := testAPIGetBranchProtection(t, "master", http.StatusOK)
+ assert.True(t, bp.EnableStatusCheck)
+ assert.Equal(t, []string{"test1"}, bp.StatusCheckContexts)
+
+ // disable status checks, clear the list of required checks
+ testAPIEditBranchProtection(t, "master", &api.BranchProtection{
+ EnableStatusCheck: false,
+ StatusCheckContexts: []string{},
+ }, http.StatusOK)
+ bp = testAPIGetBranchProtection(t, "master", http.StatusOK)
+ assert.False(t, bp.EnableStatusCheck)
+ assert.Equal(t, []string{}, bp.StatusCheckContexts)
+
+ testAPIDeleteBranchProtection(t, "master", http.StatusNoContent)
+
+ // Test branch deletion
+ testAPIDeleteBranch(t, "master", http.StatusForbidden)
+ testAPIDeleteBranch(t, "branch2", http.StatusNoContent)
+}
+
+func TestAPICreateBranchWithSyncBranches(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ branches, err := db.Find[git_model.Branch](db.DefaultContext, git_model.FindBranchOptions{
+ RepoID: 1,
+ })
+ require.NoError(t, err)
+ assert.Len(t, branches, 4)
+
+ // make a broke repository with no branch on database
+ _, err = db.DeleteByBean(db.DefaultContext, git_model.Branch{RepoID: 1})
+ require.NoError(t, err)
+
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ giteaURL.Path = ctx.GitPath()
+
+ testAPICreateBranch(t, ctx.Session, "user2", "repo1", "", "new_branch", http.StatusCreated)
+ })
+
+ branches, err = db.Find[git_model.Branch](db.DefaultContext, git_model.FindBranchOptions{
+ RepoID: 1,
+ })
+ require.NoError(t, err)
+ assert.Len(t, branches, 5)
+
+ branches, err = db.Find[git_model.Branch](db.DefaultContext, git_model.FindBranchOptions{
+ RepoID: 1,
+ Keyword: "new_branch",
+ })
+ require.NoError(t, err)
+ assert.Len(t, branches, 1)
+}
diff --git a/tests/integration/api_comment_attachment_test.go b/tests/integration/api_comment_attachment_test.go
new file mode 100644
index 0000000..db1b98a
--- /dev/null
+++ b/tests/integration/api_comment_attachment_test.go
@@ -0,0 +1,278 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/convert"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIGetCommentAttachment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
+ require.NoError(t, comment.LoadIssue(db.DefaultContext))
+ require.NoError(t, comment.LoadAttachments(db.DefaultContext))
+ attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: comment.Attachments[0].ID})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: comment.Issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ t.Run("UnrelatedCommentID", func(t *testing.T) {
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue)
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d/assets/%d", repoOwner.Name, repo.Name, comment.ID, attachment.ID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue)
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d", repoOwner.Name, repo.Name, comment.ID).
+ AddTokenAuth(token)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ var apiComment api.Comment
+ DecodeJSON(t, resp, &apiComment)
+ assert.NotEmpty(t, apiComment.Attachments)
+
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d/assets/%d", repoOwner.Name, repo.Name, comment.ID, attachment.ID).
+ AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d/assets/%d", repoOwner.Name, repo.Name, comment.ID, attachment.ID).
+ AddTokenAuth(token)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ var apiAttachment api.Attachment
+ DecodeJSON(t, resp, &apiAttachment)
+
+ expect := convert.ToAPIAttachment(repo, attachment)
+ assert.Equal(t, expect.ID, apiAttachment.ID)
+ assert.Equal(t, expect.Name, apiAttachment.Name)
+ assert.Equal(t, expect.UUID, apiAttachment.UUID)
+ assert.Equal(t, expect.Created.Unix(), apiAttachment.Created.Unix())
+ assert.Equal(t, expect.DownloadURL, apiAttachment.DownloadURL)
+}
+
+func TestAPIListCommentAttachments(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue)
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d/assets", repoOwner.Name, repo.Name, comment.ID).
+ AddTokenAuth(token)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ var apiAttachments []*api.Attachment
+ DecodeJSON(t, resp, &apiAttachments)
+ expectedCount := unittest.GetCount(t, &repo_model.Attachment{CommentID: comment.ID})
+ assert.Len(t, apiAttachments, expectedCount)
+
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachments[0].ID, CommentID: comment.ID})
+}
+
+func TestAPICreateCommentAttachment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ filename := "image.png"
+ buff := generateImg()
+ body := &bytes.Buffer{}
+
+ // Setup multi-part
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("attachment", filename)
+ require.NoError(t, err)
+ _, err = io.Copy(part, &buff)
+ require.NoError(t, err)
+ err = writer.Close()
+ require.NoError(t, err)
+
+ req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets", repoOwner.Name, repo.Name, comment.ID), body).
+ AddTokenAuth(token).
+ SetHeader("Content-Type", writer.FormDataContentType())
+ resp := session.MakeRequest(t, req, http.StatusCreated)
+
+ apiAttachment := new(api.Attachment)
+ DecodeJSON(t, resp, &apiAttachment)
+
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, CommentID: comment.ID})
+}
+
+func TestAPICreateCommentAttachmentAutoDate(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets",
+ repoOwner.Name, repo.Name, comment.ID)
+
+ filename := "image.png"
+ buff := generateImg()
+ body := &bytes.Buffer{}
+
+ t.Run("WithAutoDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Setup multi-part
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("attachment", filename)
+ require.NoError(t, err)
+ _, err = io.Copy(part, &buff)
+ require.NoError(t, err)
+ err = writer.Close()
+ require.NoError(t, err)
+
+ req := NewRequestWithBody(t, "POST", urlStr, body).AddTokenAuth(token)
+ req.Header.Add("Content-Type", writer.FormDataContentType())
+ resp := session.MakeRequest(t, req, http.StatusCreated)
+ apiAttachment := new(api.Attachment)
+ DecodeJSON(t, resp, &apiAttachment)
+
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID})
+ // the execution of the API call supposedly lasted less than one minute
+ updatedSince := time.Since(apiAttachment.Created)
+ assert.LessOrEqual(t, updatedSince, time.Minute)
+
+ commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID})
+ updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime())
+ assert.LessOrEqual(t, updatedSince, time.Minute)
+ })
+
+ t.Run("WithUpdateDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
+ urlStr += fmt.Sprintf("?updated_at=%s", updatedAt.UTC().Format(time.RFC3339))
+
+ // Setup multi-part
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("attachment", filename)
+ require.NoError(t, err)
+ _, err = io.Copy(part, &buff)
+ require.NoError(t, err)
+ err = writer.Close()
+ require.NoError(t, err)
+
+ req := NewRequestWithBody(t, "POST", urlStr, body).AddTokenAuth(token)
+ req.Header.Add("Content-Type", writer.FormDataContentType())
+ resp := session.MakeRequest(t, req, http.StatusCreated)
+ apiAttachment := new(api.Attachment)
+ DecodeJSON(t, resp, &apiAttachment)
+
+ // dates will be converted into the same tz, in order to compare them
+ utcTZ, _ := time.LoadLocation("UTC")
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID})
+ assert.Equal(t, updatedAt.In(utcTZ), apiAttachment.Created.In(utcTZ))
+
+ commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID})
+ assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ))
+ })
+}
+
+func TestAPIEditCommentAttachment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ const newAttachmentName = "newAttachmentName"
+
+ attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 6})
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: attachment.CommentID})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets/%d",
+ repoOwner.Name, repo.Name, comment.ID, attachment.ID)
+ req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{
+ "name": newAttachmentName,
+ }).AddTokenAuth(token)
+ resp := session.MakeRequest(t, req, http.StatusCreated)
+ apiAttachment := new(api.Attachment)
+ DecodeJSON(t, resp, &apiAttachment)
+
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, CommentID: comment.ID, Name: apiAttachment.Name})
+}
+
+func TestAPIDeleteCommentAttachment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 6})
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: attachment.CommentID})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets/%d", repoOwner.Name, repo.Name, comment.ID, attachment.ID)).
+ AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusNoContent)
+
+ unittest.AssertNotExistsBean(t, &repo_model.Attachment{ID: attachment.ID, CommentID: comment.ID})
+}
+
+func TestAPICreateCommentAttachmentWithUnallowedFile(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ filename := "file.bad"
+ body := &bytes.Buffer{}
+
+ // Setup multi-part.
+ writer := multipart.NewWriter(body)
+ _, err := writer.CreateFormFile("attachment", filename)
+ require.NoError(t, err)
+ err = writer.Close()
+ require.NoError(t, err)
+
+ req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/assets", repoOwner.Name, repo.Name, comment.ID), body).
+ AddTokenAuth(token).
+ SetHeader("Content-Type", writer.FormDataContentType())
+
+ session.MakeRequest(t, req, http.StatusUnprocessableEntity)
+}
diff --git a/tests/integration/api_comment_test.go b/tests/integration/api_comment_test.go
new file mode 100644
index 0000000..a53b56d
--- /dev/null
+++ b/tests/integration/api_comment_test.go
@@ -0,0 +1,467 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/references"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/convert"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIListRepoComments(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{},
+ unittest.Cond("type = ?", issues_model.CommentTypeComment))
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments", repoOwner.Name, repo.Name))
+ req := NewRequest(t, "GET", link.String())
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiComments []*api.Comment
+ DecodeJSON(t, resp, &apiComments)
+ assert.Len(t, apiComments, 3)
+ for _, apiComment := range apiComments {
+ c := &issues_model.Comment{ID: apiComment.ID}
+ unittest.AssertExistsAndLoadBean(t, c,
+ unittest.Cond("type = ?", issues_model.CommentTypeComment))
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: c.IssueID, RepoID: repo.ID})
+ }
+
+ // test before and since filters
+ query := url.Values{}
+ before := "2000-01-01T00:00:11+00:00" // unix: 946684811
+ since := "2000-01-01T00:00:12+00:00" // unix: 946684812
+ query.Add("before", before)
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiComments)
+ assert.Len(t, apiComments, 1)
+ assert.EqualValues(t, 2, apiComments[0].ID)
+
+ query.Del("before")
+ query.Add("since", since)
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiComments)
+ assert.Len(t, apiComments, 2)
+ assert.EqualValues(t, 3, apiComments[0].ID)
+}
+
+func TestAPIListIssueComments(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{},
+ unittest.Cond("type = ?", issues_model.CommentTypeComment))
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeReadIssue)
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/%d/comments", repoOwner.Name, repo.Name, issue.Index).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var comments []*api.Comment
+ DecodeJSON(t, resp, &comments)
+ expectedCount := unittest.GetCount(t, &issues_model.Comment{IssueID: issue.ID},
+ unittest.Cond("type = ?", issues_model.CommentTypeComment))
+ assert.Len(t, comments, expectedCount)
+}
+
+func TestAPICreateComment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ const commentBody = "Comment body"
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments",
+ repoOwner.Name, repo.Name, issue.Index)
+ req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
+ "body": commentBody,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var updatedComment api.Comment
+ DecodeJSON(t, resp, &updatedComment)
+ assert.EqualValues(t, commentBody, updatedComment.Body)
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody})
+}
+
+func TestAPICreateCommentAutoDate(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue)
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments",
+ repoOwner.Name, repo.Name, issue.Index)
+ const commentBody = "Comment body"
+
+ t.Run("WithAutoDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
+ "body": commentBody,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var updatedComment api.Comment
+ DecodeJSON(t, resp, &updatedComment)
+
+ // the execution of the API call supposedly lasted less than one minute
+ updatedSince := time.Since(updatedComment.Updated)
+ assert.LessOrEqual(t, updatedSince, time.Minute)
+
+ commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody})
+ updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime())
+ assert.LessOrEqual(t, updatedSince, time.Minute)
+ })
+
+ t.Run("WithUpdateDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
+
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueCommentOption{
+ Body: commentBody,
+ Updated: &updatedAt,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var updatedComment api.Comment
+ DecodeJSON(t, resp, &updatedComment)
+
+ // dates will be converted into the same tz, in order to compare them
+ utcTZ, _ := time.LoadLocation("UTC")
+ assert.Equal(t, updatedAt.In(utcTZ), updatedComment.Updated.In(utcTZ))
+ commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: updatedComment.ID, IssueID: issue.ID, Content: commentBody})
+ assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ))
+ })
+}
+
+func TestAPICommentXRefAutoDate(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue)
+
+ t.Run("WithAutoDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Create a comment mentioning issue #2 and check that a xref comment was added
+ // in issue #2
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments",
+ repoOwner.Name, repo.Name, issue.Index)
+
+ commentBody := "mention #2"
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueCommentOption{
+ Body: commentBody,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var createdComment api.Comment
+ DecodeJSON(t, resp, &createdComment)
+
+ ref := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: 1, RefCommentID: createdComment.ID})
+ assert.Equal(t, issues_model.CommentTypeCommentRef, ref.Type)
+ assert.Equal(t, references.XRefActionNone, ref.RefAction)
+ // the execution of the API call supposedly lasted less than one minute
+ updatedSince := time.Since(ref.UpdatedUnix.AsTime())
+ assert.LessOrEqual(t, updatedSince, time.Minute)
+
+ // Remove the mention to issue #2 and check that the xref was neutered
+ urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d",
+ repoOwner.Name, repo.Name, createdComment.ID)
+
+ newCommentBody := "no mention"
+ req = NewRequestWithJSON(t, "PATCH", urlStr, &api.EditIssueCommentOption{
+ Body: newCommentBody,
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var updatedComment api.Comment
+ DecodeJSON(t, resp, &updatedComment)
+
+ ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: 1, RefCommentID: updatedComment.ID})
+ assert.Equal(t, issues_model.CommentTypeCommentRef, ref.Type)
+ assert.Equal(t, references.XRefActionNeutered, ref.RefAction)
+ // the execution of the API call supposedly lasted less than one minute
+ updatedSince = time.Since(ref.UpdatedUnix.AsTime())
+ assert.LessOrEqual(t, updatedSince, time.Minute)
+ })
+
+ t.Run("WithUpdateDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // dates will be converted into the same tz, in order to compare them
+ utcTZ, _ := time.LoadLocation("UTC")
+
+ // Create a comment mentioning issue #2 and check that a xref comment was added
+ // in issue #2
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments",
+ repoOwner.Name, repo.Name, issue.Index)
+
+ commentBody := "re-mention #2"
+ updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueCommentOption{
+ Body: commentBody,
+ Updated: &updatedAt,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var createdComment api.Comment
+ DecodeJSON(t, resp, &createdComment)
+
+ ref := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: 1, RefCommentID: createdComment.ID})
+ assert.Equal(t, issues_model.CommentTypeCommentRef, ref.Type)
+ assert.Equal(t, references.XRefActionNone, ref.RefAction)
+ assert.Equal(t, updatedAt.In(utcTZ), ref.UpdatedUnix.AsTimeInLocation(utcTZ))
+
+ // Remove the mention to issue #2 and check that the xref was neutered
+ urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d",
+ repoOwner.Name, repo.Name, createdComment.ID)
+
+ newCommentBody := "no mention"
+ updatedAt = time.Now().Add(-time.Hour).Truncate(time.Second)
+ req = NewRequestWithJSON(t, "PATCH", urlStr, &api.EditIssueCommentOption{
+ Body: newCommentBody,
+ Updated: &updatedAt,
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var updatedComment api.Comment
+ DecodeJSON(t, resp, &updatedComment)
+
+ ref = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: 2, RefIssueID: 1, RefCommentID: updatedComment.ID})
+ assert.Equal(t, issues_model.CommentTypeCommentRef, ref.Type)
+ assert.Equal(t, references.XRefActionNeutered, ref.RefAction)
+ assert.Equal(t, updatedAt.In(utcTZ), ref.UpdatedUnix.AsTimeInLocation(utcTZ))
+ })
+}
+
+func TestAPIGetComment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
+ require.NoError(t, comment.LoadIssue(db.DefaultContext))
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: comment.Issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeReadIssue)
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d", repoOwner.Name, repo.Name, comment.ID)
+ MakeRequest(t, req, http.StatusOK)
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d", repoOwner.Name, repo.Name, comment.ID).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiComment api.Comment
+ DecodeJSON(t, resp, &apiComment)
+
+ require.NoError(t, comment.LoadPoster(db.DefaultContext))
+ expect := convert.ToAPIComment(db.DefaultContext, repo, comment)
+
+ assert.Equal(t, expect.ID, apiComment.ID)
+ assert.Equal(t, expect.Poster.FullName, apiComment.Poster.FullName)
+ assert.Equal(t, expect.Body, apiComment.Body)
+ assert.Equal(t, expect.Created.Unix(), apiComment.Created.Unix())
+}
+
+func TestAPIGetSystemUserComment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ for _, systemUser := range []*user_model.User{
+ user_model.NewGhostUser(),
+ user_model.NewActionsUser(),
+ } {
+ body := fmt.Sprintf("Hello %s", systemUser.Name)
+ comment, err := issues_model.CreateComment(db.DefaultContext, &issues_model.CreateCommentOptions{
+ Type: issues_model.CommentTypeComment,
+ Doer: systemUser,
+ Repo: repo,
+ Issue: issue,
+ Content: body,
+ })
+ require.NoError(t, err)
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/comments/%d", repoOwner.Name, repo.Name, comment.ID)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiComment api.Comment
+ DecodeJSON(t, resp, &apiComment)
+
+ if assert.NotNil(t, apiComment.Poster) {
+ if assert.Equal(t, systemUser.ID, apiComment.Poster.ID) {
+ require.NoError(t, comment.LoadPoster(db.DefaultContext))
+ assert.Equal(t, systemUser.Name, apiComment.Poster.UserName)
+ }
+ }
+ assert.Equal(t, body, apiComment.Body)
+ }
+}
+
+func TestAPIEditComment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ const newCommentBody = "This is the new comment body"
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 8},
+ unittest.Cond("type = ?", issues_model.CommentTypeComment))
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ t.Run("UnrelatedCommentID", func(t *testing.T) {
+ // Using the ID of a comment that does not belong to the repository must fail
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d",
+ repoOwner.Name, repo.Name, comment.ID)
+ req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{
+ "body": newCommentBody,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d",
+ repoOwner.Name, repo.Name, comment.ID)
+ req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{
+ "body": newCommentBody,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var updatedComment api.Comment
+ DecodeJSON(t, resp, &updatedComment)
+ assert.EqualValues(t, comment.ID, updatedComment.ID)
+ assert.EqualValues(t, newCommentBody, updatedComment.Body)
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody})
+}
+
+func TestAPIEditCommentWithDate(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{},
+ unittest.Cond("type = ?", issues_model.CommentTypeComment))
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue)
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d",
+ repoOwner.Name, repo.Name, comment.ID)
+ const newCommentBody = "This is the new comment body"
+
+ t.Run("WithAutoDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{
+ "body": newCommentBody,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var updatedComment api.Comment
+ DecodeJSON(t, resp, &updatedComment)
+
+ // the execution of the API call supposedly lasted less than one minute
+ updatedSince := time.Since(updatedComment.Updated)
+ assert.LessOrEqual(t, updatedSince, time.Minute)
+
+ commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody})
+ updatedSince = time.Since(commentAfter.UpdatedUnix.AsTime())
+ assert.LessOrEqual(t, updatedSince, time.Minute)
+ })
+
+ t.Run("WithUpdateDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
+
+ req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditIssueCommentOption{
+ Body: newCommentBody,
+ Updated: &updatedAt,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var updatedComment api.Comment
+ DecodeJSON(t, resp, &updatedComment)
+
+ // dates will be converted into the same tz, in order to compare them
+ utcTZ, _ := time.LoadLocation("UTC")
+ assert.Equal(t, updatedAt.In(utcTZ), updatedComment.Updated.In(utcTZ))
+ commentAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: comment.ID, IssueID: issue.ID, Content: newCommentBody})
+ assert.Equal(t, updatedAt.In(utcTZ), commentAfter.UpdatedUnix.AsTime().In(utcTZ))
+ })
+}
+
+func TestAPIDeleteComment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 8},
+ unittest.Cond("type = ?", issues_model.CommentTypeComment))
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ t.Run("UnrelatedCommentID", func(t *testing.T) {
+ // Using the ID of a comment that does not belong to the repository must fail
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue)
+ req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/issues/comments/%d", repoOwner.Name, repo.Name, comment.ID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue)
+ req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/issues/comments/%d", repoOwner.Name, repo.Name, comment.ID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ unittest.AssertNotExistsBean(t, &issues_model.Comment{ID: comment.ID})
+}
+
+func TestAPIListIssueTimeline(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // load comment
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ // make request
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/%d/timeline", repoOwner.Name, repo.Name, issue.Index)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ // check if lens of list returned by API and
+ // lists extracted directly from DB are the same
+ var comments []*api.TimelineComment
+ DecodeJSON(t, resp, &comments)
+ expectedCount := unittest.GetCount(t, &issues_model.Comment{IssueID: issue.ID})
+ assert.Len(t, comments, expectedCount)
+}
diff --git a/tests/integration/api_feed_plain_text_titles_test.go b/tests/integration/api_feed_plain_text_titles_test.go
new file mode 100644
index 0000000..b124778
--- /dev/null
+++ b/tests/integration/api_feed_plain_text_titles_test.go
@@ -0,0 +1,43 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFeedPlainTextTitles(t *testing.T) {
+ // This test verifies that items' titles in feeds are generated as plain text.
+ // See https://codeberg.org/forgejo/forgejo/pulls/1595
+ defer test.MockVariableValue(&setting.UI.DefaultShowFullName, true)()
+
+ t.Run("Feed plain text titles", func(t *testing.T) {
+ t.Run("Atom", func(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1.atom")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ data := resp.Body.String()
+ assert.Contains(t, data, "<title>the_1-user.with.all.allowedChars closed issue user2/repo1#4</title>")
+ })
+
+ t.Run("RSS", func(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1.rss")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ data := resp.Body.String()
+ assert.Contains(t, data, "<title>the_1-user.with.all.allowedChars closed issue user2/repo1#4</title>")
+ })
+ })
+}
diff --git a/tests/integration/api_feed_user_test.go b/tests/integration/api_feed_user_test.go
new file mode 100644
index 0000000..7f38249
--- /dev/null
+++ b/tests/integration/api_feed_user_test.go
@@ -0,0 +1,89 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestFeed(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("User", func(t *testing.T) {
+ t.Run("Atom", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2.atom")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ data := resp.Body.String()
+ assert.Contains(t, data, `<feed xmlns="http://www.w3.org/2005/Atom"`)
+ })
+
+ t.Run("RSS", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2.rss")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ data := resp.Body.String()
+ assert.Contains(t, data, `<rss version="2.0"`)
+ })
+ })
+
+ t.Run("Repo", func(t *testing.T) {
+ t.Run("Normal", func(t *testing.T) {
+ t.Run("Atom", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/atom/branch/master")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ data := resp.Body.String()
+ assert.Contains(t, data, `<feed xmlns="http://www.w3.org/2005/Atom"`)
+ })
+ t.Run("RSS", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/rss/branch/master")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ data := resp.Body.String()
+ assert.Contains(t, data, `<rss version="2.0"`)
+ })
+ })
+ t.Run("Empty", func(t *testing.T) {
+ err := user_model.UpdateUserCols(db.DefaultContext, &user_model.User{ID: 30, ProhibitLogin: false}, "prohibit_login")
+ require.NoError(t, err)
+
+ session := loginUser(t, "user30")
+ t.Run("Atom", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user30/empty/atom/branch/master")
+ session.MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", "/user30/empty.atom/src/branch/master")
+ session.MakeRequest(t, req, http.StatusNotFound)
+ })
+ t.Run("RSS", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user30/empty/rss/branch/master")
+ session.MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", "/user30/empty.rss/src/branch/master")
+ session.MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+ })
+}
diff --git a/tests/integration/api_forgejo_root_test.go b/tests/integration/api_forgejo_root_test.go
new file mode 100644
index 0000000..d21c944
--- /dev/null
+++ b/tests/integration/api_forgejo_root_test.go
@@ -0,0 +1,21 @@
+// Copyright The Forgejo Authors.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIForgejoRoot(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/api/forgejo/v1")
+ resp := MakeRequest(t, req, http.StatusNoContent)
+ assert.Contains(t, resp.Header().Get("Link"), "/assets/forgejo/api.v1.yml")
+}
diff --git a/tests/integration/api_forgejo_version_test.go b/tests/integration/api_forgejo_version_test.go
new file mode 100644
index 0000000..5c95fd3
--- /dev/null
+++ b/tests/integration/api_forgejo_version_test.go
@@ -0,0 +1,59 @@
+// Copyright The Forgejo Authors.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+ v1 "code.gitea.io/gitea/routers/api/forgejo/v1"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIForgejoVersion(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("Version", func(t *testing.T) {
+ req := NewRequest(t, "GET", "/api/forgejo/v1/version")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var version v1.Version
+ DecodeJSON(t, resp, &version)
+ assert.Equal(t, "1.0.0", *version.Version)
+ })
+
+ t.Run("Versions with REQUIRE_SIGNIN_VIEW enabled", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.Service.RequireSignInView, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ t.Run("Get forgejo version without auth", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // GET api without auth
+ req := NewRequest(t, "GET", "/api/forgejo/v1/version")
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+
+ t.Run("Get forgejo version without auth", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ username := "user1"
+ session := loginUser(t, username)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ // GET api with auth
+ req := NewRequest(t, "GET", "/api/forgejo/v1/version").AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var version v1.Version
+ DecodeJSON(t, resp, &version)
+ assert.Equal(t, "1.0.0", *version.Version)
+ })
+ })
+}
diff --git a/tests/integration/api_fork_test.go b/tests/integration/api_fork_test.go
new file mode 100644
index 0000000..b80b4c6
--- /dev/null
+++ b/tests/integration/api_fork_test.go
@@ -0,0 +1,110 @@
+// Copyright 2017 The Gogs Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/tests"
+)
+
+func TestAPIForkAsAdminIgnoringLimits(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Repository.AllowForkWithoutMaximumLimit, false)()
+ defer test.MockVariableValue(&setting.Repository.MaxCreationLimit, 0)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+ userSession := loginUser(t, user.Name)
+ userToken := getTokenForLoggedInUser(t, userSession, auth_model.AccessTokenScopeWriteRepository)
+ adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+ adminSession := loginUser(t, adminUser.Name)
+ adminToken := getTokenForLoggedInUser(t, adminSession,
+ auth_model.AccessTokenScopeWriteRepository,
+ auth_model.AccessTokenScopeWriteOrganization)
+
+ originForkURL := "/api/v1/repos/user12/repo10/forks"
+ orgName := "fork-org"
+
+ // Create an organization
+ req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{
+ UserName: orgName,
+ }).AddTokenAuth(adminToken)
+ MakeRequest(t, req, http.StatusCreated)
+
+ // Create a team
+ teamToCreate := &api.CreateTeamOption{
+ Name: "testers",
+ IncludesAllRepositories: true,
+ Permission: "write",
+ Units: []string{"repo.code", "repo.issues"},
+ }
+
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", orgName), &teamToCreate).AddTokenAuth(adminToken)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var team api.Team
+ DecodeJSON(t, resp, &team)
+
+ // Add user2 to the team
+ req = NewRequestf(t, "PUT", "/api/v1/teams/%d/members/user2", team.ID).AddTokenAuth(adminToken)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ t.Run("forking as regular user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", originForkURL, &api.CreateForkOption{
+ Organization: &orgName,
+ }).AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusConflict)
+ })
+
+ t.Run("forking as an instance admin", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", originForkURL, &api.CreateForkOption{
+ Organization: &orgName,
+ }).AddTokenAuth(adminToken)
+ MakeRequest(t, req, http.StatusAccepted)
+ })
+}
+
+func TestCreateForkNoLogin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{})
+ MakeRequest(t, req, http.StatusUnauthorized)
+}
+
+func TestAPIDisabledForkRepo(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ defer test.MockVariableValue(&setting.Repository.DisableForks, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ t.Run("fork listing", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/forks")
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("forking", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, "user5")
+ token := getTokenForLoggedInUser(t, session)
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/forks", &api.CreateForkOption{}).AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+}
diff --git a/tests/integration/api_gitignore_templates_test.go b/tests/integration/api_gitignore_templates_test.go
new file mode 100644
index 0000000..c58f5ee
--- /dev/null
+++ b/tests/integration/api_gitignore_templates_test.go
@@ -0,0 +1,53 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/modules/options"
+ repo_module "code.gitea.io/gitea/modules/repository"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIListGitignoresTemplates(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/gitignore/templates")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ // This tests if the API returns a list of strings
+ var gitignoreList []string
+ DecodeJSON(t, resp, &gitignoreList)
+}
+
+func TestAPIGetGitignoreTemplateInfo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // If Gitea has for some reason no Gitignore templates, we need to skip this test
+ if len(repo_module.Gitignores) == 0 {
+ return
+ }
+
+ // Use the first template for the test
+ templateName := repo_module.Gitignores[0]
+
+ urlStr := fmt.Sprintf("/api/v1/gitignore/templates/%s", templateName)
+ req := NewRequest(t, "GET", urlStr)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var templateInfo api.GitignoreTemplateInfo
+ DecodeJSON(t, resp, &templateInfo)
+
+ // We get the text of the template here
+ text, _ := options.Gitignore(templateName)
+
+ assert.Equal(t, templateInfo.Name, templateName)
+ assert.Equal(t, templateInfo.Source, string(text))
+}
diff --git a/tests/integration/api_gpg_keys_test.go b/tests/integration/api_gpg_keys_test.go
new file mode 100644
index 0000000..ec0dafc
--- /dev/null
+++ b/tests/integration/api_gpg_keys_test.go
@@ -0,0 +1,279 @@
+// Copyright 2017 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type makeRequestFunc func(testing.TB, *RequestWrapper, int) *httptest.ResponseRecorder
+
+func TestGPGKeys(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ tokenWithGPGKeyScope := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+ tt := []struct {
+ name string
+ makeRequest makeRequestFunc
+ token string
+ results []int
+ }{
+ {
+ name: "NoLogin", makeRequest: MakeRequest, token: "",
+ results: []int{http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized, http.StatusUnauthorized},
+ },
+ {
+ name: "LoggedAsUser2", makeRequest: session.MakeRequest, token: token,
+ results: []int{http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden},
+ },
+ {
+ name: "LoggedAsUser2WithScope", makeRequest: session.MakeRequest, token: tokenWithGPGKeyScope,
+ results: []int{http.StatusOK, http.StatusOK, http.StatusNotFound, http.StatusNoContent, http.StatusUnprocessableEntity, http.StatusNotFound, http.StatusCreated, http.StatusNotFound, http.StatusCreated},
+ },
+ }
+
+ for _, tc := range tt {
+ // Basic test on result code
+ t.Run(tc.name, func(t *testing.T) {
+ t.Run("ViewOwnGPGKeys", func(t *testing.T) {
+ testViewOwnGPGKeys(t, tc.makeRequest, tc.token, tc.results[0])
+ })
+ t.Run("ViewGPGKeys", func(t *testing.T) {
+ testViewGPGKeys(t, tc.makeRequest, tc.token, tc.results[1])
+ })
+ t.Run("GetGPGKey", func(t *testing.T) {
+ testGetGPGKey(t, tc.makeRequest, tc.token, tc.results[2])
+ })
+ t.Run("DeleteGPGKey", func(t *testing.T) {
+ testDeleteGPGKey(t, tc.makeRequest, tc.token, tc.results[3])
+ })
+
+ t.Run("CreateInvalidGPGKey", func(t *testing.T) {
+ testCreateInvalidGPGKey(t, tc.makeRequest, tc.token, tc.results[4])
+ })
+ t.Run("CreateNoneRegistredEmailGPGKey", func(t *testing.T) {
+ testCreateNoneRegistredEmailGPGKey(t, tc.makeRequest, tc.token, tc.results[5])
+ })
+ t.Run("CreateValidGPGKey", func(t *testing.T) {
+ testCreateValidGPGKey(t, tc.makeRequest, tc.token, tc.results[6])
+ })
+ t.Run("CreateValidSecondaryEmailGPGKeyNotActivated", func(t *testing.T) {
+ testCreateValidSecondaryEmailGPGKey(t, tc.makeRequest, tc.token, tc.results[7])
+ })
+ })
+ }
+
+ // Check state after basic add
+ t.Run("CheckState", func(t *testing.T) {
+ var keys []*api.GPGKey
+
+ req := NewRequest(t, "GET", "/api/v1/user/gpg_keys"). // GET all keys
+ AddTokenAuth(tokenWithGPGKeyScope)
+ resp := MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &keys)
+ assert.Len(t, keys, 1)
+
+ primaryKey1 := keys[0] // Primary key 1
+ assert.EqualValues(t, "38EA3BCED732982C", primaryKey1.KeyID)
+ assert.Len(t, primaryKey1.Emails, 1)
+ assert.EqualValues(t, "user2@example.com", primaryKey1.Emails[0].Email)
+ assert.True(t, primaryKey1.Emails[0].Verified)
+
+ subKey := primaryKey1.SubsKey[0] // Subkey of 38EA3BCED732982C
+ assert.EqualValues(t, "70D7C694D17D03AD", subKey.KeyID)
+ assert.Empty(t, subKey.Emails)
+
+ var key api.GPGKey
+ req = NewRequest(t, "GET", "/api/v1/user/gpg_keys/"+strconv.FormatInt(primaryKey1.ID, 10)). // Primary key 1
+ AddTokenAuth(tokenWithGPGKeyScope)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &key)
+ assert.EqualValues(t, "38EA3BCED732982C", key.KeyID)
+ assert.Len(t, key.Emails, 1)
+ assert.EqualValues(t, "user2@example.com", key.Emails[0].Email)
+ assert.True(t, key.Emails[0].Verified)
+
+ req = NewRequest(t, "GET", "/api/v1/user/gpg_keys/"+strconv.FormatInt(subKey.ID, 10)). // Subkey of 38EA3BCED732982C
+ AddTokenAuth(tokenWithGPGKeyScope)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &key)
+ assert.EqualValues(t, "70D7C694D17D03AD", key.KeyID)
+ assert.Empty(t, key.Emails)
+ })
+
+ // Check state after basic add
+ t.Run("CheckCommits", func(t *testing.T) {
+ t.Run("NotSigned", func(t *testing.T) {
+ var branch api.Branch
+ req := NewRequest(t, "GET", "/api/v1/repos/user2/repo16/branches/not-signed").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &branch)
+ assert.False(t, branch.Commit.Verification.Verified)
+ })
+
+ t.Run("SignedWithNotValidatedEmail", func(t *testing.T) {
+ var branch api.Branch
+ req := NewRequest(t, "GET", "/api/v1/repos/user2/repo16/branches/good-sign-not-yet-validated").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &branch)
+ assert.False(t, branch.Commit.Verification.Verified)
+ })
+
+ t.Run("SignedWithValidEmail", func(t *testing.T) {
+ var branch api.Branch
+ req := NewRequest(t, "GET", "/api/v1/repos/user2/repo16/branches/good-sign").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &branch)
+ assert.True(t, branch.Commit.Verification.Verified)
+ })
+ })
+}
+
+func testViewOwnGPGKeys(t *testing.T, makeRequest makeRequestFunc, token string, expected int) {
+ req := NewRequest(t, "GET", "/api/v1/user/gpg_keys").
+ AddTokenAuth(token)
+ makeRequest(t, req, expected)
+}
+
+func testViewGPGKeys(t *testing.T, makeRequest makeRequestFunc, token string, expected int) {
+ req := NewRequest(t, "GET", "/api/v1/users/user2/gpg_keys").
+ AddTokenAuth(token)
+ makeRequest(t, req, expected)
+}
+
+func testGetGPGKey(t *testing.T, makeRequest makeRequestFunc, token string, expected int) {
+ req := NewRequest(t, "GET", "/api/v1/user/gpg_keys/1").
+ AddTokenAuth(token)
+ makeRequest(t, req, expected)
+}
+
+func testDeleteGPGKey(t *testing.T, makeRequest makeRequestFunc, token string, expected int) {
+ req := NewRequest(t, "DELETE", "/api/v1/user/gpg_keys/1").
+ AddTokenAuth(token)
+ makeRequest(t, req, expected)
+}
+
+func testCreateGPGKey(t *testing.T, makeRequest makeRequestFunc, token string, expected int, publicKey string) {
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/gpg_keys", api.CreateGPGKeyOption{
+ ArmoredKey: publicKey,
+ }).AddTokenAuth(token)
+ makeRequest(t, req, expected)
+}
+
+func testCreateInvalidGPGKey(t *testing.T, makeRequest makeRequestFunc, token string, expected int) {
+ testCreateGPGKey(t, makeRequest, token, expected, "invalid_key")
+}
+
+func testCreateNoneRegistredEmailGPGKey(t *testing.T, makeRequest makeRequestFunc, token string, expected int) {
+ testCreateGPGKey(t, makeRequest, token, expected, `-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQENBFmGUygBCACjCNbKvMGgp0fd5vyFW9olE1CLCSyyF9gQN2hSuzmZLuAZF2Kh
+dCMCG2T1UwzUB/yWUFWJ2BtCwSjuaRv+cGohqEy6bhEBV90peGA33lHfjx7wP25O
+7moAphDOTZtDj1AZfCh/PTcJut8Lc0eRDMhNyp/bYtO7SHNT1Hr6rrCV/xEtSAvR
+3b148/tmIBiSadaLwc558KU3ucjnW5RVGins3AjBZ+TuT4XXVH/oeLSeXPSJ5rt1
+rHwaseslMqZ4AbvwFLx5qn1OC9rEQv/F548QsA8m0IntLjoPon+6wcubA9Gra21c
+Fp6aRYl9x7fiqXDLg8i3s2nKdV7+e6as6Tp9ABEBAAG0FG5vdGtub3duQGV4YW1w
+bGUuY29tiQEcBBABAgAGBQJZhlMoAAoJEC8+pvYULDtte/wH/2JNrhmHwDY+hMj0
+batIK4HICnkKxjIgbha80P2Ao08NkzSge58fsxiKDFYAQjHui+ZAw4dq79Ax9AOO
+Iv2GS9+DUfWhrb6RF+vNuJldFzcI0rTW/z2q+XGKrUCwN3khJY5XngHfQQrdBtMK
+qsoUXz/5B8g422RTbo/SdPsyYAV6HeLLeV3rdgjI1fpaW0seZKHeTXQb/HvNeuPg
+qz+XV1g6Gdqa1RjDOaX7A8elVKxrYq3LBtc93FW+grBde8n7JL0zPM3DY+vJ0IJZ
+INx/MmBfmtCq05FqNclvU+sj2R3N1JJOtBOjZrJHQbJhzoILou8AkxeX1A+q9OAz
+1geiY5E=
+=TkP3
+-----END PGP PUBLIC KEY BLOCK-----`)
+}
+
+func testCreateValidGPGKey(t *testing.T, makeRequest makeRequestFunc, token string, expected int) {
+ // User2 <user2@example.com> //primary & activated
+ testCreateGPGKey(t, makeRequest, token, expected, `-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQENBFmGVsMBCACuxgZ7W7rI9xN08Y4M7B8yx/6/I4Slm94+wXf8YNRvAyqj30dW
+VJhyBcnfNRDLKSQp5o/hhfDkCgdqBjLa1PnHlGS3PXJc0hP/FyYPD2BFvNMPpCYS
+eu3T1qKSNXm6X0XOWD2LIrdiDC8HaI9FqZVMI/srMK2CF8XCL2m67W1FuoPlWzod
+5ORy0IZB7spoF0xihmcgnEGElRmdo5w/vkGH8U7Zyn9Eb57UVFeafgeskf4wqB23
+BjbMdW2YaB+yzMRwYgOnD5lnBD4uqSmvjaV9C0kxn7x+oJkkiRV8/z1cNcO+BaeQ
+Akh/yTTeTzYGSc/ZOqCX1O+NOPgSeixVlqenABEBAAG0GVVzZXIyIDx1c2VyMkBl
+eGFtcGxlLmNvbT6JAVQEEwEIAD4WIQRXgbSh0TtGbgRd7XI46jvO1zKYLAUCWYZW
+wwIbAwUJA8JnAAULCQgHAgYVCAkKCwIEFgIDAQIeAQIXgAAKCRA46jvO1zKYLF/e
+B/91wm2KLMIQBZBA9WA2/+9rQWTo9EqgYrXN60rEzX3cYJWXZiE4DrKR1oWDGNLi
+KXOCW62snvJldolBqq0ZqaKvPKzl0Y5TRqbYEc9AjUSqgRin1b+G2DevLGT4ibq+
+7ocQvz0XkASEUAgHahp0Ubiiib1521WwT/duL+AG8Gg0+DK09RfV3eX5/EOkQCKv
+8cutqgsd2Smz40A8wXuJkRcipZBtrB/GkUaZ/eJdwEeSYZjEA9GWF61LJT2stvRN
+HCk7C3z3pVEek1PluiFs/4VN8BG8yDzW4c0tLty4Fj3VwPqwIbB5AJbquVfhQCb4
+Eep2lm3Lc9b1OwO5N3coPJkouQENBFmGVsMBCADAGba2L6NCOE1i3WIP6CPzbdOo
+N3gdTfTgccAx9fNeon9jor+3tgEjlo9/6cXiRoksOV6W4wFab/ZwWgwN6JO4CGvZ
+Wi7EQwMMMp1E36YTojKQJrcA9UvMnTHulqQQ88F5E845DhzFQM3erv42QZZMBAX3
+kXCgy1GNFocl6tLUvJdEqs+VcJGGANMpmzE4WLa8KhSYnxipwuQ62JBy9R+cHyKT
+OARk8znRqSu5bT3LtlrZ/HXu+6Oy4+2uCdNzZIh5J5tPS7CPA6ptl88iGVBte/CJ
+7cjgJWSQqeYp2Y5QvsWAivkQ4Ww9plHbbwV0A2eaHsjjWzlUl3HoJ/snMOhBABEB
+AAGJATwEGAEIACYWIQRXgbSh0TtGbgRd7XI46jvO1zKYLAUCWYZWwwIbDAUJA8Jn
+AAAKCRA46jvO1zKYLBwLCACQOpeRVrwIKVaWcPMYjVHHJsGscaLKpgpARAUgbiG6
+Cbc2WI8Sm3fRwrY0VAfN+u9QwrtvxANcyB3vTgTzw7FimfhOimxiTSO8HQCfjDZF
+Xly8rq+Fua7+ClWUpy21IekW41VvZYjH2sL6EVP+UcEOaGAyN53XfhaRVZPhNtZN
+NKAE9N5EG3rbsZ33LzJj40rEKlzFSseAAPft8qA3IXjzFBx+PQXHMpNCagL79he6
+lqockTJ+oPmta4CF/J0U5LUr1tOZXheL3TP6m8d08gDrtn0YuGOPk87i9sJz+jR9
+uy6MA3VSB99SK9ducGmE1Jv8mcziREroz2TEGr0zPs6h
+=J59D
+-----END PGP PUBLIC KEY BLOCK-----`)
+}
+
+func testCreateValidSecondaryEmailGPGKey(t *testing.T, makeRequest makeRequestFunc, token string, expected int) {
+ // User2 <user2-2@example.com> //secondary and not activated
+ testCreateGPGKey(t, makeRequest, token, expected, `-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQGNBGC2K2cBDAC1+Xgk+8UfhASVgRngQi4rnQ8k0t+bWsBz4Czd26+cxVDRwlTT
+8PALdrbrY/e9iXjcVcZ8Npo4UYe7/LfnL57dc7tgbenRGYYrWyVoNNv58BVw4xCY
+RmgvdHWIIPGuz3aME0smHxbJ2KewYTqjTPuVKF/wrHTwCpVWdjYKC5KDo3yx0mro
+xf9vOJOnkWNMiEw7TiZfkrbUqxyA53BVsSNKRX5C3b4FJcVT7eiAq7sDAaFxjEHy
+ahZslmvg7XZxWzSVzxDNesR7f4xuop8HBjzaluJoVuwiyWculTvz1b6hyHVQr+ad
+h8JGjj1tySI65OTFsTuptsfHXjtjl/NR4P6BXkf+FVwweaTQaEzpHkv0m9b9pY43
+CY/8XtS4uNPermiLG/Z0BB1eOCdoOQVHpjOa55IXQWhxXB6NZVyowiUbrR7jLDQy
+5JP7D1HmErTR8JRm3VDqGbSaCgugRgFX+lb/fpgFp9k02OeK+JQudolZOt1mVk+T
+C4xmEWxfiH15/JMAEQEAAbQbdXNlcjIgPHVzZXIyLTJAZXhhbXBsZS5jb20+iQHU
+BBMBCAA+FiEEB/Y4DM3Ba2H9iXmlPO9G70C+/D4FAmC2K2cCGwMFCQPCZwAFCwkI
+BwIGFQoJCAsCBBYCAwECHgECF4AACgkQPO9G70C+/D59/Av/XZIhCH4X2FpxCO3d
+oCa+sbYkBL5xeUoPfAx5ThXzqL/tllO88TKTMEGZF3k5pocXWH0xmhqlvDTcdb0i
+W3O0CN8FLmuotU51c0JC1mt9zwJP9PeJNyqxrMm01Yzj55z/Dz3QHSTlDjrWTWjn
+YBqDf2HfdM177oydfSYmevZni1aDmBalWpFPRvqISCO7uFnvg1hJQ5mD/0qie663
+QJ8LAAANg32H9DyPnYi9wU62WX0DMUVTjKctT3cnYCbirjjJ7ZlCCm+cf61CRX1B
+E1Ng/Ef3ZcUfXWitZSjfET/pKEMSNjsQawFpZ/LPCBl+UPHzaTPAASeGJvcbZ3py
+wZQLQc1MCu2hmMBQ8zHQTdS2Pp0RISxCQLYvVQL6DrcJDNiSqn9p9RQt5c5r5Pjx
+80BIPcjj3glOVP7PYE2azQAkt6reEjhimwCfjeDpiPnkBTY7Av2jCcUFhhemDY/j
+TRXK1paLphhJ36zC22SeHGxNNakjjuUakqB85DEUeoWuVm6ouQGNBGC2K2cBDADx
+G2rIAgMjdPtofhkEZXwv6zdNwmYOlIIM+59bam9Ep/vFq8F5f+xldevm5dvM8SeR
+pNwDGSOUf5OKBWBdsJFhlYBl7+EcKd/Tent/XS6JoA9ffF33b+r04L543+ykiKON
+WYeYi0F4WwYTIQgqZHJze1sPVkYGR5F0bL8PAcLuwd5dzZVi/q2HakrGdg29N8oY
+b/XnoR7FflPrNYdzO6hawi5Inx7KS7aWa0ZkARb0F4HSct+/m6nAZVsoJINLudyQ
+ut2NWeU8rWIm1hqyIxQFvuQJy46umq++10J/sWA98bkg41Rx+72+eP7DM5v8IgUp
+clJsfljRXIBWbmRAVZvtNI7PX9fwMMhf4M7wHO7G2WV39o1exKps5xFFcn8PUQiX
+jCSR81M145CgCdmLUR1y0pdkN/WIqjXBhkPIvO2dxEcodMNHb1aUUuUOnww6+xIP
+8rGVw+a2DUiALc8Qr5RP21AYKRctfiwhSQh2KODveMtyLI3U9C/eLRPp+QM3XB8A
+EQEAAYkBvAQYAQgAJhYhBAf2OAzNwWth/Yl5pTzvRu9Avvw+BQJgtitnAhsMBQkD
+wmcAAAoJEDzvRu9Avvw+3FcMAJBwupyJ4zwQFxTJ5BkDlusG3U2FXEf3bDrXhvNd
+qi8eS8Vo/vRiH/w/my5JFpz1o2tJToryF71D+uF5DTItalKquhsQ9reAEmXggqOh
+9Jd9mWJIEEWcRORiLNDKENKvE8bouw4U4hRaSF0IaGzAe5mO+oOvwal8L97wFxrZ
+4leM1GzkopiuNfbkkBBw2KJcMjYBHzzXSCALnVwhjbgkBEWPIg38APT3cr9KfnMM
+q8+tvsGLj4piAl3Lww7+GhSsDOUXH8btR41BSAQDrbO5q6oi/h4nuxoNmQIDW/Ug
+s+dd5hnY2FtHRjb4FCR9kAjdTE6stc8wzohWfbg1N+12TTA2ylByAumICVXixavH
+RJ7l0OiWJk388qw9mqh3k8HcBxL7OfDlFC9oPmCS0iYiIwW/Yc80kBhoxcvl/Xa7
+mIMMn8taHIaQO7v9ln2EVQYTzbNCmwTw9ovTM0j/Pbkg2EftfP1TCoxQHvBnsCED
+6qgtsUdi5eviONRkBgeZtN3oxA==
+=MgDv
+-----END PGP PUBLIC KEY BLOCK-----`)
+}
diff --git a/tests/integration/api_health_test.go b/tests/integration/api_health_test.go
new file mode 100644
index 0000000..5657f4f
--- /dev/null
+++ b/tests/integration/api_health_test.go
@@ -0,0 +1,25 @@
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/routers/web/healthcheck"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestApiHeatlhCheck(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/api/healthz")
+ resp := MakeRequest(t, req, http.StatusOK)
+ assert.Contains(t, resp.Header().Values("Cache-Control"), "no-store")
+
+ var status healthcheck.Response
+ DecodeJSON(t, resp, &status)
+ assert.Equal(t, healthcheck.Pass, status.Status)
+ assert.Equal(t, setting.AppName, status.Description)
+}
diff --git a/tests/integration/api_helper_for_declarative_test.go b/tests/integration/api_helper_for_declarative_test.go
new file mode 100644
index 0000000..dae71ca
--- /dev/null
+++ b/tests/integration/api_helper_for_declarative_test.go
@@ -0,0 +1,463 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/perm"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/queue"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/forms"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type APITestContext struct {
+ Reponame string
+ Session *TestSession
+ Token string
+ Username string
+ ExpectedCode int
+}
+
+func NewAPITestContext(t *testing.T, username, reponame string, scope ...auth.AccessTokenScope) APITestContext {
+ session := loginUser(t, username)
+ token := getTokenForLoggedInUser(t, session, scope...)
+ return APITestContext{
+ Session: session,
+ Token: token,
+ Username: username,
+ Reponame: reponame,
+ }
+}
+
+func (ctx APITestContext) GitPath() string {
+ return fmt.Sprintf("%s/%s.git", ctx.Username, ctx.Reponame)
+}
+
+func doAPICreateRepository(ctx APITestContext, empty bool, objectFormat git.ObjectFormat, callback ...func(*testing.T, api.Repository)) func(*testing.T) {
+ return func(t *testing.T) {
+ createRepoOption := &api.CreateRepoOption{
+ AutoInit: !empty,
+ Description: "Temporary repo",
+ Name: ctx.Reponame,
+ Private: true,
+ Template: true,
+ Gitignores: "",
+ License: "WTFPL",
+ Readme: "Default",
+ ObjectFormatName: objectFormat.Name(),
+ }
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", createRepoOption).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ resp := ctx.Session.MakeRequest(t, req, http.StatusCreated)
+
+ var repository api.Repository
+ DecodeJSON(t, resp, &repository)
+ if len(callback) > 0 {
+ callback[0](t, repository)
+ }
+ }
+}
+
+func doAPIEditRepository(ctx APITestContext, editRepoOption *api.EditRepoOption, callback ...func(*testing.T, api.Repository)) func(*testing.T) {
+ return func(t *testing.T) {
+ req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), editRepoOption).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ resp := ctx.Session.MakeRequest(t, req, http.StatusOK)
+
+ var repository api.Repository
+ DecodeJSON(t, resp, &repository)
+ if len(callback) > 0 {
+ callback[0](t, repository)
+ }
+ }
+}
+
+func doAPIAddCollaborator(ctx APITestContext, username string, mode perm.AccessMode) func(*testing.T) {
+ return func(t *testing.T) {
+ permission := "read"
+
+ if mode == perm.AccessModeAdmin {
+ permission = "admin"
+ } else if mode > perm.AccessModeRead {
+ permission = "write"
+ }
+ addCollaboratorOption := &api.AddCollaboratorOption{
+ Permission: &permission,
+ }
+ req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/collaborators/%s", ctx.Username, ctx.Reponame, username), addCollaboratorOption).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ ctx.Session.MakeRequest(t, req, http.StatusNoContent)
+ }
+}
+
+func doAPIForkRepository(ctx APITestContext, username string, callback ...func(*testing.T, api.Repository)) func(*testing.T) {
+ return func(t *testing.T) {
+ createForkOption := &api.CreateForkOption{}
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", username, ctx.Reponame), createForkOption).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ resp := ctx.Session.MakeRequest(t, req, http.StatusAccepted)
+ var repository api.Repository
+ DecodeJSON(t, resp, &repository)
+ if len(callback) > 0 {
+ callback[0](t, repository)
+ }
+ }
+}
+
+func doAPIGetRepository(ctx APITestContext, callback ...func(*testing.T, api.Repository)) func(*testing.T) {
+ return func(t *testing.T) {
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s", ctx.Username, ctx.Reponame)).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ resp := ctx.Session.MakeRequest(t, req, http.StatusOK)
+
+ var repository api.Repository
+ DecodeJSON(t, resp, &repository)
+ if len(callback) > 0 {
+ callback[0](t, repository)
+ }
+ }
+}
+
+func doAPIDeleteRepository(ctx APITestContext) func(*testing.T) {
+ return func(t *testing.T) {
+ req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s", ctx.Username, ctx.Reponame)).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ ctx.Session.MakeRequest(t, req, http.StatusNoContent)
+ }
+}
+
+func doAPICreateUserKey(ctx APITestContext, keyname, keyFile string, callback ...func(*testing.T, api.PublicKey)) func(*testing.T) {
+ return func(t *testing.T) {
+ dataPubKey, err := os.ReadFile(keyFile + ".pub")
+ require.NoError(t, err)
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/keys", &api.CreateKeyOption{
+ Title: keyname,
+ Key: string(dataPubKey),
+ }).AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ resp := ctx.Session.MakeRequest(t, req, http.StatusCreated)
+ var publicKey api.PublicKey
+ DecodeJSON(t, resp, &publicKey)
+ if len(callback) > 0 {
+ callback[0](t, publicKey)
+ }
+ }
+}
+
+func doAPIDeleteUserKey(ctx APITestContext, keyID int64) func(*testing.T) {
+ return func(t *testing.T) {
+ req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/keys/%d", keyID)).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ ctx.Session.MakeRequest(t, req, http.StatusNoContent)
+ }
+}
+
+func doAPICreateDeployKey(ctx APITestContext, keyname, keyFile string, readOnly bool) func(*testing.T) {
+ return func(t *testing.T) {
+ dataPubKey, err := os.ReadFile(keyFile + ".pub")
+ require.NoError(t, err)
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/keys", ctx.Username, ctx.Reponame), api.CreateKeyOption{
+ Title: keyname,
+ Key: string(dataPubKey),
+ ReadOnly: readOnly,
+ }).AddTokenAuth(ctx.Token)
+
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ ctx.Session.MakeRequest(t, req, http.StatusCreated)
+ }
+}
+
+func doAPICreatePullRequest(ctx APITestContext, owner, repo, baseBranch, headBranch string) func(*testing.T) (api.PullRequest, error) {
+ return func(t *testing.T) (api.PullRequest, error) {
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner, repo), &api.CreatePullRequestOption{
+ Head: headBranch,
+ Base: baseBranch,
+ Title: fmt.Sprintf("create a pr from %s to %s", headBranch, baseBranch),
+ }).AddTokenAuth(ctx.Token)
+
+ expected := http.StatusCreated
+ if ctx.ExpectedCode != 0 {
+ expected = ctx.ExpectedCode
+ }
+ resp := ctx.Session.MakeRequest(t, req, expected)
+
+ decoder := json.NewDecoder(resp.Body)
+ pr := api.PullRequest{}
+ err := decoder.Decode(&pr)
+ return pr, err
+ }
+}
+
+func doAPIGetPullRequest(ctx APITestContext, owner, repo string, index int64) func(*testing.T) (api.PullRequest, error) {
+ return func(t *testing.T) (api.PullRequest, error) {
+ req := NewRequest(t, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner, repo, index)).
+ AddTokenAuth(ctx.Token)
+
+ expected := http.StatusOK
+ if ctx.ExpectedCode != 0 {
+ expected = ctx.ExpectedCode
+ }
+ resp := ctx.Session.MakeRequest(t, req, expected)
+
+ decoder := json.NewDecoder(resp.Body)
+ pr := api.PullRequest{}
+ err := decoder.Decode(&pr)
+ return pr, err
+ }
+}
+
+func doAPIMergePullRequest(ctx APITestContext, owner, repo string, index int64) func(*testing.T) {
+ return func(t *testing.T) {
+ t.Helper()
+ doAPIMergePullRequestForm(t, ctx, owner, repo, index, &forms.MergePullRequestForm{
+ MergeMessageField: "doAPIMergePullRequest Merge",
+ Do: string(repo_model.MergeStyleMerge),
+ })
+ }
+}
+
+func doAPIMergePullRequestForm(t *testing.T, ctx APITestContext, owner, repo string, index int64, merge *forms.MergePullRequestForm) *httptest.ResponseRecorder {
+ t.Helper()
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index)
+
+ var req *RequestWrapper
+ var resp *httptest.ResponseRecorder
+
+ for i := 0; i < 6; i++ {
+ req = NewRequestWithJSON(t, http.MethodPost, urlStr, merge).AddTokenAuth(ctx.Token)
+
+ resp = ctx.Session.MakeRequest(t, req, NoExpectedStatus)
+
+ if resp.Code != http.StatusMethodNotAllowed {
+ break
+ }
+ err := api.APIError{}
+ DecodeJSON(t, resp, &err)
+ if err.Message != "Please try again later" {
+ break
+ }
+ queue.GetManager().FlushAll(context.Background(), 5*time.Second)
+ <-time.After(1 * time.Second)
+ }
+
+ expected := ctx.ExpectedCode
+ if expected == 0 {
+ expected = http.StatusOK
+ }
+
+ if !assert.EqualValues(t, expected, resp.Code,
+ "Request: %s %s", req.Method, req.URL.String()) {
+ logUnexpectedResponse(t, resp)
+ }
+
+ return resp
+}
+
+func doAPIManuallyMergePullRequest(ctx APITestContext, owner, repo, commitID string, index int64) func(*testing.T) {
+ return func(t *testing.T) {
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index)
+ req := NewRequestWithJSON(t, http.MethodPost, urlStr, &forms.MergePullRequestForm{
+ Do: string(repo_model.MergeStyleManuallyMerged),
+ MergeCommitID: commitID,
+ }).AddTokenAuth(ctx.Token)
+
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ ctx.Session.MakeRequest(t, req, http.StatusOK)
+ }
+}
+
+func doAPIAutoMergePullRequest(ctx APITestContext, owner, repo string, index int64) func(*testing.T) {
+ return func(t *testing.T) {
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index)
+ req := NewRequestWithJSON(t, http.MethodPost, urlStr, &forms.MergePullRequestForm{
+ MergeMessageField: "doAPIMergePullRequest Merge",
+ Do: string(repo_model.MergeStyleMerge),
+ MergeWhenChecksSucceed: true,
+ }).AddTokenAuth(ctx.Token)
+
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ ctx.Session.MakeRequest(t, req, http.StatusOK)
+ }
+}
+
+func doAPICancelAutoMergePullRequest(ctx APITestContext, owner, repo string, index int64) func(*testing.T) {
+ return func(t *testing.T) {
+ req := NewRequest(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner, repo, index)).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ ctx.Session.MakeRequest(t, req, http.StatusNoContent)
+ }
+}
+
+func doAPIGetBranch(ctx APITestContext, branch string, callback ...func(*testing.T, api.Branch)) func(*testing.T) {
+ return func(t *testing.T) {
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/branches/%s", ctx.Username, ctx.Reponame, branch).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ resp := ctx.Session.MakeRequest(t, req, http.StatusOK)
+
+ var branch api.Branch
+ DecodeJSON(t, resp, &branch)
+ if len(callback) > 0 {
+ callback[0](t, branch)
+ }
+ }
+}
+
+func doAPICreateFile(ctx APITestContext, treepath string, options *api.CreateFileOptions, callback ...func(*testing.T, api.FileResponse)) func(*testing.T) {
+ return func(t *testing.T) {
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", ctx.Username, ctx.Reponame, treepath), &options).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ resp := ctx.Session.MakeRequest(t, req, http.StatusCreated)
+
+ var contents api.FileResponse
+ DecodeJSON(t, resp, &contents)
+ if len(callback) > 0 {
+ callback[0](t, contents)
+ }
+ }
+}
+
+func doAPICreateOrganization(ctx APITestContext, options *api.CreateOrgOption, callback ...func(*testing.T, api.Organization)) func(t *testing.T) {
+ return func(t *testing.T) {
+ req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &options).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ resp := ctx.Session.MakeRequest(t, req, http.StatusCreated)
+
+ var contents api.Organization
+ DecodeJSON(t, resp, &contents)
+ if len(callback) > 0 {
+ callback[0](t, contents)
+ }
+ }
+}
+
+func doAPICreateOrganizationRepository(ctx APITestContext, orgName string, options *api.CreateRepoOption, callback ...func(*testing.T, api.Repository)) func(t *testing.T) {
+ return func(t *testing.T) {
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName), &options).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ resp := ctx.Session.MakeRequest(t, req, http.StatusCreated)
+
+ var contents api.Repository
+ DecodeJSON(t, resp, &contents)
+ if len(callback) > 0 {
+ callback[0](t, contents)
+ }
+ }
+}
+
+func doAPICreateOrganizationTeam(ctx APITestContext, orgName string, options *api.CreateTeamOption, callback ...func(*testing.T, api.Team)) func(t *testing.T) {
+ return func(t *testing.T) {
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", orgName), &options).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ resp := ctx.Session.MakeRequest(t, req, http.StatusCreated)
+
+ var contents api.Team
+ DecodeJSON(t, resp, &contents)
+ if len(callback) > 0 {
+ callback[0](t, contents)
+ }
+ }
+}
+
+func doAPIAddUserToOrganizationTeam(ctx APITestContext, teamID int64, username string) func(t *testing.T) {
+ return func(t *testing.T) {
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/teams/%d/members/%s", teamID, username)).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ ctx.Session.MakeRequest(t, req, http.StatusNoContent)
+ }
+}
+
+func doAPIAddRepoToOrganizationTeam(ctx APITestContext, teamID int64, orgName, repoName string) func(t *testing.T) {
+ return func(t *testing.T) {
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/teams/%d/repos/%s/%s", teamID, orgName, repoName)).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ ctx.Session.MakeRequest(t, req, http.StatusNoContent)
+ }
+}
diff --git a/tests/integration/api_httpsig_test.go b/tests/integration/api_httpsig_test.go
new file mode 100644
index 0000000..30aed3c
--- /dev/null
+++ b/tests/integration/api_httpsig_test.go
@@ -0,0 +1,142 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "encoding/base64"
+ "net/http"
+ "net/url"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/go-fed/httpsig"
+ "golang.org/x/crypto/ssh"
+)
+
+const (
+ httpsigPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn
+NhAAAAAwEAAQAAAQEAqjmQeb5Eb1xV7qbNf9ErQ0XRvKZWzUsLFhJzZz+Ab7q8WtPs91vQ
+fBiypw4i8OTG6WzDcgZaV8Ndxn7iHnIstdA1k89MVG4stydymmwmk9+mrCMNsu5OmdIy9F
+AZ61RDcKuf5VG2WKkmeK0VO+OMJIYfE1C6czNeJ6UAmcIOmhGxvjMI83XUO9n0ftwTwayp
++XU5prvKx/fTvlPjbraPNU4OzwPjVLqXBzpoXYhBquPaZYFRVyvfFZLObYsmy+BrsxcloM
+l+9w4P0ATJ9njB7dRDL+RrN4uhhYSihqOK4w4vaiOj1+aA0eC0zXunEfLXfGIVQ/FhWcCy
+5f72mMiKnQAAA9AxSmzFMUpsxQAAAAdzc2gtcnNhAAABAQCqOZB5vkRvXFXups1/0StDRd
+G8plbNSwsWEnNnP4Bvurxa0+z3W9B8GLKnDiLw5MbpbMNyBlpXw13GfuIeciy10DWTz0xU
+biy3J3KabCaT36asIw2y7k6Z0jL0UBnrVENwq5/lUbZYqSZ4rRU744wkhh8TULpzM14npQ
+CZwg6aEbG+MwjzddQ72fR+3BPBrKn5dTmmu8rH99O+U+Nuto81Tg7PA+NUupcHOmhdiEGq
+49plgVFXK98Vks5tiybL4GuzFyWgyX73Dg/QBMn2eMHt1EMv5Gs3i6GFhKKGo4rjDi9qI6
+PX5oDR4LTNe6cR8td8YhVD8WFZwLLl/vaYyIqdAAAAAwEAAQAAAQBz+nyBNi2SYir6SxPA
+flcnoq5gBkUl4ndPNosCUbXEakpi5/mQHzJRGtK+F1efIYCVEdGoIsPy/90onNKbQ9dKmO
+2oI5kx/U7iCzJ+HCm8nqkEp21x+AP9scWdx+Wg/OxmG8j5iU7f4X+gwOyyvTqCuA78Lgia
+7Oi9wiJCoIEqXr6dRYGJzfASwKA2dj995HzATexleLSD5fQCmZTF+Vh5OQ5WmE+c53JdZS
+T3Plie/P/smgSWBtf1fWr6JL2+EBsqQsIK1Jo7r/7rxsz+ILoVfnneNQY4QSa9W+t6ZAI+
+caSA0Guv7vC92ewjlMVlwKa3XaEjMJb5sFlg1r6TYMwBAAAAgQDQwXvgSXNaSHIeH53/Ab
+t4BlNibtxK8vY8CZFloAKXkjrivKSlDAmQCM0twXOweX2ScPjE+XlSMV4AUsv/J6XHGHci
+W3+PGIBfc/fQRBpiyhzkoXYDVrlkSKHffCnAqTUQlYkhr0s7NkZpEeqPE0doAUs4dK3Iqb
+zdtz8e5BPXZwAAAIEA4U/JskIu5Oge8Is2OLOhlol0EJGw5JGodpFyhbMC+QYK9nYqy7wI
+a6mZ2EfOjjwIZD/+wYyulw6cRve4zXwgzUEXLIKp8/H3sYvJK2UMeP7y68sQFqGxbm6Rnh
+tyBBSaJQnOXVOFf9gqZGCyO/J0Illg3AXTuC8KS/cxwasC38EAAACBAMFo/6XQoR6E3ynj
+VBaz2SilWqQBixUyvcNz8LY73IIDCecoccRMFSEKhWtvlJijxvFbF9M8g9oKAVPuub4V5r
+CGmwVPEd5yt4C2iyV0PhLp1PA2/i42FpCSnHaz/EXSz6ncTZcOMMuDqUbgUUpQg4VSUDl9
+fhTNAzWwZoQ91aHdAAAAFHUwMDIyMTQ2QGljdHMtcC1ueC03AQIDBAUG
+-----END OPENSSH PRIVATE KEY-----`
+ httpsigCertificate = `ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAgiR7SU8gmZLhopx4Y03nOXVuAb+4fyMcJYjMGcE1Z2oEAAAADAQABAAABAQCqOZB5vkRvXFXups1/0StDRdG8plbNSwsWEnNnP4Bvurxa0+z3W9B8GLKnDiLw5MbpbMNyBlpXw13GfuIeciy10DWTz0xUbiy3J3KabCaT36asIw2y7k6Z0jL0UBnrVENwq5/lUbZYqSZ4rRU744wkhh8TULpzM14npQCZwg6aEbG+MwjzddQ72fR+3BPBrKn5dTmmu8rH99O+U+Nuto81Tg7PA+NUupcHOmhdiEGq49plgVFXK98Vks5tiybL4GuzFyWgyX73Dg/QBMn2eMHt1EMv5Gs3i6GFhKKGo4rjDi9qI6PX5oDR4LTNe6cR8td8YhVD8WFZwLLl/vaYyIqdAAAAAAAAAAEAAAABAAAABXVzZXIxAAAACQAAAAV1c2VyMQAAAABimoIOAAAAAMCWkRMAAAAAAAAAggAAABVwZXJtaXQtWDExLWZvcndhcmRpbmcAAAAAAAAAF3Blcm1pdC1hZ2VudC1mb3J3YXJkaW5nAAAAAAAAABZwZXJtaXQtcG9ydC1mb3J3YXJkaW5nAAAAAAAAAApwZXJtaXQtcHR5AAAAAAAAAA5wZXJtaXQtdXNlci1yYwAAAAAAAAAAAAABlwAAAAdzc2gtcnNhAAAAAwEAAQAAAYEAm+AwtXTBZyeqV1qOxjMU3Ibc5iR2M3zerGfRQDxUeIozC3xpIvqJbzjDuRapdf8hpxn2xC0GtUusuLIUr4/+Svs1BUnJhF2H9xnK/O0aopS5MpNekUvnBzQdbvO8Ux2xE2mt58giXhkEaXeCEODSqG++OZsA2e40AR/AGRJ4OdDofMvH4vLJAQQc2mKdYpYL8xu+NC+7nsenx1etpsqtEl3gmvqCVI6t9uhVPMvlbGt9h/AN3u7ToF2T3bdk1TZbcdkvR9ljvETIuy32ksAETX8tc7vm30edK+nn/GMeWCgjM+MFm9Uh1NRkvNNJozo5SJy0DkWETTJUsEdfry5VQ3IjqhWqQ0m4/mDlTmsEdEdWqpUiqWZLd9w7jgT8fanuglZyIu2fj8fyqjPjiws5S2P0Uvi28UKQ1nH01UYj/kuakU3BNzN1IqDf3tARP9fjKV/dCBqb1ZAOtyC2GyhGuGzNwEi+woUwq+sTeV0/hqVSb3hSitXHzcfRMRyOK82BAAABlAAAAAxyc2Etc2hhMi01MTIAAAGAMBfgZFvz4BdxriGKYd6eRhMo6hf+I8S9uzNRsflJXHuA+HR9ExIm/Q9JjKmfThQzNyGGBOBILaDU205SAJuG+kk3SieSQDd75ZQd8YmNlCc+516AriOsTiyVCupnf3I2euTjMZqEZbJcBbkBljppTOWQVN7xxE8QakDfGhg0+RjJE9wYOTmkKpDBfII5Nw8V5DoOD7kNEpXYqHdy/8lVxpqUYNIP1J0dNP4f6qBcZcM1PDA12q8zwIGqSNNjf2UXY/Nr8nv9CnK4fB8NDOPKTBa4cm48BGbvM/X0l6dYKswuZ9Np8lw+y6+GxTgznGCrkzMmuEV4FzSq4xHp41H2L2MTwUkwYaeyG1VP6aWkvn6zPkSxaaJDfQX7CAFe17IhIGXR0UPLjKjh35nDLzMWb/W6/W1lK9YkZNHXSf7Z9m9MUAZN7yQgOggGsuYEW4imZxvZizMd+fdDu9mbhr0FDis89I7MSJDnyYRE9FXS7p3QpppBwGcss/9yV3JV3Bjc`
+)
+
+func TestHTTPSigPubKey(t *testing.T) {
+ // Add our public key to user1
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.SSH.MinimumKeySizeCheck, false)()
+ session := loginUser(t, "user1")
+ token := url.QueryEscape(getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser))
+ keyType := "ssh-rsa"
+ keyContent := "AAAAB3NzaC1yc2EAAAADAQABAAABAQCqOZB5vkRvXFXups1/0StDRdG8plbNSwsWEnNnP4Bvurxa0+z3W9B8GLKnDiLw5MbpbMNyBlpXw13GfuIeciy10DWTz0xUbiy3J3KabCaT36asIw2y7k6Z0jL0UBnrVENwq5/lUbZYqSZ4rRU744wkhh8TULpzM14npQCZwg6aEbG+MwjzddQ72fR+3BPBrKn5dTmmu8rH99O+U+Nuto81Tg7PA+NUupcHOmhdiEGq49plgVFXK98Vks5tiybL4GuzFyWgyX73Dg/QBMn2eMHt1EMv5Gs3i6GFhKKGo4rjDi9qI6PX5oDR4LTNe6cR8td8YhVD8WFZwLLl/vaYyIqd"
+ rawKeyBody := api.CreateKeyOption{
+ Title: "test-key",
+ Key: keyType + " " + keyContent,
+ }
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/keys", rawKeyBody).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ // parse our private key and create the httpsig request
+ sshSigner, _ := ssh.ParsePrivateKey([]byte(httpsigPrivateKey))
+ keyID := ssh.FingerprintSHA256(sshSigner.PublicKey())
+
+ // create the request
+ token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadAdmin)
+ req = NewRequest(t, "GET", "/api/v1/admin/users").
+ AddTokenAuth(token)
+
+ signer, _, err := httpsig.NewSSHSigner(sshSigner, httpsig.DigestSha512, []string{httpsig.RequestTarget, "(created)", "(expires)"}, httpsig.Signature, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // sign the request
+ err = signer.SignRequest(keyID, req.Request, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // make the request
+ MakeRequest(t, req, http.StatusOK)
+}
+
+func TestHTTPSigCert(t *testing.T) {
+ // Add our public key to user1
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user1")
+
+ csrf := GetCSRF(t, session, "/user/settings/keys")
+ req := NewRequestWithValues(t, "POST", "/user/settings/keys", map[string]string{
+ "_csrf": csrf,
+ "content": "user1",
+ "title": "principal",
+ "type": "principal",
+ })
+
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ pkcert, _, _, _, err := ssh.ParseAuthorizedKey([]byte(httpsigCertificate))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // parse our private key and create the httpsig request
+ sshSigner, _ := ssh.ParsePrivateKey([]byte(httpsigPrivateKey))
+ keyID := "gitea"
+
+ // create our certificate signer using the ssh signer and our certificate
+ certSigner, err := ssh.NewCertSigner(pkcert.(*ssh.Certificate), sshSigner)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // create the request
+ req = NewRequest(t, "GET", "/api/v1/admin/users")
+
+ // add our cert to the request
+ certString := base64.RawStdEncoding.EncodeToString(pkcert.(*ssh.Certificate).Marshal())
+ req.SetHeader("x-ssh-certificate", certString)
+
+ signer, _, err := httpsig.NewSSHSigner(certSigner, httpsig.DigestSha512, []string{httpsig.RequestTarget, "(created)", "(expires)", "x-ssh-certificate"}, httpsig.Signature, 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // sign the request
+ err = signer.SignRequest(keyID, req.Request, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // make the request
+ MakeRequest(t, req, http.StatusOK)
+}
diff --git a/tests/integration/api_issue_attachment_test.go b/tests/integration/api_issue_attachment_test.go
new file mode 100644
index 0000000..77e752d
--- /dev/null
+++ b/tests/integration/api_issue_attachment_test.go
@@ -0,0 +1,244 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIGetIssueAttachment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: attachment.IssueID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d", repoOwner.Name, repo.Name, issue.Index, attachment.ID)).
+ AddTokenAuth(token)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ apiAttachment := new(api.Attachment)
+ DecodeJSON(t, resp, &apiAttachment)
+
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID})
+}
+
+func TestAPIListIssueAttachments(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: attachment.IssueID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", repoOwner.Name, repo.Name, issue.Index)).
+ AddTokenAuth(token)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ apiAttachment := new([]api.Attachment)
+ DecodeJSON(t, resp, &apiAttachment)
+
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: (*apiAttachment)[0].ID, IssueID: issue.ID})
+}
+
+func TestAPICreateIssueAttachment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ filename := "image.png"
+ buff := generateImg()
+ body := &bytes.Buffer{}
+
+ // Setup multi-part
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("attachment", filename)
+ require.NoError(t, err)
+ _, err = io.Copy(part, &buff)
+ require.NoError(t, err)
+ err = writer.Close()
+ require.NoError(t, err)
+
+ req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", repoOwner.Name, repo.Name, issue.Index), body).
+ AddTokenAuth(token)
+ req.Header.Add("Content-Type", writer.FormDataContentType())
+ resp := session.MakeRequest(t, req, http.StatusCreated)
+
+ apiAttachment := new(api.Attachment)
+ DecodeJSON(t, resp, &apiAttachment)
+
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID})
+}
+
+func TestAPICreateIssueAttachmentAutoDate(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets",
+ repoOwner.Name, repo.Name, issue.Index)
+
+ filename := "image.png"
+ buff := generateImg()
+ body := &bytes.Buffer{}
+
+ t.Run("WithAutoDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Setup multi-part
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("attachment", filename)
+ require.NoError(t, err)
+ _, err = io.Copy(part, &buff)
+ require.NoError(t, err)
+ err = writer.Close()
+ require.NoError(t, err)
+
+ req := NewRequestWithBody(t, "POST", urlStr, body).AddTokenAuth(token)
+ req.Header.Add("Content-Type", writer.FormDataContentType())
+ resp := session.MakeRequest(t, req, http.StatusCreated)
+
+ apiAttachment := new(api.Attachment)
+ DecodeJSON(t, resp, &apiAttachment)
+
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID})
+ // the execution of the API call supposedly lasted less than one minute
+ updatedSince := time.Since(apiAttachment.Created)
+ assert.LessOrEqual(t, updatedSince, time.Minute)
+
+ issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.Index})
+ updatedSince = time.Since(issueAfter.UpdatedUnix.AsTime())
+ assert.LessOrEqual(t, updatedSince, time.Minute)
+ })
+
+ t.Run("WithUpdateDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
+ urlStr += fmt.Sprintf("?updated_at=%s", updatedAt.UTC().Format(time.RFC3339))
+
+ // Setup multi-part
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("attachment", filename)
+ require.NoError(t, err)
+ _, err = io.Copy(part, &buff)
+ require.NoError(t, err)
+ err = writer.Close()
+ require.NoError(t, err)
+
+ req := NewRequestWithBody(t, "POST", urlStr, body).AddTokenAuth(token)
+ req.Header.Add("Content-Type", writer.FormDataContentType())
+ resp := session.MakeRequest(t, req, http.StatusCreated)
+
+ apiAttachment := new(api.Attachment)
+ DecodeJSON(t, resp, &apiAttachment)
+
+ // dates will be converted into the same tz, in order to compare them
+ utcTZ, _ := time.LoadLocation("UTC")
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID})
+ assert.Equal(t, updatedAt.In(utcTZ), apiAttachment.Created.In(utcTZ))
+ issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID})
+ assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ))
+ })
+}
+
+func TestAPICreateIssueAttachmentWithUnallowedFile(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ filename := "file.bad"
+ body := &bytes.Buffer{}
+
+ // Setup multi-part.
+ writer := multipart.NewWriter(body)
+ _, err := writer.CreateFormFile("attachment", filename)
+ require.NoError(t, err)
+ err = writer.Close()
+ require.NoError(t, err)
+
+ req := NewRequestWithBody(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets", repoOwner.Name, repo.Name, issue.Index), body).
+ AddTokenAuth(token)
+ req.Header.Add("Content-Type", writer.FormDataContentType())
+
+ session.MakeRequest(t, req, http.StatusUnprocessableEntity)
+}
+
+func TestAPIEditIssueAttachment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ const newAttachmentName = "newAttachmentName"
+
+ attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: attachment.IssueID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d",
+ repoOwner.Name, repo.Name, issue.Index, attachment.ID)
+ req := NewRequestWithValues(t, "PATCH", urlStr, map[string]string{
+ "name": newAttachmentName,
+ }).AddTokenAuth(token)
+ resp := session.MakeRequest(t, req, http.StatusCreated)
+ apiAttachment := new(api.Attachment)
+ DecodeJSON(t, resp, &apiAttachment)
+
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: apiAttachment.ID, IssueID: issue.ID, Name: apiAttachment.Name})
+}
+
+func TestAPIDeleteIssueAttachment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ attachment := unittest.AssertExistsAndLoadBean(t, &repo_model.Attachment{ID: 1})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: attachment.RepoID})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: attachment.IssueID})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/assets/%d", repoOwner.Name, repo.Name, issue.Index, attachment.ID)).
+ AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusNoContent)
+
+ unittest.AssertNotExistsBean(t, &repo_model.Attachment{ID: attachment.ID, IssueID: issue.ID})
+}
diff --git a/tests/integration/api_issue_config_test.go b/tests/integration/api_issue_config_test.go
new file mode 100644
index 0000000..16f81e7
--- /dev/null
+++ b/tests/integration/api_issue_config_test.go
@@ -0,0 +1,211 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+)
+
+func createIssueConfigInDirectory(t *testing.T, user *user_model.User, repo *repo_model.Repository, dir string, issueConfig map[string]any) {
+ config, err := yaml.Marshal(issueConfig)
+ require.NoError(t, err)
+
+ err = createOrReplaceFileInBranch(user, repo, fmt.Sprintf("%s/ISSUE_TEMPLATE/config.yaml", dir), repo.DefaultBranch, string(config))
+ require.NoError(t, err)
+}
+
+func createIssueConfig(t *testing.T, user *user_model.User, repo *repo_model.Repository, issueConfig map[string]any) {
+ createIssueConfigInDirectory(t, user, repo, ".gitea", issueConfig)
+}
+
+func getIssueConfig(t *testing.T, owner, repo string) api.IssueConfig {
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issue_config", owner, repo)
+ req := NewRequest(t, "GET", urlStr)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var issueConfig api.IssueConfig
+ DecodeJSON(t, resp, &issueConfig)
+
+ return issueConfig
+}
+
+func TestAPIRepoGetIssueConfig(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 49})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ t.Run("Default", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ issueConfig := getIssueConfig(t, owner.Name, repo.Name)
+
+ assert.True(t, issueConfig.BlankIssuesEnabled)
+ assert.Empty(t, issueConfig.ContactLinks)
+ })
+
+ t.Run("DisableBlankIssues", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ config := make(map[string]any)
+ config["blank_issues_enabled"] = false
+
+ createIssueConfig(t, owner, repo, config)
+
+ issueConfig := getIssueConfig(t, owner.Name, repo.Name)
+
+ assert.False(t, issueConfig.BlankIssuesEnabled)
+ assert.Empty(t, issueConfig.ContactLinks)
+ })
+
+ t.Run("ContactLinks", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ contactLink := make(map[string]string)
+ contactLink["name"] = "TestName"
+ contactLink["url"] = "https://example.com"
+ contactLink["about"] = "TestAbout"
+
+ config := make(map[string]any)
+ config["contact_links"] = []map[string]string{contactLink}
+
+ createIssueConfig(t, owner, repo, config)
+
+ issueConfig := getIssueConfig(t, owner.Name, repo.Name)
+
+ assert.True(t, issueConfig.BlankIssuesEnabled)
+ assert.Len(t, issueConfig.ContactLinks, 1)
+
+ assert.Equal(t, "TestName", issueConfig.ContactLinks[0].Name)
+ assert.Equal(t, "https://example.com", issueConfig.ContactLinks[0].URL)
+ assert.Equal(t, "TestAbout", issueConfig.ContactLinks[0].About)
+ })
+
+ t.Run("Full", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ contactLink := make(map[string]string)
+ contactLink["name"] = "TestName"
+ contactLink["url"] = "https://example.com"
+ contactLink["about"] = "TestAbout"
+
+ config := make(map[string]any)
+ config["blank_issues_enabled"] = false
+ config["contact_links"] = []map[string]string{contactLink}
+
+ createIssueConfig(t, owner, repo, config)
+
+ issueConfig := getIssueConfig(t, owner.Name, repo.Name)
+
+ assert.False(t, issueConfig.BlankIssuesEnabled)
+ assert.Len(t, issueConfig.ContactLinks, 1)
+
+ assert.Equal(t, "TestName", issueConfig.ContactLinks[0].Name)
+ assert.Equal(t, "https://example.com", issueConfig.ContactLinks[0].URL)
+ assert.Equal(t, "TestAbout", issueConfig.ContactLinks[0].About)
+ })
+}
+
+func TestAPIRepoIssueConfigPaths(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 49})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ templateConfigCandidates := []string{
+ ".forgejo/ISSUE_TEMPLATE/config",
+ ".forgejo/issue_template/config",
+ ".gitea/ISSUE_TEMPLATE/config",
+ ".gitea/issue_template/config",
+ ".github/ISSUE_TEMPLATE/config",
+ ".github/issue_template/config",
+ }
+
+ for _, candidate := range templateConfigCandidates {
+ for _, extension := range []string{".yaml", ".yml"} {
+ fullPath := candidate + extension
+ t.Run(fullPath, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ configMap := make(map[string]any)
+ configMap["blank_issues_enabled"] = false
+
+ configData, err := yaml.Marshal(configMap)
+ require.NoError(t, err)
+
+ _, err = createFileInBranch(owner, repo, fullPath, repo.DefaultBranch, string(configData))
+ require.NoError(t, err)
+
+ issueConfig := getIssueConfig(t, owner.Name, repo.Name)
+
+ assert.False(t, issueConfig.BlankIssuesEnabled)
+ assert.Empty(t, issueConfig.ContactLinks)
+
+ _, err = deleteFileInBranch(owner, repo, fullPath, repo.DefaultBranch)
+ require.NoError(t, err)
+ })
+ }
+ }
+}
+
+func TestAPIRepoValidateIssueConfig(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 49})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issue_config/validate", owner.Name, repo.Name)
+
+ t.Run("Valid", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", urlStr)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var issueConfigValidation api.IssueConfigValidation
+ DecodeJSON(t, resp, &issueConfigValidation)
+
+ assert.True(t, issueConfigValidation.Valid)
+ assert.Empty(t, issueConfigValidation.Message)
+ })
+
+ t.Run("Invalid", func(t *testing.T) {
+ dirs := []string{".gitea", ".forgejo"}
+ for _, dir := range dirs {
+ t.Run(dir, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer func() {
+ deleteFileInBranch(owner, repo, fmt.Sprintf("%s/ISSUE_TEMPLATE/config.yaml", dir), repo.DefaultBranch)
+ }()
+
+ config := make(map[string]any)
+ config["blank_issues_enabled"] = "Test"
+
+ createIssueConfigInDirectory(t, owner, repo, dir, config)
+
+ req := NewRequest(t, "GET", urlStr)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var issueConfigValidation api.IssueConfigValidation
+ DecodeJSON(t, resp, &issueConfigValidation)
+
+ assert.False(t, issueConfigValidation.Valid)
+ assert.NotEmpty(t, issueConfigValidation.Message)
+ })
+ }
+ })
+}
diff --git a/tests/integration/api_issue_label_test.go b/tests/integration/api_issue_label_test.go
new file mode 100644
index 0000000..29da419
--- /dev/null
+++ b/tests/integration/api_issue_label_test.go
@@ -0,0 +1,309 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIModifyLabels(t *testing.T) {
+ require.NoError(t, unittest.LoadFixtures())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner.Name, repo.Name)
+
+ // CreateLabel
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{
+ Name: "TestL 1",
+ Color: "abcdef",
+ Description: "test label",
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ apiLabel := new(api.Label)
+ DecodeJSON(t, resp, &apiLabel)
+ dbLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: apiLabel.ID, RepoID: repo.ID})
+ assert.EqualValues(t, dbLabel.Name, apiLabel.Name)
+ assert.EqualValues(t, strings.TrimLeft(dbLabel.Color, "#"), apiLabel.Color)
+
+ req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{
+ Name: "TestL 2",
+ Color: "#123456",
+ Description: "jet another test label",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+ req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{
+ Name: "WrongTestL",
+ Color: "#12345g",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ // ListLabels
+ req = NewRequest(t, "GET", urlStr).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var apiLabels []*api.Label
+ DecodeJSON(t, resp, &apiLabels)
+ assert.Len(t, apiLabels, 2)
+
+ // GetLabel
+ singleURLStr := fmt.Sprintf("/api/v1/repos/%s/%s/labels/%d", owner.Name, repo.Name, dbLabel.ID)
+ req = NewRequest(t, "GET", singleURLStr).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiLabel)
+ assert.EqualValues(t, strings.TrimLeft(dbLabel.Color, "#"), apiLabel.Color)
+
+ // EditLabel
+ newName := "LabelNewName"
+ newColor := "09876a"
+ newColorWrong := "09g76a"
+ req = NewRequestWithJSON(t, "PATCH", singleURLStr, &api.EditLabelOption{
+ Name: &newName,
+ Color: &newColor,
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiLabel)
+ assert.EqualValues(t, newColor, apiLabel.Color)
+ req = NewRequestWithJSON(t, "PATCH", singleURLStr, &api.EditLabelOption{
+ Color: &newColorWrong,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ // DeleteLabel
+ req = NewRequest(t, "DELETE", singleURLStr).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+}
+
+func TestAPIAddIssueLabels(t *testing.T) {
+ require.NoError(t, unittest.LoadFixtures())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
+ _ = unittest.AssertExistsAndLoadBean(t, &issues_model.Label{RepoID: repo.ID, ID: 2})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels",
+ repo.OwnerName, repo.Name, issue.Index)
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{
+ Labels: []any{1, 2},
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiLabels []*api.Label
+ DecodeJSON(t, resp, &apiLabels)
+ assert.Len(t, apiLabels, unittest.GetCount(t, &issues_model.IssueLabel{IssueID: issue.ID}))
+
+ unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: 2})
+}
+
+func TestAPIAddIssueLabelsWithLabelNames(t *testing.T) {
+ require.NoError(t, unittest.LoadFixtures())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels",
+ repo.OwnerName, repo.Name, issue.Index)
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{
+ Labels: []any{"label1", "label2"},
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiLabels []*api.Label
+ DecodeJSON(t, resp, &apiLabels)
+ assert.Len(t, apiLabels, unittest.GetCount(t, &issues_model.IssueLabel{IssueID: issue.ID}))
+
+ var apiLabelNames []string
+ for _, label := range apiLabels {
+ apiLabelNames = append(apiLabelNames, label.Name)
+ }
+ assert.ElementsMatch(t, apiLabelNames, []string{"label1", "label2"})
+}
+
+func TestAPIAddIssueLabelsAutoDate(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels",
+ owner.Name, repo.Name, issueBefore.Index)
+
+ t.Run("WithAutoDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{
+ Labels: []any{1},
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID})
+ // the execution of the API call supposedly lasted less than one minute
+ updatedSince := time.Since(issueAfter.UpdatedUnix.AsTime())
+ assert.LessOrEqual(t, updatedSince, time.Minute)
+ })
+
+ t.Run("WithUpdatedDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.IssueLabelsOption{
+ Labels: []any{2},
+ Updated: &updatedAt,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ // dates will be converted into the same tz, in order to compare them
+ utcTZ, _ := time.LoadLocation("UTC")
+ issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID})
+ assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ))
+ })
+}
+
+func TestAPIReplaceIssueLabels(t *testing.T) {
+ require.NoError(t, unittest.LoadFixtures())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
+ label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{RepoID: repo.ID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels",
+ owner.Name, repo.Name, issue.Index)
+ req := NewRequestWithJSON(t, "PUT", urlStr, &api.IssueLabelsOption{
+ Labels: []any{label.ID},
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiLabels []*api.Label
+ DecodeJSON(t, resp, &apiLabels)
+ if assert.Len(t, apiLabels, 1) {
+ assert.EqualValues(t, label.ID, apiLabels[0].ID)
+ }
+
+ unittest.AssertCount(t, &issues_model.IssueLabel{IssueID: issue.ID}, 1)
+ unittest.AssertExistsAndLoadBean(t, &issues_model.IssueLabel{IssueID: issue.ID, LabelID: label.ID})
+}
+
+func TestAPIReplaceIssueLabelsWithLabelNames(t *testing.T) {
+ require.NoError(t, unittest.LoadFixtures())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
+ label := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{RepoID: repo.ID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels",
+ owner.Name, repo.Name, issue.Index)
+ req := NewRequestWithJSON(t, "PUT", urlStr, &api.IssueLabelsOption{
+ Labels: []any{label.Name},
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiLabels []*api.Label
+ DecodeJSON(t, resp, &apiLabels)
+ if assert.Len(t, apiLabels, 1) {
+ assert.EqualValues(t, label.Name, apiLabels[0].Name)
+ }
+}
+
+func TestAPIModifyOrgLabels(t *testing.T) {
+ require.NoError(t, unittest.LoadFixtures())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ user := "user1"
+ session := loginUser(t, user)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteOrganization)
+ urlStr := fmt.Sprintf("/api/v1/orgs/%s/labels", owner.Name)
+
+ // CreateLabel
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{
+ Name: "TestL 1",
+ Color: "abcdef",
+ Description: "test label",
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ apiLabel := new(api.Label)
+ DecodeJSON(t, resp, &apiLabel)
+ dbLabel := unittest.AssertExistsAndLoadBean(t, &issues_model.Label{ID: apiLabel.ID, OrgID: owner.ID})
+ assert.EqualValues(t, dbLabel.Name, apiLabel.Name)
+ assert.EqualValues(t, strings.TrimLeft(dbLabel.Color, "#"), apiLabel.Color)
+
+ req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{
+ Name: "TestL 2",
+ Color: "#123456",
+ Description: "jet another test label",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+ req = NewRequestWithJSON(t, "POST", urlStr, &api.CreateLabelOption{
+ Name: "WrongTestL",
+ Color: "#12345g",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ // ListLabels
+ req = NewRequest(t, "GET", urlStr).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var apiLabels []*api.Label
+ DecodeJSON(t, resp, &apiLabels)
+ assert.Len(t, apiLabels, 4)
+
+ // GetLabel
+ singleURLStr := fmt.Sprintf("/api/v1/orgs/%s/labels/%d", owner.Name, dbLabel.ID)
+ req = NewRequest(t, "GET", singleURLStr).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiLabel)
+ assert.EqualValues(t, strings.TrimLeft(dbLabel.Color, "#"), apiLabel.Color)
+
+ // EditLabel
+ newName := "LabelNewName"
+ newColor := "09876a"
+ newColorWrong := "09g76a"
+ req = NewRequestWithJSON(t, "PATCH", singleURLStr, &api.EditLabelOption{
+ Name: &newName,
+ Color: &newColor,
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiLabel)
+ assert.EqualValues(t, newColor, apiLabel.Color)
+ req = NewRequestWithJSON(t, "PATCH", singleURLStr, &api.EditLabelOption{
+ Color: &newColorWrong,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ // DeleteLabel
+ req = NewRequest(t, "DELETE", singleURLStr).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+}
diff --git a/tests/integration/api_issue_milestone_test.go b/tests/integration/api_issue_milestone_test.go
new file mode 100644
index 0000000..32ac562
--- /dev/null
+++ b/tests/integration/api_issue_milestone_test.go
@@ -0,0 +1,86 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIIssuesMilestone(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ milestone := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: 1})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: milestone.RepoID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ assert.Equal(t, int64(1), int64(milestone.NumIssues))
+ assert.Equal(t, structs.StateOpen, milestone.State())
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ // update values of issue
+ milestoneState := "closed"
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/milestones/%d", owner.Name, repo.Name, milestone.ID)
+ req := NewRequestWithJSON(t, "PATCH", urlStr, structs.EditMilestoneOption{
+ State: &milestoneState,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiMilestone structs.Milestone
+ DecodeJSON(t, resp, &apiMilestone)
+ assert.EqualValues(t, "closed", apiMilestone.State)
+
+ req = NewRequest(t, "GET", urlStr).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var apiMilestone2 structs.Milestone
+ DecodeJSON(t, resp, &apiMilestone2)
+ assert.EqualValues(t, "closed", apiMilestone2.State)
+
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/milestones", owner.Name, repo.Name), structs.CreateMilestoneOption{
+ Title: "wow",
+ Description: "closed one",
+ State: "closed",
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, &apiMilestone)
+ assert.Equal(t, "wow", apiMilestone.Title)
+ assert.Equal(t, structs.StateClosed, apiMilestone.State)
+
+ var apiMilestones []structs.Milestone
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/milestones?state=%s", owner.Name, repo.Name, "all")).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiMilestones)
+ assert.Len(t, apiMilestones, 4)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/milestones/%s", owner.Name, repo.Name, apiMilestones[2].Title)).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiMilestone)
+ assert.EqualValues(t, apiMilestones[2], apiMilestone)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/milestones?state=%s&name=%s", owner.Name, repo.Name, "all", "milestone2")).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiMilestones)
+ assert.Len(t, apiMilestones, 1)
+ assert.Equal(t, int64(2), apiMilestones[0].ID)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/milestones/%d", owner.Name, repo.Name, apiMilestone.ID)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+}
diff --git a/tests/integration/api_issue_pin_test.go b/tests/integration/api_issue_pin_test.go
new file mode 100644
index 0000000..2f257a8
--- /dev/null
+++ b/tests/integration/api_issue_pin_test.go
@@ -0,0 +1,190 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIPinIssue(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ require.NoError(t, unittest.LoadFixtures())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ // Pin the Issue
+ req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", repo.OwnerName, repo.Name, issue.Index)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Check if the Issue is pinned
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index))
+ resp := MakeRequest(t, req, http.StatusOK)
+ var issueAPI api.Issue
+ DecodeJSON(t, resp, &issueAPI)
+ assert.Equal(t, 1, issueAPI.PinOrder)
+}
+
+func TestAPIUnpinIssue(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ require.NoError(t, unittest.LoadFixtures())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ // Pin the Issue
+ req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", repo.OwnerName, repo.Name, issue.Index)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Check if the Issue is pinned
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index))
+ resp := MakeRequest(t, req, http.StatusOK)
+ var issueAPI api.Issue
+ DecodeJSON(t, resp, &issueAPI)
+ assert.Equal(t, 1, issueAPI.PinOrder)
+
+ // Unpin the Issue
+ req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", repo.OwnerName, repo.Name, issue.Index)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Check if the Issue is no longer pinned
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index))
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &issueAPI)
+ assert.Equal(t, 0, issueAPI.PinOrder)
+}
+
+func TestAPIMoveIssuePin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ require.NoError(t, unittest.LoadFixtures())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
+ issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2, RepoID: repo.ID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ // Pin the first Issue
+ req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", repo.OwnerName, repo.Name, issue.Index)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Check if the first Issue is pinned at position 1
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index))
+ resp := MakeRequest(t, req, http.StatusOK)
+ var issueAPI api.Issue
+ DecodeJSON(t, resp, &issueAPI)
+ assert.Equal(t, 1, issueAPI.PinOrder)
+
+ // Pin the second Issue
+ req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", repo.OwnerName, repo.Name, issue2.Index)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Move the first Issue to position 2
+ req = NewRequest(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin/2", repo.OwnerName, repo.Name, issue.Index)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Check if the first Issue is pinned at position 2
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue.Index))
+ resp = MakeRequest(t, req, http.StatusOK)
+ var issueAPI3 api.Issue
+ DecodeJSON(t, resp, &issueAPI3)
+ assert.Equal(t, 2, issueAPI3.PinOrder)
+
+ // Check if the second Issue is pinned at position 1
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", repo.OwnerName, repo.Name, issue2.Index))
+ resp = MakeRequest(t, req, http.StatusOK)
+ var issueAPI4 api.Issue
+ DecodeJSON(t, resp, &issueAPI4)
+ assert.Equal(t, 1, issueAPI4.PinOrder)
+}
+
+func TestAPIListPinnedIssues(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ require.NoError(t, unittest.LoadFixtures())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repo.ID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ // Pin the Issue
+ req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/pin", repo.OwnerName, repo.Name, issue.Index)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Check if the Issue is in the List
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/pinned", repo.OwnerName, repo.Name))
+ resp := MakeRequest(t, req, http.StatusOK)
+ var issueList []api.Issue
+ DecodeJSON(t, resp, &issueList)
+
+ assert.Len(t, issueList, 1)
+ assert.Equal(t, issue.ID, issueList[0].ID)
+}
+
+func TestAPIListPinnedPullrequests(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ require.NoError(t, unittest.LoadFixtures())
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/pulls/pinned", repo.OwnerName, repo.Name))
+ resp := MakeRequest(t, req, http.StatusOK)
+ var prList []api.PullRequest
+ DecodeJSON(t, resp, &prList)
+
+ assert.Empty(t, prList)
+}
+
+func TestAPINewPinAllowed(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/new_pin_allowed", owner.Name, repo.Name))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var newPinsAllowed api.NewIssuePinsAllowed
+ DecodeJSON(t, resp, &newPinsAllowed)
+
+ assert.True(t, newPinsAllowed.Issues)
+ assert.True(t, newPinsAllowed.PullRequests)
+}
diff --git a/tests/integration/api_issue_reaction_test.go b/tests/integration/api_issue_reaction_test.go
new file mode 100644
index 0000000..4ca909f
--- /dev/null
+++ b/tests/integration/api_issue_reaction_test.go
@@ -0,0 +1,165 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/convert"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIIssuesReactions(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+ _ = issue.LoadRepo(db.DefaultContext)
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions", owner.Name, issue.Repo.Name, issue.Index)
+
+ // Try to add not allowed reaction
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
+ Reaction: "wrong",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+
+ // Delete not allowed reaction
+ req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{
+ Reaction: "zzz",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ // Add allowed reaction
+ req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
+ Reaction: "rocket",
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var apiNewReaction api.Reaction
+ DecodeJSON(t, resp, &apiNewReaction)
+
+ // Add existing reaction
+ MakeRequest(t, req, http.StatusForbidden)
+
+ // Get end result of reaction list of issue #1
+ req = NewRequest(t, "GET", urlStr).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var apiReactions []*api.Reaction
+ DecodeJSON(t, resp, &apiReactions)
+ expectResponse := make(map[int]api.Reaction)
+ expectResponse[0] = api.Reaction{
+ User: convert.ToUser(db.DefaultContext, user2, user2),
+ Reaction: "eyes",
+ Created: time.Unix(1573248003, 0),
+ }
+ expectResponse[1] = apiNewReaction
+ assert.Len(t, apiReactions, 2)
+ for i, r := range apiReactions {
+ assert.Equal(t, expectResponse[i].Reaction, r.Reaction)
+ assert.Equal(t, expectResponse[i].Created.Unix(), r.Created.Unix())
+ assert.Equal(t, expectResponse[i].User.ID, r.User.ID)
+ }
+}
+
+func TestAPICommentReactions(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
+ _ = comment.LoadIssue(db.DefaultContext)
+ issue := comment.Issue
+ _ = issue.LoadRepo(db.DefaultContext)
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/reactions", owner.Name, issue.Repo.Name, comment.ID)
+
+ // Try to add not allowed reaction
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
+ Reaction: "wrong",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+
+ // Delete none existing reaction
+ req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{
+ Reaction: "eyes",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ t.Run("UnrelatedCommentID", func(t *testing.T) {
+ // Using the ID of a comment that does not belong to the repository must fail
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ token := getUserToken(t, repoOwner.Name, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/reactions", repoOwner.Name, repo.Name, comment.ID)
+ req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
+ Reaction: "+1",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{
+ Reaction: "+1",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", urlStr).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ // Add allowed reaction
+ req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
+ Reaction: "+1",
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var apiNewReaction api.Reaction
+ DecodeJSON(t, resp, &apiNewReaction)
+
+ // Add existing reaction
+ MakeRequest(t, req, http.StatusForbidden)
+
+ // Get end result of reaction list of issue #1
+ req = NewRequest(t, "GET", urlStr).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var apiReactions []*api.Reaction
+ DecodeJSON(t, resp, &apiReactions)
+ expectResponse := make(map[int]api.Reaction)
+ expectResponse[0] = api.Reaction{
+ User: convert.ToUser(db.DefaultContext, user2, user2),
+ Reaction: "laugh",
+ Created: time.Unix(1573248004, 0),
+ }
+ expectResponse[1] = api.Reaction{
+ User: convert.ToUser(db.DefaultContext, user1, user1),
+ Reaction: "laugh",
+ Created: time.Unix(1573248005, 0),
+ }
+ expectResponse[2] = apiNewReaction
+ assert.Len(t, apiReactions, 3)
+ for i, r := range apiReactions {
+ assert.Equal(t, expectResponse[i].Reaction, r.Reaction)
+ assert.Equal(t, expectResponse[i].Created.Unix(), r.Created.Unix())
+ assert.Equal(t, expectResponse[i].User.ID, r.User.ID)
+ }
+}
diff --git a/tests/integration/api_issue_stopwatch_test.go b/tests/integration/api_issue_stopwatch_test.go
new file mode 100644
index 0000000..4765787
--- /dev/null
+++ b/tests/integration/api_issue_stopwatch_test.go
@@ -0,0 +1,96 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIListStopWatches(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopeReadUser)
+ req := NewRequest(t, "GET", "/api/v1/user/stopwatches").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiWatches []*api.StopWatch
+ DecodeJSON(t, resp, &apiWatches)
+ stopwatch := unittest.AssertExistsAndLoadBean(t, &issues_model.Stopwatch{UserID: owner.ID})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: stopwatch.IssueID})
+ if assert.Len(t, apiWatches, 1) {
+ assert.EqualValues(t, stopwatch.CreatedUnix.AsTime().Unix(), apiWatches[0].Created.Unix())
+ assert.EqualValues(t, issue.Index, apiWatches[0].IssueIndex)
+ assert.EqualValues(t, issue.Title, apiWatches[0].IssueTitle)
+ assert.EqualValues(t, repo.Name, apiWatches[0].RepoName)
+ assert.EqualValues(t, repo.OwnerName, apiWatches[0].RepoOwnerName)
+ assert.Positive(t, apiWatches[0].Seconds)
+ }
+}
+
+func TestAPIStopStopWatches(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
+ _ = issue.LoadRepo(db.DefaultContext)
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID})
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/issues/%d/stopwatch/stop", owner.Name, issue.Repo.Name, issue.Index).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+ MakeRequest(t, req, http.StatusConflict)
+}
+
+func TestAPICancelStopWatches(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+ _ = issue.LoadRepo(db.DefaultContext)
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID})
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/issues/%d/stopwatch/delete", owner.Name, issue.Repo.Name, issue.Index).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ MakeRequest(t, req, http.StatusConflict)
+}
+
+func TestAPIStartStopWatches(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
+ _ = issue.LoadRepo(db.DefaultContext)
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID})
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/issues/%d/stopwatch/start", owner.Name, issue.Repo.Name, issue.Index).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+ MakeRequest(t, req, http.StatusConflict)
+}
diff --git a/tests/integration/api_issue_subscription_test.go b/tests/integration/api_issue_subscription_test.go
new file mode 100644
index 0000000..7a71630
--- /dev/null
+++ b/tests/integration/api_issue_subscription_test.go
@@ -0,0 +1,82 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIIssueSubscriptions(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issue1 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+ issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
+ issue3 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
+ issue4 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 4})
+ issue5 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 8})
+
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue1.PosterID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ testSubscription := func(issue *issues_model.Issue, isWatching bool) {
+ issueRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/subscriptions/check", issueRepo.OwnerName, issueRepo.Name, issue.Index)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ wi := new(api.WatchInfo)
+ DecodeJSON(t, resp, wi)
+
+ assert.EqualValues(t, isWatching, wi.Subscribed)
+ assert.EqualValues(t, !isWatching, wi.Ignored)
+ assert.EqualValues(t, issue.APIURL(db.DefaultContext)+"/subscriptions", wi.URL)
+ assert.EqualValues(t, issue.CreatedUnix, wi.CreatedAt.Unix())
+ assert.EqualValues(t, issueRepo.APIURL(), wi.RepositoryURL)
+ }
+
+ testSubscription(issue1, true)
+ testSubscription(issue2, true)
+ testSubscription(issue3, true)
+ testSubscription(issue4, false)
+ testSubscription(issue5, false)
+
+ issue1Repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue1.RepoID})
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/subscriptions/%s", issue1Repo.OwnerName, issue1Repo.Name, issue1.Index, owner.Name)
+ req := NewRequest(t, "DELETE", urlStr).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+ testSubscription(issue1, false)
+
+ req = NewRequest(t, "DELETE", urlStr).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ testSubscription(issue1, false)
+
+ issue5Repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue5.RepoID})
+ urlStr = fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/subscriptions/%s", issue5Repo.OwnerName, issue5Repo.Name, issue5.Index, owner.Name)
+ req = NewRequest(t, "PUT", urlStr).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+ testSubscription(issue5, true)
+
+ req = NewRequest(t, "PUT", urlStr).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ testSubscription(issue5, true)
+}
diff --git a/tests/integration/api_issue_templates_test.go b/tests/integration/api_issue_templates_test.go
new file mode 100644
index 0000000..d634329
--- /dev/null
+++ b/tests/integration/api_issue_templates_test.go
@@ -0,0 +1,115 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "testing"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIIssueTemplateList(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ t.Run("no templates", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/issue_templates", repo.FullName()))
+ resp := MakeRequest(t, req, http.StatusOK)
+ var issueTemplates []*api.IssueTemplate
+ DecodeJSON(t, resp, &issueTemplates)
+ assert.Empty(t, issueTemplates)
+ })
+
+ t.Run("existing template", func(t *testing.T) {
+ templateCandidates := []string{
+ ".forgejo/ISSUE_TEMPLATE/test.md",
+ ".forgejo/issue_template/test.md",
+ ".gitea/ISSUE_TEMPLATE/test.md",
+ ".gitea/issue_template/test.md",
+ ".github/ISSUE_TEMPLATE/test.md",
+ ".github/issue_template/test.md",
+ }
+
+ for _, template := range templateCandidates {
+ t.Run(template, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer func() {
+ deleteFileInBranch(user, repo, template, repo.DefaultBranch)
+ }()
+
+ err := createOrReplaceFileInBranch(user, repo, template, repo.DefaultBranch,
+ `---
+name: 'Template Name'
+about: 'This template is for testing!'
+title: '[TEST] '
+ref: 'main'
+---
+
+This is the template!`)
+ require.NoError(t, err)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/issue_templates", repo.FullName()))
+ resp := MakeRequest(t, req, http.StatusOK)
+ var issueTemplates []*api.IssueTemplate
+ DecodeJSON(t, resp, &issueTemplates)
+ assert.Len(t, issueTemplates, 1)
+ assert.Equal(t, "Template Name", issueTemplates[0].Name)
+ assert.Equal(t, "This template is for testing!", issueTemplates[0].About)
+ assert.Equal(t, "refs/heads/main", issueTemplates[0].Ref)
+ assert.Equal(t, template, issueTemplates[0].FileName)
+ })
+ }
+ })
+
+ t.Run("multiple templates", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ templatePriority := []string{
+ ".forgejo/issue_template/test.md",
+ ".gitea/issue_template/test.md",
+ ".github/issue_template/test.md",
+ }
+ defer func() {
+ for _, template := range templatePriority {
+ deleteFileInBranch(user, repo, template, repo.DefaultBranch)
+ }
+ }()
+
+ for _, template := range templatePriority {
+ err := createOrReplaceFileInBranch(user, repo, template, repo.DefaultBranch,
+ `---
+name: 'Template Name'
+about: 'This template is for testing!'
+title: '[TEST] '
+ref: 'main'
+---
+
+This is the template!`)
+ require.NoError(t, err)
+ }
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/issue_templates", repo.FullName()))
+ resp := MakeRequest(t, req, http.StatusOK)
+ var issueTemplates []*api.IssueTemplate
+ DecodeJSON(t, resp, &issueTemplates)
+
+ // If templates have the same filename and content, but in different
+ // directories, they count as different templates, and all are
+ // considered.
+ assert.Len(t, issueTemplates, 3)
+ })
+ })
+}
diff --git a/tests/integration/api_issue_test.go b/tests/integration/api_issue_test.go
new file mode 100644
index 0000000..4051f95
--- /dev/null
+++ b/tests/integration/api_issue_test.go
@@ -0,0 +1,578 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+ "sync"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIListIssues(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue)
+ link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner.Name, repo.Name))
+
+ link.RawQuery = url.Values{"token": {token}, "state": {"all"}}.Encode()
+ resp := MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK)
+ var apiIssues []*api.Issue
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, unittest.GetCount(t, &issues_model.Issue{RepoID: repo.ID}))
+ for _, apiIssue := range apiIssues {
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: apiIssue.ID, RepoID: repo.ID})
+ }
+
+ // test milestone filter
+ link.RawQuery = url.Values{"token": {token}, "state": {"all"}, "type": {"all"}, "milestones": {"ignore,milestone1,3,4"}}.Encode()
+ resp = MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ if assert.Len(t, apiIssues, 2) {
+ assert.EqualValues(t, 3, apiIssues[0].Milestone.ID)
+ assert.EqualValues(t, 1, apiIssues[1].Milestone.ID)
+ }
+
+ link.RawQuery = url.Values{"token": {token}, "state": {"all"}, "created_by": {"user2"}}.Encode()
+ resp = MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ if assert.Len(t, apiIssues, 1) {
+ assert.EqualValues(t, 5, apiIssues[0].ID)
+ }
+
+ link.RawQuery = url.Values{"token": {token}, "state": {"all"}, "assigned_by": {"user1"}}.Encode()
+ resp = MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ if assert.Len(t, apiIssues, 1) {
+ assert.EqualValues(t, 1, apiIssues[0].ID)
+ }
+
+ link.RawQuery = url.Values{"token": {token}, "state": {"all"}, "mentioned_by": {"user4"}}.Encode()
+ resp = MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ if assert.Len(t, apiIssues, 1) {
+ assert.EqualValues(t, 1, apiIssues[0].ID)
+ }
+}
+
+func TestAPIListIssuesPublicOnly(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo1.OwnerID})
+
+ session := loginUser(t, owner1.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue)
+ link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner1.Name, repo1.Name))
+ link.RawQuery = url.Values{"state": {"all"}}.Encode()
+ req := NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+ owner2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo2.OwnerID})
+
+ session = loginUser(t, owner2.Name)
+ token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue)
+ link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner2.Name, repo2.Name))
+ link.RawQuery = url.Values{"state": {"all"}}.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopePublicOnly)
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken)
+ MakeRequest(t, req, http.StatusForbidden)
+}
+
+func TestAPICreateIssue(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ const body, title = "apiTestBody", "apiTestTitle"
+
+ repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues?state=all", owner.Name, repoBefore.Name)
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{
+ Body: body,
+ Title: title,
+ Assignee: owner.Name,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var apiIssue api.Issue
+ DecodeJSON(t, resp, &apiIssue)
+ assert.Equal(t, body, apiIssue.Body)
+ assert.Equal(t, title, apiIssue.Title)
+
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{
+ RepoID: repoBefore.ID,
+ AssigneeID: owner.ID,
+ Content: body,
+ Title: title,
+ })
+
+ repoAfter := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ assert.Equal(t, repoBefore.NumIssues+1, repoAfter.NumIssues)
+ assert.Equal(t, repoBefore.NumClosedIssues, repoAfter.NumClosedIssues)
+}
+
+func TestAPICreateIssueParallel(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ const body, title = "apiTestBody", "apiTestTitle"
+
+ repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues?state=all", owner.Name, repoBefore.Name)
+
+ var wg sync.WaitGroup
+ for i := 0; i < 10; i++ {
+ wg.Add(1)
+ go func(parentT *testing.T, i int) {
+ parentT.Run(fmt.Sprintf("ParallelCreateIssue_%d", i), func(t *testing.T) {
+ newTitle := title + strconv.Itoa(i)
+ newBody := body + strconv.Itoa(i)
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{
+ Body: newBody,
+ Title: newTitle,
+ Assignee: owner.Name,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var apiIssue api.Issue
+ DecodeJSON(t, resp, &apiIssue)
+ assert.Equal(t, newBody, apiIssue.Body)
+ assert.Equal(t, newTitle, apiIssue.Title)
+
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{
+ RepoID: repoBefore.ID,
+ AssigneeID: owner.ID,
+ Content: newBody,
+ Title: newTitle,
+ })
+
+ wg.Done()
+ })
+ }(t, i)
+ }
+ wg.Wait()
+}
+
+func TestAPIEditIssue(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10})
+ repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID})
+ require.NoError(t, issueBefore.LoadAttributes(db.DefaultContext))
+ assert.Equal(t, int64(1019307200), int64(issueBefore.DeadlineUnix))
+ assert.Equal(t, api.StateOpen, issueBefore.State())
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ // update values of issue
+ issueState := "closed"
+ removeDeadline := true
+ milestone := int64(4)
+ body := "new content!"
+ title := "new title from api set"
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner.Name, repoBefore.Name, issueBefore.Index)
+ req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{
+ State: &issueState,
+ RemoveDeadline: &removeDeadline,
+ Milestone: &milestone,
+ Body: &body,
+ Title: title,
+
+ // ToDo change more
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var apiIssue api.Issue
+ DecodeJSON(t, resp, &apiIssue)
+
+ issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10})
+ repoAfter := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID})
+
+ // check comment history
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: issueAfter.ID, OldTitle: issueBefore.Title, NewTitle: title})
+ unittest.AssertExistsAndLoadBean(t, &issues_model.ContentHistory{IssueID: issueAfter.ID, ContentText: body, IsFirstCreated: false})
+
+ // check deleted user
+ assert.Equal(t, int64(500), issueAfter.PosterID)
+ require.NoError(t, issueAfter.LoadAttributes(db.DefaultContext))
+ assert.Equal(t, int64(-1), issueAfter.PosterID)
+ assert.Equal(t, int64(-1), issueBefore.PosterID)
+ assert.Equal(t, int64(-1), apiIssue.Poster.ID)
+
+ // check repo change
+ assert.Equal(t, repoBefore.NumClosedIssues+1, repoAfter.NumClosedIssues)
+
+ // API response
+ assert.Equal(t, api.StateClosed, apiIssue.State)
+ assert.Equal(t, milestone, apiIssue.Milestone.ID)
+ assert.Equal(t, body, apiIssue.Body)
+ assert.Nil(t, apiIssue.Deadline)
+ assert.Equal(t, title, apiIssue.Title)
+
+ // in database
+ assert.Equal(t, api.StateClosed, issueAfter.State())
+ assert.Equal(t, milestone, issueAfter.MilestoneID)
+ assert.Equal(t, int64(0), int64(issueAfter.DeadlineUnix))
+ assert.Equal(t, body, issueAfter.Content)
+ assert.Equal(t, title, issueAfter.Title)
+
+ // verify the idempotency of state, milestone, body and title changes
+ req = NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{
+ State: &issueState,
+ Milestone: &milestone,
+ Body: &body,
+ Title: title,
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusCreated)
+ var apiIssueIdempotent api.Issue
+ DecodeJSON(t, resp, &apiIssueIdempotent)
+ assert.Equal(t, apiIssue.State, apiIssueIdempotent.State)
+ assert.Equal(t, apiIssue.Milestone.Title, apiIssueIdempotent.Milestone.Title)
+ assert.Equal(t, apiIssue.Body, apiIssueIdempotent.Body)
+ assert.Equal(t, apiIssue.Title, apiIssueIdempotent.Title)
+}
+
+func TestAPIEditIssueAutoDate(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 13})
+ repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID})
+ require.NoError(t, issueBefore.LoadAttributes(db.DefaultContext))
+
+ t.Run("WithAutoDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // User2 is not owner, but can update the 'public' issue with auto date
+ session := loginUser(t, "user2")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner.Name, repoBefore.Name, issueBefore.Index)
+
+ body := "new content!"
+ req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{
+ Body: &body,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var apiIssue api.Issue
+ DecodeJSON(t, resp, &apiIssue)
+
+ // the execution of the API call supposedly lasted less than one minute
+ updatedSince := time.Since(apiIssue.Updated)
+ assert.LessOrEqual(t, updatedSince, time.Minute)
+
+ issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID})
+ updatedSince = time.Since(issueAfter.UpdatedUnix.AsTime())
+ assert.LessOrEqual(t, updatedSince, time.Minute)
+ })
+
+ t.Run("WithUpdateDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // User1 is admin, and so can update the issue without auto date
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner.Name, repoBefore.Name, issueBefore.Index)
+
+ body := "new content, with updated time"
+ updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
+ req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{
+ Body: &body,
+ Updated: &updatedAt,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var apiIssue api.Issue
+ DecodeJSON(t, resp, &apiIssue)
+
+ // dates are converted into the same tz, in order to compare them
+ utcTZ, _ := time.LoadLocation("UTC")
+ assert.Equal(t, updatedAt.In(utcTZ), apiIssue.Updated.In(utcTZ))
+
+ issueAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issueBefore.ID})
+ assert.Equal(t, updatedAt.In(utcTZ), issueAfter.UpdatedUnix.AsTime().In(utcTZ))
+ })
+
+ t.Run("WithoutPermission", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // User2 is not owner nor admin, and so can't update the issue without auto date
+ session := loginUser(t, "user2")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner.Name, repoBefore.Name, issueBefore.Index)
+
+ body := "new content, with updated time"
+ updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
+ req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{
+ Body: &body,
+ Updated: &updatedAt,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusForbidden)
+ var apiError api.APIError
+ DecodeJSON(t, resp, &apiError)
+
+ assert.Equal(t, "user needs to have admin or owner right", apiError.Message)
+ })
+}
+
+func TestAPIEditIssueMilestoneAutoDate(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+ repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID})
+
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID})
+ require.NoError(t, issueBefore.LoadAttributes(db.DefaultContext))
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner.Name, repoBefore.Name, issueBefore.Index)
+
+ t.Run("WithAutoDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ milestone := int64(1)
+ req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{
+ Milestone: &milestone,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ // the execution of the API call supposedly lasted less than one minute
+ milestoneAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone})
+ updatedSince := time.Since(milestoneAfter.UpdatedUnix.AsTime())
+ assert.LessOrEqual(t, updatedSince, time.Minute)
+ })
+
+ t.Run("WithPostUpdateDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Note: the updated_unix field of the test Milestones is set to NULL
+ // Hence, any date is higher than the Milestone's updated date
+ updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
+ milestone := int64(2)
+ req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{
+ Milestone: &milestone,
+ Updated: &updatedAt,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ // the milestone date should be set to 'updatedAt'
+ // dates are converted into the same tz, in order to compare them
+ utcTZ, _ := time.LoadLocation("UTC")
+ milestoneAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone})
+ assert.Equal(t, updatedAt.In(utcTZ), milestoneAfter.UpdatedUnix.AsTime().In(utcTZ))
+ })
+
+ t.Run("WithPastUpdateDate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Note: This Milestone's updated_unix has been set to Now() by the first subtest
+ milestone := int64(1)
+ milestoneBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone})
+
+ updatedAt := time.Now().Add(-time.Hour).Truncate(time.Second)
+ req := NewRequestWithJSON(t, "PATCH", urlStr, api.EditIssueOption{
+ Milestone: &milestone,
+ Updated: &updatedAt,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ // the milestone date should not change
+ // dates are converted into the same tz, in order to compare them
+ utcTZ, _ := time.LoadLocation("UTC")
+ milestoneAfter := unittest.AssertExistsAndLoadBean(t, &issues_model.Milestone{ID: milestone})
+ assert.Equal(t, milestoneAfter.UpdatedUnix.AsTime().In(utcTZ), milestoneBefore.UpdatedUnix.AsTime().In(utcTZ))
+ })
+}
+
+func TestAPISearchIssues(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // as this API was used in the frontend, it uses UI page size
+ expectedIssueCount := 20 // from the fixtures
+ if expectedIssueCount > setting.UI.IssuePagingNum {
+ expectedIssueCount = setting.UI.IssuePagingNum
+ }
+
+ link, _ := url.Parse("/api/v1/repos/issues/search")
+ token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadIssue)
+ query := url.Values{}
+ var apiIssues []*api.Issue
+
+ link.RawQuery = query.Encode()
+ req := NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, expectedIssueCount)
+
+ publicOnlyToken := getUserToken(t, "user1", auth_model.AccessTokenScopeReadIssue, auth_model.AccessTokenScopePublicOnly)
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 15) // 15 public issues
+
+ since := "2000-01-01T00:50:01+00:00" // 946687801
+ before := time.Unix(999307200, 0).Format(time.RFC3339)
+ query.Add("since", since)
+ query.Add("before", before)
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 11)
+ query.Del("since")
+ query.Del("before")
+
+ query.Add("state", "closed")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+
+ query.Set("state", "all")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.EqualValues(t, "22", resp.Header().Get("X-Total-Count"))
+ assert.Len(t, apiIssues, 20)
+
+ query.Add("limit", "10")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.EqualValues(t, "22", resp.Header().Get("X-Total-Count"))
+ assert.Len(t, apiIssues, 10)
+
+ query = url.Values{"assigned": {"true"}, "state": {"all"}}
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+
+ query = url.Values{"milestones": {"milestone1"}, "state": {"all"}}
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 1)
+
+ query = url.Values{"milestones": {"milestone1,milestone3"}, "state": {"all"}}
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+
+ query = url.Values{"owner": {"user2"}} // user
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 8)
+
+ query = url.Values{"owner": {"org3"}} // organization
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 5)
+
+ query = url.Values{"owner": {"org3"}, "team": {"team1"}} // organization + team
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+}
+
+func TestAPISearchIssuesWithLabels(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // as this API was used in the frontend, it uses UI page size
+ expectedIssueCount := 20 // from the fixtures
+ if expectedIssueCount > setting.UI.IssuePagingNum {
+ expectedIssueCount = setting.UI.IssuePagingNum
+ }
+
+ link, _ := url.Parse("/api/v1/repos/issues/search")
+ token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadIssue)
+ query := url.Values{}
+ var apiIssues []*api.Issue
+
+ link.RawQuery = query.Encode()
+ req := NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, expectedIssueCount)
+
+ query.Add("labels", "label1")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+
+ // multiple labels
+ query.Set("labels", "label1,label2")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+
+ // an org label
+ query.Set("labels", "orglabel4")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 1)
+
+ // org and repo label
+ query.Set("labels", "label2,orglabel4")
+ query.Add("state", "all")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+
+ // org and repo label which share the same issue
+ query.Set("labels", "label1,orglabel4")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String()).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+}
diff --git a/tests/integration/api_issue_tracked_time_test.go b/tests/integration/api_issue_tracked_time_test.go
new file mode 100644
index 0000000..90a59fb
--- /dev/null
+++ b/tests/integration/api_issue_tracked_time_test.go
@@ -0,0 +1,131 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIGetTrackedTimes(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
+ require.NoError(t, issue2.LoadRepo(db.DefaultContext))
+
+ session := loginUser(t, user2.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadIssue)
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/%d/times", user2.Name, issue2.Repo.Name, issue2.Index).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiTimes api.TrackedTimeList
+ DecodeJSON(t, resp, &apiTimes)
+ expect, err := issues_model.GetTrackedTimes(db.DefaultContext, &issues_model.FindTrackedTimesOptions{IssueID: issue2.ID})
+ require.NoError(t, err)
+ assert.Len(t, apiTimes, 3)
+
+ for i, time := range expect {
+ assert.Equal(t, time.ID, apiTimes[i].ID)
+ assert.EqualValues(t, issue2.Title, apiTimes[i].Issue.Title)
+ assert.EqualValues(t, issue2.ID, apiTimes[i].IssueID)
+ assert.Equal(t, time.Created.Unix(), apiTimes[i].Created.Unix())
+ assert.Equal(t, time.Time, apiTimes[i].Time)
+ user, err := user_model.GetUserByID(db.DefaultContext, time.UserID)
+ require.NoError(t, err)
+ assert.Equal(t, user.Name, apiTimes[i].UserName)
+ }
+
+ // test filter
+ since := "2000-01-01T00%3A00%3A02%2B00%3A00" // 946684802
+ before := "2000-01-01T00%3A00%3A12%2B00%3A00" // 946684812
+
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/issues/%d/times?since=%s&before=%s", user2.Name, issue2.Repo.Name, issue2.Index, since, before).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var filterAPITimes api.TrackedTimeList
+ DecodeJSON(t, resp, &filterAPITimes)
+ assert.Len(t, filterAPITimes, 2)
+ assert.Equal(t, int64(3), filterAPITimes[0].ID)
+ assert.Equal(t, int64(6), filterAPITimes[1].ID)
+}
+
+func TestAPIDeleteTrackedTime(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ time6 := unittest.AssertExistsAndLoadBean(t, &issues_model.TrackedTime{ID: 6})
+ issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
+ require.NoError(t, issue2.LoadRepo(db.DefaultContext))
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ session := loginUser(t, user2.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ // Deletion not allowed
+ req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/issues/%d/times/%d", user2.Name, issue2.Repo.Name, issue2.Index, time6.ID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+
+ time3 := unittest.AssertExistsAndLoadBean(t, &issues_model.TrackedTime{ID: 3})
+ req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/issues/%d/times/%d", user2.Name, issue2.Repo.Name, issue2.Index, time3.ID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ // Delete non existing time
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Reset time of user 2 on issue 2
+ trackedSeconds, err := issues_model.GetTrackedSeconds(db.DefaultContext, issues_model.FindTrackedTimesOptions{IssueID: 2, UserID: 2})
+ require.NoError(t, err)
+ assert.Equal(t, int64(3661), trackedSeconds)
+
+ req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/issues/%d/times", user2.Name, issue2.Repo.Name, issue2.Index).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ trackedSeconds, err = issues_model.GetTrackedSeconds(db.DefaultContext, issues_model.FindTrackedTimesOptions{IssueID: 2, UserID: 2})
+ require.NoError(t, err)
+ assert.Equal(t, int64(0), trackedSeconds)
+}
+
+func TestAPIAddTrackedTimes(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issue2 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 2})
+ require.NoError(t, issue2.LoadRepo(db.DefaultContext))
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ session := loginUser(t, admin.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/times", user2.Name, issue2.Repo.Name, issue2.Index)
+
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.AddTimeOption{
+ Time: 33,
+ User: user2.Name,
+ Created: time.Unix(947688818, 0),
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiNewTime api.TrackedTime
+ DecodeJSON(t, resp, &apiNewTime)
+
+ assert.EqualValues(t, 33, apiNewTime.Time)
+ assert.EqualValues(t, user2.ID, apiNewTime.UserID)
+ assert.EqualValues(t, 947688818, apiNewTime.Created.Unix())
+}
diff --git a/tests/integration/api_keys_test.go b/tests/integration/api_keys_test.go
new file mode 100644
index 0000000..86daa8c
--- /dev/null
+++ b/tests/integration/api_keys_test.go
@@ -0,0 +1,213 @@
+// Copyright 2017 The Gogs Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "testing"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/perm"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestViewDeployKeysNoLogin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/keys")
+ MakeRequest(t, req, http.StatusUnauthorized)
+}
+
+func TestCreateDeployKeyNoLogin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user2/repo1/keys", api.CreateKeyOption{
+ Title: "title",
+ Key: "key",
+ })
+ MakeRequest(t, req, http.StatusUnauthorized)
+}
+
+func TestGetDeployKeyNoLogin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/keys/1")
+ MakeRequest(t, req, http.StatusUnauthorized)
+}
+
+func TestDeleteDeployKeyNoLogin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequest(t, "DELETE", "/api/v1/repos/user2/repo1/keys/1")
+ MakeRequest(t, req, http.StatusUnauthorized)
+}
+
+func TestCreateReadOnlyDeployKey(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/keys", repoOwner.Name, repo.Name)
+ rawKeyBody := api.CreateKeyOption{
+ Title: "read-only",
+ Key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n",
+ ReadOnly: true,
+ }
+ req := NewRequestWithJSON(t, "POST", keysURL, rawKeyBody).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var newDeployKey api.DeployKey
+ DecodeJSON(t, resp, &newDeployKey)
+ unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{
+ ID: newDeployKey.ID,
+ Name: rawKeyBody.Title,
+ Content: rawKeyBody.Key,
+ Mode: perm.AccessModeRead,
+ })
+
+ // Using the ID of a key that does not belong to the repository must fail
+ {
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/keys/%d", repoOwner.Name, repo.Name, newDeployKey.ID)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ session5 := loginUser(t, "user5")
+ token5 := getTokenForLoggedInUser(t, session5, auth_model.AccessTokenScopeWriteRepository)
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/user5/repo4/keys/%d", newDeployKey.ID)).
+ AddTokenAuth(token5)
+ MakeRequest(t, req, http.StatusNotFound)
+ }
+}
+
+func TestCreateReadWriteDeployKey(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo1"})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ keysURL := fmt.Sprintf("/api/v1/repos/%s/%s/keys", repoOwner.Name, repo.Name)
+ rawKeyBody := api.CreateKeyOption{
+ Title: "read-write",
+ Key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM= nocomment\n",
+ }
+ req := NewRequestWithJSON(t, "POST", keysURL, rawKeyBody).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var newDeployKey api.DeployKey
+ DecodeJSON(t, resp, &newDeployKey)
+ unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{
+ ID: newDeployKey.ID,
+ Name: rawKeyBody.Title,
+ Content: rawKeyBody.Key,
+ Mode: perm.AccessModeWrite,
+ })
+}
+
+func TestCreateUserKey(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
+
+ session := loginUser(t, "user1")
+ token := url.QueryEscape(getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser))
+ keyType := "ssh-rsa"
+ keyContent := "AAAAB3NzaC1yc2EAAAADAQABAAABgQC4cn+iXnA4KvcQYSV88vGn0Yi91vG47t1P7okprVmhNTkipNRIHWr6WdCO4VDr/cvsRkuVJAsLO2enwjGWWueOO6BodiBgyAOZ/5t5nJNMCNuLGT5UIo/RI1b0WRQwxEZTRjt6mFNw6lH14wRd8ulsr9toSWBPMOGWoYs1PDeDL0JuTjL+tr1SZi/EyxCngpYszKdXllJEHyI79KQgeD0Vt3pTrkbNVTOEcCNqZePSVmUH8X8Vhugz3bnE0/iE9Pb5fkWO9c4AnM1FgI/8Bvp27Fw2ShryIXuR6kKvUqhVMTuOSDHwu6A8jLE5Owt3GAYugDpDYuwTVNGrHLXKpPzrGGPE/jPmaLCMZcsdkec95dYeU3zKODEm8UQZFhmJmDeWVJ36nGrGZHL4J5aTTaeFUJmmXDaJYiJ+K2/ioKgXqnXvltu0A9R8/LGy4nrTJRr4JMLuJFoUXvGm1gXQ70w2LSpk6yl71RNC0hCtsBe8BP8IhYCM0EP5jh7eCMQZNvM="
+ rawKeyBody := api.CreateKeyOption{
+ Title: "test-key",
+ Key: keyType + " " + keyContent,
+ }
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/keys", rawKeyBody).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var newPublicKey api.PublicKey
+ DecodeJSON(t, resp, &newPublicKey)
+ fingerprint, err := asymkey_model.CalcFingerprint(rawKeyBody.Key)
+ require.NoError(t, err)
+ unittest.AssertExistsAndLoadBean(t, &asymkey_model.PublicKey{
+ ID: newPublicKey.ID,
+ OwnerID: user.ID,
+ Name: rawKeyBody.Title,
+ Fingerprint: fingerprint,
+ Mode: perm.AccessModeWrite,
+ })
+
+ // Search by fingerprint
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/keys?fingerprint=%s", newPublicKey.Fingerprint)).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ var fingerprintPublicKeys []api.PublicKey
+ DecodeJSON(t, resp, &fingerprintPublicKeys)
+ assert.Equal(t, newPublicKey.Fingerprint, fingerprintPublicKeys[0].Fingerprint)
+ assert.Equal(t, newPublicKey.ID, fingerprintPublicKeys[0].ID)
+ assert.Equal(t, user.ID, fingerprintPublicKeys[0].Owner.ID)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/keys?fingerprint=%s", user.Name, newPublicKey.Fingerprint)).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &fingerprintPublicKeys)
+ assert.Equal(t, newPublicKey.Fingerprint, fingerprintPublicKeys[0].Fingerprint)
+ assert.Equal(t, newPublicKey.ID, fingerprintPublicKeys[0].ID)
+ assert.Equal(t, user.ID, fingerprintPublicKeys[0].Owner.ID)
+
+ // Fail search by fingerprint
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/keys?fingerprint=%sA", newPublicKey.Fingerprint)).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &fingerprintPublicKeys)
+ assert.Empty(t, fingerprintPublicKeys)
+
+ // Fail searching for wrong users key
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/keys?fingerprint=%s", "user2", newPublicKey.Fingerprint)).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &fingerprintPublicKeys)
+ assert.Empty(t, fingerprintPublicKeys)
+
+ // Now login as user 2
+ session2 := loginUser(t, "user2")
+ token2 := getTokenForLoggedInUser(t, session2, auth_model.AccessTokenScopeWriteUser)
+
+ // Should find key even though not ours, but we shouldn't know whose it is
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/keys?fingerprint=%s", newPublicKey.Fingerprint)).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &fingerprintPublicKeys)
+ assert.Equal(t, newPublicKey.Fingerprint, fingerprintPublicKeys[0].Fingerprint)
+ assert.Equal(t, newPublicKey.ID, fingerprintPublicKeys[0].ID)
+ assert.Nil(t, fingerprintPublicKeys[0].Owner)
+
+ // Should find key even though not ours, but we shouldn't know whose it is
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/keys?fingerprint=%s", user.Name, newPublicKey.Fingerprint)).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &fingerprintPublicKeys)
+ assert.Equal(t, newPublicKey.Fingerprint, fingerprintPublicKeys[0].Fingerprint)
+ assert.Equal(t, newPublicKey.ID, fingerprintPublicKeys[0].ID)
+ assert.Nil(t, fingerprintPublicKeys[0].Owner)
+
+ // Fail when searching for key if it is not ours
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/keys?fingerprint=%s", "user2", newPublicKey.Fingerprint)).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &fingerprintPublicKeys)
+ assert.Empty(t, fingerprintPublicKeys)
+}
diff --git a/tests/integration/api_label_templates_test.go b/tests/integration/api_label_templates_test.go
new file mode 100644
index 0000000..3039f8c
--- /dev/null
+++ b/tests/integration/api_label_templates_test.go
@@ -0,0 +1,62 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ repo_module "code.gitea.io/gitea/modules/repository"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIListLabelTemplates(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/label/templates")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var templateList []string
+ DecodeJSON(t, resp, &templateList)
+
+ for i := range repo_module.LabelTemplateFiles {
+ assert.Equal(t, repo_module.LabelTemplateFiles[i].DisplayName, templateList[i])
+ }
+}
+
+func TestAPIGetLabelTemplateInfo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // If Gitea has for some reason no Label templates, we need to skip this test
+ if len(repo_module.LabelTemplateFiles) == 0 {
+ return
+ }
+
+ // Use the first template for the test
+ templateName := repo_module.LabelTemplateFiles[0].DisplayName
+
+ urlStr := fmt.Sprintf("/api/v1/label/templates/%s", url.PathEscape(templateName))
+ req := NewRequest(t, "GET", urlStr)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var templateInfo []api.LabelTemplate
+ DecodeJSON(t, resp, &templateInfo)
+
+ labels, err := repo_module.LoadTemplateLabelsByDisplayName(templateName)
+ require.NoError(t, err)
+
+ for i := range labels {
+ assert.Equal(t, strings.TrimLeft(labels[i].Color, "#"), templateInfo[i].Color)
+ assert.Equal(t, labels[i].Description, templateInfo[i].Description)
+ assert.Equal(t, labels[i].Exclusive, templateInfo[i].Exclusive)
+ assert.Equal(t, labels[i].Name, templateInfo[i].Name)
+ }
+}
diff --git a/tests/integration/api_license_templates_test.go b/tests/integration/api_license_templates_test.go
new file mode 100644
index 0000000..e12aab7
--- /dev/null
+++ b/tests/integration/api_license_templates_test.go
@@ -0,0 +1,55 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "testing"
+
+ "code.gitea.io/gitea/modules/options"
+ repo_module "code.gitea.io/gitea/modules/repository"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIListLicenseTemplates(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/licenses")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ // This tests if the API returns a list of strings
+ var licenseList []api.LicensesTemplateListEntry
+ DecodeJSON(t, resp, &licenseList)
+}
+
+func TestAPIGetLicenseTemplateInfo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // If Gitea has for some reason no License templates, we need to skip this test
+ if len(repo_module.Licenses) == 0 {
+ return
+ }
+
+ // Use the first template for the test
+ licenseName := repo_module.Licenses[0]
+
+ urlStr := fmt.Sprintf("/api/v1/licenses/%s", url.PathEscape(licenseName))
+ req := NewRequest(t, "GET", urlStr)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var licenseInfo api.LicenseTemplateInfo
+ DecodeJSON(t, resp, &licenseInfo)
+
+ // We get the text of the template here
+ text, _ := options.License(licenseName)
+
+ assert.Equal(t, licenseInfo.Key, licenseName)
+ assert.Equal(t, licenseInfo.Name, licenseName)
+ assert.Equal(t, licenseInfo.Body, string(text))
+}
diff --git a/tests/integration/api_nodeinfo_test.go b/tests/integration/api_nodeinfo_test.go
new file mode 100644
index 0000000..33d06ed
--- /dev/null
+++ b/tests/integration/api_nodeinfo_test.go
@@ -0,0 +1,39 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/routers"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNodeinfo(t *testing.T) {
+ setting.Federation.Enabled = true
+ testWebRoutes = routers.NormalRoutes()
+ defer func() {
+ setting.Federation.Enabled = false
+ testWebRoutes = routers.NormalRoutes()
+ }()
+
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ req := NewRequest(t, "GET", "/api/v1/nodeinfo")
+ resp := MakeRequest(t, req, http.StatusOK)
+ VerifyJSONSchema(t, resp, "nodeinfo_2.1.json")
+
+ var nodeinfo api.NodeInfo
+ DecodeJSON(t, resp, &nodeinfo)
+ assert.True(t, nodeinfo.OpenRegistrations)
+ assert.Equal(t, "forgejo", nodeinfo.Software.Name)
+ assert.Equal(t, 29, nodeinfo.Usage.Users.Total)
+ assert.Equal(t, 22, nodeinfo.Usage.LocalPosts)
+ assert.Equal(t, 4, nodeinfo.Usage.LocalComments)
+ })
+}
diff --git a/tests/integration/api_notification_test.go b/tests/integration/api_notification_test.go
new file mode 100644
index 0000000..ad233d9
--- /dev/null
+++ b/tests/integration/api_notification_test.go
@@ -0,0 +1,216 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPINotification(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ thread5 := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 5})
+ require.NoError(t, thread5.LoadAttributes(db.DefaultContext))
+ session := loginUser(t, user2.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteNotification, auth_model.AccessTokenScopeWriteRepository)
+
+ MakeRequest(t, NewRequest(t, "GET", "/api/v1/notifications"), http.StatusUnauthorized)
+
+ // -- GET /notifications --
+ // test filter
+ since := "2000-01-01T00%3A50%3A01%2B00%3A00" // 946687801
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?since=%s", since)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiNL []api.NotificationThread
+ DecodeJSON(t, resp, &apiNL)
+
+ assert.Len(t, apiNL, 1)
+ assert.EqualValues(t, 5, apiNL[0].ID)
+
+ // test filter
+ before := "2000-01-01T01%3A06%3A59%2B00%3A00" // 946688819
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications?all=%s&before=%s", "true", before)).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiNL)
+
+ assert.Len(t, apiNL, 3)
+ assert.EqualValues(t, 4, apiNL[0].ID)
+ assert.True(t, apiNL[0].Unread)
+ assert.False(t, apiNL[0].Pinned)
+ assert.EqualValues(t, 3, apiNL[1].ID)
+ assert.False(t, apiNL[1].Unread)
+ assert.True(t, apiNL[1].Pinned)
+ assert.EqualValues(t, 2, apiNL[2].ID)
+ assert.False(t, apiNL[2].Unread)
+ assert.False(t, apiNL[2].Pinned)
+
+ // -- GET /repos/{owner}/{repo}/notifications --
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?status-types=unread", user2.Name, repo1.Name)).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiNL)
+
+ assert.Len(t, apiNL, 1)
+ assert.EqualValues(t, 4, apiNL[0].ID)
+
+ // -- GET /repos/{owner}/{repo}/notifications -- multiple status-types
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?status-types=unread&status-types=pinned", user2.Name, repo1.Name)).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiNL)
+
+ assert.Len(t, apiNL, 2)
+ assert.EqualValues(t, 4, apiNL[0].ID)
+ assert.True(t, apiNL[0].Unread)
+ assert.False(t, apiNL[0].Pinned)
+ assert.EqualValues(t, 3, apiNL[1].ID)
+ assert.False(t, apiNL[1].Unread)
+ assert.True(t, apiNL[1].Pinned)
+
+ MakeRequest(t, NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications/threads/%d", 1)), http.StatusUnauthorized)
+
+ // -- GET /notifications/threads/{id} --
+ // get forbidden
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications/threads/%d", 1)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+
+ // get own
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/notifications/threads/%d", thread5.ID)).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var apiN api.NotificationThread
+ DecodeJSON(t, resp, &apiN)
+
+ assert.EqualValues(t, 5, apiN.ID)
+ assert.False(t, apiN.Pinned)
+ assert.True(t, apiN.Unread)
+ assert.EqualValues(t, "issue4", apiN.Subject.Title)
+ assert.EqualValues(t, "Issue", apiN.Subject.Type)
+ assert.EqualValues(t, thread5.Issue.APIURL(db.DefaultContext), apiN.Subject.URL)
+ assert.EqualValues(t, thread5.Repository.HTMLURL(), apiN.Repository.HTMLURL)
+
+ MakeRequest(t, NewRequest(t, "GET", "/api/v1/notifications/new"), http.StatusUnauthorized)
+
+ newStruct := struct {
+ New int64 `json:"new"`
+ }{}
+
+ // -- check notifications --
+ req = NewRequest(t, "GET", "/api/v1/notifications/new").
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &newStruct)
+ assert.Positive(t, newStruct.New)
+
+ // -- mark notifications as read --
+ req = NewRequest(t, "GET", "/api/v1/notifications?status-types=unread").
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiNL)
+ assert.Len(t, apiNL, 2)
+
+ lastReadAt := "2000-01-01T00%3A50%3A01%2B00%3A00" // 946687801 <- only Notification 4 is in this filter ...
+ req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?last_read_at=%s", user2.Name, repo1.Name, lastReadAt)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusResetContent)
+
+ req = NewRequest(t, "GET", "/api/v1/notifications?status-types=unread").
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiNL)
+ assert.Len(t, apiNL, 1)
+
+ // -- PATCH /notifications/threads/{id} --
+ req = NewRequest(t, "PATCH", fmt.Sprintf("/api/v1/notifications/threads/%d", thread5.ID)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusResetContent)
+
+ assert.Equal(t, activities_model.NotificationStatusUnread, thread5.Status)
+ thread5 = unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 5})
+ assert.Equal(t, activities_model.NotificationStatusRead, thread5.Status)
+
+ // -- check notifications --
+ req = NewRequest(t, "GET", "/api/v1/notifications/new").
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &newStruct)
+ assert.Equal(t, int64(0), newStruct.New)
+}
+
+func TestAPINotificationPUT(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ thread5 := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 5})
+ require.NoError(t, thread5.LoadAttributes(db.DefaultContext))
+ session := loginUser(t, user2.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteNotification)
+
+ // Check notifications are as expected
+ req := NewRequest(t, "GET", "/api/v1/notifications?all=true").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiNL []api.NotificationThread
+ DecodeJSON(t, resp, &apiNL)
+
+ assert.Len(t, apiNL, 4)
+ assert.EqualValues(t, 5, apiNL[0].ID)
+ assert.True(t, apiNL[0].Unread)
+ assert.False(t, apiNL[0].Pinned)
+ assert.EqualValues(t, 4, apiNL[1].ID)
+ assert.True(t, apiNL[1].Unread)
+ assert.False(t, apiNL[1].Pinned)
+ assert.EqualValues(t, 3, apiNL[2].ID)
+ assert.False(t, apiNL[2].Unread)
+ assert.True(t, apiNL[2].Pinned)
+ assert.EqualValues(t, 2, apiNL[3].ID)
+ assert.False(t, apiNL[3].Unread)
+ assert.False(t, apiNL[3].Pinned)
+
+ //
+ // Notification ID 2 is the only one with status-type read & pinned
+ // change it to unread.
+ //
+ req = NewRequest(t, "PUT", "/api/v1/notifications?status-types=read&status-type=pinned&to-status=unread").
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusResetContent)
+ DecodeJSON(t, resp, &apiNL)
+ assert.Len(t, apiNL, 1)
+ assert.EqualValues(t, 2, apiNL[0].ID)
+ assert.True(t, apiNL[0].Unread)
+ assert.False(t, apiNL[0].Pinned)
+
+ //
+ // Now nofication ID 2 is the first in the list and is unread.
+ //
+ req = NewRequest(t, "GET", "/api/v1/notifications?all=true").
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiNL)
+
+ assert.Len(t, apiNL, 4)
+ assert.EqualValues(t, 2, apiNL[0].ID)
+ assert.True(t, apiNL[0].Unread)
+ assert.False(t, apiNL[0].Pinned)
+}
diff --git a/tests/integration/api_oauth2_apps_test.go b/tests/integration/api_oauth2_apps_test.go
new file mode 100644
index 0000000..85c7184
--- /dev/null
+++ b/tests/integration/api_oauth2_apps_test.go
@@ -0,0 +1,175 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestOAuth2Application(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testAPICreateOAuth2Application(t)
+ testAPIListOAuth2Applications(t)
+ testAPIGetOAuth2Application(t)
+ testAPIUpdateOAuth2Application(t)
+ testAPIDeleteOAuth2Application(t)
+}
+
+func testAPICreateOAuth2Application(t *testing.T) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ appBody := api.CreateOAuth2ApplicationOptions{
+ Name: "test-app-1",
+ RedirectURIs: []string{
+ "http://www.google.com",
+ },
+ ConfidentialClient: true,
+ }
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var createdApp *api.OAuth2Application
+ DecodeJSON(t, resp, &createdApp)
+
+ assert.EqualValues(t, appBody.Name, createdApp.Name)
+ assert.Len(t, createdApp.ClientSecret, 56)
+ assert.Len(t, createdApp.ClientID, 36)
+ assert.True(t, createdApp.ConfidentialClient)
+ assert.NotEmpty(t, createdApp.Created)
+ assert.EqualValues(t, appBody.RedirectURIs[0], createdApp.RedirectURIs[0])
+ unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{UID: user.ID, Name: createdApp.Name})
+}
+
+func testAPIListOAuth2Applications(t *testing.T) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
+
+ existApp := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{
+ UID: user.ID,
+ Name: "test-app-1",
+ RedirectURIs: []string{
+ "http://www.google.com",
+ },
+ ConfidentialClient: true,
+ })
+
+ req := NewRequest(t, "GET", "/api/v1/user/applications/oauth2").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var appList api.OAuth2ApplicationList
+ DecodeJSON(t, resp, &appList)
+ expectedApp := appList[0]
+
+ assert.EqualValues(t, expectedApp.Name, existApp.Name)
+ assert.EqualValues(t, expectedApp.ClientID, existApp.ClientID)
+ assert.Equal(t, expectedApp.ConfidentialClient, existApp.ConfidentialClient)
+ assert.Len(t, expectedApp.ClientID, 36)
+ assert.Empty(t, expectedApp.ClientSecret)
+ assert.EqualValues(t, expectedApp.RedirectURIs[0], existApp.RedirectURIs[0])
+ unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ID: expectedApp.ID, Name: expectedApp.Name})
+}
+
+func testAPIDeleteOAuth2Application(t *testing.T) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+
+ oldApp := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{
+ UID: user.ID,
+ Name: "test-app-1",
+ })
+
+ urlStr := fmt.Sprintf("/api/v1/user/applications/oauth2/%d", oldApp.ID)
+ req := NewRequest(t, "DELETE", urlStr).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ unittest.AssertNotExistsBean(t, &auth_model.OAuth2Application{UID: oldApp.UID, Name: oldApp.Name})
+
+ // Delete again will return not found
+ req = NewRequest(t, "DELETE", urlStr).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+}
+
+func testAPIGetOAuth2Application(t *testing.T) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
+
+ existApp := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{
+ UID: user.ID,
+ Name: "test-app-1",
+ RedirectURIs: []string{
+ "http://www.google.com",
+ },
+ ConfidentialClient: true,
+ })
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/applications/oauth2/%d", existApp.ID)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var app api.OAuth2Application
+ DecodeJSON(t, resp, &app)
+ expectedApp := app
+
+ assert.EqualValues(t, expectedApp.Name, existApp.Name)
+ assert.EqualValues(t, expectedApp.ClientID, existApp.ClientID)
+ assert.Equal(t, expectedApp.ConfidentialClient, existApp.ConfidentialClient)
+ assert.Len(t, expectedApp.ClientID, 36)
+ assert.Empty(t, expectedApp.ClientSecret)
+ assert.Len(t, expectedApp.RedirectURIs, 1)
+ assert.EqualValues(t, expectedApp.RedirectURIs[0], existApp.RedirectURIs[0])
+ unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ID: expectedApp.ID, Name: expectedApp.Name})
+}
+
+func testAPIUpdateOAuth2Application(t *testing.T) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ existApp := unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{
+ UID: user.ID,
+ Name: "test-app-1",
+ RedirectURIs: []string{
+ "http://www.google.com",
+ },
+ })
+
+ appBody := api.CreateOAuth2ApplicationOptions{
+ Name: "test-app-1",
+ RedirectURIs: []string{
+ "http://www.google.com/",
+ "http://www.github.com/",
+ },
+ ConfidentialClient: true,
+ }
+
+ urlStr := fmt.Sprintf("/api/v1/user/applications/oauth2/%d", existApp.ID)
+ req := NewRequestWithJSON(t, "PATCH", urlStr, &appBody).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var app api.OAuth2Application
+ DecodeJSON(t, resp, &app)
+ expectedApp := app
+
+ assert.Len(t, expectedApp.RedirectURIs, 2)
+ assert.EqualValues(t, expectedApp.RedirectURIs[0], appBody.RedirectURIs[0])
+ assert.EqualValues(t, expectedApp.RedirectURIs[1], appBody.RedirectURIs[1])
+ assert.Equal(t, expectedApp.ConfidentialClient, appBody.ConfidentialClient)
+ unittest.AssertExistsAndLoadBean(t, &auth_model.OAuth2Application{ID: expectedApp.ID, Name: expectedApp.Name})
+}
diff --git a/tests/integration/api_org_avatar_test.go b/tests/integration/api_org_avatar_test.go
new file mode 100644
index 0000000..bbe116c
--- /dev/null
+++ b/tests/integration/api_org_avatar_test.go
@@ -0,0 +1,77 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "encoding/base64"
+ "net/http"
+ "os"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIUpdateOrgAvatar(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
+
+ // Test what happens if you use a valid image
+ avatar, err := os.ReadFile("tests/integration/avatar.png")
+ require.NoError(t, err)
+ if err != nil {
+ assert.FailNow(t, "Unable to open avatar.png")
+ }
+
+ opts := api.UpdateUserAvatarOption{
+ Image: base64.StdEncoding.EncodeToString(avatar),
+ }
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/avatar", &opts).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Test what happens if you don't have a valid Base64 string
+ opts = api.UpdateUserAvatarOption{
+ Image: "Invalid",
+ }
+
+ req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/avatar", &opts).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ // Test what happens if you use a file that is not an image
+ text, err := os.ReadFile("tests/integration/README.md")
+ require.NoError(t, err)
+ if err != nil {
+ assert.FailNow(t, "Unable to open README.md")
+ }
+
+ opts = api.UpdateUserAvatarOption{
+ Image: base64.StdEncoding.EncodeToString(text),
+ }
+
+ req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/org3/avatar", &opts).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusInternalServerError)
+}
+
+func TestAPIDeleteOrgAvatar(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
+
+ req := NewRequest(t, "DELETE", "/api/v1/orgs/org3/avatar").
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+}
diff --git a/tests/integration/api_org_test.go b/tests/integration/api_org_test.go
new file mode 100644
index 0000000..70d3a44
--- /dev/null
+++ b/tests/integration/api_org_test.go
@@ -0,0 +1,228 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ org_model "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIOrgCreate(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteOrganization)
+
+ org := api.CreateOrgOption{
+ UserName: "user1_org",
+ FullName: "User1's organization",
+ Description: "This organization created by user1",
+ Website: "https://try.gitea.io",
+ Location: "Shanghai",
+ Visibility: "limited",
+ }
+ req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &org).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var apiOrg api.Organization
+ DecodeJSON(t, resp, &apiOrg)
+
+ assert.Equal(t, org.UserName, apiOrg.Name)
+ assert.Equal(t, org.FullName, apiOrg.FullName)
+ assert.Equal(t, org.Description, apiOrg.Description)
+ assert.Equal(t, org.Website, apiOrg.Website)
+ assert.Equal(t, org.Location, apiOrg.Location)
+ assert.Equal(t, org.Visibility, apiOrg.Visibility)
+
+ unittest.AssertExistsAndLoadBean(t, &user_model.User{
+ Name: org.UserName,
+ LowerName: strings.ToLower(org.UserName),
+ FullName: org.FullName,
+ })
+
+ // Check owner team permission
+ ownerTeam, _ := org_model.GetOwnerTeam(db.DefaultContext, apiOrg.ID)
+
+ for _, ut := range unit_model.AllRepoUnitTypes {
+ up := perm.AccessModeOwner
+ if ut == unit_model.TypeExternalTracker || ut == unit_model.TypeExternalWiki {
+ up = perm.AccessModeRead
+ }
+ unittest.AssertExistsAndLoadBean(t, &org_model.TeamUnit{
+ OrgID: apiOrg.ID,
+ TeamID: ownerTeam.ID,
+ Type: ut,
+ AccessMode: up,
+ })
+ }
+
+ req = NewRequestf(t, "GET", "/api/v1/orgs/%s", org.UserName).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiOrg)
+ assert.EqualValues(t, org.UserName, apiOrg.Name)
+
+ req = NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", org.UserName).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ var repos []*api.Repository
+ DecodeJSON(t, resp, &repos)
+ for _, repo := range repos {
+ assert.False(t, repo.Private)
+ }
+
+ req = NewRequestf(t, "GET", "/api/v1/orgs/%s/members", org.UserName).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ // user1 on this org is public
+ var users []*api.User
+ DecodeJSON(t, resp, &users)
+ assert.Len(t, users, 1)
+ assert.EqualValues(t, "user1", users[0].UserName)
+ })
+}
+
+func TestAPIOrgEdit(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ session := loginUser(t, "user1")
+
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
+ org := api.EditOrgOption{
+ FullName: "Org3 organization new full name",
+ Description: "A new description",
+ Website: "https://try.gitea.io/new",
+ Location: "Beijing",
+ Visibility: "private",
+ }
+ req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiOrg api.Organization
+ DecodeJSON(t, resp, &apiOrg)
+
+ assert.Equal(t, "org3", apiOrg.Name)
+ assert.Equal(t, org.FullName, apiOrg.FullName)
+ assert.Equal(t, org.Description, apiOrg.Description)
+ assert.Equal(t, org.Website, apiOrg.Website)
+ assert.Equal(t, org.Location, apiOrg.Location)
+ assert.Equal(t, org.Visibility, apiOrg.Visibility)
+ })
+}
+
+func TestAPIOrgEditBadVisibility(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ session := loginUser(t, "user1")
+
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
+ org := api.EditOrgOption{
+ FullName: "Org3 organization new full name",
+ Description: "A new description",
+ Website: "https://try.gitea.io/new",
+ Location: "Beijing",
+ Visibility: "badvisibility",
+ }
+ req := NewRequestWithJSON(t, "PATCH", "/api/v1/orgs/org3", &org).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+}
+
+func TestAPIOrgDeny(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ setting.Service.RequireSignInView = true
+ defer func() {
+ setting.Service.RequireSignInView = false
+ }()
+
+ orgName := "user1_org"
+ req := NewRequestf(t, "GET", "/api/v1/orgs/%s", orgName)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", orgName)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequestf(t, "GET", "/api/v1/orgs/%s/members", orgName)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+}
+
+func TestAPIGetAll(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadOrganization)
+
+ // accessing with a token will return all orgs
+ req := NewRequest(t, "GET", "/api/v1/orgs").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiOrgList []*api.Organization
+
+ DecodeJSON(t, resp, &apiOrgList)
+ assert.Len(t, apiOrgList, 12)
+ assert.Equal(t, "Limited Org 36", apiOrgList[1].FullName)
+ assert.Equal(t, "limited", apiOrgList[1].Visibility)
+
+ // accessing without a token will return only public orgs
+ req = NewRequest(t, "GET", "/api/v1/orgs")
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &apiOrgList)
+ assert.Len(t, apiOrgList, 8)
+ assert.Equal(t, "org 17", apiOrgList[0].FullName)
+ assert.Equal(t, "public", apiOrgList[0].Visibility)
+}
+
+func TestAPIOrgSearchEmptyTeam(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteOrganization)
+ orgName := "org_with_empty_team"
+
+ // create org
+ req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", &api.CreateOrgOption{
+ UserName: orgName,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ // create team with no member
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", orgName), &api.CreateTeamOption{
+ Name: "Empty",
+ IncludesAllRepositories: true,
+ Permission: "read",
+ Units: []string{"repo.code", "repo.issues", "repo.ext_issues", "repo.wiki", "repo.pulls"},
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ // case-insensitive search for teams that have no members
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/orgs/%s/teams/search?q=%s", orgName, "empty")).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ data := struct {
+ Ok bool
+ Data []*api.Team
+ }{}
+ DecodeJSON(t, resp, &data)
+ assert.True(t, data.Ok)
+ if assert.Len(t, data.Data, 1) {
+ assert.EqualValues(t, "Empty", data.Data[0].Name)
+ }
+ })
+}
diff --git a/tests/integration/api_packages_alpine_test.go b/tests/integration/api_packages_alpine_test.go
new file mode 100644
index 0000000..2264625
--- /dev/null
+++ b/tests/integration/api_packages_alpine_test.go
@@ -0,0 +1,502 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/tar"
+ "bufio"
+ "bytes"
+ "compress/gzip"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ alpine_module "code.gitea.io/gitea/modules/packages/alpine"
+ alpine_service "code.gitea.io/gitea/services/packages/alpine"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageAlpine(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ packageName := "gitea-test"
+ packageVersion := "1.4.1-r3"
+
+ base64AlpinePackageContent := `H4sIAAAAAAACA9ML9nT30wsKdtTLzjNJzjYuckjPLElN1DUzMUxMNTa11CsqTtQrKE1ioAAYAIGZ
+iQmYBgJ02hDENjQxMTAzMzQ1MTVjMDA0MTQ1ZlAwYKADKC0uSSxSUGAYoWDm4sZZtypv75+q2fVT
+POD1bKkFB22ms+g1z+H4dk7AhC3HwUSj9EbT0Rk3Dn55dHxy/K7Q+Nl/i+L7Z036ypcRvvpZuMiN
+s7wbZL/klqRGGshv9Gi0qHTgTZfw3HytnJdx9c3NTRp/PHn+Z50uq2pjkilzjtpfd+uzQMw1M7cY
+i9RXJasnT2M+vDXCesLK7MilJt8sGplj4xUlLMUun9SzY+phFpxWxRXa06AseV9WvzH3jtGGoL5A
+vQkea+VKPj5R+Cb461tIk97qpa9nJYsJujTNl2B/J1P52H/D2rPr/j19uU8p7cMSq5tmXk51ReXl
+F/Yddr9XsMpEwFKlXSPo3QSGwnCOG8y2uadjm6ui998WYXNYubjg78N3a7bnXjhrl5fB8voI++LI
+1FP5W44e2xf4Ou2wrtyic1Onz7MzMV5ksuno2V/LVG4eN/15X/n2/2vJ2VV+T68aT327dOrhd6e6
+q5Y0V82Y83tdqkFa8TW2BvGCZ0ds/iibHVpzKuPcuSULO63/bNmfrnhjWqXzhMSXTb5Cv4vPaxSL
+8LFMdqmxbN7+Y+Yi0ZyZhz4UxexLuHHFd1VFvk+kwvniq3P+f9rh52InWnL8Lpvedcecoh1GFSc5
+xZ9VBGex2V269HZfwxSVCvP35wQfi2xKX+lYMXtF48n1R65O2PLWpm69RdESMa79dlrTGazsZacu
+MbMLeSSScPORZde76/MBV6SFJAAEAAAfiwgAAAAAAAID7VRLaxsxEN6zfoUgZ++OVq+1aUIhUDeY
+pKa49FhmJdkW3ofRysXpr69220t9SCk0gZJ+IGaY56eBmbxY4/m9Q+vCUOTr1fLu4d2H7O8CEpQQ
+k0y4lAClypgQoBSTQqoMGBMgMnrOXgCnIWJIVLLXCcaoib5110CSij/V7D9eCZ5p5f9o/5VkF/tf
+MqUzCi+5/6Hv41Nxv/Nffu4fwRVdus4FjM7S+pFiffKNpTxnkMMsALmin5PnHgMtS8rkgvGFBPpp
+c0tLKDk5HnYdto5e052PDmfRDXE0fnUh2VgucjYLU5h1g0mm5RhGNymMrtEccOfIKTTJsY/xOCyK
+YqqT+74gExWbmI2VlJ6LeQUcyPFH2lh/9SBuV/wjfXPohDnw8HZKviGD/zYmCZgrgsHsk36u1Bcl
+SB/8zne/0jV92/qYbKRF38X0niiemN2QxhvXDWOL+7tNGhGeYt+m22mwaR6pddGZNM8FSeRxj8PY
+X7PaqdqAVlqWXHKnmQGmK43VlqNlILRilbBSMI2jV5Vbu5XGSVsDyGc7yd8B/gK2qgAIAAAfiwgA
+AAAAAAID7dNNSgMxGAbg7MSCOxcu5wJOv0x+OlkU7K5QoYXqVsxMMihlKMwP1Fu48QQewCN4DfEQ
+egUz4sYuFKEtFN9n870hWSSQN+7P7GrsrfNV3Y9dW5Z3bNMo0FJ+zmB9EhcJ41KS1lxJpRnxbsWi
+FduBtm5sFa7C/ifOo7y5Lf2QeiHar6jTaDSbnF5Mp+fzOL/x+aJuy3g+HvGhs8JY4b3yOpMZOZEo
+lRW+MEoTTw3ZwqU0INNjsAe2VPk/9b/L3/s/kIKzqOtk+IbJGTtmr+bx7WoxOUoun98frk/un14O
+Djfa/2q5bH4699v++uMAAAAAAAAAAAAAAAAAAAAAAHbgA/eXQh8AKAAA`
+ content, err := base64.StdEncoding.DecodeString(base64AlpinePackageContent)
+ require.NoError(t, err)
+
+ branches := []string{"v3.16", "v3.17", "v3.18"}
+ repositories := []string{"main", "testing"}
+
+ rootURL := fmt.Sprintf("/api/packages/%s/alpine", user.Name)
+
+ t.Run("RepositoryKey", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", rootURL+"/key")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "application/x-pem-file", resp.Header().Get("Content-Type"))
+ assert.Contains(t, resp.Body.String(), "-----BEGIN PUBLIC KEY-----")
+ })
+
+ for _, branch := range branches {
+ for _, repository := range repositories {
+ t.Run(fmt.Sprintf("[Branch:%s,Repository:%s]", branch, repository), func(t *testing.T) {
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadURL := fmt.Sprintf("%s/%s/%s", rootURL, branch, repository)
+
+ req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{}))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeAlpine)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.Nil(t, pd.SemVer)
+ assert.IsType(t, &alpine_module.VersionMetadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.NotEmpty(t, pfs)
+ assert.Condition(t, func() bool {
+ seen := false
+ expectedFilename := fmt.Sprintf("%s-%s.apk", packageName, packageVersion)
+ expectedCompositeKey := fmt.Sprintf("%s|%s|x86_64", branch, repository)
+ for _, pf := range pfs {
+ if pf.Name == expectedFilename && pf.CompositeKey == expectedCompositeKey {
+ if seen {
+ return false
+ }
+ seen = true
+
+ assert.True(t, pf.IsLead)
+
+ pfps, err := packages.GetProperties(db.DefaultContext, packages.PropertyTypeFile, pf.ID)
+ require.NoError(t, err)
+
+ for _, pfp := range pfps {
+ switch pfp.Name {
+ case alpine_module.PropertyBranch:
+ assert.Equal(t, branch, pfp.Value)
+ case alpine_module.PropertyRepository:
+ assert.Equal(t, repository, pfp.Value)
+ case alpine_module.PropertyArchitecture:
+ assert.Equal(t, "x86_64", pfp.Value)
+ }
+ }
+ }
+ }
+ return seen
+ })
+ })
+
+ readIndexContent := func(r io.Reader) (string, error) {
+ br := bufio.NewReader(r)
+
+ gzr, err := gzip.NewReader(br)
+ if err != nil {
+ return "", err
+ }
+
+ for {
+ gzr.Multistream(false)
+
+ tr := tar.NewReader(gzr)
+ for {
+ hd, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return "", err
+ }
+
+ if hd.Name == alpine_service.IndexFilename {
+ buf, err := io.ReadAll(tr)
+ if err != nil {
+ return "", err
+ }
+
+ return string(buf), nil
+ }
+ }
+
+ err = gzr.Reset(br)
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return "", err
+ }
+ }
+
+ return "", io.EOF
+ }
+
+ t.Run("Index", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ url := fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, repository)
+
+ req := NewRequest(t, "GET", url)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ content, err := readIndexContent(resp.Body)
+ require.NoError(t, err)
+
+ assert.Contains(t, content, "C:Q1/se1PjO94hYXbfpNR1/61hVORIc=\n")
+ assert.Contains(t, content, "P:"+packageName+"\n")
+ assert.Contains(t, content, "V:"+packageVersion+"\n")
+ assert.Contains(t, content, "A:x86_64\n")
+ assert.NotContains(t, content, "A:noarch\n")
+ assert.Contains(t, content, "T:Gitea Test Package\n")
+ assert.Contains(t, content, "U:https://gitea.io/\n")
+ assert.Contains(t, content, "L:MIT\n")
+ assert.Contains(t, content, "S:1353\n")
+ assert.Contains(t, content, "I:4096\n")
+ assert.Contains(t, content, "o:gitea-test\n")
+ assert.Contains(t, content, "m:KN4CK3R <kn4ck3r@gitea.io>\n")
+ assert.Contains(t, content, "t:1679498030\n")
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion))
+ MakeRequest(t, req, http.StatusOK)
+ })
+ })
+ }
+ }
+
+ t.Run("Delete", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ for _, branch := range branches {
+ for _, repository := range repositories {
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageName, packageVersion)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Deleting the last file of an architecture should remove that index
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, repository))
+ MakeRequest(t, req, http.StatusNotFound)
+ }
+ }
+ })
+}
+
+func TestPackageAlpineNoArch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ packageNames := []string{"forgejo-noarch-test", "forgejo-noarch-test-openrc"}
+ packageVersion := "1.0.0-r0"
+
+ base64AlpinePackageContent := `H4sIAAAAAAACA9ML9nT30wsKdtSryMxLrExJLdIrKk7UKyhNYqAaMAACMxMTMA0E6LQhiG1oYmpm
+ZGhqZGBkzmBgaGRsaM6gYMBAB1BaXJJYpKDAMEKBxuPYoD/96z0zNn4N0464vt6n6JW44rN8ppVn
+DtjwvbEVF3xzIV5uT1rSlI7S7Qq75j/z29q5ZoeawuaviYKTK/cYCX/Zuzhi1h1Pjk31NWyJfvts
+665n++ytWf6aoSylw+xYXv57tTdHPt7duGfS0oS+N8E/XVXnSqueii/8FKF6XURDXyj8gv27ORk8
+v8M8znXGXNze/lzwlKyTuXqc6svbH/7u6pv0uHGrjcEavud5PL8krmQ4bn3zt3Jeh9y6WTJfvcLn
+5uy9s9vFyqSHh1dZiCOwqVCjg3nljDWs/06eTfQSuslTeE9pRUP1u6Yq69LDUxvenFmadW5y5cYN
+P/+IMJx/pb8hNvDKimVlKT2dLlZNkkq+Z9eytdhlWjakXP/JMe15zOc1s9+4G4RMf33E/kzF55Lz
+za7vP9cb8FkL6W3mvfYvzf7LjB1/8pes7NSOzzu6q17GSuZKmuA8fpFnpWuTVjst73gqctl1V6eW
+irR9av9Rqcb4Lwyx34xDtWoTTvYvCdfxL3+hyNu2p1550dcEjZrJvKX7d9+wNmpJelfAuvKnzeXe
+SvUbyuybQs4eefFb/IVlnFXkjyY7ma6tz3Rlrnw6nl2tXdg9o2wW26GTrm9nLvE0Xrj5XM9MVuFM
+rhrGubNe8O4JrW12cTJaaLTreWXyep2Pb4/f49oQkFu67neQ0t4lt2uyXZQ+bn1dEeKy/L3292cA
+2zwJWgAEAAAfiwgAAAAAAAID7VVdr9MwDO1zfkWkPa9L2vRjFUMgpA0Egklwn5GbpF1Ym0xpOnb5
+9bjbxMOVACHBldDuqSo7sWufOLIbL7Zweq1BaT8s4u3bzZv36w/R3wVD5EKcJeKhZCzJIy6yPOFZ
+wpIiYpynRRbRU/QIGIcAHqlEtwnOqQym1ytGUIWrGj3hRvCPWv5P+p/j86D/WcHyiLLH7H/vXPiV
+3+/s/ylmdKOt9hC0ovU9hXo0naJpzJOYzT0jMzoOxra0gb2eSkCP+KMwzlIep0nM0f5xtHSta4rj
+g4uKZRUqd59eUbxKQQ771kKv6Yo2zrf6i5tbB17u5kEPYbJiODTymF3S4Y7Sg8St9cWfzhJepNTr
+g3dqxFHlLBl9hw67EA5DtVhcA8coyJm9wsNMMQtW5DlLOScHkHtoz5nu7N66r5YM5tvklDBRMjIx
+wsWFGnHetMb+hLJ0fW8CGkkPxgZ8z2FfdvoEVnmNWq+NAvqsNeEFOLgsY/zuOemM1HaY8m62744p
+Fg/G4HqcuhK67p4qHbTEm6gInvZosBLoKntVXZl8nmqx+lEsPCjsYJioC2A1k1m25KWq67JcJk2u
+5FJKIZXgItWsgbqsdZoL1bAmF0wsVV4XDVcNB8ieJv6N4jubJ8CtAAoAAB+LCAAAAAAAAgPt1r9K
+w0AcB/CbO3eWWBcX2/ufdChYBCkotFChiyDX5GqrrZGkha5uPoe4+AC+gG/j4OrstTjUgErRRku/
+nyVHEkjg8v3+Uq60zLRhTWSTtDJJE7IC1NFSzo9O9kgp14RJpTlTnHKfUMaoEMSbkhxM0rFJ3KuQ
+zcSYF44HI1ujBbc070sCG8JFvrLqZ8wi7iv1ef7d+mP+qRSMeCrP/CdxPP7qvu+ur/H+L0yA7uDq
+X/S/lBr9j/6HPPLvQr/SGbB8/zNO0f+57v/CDOjFybm9iM8480Uu/c8Ez+y/UAr//3/Y/zrw6q2j
+vZNm87hdDvs2vEwno3K7UWc1Iw1341kw21U26mkeBIFPlW+rmkktopAHTIWmihmyVvn/9dAv0/8i
+8//Hqe9OebNMus+Q75Miub8rHmw9vrzu3l53ns1h7enm9AH9/3M72/PtT/uFgg37sVdq2OEw9jpx
+MoxKyDAAAAAAAAAAAADA2noDOINxQwAoAAA=`
+ content, err := base64.StdEncoding.DecodeString(base64AlpinePackageContent)
+ require.NoError(t, err)
+
+ packageContents := map[string][]byte{}
+ packageContents["forgejo-noarch-test"] = content
+
+ base64AlpinePackageContent = `H4sIAAAAAAACA9ML9nT30wsKdtSryMxLrExJLdIrKk7UKyhNYqAaMAACMxMTMA0E6LQhiG1oYmpm
+ZGhqZGBkzmBgaGRsaM6gYMBAB1BaXJJYpKDAMEJBV8/bw4880tiXWbav8ygSDheyNpq/YubDz3sy
+FI+wSHHGpNtx/wpYGTCzVFxz2/pdCvcWzJ3gY2k2L5I7dfvG43G+ja0KkSwPedaI8/05HFGq9ru0
+ye/lIfvchSobf0lGnFr8SWmnXR0DayuTQu70y3wRDR9ltIQ3OE6X2PZs2kv3tKerFs3YkL2XPyPx
+h8TGDV8EFtwLXO35KOdkp/yS817if/vC9/J1bfzBXa8Y8mBvzd0dP5p5HkprPls69e0d39anVa9a
++7n2P1Uw0fyoIcP5zn8NS+blmRzXrrxMNpR8Lif37J/GbDWDyocte6f/fjYz62Lw+hPxt7/buhkJ
+lJ742LRi+idxvn8B2tGB/Sotkle9Pb1XtJq912V6PHGSmWEie1WIeMvnY6pCPCt366o6uOSv7d4j
+0qv2j2vps3tsCw7NnjU/+ixj1aK+GQLWy2+elC1fuL3iQsmatsb6WbGqz2bEvdwzXWhi5lO7C24S
+TJt4jjBFff3Y++/P/NvhjakNT294TLnRJZrsHto4cYeSqlPsyhrPz/d0LmmbKeVu6CgMTNpuMl3U
+ceaNiqs/xFSevWlUeSl7JT9dTHVi8MqmwPTlXkXF0jGbfioscdJg/cTwa39/jPzxnJ9vw101502Y
+XXIpq0jgzsYT20SXnp5l2fZqF/MtG7mCju+uL9nO6Bro7taZnzJlyre/f9pP+Vb058+Sdv3zWHQD
+AJIwfO8ABAAAH4sIAAAAAAACA+1V3W/TMBDPs/+Kk/oCD03tfDhtRRFoUgsCsQrYM3KcS2qa2JHj
+jG1/Pdd24mGaQEgwCbafFN35fOf7cO4cz7bq6g2qCv0wi7fvNm8/rM+jPwtOkFl2pIS7lPNERiLL
+ZSLyhCdFxIVIizyCq+gBMA5BeQolepwQAnQwHa44I1bdstETHgn+Usv/Tv8LLsWd/ueFyCLgD9n/
+3rnwM71f7f+jmMAGLXoVsILyGlQ5mraCNBZJzKeeswmMg7EN1GqPhxLAJT0UxlkQcZrEgvY/jRbW
+WAKND5Eteb4k5uLzGdBVZqzfN1Z1CCuonW/wq5tap7zeTQMOYep6tF4flOhU0hExP3klSYWDJtH6
+ZAaTRBQpeOy9q0aaWBTBs3My/3gGxpoAg/amD8NzNvqWzHYh9MNyNrv1GhNhx9QqyvTgqeCFlDwV
+gvVK71Vz9H9h99Z9s2wwN0clmc4zdgiXFqe4mfOmMfb+fJh2XUexrIB1ythA3/HY1y1eKVt5JK5D
+Uyl40ZjwSjl1WsZk95K1RqMdDn432/eXKTOW/sy2/WJqEp0qdZ/T1Y+iTUCNwXU0wzXZXUOFATXd
+65JR0mqnhkMai0xX1Iyasi8xSzGpy1woqoQUUhYokoVKRb6Qc6VLuShzFJmUtcwWRbGY10n69DT8
+X/gOnWH3xAAKAAAfiwgAAAAAAAID7dVNSsNAGAbg2bgwbnuAWDcKNpmZzCTpImAXhYJCC3Uv+Zlo
+lCSSHyiKK8G1C8/gGbyLp9ADiBN1UQsqRZNa+j2bGZJAApP3/TR95E4Gwg1Eluui8FENsGQy9rZK
+syvG1ESEcZMSTjG1ECbYsjhSJ6gBZV64mfwUtJoIUf0iioWDFbl1P7YIrAgZeb3ud1QRtzj/Ov9y
+P5N/wzKQypvMf5amxXfP/XR/ic9/agJESVRowcL7Xy7Q/9D/oJH8v4e+vjEwf/8TbmLo/4bPf2oM
+hGl2LE7TI0rkHK69/4lBZ86fVZeg/xfW/6at9kb7ncPh8GCs+SfCP8vLWBsPesTxbMZDEZIuDjzq
+W9xysWebmBuBbbgm44R1mWGHFGbIsuX/b0M/R/8Twj7nnxJS9X+VSfkb0j3UQg/9l6fbx93yYuNm
+zbm+77fu7Gfo/9/b2tRzL0r09Fwkmd/JykRR/DSO3SRw2nqZZ3p1d/rXaCtKIOTTwfaOeqmsJ0IE
+aiIK5QoSDwAAAAAAAAAAAAAAAP/IK49O1e8AKAAA`
+ content, err = base64.StdEncoding.DecodeString(base64AlpinePackageContent)
+ require.NoError(t, err)
+
+ packageContents["forgejo-noarch-test-openrc"] = content
+
+ branches := []string{"v3.16", "v3.17", "v3.18"}
+ repositories := []string{"main", "testing"}
+
+ rootURL := fmt.Sprintf("/api/packages/%s/alpine", user.Name)
+
+ t.Run("RepositoryKey", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", rootURL+"/key")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "application/x-pem-file", resp.Header().Get("Content-Type"))
+ assert.Contains(t, resp.Body.String(), "-----BEGIN PUBLIC KEY-----")
+ })
+
+ for _, branch := range branches {
+ for _, repository := range repositories {
+ t.Run(fmt.Sprintf("[Branch:%s,Repository:%s]", branch, repository), func(t *testing.T) {
+ for _, pkg := range packageNames {
+ t.Run(fmt.Sprintf("Upload[Package:%s]", pkg), func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadURL := fmt.Sprintf("%s/%s/%s", rootURL, branch, repository)
+
+ req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{}))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(packageContents[pkg])).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageName(db.DefaultContext, user.ID, packages.TypeAlpine, pkg)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.Nil(t, pd.SemVer)
+ assert.IsType(t, &alpine_module.VersionMetadata{}, pd.Metadata)
+ assert.Equal(t, pkg, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.NotEmpty(t, pfs)
+ assert.Condition(t, func() bool {
+ seen := false
+ expectedFilename := fmt.Sprintf("%s-%s.apk", pkg, packageVersion)
+ expectedCompositeKey := fmt.Sprintf("%s|%s|x86_64", branch, repository)
+ for _, pf := range pfs {
+ if pf.Name == expectedFilename && pf.CompositeKey == expectedCompositeKey {
+ if seen {
+ return false
+ }
+ seen = true
+
+ assert.True(t, pf.IsLead)
+
+ pfps, err := packages.GetProperties(db.DefaultContext, packages.PropertyTypeFile, pf.ID)
+ require.NoError(t, err)
+
+ for _, pfp := range pfps {
+ switch pfp.Name {
+ case alpine_module.PropertyBranch:
+ assert.Equal(t, branch, pfp.Value)
+ case alpine_module.PropertyRepository:
+ assert.Equal(t, repository, pfp.Value)
+ case alpine_module.PropertyArchitecture:
+ assert.Equal(t, "x86_64", pfp.Value)
+ }
+ }
+ }
+ }
+ return seen
+ })
+ })
+ }
+
+ t.Run("Index", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ url := fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, repository)
+
+ req := NewRequest(t, "GET", url)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Condition(t, func() bool {
+ br := bufio.NewReader(resp.Body)
+
+ gzr, err := gzip.NewReader(br)
+ require.NoError(t, err)
+
+ for {
+ gzr.Multistream(false)
+
+ tr := tar.NewReader(gzr)
+ for {
+ hd, err := tr.Next()
+ if err == io.EOF {
+ break
+ }
+ require.NoError(t, err)
+
+ if hd.Name == "APKINDEX" {
+ buf, err := io.ReadAll(tr)
+ require.NoError(t, err)
+
+ s := string(buf)
+
+ assert.Contains(t, s, "C:Q14rbX8G4tErQO98k5J4uHsNaoiqk=\n")
+ assert.Contains(t, s, "P:"+packageNames[0]+"\n")
+ assert.Contains(t, s, "V:"+packageVersion+"\n")
+ assert.Contains(t, s, "A:x86_64\n")
+ assert.Contains(t, s, "T:Forgejo #2173 reproduction\n")
+ assert.Contains(t, s, "U:https://forgejo.org\n")
+ assert.Contains(t, s, "L:GPLv3\n")
+ assert.Contains(t, s, "S:1508\n")
+ assert.Contains(t, s, "I:20480\n")
+ assert.Contains(t, s, "o:forgejo-noarch-test\n")
+ assert.Contains(t, s, "m:Alexandre Almeida <git@aoalmeida.com>\n")
+ assert.Contains(t, s, "t:1707660311\n")
+ assert.Contains(t, s, "p:cmd:forgejo_2173=1.0.0-r0")
+
+ assert.Contains(t, s, "C:Q1zTXZP03UbSled31mi4MXmsrgNQ4=\n")
+ assert.Contains(t, s, "P:"+packageNames[1]+"\n")
+ assert.Contains(t, s, "V:"+packageVersion+"\n")
+ assert.Contains(t, s, "A:x86_64\n")
+ assert.Contains(t, s, "T:Forgejo #2173 reproduction (OpenRC init scripts)\n")
+ assert.Contains(t, s, "U:https://forgejo.org\n")
+ assert.Contains(t, s, "L:GPLv3\n")
+ assert.Contains(t, s, "S:1569\n")
+ assert.Contains(t, s, "I:16384\n")
+ assert.Contains(t, s, "o:forgejo-noarch-test\n")
+ assert.Contains(t, s, "m:Alexandre Almeida <git@aoalmeida.com>\n")
+ assert.Contains(t, s, "t:1707660311\n")
+ assert.Contains(t, s, "i:openrc forgejo-noarch-test=1.0.0-r0")
+
+ return true
+ }
+ }
+
+ err = gzr.Reset(br)
+ if err == io.EOF {
+ break
+ }
+ require.NoError(t, err)
+ }
+
+ return false
+ })
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, packageNames[0], packageVersion))
+ MakeRequest(t, req, http.StatusOK)
+ })
+ })
+ }
+ }
+
+ t.Run("Delete", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ for _, branch := range branches {
+ for _, repository := range repositories {
+ for _, pkg := range packageNames {
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, pkg, packageVersion))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/x86_64/%s-%s.apk", rootURL, branch, repository, pkg, packageVersion)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+ }
+ // Deleting the last file of an architecture should remove that index
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/x86_64/APKINDEX.tar.gz", rootURL, branch, repository))
+ MakeRequest(t, req, http.StatusNotFound)
+ }
+ }
+ })
+}
diff --git a/tests/integration/api_packages_arch_test.go b/tests/integration/api_packages_arch_test.go
new file mode 100644
index 0000000..2cf0186
--- /dev/null
+++ b/tests/integration/api_packages_arch_test.go
@@ -0,0 +1,409 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/tar"
+ "bufio"
+ "bytes"
+ "compress/gzip"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "testing"
+ "testing/fstest"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ arch_model "code.gitea.io/gitea/modules/packages/arch"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/ProtonMail/go-crypto/openpgp/armor"
+ "github.com/ProtonMail/go-crypto/openpgp/packet"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageArch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ unPack := func(s string) []byte {
+ data, _ := base64.StdEncoding.DecodeString(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(s), "\n", ""), "\r", ""))
+ return data
+ }
+ rootURL := fmt.Sprintf("/api/packages/%s/arch", user.Name)
+
+ pkgs := map[string][]byte{
+ "any": unPack(`
+KLUv/QBYXRMABmOHSbCWag6dY6d8VNtVR3rpBnWdBbkDAxM38Dj3XG3FK01TCKlWtMV9QpskYdsm
+e6fh5gWqM8edeurYNESoIUz/RmtyQy68HVrBj1p+AIoAYABFSJh4jcDyWNQgHIKIuNgIll64S4oY
+FFIUk6vJQBMIIl2iYtIysqKWVYMCYvXDpAKTMzVGwZTUWhbciFCglIMH1QMbEtjHpohSi8XRYwPr
+AwACSy/fzxO1FobizlP7sFgHcpx90Pus94Edjcc9GOustbD3PBprLUxH50IGC1sfw31c7LOfT4Qe
+nh0KP1uKywwdPrRYmuyIkWBHRlcLfeBIDpKKqw44N0K2nNAfFW5grHRfSShyVgaEIZwIVVmFGL7O
+88XDE5whJm4NkwA91dRoPBCcrgqozKSyah1QygsWkCshAaYrvbHCFdUTJCOgBpeUTMuJJ6+SRtcj
+wIRua8mGJyg7qWoqJQq9z/4+DU1rHrEO8f6QZ3HUu3IM7GY37u+jeWjUu45637yN+qj338cdi0Uc
+y0a9a+e5//1cYnPUu37dxr15khzNQ9/PE80aC/1okjz9mGo3bqP5Ue+scflGshdzx2g28061k2PW
+uKwzjmV/XzTzzmKdcfz3eRbJoRPddcaP/n4PSZqQeYa1PDtPQzOHJK0amfjvz0IUV/v38xHJK/rz
+JtFpalPD30drDWi7Bl8NB3J/P3csijQyldWZ8gy3TNslLsozMw74DhoAXoAfnE8xydUUHPZ3hML4
+2zVDGiEXSGYRx4BKQDcDJA5S9Ca25FRgPtSWSowZJpJTYAR9WCPHUDgACm6+hBecGDPNClpwHZ2A
+EQ==
+`),
+ "x86_64": unPack(`
+KLUv/QBYnRMAFmOJS7BUbg7Un8q21hxCopsOMn6UGTzJRbHI753uOeMdxZ+V7ajoETVxl9CSBCR5
+2a3K1vr1gwyp9gCTH422bRNxHEg7Z0z9HV4rH/DGFn8AjABjAFQ2oaUVMRRGViVoqmxAVKuoKQVM
+NJRwTDl9NcHCClliWjTpWin6sRUZsXSipWlAipQnleThRgFF5QTAzpth0UPFkhQeJRnYOaqSScEC
+djCPDwE8pQTfVXW9F7bmznX3YTNZDeP7IHgxDazNQhp+UDa798KeRgvvvbCamgsYdL461TfvcmlY
+djFowWYH5yaH5ztZcemh4omAkm7iQIWvGypNIXJQNgc7DVuHjx06I4MZGTIkeEBIOIL0OxcvnGps
+0TwxycqKYESrwwQYEDKI2F0hNXH1/PCQ2BS4Ykki48EAaflAbRHxYrRQbdAZ4oXVAMGCkYOXkBRb
+NkwjNCoIF07ByTlyfJhmoHQtCbFYDN+941783KqzusznmPePXJPluS1+cL/74Rd/1UHluW15blFv
+ol6e+8XPPZNDPN/Kc9vOdX/xNZrT8twWnH34U9Xkqw76rqqrPjPQl6nJde9i74e/8Mtz6zOjT3R7
+Uve8BrabpT4zanE83158MtVbkxbH84vPNWkGqeu2OF704vfRzAGl6mhRtXPdmOrRzFla+BO+DL34
+uHHN9r74usjkduX5VEhNz9TnxV9trSabvYAwuIZffN0zSeZM3c3GUHX8dG6jeUgHGgBbgB9cUDHJ
+1RR09teBwvjbNUMaIRdIZhHHgEpANwMkDpL0JsbkVFA+0JZKjBkmklNgBH1YI8dQOAAKbr6EF5wY
+M80KWnAdnYAR
+`),
+ "aarch64": unPack(`
+KLUv/QBYdRQAVuSMS7BUbg7Un8q21hxCopsOMn6UGTzJRbHI753uOeMdxZ+V7ajoEbUkUXbXhXW/
+7FanWzv7B/EcMxhodFqyZkUcB9LOGVN/h9MqG7zFFmoAaQB8AEFrvpXntn3V/cXXaE7Lc9uP5uFP
+VXPl+ue7qnJ9Zp8vU3PVvYu9HvbAL8+tz4y+0O1J3TPXqbZ5l3+lapk5ee+L577qXvdf+Atn+P69
+4Qz8QhpYw4/xd78Q3/v6Wg28974u1Ojc2ODseAGpHs2crYG4kef84uNGnu198fWQuVq+8ymQmp5p
+z4vPbRjOaBC+FxziF1/3TJI5U3ezMlQdPZ3baA7SMhnMunvHvfg5rrO6zOeY94+rJstzW/zgetfD
+Lz7XP+W5bXluUW+hXp77xc89kwFRTF1PrKxAFpgXT7ZWhjzYjpRIStGyNCAGBYM6AnGrkKKCAmAH
+k3HBI8VyBBYdGdApmoqJYQE62EeIADCkBF1VOW0WYnz/+y6ufTMaDQ2GDDme7Wapz4xa3JpvLz6Z
+6q1Ji1vzi79q0vxR+ba4dejF76OZ80nV0aJqX3VjKCsuP1g0EWDSURyw0JVDZWlEzsnmYLdh8wDS
+I2dkIEMjxsSOiAlJjH4HIwbTjayZJidXVxKQYH2gICOCBhK7KqMlLZ4gMCU1BapYlsTAXnywepyy
+jMBmtEhxyCnCZdUAwYKxAxeRFVk4TCL0aYgWjt3kHTg9SjVStppI2YCSWshUEFGdmJmyCVGpnqIU
+KNlA0hEjIOACGSLqYpXAD5SSNVT2MJRJwREAF4FRHPBlCJMSNwFguGAWDJBg+KIArkIJGNtCydUL
+TuN1oBh/+zKkEblAsgjGqVgUwKLP+UOMOGCpAhICtg6ncFJH`),
+ "otherXZ": unPack(`
+/Td6WFoAAATm1rRGBMCyBIAYIQEWAAAAAAAAABaHRszgC/8CKl0AFxNGhTWwfXmuDQEJlHgNLrkq
+VxpJY6d9iRTt6gB4uCj0481rnYfXaUADHzOFuF3490RPrM6juPXrknqtVyuWJ5efW19BgwctN6xk
+UiXiZaXVAWVWJWy2XHJiyYCMWBfIjUfo1ccOgwolwgFHJ64ZJjbayA3k6lYPcImuAqYL5NEVHpwl
+Z8CWIjiXXSMQGsB3gxMdq9nySZbHQLK/KCKQ+oseF6kXyIgSEyuG4HhjVBBYIwTvWzI06kjNUXEy
+2sw0n50uocLSAwJ/3mdX3n3XF5nmmuQMPtFbdQgQtC2VhyVd3TdIF+pT6zAEzXFJJ3uLkNbKSS88
+ZdBny6X/ftT5lQpNi/Wg0xLEQA4m4fu4fRAR0kOKzHM2svNLbTxa/wOPidqPzR6b/jfKmHkXxBNa
+jFafty0a5K2S3F6JpwXZ2fqti/zG9NtMc+bbuXycC327EofXRXNtuOupELDD+ltTOIBF7CcTswyi
+MZDP1PBie6GqDV2GuPz+0XXmul/ds+XysG19HIkKbJ+cQKp5o7Y0tI7EHM8GhwMl7MjgpQGj5nuv
+0u2hqt4NXPNYqaMm9bFnnIUxEN82HgNWBcXf2baWKOdGzPzCuWg2fAM4zxHnBWcimxLXiJgaI8mU
+J/QqTPWE0nJf1PW/J9yFQVR1Xo0TJyiX8/ObwmbqUPpxRGjKlYRBvn0jbTdUAENBSn+QVcASRGFE
+SB9OM2B8Bg4jR/oojs8Beoq7zbIblgAAAACfRtXvhmznOgABzgSAGAAAKklb4rHEZ/sCAAAAAARZ
+Wg==`),
+ "otherZST": unPack(`
+KLUv/QRYbRMABuOHS9BSNQdQ56F+xNFoV3CijY54JYt3VqV1iUU3xmj00y2pyBOCuokbhDYpvNsj
+ZJeCxqH+nQFpMf4Wa92okaZoF4eH6HsXXCBo+qy3Fn4AigBgAEaYrLCQEuAom6YbHyuKZAFYksqi
+sSOFiRs0WDmlACk0CnpnaAeKiCS3BlwVkViJEbDS43lFNbLkZEmGhc305Nn4AMLGiUkBDiMTG5Vz
+q4ZISjCofEfR1NpXijvP2X95Hu1e+zLalc0+mjeT3Z/FPGvt62WymbX2dXMDIYKDLjjP8n03RrPf
+A1vOApwGOh2MgE2LpgZrgXLDF2CUJ15idG2J8GCSgcc2ZVRgA8+RHD0k2VJjg6mRUgGGhBWEyEcz
+5EePLhUeWlYhoFCKONxUiBiIUiQeDIqiQwkjLiyqnF5eGs6a2gGRapbU9JRyuXAlPemYajlJojJd
+GBBJjo5GxFRkITOAvLhSCr2TDz4uzdU8Yh3i/SHP4qh3vTG2s9198NP8M+pdR73BvIP6qPeDjzsW
+gTi+jXrXWOe5P/jZxOeod/287v6JljzNP99RNM0a+/x4ljz3LNV2t5v9qHfW2Pyg24u54zSfObWX
+Y9bYrCTHtwdfPPPOYiU5fvB5FssfNN2V5EIPfg9LnM+JhtVEO8+FZw5LXA068YNPhimu9sHPQiWv
+qc6fE9BTnxIe/LTKatab+WYu7T74uWNRxJW5W5Ux0bDLuG1ioCwjg4DvGgBcgB8cUDHJ1RQ89neE
+wvjbNUMiIZdo5hbHgEpANwMkDnL0Jr7kVFg+0pZKjBkmklNgBH1YI8dQOAAKbr6EF5wYM80KWnAd
+nYARrByncQ==`),
+ "otherGZ": unPack(`
+H4sIAAAAAAAAA9PzDQlydWWgKTAwMDAzMVEA0UCAThsYGBuZKRiamBmbm5qZGJqbKBgYGpobGzMo
+GNDWWRBQWlySWAR0SlF+fgk+dYTk0T03RIB8NweEwVx71tDviIFA60O75Rtc5s+9YbxteUHzhUWi
+HBkWDcbGcUqCukrLGi4Lv8jIqNsbXhueXW8uzTe79Lr9/TVbnl69c3wR652f21+7rnU5kmjTc/38
+8t+zLx/+ePFr6lajpZ2dzCkyB3NPTxdVOfFk2/RXmq+Ktq2dbnY6RcPCMW8Kg9aGszs1f6+YsTlf
+x5j5eIpXnXzStAbJvQvPP3su//3lu2/2pj++XO9hbJS+puPmqJKREff4X+RUqdYTbpGTBGYuefH9
+mNbGzKNdiUmS+xgt7J+5iTMObIgOLaAX4O3u6efmT0s7COV/UwNztPxvZGhqOpr/6QGUFdxT81KL
+EktSUxSSKhVyE7NTC7LTFcz0DPUMuJQVSosz89IV0oCiIP8rlKUWFWfm5ykY6hmbcgHV5SXmpirY
+KpSkFpcYgfhJicUIfkVKYkkikAcUL6ksSLUF0iA1QDOAgkDj9Qx0DUECKanFyVBNCgWJydmJ6alc
+pUU5QKGMkpKCYit9/dSKxNyCnFS95Pxcfa6k0sycFKDRIIsMzQ0tTS2NDSxMuKA6QWaH5mXn5Zfn
+KQRAhbiKM6tAqg24EouSM4CMxLxKrpzM5NQ8sGuTgUkgP5crOT8vDShYAhSpKs7gKijKL8sEOg2k
+HMhNSS1IzUsBcpJAPFAwwUXSM0u4BjoaR8EoGAWjgGQAAILFeyQADAAA
+`),
+ }
+
+ t.Run("RepositoryKey", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", rootURL+"/repository.key")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ require.Equal(t, "application/pgp-keys", resp.Header().Get("Content-Type"))
+ require.Contains(t, resp.Body.String(), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
+ })
+
+ for _, group := range []string{"", "arch", "arch/os", "x86_64"} {
+ groupURL := rootURL
+ if group != "" {
+ groupURL = groupURL + "/" + group
+ }
+ t.Run(fmt.Sprintf("Upload[%s]", group), func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs["any"]))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs["any"])).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequestWithBody(t, "PUT", groupURL, bytes.NewBuffer([]byte("any string"))).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeArch)
+ require.NoError(t, err)
+ require.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ require.Nil(t, pd.SemVer)
+ require.IsType(t, &arch_model.VersionMetadata{}, pd.Metadata)
+ require.Equal(t, "test", pd.Package.Name)
+ require.Equal(t, "1.0.0-1", pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ size := 0
+ for _, pf := range pfs {
+ if pf.CompositeKey == group {
+ size++
+ }
+ }
+ require.Equal(t, 2, size) // zst and zst.sig
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ require.Equal(t, int64(len(pkgs["any"])), pb.Size)
+
+ req = NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs["any"])).
+ AddBasicAuth(user.Name) // exists
+ MakeRequest(t, req, http.StatusConflict)
+ req = NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs["x86_64"])).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+ req = NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs["aarch64"])).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+ req = NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs["aarch64"])).
+ AddBasicAuth(user.Name) // exists again
+ MakeRequest(t, req, http.StatusConflict)
+ })
+
+ t.Run(fmt.Sprintf("Download[%s]", group), func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ req := NewRequest(t, "GET", groupURL+"/x86_64/test-1.0.0-1-x86_64.pkg.tar.zst")
+ resp := MakeRequest(t, req, http.StatusOK)
+ require.Equal(t, pkgs["x86_64"], resp.Body.Bytes())
+
+ req = NewRequest(t, "GET", groupURL+"/x86_64/test-1.0.0-1-any.pkg.tar.zst")
+ resp = MakeRequest(t, req, http.StatusOK)
+ require.Equal(t, pkgs["any"], resp.Body.Bytes())
+
+ // get other group
+ req = NewRequest(t, "GET", rootURL+"/unknown/x86_64/test-1.0.0-1-aarch64.pkg.tar.zst")
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run(fmt.Sprintf("SignVerify[%s]", group), func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ req := NewRequest(t, "GET", rootURL+"/repository.key")
+ respPub := MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", groupURL+"/x86_64/test-1.0.0-1-any.pkg.tar.zst")
+ respPkg := MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", groupURL+"/x86_64/test-1.0.0-1-any.pkg.tar.zst.sig")
+ respSig := MakeRequest(t, req, http.StatusOK)
+
+ if err := gpgVerify(respPub.Body.Bytes(), respSig.Body.Bytes(), respPkg.Body.Bytes()); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ t.Run(fmt.Sprintf("RepositoryDB[%s]", group), func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ req := NewRequest(t, "GET", rootURL+"/repository.key")
+ respPub := MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", groupURL+"/x86_64/base.db")
+ respPkg := MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", groupURL+"/x86_64/base.db.sig")
+ respSig := MakeRequest(t, req, http.StatusOK)
+
+ if err := gpgVerify(respPub.Body.Bytes(), respSig.Body.Bytes(), respPkg.Body.Bytes()); err != nil {
+ t.Fatal(err)
+ }
+ files, err := listTarGzFiles(respPkg.Body.Bytes())
+ require.NoError(t, err)
+ require.Len(t, files, 1)
+ for s, d := range files {
+ name := getProperty(string(d.Data), "NAME")
+ ver := getProperty(string(d.Data), "VERSION")
+ require.Equal(t, name+"-"+ver+"/desc", s)
+ fn := getProperty(string(d.Data), "FILENAME")
+ pgp := getProperty(string(d.Data), "PGPSIG")
+ req = NewRequest(t, "GET", groupURL+"/x86_64/"+fn+".sig")
+ respSig := MakeRequest(t, req, http.StatusOK)
+ decodeString, err := base64.StdEncoding.DecodeString(pgp)
+ require.NoError(t, err)
+ require.Equal(t, respSig.Body.Bytes(), decodeString)
+ }
+ })
+
+ t.Run(fmt.Sprintf("Delete[%s]", group), func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ // test data
+ req := NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs["otherXZ"])).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequestWithBody(t, "DELETE", rootURL+"/base/notfound/1.0.0-1/any", nil).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequestWithBody(t, "DELETE", groupURL+"/test/1.0.0-1/x86_64", nil).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequestWithBody(t, "DELETE", groupURL+"/test/1.0.0-1/any", nil).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "GET", groupURL+"/x86_64/base.db")
+ respPkg := MakeRequest(t, req, http.StatusOK)
+ files, err := listTarGzFiles(respPkg.Body.Bytes())
+ require.NoError(t, err)
+ require.Len(t, files, 1)
+
+ req = NewRequestWithBody(t, "DELETE", groupURL+"/test2/1.0.0-1/any", nil).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "GET", groupURL+"/x86_64/base.db").
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequestWithBody(t, "DELETE", groupURL+"/test/1.0.0-1/aarch64", nil).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "GET", groupURL+"/aarch64/base.db").
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ for tp, key := range map[string]string{
+ "GZ": "otherGZ",
+ "XZ": "otherXZ",
+ "ZST": "otherZST",
+ } {
+ t.Run(fmt.Sprintf("Upload%s[%s]", tp, group), func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ req := NewRequestWithBody(t, "PUT", groupURL, bytes.NewReader(pkgs[key])).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequest(t, "GET", groupURL+"/x86_64/test2-1.0.0-1-any.pkg.tar."+strings.ToLower(tp))
+ resp := MakeRequest(t, req, http.StatusOK)
+ require.Equal(t, pkgs[key], resp.Body.Bytes())
+
+ req = NewRequestWithBody(t, "DELETE", groupURL+"/test2/1.0.0-1/any", nil).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+ })
+ }
+ }
+ t.Run("Concurrent Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ var wg sync.WaitGroup
+
+ targets := []string{"any", "aarch64", "x86_64"}
+ for _, tag := range targets {
+ wg.Add(1)
+ go func(i string) {
+ defer wg.Done()
+ req := NewRequestWithBody(t, "PUT", rootURL, bytes.NewReader(pkgs[i])).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+ }(tag)
+ }
+ wg.Wait()
+ for _, target := range targets {
+ req := NewRequestWithBody(t, "DELETE", rootURL+"/test/1.0.0-1/"+target, nil).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+ }
+ })
+}
+
+func getProperty(data, key string) string {
+ r := bufio.NewReader(strings.NewReader(data))
+ for {
+ line, _, err := r.ReadLine()
+ if err != nil {
+ return ""
+ }
+ if strings.Contains(string(line), "%"+key+"%") {
+ readLine, _, _ := r.ReadLine()
+ return string(readLine)
+ }
+ }
+}
+
+func listTarGzFiles(data []byte) (fstest.MapFS, error) {
+ reader, err := gzip.NewReader(bytes.NewBuffer(data))
+ if err != nil {
+ return nil, err
+ }
+ defer reader.Close()
+ tarRead := tar.NewReader(reader)
+ files := make(fstest.MapFS)
+ for {
+ cur, err := tarRead.Next()
+ if err == io.EOF {
+ break
+ } else if err != nil {
+ return nil, err
+ }
+ if cur.Typeflag != tar.TypeReg {
+ continue
+ }
+ data, err := io.ReadAll(tarRead)
+ if err != nil {
+ return nil, err
+ }
+ files[cur.Name] = &fstest.MapFile{Data: data}
+ }
+ return files, nil
+}
+
+func gpgVerify(pub, sig, data []byte) error {
+ sigPack, err := packet.Read(bytes.NewBuffer(sig))
+ if err != nil {
+ return err
+ }
+ signature, ok := sigPack.(*packet.Signature)
+ if !ok {
+ return errors.New("invalid sign key")
+ }
+ pubBlock, err := armor.Decode(bytes.NewReader(pub))
+ if err != nil {
+ return err
+ }
+ pack, err := packet.Read(pubBlock.Body)
+ if err != nil {
+ return err
+ }
+ publicKey, ok := pack.(*packet.PublicKey)
+ if !ok {
+ return errors.New("invalid public key")
+ }
+ hash := signature.Hash.New()
+ _, err = hash.Write(data)
+ if err != nil {
+ return err
+ }
+ return publicKey.VerifySignature(hash, signature)
+}
diff --git a/tests/integration/api_packages_cargo_test.go b/tests/integration/api_packages_cargo_test.go
new file mode 100644
index 0000000..7a9105e
--- /dev/null
+++ b/tests/integration/api_packages_cargo_test.go
@@ -0,0 +1,447 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "net/http"
+ neturl "net/url"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/json"
+ cargo_module "code.gitea.io/gitea/modules/packages/cargo"
+ "code.gitea.io/gitea/modules/setting"
+ cargo_router "code.gitea.io/gitea/routers/api/packages/cargo"
+ gitea_context "code.gitea.io/gitea/services/context"
+ cargo_service "code.gitea.io/gitea/services/packages/cargo"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageCargo(t *testing.T) {
+ onGiteaRun(t, testPackageCargo)
+}
+
+func testPackageCargo(t *testing.T, _ *neturl.URL) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ packageName := "cargo-package"
+ packageVersion := "1.0.3"
+ packageDescription := "Package Description"
+ packageAuthor := "KN4CK3R"
+ packageHomepage := "https://gitea.io/"
+ packageLicense := "MIT"
+
+ createPackage := func(name, version string) io.Reader {
+ metadata := `{
+ "name":"` + name + `",
+ "vers":"` + version + `",
+ "description":"` + packageDescription + `",
+ "authors": ["` + packageAuthor + `"],
+ "deps":[
+ {
+ "name":"dep",
+ "version_req":"1.0",
+ "registry": "https://gitea.io/user/_cargo-index",
+ "kind": "normal",
+ "default_features": true
+ }
+ ],
+ "homepage":"` + packageHomepage + `",
+ "license":"` + packageLicense + `"
+}`
+
+ var buf bytes.Buffer
+ binary.Write(&buf, binary.LittleEndian, uint32(len(metadata)))
+ buf.WriteString(metadata)
+ binary.Write(&buf, binary.LittleEndian, uint32(4))
+ buf.WriteString("test")
+ return &buf
+ }
+
+ err := cargo_service.InitializeIndexRepository(db.DefaultContext, user, user)
+ require.NoError(t, err)
+
+ repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, cargo_service.IndexRepositoryName)
+ assert.NotNil(t, repo)
+ require.NoError(t, err)
+
+ readGitContent := func(t *testing.T, path string) string {
+ gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
+ require.NoError(t, err)
+
+ blob, err := commit.GetBlobByPath(path)
+ require.NoError(t, err)
+
+ content, err := blob.GetBlobContent(1024)
+ require.NoError(t, err)
+
+ return content
+ }
+
+ root := fmt.Sprintf("%sapi/packages/%s/cargo", setting.AppURL, user.Name)
+ url := fmt.Sprintf("%s/api/v1/crates", root)
+
+ t.Run("Index", func(t *testing.T) {
+ t.Run("Git/Config", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ content := readGitContent(t, cargo_service.ConfigFileName)
+
+ var config cargo_service.Config
+ err := json.Unmarshal([]byte(content), &config)
+ require.NoError(t, err)
+
+ assert.Equal(t, url, config.DownloadURL)
+ assert.Equal(t, root, config.APIURL)
+ })
+
+ t.Run("HTTP/Config", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", root+"/"+cargo_service.ConfigFileName)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var config cargo_service.Config
+ err := json.Unmarshal(resp.Body.Bytes(), &config)
+ require.NoError(t, err)
+
+ assert.Equal(t, url, config.DownloadURL)
+ assert.Equal(t, root, config.APIURL)
+ })
+ })
+
+ t.Run("Upload", func(t *testing.T) {
+ t.Run("InvalidNameOrVersion", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ content := createPackage("0test", "1.0.0")
+
+ req := NewRequestWithBody(t, "PUT", url+"/new", content).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusBadRequest)
+
+ var status cargo_router.StatusResponse
+ DecodeJSON(t, resp, &status)
+ assert.False(t, status.OK)
+
+ content = createPackage("test", "-1.0.0")
+
+ req = NewRequestWithBody(t, "PUT", url+"/new", content).
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusBadRequest)
+
+ DecodeJSON(t, resp, &status)
+ assert.False(t, status.OK)
+ })
+
+ t.Run("InvalidContent", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ metadata := `{"name":"test","vers":"1.0.0"}`
+
+ var buf bytes.Buffer
+ binary.Write(&buf, binary.LittleEndian, uint32(len(metadata)))
+ buf.WriteString(metadata)
+ binary.Write(&buf, binary.LittleEndian, uint32(4))
+ buf.WriteString("te")
+
+ req := NewRequestWithBody(t, "PUT", url+"/new", &buf).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", url+"/new", createPackage(packageName, packageVersion))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "PUT", url+"/new", createPackage(packageName, packageVersion)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var status cargo_router.StatusResponse
+ DecodeJSON(t, resp, &status)
+ assert.True(t, status.OK)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeCargo)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.NotNil(t, pd.SemVer)
+ assert.IsType(t, &cargo_module.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, fmt.Sprintf("%s-%s.crate", packageName, packageVersion), pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ assert.EqualValues(t, 4, pb.Size)
+
+ req = NewRequestWithBody(t, "PUT", url+"/new", createPackage(packageName, packageVersion)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusConflict)
+
+ t.Run("Index", func(t *testing.T) {
+ t.Run("Git", func(t *testing.T) {
+ t.Run("Entry", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ content := readGitContent(t, cargo_service.BuildPackagePath(packageName))
+
+ var entry cargo_service.IndexVersionEntry
+ err := json.Unmarshal([]byte(content), &entry)
+ require.NoError(t, err)
+
+ assert.Equal(t, packageName, entry.Name)
+ assert.Equal(t, packageVersion, entry.Version)
+ assert.Equal(t, pb.HashSHA256, entry.FileChecksum)
+ assert.False(t, entry.Yanked)
+ assert.Len(t, entry.Dependencies, 1)
+ dep := entry.Dependencies[0]
+ assert.Equal(t, "dep", dep.Name)
+ assert.Equal(t, "1.0", dep.Req)
+ assert.Equal(t, "normal", dep.Kind)
+ assert.True(t, dep.DefaultFeatures)
+ assert.Empty(t, dep.Features)
+ assert.False(t, dep.Optional)
+ assert.Nil(t, dep.Target)
+ assert.NotNil(t, dep.Registry)
+ assert.Equal(t, "https://gitea.io/user/_cargo-index", *dep.Registry)
+ assert.Nil(t, dep.Package)
+ })
+
+ t.Run("Rebuild", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ err := cargo_service.RebuildIndex(db.DefaultContext, user, user)
+ require.NoError(t, err)
+
+ _ = readGitContent(t, cargo_service.BuildPackagePath(packageName))
+ })
+ })
+
+ t.Run("HTTP", func(t *testing.T) {
+ t.Run("Entry", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", root+"/"+cargo_service.BuildPackagePath(packageName))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var entry cargo_service.IndexVersionEntry
+ err := json.Unmarshal(resp.Body.Bytes(), &entry)
+ require.NoError(t, err)
+
+ assert.Equal(t, packageName, entry.Name)
+ assert.Equal(t, packageVersion, entry.Version)
+ assert.Equal(t, pb.HashSHA256, entry.FileChecksum)
+ assert.False(t, entry.Yanked)
+ assert.Len(t, entry.Dependencies, 1)
+ dep := entry.Dependencies[0]
+ assert.Equal(t, "dep", dep.Name)
+ assert.Equal(t, "1.0", dep.Req)
+ assert.Equal(t, "normal", dep.Kind)
+ assert.True(t, dep.DefaultFeatures)
+ assert.Empty(t, dep.Features)
+ assert.False(t, dep.Optional)
+ assert.Nil(t, dep.Target)
+ assert.NotNil(t, dep.Registry)
+ assert.Equal(t, "https://gitea.io/user/_cargo-index", *dep.Registry)
+ assert.Nil(t, dep.Package)
+ })
+ })
+ })
+ })
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeCargo, packageName, packageVersion)
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, pv.DownloadCount)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pv.ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/download", url, neturl.PathEscape(packageName), neturl.PathEscape(pv.Version))).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "test", resp.Body.String())
+
+ pv, err = packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeCargo, packageName, packageVersion)
+ require.NoError(t, err)
+ assert.EqualValues(t, 1, pv.DownloadCount)
+ })
+
+ t.Run("Search", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ cases := []struct {
+ Query string
+ Page int
+ PerPage int
+ ExpectedTotal int64
+ ExpectedResults int
+ }{
+ {"", 0, 0, 1, 1},
+ {"", 1, 10, 1, 1},
+ {"cargo", 1, 0, 1, 1},
+ {"cargo", 1, 10, 1, 1},
+ {"cargo", 2, 10, 1, 0},
+ {"test", 0, 10, 0, 0},
+ }
+
+ for i, c := range cases {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s?q=%s&page=%d&per_page=%d", url, c.Query, c.Page, c.PerPage)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result cargo_router.SearchResult
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, c.ExpectedTotal, result.Meta.Total, "case %d: unexpected total hits", i)
+ assert.Len(t, result.Crates, c.ExpectedResults, "case %d: unexpected result count", i)
+ }
+ })
+
+ t.Run("Yank", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s/yank", url, neturl.PathEscape(packageName), neturl.PathEscape(packageVersion))).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var status cargo_router.StatusResponse
+ DecodeJSON(t, resp, &status)
+ assert.True(t, status.OK)
+
+ content := readGitContent(t, cargo_service.BuildPackagePath(packageName))
+
+ var entry cargo_service.IndexVersionEntry
+ err := json.Unmarshal([]byte(content), &entry)
+ require.NoError(t, err)
+
+ assert.True(t, entry.Yanked)
+ })
+
+ t.Run("Unyank", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", fmt.Sprintf("%s/%s/%s/unyank", url, neturl.PathEscape(packageName), neturl.PathEscape(packageVersion))).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var status cargo_router.StatusResponse
+ DecodeJSON(t, resp, &status)
+ assert.True(t, status.OK)
+
+ content := readGitContent(t, cargo_service.BuildPackagePath(packageName))
+
+ var entry cargo_service.IndexVersionEntry
+ err := json.Unmarshal([]byte(content), &entry)
+ require.NoError(t, err)
+
+ assert.False(t, entry.Yanked)
+ })
+
+ t.Run("ListOwners", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/owners", url, neturl.PathEscape(packageName)))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var owners cargo_router.Owners
+ DecodeJSON(t, resp, &owners)
+
+ assert.Len(t, owners.Users, 1)
+ assert.Equal(t, user.ID, owners.Users[0].ID)
+ assert.Equal(t, user.Name, owners.Users[0].Login)
+ assert.Equal(t, user.DisplayName(), owners.Users[0].Name)
+ })
+}
+
+func TestRebuildCargo(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *neturl.URL) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user.Name)
+ unittest.AssertExistsIf(t, false, &repo_model.Repository{OwnerID: user.ID, Name: cargo_service.IndexRepositoryName})
+
+ t.Run("No index", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user/settings/packages/cargo/rebuild", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings/packages"),
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.EqualValues(t, "error%3DCannot%2Brebuild%252C%2Bno%2Bindex%2Bis%2Binitialized.", flashCookie.Value)
+ unittest.AssertExistsIf(t, false, &repo_model.Repository{OwnerID: user.ID, Name: cargo_service.IndexRepositoryName})
+ })
+
+ t.Run("Initialize Cargo", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user/settings/packages")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, `form[action="/user/settings/packages/cargo/rebuild"]`, false)
+ htmlDoc.AssertElement(t, `form[action="/user/settings/packages/cargo/initialize"]`, true)
+
+ req = NewRequestWithValues(t, "POST", "/user/settings/packages/cargo/initialize", map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ unittest.AssertExistsIf(t, true, &repo_model.Repository{OwnerID: user.ID, Name: cargo_service.IndexRepositoryName})
+
+ req = NewRequest(t, "GET", "/user/settings/packages")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, `form[action="/user/settings/packages/cargo/rebuild"]`, true)
+ htmlDoc.AssertElement(t, `form[action="/user/settings/packages/cargo/initialize"]`, false)
+ })
+
+ t.Run("With index", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user/settings/packages/cargo/rebuild", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings/packages"),
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.EqualValues(t, "success%3DThe%2BCargo%2Bindex%2Bwas%2Bsuccessfully%2Brebuild.", flashCookie.Value)
+ })
+ })
+}
diff --git a/tests/integration/api_packages_chef_test.go b/tests/integration/api_packages_chef_test.go
new file mode 100644
index 0000000..febb1a8
--- /dev/null
+++ b/tests/integration/api_packages_chef_test.go
@@ -0,0 +1,562 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha1"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/pem"
+ "fmt"
+ "hash"
+ "math/big"
+ "mime/multipart"
+ "net/http"
+ "path"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ chef_module "code.gitea.io/gitea/modules/packages/chef"
+ "code.gitea.io/gitea/modules/setting"
+ chef_router "code.gitea.io/gitea/routers/api/packages/chef"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageChef(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ privPem := `-----BEGIN RSA PRIVATE KEY-----
+MIIEpQIBAAKCAQEAtWp2PZz4TSU5A6ixw41HdbfBuGJwPuTtrsdoUf0DQ0/DJBNP
+qOCBAgEu6ZdUqIbWJ5Da+nevjtncy5hENdi6XrXjyzlUxghMuXjE5SeLGpgfQvkq
+bTkYaFpMe8PTzNeze3fei8+Eu6mzeb6g1GrqXznuPIc7bNss0w5iX9RiBM9dWPuX
+onx9xSEy0LYqJm7yXmshNe1aRwkjG/y5C26BzBFnMKp9YRTua0DO1WqLNhcaRnda
+lIFYouDNVTbwxSlYL16bZVoebqzZvLGrPvZJkPuCu6vH9brvOuYo0q8hLVNkBeXc
+imRpsDjLhQYzEJjoMTbaiVGnjBky+PWNiofJnwIDAQABAoIBAQCotF1KxLt/ejr/
+9ROCh9JJXV3v6tL5GgkSPOv9Oq2bHgSZer/cixJNW+5VWd5nbiSe3K1WuJBw5pbW
+Wj4sWORPiRRR+3mjQzqeS/nGJDTOwWJo9K8IrUzOVhLEEYLX/ksxaXJyT8PehFyb
+vbNwdhCIB6ZNcXDItTWE+95twWJ5lxAIj2dNwZZni3UkwwjYnCnqFtvHCKOg0NH2
+RjQcFYmu3fncNeqLezUSdVyRyXxSCHsUdlYeX/e44StCnXdrmLUHlb2P27ZVdPGh
+SW7qTUPpmJKekYiRPOpTLj+ZKXIsANkyWO+7dVtZLBm5bIyAsmp0W/DmK+wRsejj
+alFbIsh5AoGBANJr7HSG695wkfn+kvu/V8qHbt+KDv4WjWHjGRsUqvxoHOUNkQmW
+vZWdk4gjHYn1l+QHWmoOE3AgyqtCZ4bFILkZPLN/F8Mh3+r4B0Ac4biJJt7XGMNQ
+Nv4wsk7TR7CCARsjO7GP1PT60hpjMvYmc1E36gNM7QIZE9jBE+L8eWYtAoGBANy2
+JOAWf+QeBlur6o9feH76cEmpQzUUq4Lj9mmnXgIirSsFoBnDb8VA6Ws+ltL9U9H2
+vaCoaTyi9twW9zWj+Ywg2mVR5nlSAPfdlTWS1GLUbDotlj5apc/lvnGuNlWzN+I4
+Tu64hhgBXqGvRZ0o7HzFodqRAkpVXp6CQCqBM7p7AoGAIgO0K3oL8t87ma/fTra1
+mFWgRJ5qogQ/Qo2VZ11F7ptd4GD7CxPE/cSFLsKOadi7fu75XJ994OhMGrcXSR/g
+lEtSFqn6y15UdgU2FtUUX+I72FXo+Nmkqh5xFHDu68d4Kkzdv2xCvn81K3LRsByz
+E3P4biQnQ+mN3cIIVu79KNkCgYEAm6uctrEn4y2KLn5DInyj8GuTZ2ELFhVOIzPG
+SR7TH451tTJyiblezDHMcOfkWUx0IlN1zCr8jtgiZXmNQzg0erFxWKU7ebZtGGYh
+J3g4dLx+2Unt/mzRJqFUgbnueOO/Nr+gbJ+ZdLUCmeeVohOLOTXrws0kYGl2Izab
+K1+VrKECgYEAxQohoOegA0f4mofisXItbwwqTIX3bLpxBc4woa1sB4kjNrLo4slc
+qtWZGVlRxwBvQUg0cYj+xtr5nyBdHLy0qwX/kMq4GqQnvW6NqsbrP3MjCZ8NX/Sj
+A2W0jx50Hs/XNw6IZFLYgWVoOzCaD+jYFpHhzUZyQD6/rYhwhHrNQmU=
+-----END RSA PRIVATE KEY-----`
+
+ tmp, _ := pem.Decode([]byte(privPem))
+ privKey, _ := x509.ParsePKCS1PrivateKey(tmp.Bytes)
+
+ pubPem := `-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtWp2PZz4TSU5A6ixw41H
+dbfBuGJwPuTtrsdoUf0DQ0/DJBNPqOCBAgEu6ZdUqIbWJ5Da+nevjtncy5hENdi6
+XrXjyzlUxghMuXjE5SeLGpgfQvkqbTkYaFpMe8PTzNeze3fei8+Eu6mzeb6g1Grq
+XznuPIc7bNss0w5iX9RiBM9dWPuXonx9xSEy0LYqJm7yXmshNe1aRwkjG/y5C26B
+zBFnMKp9YRTua0DO1WqLNhcaRndalIFYouDNVTbwxSlYL16bZVoebqzZvLGrPvZJ
+kPuCu6vH9brvOuYo0q8hLVNkBeXcimRpsDjLhQYzEJjoMTbaiVGnjBky+PWNiofJ
+nwIDAQAB
+-----END PUBLIC KEY-----`
+
+ err := user_model.SetUserSetting(db.DefaultContext, user.ID, chef_module.SettingPublicPem, pubPem)
+ require.NoError(t, err)
+
+ t.Run("Authenticate", func(t *testing.T) {
+ auth := &chef_router.Auth{}
+
+ t.Run("MissingUser", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "POST", "/dummy")
+ u, err := auth.Verify(req.Request, nil, nil, nil)
+ assert.Nil(t, u)
+ require.NoError(t, err)
+ })
+
+ t.Run("NotExistingUser", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "POST", "/dummy").
+ SetHeader("X-Ops-Userid", "not-existing-user")
+ u, err := auth.Verify(req.Request, nil, nil, nil)
+ assert.Nil(t, u)
+ require.Error(t, err)
+ })
+
+ t.Run("Timestamp", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "POST", "/dummy").
+ SetHeader("X-Ops-Userid", user.Name)
+ u, err := auth.Verify(req.Request, nil, nil, nil)
+ assert.Nil(t, u)
+ require.Error(t, err)
+
+ req.SetHeader("X-Ops-Timestamp", "2023-01-01T00:00:00Z")
+ u, err = auth.Verify(req.Request, nil, nil, nil)
+ assert.Nil(t, u)
+ require.Error(t, err)
+ })
+
+ t.Run("SigningVersion", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "POST", "/dummy").
+ SetHeader("X-Ops-Userid", user.Name).
+ SetHeader("X-Ops-Timestamp", time.Now().UTC().Format(time.RFC3339))
+ u, err := auth.Verify(req.Request, nil, nil, nil)
+ assert.Nil(t, u)
+ require.Error(t, err)
+
+ req.SetHeader("X-Ops-Sign", "version=none")
+ u, err = auth.Verify(req.Request, nil, nil, nil)
+ assert.Nil(t, u)
+ require.Error(t, err)
+
+ req.SetHeader("X-Ops-Sign", "version=1.4")
+ u, err = auth.Verify(req.Request, nil, nil, nil)
+ assert.Nil(t, u)
+ require.Error(t, err)
+
+ req.SetHeader("X-Ops-Sign", "version=1.0;algorithm=sha2")
+ u, err = auth.Verify(req.Request, nil, nil, nil)
+ assert.Nil(t, u)
+ require.Error(t, err)
+
+ req.SetHeader("X-Ops-Sign", "version=1.0;algorithm=sha256")
+ u, err = auth.Verify(req.Request, nil, nil, nil)
+ assert.Nil(t, u)
+ require.Error(t, err)
+ })
+
+ t.Run("SignedHeaders", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ ts := time.Now().UTC().Format(time.RFC3339)
+
+ req := NewRequest(t, "POST", "/dummy").
+ SetHeader("X-Ops-Userid", user.Name).
+ SetHeader("X-Ops-Timestamp", ts).
+ SetHeader("X-Ops-Sign", "version=1.0;algorithm=sha1").
+ SetHeader("X-Ops-Content-Hash", "unused").
+ SetHeader("X-Ops-Authorization-4", "dummy")
+ u, err := auth.Verify(req.Request, nil, nil, nil)
+ assert.Nil(t, u)
+ require.Error(t, err)
+
+ signRequest := func(rw *RequestWrapper, version string) {
+ req := rw.Request
+ username := req.Header.Get("X-Ops-Userid")
+ if version != "1.0" && version != "1.3" {
+ sum := sha1.Sum([]byte(username))
+ username = base64.StdEncoding.EncodeToString(sum[:])
+ }
+
+ req.Header.Set("X-Ops-Sign", "version="+version)
+
+ var data []byte
+ if version == "1.3" {
+ data = []byte(fmt.Sprintf(
+ "Method:%s\nPath:%s\nX-Ops-Content-Hash:%s\nX-Ops-Sign:version=%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s\nX-Ops-Server-API-Version:%s",
+ req.Method,
+ path.Clean(req.URL.Path),
+ req.Header.Get("X-Ops-Content-Hash"),
+ version,
+ req.Header.Get("X-Ops-Timestamp"),
+ username,
+ req.Header.Get("X-Ops-Server-Api-Version"),
+ ))
+ } else {
+ sum := sha1.Sum([]byte(path.Clean(req.URL.Path)))
+ data = []byte(fmt.Sprintf(
+ "Method:%s\nHashed Path:%s\nX-Ops-Content-Hash:%s\nX-Ops-Timestamp:%s\nX-Ops-UserId:%s",
+ req.Method,
+ base64.StdEncoding.EncodeToString(sum[:]),
+ req.Header.Get("X-Ops-Content-Hash"),
+ req.Header.Get("X-Ops-Timestamp"),
+ username,
+ ))
+ }
+
+ for k := range req.Header {
+ if strings.HasPrefix(k, "X-Ops-Authorization-") {
+ req.Header.Del(k)
+ }
+ }
+
+ var signature []byte
+ if version == "1.3" || version == "1.2" {
+ var h hash.Hash
+ var ch crypto.Hash
+ if version == "1.3" {
+ h = sha256.New()
+ ch = crypto.SHA256
+ } else {
+ h = sha1.New()
+ ch = crypto.SHA1
+ }
+ h.Write(data)
+
+ signature, _ = rsa.SignPKCS1v15(rand.Reader, privKey, ch, h.Sum(nil))
+ } else {
+ c := new(big.Int).SetBytes(data)
+ m := new(big.Int).Exp(c, privKey.D, privKey.N)
+
+ signature = m.Bytes()
+ }
+
+ enc := base64.StdEncoding.EncodeToString(signature)
+
+ const chunkSize = 60
+ chunks := make([]string, 0, (len(enc)-1)/chunkSize+1)
+ currentLen := 0
+ currentStart := 0
+ for i := range enc {
+ if currentLen == chunkSize {
+ chunks = append(chunks, enc[currentStart:i])
+ currentLen = 0
+ currentStart = i
+ }
+ currentLen++
+ }
+ chunks = append(chunks, enc[currentStart:])
+
+ for i, chunk := range chunks {
+ req.Header.Set(fmt.Sprintf("X-Ops-Authorization-%d", i+1), chunk)
+ }
+ }
+
+ for _, v := range []string{"1.0", "1.1", "1.2", "1.3"} {
+ t.Run(v, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ signRequest(req, v)
+ u, err = auth.Verify(req.Request, nil, nil, nil)
+ assert.NotNil(t, u)
+ require.NoError(t, err)
+ })
+ }
+ })
+ })
+
+ packageName := "test"
+ packageVersion := "1.0.1"
+ packageDescription := "Test Description"
+ packageAuthor := "KN4CK3R"
+
+ root := fmt.Sprintf("/api/packages/%s/chef/api/v1", user.Name)
+
+ uploadPackage := func(t *testing.T, version string, expectedStatus int) {
+ var body bytes.Buffer
+ mpw := multipart.NewWriter(&body)
+ part, _ := mpw.CreateFormFile("tarball", fmt.Sprintf("%s.tar.gz", version))
+ zw := gzip.NewWriter(part)
+ tw := tar.NewWriter(zw)
+
+ content := `{"name":"` + packageName + `","version":"` + version + `","description":"` + packageDescription + `","maintainer":"` + packageAuthor + `"}`
+
+ hdr := &tar.Header{
+ Name: packageName + "/metadata.json",
+ Mode: 0o600,
+ Size: int64(len(content)),
+ }
+ tw.WriteHeader(hdr)
+ tw.Write([]byte(content))
+
+ tw.Close()
+ zw.Close()
+ mpw.Close()
+
+ req := NewRequestWithBody(t, "POST", root+"/cookbooks", &body).
+ SetHeader("Content-Type", mpw.FormDataContentType()).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, expectedStatus)
+ }
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "POST", root+"/cookbooks", bytes.NewReader([]byte{}))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ uploadPackage(t, packageVersion, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeChef)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.NotNil(t, pd.SemVer)
+ assert.IsType(t, &chef_module.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, fmt.Sprintf("%s.tar.gz", packageVersion), pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ uploadPackage(t, packageVersion, http.StatusConflict)
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks/%s/versions/%s/download", root, packageName, packageVersion))
+ MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("Universe", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", root+"/universe")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type VersionInfo struct {
+ LocationType string `json:"location_type"`
+ LocationPath string `json:"location_path"`
+ DownloadURL string `json:"download_url"`
+ Dependencies map[string]string `json:"dependencies"`
+ }
+
+ var result map[string]map[string]*VersionInfo
+ DecodeJSON(t, resp, &result)
+
+ assert.Len(t, result, 1)
+ assert.Contains(t, result, packageName)
+
+ versions := result[packageName]
+
+ assert.Len(t, versions, 1)
+ assert.Contains(t, versions, packageVersion)
+
+ info := versions[packageVersion]
+
+ assert.Equal(t, "opscode", info.LocationType)
+ assert.Equal(t, setting.AppURL+root[1:], info.LocationPath)
+ assert.Equal(t, fmt.Sprintf("%s%s/cookbooks/%s/versions/%s/download", setting.AppURL, root[1:], packageName, packageVersion), info.DownloadURL)
+ })
+
+ t.Run("Search", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ cases := []struct {
+ Query string
+ Start int
+ Items int
+ ExpectedTotal int
+ ExpectedResults int
+ }{
+ {"", 0, 0, 1, 1},
+ {"", 0, 10, 1, 1},
+ {"gitea", 0, 10, 0, 0},
+ {"test", 0, 10, 1, 1},
+ {"test", 1, 10, 1, 0},
+ }
+
+ type Item struct {
+ CookbookName string `json:"cookbook_name"`
+ CookbookMaintainer string `json:"cookbook_maintainer"`
+ CookbookDescription string `json:"cookbook_description"`
+ Cookbook string `json:"cookbook"`
+ }
+
+ type Result struct {
+ Start int `json:"start"`
+ Total int `json:"total"`
+ Items []*Item `json:"items"`
+ }
+
+ for i, c := range cases {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/search?q=%s&start=%d&items=%d", root, c.Query, c.Start, c.Items)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result Result
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, c.ExpectedTotal, result.Total, "case %d: unexpected total hits", i)
+ assert.Len(t, result.Items, c.ExpectedResults, "case %d: unexpected result count", i)
+
+ if len(result.Items) == 1 {
+ item := result.Items[0]
+ assert.Equal(t, packageName, item.CookbookName)
+ assert.Equal(t, packageAuthor, item.CookbookMaintainer)
+ assert.Equal(t, packageDescription, item.CookbookDescription)
+ assert.Equal(t, fmt.Sprintf("%s%s/cookbooks/%s", setting.AppURL, root[1:], packageName), item.Cookbook)
+ }
+ }
+ })
+
+ t.Run("EnumeratePackages", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ cases := []struct {
+ Sort string
+ Start int
+ Items int
+ ExpectedTotal int
+ ExpectedResults int
+ }{
+ {"", 0, 0, 1, 1},
+ {"", 0, 10, 1, 1},
+ {"RECENTLY_ADDED", 0, 10, 1, 1},
+ {"RECENTLY_UPDATED", 0, 10, 1, 1},
+ {"", 1, 10, 1, 0},
+ }
+
+ type Item struct {
+ CookbookName string `json:"cookbook_name"`
+ CookbookMaintainer string `json:"cookbook_maintainer"`
+ CookbookDescription string `json:"cookbook_description"`
+ Cookbook string `json:"cookbook"`
+ }
+
+ type Result struct {
+ Start int `json:"start"`
+ Total int `json:"total"`
+ Items []*Item `json:"items"`
+ }
+
+ for i, c := range cases {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks?start=%d&items=%d&sort=%s", root, c.Start, c.Items, c.Sort)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result Result
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, c.ExpectedTotal, result.Total, "case %d: unexpected total hits", i)
+ assert.Len(t, result.Items, c.ExpectedResults, "case %d: unexpected result count", i)
+
+ if len(result.Items) == 1 {
+ item := result.Items[0]
+ assert.Equal(t, packageName, item.CookbookName)
+ assert.Equal(t, packageAuthor, item.CookbookMaintainer)
+ assert.Equal(t, packageDescription, item.CookbookDescription)
+ assert.Equal(t, fmt.Sprintf("%s%s/cookbooks/%s", setting.AppURL, root[1:], packageName), item.Cookbook)
+ }
+ }
+ })
+
+ t.Run("PackageMetadata", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks/%s", root, packageName))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type Result struct {
+ Name string `json:"name"`
+ Maintainer string `json:"maintainer"`
+ Description string `json:"description"`
+ Category string `json:"category"`
+ LatestVersion string `json:"latest_version"`
+ SourceURL string `json:"source_url"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Deprecated bool `json:"deprecated"`
+ Versions []string `json:"versions"`
+ }
+
+ var result Result
+ DecodeJSON(t, resp, &result)
+
+ versionURL := fmt.Sprintf("%s%s/cookbooks/%s/versions/%s", setting.AppURL, root[1:], packageName, packageVersion)
+
+ assert.Equal(t, packageName, result.Name)
+ assert.Equal(t, packageAuthor, result.Maintainer)
+ assert.Equal(t, packageDescription, result.Description)
+ assert.Equal(t, versionURL, result.LatestVersion)
+ assert.False(t, result.Deprecated)
+ assert.ElementsMatch(t, []string{versionURL}, result.Versions)
+ })
+
+ t.Run("PackageVersionMetadata", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/cookbooks/%s/versions/%s", root, packageName, packageVersion))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type Result struct {
+ Version string `json:"version"`
+ TarballFileSize int64 `json:"tarball_file_size"`
+ PublishedAt time.Time `json:"published_at"`
+ Cookbook string `json:"cookbook"`
+ File string `json:"file"`
+ License string `json:"license"`
+ Dependencies map[string]string `json:"dependencies"`
+ }
+
+ var result Result
+ DecodeJSON(t, resp, &result)
+
+ packageURL := fmt.Sprintf("%s%s/cookbooks/%s", setting.AppURL, root[1:], packageName)
+
+ assert.Equal(t, packageVersion, result.Version)
+ assert.Equal(t, packageURL, result.Cookbook)
+ assert.Equal(t, fmt.Sprintf("%s/versions/%s/download", packageURL, packageVersion), result.File)
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ uploadPackage(t, "1.0.2", http.StatusCreated)
+ uploadPackage(t, "1.0.3", http.StatusCreated)
+
+ t.Run("Version", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s/versions/%s", root, packageName, "1.0.2"))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s/versions/%s", root, packageName, "1.0.2")).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusOK)
+
+ pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeChef, packageName, "1.0.2")
+ assert.Nil(t, pv)
+ require.Error(t, err)
+ })
+
+ t.Run("Package", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s", root, packageName))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/cookbooks/%s", root, packageName)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusOK)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeChef)
+ require.NoError(t, err)
+ assert.Empty(t, pvs)
+ })
+ })
+}
diff --git a/tests/integration/api_packages_composer_test.go b/tests/integration/api_packages_composer_test.go
new file mode 100644
index 0000000..9d25cc4
--- /dev/null
+++ b/tests/integration/api_packages_composer_test.go
@@ -0,0 +1,222 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/zip"
+ "bytes"
+ "fmt"
+ "net/http"
+ neturl "net/url"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ composer_module "code.gitea.io/gitea/modules/packages/composer"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/routers/api/packages/composer"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageComposer(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ vendorName := "gitea"
+ projectName := "composer-package"
+ packageName := vendorName + "/" + projectName
+ packageVersion := "1.0.3"
+ packageDescription := "Package Description"
+ packageType := "composer-plugin"
+ packageAuthor := "Gitea Authors"
+ packageLicense := "MIT"
+ packageBin := "./bin/script"
+
+ var buf bytes.Buffer
+ archive := zip.NewWriter(&buf)
+ w, _ := archive.Create("composer.json")
+ w.Write([]byte(`{
+ "name": "` + packageName + `",
+ "description": "` + packageDescription + `",
+ "type": "` + packageType + `",
+ "license": "` + packageLicense + `",
+ "authors": [
+ {
+ "name": "` + packageAuthor + `"
+ }
+ ],
+ "bin": [
+ "` + packageBin + `"
+ ]
+ }`))
+ archive.Close()
+ content := buf.Bytes()
+
+ url := fmt.Sprintf("%sapi/packages/%s/composer", setting.AppURL, user.Name)
+
+ t.Run("ServiceIndex", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/packages.json", url)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result composer.ServiceIndexResponse
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, url+"/search.json?q=%query%&type=%type%", result.SearchTemplate)
+ assert.Equal(t, url+"/p2/%package%.json", result.MetadataTemplate)
+ assert.Equal(t, url+"/list.json", result.PackageList)
+ })
+
+ t.Run("Upload", func(t *testing.T) {
+ t.Run("MissingVersion", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+ })
+
+ t.Run("Valid", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadURL := url + "?version=" + packageVersion
+
+ req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeComposer)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.NotNil(t, pd.SemVer)
+ assert.IsType(t, &composer_module.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, fmt.Sprintf("%s-%s.%s.zip", vendorName, projectName, packageVersion), pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(content)), pb.Size)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusConflict)
+ })
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeComposer)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+ assert.Equal(t, int64(0), pvs[0].DownloadCount)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/files/%s/%s/%s", url, neturl.PathEscape(packageName), neturl.PathEscape(pvs[0].LowerVersion), neturl.PathEscape(pfs[0].LowerName))).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, content, resp.Body.Bytes())
+
+ pvs, err = packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeComposer)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+ assert.Equal(t, int64(1), pvs[0].DownloadCount)
+ })
+
+ t.Run("SearchService", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ cases := []struct {
+ Query string
+ Type string
+ Page int
+ PerPage int
+ ExpectedTotal int64
+ ExpectedResults int
+ }{
+ {"", "", 0, 0, 1, 1},
+ {"", "", 1, 1, 1, 1},
+ {"test", "", 1, 0, 0, 0},
+ {"gitea", "", 1, 1, 1, 1},
+ {"gitea", "", 2, 1, 1, 0},
+ {"", packageType, 1, 1, 1, 1},
+ {"gitea", packageType, 1, 1, 1, 1},
+ {"gitea", "dummy", 1, 1, 0, 0},
+ }
+
+ for i, c := range cases {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/search.json?q=%s&type=%s&page=%d&per_page=%d", url, c.Query, c.Type, c.Page, c.PerPage)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result composer.SearchResultResponse
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, c.ExpectedTotal, result.Total, "case %d: unexpected total hits", i)
+ assert.Len(t, result.Results, c.ExpectedResults, "case %d: unexpected result count", i)
+ }
+ })
+
+ t.Run("EnumeratePackages", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", url+"/list.json").
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result map[string][]string
+ DecodeJSON(t, resp, &result)
+
+ assert.Contains(t, result, "packageNames")
+ names := result["packageNames"]
+ assert.Len(t, names, 1)
+ assert.Equal(t, packageName, names[0])
+ })
+
+ t.Run("PackageMetadata", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/p2/%s/%s.json", url, vendorName, projectName)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result composer.PackageMetadataResponse
+ DecodeJSON(t, resp, &result)
+
+ assert.Contains(t, result.Packages, packageName)
+ pkgs := result.Packages[packageName]
+ assert.Len(t, pkgs, 1)
+ assert.Equal(t, packageName, pkgs[0].Name)
+ assert.Equal(t, packageVersion, pkgs[0].Version)
+ assert.Equal(t, packageType, pkgs[0].Type)
+ assert.Equal(t, packageDescription, pkgs[0].Description)
+ assert.Len(t, pkgs[0].Authors, 1)
+ assert.Equal(t, packageAuthor, pkgs[0].Authors[0].Name)
+ assert.Equal(t, "zip", pkgs[0].Dist.Type)
+ assert.Equal(t, "4f5fa464c3cb808a1df191dbf6cb75363f8b7072", pkgs[0].Dist.Checksum)
+ assert.Len(t, pkgs[0].Bin, 1)
+ assert.Equal(t, packageBin, pkgs[0].Bin[0])
+ })
+}
diff --git a/tests/integration/api_packages_conan_test.go b/tests/integration/api_packages_conan_test.go
new file mode 100644
index 0000000..9d8f435
--- /dev/null
+++ b/tests/integration/api_packages_conan_test.go
@@ -0,0 +1,793 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ stdurl "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ conan_model "code.gitea.io/gitea/models/packages/conan"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ conan_module "code.gitea.io/gitea/modules/packages/conan"
+ "code.gitea.io/gitea/modules/setting"
+ conan_router "code.gitea.io/gitea/routers/api/packages/conan"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ conanfileName = "conanfile.py"
+ conaninfoName = "conaninfo.txt"
+
+ conanLicense = "MIT"
+ conanAuthor = "Gitea <info@gitea.io>"
+ conanHomepage = "https://gitea.io/"
+ conanURL = "https://gitea.com/"
+ conanDescription = "Description of ConanPackage"
+ conanTopic = "gitea"
+
+ conanPackageReference = "dummyreference"
+
+ contentConaninfo = `[settings]
+ arch=x84_64
+
+[requires]
+ fmt/7.1.3
+
+[options]
+ shared=False
+
+[full_settings]
+ arch=x84_64
+
+[full_requires]
+ fmt/7.1.3
+
+[full_options]
+ shared=False
+
+[recipe_hash]
+ 74714915a51073acb548ca1ce29afbac
+
+[env]
+CC=gcc-10`
+)
+
+func buildConanfileContent(name, version string) string {
+ return `from conans import ConanFile, CMake, tools
+
+class ConanPackageConan(ConanFile):
+ name = "` + name + `"
+ version = "` + version + `"
+ license = "` + conanLicense + `"
+ author = "` + conanAuthor + `"
+ homepage = "` + conanHomepage + `"
+ url = "` + conanURL + `"
+ description = "` + conanDescription + `"
+ topics = ("` + conanTopic + `")
+ settings = "os", "compiler", "build_type", "arch"
+ options = {"shared": [True, False], "fPIC": [True, False]}
+ default_options = {"shared": False, "fPIC": True}
+ generators = "cmake"`
+}
+
+func uploadConanPackageV1(t *testing.T, baseURL, token, name, version, user, channel string) {
+ contentConanfile := buildConanfileContent(name, version)
+
+ recipeURL := fmt.Sprintf("%s/v1/conans/%s/%s/%s/%s", baseURL, name, version, user, channel)
+
+ req := NewRequest(t, "GET", recipeURL).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/digest", recipeURL)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/download_urls", recipeURL)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "POST", fmt.Sprintf("%s/upload_urls", recipeURL))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("%s/upload_urls", recipeURL), map[string]int64{
+ conanfileName: int64(len(contentConanfile)),
+ "removed.txt": 0,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ uploadURLs := make(map[string]string)
+ DecodeJSON(t, resp, &uploadURLs)
+
+ assert.Contains(t, uploadURLs, conanfileName)
+ assert.NotContains(t, uploadURLs, "removed.txt")
+
+ uploadURL := uploadURLs[conanfileName]
+ assert.NotEmpty(t, uploadURL)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, strings.NewReader(contentConanfile)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ packageURL := fmt.Sprintf("%s/packages/%s", recipeURL, conanPackageReference)
+
+ req = NewRequest(t, "GET", packageURL).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/digest", packageURL)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/download_urls", packageURL)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "POST", fmt.Sprintf("%s/upload_urls", packageURL))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("%s/upload_urls", packageURL), map[string]int64{
+ conaninfoName: int64(len(contentConaninfo)),
+ "removed.txt": 0,
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ uploadURLs = make(map[string]string)
+ DecodeJSON(t, resp, &uploadURLs)
+
+ assert.Contains(t, uploadURLs, conaninfoName)
+ assert.NotContains(t, uploadURLs, "removed.txt")
+
+ uploadURL = uploadURLs[conaninfoName]
+ assert.NotEmpty(t, uploadURL)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, strings.NewReader(contentConaninfo)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+}
+
+func uploadConanPackageV2(t *testing.T, baseURL, token, name, version, user, channel, recipeRevision, packageRevision string) {
+ contentConanfile := buildConanfileContent(name, version)
+
+ recipeURL := fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s", baseURL, name, version, user, channel, recipeRevision)
+
+ req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/files/%s", recipeURL, conanfileName), strings.NewReader(contentConanfile)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/files", recipeURL)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var list *struct {
+ Files map[string]any `json:"files"`
+ }
+ DecodeJSON(t, resp, &list)
+ assert.Len(t, list.Files, 1)
+ assert.Contains(t, list.Files, conanfileName)
+
+ packageURL := fmt.Sprintf("%s/packages/%s/revisions/%s", recipeURL, conanPackageReference, packageRevision)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/files", packageURL)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/files/%s", packageURL, conaninfoName), strings.NewReader(contentConaninfo)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/files", packageURL)).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ list = nil
+ DecodeJSON(t, resp, &list)
+ assert.Len(t, list.Files, 1)
+ assert.Contains(t, list.Files, conaninfoName)
+}
+
+func TestPackageConan(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ name := "ConanPackage"
+ version1 := "1.2"
+ version2 := "1.3"
+ user1 := "dummy"
+ user2 := "gitea"
+ channel1 := "test"
+ channel2 := "final"
+ revision1 := "rev1"
+ revision2 := "rev2"
+
+ url := fmt.Sprintf("%sapi/packages/%s/conan", setting.AppURL, user.Name)
+
+ t.Run("v1", func(t *testing.T) {
+ t.Run("Ping", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/v1/ping", url))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "revisions", resp.Header().Get("X-Conan-Server-Capabilities"))
+ })
+
+ t.Run("Token Scope Authentication", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, user.Name)
+
+ testCase := func(t *testing.T, scope auth_model.AccessTokenScope, expectedStatusCode int) {
+ t.Helper()
+
+ token := getTokenForLoggedInUser(t, session, scope)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/v1/users/authenticate", url)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ body := resp.Body.String()
+ assert.NotEmpty(t, body)
+
+ recipeURL := fmt.Sprintf("%s/v1/conans/%s/%s/%s/%s", url, "TestScope", version1, "testing", channel1)
+
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("%s/upload_urls", recipeURL), map[string]int64{
+ conanfileName: 64,
+ "removed.txt": 0,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, expectedStatusCode)
+ }
+
+ t.Run("Read permission", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ testCase(t, auth_model.AccessTokenScopeReadPackage, http.StatusUnauthorized)
+ })
+
+ t.Run("Write permission", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ testCase(t, auth_model.AccessTokenScopeWritePackage, http.StatusOK)
+ })
+ })
+
+ token := ""
+
+ t.Run("Authenticate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/v1/users/authenticate", url)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ token = resp.Body.String()
+ assert.NotEmpty(t, token)
+ })
+
+ t.Run("CheckCredentials", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/v1/users/check_credentials", url)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadConanPackageV1(t, url, token, name, version1, user1, channel1)
+
+ t.Run("Validate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeConan)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.Nil(t, pd.SemVer)
+ assert.Equal(t, name, pd.Package.Name)
+ assert.Equal(t, version1, pd.Version.Version)
+ assert.IsType(t, &conan_module.Metadata{}, pd.Metadata)
+ metadata := pd.Metadata.(*conan_module.Metadata)
+ assert.Equal(t, conanLicense, metadata.License)
+ assert.Equal(t, conanAuthor, metadata.Author)
+ assert.Equal(t, conanHomepage, metadata.ProjectURL)
+ assert.Equal(t, conanURL, metadata.RepositoryURL)
+ assert.Equal(t, conanDescription, metadata.Description)
+ assert.Equal(t, []string{conanTopic}, metadata.Keywords)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 2)
+
+ for _, pf := range pfs {
+ pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID)
+ require.NoError(t, err)
+
+ if pf.Name == conanfileName {
+ assert.True(t, pf.IsLead)
+
+ assert.Equal(t, int64(len(buildConanfileContent(name, version1))), pb.Size)
+ } else if pf.Name == conaninfoName {
+ assert.False(t, pf.IsLead)
+
+ assert.Equal(t, int64(len(contentConaninfo)), pb.Size)
+ } else {
+ assert.FailNow(t, "unknown file: %s", pf.Name)
+ }
+ }
+ })
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ recipeURL := fmt.Sprintf("%s/v1/conans/%s/%s/%s/%s", url, name, version1, user1, channel1)
+
+ req := NewRequest(t, "GET", recipeURL)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ fileHashes := make(map[string]string)
+ DecodeJSON(t, resp, &fileHashes)
+ assert.Len(t, fileHashes, 1)
+ assert.Contains(t, fileHashes, conanfileName)
+ assert.Equal(t, "7abc52241c22090782c54731371847a8", fileHashes[conanfileName])
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/digest", recipeURL))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ downloadURLs := make(map[string]string)
+ DecodeJSON(t, resp, &downloadURLs)
+ assert.Contains(t, downloadURLs, conanfileName)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/download_urls", recipeURL))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &downloadURLs)
+ assert.Contains(t, downloadURLs, conanfileName)
+
+ req = NewRequest(t, "GET", downloadURLs[conanfileName])
+ resp = MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, buildConanfileContent(name, version1), resp.Body.String())
+
+ packageURL := fmt.Sprintf("%s/packages/%s", recipeURL, conanPackageReference)
+
+ req = NewRequest(t, "GET", packageURL)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ fileHashes = make(map[string]string)
+ DecodeJSON(t, resp, &fileHashes)
+ assert.Len(t, fileHashes, 1)
+ assert.Contains(t, fileHashes, conaninfoName)
+ assert.Equal(t, "7628bfcc5b17f1470c468621a78df394", fileHashes[conaninfoName])
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/digest", packageURL))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ downloadURLs = make(map[string]string)
+ DecodeJSON(t, resp, &downloadURLs)
+ assert.Contains(t, downloadURLs, conaninfoName)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/download_urls", packageURL))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &downloadURLs)
+ assert.Contains(t, downloadURLs, conaninfoName)
+
+ req = NewRequest(t, "GET", downloadURLs[conaninfoName])
+ resp = MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, contentConaninfo, resp.Body.String())
+ })
+
+ t.Run("Search", func(t *testing.T) {
+ uploadConanPackageV1(t, url, token, name, version2, user1, channel1)
+ uploadConanPackageV1(t, url, token, name, version1, user1, channel2)
+ uploadConanPackageV1(t, url, token, name, version1, user2, channel1)
+ uploadConanPackageV1(t, url, token, name, version1, user2, channel2)
+
+ t.Run("Recipe", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ cases := []struct {
+ Query string
+ Expected []string
+ }{
+ {"ConanPackage", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/1.2", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/1.1", []string{}},
+ {"Conan*", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/*", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/1*", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/*2", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/1*2", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/1.2@", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/1.2@du*", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@dummy/final"}},
+ {"ConanPackage/1.2@du*/", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@dummy/final"}},
+ {"ConanPackage/1.2@du*/*test", []string{"ConanPackage/1.2@dummy/test"}},
+ {"ConanPackage/1.2@du*/*st", []string{"ConanPackage/1.2@dummy/test"}},
+ {"ConanPackage/1.2@gitea/*", []string{"ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"*/*@dummy", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@dummy/final"}},
+ {"*/*@*/final", []string{"ConanPackage/1.2@dummy/final", "ConanPackage/1.2@gitea/final"}},
+ }
+
+ for i, c := range cases {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/v1/conans/search?q=%s", url, stdurl.QueryEscape(c.Query)))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result *conan_router.SearchResult
+ DecodeJSON(t, resp, &result)
+
+ assert.ElementsMatch(t, c.Expected, result.Results, "case %d: unexpected result", i)
+ }
+ })
+
+ t.Run("Package", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/v1/conans/%s/%s/%s/%s/search", url, name, version1, user1, channel2))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result map[string]*conan_module.Conaninfo
+ DecodeJSON(t, resp, &result)
+
+ assert.Contains(t, result, conanPackageReference)
+ info := result[conanPackageReference]
+ assert.NotEmpty(t, info.Settings)
+ })
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ t.Run("Package", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ cases := []struct {
+ Channel string
+ References []string
+ }{
+ {channel1, []string{conanPackageReference}},
+ {channel2, []string{}},
+ }
+
+ for i, c := range cases {
+ rref, _ := conan_module.NewRecipeReference(name, version1, user1, c.Channel, conan_module.DefaultRevision)
+ references, err := conan_model.GetPackageReferences(db.DefaultContext, user.ID, rref)
+ require.NoError(t, err)
+ assert.NotEmpty(t, references)
+
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("%s/v1/conans/%s/%s/%s/%s/packages/delete", url, name, version1, user1, c.Channel), map[string][]string{
+ "package_ids": c.References,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ references, err = conan_model.GetPackageReferences(db.DefaultContext, user.ID, rref)
+ require.NoError(t, err)
+ assert.Empty(t, references, "case %d: should be empty", i)
+ }
+ })
+
+ t.Run("Recipe", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ cases := []struct {
+ Channel string
+ }{
+ {channel1},
+ {channel2},
+ }
+
+ for i, c := range cases {
+ rref, _ := conan_module.NewRecipeReference(name, version1, user1, c.Channel, conan_module.DefaultRevision)
+ revisions, err := conan_model.GetRecipeRevisions(db.DefaultContext, user.ID, rref)
+ require.NoError(t, err)
+ assert.NotEmpty(t, revisions)
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/v1/conans/%s/%s/%s/%s", url, name, version1, user1, c.Channel)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ revisions, err = conan_model.GetRecipeRevisions(db.DefaultContext, user.ID, rref)
+ require.NoError(t, err)
+ assert.Empty(t, revisions, "case %d: should be empty", i)
+ }
+ })
+ })
+ })
+
+ t.Run("v2", func(t *testing.T) {
+ t.Run("Ping", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/ping", url))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "revisions", resp.Header().Get("X-Conan-Server-Capabilities"))
+ })
+
+ token := ""
+
+ t.Run("Token Scope Authentication", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, user.Name)
+
+ testCase := func(t *testing.T, scope auth_model.AccessTokenScope, expectedStatusCode int) {
+ t.Helper()
+
+ token := getTokenForLoggedInUser(t, session, scope)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/users/authenticate", url)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ body := resp.Body.String()
+ assert.NotEmpty(t, body)
+
+ recipeURL := fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s", url, "TestScope", version1, "testing", channel1, revision1)
+
+ req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/files/%s", recipeURL, conanfileName), strings.NewReader("Doesn't need to be valid")).
+ AddTokenAuth("Bearer " + body)
+ MakeRequest(t, req, expectedStatusCode)
+ }
+
+ t.Run("Read permission", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ testCase(t, auth_model.AccessTokenScopeReadPackage, http.StatusUnauthorized)
+ })
+
+ t.Run("Write permission", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ testCase(t, auth_model.AccessTokenScopeWritePackage, http.StatusCreated)
+ })
+ })
+
+ t.Run("Authenticate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/users/authenticate", url)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ body := resp.Body.String()
+ assert.NotEmpty(t, body)
+
+ token = fmt.Sprintf("Bearer %s", body)
+ })
+
+ t.Run("CheckCredentials", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/users/check_credentials", url)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadConanPackageV2(t, url, token, name, version1, user1, channel1, revision1, revision1)
+
+ t.Run("Validate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeConan)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 3)
+ })
+ })
+
+ t.Run("Latest", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ recipeURL := fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s", url, name, version1, user1, channel1)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/latest", recipeURL))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ obj := make(map[string]string)
+ DecodeJSON(t, resp, &obj)
+ assert.Contains(t, obj, "revision")
+ assert.Equal(t, revision1, obj["revision"])
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/revisions/%s/packages/%s/latest", recipeURL, revision1, conanPackageReference))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ obj = make(map[string]string)
+ DecodeJSON(t, resp, &obj)
+ assert.Contains(t, obj, "revision")
+ assert.Equal(t, revision1, obj["revision"])
+ })
+
+ t.Run("ListRevisions", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadConanPackageV2(t, url, token, name, version1, user1, channel1, revision1, revision2)
+ uploadConanPackageV2(t, url, token, name, version1, user1, channel1, revision2, revision1)
+ uploadConanPackageV2(t, url, token, name, version1, user1, channel1, revision2, revision2)
+
+ recipeURL := fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions", url, name, version1, user1, channel1)
+
+ req := NewRequest(t, "GET", recipeURL)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type RevisionInfo struct {
+ Revision string `json:"revision"`
+ Time time.Time `json:"time"`
+ }
+
+ type RevisionList struct {
+ Revisions []*RevisionInfo `json:"revisions"`
+ }
+
+ var list *RevisionList
+ DecodeJSON(t, resp, &list)
+ assert.Len(t, list.Revisions, 2)
+ revs := make([]string, 0, len(list.Revisions))
+ for _, rev := range list.Revisions {
+ revs = append(revs, rev.Revision)
+ }
+ assert.ElementsMatch(t, []string{revision1, revision2}, revs)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/packages/%s/revisions", recipeURL, revision1, conanPackageReference))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &list)
+ assert.Len(t, list.Revisions, 2)
+ revs = make([]string, 0, len(list.Revisions))
+ for _, rev := range list.Revisions {
+ revs = append(revs, rev.Revision)
+ }
+ assert.ElementsMatch(t, []string{revision1, revision2}, revs)
+ })
+
+ t.Run("Search", func(t *testing.T) {
+ t.Run("Recipe", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ cases := []struct {
+ Query string
+ Expected []string
+ }{
+ {"ConanPackage", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/1.2", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/1.1", []string{}},
+ {"Conan*", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/*", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/1*", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/*2", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/1*2", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/1.2@", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"ConanPackage/1.2@du*", []string{"ConanPackage/1.2@dummy/test"}},
+ {"ConanPackage/1.2@du*/", []string{"ConanPackage/1.2@dummy/test"}},
+ {"ConanPackage/1.2@du*/*test", []string{"ConanPackage/1.2@dummy/test"}},
+ {"ConanPackage/1.2@du*/*st", []string{"ConanPackage/1.2@dummy/test"}},
+ {"ConanPackage/1.2@gitea/*", []string{"ConanPackage/1.2@gitea/test", "ConanPackage/1.2@gitea/final"}},
+ {"*/*@dummy", []string{"ConanPackage/1.2@dummy/test", "ConanPackage/1.3@dummy/test"}},
+ {"*/*@*/final", []string{"ConanPackage/1.2@gitea/final"}},
+ }
+
+ for i, c := range cases {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/conans/search?q=%s", url, stdurl.QueryEscape(c.Query)))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result *conan_router.SearchResult
+ DecodeJSON(t, resp, &result)
+
+ assert.ElementsMatch(t, c.Expected, result.Results, "case %d: unexpected result", i)
+ }
+ })
+
+ t.Run("Package", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/search", url, name, version1, user1, channel1))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result map[string]*conan_module.Conaninfo
+ DecodeJSON(t, resp, &result)
+
+ assert.Contains(t, result, conanPackageReference)
+ info := result[conanPackageReference]
+ assert.NotEmpty(t, info.Settings)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s/search", url, name, version1, user1, channel1, revision1))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ result = make(map[string]*conan_module.Conaninfo)
+ DecodeJSON(t, resp, &result)
+
+ assert.Contains(t, result, conanPackageReference)
+ info = result[conanPackageReference]
+ assert.NotEmpty(t, info.Settings)
+ })
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ t.Run("Package", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ rref, _ := conan_module.NewRecipeReference(name, version1, user1, channel1, revision1)
+ pref, _ := conan_module.NewPackageReference(rref, conanPackageReference, conan_module.DefaultRevision)
+
+ checkPackageRevisionCount := func(count int) {
+ revisions, err := conan_model.GetPackageRevisions(db.DefaultContext, user.ID, pref)
+ require.NoError(t, err)
+ assert.Len(t, revisions, count)
+ }
+ checkPackageReferenceCount := func(count int) {
+ references, err := conan_model.GetPackageReferences(db.DefaultContext, user.ID, rref)
+ require.NoError(t, err)
+ assert.Len(t, references, count)
+ }
+
+ checkPackageRevisionCount(2)
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s/packages/%s/revisions/%s", url, name, version1, user1, channel1, revision1, conanPackageReference, revision1)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ checkPackageRevisionCount(1)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s/packages/%s", url, name, version1, user1, channel1, revision1, conanPackageReference)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ checkPackageRevisionCount(0)
+
+ rref = rref.WithRevision(revision2)
+
+ checkPackageReferenceCount(1)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s/packages", url, name, version1, user1, channel1, revision2)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ checkPackageReferenceCount(0)
+ })
+
+ t.Run("Recipe", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ rref, _ := conan_module.NewRecipeReference(name, version1, user1, channel1, conan_module.DefaultRevision)
+
+ checkRecipeRevisionCount := func(count int) {
+ revisions, err := conan_model.GetRecipeRevisions(db.DefaultContext, user.ID, rref)
+ require.NoError(t, err)
+ assert.Len(t, revisions, count)
+ }
+
+ checkRecipeRevisionCount(2)
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s/revisions/%s", url, name, version1, user1, channel1, revision1)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ checkRecipeRevisionCount(1)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/v2/conans/%s/%s/%s/%s", url, name, version1, user1, channel1)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ checkRecipeRevisionCount(0)
+ })
+ })
+ })
+}
diff --git a/tests/integration/api_packages_conda_test.go b/tests/integration/api_packages_conda_test.go
new file mode 100644
index 0000000..4625c58
--- /dev/null
+++ b/tests/integration/api_packages_conda_test.go
@@ -0,0 +1,275 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ conda_module "code.gitea.io/gitea/modules/packages/conda"
+ "code.gitea.io/gitea/modules/zstd"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/dsnet/compress/bzip2"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageConda(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ packageName := "test_package"
+ packageVersion := "1.0.1"
+
+ channel := "test-channel"
+ root := fmt.Sprintf("/api/packages/%s/conda", user.Name)
+
+ t.Run("Upload", func(t *testing.T) {
+ tarContent := func() []byte {
+ var buf bytes.Buffer
+ tw := tar.NewWriter(&buf)
+
+ content := []byte(`{"name":"` + packageName + `","version":"` + packageVersion + `","subdir":"noarch","build":"xxx"}`)
+
+ hdr := &tar.Header{
+ Name: "info/index.json",
+ Mode: 0o600,
+ Size: int64(len(content)),
+ }
+ tw.WriteHeader(hdr)
+ tw.Write(content)
+ tw.Close()
+ return buf.Bytes()
+ }()
+
+ t.Run(".tar.bz2", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ var buf bytes.Buffer
+ bw, _ := bzip2.NewWriter(&buf, nil)
+ io.Copy(bw, bytes.NewReader(tarContent))
+ bw.Close()
+
+ filename := fmt.Sprintf("%s-%s.tar.bz2", packageName, packageVersion)
+
+ req := NewRequestWithBody(t, "PUT", root+"/"+filename, bytes.NewReader(buf.Bytes()))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "PUT", root+"/"+filename, bytes.NewReader(buf.Bytes())).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequestWithBody(t, "PUT", root+"/"+filename, bytes.NewReader(buf.Bytes())).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusConflict)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeConda)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.Nil(t, pd.SemVer)
+ assert.IsType(t, &conda_module.VersionMetadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+ assert.Empty(t, pd.PackageProperties.GetByName(conda_module.PropertyChannel))
+ })
+
+ t.Run(".conda", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ var infoBuf bytes.Buffer
+ zsw, _ := zstd.NewWriter(&infoBuf)
+ io.Copy(zsw, bytes.NewReader(tarContent))
+ zsw.Close()
+
+ var buf bytes.Buffer
+ zpw := zip.NewWriter(&buf)
+ w, _ := zpw.Create("info-x.tar.zst")
+ w.Write(infoBuf.Bytes())
+ zpw.Close()
+
+ fullName := channel + "/" + packageName
+ filename := fmt.Sprintf("%s-%s.conda", packageName, packageVersion)
+
+ req := NewRequestWithBody(t, "PUT", root+"/"+channel+"/"+filename, bytes.NewReader(buf.Bytes()))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "PUT", root+"/"+channel+"/"+filename, bytes.NewReader(buf.Bytes())).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequestWithBody(t, "PUT", root+"/"+channel+"/"+filename, bytes.NewReader(buf.Bytes())).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusConflict)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeConda)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 2)
+
+ pds, err := packages.GetPackageDescriptors(db.DefaultContext, pvs)
+ require.NoError(t, err)
+
+ assert.Condition(t, func() bool {
+ for _, pd := range pds {
+ if pd.Package.Name == fullName {
+ return true
+ }
+ }
+ return false
+ })
+
+ for _, pd := range pds {
+ if pd.Package.Name == fullName {
+ assert.Nil(t, pd.SemVer)
+ assert.IsType(t, &conda_module.VersionMetadata{}, pd.Metadata)
+ assert.Equal(t, fullName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+ assert.Equal(t, channel, pd.PackageProperties.GetByName(conda_module.PropertyChannel))
+ }
+ }
+ })
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ t.Run(".tar.bz2", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/noarch/%s-%s-xxx.tar.bz2", root, packageName, packageVersion))
+ MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/noarch/%s-%s-xxx.tar.bz2", root, channel, packageName, packageVersion))
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run(".conda", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/noarch/%s-%s-xxx.conda", root, packageName, packageVersion))
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/noarch/%s-%s-xxx.conda", root, channel, packageName, packageVersion))
+ MakeRequest(t, req, http.StatusOK)
+ })
+ })
+
+ t.Run("EnumeratePackages", func(t *testing.T) {
+ type Info struct {
+ Subdir string `json:"subdir"`
+ }
+
+ type PackageInfo struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+ NoArch string `json:"noarch"`
+ Subdir string `json:"subdir"`
+ Timestamp int64 `json:"timestamp"`
+ Build string `json:"build"`
+ BuildNumber int64 `json:"build_number"`
+ Dependencies []string `json:"depends"`
+ License string `json:"license"`
+ LicenseFamily string `json:"license_family"`
+ HashMD5 string `json:"md5"`
+ HashSHA256 string `json:"sha256"`
+ Size int64 `json:"size"`
+ }
+
+ type RepoData struct {
+ Info Info `json:"info"`
+ Packages map[string]*PackageInfo `json:"packages"`
+ PackagesConda map[string]*PackageInfo `json:"packages.conda"`
+ Removed map[string]*PackageInfo `json:"removed"`
+ }
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/noarch/repodata.json", root))
+ resp := MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, "application/json", resp.Header().Get("Content-Type"))
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/noarch/repodata.json.bz2", root))
+ resp = MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, "application/x-bzip2", resp.Header().Get("Content-Type"))
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/noarch/current_repodata.json", root))
+ resp = MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, "application/json", resp.Header().Get("Content-Type"))
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/noarch/current_repodata.json.bz2", root))
+ resp = MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, "application/x-bzip2", resp.Header().Get("Content-Type"))
+
+ t.Run(".tar.bz2", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeConda, packageName, packageVersion)
+ require.NoError(t, err)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pv)
+ require.NoError(t, err)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/noarch/repodata.json", root))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result RepoData
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, "noarch", result.Info.Subdir)
+ assert.Empty(t, result.PackagesConda)
+ assert.Empty(t, result.Removed)
+
+ filename := fmt.Sprintf("%s-%s-xxx.tar.bz2", packageName, packageVersion)
+ assert.Contains(t, result.Packages, filename)
+ packageInfo := result.Packages[filename]
+ assert.Equal(t, packageName, packageInfo.Name)
+ assert.Equal(t, packageVersion, packageInfo.Version)
+ assert.Equal(t, "noarch", packageInfo.Subdir)
+ assert.Equal(t, "xxx", packageInfo.Build)
+ assert.Equal(t, pd.Files[0].Blob.HashMD5, packageInfo.HashMD5)
+ assert.Equal(t, pd.Files[0].Blob.HashSHA256, packageInfo.HashSHA256)
+ assert.Equal(t, pd.Files[0].Blob.Size, packageInfo.Size)
+ })
+
+ t.Run(".conda", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeConda, channel+"/"+packageName, packageVersion)
+ require.NoError(t, err)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pv)
+ require.NoError(t, err)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/noarch/repodata.json", root, channel))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result RepoData
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, "noarch", result.Info.Subdir)
+ assert.Empty(t, result.Packages)
+ assert.Empty(t, result.Removed)
+
+ filename := fmt.Sprintf("%s-%s-xxx.conda", packageName, packageVersion)
+ assert.Contains(t, result.PackagesConda, filename)
+ packageInfo := result.PackagesConda[filename]
+ assert.Equal(t, packageName, packageInfo.Name)
+ assert.Equal(t, packageVersion, packageInfo.Version)
+ assert.Equal(t, "noarch", packageInfo.Subdir)
+ assert.Equal(t, "xxx", packageInfo.Build)
+ assert.Equal(t, pd.Files[0].Blob.HashMD5, packageInfo.HashMD5)
+ assert.Equal(t, pd.Files[0].Blob.HashSHA256, packageInfo.HashSHA256)
+ assert.Equal(t, pd.Files[0].Blob.Size, packageInfo.Size)
+ })
+ })
+}
diff --git a/tests/integration/api_packages_container_cleanup_sha256_test.go b/tests/integration/api_packages_container_cleanup_sha256_test.go
new file mode 100644
index 0000000..eb63eff
--- /dev/null
+++ b/tests/integration/api_packages_container_cleanup_sha256_test.go
@@ -0,0 +1,238 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package integration
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ packages_cleanup "code.gitea.io/gitea/services/packages/cleanup"
+ packages_container "code.gitea.io/gitea/services/packages/container"
+ "code.gitea.io/gitea/tests"
+
+ oci "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackagesContainerCleanupSHA256(t *testing.T) {
+ defer tests.PrepareTestEnv(t, 1)()
+ defer test.MockVariableValue(&setting.Packages.Storage.Type, setting.LocalStorageType)()
+ defer test.MockVariableValue(&packages_container.SHA256BatchSize, 1)()
+
+ ctx := db.DefaultContext
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ cleanupAndCheckLogs := func(t *testing.T, expected ...string) {
+ t.Helper()
+ logChecker, cleanup := test.NewLogChecker(log.DEFAULT, log.TRACE)
+ logChecker.Filter(expected...)
+ logChecker.StopMark(packages_container.SHA256LogFinish)
+ defer cleanup()
+
+ require.NoError(t, packages_cleanup.CleanupExpiredData(ctx, -1*time.Hour))
+
+ logFiltered, logStopped := logChecker.Check(5 * time.Second)
+ assert.True(t, logStopped)
+ filtered := make([]bool, 0, len(expected))
+ for range expected {
+ filtered = append(filtered, true)
+ }
+ assert.EqualValues(t, filtered, logFiltered, expected)
+ }
+
+ userToken := ""
+
+ t.Run("Authenticate", func(t *testing.T) {
+ type TokenResponse struct {
+ Token string `json:"token"`
+ }
+
+ authenticate := []string{`Bearer realm="` + setting.AppURL + `v2/token",service="container_registry",scope="*"`}
+
+ t.Run("User", func(t *testing.T) {
+ req := NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL))
+ resp := MakeRequest(t, req, http.StatusUnauthorized)
+
+ assert.ElementsMatch(t, authenticate, resp.Header().Values("WWW-Authenticate"))
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%sv2/token", setting.AppURL)).
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ tokenResponse := &TokenResponse{}
+ DecodeJSON(t, resp, &tokenResponse)
+
+ assert.NotEmpty(t, tokenResponse.Token)
+
+ userToken = fmt.Sprintf("Bearer %s", tokenResponse.Token)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusOK)
+ })
+ })
+
+ image := "test"
+ multiTag := "multi"
+
+ url := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, image)
+
+ blobDigest := "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
+ sha256ManifestDigest := "sha256:4305f5f5572b9a426b88909b036e52ee3cf3d7b9c1b01fac840e90747f56623d"
+ indexManifestDigest := "sha256:b992f98104ab25f60d78368a674ce6f6a49741f4e32729e8496067ed06174e9b"
+
+ uploadSHA256Version := func(t *testing.T) {
+ t.Helper()
+
+ blobContent, _ := base64.StdEncoding.DecodeString(`H4sIAAAJbogA/2IYBaNgFIxYAAgAAP//Lq+17wAEAAA=`)
+
+ req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, blobDigest), bytes.NewReader(blobContent)).
+ AddTokenAuth(userToken)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, blobDigest), resp.Header().Get("Location"))
+ assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest"))
+
+ configDigest := "sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d"
+ configContent := `{"architecture":"amd64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/true"],"ArgsEscaped":true,"Image":"sha256:9bd8b88dc68b80cffe126cc820e4b52c6e558eb3b37680bfee8e5f3ed7b8c257"},"container":"b89fe92a887d55c0961f02bdfbfd8ac3ddf66167db374770d2d9e9fab3311510","container_config":{"Hostname":"b89fe92a887d","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"/true\"]"],"ArgsEscaped":true,"Image":"sha256:9bd8b88dc68b80cffe126cc820e4b52c6e558eb3b37680bfee8e5f3ed7b8c257"},"created":"2022-01-01T00:00:00.000000000Z","docker_version":"20.10.12","history":[{"created":"2022-01-01T00:00:00.000000000Z","created_by":"/bin/sh -c #(nop) COPY file:0e7589b0c800daaf6fa460d2677101e4676dd9491980210cb345480e513f3602 in /true "},{"created":"2022-01-01T00:00:00.000000001Z","created_by":"/bin/sh -c #(nop) CMD [\"/true\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:0ff3b91bdf21ecdf2f2f3d4372c2098a14dbe06cd678e8f0a85fd4902d00e2e2"]}}`
+
+ req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, configDigest), strings.NewReader(configContent)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusCreated)
+
+ sha256ManifestContent := `{"schemaVersion":2,"mediaType":"` + oci.MediaTypeImageManifest + `","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d","size":1069},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","digest":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4","size":32}]}`
+ req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, sha256ManifestDigest), strings.NewReader(sha256ManifestContent)).
+ AddTokenAuth(userToken).
+ SetHeader("Content-Type", oci.MediaTypeImageManifest)
+ resp = MakeRequest(t, req, http.StatusCreated)
+
+ assert.Equal(t, sha256ManifestDigest, resp.Header().Get("Docker-Content-Digest"))
+
+ req = NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/%s", url, sha256ManifestDigest)).
+ AddTokenAuth(userToken)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, fmt.Sprintf("%d", len(sha256ManifestContent)), resp.Header().Get("Content-Length"))
+ assert.Equal(t, sha256ManifestDigest, resp.Header().Get("Docker-Content-Digest"))
+ }
+
+ uploadIndexManifest := func(t *testing.T) {
+ indexManifestContent := `{"schemaVersion":2,"mediaType":"` + oci.MediaTypeImageIndex + `","manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"` + sha256ManifestDigest + `","platform":{"os":"linux","architecture":"arm","variant":"v7"}}]}`
+ req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, multiTag), strings.NewReader(indexManifestContent)).
+ AddTokenAuth(userToken).
+ SetHeader("Content-Type", oci.MediaTypeImageIndex)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ assert.Equal(t, indexManifestDigest, resp.Header().Get("Docker-Content-Digest"))
+ }
+
+ assertImageExists := func(t *testing.T, manifestDigest, blobDigest string) {
+ req := NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/%s", url, manifestDigest)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusOK)
+ }
+
+ assertImageNotExists := func(t *testing.T, manifestDigest, blobDigest string) {
+ req := NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/%s", url, manifestDigest)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusNotFound)
+ }
+
+ assertImageDeleted := func(t *testing.T, image, manifestDigest, blobDigest string, cleanup func()) {
+ t.Helper()
+ packageVersion := unittest.AssertExistsAndLoadBean(t, &packages_model.PackageVersion{Version: manifestDigest})
+ packageFile := unittest.AssertExistsAndLoadBean(t, &packages_model.PackageFile{VersionID: packageVersion.ID})
+ unittest.AssertExistsAndLoadBean(t, &packages_model.PackageProperty{RefID: packageFile.ID, RefType: packages_model.PropertyTypeVersion})
+ packageBlob := unittest.AssertExistsAndLoadBean(t, &packages_model.PackageBlob{ID: packageFile.BlobID})
+ contentStore := packages_module.NewContentStore()
+ require.NoError(t, contentStore.Has(packages_module.BlobHash256Key(packageBlob.HashSHA256)))
+
+ assertImageExists(t, manifestDigest, blobDigest)
+
+ cleanup()
+
+ assertImageNotExists(t, manifestDigest, blobDigest)
+
+ unittest.AssertNotExistsBean(t, &packages_model.PackageVersion{Version: manifestDigest})
+ unittest.AssertNotExistsBean(t, &packages_model.PackageFile{VersionID: packageVersion.ID})
+ unittest.AssertNotExistsBean(t, &packages_model.PackageProperty{RefID: packageFile.ID, RefType: packages_model.PropertyTypeVersion})
+ unittest.AssertNotExistsBean(t, &packages_model.PackageBlob{ID: packageFile.BlobID})
+ assert.Error(t, contentStore.Has(packages_module.BlobHash256Key(packageBlob.HashSHA256)))
+ }
+
+ assertImageAndPackageDeleted := func(t *testing.T, image, manifestDigest, blobDigest string, cleanup func()) {
+ t.Helper()
+ unittest.AssertExistsAndLoadBean(t, &packages_model.Package{Name: image})
+ assertImageDeleted(t, image, manifestDigest, blobDigest, cleanup)
+ unittest.AssertNotExistsBean(t, &packages_model.Package{Name: image})
+ }
+
+ t.Run("Nothing to look at", func(t *testing.T) {
+ cleanupAndCheckLogs(t, "There are no container images with a version matching sha256:*")
+ })
+
+ uploadSHA256Version(t)
+
+ t.Run("Dangling image found", func(t *testing.T) {
+ assertImageAndPackageDeleted(t, image, sha256ManifestDigest, blobDigest, func() {
+ cleanupAndCheckLogs(t,
+ "Removing 3 entries from `package_file` and `package_property`",
+ "Removing 1 entries from `package_version` and `package_property`",
+ )
+ })
+ })
+
+ uploadSHA256Version(t)
+ uploadIndexManifest(t)
+
+ t.Run("Corrupted index manifest metadata is ignored", func(t *testing.T) {
+ assertImageExists(t, sha256ManifestDigest, blobDigest)
+ _, err := db.GetEngine(ctx).Table("package_version").Where("version = ?", multiTag).Update(&packages_model.PackageVersion{MetadataJSON: `corrupted "manifests":[{ bad`})
+ require.NoError(t, err)
+
+ // do not expect the package to be deleted because it contains
+ // corrupted metadata that prevents that from happening
+ assertImageDeleted(t, image, sha256ManifestDigest, blobDigest, func() {
+ cleanupAndCheckLogs(t,
+ "Removing 3 entries from `package_file` and `package_property`",
+ "Removing 1 entries from `package_version` and `package_property`",
+ "is not a JSON string containing valid metadata",
+ )
+ })
+ })
+
+ uploadSHA256Version(t)
+ uploadIndexManifest(t)
+
+ t.Run("Image found but referenced", func(t *testing.T) {
+ assertImageExists(t, sha256ManifestDigest, blobDigest)
+ cleanupAndCheckLogs(t,
+ "All container images with a version matching sha256:* are referenced by an index manifest",
+ )
+ assertImageExists(t, sha256ManifestDigest, blobDigest)
+ })
+}
diff --git a/tests/integration/api_packages_container_test.go b/tests/integration/api_packages_container_test.go
new file mode 100644
index 0000000..3c28f45
--- /dev/null
+++ b/tests/integration/api_packages_container_test.go
@@ -0,0 +1,759 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ container_model "code.gitea.io/gitea/models/packages/container"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ container_module "code.gitea.io/gitea/modules/packages/container"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/tests"
+
+ oci "github.com/opencontainers/image-spec/specs-go/v1"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageContainer(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadPackage)
+ privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 31})
+
+ has := func(l packages_model.PackagePropertyList, name string) bool {
+ for _, pp := range l {
+ if pp.Name == name {
+ return true
+ }
+ }
+ return false
+ }
+ getAllByName := func(l packages_model.PackagePropertyList, name string) []string {
+ values := make([]string, 0, len(l))
+ for _, pp := range l {
+ if pp.Name == name {
+ values = append(values, pp.Value)
+ }
+ }
+ return values
+ }
+
+ images := []string{"test", "te/st"}
+ tags := []string{"latest", "main"}
+ multiTag := "multi"
+
+ unknownDigest := "sha256:0000000000000000000000000000000000000000000000000000000000000000"
+
+ blobDigest := "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
+ blobContent, _ := base64.StdEncoding.DecodeString(`H4sIAAAJbogA/2IYBaNgFIxYAAgAAP//Lq+17wAEAAA=`)
+
+ configDigest := "sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d"
+ configContent := `{"architecture":"amd64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/true"],"ArgsEscaped":true,"Image":"sha256:9bd8b88dc68b80cffe126cc820e4b52c6e558eb3b37680bfee8e5f3ed7b8c257"},"container":"b89fe92a887d55c0961f02bdfbfd8ac3ddf66167db374770d2d9e9fab3311510","container_config":{"Hostname":"b89fe92a887d","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh","-c","#(nop) ","CMD [\"/true\"]"],"ArgsEscaped":true,"Image":"sha256:9bd8b88dc68b80cffe126cc820e4b52c6e558eb3b37680bfee8e5f3ed7b8c257"},"created":"2022-01-01T00:00:00.000000000Z","docker_version":"20.10.12","history":[{"created":"2022-01-01T00:00:00.000000000Z","created_by":"/bin/sh -c #(nop) COPY file:0e7589b0c800daaf6fa460d2677101e4676dd9491980210cb345480e513f3602 in /true "},{"created":"2022-01-01T00:00:00.000000001Z","created_by":"/bin/sh -c #(nop) CMD [\"/true\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:0ff3b91bdf21ecdf2f2f3d4372c2098a14dbe06cd678e8f0a85fd4902d00e2e2"]}}`
+
+ manifestDigest := "sha256:4f10484d1c1bb13e3956b4de1cd42db8e0f14a75be1617b60f2de3cd59c803c6"
+ manifestContent := `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d","size":1069},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","digest":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4","size":32}]}`
+
+ untaggedManifestDigest := "sha256:4305f5f5572b9a426b88909b036e52ee3cf3d7b9c1b01fac840e90747f56623d"
+ untaggedManifestContent := `{"schemaVersion":2,"mediaType":"` + oci.MediaTypeImageManifest + `","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"sha256:4607e093bec406eaadb6f3a340f63400c9d3a7038680744c406903766b938f0d","size":1069},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","digest":"sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4","size":32}]}`
+
+ indexManifestDigest := "sha256:bab112d6efb9e7f221995caaaa880352feb5bd8b1faf52fae8d12c113aa123ec"
+ indexManifestContent := `{"schemaVersion":2,"mediaType":"` + oci.MediaTypeImageIndex + `","manifests":[{"mediaType":"application/vnd.docker.distribution.manifest.v2+json","digest":"` + manifestDigest + `","platform":{"os":"linux","architecture":"arm","variant":"v7"}},{"mediaType":"` + oci.MediaTypeImageManifest + `","digest":"` + untaggedManifestDigest + `","platform":{"os":"linux","architecture":"arm64","variant":"v8"}}]}`
+
+ anonymousToken := ""
+ readUserToken := ""
+ userToken := ""
+
+ t.Run("Authenticate", func(t *testing.T) {
+ type TokenResponse struct {
+ Token string `json:"token"`
+ }
+
+ authenticate := []string{`Bearer realm="` + setting.AppURL + `v2/token",service="container_registry",scope="*"`}
+
+ t.Run("Anonymous", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL))
+ resp := MakeRequest(t, req, http.StatusUnauthorized)
+
+ assert.ElementsMatch(t, authenticate, resp.Header().Values("WWW-Authenticate"))
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%sv2/token", setting.AppURL))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ tokenResponse := &TokenResponse{}
+ DecodeJSON(t, resp, &tokenResponse)
+
+ assert.NotEmpty(t, tokenResponse.Token)
+
+ anonymousToken = fmt.Sprintf("Bearer %s", tokenResponse.Token)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)).
+ AddTokenAuth(anonymousToken)
+ MakeRequest(t, req, http.StatusOK)
+
+ defer test.MockVariableValue(&setting.Service.RequireSignInView, true)()
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%sv2/token", setting.AppURL))
+ MakeRequest(t, req, http.StatusUnauthorized)
+ })
+
+ t.Run("User", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL))
+ resp := MakeRequest(t, req, http.StatusUnauthorized)
+
+ assert.ElementsMatch(t, authenticate, resp.Header().Values("WWW-Authenticate"))
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%sv2/token", setting.AppURL)).
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ tokenResponse := &TokenResponse{}
+ DecodeJSON(t, resp, &tokenResponse)
+
+ assert.NotEmpty(t, tokenResponse.Token)
+
+ userToken = fmt.Sprintf("Bearer %s", tokenResponse.Token)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusOK)
+
+ // Token that should enforce the read scope.
+ t.Run("Read scope", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadPackage)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%sv2/token", setting.AppURL))
+ req.SetBasicAuth(user.Name, token)
+
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ tokenResponse := &TokenResponse{}
+ DecodeJSON(t, resp, &tokenResponse)
+
+ assert.NotEmpty(t, tokenResponse.Token)
+
+ readUserToken = fmt.Sprintf("Bearer %s", tokenResponse.Token)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)).
+ AddTokenAuth(readUserToken)
+ MakeRequest(t, req, http.StatusOK)
+ })
+ })
+ })
+
+ t.Run("DetermineSupport", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%sv2", setting.AppURL)).
+ AddTokenAuth(userToken)
+ resp := MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, "registry/2.0", resp.Header().Get("Docker-Distribution-Api-Version"))
+ })
+
+ for _, image := range images {
+ t.Run(fmt.Sprintf("[Image:%s]", image), func(t *testing.T) {
+ url := fmt.Sprintf("%sv2/%s/%s", setting.AppURL, user.Name, image)
+
+ t.Run("UploadBlob/Monolithic", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads", url)).
+ AddTokenAuth(anonymousToken)
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads", url)).
+ AddTokenAuth(readUserToken)
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, unknownDigest), bytes.NewReader(blobContent)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, blobDigest), bytes.NewReader(blobContent)).
+ AddTokenAuth(userToken)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, blobDigest), resp.Header().Get("Location"))
+ assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest"))
+
+ pv, err := packages_model.GetInternalVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, image, container_model.UploadVersion)
+ require.NoError(t, err)
+
+ pfs, err := packages_model.GetFilesByVersionID(db.DefaultContext, pv.ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+
+ pb, err := packages_model.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ assert.EqualValues(t, len(blobContent), pb.Size)
+ })
+
+ t.Run("UploadBlob/Chunked", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads", url)).
+ AddTokenAuth(userToken)
+ resp := MakeRequest(t, req, http.StatusAccepted)
+
+ uuid := resp.Header().Get("Docker-Upload-Uuid")
+ assert.NotEmpty(t, uuid)
+
+ pbu, err := packages_model.GetBlobUploadByID(db.DefaultContext, uuid)
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, pbu.BytesReceived)
+
+ uploadURL := resp.Header().Get("Location")
+ assert.NotEmpty(t, uploadURL)
+
+ req = NewRequestWithBody(t, "PATCH", setting.AppURL+uploadURL[1:]+"000", bytes.NewReader(blobContent)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequestWithBody(t, "PATCH", setting.AppURL+uploadURL[1:], bytes.NewReader(blobContent)).
+ AddTokenAuth(userToken).
+ SetHeader("Content-Range", "1-10")
+ MakeRequest(t, req, http.StatusRequestedRangeNotSatisfiable)
+
+ contentRange := fmt.Sprintf("0-%d", len(blobContent)-1)
+ req.SetHeader("Content-Range", contentRange)
+ resp = MakeRequest(t, req, http.StatusAccepted)
+
+ assert.Equal(t, uuid, resp.Header().Get("Docker-Upload-Uuid"))
+ assert.Equal(t, contentRange, resp.Header().Get("Range"))
+
+ uploadURL = resp.Header().Get("Location")
+
+ req = NewRequest(t, "GET", setting.AppURL+uploadURL[1:]).
+ AddTokenAuth(userToken)
+ resp = MakeRequest(t, req, http.StatusNoContent)
+
+ assert.Equal(t, uuid, resp.Header().Get("Docker-Upload-Uuid"))
+ assert.Equal(t, fmt.Sprintf("0-%d", len(blobContent)), resp.Header().Get("Range"))
+
+ pbu, err = packages_model.GetBlobUploadByID(db.DefaultContext, uuid)
+ require.NoError(t, err)
+ assert.EqualValues(t, len(blobContent), pbu.BytesReceived)
+
+ req = NewRequest(t, "PUT", fmt.Sprintf("%s?digest=%s", setting.AppURL+uploadURL[1:], blobDigest)).
+ AddTokenAuth(userToken)
+ resp = MakeRequest(t, req, http.StatusCreated)
+
+ assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, blobDigest), resp.Header().Get("Location"))
+ assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest"))
+
+ t.Run("Cancel", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads", url)).
+ AddTokenAuth(userToken)
+ resp := MakeRequest(t, req, http.StatusAccepted)
+
+ uuid := resp.Header().Get("Docker-Upload-Uuid")
+ assert.NotEmpty(t, uuid)
+
+ uploadURL := resp.Header().Get("Location")
+ assert.NotEmpty(t, uploadURL)
+
+ req = NewRequest(t, "GET", setting.AppURL+uploadURL[1:]).
+ AddTokenAuth(userToken)
+ resp = MakeRequest(t, req, http.StatusNoContent)
+
+ assert.Equal(t, uuid, resp.Header().Get("Docker-Upload-Uuid"))
+ assert.Equal(t, "0-0", resp.Header().Get("Range"))
+
+ req = NewRequest(t, "DELETE", setting.AppURL+uploadURL[1:]).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "GET", setting.AppURL+uploadURL[1:]).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+
+ t.Run("UploadBlob/Mount", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ privateBlobDigest := "sha256:6ccce4863b70f258d691f59609d31b4502e1ba5199942d3bc5d35d17a4ce771d"
+ req := NewRequestWithBody(t, "POST", fmt.Sprintf("%sv2/%s/%s/blobs/uploads?digest=%s", setting.AppURL, privateUser.Name, image, privateBlobDigest), strings.NewReader("gitea")).
+ AddBasicAuth(privateUser.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads?mount=%s", url, unknownDigest)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusAccepted)
+
+ req = NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads?mount=%s", url, privateBlobDigest)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusAccepted)
+
+ req = NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads?mount=%s", url, blobDigest)).
+ AddTokenAuth(userToken)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, blobDigest), resp.Header().Get("Location"))
+ assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest"))
+
+ req = NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads?mount=%s&from=%s", url, unknownDigest, "unknown/image")).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusAccepted)
+
+ req = NewRequest(t, "POST", fmt.Sprintf("%s/blobs/uploads?mount=%s&from=%s/%s", url, blobDigest, user.Name, image)).
+ AddTokenAuth(userToken)
+ resp = MakeRequest(t, req, http.StatusCreated)
+
+ assert.Equal(t, fmt.Sprintf("/v2/%s/%s/blobs/%s", user.Name, image, blobDigest), resp.Header().Get("Location"))
+ assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest"))
+ })
+
+ for _, tag := range tags {
+ t.Run(fmt.Sprintf("[Tag:%s]", tag), func(t *testing.T) {
+ t.Run("UploadManifest", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, configDigest), strings.NewReader(configContent)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, tag), strings.NewReader(manifestContent)).
+ AddTokenAuth(anonymousToken).
+ SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, tag), strings.NewReader(manifestContent)).
+ AddTokenAuth(readUserToken).
+ SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, tag), strings.NewReader(manifestContent)).
+ AddTokenAuth(userToken).
+ SetHeader("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ assert.Equal(t, manifestDigest, resp.Header().Get("Docker-Content-Digest"))
+
+ pv, err := packages_model.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, image, tag)
+ require.NoError(t, err)
+
+ pd, err := packages_model.GetPackageDescriptor(db.DefaultContext, pv)
+ require.NoError(t, err)
+ assert.Nil(t, pd.SemVer)
+ assert.Equal(t, image, pd.Package.Name)
+ assert.Equal(t, tag, pd.Version.Version)
+ assert.ElementsMatch(t, []string{strings.ToLower(user.LowerName + "/" + image)}, getAllByName(pd.PackageProperties, container_module.PropertyRepository))
+ assert.True(t, has(pd.VersionProperties, container_module.PropertyManifestTagged))
+
+ assert.IsType(t, &container_module.Metadata{}, pd.Metadata)
+ metadata := pd.Metadata.(*container_module.Metadata)
+ assert.Equal(t, container_module.TypeOCI, metadata.Type)
+ assert.Len(t, metadata.ImageLayers, 2)
+ assert.Empty(t, metadata.Manifests)
+
+ assert.Len(t, pd.Files, 3)
+ for _, pfd := range pd.Files {
+ switch pfd.File.Name {
+ case container_model.ManifestFilename:
+ assert.True(t, pfd.File.IsLead)
+ assert.Equal(t, "application/vnd.docker.distribution.manifest.v2+json", pfd.Properties.GetByName(container_module.PropertyMediaType))
+ assert.Equal(t, manifestDigest, pfd.Properties.GetByName(container_module.PropertyDigest))
+ case strings.Replace(configDigest, ":", "_", 1):
+ assert.False(t, pfd.File.IsLead)
+ assert.Equal(t, "application/vnd.docker.container.image.v1+json", pfd.Properties.GetByName(container_module.PropertyMediaType))
+ assert.Equal(t, configDigest, pfd.Properties.GetByName(container_module.PropertyDigest))
+ case strings.Replace(blobDigest, ":", "_", 1):
+ assert.False(t, pfd.File.IsLead)
+ assert.Equal(t, "application/vnd.docker.image.rootfs.diff.tar.gzip", pfd.Properties.GetByName(container_module.PropertyMediaType))
+ assert.Equal(t, blobDigest, pfd.Properties.GetByName(container_module.PropertyDigest))
+ default:
+ assert.FailNow(t, "unknown file: %s", pfd.File.Name)
+ }
+ }
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/manifests/%s", url, tag)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusOK)
+
+ pv, err = packages_model.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, image, tag)
+ require.NoError(t, err)
+ assert.EqualValues(t, 1, pv.DownloadCount)
+
+ // Overwrite existing tag should keep the download count
+ req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, tag), strings.NewReader(manifestContent)).
+ AddTokenAuth(userToken).
+ SetHeader("Content-Type", oci.MediaTypeImageManifest)
+ MakeRequest(t, req, http.StatusCreated)
+
+ pv, err = packages_model.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, image, tag)
+ require.NoError(t, err)
+ assert.EqualValues(t, 1, pv.DownloadCount)
+ })
+
+ t.Run("HeadManifest", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/unknown-tag", url)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/%s", url, tag)).
+ AddTokenAuth(userToken)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, fmt.Sprintf("%d", len(manifestContent)), resp.Header().Get("Content-Length"))
+ assert.Equal(t, manifestDigest, resp.Header().Get("Docker-Content-Digest"))
+ })
+
+ t.Run("GetManifest", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/manifests/unknown-tag", url)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/manifests/%s", url, tag)).
+ AddTokenAuth(userToken)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, fmt.Sprintf("%d", len(manifestContent)), resp.Header().Get("Content-Length"))
+ assert.Equal(t, oci.MediaTypeImageManifest, resp.Header().Get("Content-Type"))
+ assert.Equal(t, manifestDigest, resp.Header().Get("Docker-Content-Digest"))
+ assert.Equal(t, manifestContent, resp.Body.String())
+ })
+ })
+ }
+
+ t.Run("UploadUntaggedManifest", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, untaggedManifestDigest), strings.NewReader(untaggedManifestContent)).
+ AddTokenAuth(userToken).
+ SetHeader("Content-Type", oci.MediaTypeImageManifest)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ assert.Equal(t, untaggedManifestDigest, resp.Header().Get("Docker-Content-Digest"))
+
+ req = NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/%s", url, untaggedManifestDigest)).
+ AddTokenAuth(userToken)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, fmt.Sprintf("%d", len(untaggedManifestContent)), resp.Header().Get("Content-Length"))
+ assert.Equal(t, untaggedManifestDigest, resp.Header().Get("Docker-Content-Digest"))
+
+ pv, err := packages_model.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, image, untaggedManifestDigest)
+ require.NoError(t, err)
+
+ pd, err := packages_model.GetPackageDescriptor(db.DefaultContext, pv)
+ require.NoError(t, err)
+ assert.Nil(t, pd.SemVer)
+ assert.Equal(t, image, pd.Package.Name)
+ assert.Equal(t, untaggedManifestDigest, pd.Version.Version)
+ assert.ElementsMatch(t, []string{strings.ToLower(user.LowerName + "/" + image)}, getAllByName(pd.PackageProperties, container_module.PropertyRepository))
+ assert.False(t, has(pd.VersionProperties, container_module.PropertyManifestTagged))
+
+ assert.IsType(t, &container_module.Metadata{}, pd.Metadata)
+
+ assert.Len(t, pd.Files, 3)
+ for _, pfd := range pd.Files {
+ if pfd.File.Name == container_model.ManifestFilename {
+ assert.True(t, pfd.File.IsLead)
+ assert.Equal(t, oci.MediaTypeImageManifest, pfd.Properties.GetByName(container_module.PropertyMediaType))
+ assert.Equal(t, untaggedManifestDigest, pfd.Properties.GetByName(container_module.PropertyDigest))
+ }
+ }
+ })
+
+ t.Run("UploadIndexManifest", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/manifests/%s", url, multiTag), strings.NewReader(indexManifestContent)).
+ AddTokenAuth(userToken).
+ SetHeader("Content-Type", oci.MediaTypeImageIndex)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ assert.Equal(t, indexManifestDigest, resp.Header().Get("Docker-Content-Digest"))
+
+ pv, err := packages_model.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, image, multiTag)
+ require.NoError(t, err)
+
+ pd, err := packages_model.GetPackageDescriptor(db.DefaultContext, pv)
+ require.NoError(t, err)
+ assert.Nil(t, pd.SemVer)
+ assert.Equal(t, image, pd.Package.Name)
+ assert.Equal(t, multiTag, pd.Version.Version)
+ assert.ElementsMatch(t, []string{strings.ToLower(user.LowerName + "/" + image)}, getAllByName(pd.PackageProperties, container_module.PropertyRepository))
+ assert.True(t, has(pd.VersionProperties, container_module.PropertyManifestTagged))
+
+ assert.ElementsMatch(t, []string{manifestDigest, untaggedManifestDigest}, getAllByName(pd.VersionProperties, container_module.PropertyManifestReference))
+
+ assert.IsType(t, &container_module.Metadata{}, pd.Metadata)
+ metadata := pd.Metadata.(*container_module.Metadata)
+ assert.Equal(t, container_module.TypeOCI, metadata.Type)
+ assert.Len(t, metadata.Manifests, 2)
+ assert.Condition(t, func() bool {
+ for _, m := range metadata.Manifests {
+ switch m.Platform {
+ case "linux/arm/v7":
+ assert.Equal(t, manifestDigest, m.Digest)
+ assert.EqualValues(t, 1524, m.Size)
+ case "linux/arm64/v8":
+ assert.Equal(t, untaggedManifestDigest, m.Digest)
+ assert.EqualValues(t, 1514, m.Size)
+ default:
+ return false
+ }
+ }
+ return true
+ })
+
+ assert.Len(t, pd.Files, 1)
+ assert.True(t, pd.Files[0].File.IsLead)
+ assert.Equal(t, oci.MediaTypeImageIndex, pd.Files[0].Properties.GetByName(container_module.PropertyMediaType))
+ assert.Equal(t, indexManifestDigest, pd.Files[0].Properties.GetByName(container_module.PropertyDigest))
+ })
+
+ t.Run("HeadBlob", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, unknownDigest)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
+ AddTokenAuth(userToken)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, fmt.Sprintf("%d", len(blobContent)), resp.Header().Get("Content-Length"))
+ assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest"))
+
+ req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
+ AddTokenAuth(anonymousToken)
+ MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
+ AddTokenAuth(readUserToken)
+ MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("GetBlob", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/blobs/%s", url, unknownDigest)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
+ AddTokenAuth(userToken)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, fmt.Sprintf("%d", len(blobContent)), resp.Header().Get("Content-Length"))
+ assert.Equal(t, blobDigest, resp.Header().Get("Docker-Content-Digest"))
+ assert.Equal(t, blobContent, resp.Body.Bytes())
+ })
+
+ t.Run("GetTagList", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ cases := []struct {
+ URL string
+ ExpectedTags []string
+ ExpectedLink string
+ }{
+ {
+ URL: fmt.Sprintf("%s/tags/list", url),
+ ExpectedTags: []string{"latest", "main", "multi"},
+ ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=multi>; rel="next"`, user.Name, image),
+ },
+ {
+ URL: fmt.Sprintf("%s/tags/list?n=0", url),
+ ExpectedTags: []string{},
+ ExpectedLink: "",
+ },
+ {
+ URL: fmt.Sprintf("%s/tags/list?n=2", url),
+ ExpectedTags: []string{"latest", "main"},
+ ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=main&n=2>; rel="next"`, user.Name, image),
+ },
+ {
+ URL: fmt.Sprintf("%s/tags/list?last=main", url),
+ ExpectedTags: []string{"multi"},
+ ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=multi>; rel="next"`, user.Name, image),
+ },
+ {
+ URL: fmt.Sprintf("%s/tags/list?n=1&last=latest", url),
+ ExpectedTags: []string{"main"},
+ ExpectedLink: fmt.Sprintf(`</v2/%s/%s/tags/list?last=main&n=1>; rel="next"`, user.Name, image),
+ },
+ }
+
+ for _, c := range cases {
+ req := NewRequest(t, "GET", c.URL).
+ AddTokenAuth(userToken)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type TagList struct {
+ Name string `json:"name"`
+ Tags []string `json:"tags"`
+ }
+
+ tagList := &TagList{}
+ DecodeJSON(t, resp, &tagList)
+
+ assert.Equal(t, user.Name+"/"+image, tagList.Name)
+ assert.Equal(t, c.ExpectedTags, tagList.Tags)
+ assert.Equal(t, c.ExpectedLink, resp.Header().Get("Link"))
+ }
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s?type=container&q=%s", user.Name, image)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiPackages []*api.Package
+ DecodeJSON(t, resp, &apiPackages)
+ assert.Len(t, apiPackages, 4) // "latest", "main", "multi", "sha256:..."
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ t.Run("Blob", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusAccepted)
+
+ req = NewRequest(t, "HEAD", fmt.Sprintf("%s/blobs/%s", url, blobDigest)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("ManifestByDigest", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/manifests/%s", url, untaggedManifestDigest)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusAccepted)
+
+ req = NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/%s", url, untaggedManifestDigest)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("ManifestByTag", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/manifests/%s", url, multiTag)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusAccepted)
+
+ req = NewRequest(t, "HEAD", fmt.Sprintf("%s/manifests/%s", url, multiTag)).
+ AddTokenAuth(userToken)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+ })
+ }
+
+ // https://github.com/go-gitea/gitea/issues/19586
+ t.Run("ParallelUpload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ url := fmt.Sprintf("%sv2/%s/parallel", setting.AppURL, user.Name)
+
+ var wg sync.WaitGroup
+ for i := 0; i < 10; i++ {
+ wg.Add(1)
+
+ content := []byte{byte(i)}
+ digest := fmt.Sprintf("sha256:%x", sha256.Sum256(content))
+
+ go func() {
+ defer wg.Done()
+
+ req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/blobs/uploads?digest=%s", url, digest), bytes.NewReader(content)).
+ AddTokenAuth(userToken)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ assert.Equal(t, digest, resp.Header().Get("Docker-Content-Digest"))
+ }()
+ }
+ wg.Wait()
+ })
+
+ t.Run("OwnerNameChange", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ checkCatalog := func(owner string) func(t *testing.T) {
+ return func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%sv2/_catalog", setting.AppURL)).
+ AddTokenAuth(userToken)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type RepositoryList struct {
+ Repositories []string `json:"repositories"`
+ }
+
+ repoList := &RepositoryList{}
+ DecodeJSON(t, resp, &repoList)
+
+ assert.Len(t, repoList.Repositories, len(images))
+ names := make([]string, 0, len(images))
+ for _, image := range images {
+ names = append(names, strings.ToLower(owner+"/"+image))
+ }
+ assert.ElementsMatch(t, names, repoList.Repositories)
+ }
+ }
+
+ t.Run(fmt.Sprintf("Catalog[%s]", user.LowerName), checkCatalog(user.LowerName))
+
+ session := loginUser(t, user.Name)
+
+ newOwnerName := "newUsername"
+
+ req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings"),
+ "name": newOwnerName,
+ "email": "user2@example.com",
+ "language": "en-US",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ t.Run(fmt.Sprintf("Catalog[%s]", newOwnerName), checkCatalog(newOwnerName))
+
+ req = NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings"),
+ "name": user.Name,
+ "email": "user2@example.com",
+ "language": "en-US",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ })
+}
diff --git a/tests/integration/api_packages_cran_test.go b/tests/integration/api_packages_cran_test.go
new file mode 100644
index 0000000..31864d1
--- /dev/null
+++ b/tests/integration/api_packages_cran_test.go
@@ -0,0 +1,236 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "bytes"
+ "compress/gzip"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ cran_module "code.gitea.io/gitea/modules/packages/cran"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageCran(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ packageName := "test.package"
+ packageVersion := "1.0.3"
+ packageAuthor := "KN4CK3R"
+ packageDescription := "Gitea Test Package"
+
+ createDescription := func(name, version string) []byte {
+ var buf bytes.Buffer
+ fmt.Fprintln(&buf, "Package:", name)
+ fmt.Fprintln(&buf, "Version:", version)
+ fmt.Fprintln(&buf, "Description:", packageDescription)
+ fmt.Fprintln(&buf, "Imports: abc,\n123")
+ fmt.Fprintln(&buf, "NeedsCompilation: yes")
+ fmt.Fprintln(&buf, "License: MIT")
+ fmt.Fprintln(&buf, "Author:", packageAuthor)
+ return buf.Bytes()
+ }
+
+ url := fmt.Sprintf("/api/packages/%s/cran", user.Name)
+
+ t.Run("Source", func(t *testing.T) {
+ createArchive := func(filename string, content []byte) *bytes.Buffer {
+ var buf bytes.Buffer
+ gw := gzip.NewWriter(&buf)
+ tw := tar.NewWriter(gw)
+ hdr := &tar.Header{
+ Name: filename,
+ Mode: 0o600,
+ Size: int64(len(content)),
+ }
+ tw.WriteHeader(hdr)
+ tw.Write(content)
+ tw.Close()
+ gw.Close()
+ return &buf
+ }
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadURL := url + "/src"
+
+ req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{}))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, createArchive(
+ "dummy.txt",
+ []byte{},
+ )).AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, createArchive(
+ "package/DESCRIPTION",
+ createDescription(packageName, packageVersion),
+ )).AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeCran)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.Nil(t, pd.SemVer)
+ assert.IsType(t, &cran_module.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, fmt.Sprintf("%s_%s.tar.gz", packageName, packageVersion), pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, createArchive(
+ "package/DESCRIPTION",
+ createDescription(packageName, packageVersion),
+ )).AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusConflict)
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/src/contrib/%s_%s.tar.gz", url, packageName, packageVersion)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("Enumerate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", url+"/src/contrib/PACKAGES").
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Contains(t, resp.Header().Get("Content-Type"), "text/plain")
+
+ body := resp.Body.String()
+ assert.Contains(t, body, fmt.Sprintf("Package: %s", packageName))
+ assert.Contains(t, body, fmt.Sprintf("Version: %s", packageVersion))
+
+ req = NewRequest(t, "GET", url+"/src/contrib/PACKAGES.gz").
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Contains(t, resp.Header().Get("Content-Type"), "application/x-gzip")
+ })
+ })
+
+ t.Run("Binary", func(t *testing.T) {
+ createArchive := func(filename string, content []byte) *bytes.Buffer {
+ var buf bytes.Buffer
+ archive := zip.NewWriter(&buf)
+ w, _ := archive.Create(filename)
+ w.Write(content)
+ archive.Close()
+ return &buf
+ }
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadURL := url + "/bin"
+
+ req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{}))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, createArchive(
+ "dummy.txt",
+ []byte{},
+ )).AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL+"?platform=&rversion=", createArchive(
+ "package/DESCRIPTION",
+ createDescription(packageName, packageVersion),
+ )).AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ uploadURL += "?platform=windows&rversion=4.2"
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, createArchive(
+ "package/DESCRIPTION",
+ createDescription(packageName, packageVersion),
+ )).AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeCran)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 2)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, createArchive(
+ "package/DESCRIPTION",
+ createDescription(packageName, packageVersion),
+ )).AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusConflict)
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ cases := []struct {
+ Platform string
+ RVersion string
+ ExpectedStatus int
+ }{
+ {"osx", "4.2", http.StatusNotFound},
+ {"windows", "4.1", http.StatusNotFound},
+ {"windows", "4.2", http.StatusOK},
+ }
+
+ for _, c := range cases {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/bin/%s/contrib/%s/%s_%s.zip", url, c.Platform, c.RVersion, packageName, packageVersion)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, c.ExpectedStatus)
+ }
+ })
+
+ t.Run("Enumerate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", url+"/bin/windows/contrib/4.1/PACKAGES")
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", url+"/bin/windows/contrib/4.2/PACKAGES").
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Contains(t, resp.Header().Get("Content-Type"), "text/plain")
+
+ body := resp.Body.String()
+ assert.Contains(t, body, fmt.Sprintf("Package: %s", packageName))
+ assert.Contains(t, body, fmt.Sprintf("Version: %s", packageVersion))
+
+ req = NewRequest(t, "GET", url+"/bin/windows/contrib/4.2/PACKAGES.gz").
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Contains(t, resp.Header().Get("Content-Type"), "application/x-gzip")
+ })
+ })
+}
diff --git a/tests/integration/api_packages_debian_test.go b/tests/integration/api_packages_debian_test.go
new file mode 100644
index 0000000..d85f56f
--- /dev/null
+++ b/tests/integration/api_packages_debian_test.go
@@ -0,0 +1,267 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ debian_module "code.gitea.io/gitea/modules/packages/debian"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/blakesmith/ar"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageDebian(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ packageName := "gitea"
+ packageVersion := "1.0.3"
+ packageVersion2 := "1.0.4"
+ packageDescription := "Package Description"
+
+ createArchive := func(name, version, architecture string) io.Reader {
+ var cbuf bytes.Buffer
+ zw := gzip.NewWriter(&cbuf)
+ tw := tar.NewWriter(zw)
+ tw.WriteHeader(&tar.Header{
+ Name: "control",
+ Mode: 0o600,
+ Size: 50,
+ })
+ fmt.Fprintf(tw, "Package: %s\nVersion: %s\nArchitecture: %s\nDescription: %s\n", name, version, architecture, packageDescription)
+ tw.Close()
+ zw.Close()
+
+ var buf bytes.Buffer
+ aw := ar.NewWriter(&buf)
+ aw.WriteGlobalHeader()
+ hdr := &ar.Header{
+ Name: "control.tar.gz",
+ Mode: 0o600,
+ Size: int64(cbuf.Len()),
+ }
+ aw.WriteHeader(hdr)
+ aw.Write(cbuf.Bytes())
+ return &buf
+ }
+
+ distributions := []string{"test", "gitea"}
+ components := []string{"main", "stable"}
+ architectures := []string{"all", "amd64"}
+
+ rootURL := fmt.Sprintf("/api/packages/%s/debian", user.Name)
+
+ t.Run("RepositoryKey", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", rootURL+"/repository.key")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "application/pgp-keys", resp.Header().Get("Content-Type"))
+ assert.Contains(t, resp.Body.String(), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
+ })
+
+ for _, distribution := range distributions {
+ t.Run(fmt.Sprintf("[Distribution:%s]", distribution), func(t *testing.T) {
+ for _, component := range components {
+ for _, architecture := range architectures {
+ t.Run(fmt.Sprintf("[Component:%s,Architecture:%s]", component, architecture), func(t *testing.T) {
+ uploadURL := fmt.Sprintf("%s/pool/%s/%s/upload", rootURL, distribution, component)
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{}))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{})).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, createArchive("", "", "")).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, createArchive(packageName, packageVersion, architecture)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeDebian, packageName, packageVersion)
+ require.NoError(t, err)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pv)
+ require.NoError(t, err)
+ assert.Nil(t, pd.SemVer)
+ assert.IsType(t, &debian_module.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pv.ID)
+ require.NoError(t, err)
+ assert.NotEmpty(t, pfs)
+ assert.Condition(t, func() bool {
+ seen := false
+ expectedFilename := fmt.Sprintf("%s_%s_%s.deb", packageName, packageVersion, architecture)
+ expectedCompositeKey := fmt.Sprintf("%s|%s", distribution, component)
+ for _, pf := range pfs {
+ if pf.Name == expectedFilename && pf.CompositeKey == expectedCompositeKey {
+ if seen {
+ return false
+ }
+ seen = true
+
+ assert.True(t, pf.IsLead)
+
+ pfps, err := packages.GetProperties(db.DefaultContext, packages.PropertyTypeFile, pf.ID)
+ require.NoError(t, err)
+
+ for _, pfp := range pfps {
+ switch pfp.Name {
+ case debian_module.PropertyDistribution:
+ assert.Equal(t, distribution, pfp.Value)
+ case debian_module.PropertyComponent:
+ assert.Equal(t, component, pfp.Value)
+ case debian_module.PropertyArchitecture:
+ assert.Equal(t, architecture, pfp.Value)
+ }
+ }
+ }
+ }
+ return seen
+ })
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, createArchive(packageName, packageVersion, architecture)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusConflict)
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/pool/%s/%s/%s_%s_%s.deb", rootURL, distribution, component, packageName, packageVersion, architecture))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "application/vnd.debian.binary-package", resp.Header().Get("Content-Type"))
+ })
+
+ t.Run("Packages", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", uploadURL, createArchive(packageName, packageVersion2, architecture)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ url := fmt.Sprintf("%s/dists/%s/%s/binary-%s/Packages", rootURL, distribution, component, architecture)
+
+ req = NewRequest(t, "GET", url)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ body := resp.Body.String()
+
+ assert.Contains(t, body, "Package: "+packageName+"\n")
+ assert.Contains(t, body, "Version: "+packageVersion+"\n")
+ assert.Contains(t, body, "Version: "+packageVersion2+"\n")
+ assert.Contains(t, body, "Architecture: "+architecture+"\n")
+ assert.Contains(t, body, fmt.Sprintf("Filename: pool/%s/%s/%s_%s_%s.deb\n", distribution, component, packageName, packageVersion, architecture))
+ assert.Contains(t, body, fmt.Sprintf("Filename: pool/%s/%s/%s_%s_%s.deb\n", distribution, component, packageName, packageVersion2, architecture))
+
+ req = NewRequest(t, "GET", url+".gz")
+ MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", url+".xz")
+ MakeRequest(t, req, http.StatusOK)
+
+ url = fmt.Sprintf("%s/dists/%s/%s/%s/by-hash/SHA256/%s", rootURL, distribution, component, architecture, base.EncodeSha256(body))
+ req = NewRequest(t, "GET", url)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, body, resp.Body.String())
+ })
+ })
+ }
+ }
+
+ t.Run("Release", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/dists/%s/Release", rootURL, distribution))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ body := resp.Body.String()
+
+ assert.Contains(t, body, "Components: "+strings.Join(components, " ")+"\n")
+ assert.Contains(t, body, "Architectures: "+strings.Join(architectures, " ")+"\n")
+
+ for _, component := range components {
+ for _, architecture := range architectures {
+ assert.Contains(t, body, fmt.Sprintf("%s/binary-%s/Packages\n", component, architecture))
+ assert.Contains(t, body, fmt.Sprintf("%s/binary-%s/Packages.gz\n", component, architecture))
+ assert.Contains(t, body, fmt.Sprintf("%s/binary-%s/Packages.xz\n", component, architecture))
+ }
+ }
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/dists/%s/by-hash/SHA256/%s", rootURL, distribution, base.EncodeSha256(body)))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, body, resp.Body.String())
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/dists/%s/Release.gpg", rootURL, distribution))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Contains(t, resp.Body.String(), "-----BEGIN PGP SIGNATURE-----")
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/dists/%s/InRelease", rootURL, distribution))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Contains(t, resp.Body.String(), "-----BEGIN PGP SIGNED MESSAGE-----")
+ })
+ })
+ }
+
+ t.Run("Delete", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ distribution := distributions[0]
+ architecture := architectures[0]
+
+ for _, component := range components {
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/pool/%s/%s/%s/%s/%s", rootURL, distribution, component, packageName, packageVersion, architecture))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/pool/%s/%s/%s/%s/%s", rootURL, distribution, component, packageName, packageVersion, architecture)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/pool/%s/%s/%s/%s/%s", rootURL, distribution, component, packageName, packageVersion2, architecture)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/dists/%s/%s/binary-%s/Packages", rootURL, distribution, component, architecture))
+ MakeRequest(t, req, http.StatusNotFound)
+ }
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/dists/%s/Release", rootURL, distribution))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ body := resp.Body.String()
+
+ assert.Contains(t, body, "Components: "+strings.Join(components, " ")+"\n")
+ assert.Contains(t, body, "Architectures: "+architectures[1]+"\n")
+ })
+}
diff --git a/tests/integration/api_packages_generic_test.go b/tests/integration/api_packages_generic_test.go
new file mode 100644
index 0000000..1a53f33
--- /dev/null
+++ b/tests/integration/api_packages_generic_test.go
@@ -0,0 +1,245 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageGeneric(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ packageName := "te-st_pac.kage"
+ packageVersion := "1.0.3-te st"
+ filename := "fi-le_na.me"
+ content := []byte{1, 2, 3}
+
+ url := fmt.Sprintf("/api/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", url+"/"+filename, bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeGeneric)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.Nil(t, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, filename, pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(content)), pb.Size)
+
+ t.Run("Exists", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", url+"/"+filename, bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusConflict)
+ })
+
+ t.Run("Additional", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", url+"/dummy.bin", bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ // Check deduplication
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 2)
+ assert.Equal(t, pfs[0].BlobID, pfs[1].BlobID)
+ })
+
+ t.Run("InvalidParameter", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/generic/%s/%s/%s", user.Name, "invalid package name", packageVersion, filename), bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/generic/%s/%s/%s", user.Name, packageName, "%20test ", filename), bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/generic/%s/%s/%s", user.Name, packageName, packageVersion, "inva|id.name"), bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+ })
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ checkDownloadCount := func(count int64) {
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeGeneric)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+ assert.Equal(t, count, pvs[0].DownloadCount)
+ }
+
+ checkDownloadCount(0)
+
+ req := NewRequest(t, "GET", url+"/"+filename)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, content, resp.Body.Bytes())
+
+ checkDownloadCount(1)
+
+ req = NewRequest(t, "GET", url+"/dummy.bin")
+ MakeRequest(t, req, http.StatusOK)
+
+ checkDownloadCount(2)
+
+ t.Run("NotExists", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", url+"/not.found")
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("RequireSignInView", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ setting.Service.RequireSignInView = true
+ defer func() {
+ setting.Service.RequireSignInView = false
+ }()
+
+ req = NewRequest(t, "GET", url+"/dummy.bin")
+ MakeRequest(t, req, http.StatusUnauthorized)
+ })
+
+ t.Run("ServeDirect", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ if setting.Packages.Storage.Type != setting.MinioStorageType {
+ t.Skip("Test skipped for non-Minio-storage.")
+ return
+ }
+
+ if !setting.Packages.Storage.MinioConfig.ServeDirect {
+ old := setting.Packages.Storage.MinioConfig.ServeDirect
+ defer func() {
+ setting.Packages.Storage.MinioConfig.ServeDirect = old
+ }()
+
+ setting.Packages.Storage.MinioConfig.ServeDirect = true
+ }
+
+ req := NewRequest(t, "GET", url+"/"+filename)
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+
+ checkDownloadCount(3)
+
+ location := resp.Header().Get("Location")
+ assert.NotEmpty(t, location)
+
+ resp2, err := (&http.Client{}).Get(location)
+ require.NoError(t, err)
+ assert.Equal(t, http.StatusOK, resp2.StatusCode)
+
+ body, err := io.ReadAll(resp2.Body)
+ require.NoError(t, err)
+ assert.Equal(t, content, body)
+
+ checkDownloadCount(3)
+ })
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ t.Run("File", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", url+"/"+filename)
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "DELETE", url+"/"+filename).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "GET", url+"/"+filename)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "DELETE", url+"/"+filename).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeGeneric)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ t.Run("RemovesVersion", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req = NewRequest(t, "DELETE", url+"/dummy.bin").
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeGeneric)
+ require.NoError(t, err)
+ assert.Empty(t, pvs)
+ })
+ })
+
+ t.Run("Version", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", url+"/"+filename, bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequest(t, "DELETE", url)
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "DELETE", url).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeGeneric)
+ require.NoError(t, err)
+ assert.Empty(t, pvs)
+
+ req = NewRequest(t, "GET", url+"/"+filename)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "DELETE", url).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+}
diff --git a/tests/integration/api_packages_goproxy_test.go b/tests/integration/api_packages_goproxy_test.go
new file mode 100644
index 0000000..716d90b
--- /dev/null
+++ b/tests/integration/api_packages_goproxy_test.go
@@ -0,0 +1,167 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/zip"
+ "bytes"
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageGo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ packageName := "gitea.com/go-gitea/gitea"
+ packageVersion := "v0.0.1"
+ packageVersion2 := "v0.0.2"
+ goModContent := `module "gitea.com/go-gitea/gitea"`
+
+ createArchive := func(files map[string][]byte) []byte {
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ for name, content := range files {
+ w, _ := zw.Create(name)
+ w.Write(content)
+ }
+ zw.Close()
+ return buf.Bytes()
+ }
+
+ url := fmt.Sprintf("/api/packages/%s/go", user.Name)
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ content := createArchive(nil)
+
+ req := NewRequestWithBody(t, "PUT", url+"/upload", bytes.NewReader(content))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "PUT", url+"/upload", bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ content = createArchive(map[string][]byte{
+ packageName + "@" + packageVersion + "/go.mod": []byte(goModContent),
+ })
+
+ req = NewRequestWithBody(t, "PUT", url+"/upload", bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeGo)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.Nil(t, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, packageVersion+".zip", pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(content)), pb.Size)
+
+ req = NewRequestWithBody(t, "PUT", url+"/upload", bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusConflict)
+
+ time.Sleep(time.Second)
+
+ content = createArchive(map[string][]byte{
+ packageName + "@" + packageVersion2 + "/go.mod": []byte(goModContent),
+ })
+
+ req = NewRequestWithBody(t, "PUT", url+"/upload", bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+ })
+
+ t.Run("List", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/@v/list", url, packageName))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, packageVersion+"\n"+packageVersion2+"\n", resp.Body.String())
+ })
+
+ t.Run("Info", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/@v/%s.info", url, packageName, packageVersion))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type Info struct {
+ Version string `json:"Version"`
+ Time time.Time `json:"Time"`
+ }
+
+ info := &Info{}
+ DecodeJSON(t, resp, &info)
+
+ assert.Equal(t, packageVersion, info.Version)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/@v/latest.info", url, packageName))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ info = &Info{}
+ DecodeJSON(t, resp, &info)
+
+ assert.Equal(t, packageVersion2, info.Version)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/@latest", url, packageName))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ info = &Info{}
+ DecodeJSON(t, resp, &info)
+
+ assert.Equal(t, packageVersion2, info.Version)
+ })
+
+ t.Run("GoMod", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/@v/%s.mod", url, packageName, packageVersion))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, goModContent, resp.Body.String())
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/@v/latest.mod", url, packageName))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, goModContent, resp.Body.String())
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/@v/%s.zip", url, packageName, packageVersion))
+ MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/@v/latest.zip", url, packageName))
+ MakeRequest(t, req, http.StatusOK)
+ })
+}
diff --git a/tests/integration/api_packages_helm_test.go b/tests/integration/api_packages_helm_test.go
new file mode 100644
index 0000000..4b48b74
--- /dev/null
+++ b/tests/integration/api_packages_helm_test.go
@@ -0,0 +1,168 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ helm_module "code.gitea.io/gitea/modules/packages/helm"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+)
+
+func TestPackageHelm(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ packageName := "test-chart"
+ packageVersion := "1.0.3"
+ packageAuthor := "KN4CK3R"
+ packageDescription := "Gitea Test Package"
+
+ filename := fmt.Sprintf("%s-%s.tgz", packageName, packageVersion)
+
+ chartContent := `apiVersion: v2
+description: ` + packageDescription + `
+name: ` + packageName + `
+type: application
+version: ` + packageVersion + `
+maintainers:
+- name: ` + packageAuthor + `
+dependencies:
+- name: dep1
+ repository: https://example.com/
+ version: 1.0.0`
+
+ var buf bytes.Buffer
+ zw := gzip.NewWriter(&buf)
+ archive := tar.NewWriter(zw)
+ archive.WriteHeader(&tar.Header{
+ Name: fmt.Sprintf("%s/Chart.yaml", packageName),
+ Mode: 0o600,
+ Size: int64(len(chartContent)),
+ })
+ archive.Write([]byte(chartContent))
+ archive.Close()
+ zw.Close()
+ content := buf.Bytes()
+
+ url := fmt.Sprintf("/api/packages/%s/helm", user.Name)
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadURL := url + "/api/charts"
+
+ req := NewRequestWithBody(t, "POST", uploadURL, bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeHelm)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.NotNil(t, pd.SemVer)
+ assert.IsType(t, &helm_module.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, filename, pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(content)), pb.Size)
+
+ req = NewRequestWithBody(t, "POST", uploadURL, bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ checkDownloadCount := func(count int64) {
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeHelm)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+ assert.Equal(t, count, pvs[0].DownloadCount)
+ }
+
+ checkDownloadCount(0)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s", url, filename)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, content, resp.Body.Bytes())
+
+ checkDownloadCount(1)
+ })
+
+ t.Run("Index", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/index.yaml", url)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type ChartVersion struct {
+ helm_module.Metadata `yaml:",inline"`
+ URLs []string `yaml:"urls"`
+ Created time.Time `yaml:"created,omitempty"`
+ Removed bool `yaml:"removed,omitempty"`
+ Digest string `yaml:"digest,omitempty"`
+ }
+
+ type ServerInfo struct {
+ ContextPath string `yaml:"contextPath,omitempty"`
+ }
+
+ type Index struct {
+ APIVersion string `yaml:"apiVersion"`
+ Entries map[string][]*ChartVersion `yaml:"entries"`
+ Generated time.Time `yaml:"generated,omitempty"`
+ ServerInfo *ServerInfo `yaml:"serverInfo,omitempty"`
+ }
+
+ var result Index
+ require.NoError(t, yaml.NewDecoder(resp.Body).Decode(&result))
+ assert.NotEmpty(t, result.Entries)
+ assert.Contains(t, result.Entries, packageName)
+
+ cvs := result.Entries[packageName]
+ assert.Len(t, cvs, 1)
+
+ cv := cvs[0]
+ assert.Equal(t, packageName, cv.Name)
+ assert.Equal(t, packageVersion, cv.Version)
+ assert.Equal(t, packageDescription, cv.Description)
+ assert.Len(t, cv.Maintainers, 1)
+ assert.Equal(t, packageAuthor, cv.Maintainers[0].Name)
+ assert.Len(t, cv.Dependencies, 1)
+ assert.ElementsMatch(t, []string{fmt.Sprintf("%s%s/%s", setting.AppURL, url[1:], filename)}, cv.URLs)
+
+ assert.Equal(t, url, result.ServerInfo.ContextPath)
+ })
+}
diff --git a/tests/integration/api_packages_maven_test.go b/tests/integration/api_packages_maven_test.go
new file mode 100644
index 0000000..7ada3b2
--- /dev/null
+++ b/tests/integration/api_packages_maven_test.go
@@ -0,0 +1,256 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/packages/maven"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageMaven(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ groupID := "com.gitea"
+ artifactID := "test-project"
+ packageName := groupID + "-" + artifactID
+ packageVersion := "1.0.1"
+ packageDescription := "Test Description"
+
+ root := fmt.Sprintf("/api/packages/%s/maven/%s/%s", user.Name, strings.ReplaceAll(groupID, ".", "/"), artifactID)
+ filename := fmt.Sprintf("%s-%s.jar", packageName, packageVersion)
+
+ putFile := func(t *testing.T, path, content string, expectedStatus int) {
+ req := NewRequestWithBody(t, "PUT", root+path, strings.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, expectedStatus)
+ }
+
+ checkHeaders := func(t *testing.T, h http.Header, contentType string, contentLength int64) {
+ assert.Equal(t, contentType, h.Get("Content-Type"))
+ assert.Equal(t, strconv.FormatInt(contentLength, 10), h.Get("Content-Length"))
+ assert.NotEmpty(t, h.Get("Last-Modified"))
+ }
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ putFile(t, fmt.Sprintf("/%s/%s", packageVersion, filename), "test", http.StatusCreated)
+ putFile(t, fmt.Sprintf("/%s/%s", packageVersion, filename), "test", http.StatusConflict)
+ putFile(t, "/maven-metadata.xml", "test", http.StatusOK)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.Nil(t, pd.SemVer)
+ assert.Nil(t, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, filename, pfs[0].Name)
+ assert.False(t, pfs[0].IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(4), pb.Size)
+ })
+
+ t.Run("UploadExists", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ putFile(t, fmt.Sprintf("/%s/%s", packageVersion, filename), "test", http.StatusConflict)
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "HEAD", fmt.Sprintf("%s/%s/%s", root, packageVersion, filename)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ checkHeaders(t, resp.Header(), "application/java-archive", 4)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s", root, packageVersion, filename)).
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ checkHeaders(t, resp.Header(), "application/java-archive", 4)
+
+ assert.Equal(t, []byte("test"), resp.Body.Bytes())
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+ assert.Equal(t, int64(0), pvs[0].DownloadCount)
+ })
+
+ t.Run("UploadVerifySHA1", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ t.Run("Mismatch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ putFile(t, fmt.Sprintf("/%s/%s.sha1", packageVersion, filename), "test", http.StatusBadRequest)
+ })
+ t.Run("Valid", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ putFile(t, fmt.Sprintf("/%s/%s.sha1", packageVersion, filename), "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", http.StatusOK)
+ })
+ })
+
+ pomContent := `<?xml version="1.0"?>
+<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <groupId>` + groupID + `</groupId>
+ <artifactId>` + artifactID + `</artifactId>
+ <version>` + packageVersion + `</version>
+ <description>` + packageDescription + `</description>
+</project>`
+
+ t.Run("UploadPOM", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.Nil(t, pd.Metadata)
+
+ putFile(t, fmt.Sprintf("/%s/%s.pom", packageVersion, filename), pomContent, http.StatusCreated)
+
+ pvs, err = packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err = packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.IsType(t, &maven.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageDescription, pd.Metadata.(*maven.Metadata).Description)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 2)
+ for _, pf := range pfs {
+ if strings.HasSuffix(pf.Name, ".pom") {
+ assert.Equal(t, filename+".pom", pf.Name)
+ assert.True(t, pf.IsLead)
+ } else {
+ assert.False(t, pf.IsLead)
+ }
+ }
+ })
+
+ t.Run("DownloadPOM", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "HEAD", fmt.Sprintf("%s/%s/%s.pom", root, packageVersion, filename)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ checkHeaders(t, resp.Header(), "text/xml", int64(len(pomContent)))
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s.pom", root, packageVersion, filename)).
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ checkHeaders(t, resp.Header(), "text/xml", int64(len(pomContent)))
+
+ assert.Equal(t, []byte(pomContent), resp.Body.Bytes())
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeMaven)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+ assert.Equal(t, int64(1), pvs[0].DownloadCount)
+ })
+
+ t.Run("DownloadChecksums", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/1.2.3/%s", root, filename)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ for key, checksum := range map[string]string{
+ "md5": "098f6bcd4621d373cade4e832627b4f6",
+ "sha1": "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3",
+ "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
+ "sha512": "ee26b0dd4af7e749aa1a8ee3c10ae9923f618980772e473f8819a5d4940e0db27ac185f8a0e1d5f84f88bc887fd67b143732c304cc5fa9ad8e6f57f50028a8ff",
+ } {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s.%s", root, packageVersion, filename, key)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, checksum, resp.Body.String())
+ }
+ })
+
+ t.Run("DownloadMetadata", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", root+"/maven-metadata.xml").
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ expectedMetadata := `<?xml version="1.0" encoding="UTF-8"?>` + "\n<metadata><groupId>com.gitea</groupId><artifactId>test-project</artifactId><versioning><release>1.0.1</release><latest>1.0.1</latest><versions><version>1.0.1</version></versions></versioning></metadata>"
+
+ checkHeaders(t, resp.Header(), "text/xml", int64(len(expectedMetadata)))
+
+ assert.Equal(t, expectedMetadata, resp.Body.String())
+
+ for key, checksum := range map[string]string{
+ "md5": "6bee0cebaaa686d658adf3e7e16371a0",
+ "sha1": "8696abce499fe84d9ea93e5492abe7147e195b6c",
+ "sha256": "3f48322f81c4b2c3bb8649ae1e5c9801476162b520e1c2734ac06b2c06143208",
+ "sha512": "cb075aa2e2ef1a83cdc14dd1e08c505b72d633399b39e73a21f00f0deecb39a3e2c79f157c1163f8a3854828750706e0dec3a0f5e4778e91f8ec2cf351a855f2",
+ } {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/maven-metadata.xml.%s", root, key)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, checksum, resp.Body.String())
+ }
+ })
+
+ t.Run("UploadSnapshot", func(t *testing.T) {
+ snapshotVersion := packageVersion + "-SNAPSHOT"
+
+ putFile(t, fmt.Sprintf("/%s/%s", snapshotVersion, filename), "test", http.StatusCreated)
+ putFile(t, "/maven-metadata.xml", "test", http.StatusOK)
+ putFile(t, fmt.Sprintf("/%s/maven-metadata.xml", snapshotVersion), "test", http.StatusCreated)
+ putFile(t, fmt.Sprintf("/%s/maven-metadata.xml", snapshotVersion), "test-overwrite", http.StatusCreated)
+ })
+
+ t.Run("Partial upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ partialVersion := packageVersion + "-PARTIAL"
+ putFile(t, fmt.Sprintf("/%s/%s", partialVersion, filename), "test", http.StatusCreated)
+ pkgUIURL := fmt.Sprintf("/%s/-/packages/maven/%s-%s/%s", user.Name, groupID, artifactID, partialVersion)
+ req := NewRequest(t, "GET", pkgUIURL)
+ resp := MakeRequest(t, req, http.StatusOK)
+ assert.NotContains(t, resp.Body.String(), "Internal server error")
+ })
+}
diff --git a/tests/integration/api_packages_npm_test.go b/tests/integration/api_packages_npm_test.go
new file mode 100644
index 0000000..d0c54c3
--- /dev/null
+++ b/tests/integration/api_packages_npm_test.go
@@ -0,0 +1,332 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/packages/npm"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageNpm(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ token := fmt.Sprintf("Bearer %s", getTokenForLoggedInUser(t, loginUser(t, user.Name), auth_model.AccessTokenScopeWritePackage))
+
+ packageName := "@scope/test-package"
+ packageVersion := "1.0.1-pre"
+ packageTag := "latest"
+ packageTag2 := "release"
+ packageAuthor := "KN4CK3R"
+ packageDescription := "Test Description"
+ packageBinName := "cli"
+ packageBinPath := "./cli.sh"
+ repoType := "gitea"
+ repoURL := "http://localhost:3000/gitea/test.git"
+
+ data := "H4sIAAAAAAAA/ytITM5OTE/VL4DQelnF+XkMVAYGBgZmJiYK2MRBwNDcSIHB2NTMwNDQzMwAqA7IMDUxA9LUdgg2UFpcklgEdAql5kD8ogCnhwio5lJQUMpLzE1VslJQcihOzi9I1S9JLS7RhSYIJR2QgrLUouLM/DyQGkM9Az1D3YIiqExKanFyUWZBCVQ2BKhVwQVJDKwosbQkI78IJO/tZ+LsbRykxFXLNdA+HwWjYBSMgpENACgAbtAACAAA"
+
+ buildUpload := func(version string) string {
+ return `{
+ "_id": "` + packageName + `",
+ "name": "` + packageName + `",
+ "description": "` + packageDescription + `",
+ "dist-tags": {
+ "` + packageTag + `": "` + version + `"
+ },
+ "versions": {
+ "` + version + `": {
+ "name": "` + packageName + `",
+ "version": "` + version + `",
+ "description": "` + packageDescription + `",
+ "author": {
+ "name": "` + packageAuthor + `"
+ },
+ "bin": {
+ "` + packageBinName + `": "` + packageBinPath + `"
+ },
+ "dist": {
+ "integrity": "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg==",
+ "shasum": "aaa7eaf852a948b0aa05afeda35b1badca155d90"
+ },
+ "repository": {
+ "type": "` + repoType + `",
+ "url": "` + repoURL + `"
+ }
+ }
+ },
+ "_attachments": {
+ "` + packageName + `-` + version + `.tgz": {
+ "data": "` + data + `"
+ }
+ }
+ }`
+ }
+
+ root := fmt.Sprintf("/api/packages/%s/npm/%s", user.Name, url.QueryEscape(packageName))
+ tagsRoot := fmt.Sprintf("/api/packages/%s/npm/-/package/%s/dist-tags", user.Name, url.QueryEscape(packageName))
+ filename := fmt.Sprintf("%s-%s.tgz", strings.Split(packageName, "/")[1], packageVersion)
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", root, strings.NewReader(buildUpload(packageVersion))).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNpm)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.NotNil(t, pd.SemVer)
+ assert.IsType(t, &npm.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+ assert.Len(t, pd.VersionProperties, 1)
+ assert.Equal(t, npm.TagProperty, pd.VersionProperties[0].Name)
+ assert.Equal(t, packageTag, pd.VersionProperties[0].Value)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, filename, pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(192), pb.Size)
+ })
+
+ t.Run("UploadExists", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", root, strings.NewReader(buildUpload(packageVersion))).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusConflict)
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/-/%s/%s", root, packageVersion, filename)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ b, _ := base64.StdEncoding.DecodeString(data)
+ assert.Equal(t, b, resp.Body.Bytes())
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/-/%s", root, filename)).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, b, resp.Body.Bytes())
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNpm)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+ assert.Equal(t, int64(2), pvs[0].DownloadCount)
+ })
+
+ t.Run("PackageMetadata", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/packages/%s/npm/%s", user.Name, "does-not-exist")).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", root).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result npm.PackageMetadata
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, packageName, result.ID)
+ assert.Equal(t, packageName, result.Name)
+ assert.Equal(t, packageDescription, result.Description)
+ assert.Contains(t, result.DistTags, packageTag)
+ assert.Equal(t, packageVersion, result.DistTags[packageTag])
+ assert.Equal(t, packageAuthor, result.Author.Name)
+ assert.Contains(t, result.Versions, packageVersion)
+ pmv := result.Versions[packageVersion]
+ assert.Equal(t, fmt.Sprintf("%s@%s", packageName, packageVersion), pmv.ID)
+ assert.Equal(t, packageName, pmv.Name)
+ assert.Equal(t, packageDescription, pmv.Description)
+ assert.Equal(t, packageAuthor, pmv.Author.Name)
+ assert.Equal(t, packageBinPath, pmv.Bin[packageBinName])
+ assert.Equal(t, "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg==", pmv.Dist.Integrity)
+ assert.Equal(t, "aaa7eaf852a948b0aa05afeda35b1badca155d90", pmv.Dist.Shasum)
+ assert.Equal(t, fmt.Sprintf("%s%s/-/%s/%s", setting.AppURL, root[1:], packageVersion, filename), pmv.Dist.Tarball)
+ assert.Equal(t, repoType, result.Repository.Type)
+ assert.Equal(t, repoURL, result.Repository.URL)
+ })
+
+ t.Run("AddTag", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ test := func(t *testing.T, status int, tag, version string) {
+ req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/%s", tagsRoot, tag), strings.NewReader(`"`+version+`"`)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, status)
+ }
+
+ test(t, http.StatusBadRequest, "1.0", packageVersion)
+ test(t, http.StatusBadRequest, "v1.0", packageVersion)
+ test(t, http.StatusNotFound, packageTag2, "1.2")
+ test(t, http.StatusOK, packageTag, packageVersion)
+ test(t, http.StatusOK, packageTag2, packageVersion)
+ })
+
+ t.Run("ListTags", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", tagsRoot).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result map[string]string
+ DecodeJSON(t, resp, &result)
+
+ assert.Len(t, result, 2)
+ assert.Contains(t, result, packageTag)
+ assert.Equal(t, packageVersion, result[packageTag])
+ assert.Contains(t, result, packageTag2)
+ assert.Equal(t, packageVersion, result[packageTag2])
+ })
+
+ t.Run("PackageMetadataDistTags", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", root).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result npm.PackageMetadata
+ DecodeJSON(t, resp, &result)
+
+ assert.Len(t, result.DistTags, 2)
+ assert.Contains(t, result.DistTags, packageTag)
+ assert.Equal(t, packageVersion, result.DistTags[packageTag])
+ assert.Contains(t, result.DistTags, packageTag2)
+ assert.Equal(t, packageVersion, result.DistTags[packageTag2])
+ })
+
+ t.Run("DeleteTag", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ test := func(t *testing.T, status int, tag string) {
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s", tagsRoot, tag)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, status)
+ }
+
+ test(t, http.StatusBadRequest, "v1.0")
+ test(t, http.StatusBadRequest, "1.0")
+ test(t, http.StatusOK, "dummy")
+ test(t, http.StatusOK, packageTag2)
+ })
+
+ t.Run("Search", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ url := fmt.Sprintf("/api/packages/%s/npm/-/v1/search", user.Name)
+
+ cases := []struct {
+ Query string
+ Skip int
+ Take int
+ ExpectedTotal int64
+ ExpectedResults int
+ }{
+ {"", 0, 0, 1, 1},
+ {"", 0, 10, 1, 1},
+ {"gitea", 0, 10, 0, 0},
+ {"test", 0, 10, 1, 1},
+ {"test", 1, 10, 1, 0},
+ }
+
+ for i, c := range cases {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s?text=%s&from=%d&size=%d", url, c.Query, c.Skip, c.Take))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result npm.PackageSearch
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, c.ExpectedTotal, result.Total, "case %d: unexpected total hits", i)
+ assert.Len(t, result.Objects, c.ExpectedResults, "case %d: unexpected result count", i)
+ }
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", root, strings.NewReader(buildUpload(packageVersion+"-dummy"))).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequest(t, "PUT", root+"/-rev/dummy")
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "PUT", root+"/-rev/dummy").
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ t.Run("Version", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNpm)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 2)
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/%s/-rev/dummy", root, packageVersion, filename))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/-/%s/%s/-rev/dummy", root, packageVersion, filename)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ pvs, err = packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNpm)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+ })
+
+ t.Run("Full", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNpm)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ req := NewRequest(t, "DELETE", root+"/-rev/dummy")
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "DELETE", root+"/-rev/dummy").
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ pvs, err = packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNpm)
+ require.NoError(t, err)
+ assert.Empty(t, pvs)
+ })
+ })
+}
diff --git a/tests/integration/api_packages_nuget_test.go b/tests/integration/api_packages_nuget_test.go
new file mode 100644
index 0000000..03e2176
--- /dev/null
+++ b/tests/integration/api_packages_nuget_test.go
@@ -0,0 +1,763 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/base64"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ neturl "net/url"
+ "strconv"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ nuget_module "code.gitea.io/gitea/modules/packages/nuget"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/routers/api/packages/nuget"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func addNuGetAPIKeyHeader(req *RequestWrapper, token string) {
+ req.SetHeader("X-NuGet-ApiKey", token)
+}
+
+func decodeXML(t testing.TB, resp *httptest.ResponseRecorder, v any) {
+ t.Helper()
+
+ require.NoError(t, xml.NewDecoder(resp.Body).Decode(v))
+}
+
+func TestPackageNuGet(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ type FeedEntryProperties struct {
+ Version string `xml:"Version"`
+ NormalizedVersion string `xml:"NormalizedVersion"`
+ Authors string `xml:"Authors"`
+ Dependencies string `xml:"Dependencies"`
+ Description string `xml:"Description"`
+ VersionDownloadCount nuget.TypedValue[int64] `xml:"VersionDownloadCount"`
+ DownloadCount nuget.TypedValue[int64] `xml:"DownloadCount"`
+ PackageSize nuget.TypedValue[int64] `xml:"PackageSize"`
+ Created nuget.TypedValue[time.Time] `xml:"Created"`
+ LastUpdated nuget.TypedValue[time.Time] `xml:"LastUpdated"`
+ Published nuget.TypedValue[time.Time] `xml:"Published"`
+ ProjectURL string `xml:"ProjectUrl,omitempty"`
+ ReleaseNotes string `xml:"ReleaseNotes,omitempty"`
+ RequireLicenseAcceptance nuget.TypedValue[bool] `xml:"RequireLicenseAcceptance"`
+ Title string `xml:"Title"`
+ }
+
+ type FeedEntry struct {
+ XMLName xml.Name `xml:"entry"`
+ Properties *FeedEntryProperties `xml:"properties"`
+ Content string `xml:",innerxml"`
+ }
+
+ type FeedEntryLink struct {
+ Rel string `xml:"rel,attr"`
+ Href string `xml:"href,attr"`
+ }
+
+ type FeedResponse struct {
+ XMLName xml.Name `xml:"feed"`
+ Links []FeedEntryLink `xml:"link"`
+ Entries []*FeedEntry `xml:"entry"`
+ Count int64 `xml:"count"`
+ }
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ token := getUserToken(t, user.Name, auth_model.AccessTokenScopeWritePackage)
+
+ packageName := "test.package"
+ packageVersion := "1.0.3"
+ packageAuthors := "KN4CK3R"
+ packageDescription := "Gitea Test Package"
+ symbolFilename := "test.pdb"
+ symbolID := "d910bb6948bd4c6cb40155bcf52c3c94"
+
+ createPackage := func(id, version string) io.Reader {
+ var buf bytes.Buffer
+ archive := zip.NewWriter(&buf)
+ w, _ := archive.Create("package.nuspec")
+ w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
+ <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
+ <metadata>
+ <id>` + id + `</id>
+ <version>` + version + `</version>
+ <authors>` + packageAuthors + `</authors>
+ <description>` + packageDescription + `</description>
+ <dependencies>
+ <group targetFramework=".NETStandard2.0">
+ <dependency id="Microsoft.CSharp" version="4.5.0" />
+ </group>
+ </dependencies>
+ </metadata>
+ </package>`))
+ archive.Close()
+ return &buf
+ }
+
+ nuspec := `<?xml version="1.0" encoding="utf-8"?>
+ <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
+ <metadata>
+ <id>` + packageName + `</id>
+ <version>` + packageVersion + `</version>
+ <authors>` + packageAuthors + `</authors>
+ <description>` + packageDescription + `</description>
+ <dependencies>
+ <group targetFramework=".NETStandard2.0">
+ <dependency id="Microsoft.CSharp" version="4.5.0" />
+ </group>
+ </dependencies>
+ </metadata>
+ </package>`
+ content, _ := io.ReadAll(createPackage(packageName, packageVersion))
+
+ url := fmt.Sprintf("/api/packages/%s/nuget", user.Name)
+
+ t.Run("ServiceIndex", func(t *testing.T) {
+ t.Run("v2", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Visibility: structs.VisibleTypePrivate})
+
+ cases := []struct {
+ Owner string
+ UseBasicAuth bool
+ UseTokenAuth bool
+ }{
+ {privateUser.Name, false, false},
+ {privateUser.Name, true, false},
+ {privateUser.Name, false, true},
+ {user.Name, false, false},
+ {user.Name, true, false},
+ {user.Name, false, true},
+ }
+
+ for _, c := range cases {
+ url := fmt.Sprintf("/api/packages/%s/nuget", c.Owner)
+
+ req := NewRequest(t, "GET", url)
+ if c.UseBasicAuth {
+ req.AddBasicAuth(user.Name)
+ } else if c.UseTokenAuth {
+ addNuGetAPIKeyHeader(req, token)
+ }
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result nuget.ServiceIndexResponseV2
+ decodeXML(t, resp, &result)
+
+ assert.Equal(t, setting.AppURL+url[1:], result.Base)
+ assert.Equal(t, "Packages", result.Workspace.Collection.Href)
+ }
+ })
+
+ t.Run("v3", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Visibility: structs.VisibleTypePrivate})
+
+ cases := []struct {
+ Owner string
+ UseBasicAuth bool
+ UseTokenAuth bool
+ }{
+ {privateUser.Name, false, false},
+ {privateUser.Name, true, false},
+ {privateUser.Name, false, true},
+ {user.Name, false, false},
+ {user.Name, true, false},
+ {user.Name, false, true},
+ }
+
+ for _, c := range cases {
+ url := fmt.Sprintf("/api/packages/%s/nuget", c.Owner)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/index.json", url))
+ if c.UseBasicAuth {
+ req.AddBasicAuth(user.Name)
+ } else if c.UseTokenAuth {
+ addNuGetAPIKeyHeader(req, token)
+ }
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result nuget.ServiceIndexResponseV3
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, "3.0.0", result.Version)
+ assert.NotEmpty(t, result.Resources)
+
+ root := setting.AppURL + url[1:]
+ for _, r := range result.Resources {
+ switch r.Type {
+ case "SearchQueryService":
+ fallthrough
+ case "SearchQueryService/3.0.0-beta":
+ fallthrough
+ case "SearchQueryService/3.0.0-rc":
+ assert.Equal(t, root+"/query", r.ID)
+ case "RegistrationsBaseUrl":
+ fallthrough
+ case "RegistrationsBaseUrl/3.0.0-beta":
+ fallthrough
+ case "RegistrationsBaseUrl/3.0.0-rc":
+ assert.Equal(t, root+"/registration", r.ID)
+ case "PackageBaseAddress/3.0.0":
+ assert.Equal(t, root+"/package", r.ID)
+ case "PackagePublish/2.0.0":
+ assert.Equal(t, root, r.ID)
+ }
+ }
+ }
+ })
+ })
+
+ t.Run("Upload", func(t *testing.T) {
+ t.Run("DependencyPackage", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNuGet)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1, "Should have one version")
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.NotNil(t, pd.SemVer)
+ assert.IsType(t, &nuget_module.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 2, "Should have 2 files: nuget and nuspec")
+ assert.Equal(t, fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion), pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(content)), pb.Size)
+
+ req = NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusConflict)
+ })
+
+ t.Run("SymbolPackage", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ createSymbolPackage := func(id, packageType string) io.Reader {
+ var buf bytes.Buffer
+ archive := zip.NewWriter(&buf)
+
+ w, _ := archive.Create("package.nuspec")
+ w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
+ <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
+ <metadata>
+ <id>` + id + `</id>
+ <version>` + packageVersion + `</version>
+ <authors>` + packageAuthors + `</authors>
+ <description>` + packageDescription + `</description>
+ <packageTypes><packageType name="` + packageType + `" /></packageTypes>
+ </metadata>
+ </package>`))
+
+ w, _ = archive.Create(symbolFilename)
+ b, _ := base64.StdEncoding.DecodeString(`QlNKQgEAAQAAAAAADAAAAFBEQiB2MS4wAAAAAAAABgB8AAAAWAAAACNQZGIAAAAA1AAAAAgBAAAj
+fgAA3AEAAAQAAAAjU3RyaW5ncwAAAADgAQAABAAAACNVUwDkAQAAMAAAACNHVUlEAAAAFAIAACgB
+AAAjQmxvYgAAAGm7ENm9SGxMtAFVvPUsPJTF6PbtAAAAAFcVogEJAAAAAQAAAA==`)
+ w.Write(b)
+
+ archive.Close()
+ return &buf
+ }
+
+ req := NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/symbolpackage", url), createSymbolPackage("unknown-package", "SymbolsPackage")).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/symbolpackage", url), createSymbolPackage(packageName, "DummyPackage")).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/symbolpackage", url), createSymbolPackage(packageName, "SymbolsPackage")).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNuGet)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.NotNil(t, pd.SemVer)
+ assert.IsType(t, &nuget_module.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 4, "Should have 4 files: nupkg, snupkg, nuspec and pdb")
+ for _, pf := range pfs {
+ switch pf.Name {
+ case fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion):
+ assert.True(t, pf.IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(414), pb.Size)
+ case fmt.Sprintf("%s.%s.snupkg", packageName, packageVersion):
+ assert.False(t, pf.IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(616), pb.Size)
+ case fmt.Sprintf("%s.nuspec", packageName):
+ assert.False(t, pf.IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(453), pb.Size)
+ case symbolFilename:
+ assert.False(t, pf.IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(160), pb.Size)
+
+ pps, err := packages.GetProperties(db.DefaultContext, packages.PropertyTypeFile, pf.ID)
+ require.NoError(t, err)
+ assert.Len(t, pps, 1)
+ assert.Equal(t, nuget_module.PropertySymbolID, pps[0].Name)
+ assert.Equal(t, symbolID, pps[0].Value)
+ default:
+ assert.FailNow(t, "unexpected file: %v", pf.Name)
+ }
+ }
+
+ req = NewRequestWithBody(t, "PUT", fmt.Sprintf("%s/symbolpackage", url), createSymbolPackage(packageName, "SymbolsPackage")).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusConflict)
+ })
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ checkDownloadCount := func(count int64) {
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNuGet)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+ assert.Equal(t, count, pvs[0].DownloadCount)
+ }
+
+ checkDownloadCount(0)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.%s.nupkg", url, packageName, packageVersion, packageName, packageVersion)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, content, resp.Body.Bytes())
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.nuspec", url, packageName, packageVersion, packageName)).
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, nuspec, resp.Body.String())
+
+ checkDownloadCount(1)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.%s.snupkg", url, packageName, packageVersion, packageName, packageVersion)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusOK)
+
+ checkDownloadCount(1)
+
+ t.Run("Symbol", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/symbols/%s/%sFFFFFFFF/gitea.pdb", url, symbolFilename, symbolID))
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/symbols/%s/%sFFFFFFFF/%s", url, symbolFilename, "00000000000000000000000000000000", symbolFilename)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/symbols/%s/%sFFFFffff/%s", url, symbolFilename, symbolID, symbolFilename)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusOK)
+
+ checkDownloadCount(1)
+ })
+ })
+
+ containsOneNextLink := func(t *testing.T, links []FeedEntryLink) func() bool {
+ return func() bool {
+ found := 0
+ for _, l := range links {
+ if l.Rel == "next" {
+ found++
+ u, err := neturl.Parse(l.Href)
+ require.NoError(t, err)
+ q := u.Query()
+ assert.Contains(t, q, "$skip")
+ assert.Contains(t, q, "$top")
+ assert.Equal(t, "1", q.Get("$skip"))
+ assert.Equal(t, "1", q.Get("$top"))
+ }
+ }
+ return found == 1
+ }
+ }
+
+ t.Run("SearchService", func(t *testing.T) {
+ cases := []struct {
+ Query string
+ Skip int
+ Take int
+ ExpectedTotal int64
+ ExpectedResults int
+ ExpectedExactMatch bool
+ }{
+ {"", 0, 0, 4, 4, false},
+ {"", 0, 10, 4, 4, false},
+ {"gitea", 0, 10, 0, 0, false},
+ {"test", 0, 10, 1, 1, false},
+ {"test", 1, 10, 1, 0, false},
+ {"almost.similar", 0, 0, 3, 3, true},
+ }
+
+ fakePackages := []string{
+ packageName,
+ "almost.similar.dependency",
+ "almost.similar",
+ "almost.similar.dependent",
+ }
+
+ for _, fakePackageName := range fakePackages {
+ req := NewRequestWithBody(t, "PUT", url, createPackage(fakePackageName, "1.0.99")).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+ }
+
+ t.Run("v2", func(t *testing.T) {
+ t.Run("Search()", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ for i, c := range cases {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/Search()?searchTerm='%s'&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result FeedResponse
+ decodeXML(t, resp, &result)
+
+ assert.Equal(t, c.ExpectedTotal, result.Count, "case %d: unexpected total hits", i)
+ assert.Len(t, result.Entries, c.ExpectedResults, "case %d: unexpected result count", i)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/Search()/$count?searchTerm='%s'&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)).
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, strconv.FormatInt(c.ExpectedTotal, 10), resp.Body.String(), "case %d: unexpected total hits", i)
+ }
+ })
+
+ t.Run("Packages()", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ for i, c := range cases {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/Packages()?$filter=substringof('%s',tolower(Id))&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result FeedResponse
+ decodeXML(t, resp, &result)
+
+ assert.Equal(t, c.ExpectedTotal, result.Count, "case %d: unexpected total hits", i)
+ assert.Len(t, result.Entries, c.ExpectedResults, "case %d: unexpected result count", i)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/Packages()/$count?$filter=substringof('%s',tolower(Id))&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)).
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, strconv.FormatInt(c.ExpectedTotal, 10), resp.Body.String(), "case %d: unexpected total hits", i)
+ }
+ })
+
+ t.Run("Packages()", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ t.Run("substringof", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ for i, c := range cases {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/Packages()?$filter=substringof('%s',tolower(Id))&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result FeedResponse
+ decodeXML(t, resp, &result)
+
+ assert.Equal(t, c.ExpectedTotal, result.Count, "case %d: unexpected total hits", i)
+ assert.Len(t, result.Entries, c.ExpectedResults, "case %d: unexpected result count", i)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/Packages()/$count?$filter=substringof('%s',tolower(Id))&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)).
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, strconv.FormatInt(c.ExpectedTotal, 10), resp.Body.String(), "case %d: unexpected total hits", i)
+ }
+ })
+
+ t.Run("IdEq", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ for i, c := range cases {
+ if c.Query == "" {
+ // Ignore the `tolower(Id) eq ''` as it's unlikely to happen
+ continue
+ }
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/Packages()?$filter=(tolower(Id) eq '%s')&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result FeedResponse
+ decodeXML(t, resp, &result)
+
+ expectedCount := 0
+ if c.ExpectedExactMatch {
+ expectedCount = 1
+ }
+
+ assert.Equal(t, int64(expectedCount), result.Count, "case %d: unexpected total hits", i)
+ assert.Len(t, result.Entries, expectedCount, "case %d: unexpected result count", i)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/Packages()/$count?$filter=(tolower(Id) eq '%s')&$skip=%d&$top=%d", url, c.Query, c.Skip, c.Take)).
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, strconv.FormatInt(int64(expectedCount), 10), resp.Body.String(), "case %d: unexpected total hits", i)
+ }
+ })
+ })
+
+ t.Run("Next", func(t *testing.T) {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/Search()?searchTerm='test'&$skip=0&$top=1", url)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result FeedResponse
+ decodeXML(t, resp, &result)
+
+ assert.Condition(t, containsOneNextLink(t, result.Links))
+ })
+ })
+
+ t.Run("v3", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ for i, c := range cases {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/query?q=%s&skip=%d&take=%d", url, c.Query, c.Skip, c.Take)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result nuget.SearchResultResponse
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, c.ExpectedTotal, result.TotalHits, "case %d: unexpected total hits", i)
+ assert.Len(t, result.Data, c.ExpectedResults, "case %d: unexpected result count", i)
+ }
+
+ t.Run("EnforceGrouped", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, "PUT", url, createPackage(packageName+".dummy", "1.0.0")).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/query?q=%s", url, packageName)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result nuget.SearchResultResponse
+ DecodeJSON(t, resp, &result)
+
+ assert.EqualValues(t, 2, result.TotalHits)
+ assert.Len(t, result.Data, 2)
+ for _, sr := range result.Data {
+ if sr.ID == packageName {
+ assert.Len(t, sr.Versions, 2)
+ } else {
+ assert.Len(t, sr.Versions, 1)
+ }
+ }
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s", url, packageName+".dummy", "1.0.0")).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+ })
+ })
+
+ for _, fakePackageName := range fakePackages {
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s", url, fakePackageName, "1.0.99")).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+ }
+ })
+
+ t.Run("RegistrationService", func(t *testing.T) {
+ indexURL := fmt.Sprintf("%s%s/registration/%s/index.json", setting.AppURL, url[1:], packageName)
+ leafURL := fmt.Sprintf("%s%s/registration/%s/%s.json", setting.AppURL, url[1:], packageName, packageVersion)
+ contentURL := fmt.Sprintf("%s%s/package/%s/%s/%s.%s.nupkg", setting.AppURL, url[1:], packageName, packageVersion, packageName, packageVersion)
+
+ t.Run("RegistrationIndex", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/registration/%s/index.json", url, packageName)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result nuget.RegistrationIndexResponse
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, indexURL, result.RegistrationIndexURL)
+ assert.Equal(t, 1, result.Count)
+ assert.Len(t, result.Pages, 1)
+ assert.Equal(t, indexURL, result.Pages[0].RegistrationPageURL)
+ assert.Equal(t, packageVersion, result.Pages[0].Lower)
+ assert.Equal(t, packageVersion, result.Pages[0].Upper)
+ assert.Equal(t, 1, result.Pages[0].Count)
+ assert.Len(t, result.Pages[0].Items, 1)
+ assert.Equal(t, packageName, result.Pages[0].Items[0].CatalogEntry.ID)
+ assert.Equal(t, packageVersion, result.Pages[0].Items[0].CatalogEntry.Version)
+ assert.Equal(t, packageAuthors, result.Pages[0].Items[0].CatalogEntry.Authors)
+ assert.Equal(t, packageDescription, result.Pages[0].Items[0].CatalogEntry.Description)
+ assert.Equal(t, leafURL, result.Pages[0].Items[0].CatalogEntry.CatalogLeafURL)
+ assert.Equal(t, contentURL, result.Pages[0].Items[0].CatalogEntry.PackageContentURL)
+ })
+
+ t.Run("RegistrationLeaf", func(t *testing.T) {
+ t.Run("v2", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/Packages(Id='%s',Version='%s')", url, packageName, packageVersion)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result FeedEntry
+ decodeXML(t, resp, &result)
+
+ assert.Equal(t, packageName, result.Properties.Title)
+ assert.Equal(t, packageVersion, result.Properties.Version)
+ assert.Equal(t, packageAuthors, result.Properties.Authors)
+ assert.Equal(t, packageDescription, result.Properties.Description)
+ assert.Equal(t, "Microsoft.CSharp:4.5.0:.NETStandard2.0", result.Properties.Dependencies)
+ })
+
+ t.Run("v3", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/registration/%s/%s.json", url, packageName, packageVersion)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result nuget.RegistrationLeafResponse
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, leafURL, result.RegistrationLeafURL)
+ assert.Equal(t, contentURL, result.PackageContentURL)
+ assert.Equal(t, indexURL, result.RegistrationIndexURL)
+ })
+ })
+ })
+
+ t.Run("PackageService", func(t *testing.T) {
+ t.Run("v2", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/FindPackagesById()?id='%s'&$top=1", url, packageName)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result FeedResponse
+ decodeXML(t, resp, &result)
+
+ assert.Len(t, result.Entries, 1)
+ assert.Equal(t, packageVersion, result.Entries[0].Properties.Version)
+ assert.Condition(t, containsOneNextLink(t, result.Links))
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/FindPackagesById()/$count?id='%s'", url, packageName)).
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "1", resp.Body.String())
+ })
+
+ t.Run("v3", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/index.json", url, packageName)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var result nuget.PackageVersionsResponse
+ DecodeJSON(t, resp, &result)
+
+ assert.Len(t, result.Versions, 1)
+ assert.Equal(t, packageVersion, result.Versions[0])
+ })
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/%s/%s", url, packageName, packageVersion)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeNuGet)
+ require.NoError(t, err)
+ assert.Empty(t, pvs)
+ })
+
+ t.Run("DownloadNotExists", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.%s.nupkg", url, packageName, packageVersion, packageName, packageVersion)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s.%s.snupkg", url, packageName, packageVersion, packageName, packageVersion)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("DeleteNotExists", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/package/%s/%s", url, packageName, packageVersion)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+}
diff --git a/tests/integration/api_packages_pub_test.go b/tests/integration/api_packages_pub_test.go
new file mode 100644
index 0000000..d6bce30
--- /dev/null
+++ b/tests/integration/api_packages_pub_test.go
@@ -0,0 +1,182 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ pub_module "code.gitea.io/gitea/modules/packages/pub"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackagePub(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ token := "Bearer " + getUserToken(t, user.Name, auth_model.AccessTokenScopeWritePackage)
+
+ packageName := "test_package"
+ packageVersion := "1.0.1"
+ packageDescription := "Test Description"
+
+ filename := fmt.Sprintf("%s.tar.gz", packageVersion)
+
+ pubspecContent := `name: ` + packageName + `
+version: ` + packageVersion + `
+description: ` + packageDescription
+
+ var buf bytes.Buffer
+ zw := gzip.NewWriter(&buf)
+ archive := tar.NewWriter(zw)
+ archive.WriteHeader(&tar.Header{
+ Name: "pubspec.yaml",
+ Mode: 0o600,
+ Size: int64(len(pubspecContent)),
+ })
+ archive.Write([]byte(pubspecContent))
+ archive.Close()
+ zw.Close()
+ content := buf.Bytes()
+
+ root := fmt.Sprintf("/api/packages/%s/pub", user.Name)
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadURL := root + "/api/packages/versions/new"
+
+ req := NewRequest(t, "GET", uploadURL)
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "GET", uploadURL).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type UploadRequest struct {
+ URL string `json:"url"`
+ Fields map[string]string `json:"fields"`
+ }
+
+ var result UploadRequest
+ DecodeJSON(t, resp, &result)
+
+ assert.Empty(t, result.Fields)
+
+ uploadFile := func(t *testing.T, url string, content []byte, expectedStatus int) *httptest.ResponseRecorder {
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+ part, _ := writer.CreateFormFile("file", "dummy.tar.gz")
+ _, _ = io.Copy(part, bytes.NewReader(content))
+
+ _ = writer.Close()
+
+ req := NewRequestWithBody(t, "POST", url, body).
+ SetHeader("Content-Type", writer.FormDataContentType()).
+ AddTokenAuth(token)
+ return MakeRequest(t, req, expectedStatus)
+ }
+
+ resp = uploadFile(t, result.URL, content, http.StatusNoContent)
+
+ req = NewRequest(t, "GET", resp.Header().Get("Location")).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypePub)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.NotNil(t, pd.SemVer)
+ assert.IsType(t, &pub_module.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, filename, pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(content)), pb.Size)
+
+ _ = uploadFile(t, result.URL, content, http.StatusConflict)
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/api/packages/%s/%s", root, packageName, packageVersion))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type VersionMetadata struct {
+ Version string `json:"version"`
+ ArchiveURL string `json:"archive_url"`
+ Published time.Time `json:"published"`
+ Pubspec any `json:"pubspec,omitempty"`
+ }
+
+ var result VersionMetadata
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, packageVersion, result.Version)
+ assert.NotNil(t, result.Pubspec)
+
+ req = NewRequest(t, "GET", result.ArchiveURL)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, content, resp.Body.Bytes())
+ })
+
+ t.Run("EnumeratePackageVersions", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/api/packages/%s", root, packageName))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type VersionMetadata struct {
+ Version string `json:"version"`
+ ArchiveURL string `json:"archive_url"`
+ Published time.Time `json:"published"`
+ Pubspec any `json:"pubspec,omitempty"`
+ }
+
+ type PackageVersions struct {
+ Name string `json:"name"`
+ Latest *VersionMetadata `json:"latest"`
+ Versions []*VersionMetadata `json:"versions"`
+ }
+
+ var result PackageVersions
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, packageName, result.Name)
+ assert.NotNil(t, result.Latest)
+ assert.Len(t, result.Versions, 1)
+ assert.Equal(t, result.Latest.Version, result.Versions[0].Version)
+ assert.Equal(t, packageVersion, result.Latest.Version)
+ assert.NotNil(t, result.Latest.Pubspec)
+ })
+}
diff --git a/tests/integration/api_packages_pypi_test.go b/tests/integration/api_packages_pypi_test.go
new file mode 100644
index 0000000..ef03dbe
--- /dev/null
+++ b/tests/integration/api_packages_pypi_test.go
@@ -0,0 +1,183 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "regexp"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/packages/pypi"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackagePyPI(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ packageName := "test-package"
+ packageVersion := "1!1.0.1+r1234"
+ packageAuthor := "KN4CK3R"
+ packageDescription := "Test Description"
+
+ content := "test"
+ hashSHA256 := "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
+
+ root := fmt.Sprintf("/api/packages/%s/pypi", user.Name)
+
+ uploadFile := func(t *testing.T, filename, content string, expectedStatus int) {
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+ part, _ := writer.CreateFormFile("content", filename)
+ _, _ = io.Copy(part, strings.NewReader(content))
+
+ writer.WriteField("name", packageName)
+ writer.WriteField("version", packageVersion)
+ writer.WriteField("author", packageAuthor)
+ writer.WriteField("summary", packageDescription)
+ writer.WriteField("description", packageDescription)
+ writer.WriteField("sha256_digest", hashSHA256)
+ writer.WriteField("requires_python", "3.6")
+
+ _ = writer.Close()
+
+ req := NewRequestWithBody(t, "POST", root, body).
+ SetHeader("Content-Type", writer.FormDataContentType()).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, expectedStatus)
+ }
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ filename := "test.whl"
+ uploadFile(t, filename, content, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypePyPI)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.Nil(t, pd.SemVer)
+ assert.IsType(t, &pypi.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, filename, pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(4), pb.Size)
+ })
+
+ t.Run("UploadAddFile", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ filename := "test.tar.gz"
+ uploadFile(t, filename, content, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypePyPI)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.Nil(t, pd.SemVer)
+ assert.IsType(t, &pypi.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 2)
+
+ pf, err := packages.GetFileForVersionByName(db.DefaultContext, pvs[0].ID, filename, packages.EmptyFileKey)
+ require.NoError(t, err)
+ assert.Equal(t, filename, pf.Name)
+ assert.True(t, pf.IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pf.BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(4), pb.Size)
+ })
+
+ t.Run("UploadHashMismatch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ filename := "test2.whl"
+ uploadFile(t, filename, "dummy", http.StatusBadRequest)
+ })
+
+ t.Run("UploadExists", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadFile(t, "test.whl", content, http.StatusConflict)
+ uploadFile(t, "test.tar.gz", content, http.StatusConflict)
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ downloadFile := func(filename string) {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/files/%s/%s/%s", root, packageName, packageVersion, filename)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, []byte(content), resp.Body.Bytes())
+ }
+
+ downloadFile("test.whl")
+ downloadFile("test.tar.gz")
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypePyPI)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+ assert.Equal(t, int64(2), pvs[0].DownloadCount)
+ })
+
+ t.Run("PackageMetadata", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/simple/%s", root, packageName)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ nodes := htmlDoc.doc.Find("a").Nodes
+ assert.Len(t, nodes, 2)
+
+ hrefMatcher := regexp.MustCompile(fmt.Sprintf(`%s/files/%s/%s/test\..+#sha256=%s`, root, regexp.QuoteMeta(packageName), regexp.QuoteMeta(packageVersion), hashSHA256))
+
+ for _, a := range nodes {
+ for _, att := range a.Attr {
+ switch att.Key {
+ case "href":
+ assert.Regexp(t, hrefMatcher, att.Val)
+ case "data-requires-python":
+ assert.Equal(t, "3.6", att.Val)
+ default:
+ t.Fail()
+ }
+ }
+ }
+ })
+}
diff --git a/tests/integration/api_packages_rpm_test.go b/tests/integration/api_packages_rpm_test.go
new file mode 100644
index 0000000..853c8f0
--- /dev/null
+++ b/tests/integration/api_packages_rpm_test.go
@@ -0,0 +1,462 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "compress/gzip"
+ "encoding/base64"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ rpm_module "code.gitea.io/gitea/modules/packages/rpm"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/ProtonMail/go-crypto/openpgp"
+ "github.com/sassoftware/go-rpmutils"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageRpm(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ packageName := "gitea-test"
+ packageVersion := "1.0.2-1"
+ packageArchitecture := "x86_64"
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ base64RpmPackageContent := `H4sICFayB2QCAGdpdGVhLXRlc3QtMS4wLjItMS14ODZfNjQucnBtAO2YV4gTQRjHJzl7wbNhhxVF
+VNwk2zd2PdvZ9Sxnd3Z3NllNsmF3o6congVFsWFHRWwIImIXfRER0QcRfPBJEXvvBQvWSfZTT0VQ
+8TF/MuU33zcz3+zOJGEe73lyuQBRBWKWRzDrEddjuVAkxLMc+lsFUOWfm5bvvReAalWECg/TsivU
+dyKa0U61aVnl6wj0Uxe4nc8F92hZiaYE8CO/P0r7/Quegr0c7M/AvoCaGZEIWNGUqMHrhhGROIUT
+Zc7gOAOraoQzCNZ0WdU0HpEI5jiB4zlek3gT85wqCBomhomxoGCs8wImWMImbxqKgXVNUKKaqShR
+STKVKK9glFUNcf2g+/t27xs16v5x/eyOKftVGlIhyiuvvPLKK6+88sorr7zyyiuvvPKCO5HPnz+v
+pGVhhXsTsFVeSstuWR9anwU+Bk3Vch5wTwL3JkHg+8C1gR8A169wj1KdpobAj4HbAT+Be5VewE+h
+fz/g52AvBX4N9vHAb4AnA7+F8ePAH8BuA38ELgf+BLzQ50oIeBlw0OdAOXAlP57AGuCsbwGtbgCu
+DrwRuAb4bwau6T/PwFbgWsDXgWuD/y3gOmC/B1wI/Bi4AcT3Arih3z9YCNzI9w9m/YKUG4Nd9N9z
+pSZgHwrcFPgccFt//OADGE+F/q+Ao+D/FrijzwV1gbv4/QvaAHcFDgF3B5aB+wB3Be7rz1dQCtwP
+eDxwMcw3GbgU7AasdwzYE8DjwT4L/CeAvRx4IvBCYA3iWQds+FzpDjABfghsAj8BTgA/A/b8+StX
+A84A1wKe5s9fuRB4JpzHZv55rL8a/Dv49vpn/PErR4BvQX8Z+Db4l2W5CH2/f0W5+1fEoeFDBzFp
+rE/FMcK4mWQSOzN+aDOIqztW2rPsFKIyqh7sQERR42RVMSKihnzVHlQ8Ag0YLBYNEIajkhmuR5Io
+7nlpt2M4nJs0ZNkoYaUyZahMlSfJImr1n1WjFVNCPCaTZgYNGdGL8YN2mX8WHfA/C7ViHJK0pxHG
+SrkeTiSI4T+7ubf85yrzRCQRQ5EVxVAjvIBVRY/KRFAVReIkhfARSddNSceayQkGliIKb0q8RAxJ
+5QWNVxHIsW3Pz369bw+5jh5y0klE9Znqm0dF57b0HbGy2A5lVUBTZZrqZjdUjYoprFmpsBtHP5d0
++ISltS2yk2mHuC4x+lgJMhgnidvuqy3b0suK0bm+tw3FMxI2zjm7/fA0MtQhplX2s7nYLZ2ZC0yg
+CxJZDokhORTJlrlcCvG5OieGBERlVCs7CfuS6WzQ/T2j+9f92BWxTFEcp2IkYccYGp2LYySEfreq
+irue4WRF5XkpKovw2wgpq2rZBI8bQZkzxEkiYaNwxnXCCVvHidzIiB3CM2yMYdNWmjDsaLovaE4c
+x3a6mLaTxB7rEj3jWN4M2p7uwPaa1GfI8BHFfcZMKhkycnhR7y781/a+A4t7FpWWTupRUtKbegwZ
+XMKwJinTSe70uhRcj55qNu3YHtE922Fdz7FTMTq9Q3TbMdiYrrPudMvT44S6u2miu138eC0tTN9D
+2CFGHHtQsHHsGCRFDFbXuT9wx6mUTZfseydlkWZeJkW6xOgYjqXT+LA7I6XHaUx2xmUzqelWymA9
+rCXI9+D1BHbjsITssqhBNysw0tOWjcpmIh6+aViYPfftw8ZSGfRVPUqKiosZj5R5qGmk/8AjjRbZ
+d8b3vvngdPHx3HvMeCarIk7VVSwbgoZVkceEVyOmyUmGxBGNYDVKSFSOGlIkGqWnUZFkiY/wsmhK
+Mu0UFYgZ/bYnuvn/vz4wtCz8qMwsHUvP0PX3tbYFUctAPdrY6tiiDtcCddDECahx7SuVNP5dpmb5
+9tMDyaXb7OAlk5acuPn57ss9mw6Wym0m1Fq2cej7tUt2LL4/b8enXU2fndk+fvv57ndnt55/cQob
+7tpp/pEjDS7cGPZ6BY430+7danDq6f42Nw49b9F7zp6BiKpJb9s5P0AYN2+L159cnrur636rx+v1
+7ae1K28QbMMcqI8CqwIrgwg9nTOp8Oj9q81plUY7ZuwXN8Vvs8wbAAA=`
+ rpmPackageContent, err := base64.StdEncoding.DecodeString(base64RpmPackageContent)
+ require.NoError(t, err)
+
+ zr, err := gzip.NewReader(bytes.NewReader(rpmPackageContent))
+ require.NoError(t, err)
+
+ content, err := io.ReadAll(zr)
+ require.NoError(t, err)
+
+ rootURL := fmt.Sprintf("/api/packages/%s/rpm", user.Name)
+
+ for _, group := range []string{"", "el9", "el9/stable"} {
+ t.Run(fmt.Sprintf("[Group:%s]", group), func(t *testing.T) {
+ var groupParts []string
+ if group != "" {
+ groupParts = strings.Split(group, "/")
+ }
+ groupURL := strings.Join(append([]string{rootURL}, groupParts...), "/")
+
+ t.Run("RepositoryConfig", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", groupURL+".repo")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ expected := fmt.Sprintf(`[gitea-%s]
+name=%s
+baseurl=%s
+enabled=1
+gpgcheck=1
+gpgkey=%sapi/packages/%s/rpm/repository.key`,
+ strings.Join(append([]string{user.LowerName}, groupParts...), "-"),
+ strings.Join(append([]string{user.Name, setting.AppName}, groupParts...), " - "),
+ util.URLJoin(setting.AppURL, groupURL),
+ setting.AppURL,
+ user.Name,
+ )
+
+ assert.Equal(t, expected, resp.Body.String())
+ })
+
+ t.Run("RepositoryKey", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", rootURL+"/repository.key")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "application/pgp-keys", resp.Header().Get("Content-Type"))
+ assert.Contains(t, resp.Body.String(), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
+ })
+
+ t.Run("Upload", func(t *testing.T) {
+ url := groupURL + "/upload"
+
+ req := NewRequestWithBody(t, "PUT", url, bytes.NewReader(content))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRpm)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.Nil(t, pd.SemVer)
+ assert.IsType(t, &rpm_module.VersionMetadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, fmt.Sprintf("%s-%s.%s.rpm", packageName, packageVersion, packageArchitecture), pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(content)), pb.Size)
+
+ req = NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusConflict)
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, content, resp.Body.Bytes())
+ })
+
+ t.Run("Repository", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ url := groupURL + "/repodata"
+
+ req := NewRequest(t, "HEAD", url+"/dummy.xml")
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", url+"/dummy.xml")
+ MakeRequest(t, req, http.StatusNotFound)
+
+ t.Run("repomd.xml", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req = NewRequest(t, "HEAD", url+"/repomd.xml")
+ MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", url+"/repomd.xml")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type Repomd struct {
+ XMLName xml.Name `xml:"repomd"`
+ Xmlns string `xml:"xmlns,attr"`
+ XmlnsRpm string `xml:"xmlns:rpm,attr"`
+ Data []struct {
+ Type string `xml:"type,attr"`
+ Checksum struct {
+ Value string `xml:",chardata"`
+ Type string `xml:"type,attr"`
+ } `xml:"checksum"`
+ OpenChecksum struct {
+ Value string `xml:",chardata"`
+ Type string `xml:"type,attr"`
+ } `xml:"open-checksum"`
+ Location struct {
+ Href string `xml:"href,attr"`
+ } `xml:"location"`
+ Timestamp int64 `xml:"timestamp"`
+ Size int64 `xml:"size"`
+ OpenSize int64 `xml:"open-size"`
+ } `xml:"data"`
+ }
+
+ var result Repomd
+ decodeXML(t, resp, &result)
+
+ assert.Len(t, result.Data, 3)
+ for _, d := range result.Data {
+ assert.Equal(t, "sha256", d.Checksum.Type)
+ assert.NotEmpty(t, d.Checksum.Value)
+ assert.Equal(t, "sha256", d.OpenChecksum.Type)
+ assert.NotEmpty(t, d.OpenChecksum.Value)
+ assert.NotEqual(t, d.Checksum.Value, d.OpenChecksum.Value)
+ assert.Greater(t, d.OpenSize, d.Size)
+
+ switch d.Type {
+ case "primary":
+ assert.EqualValues(t, 722, d.Size)
+ assert.EqualValues(t, 1759, d.OpenSize)
+ assert.Equal(t, "repodata/primary.xml.gz", d.Location.Href)
+ case "filelists":
+ assert.EqualValues(t, 257, d.Size)
+ assert.EqualValues(t, 326, d.OpenSize)
+ assert.Equal(t, "repodata/filelists.xml.gz", d.Location.Href)
+ case "other":
+ assert.EqualValues(t, 306, d.Size)
+ assert.EqualValues(t, 394, d.OpenSize)
+ assert.Equal(t, "repodata/other.xml.gz", d.Location.Href)
+ }
+ }
+ })
+
+ t.Run("repomd.xml.asc", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req = NewRequest(t, "GET", url+"/repomd.xml.asc")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Contains(t, resp.Body.String(), "-----BEGIN PGP SIGNATURE-----")
+ })
+
+ decodeGzipXML := func(t testing.TB, resp *httptest.ResponseRecorder, v any) {
+ t.Helper()
+
+ zr, err := gzip.NewReader(resp.Body)
+ require.NoError(t, err)
+
+ require.NoError(t, xml.NewDecoder(zr).Decode(v))
+ }
+
+ t.Run("primary.xml.gz", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req = NewRequest(t, "GET", url+"/primary.xml.gz")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type EntryList struct {
+ Entries []*rpm_module.Entry `xml:"entry"`
+ }
+
+ type Metadata struct {
+ XMLName xml.Name `xml:"metadata"`
+ Xmlns string `xml:"xmlns,attr"`
+ XmlnsRpm string `xml:"xmlns:rpm,attr"`
+ PackageCount int `xml:"packages,attr"`
+ Packages []struct {
+ XMLName xml.Name `xml:"package"`
+ Type string `xml:"type,attr"`
+ Name string `xml:"name"`
+ Architecture string `xml:"arch"`
+ Version struct {
+ Epoch string `xml:"epoch,attr"`
+ Version string `xml:"ver,attr"`
+ Release string `xml:"rel,attr"`
+ } `xml:"version"`
+ Checksum struct {
+ Checksum string `xml:",chardata"`
+ Type string `xml:"type,attr"`
+ Pkgid string `xml:"pkgid,attr"`
+ } `xml:"checksum"`
+ Summary string `xml:"summary"`
+ Description string `xml:"description"`
+ Packager string `xml:"packager"`
+ URL string `xml:"url"`
+ Time struct {
+ File uint64 `xml:"file,attr"`
+ Build uint64 `xml:"build,attr"`
+ } `xml:"time"`
+ Size struct {
+ Package int64 `xml:"package,attr"`
+ Installed uint64 `xml:"installed,attr"`
+ Archive uint64 `xml:"archive,attr"`
+ } `xml:"size"`
+ Location struct {
+ Href string `xml:"href,attr"`
+ } `xml:"location"`
+ Format struct {
+ License string `xml:"license"`
+ Vendor string `xml:"vendor"`
+ Group string `xml:"group"`
+ Buildhost string `xml:"buildhost"`
+ Sourcerpm string `xml:"sourcerpm"`
+ Provides EntryList `xml:"provides"`
+ Requires EntryList `xml:"requires"`
+ Conflicts EntryList `xml:"conflicts"`
+ Obsoletes EntryList `xml:"obsoletes"`
+ Files []*rpm_module.File `xml:"file"`
+ } `xml:"format"`
+ } `xml:"package"`
+ }
+
+ var result Metadata
+ decodeGzipXML(t, resp, &result)
+
+ assert.EqualValues(t, 1, result.PackageCount)
+ assert.Len(t, result.Packages, 1)
+ p := result.Packages[0]
+ assert.Equal(t, "rpm", p.Type)
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageArchitecture, p.Architecture)
+ assert.Equal(t, "YES", p.Checksum.Pkgid)
+ assert.Equal(t, "sha256", p.Checksum.Type)
+ assert.Equal(t, "f1d5d2ffcbe4a7568e98b864f40d923ecca084e9b9bcd5977ed6521c46d3fa4c", p.Checksum.Checksum)
+ assert.Equal(t, "https://gitea.io", p.URL)
+ assert.EqualValues(t, len(content), p.Size.Package)
+ assert.EqualValues(t, 13, p.Size.Installed)
+ assert.EqualValues(t, 272, p.Size.Archive)
+ assert.Equal(t, fmt.Sprintf("package/%s/%s/%s/%s", packageName, packageVersion, packageArchitecture, fmt.Sprintf("%s-%s.%s.rpm", packageName, packageVersion, packageArchitecture)), p.Location.Href)
+ f := p.Format
+ assert.Equal(t, "MIT", f.License)
+ assert.Len(t, f.Provides.Entries, 2)
+ assert.Len(t, f.Requires.Entries, 7)
+ assert.Empty(t, f.Conflicts.Entries)
+ assert.Empty(t, f.Obsoletes.Entries)
+ assert.Len(t, f.Files, 1)
+ })
+
+ t.Run("filelists.xml.gz", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req = NewRequest(t, "GET", url+"/filelists.xml.gz")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type Filelists struct {
+ XMLName xml.Name `xml:"filelists"`
+ Xmlns string `xml:"xmlns,attr"`
+ PackageCount int `xml:"packages,attr"`
+ Packages []struct {
+ Pkgid string `xml:"pkgid,attr"`
+ Name string `xml:"name,attr"`
+ Architecture string `xml:"arch,attr"`
+ Version struct {
+ Epoch string `xml:"epoch,attr"`
+ Version string `xml:"ver,attr"`
+ Release string `xml:"rel,attr"`
+ } `xml:"version"`
+ Files []*rpm_module.File `xml:"file"`
+ } `xml:"package"`
+ }
+
+ var result Filelists
+ decodeGzipXML(t, resp, &result)
+
+ assert.EqualValues(t, 1, result.PackageCount)
+ assert.Len(t, result.Packages, 1)
+ p := result.Packages[0]
+ assert.NotEmpty(t, p.Pkgid)
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageArchitecture, p.Architecture)
+ assert.Len(t, p.Files, 1)
+ f := p.Files[0]
+ assert.Equal(t, "/usr/local/bin/hello", f.Path)
+ })
+
+ t.Run("other.xml.gz", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req = NewRequest(t, "GET", url+"/other.xml.gz")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type Other struct {
+ XMLName xml.Name `xml:"otherdata"`
+ Xmlns string `xml:"xmlns,attr"`
+ PackageCount int `xml:"packages,attr"`
+ Packages []struct {
+ Pkgid string `xml:"pkgid,attr"`
+ Name string `xml:"name,attr"`
+ Architecture string `xml:"arch,attr"`
+ Version struct {
+ Epoch string `xml:"epoch,attr"`
+ Version string `xml:"ver,attr"`
+ Release string `xml:"rel,attr"`
+ } `xml:"version"`
+ Changelogs []*rpm_module.Changelog `xml:"changelog"`
+ } `xml:"package"`
+ }
+
+ var result Other
+ decodeGzipXML(t, resp, &result)
+
+ assert.EqualValues(t, 1, result.PackageCount)
+ assert.Len(t, result.Packages, 1)
+ p := result.Packages[0]
+ assert.NotEmpty(t, p.Pkgid)
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageArchitecture, p.Architecture)
+ assert.Len(t, p.Changelogs, 1)
+ c := p.Changelogs[0]
+ assert.Equal(t, "KN4CK3R <dummy@gitea.io>", c.Author)
+ assert.EqualValues(t, 1678276800, c.Date)
+ assert.Equal(t, "- Changelog message.", c.Text)
+ })
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRpm)
+ require.NoError(t, err)
+ assert.Empty(t, pvs)
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("UploadSign", func(t *testing.T) {
+ url := groupURL + "/upload?sign=true"
+ req := NewRequestWithBody(t, "PUT", url, bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ gpgReq := NewRequest(t, "GET", rootURL+"/repository.key")
+ gpgResp := MakeRequest(t, gpgReq, http.StatusOK)
+ pub, err := openpgp.ReadArmoredKeyRing(gpgResp.Body)
+ require.NoError(t, err)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ _, sigs, err := rpmutils.Verify(resp.Body, pub)
+ require.NoError(t, err)
+ require.NotEmpty(t, sigs)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/package/%s/%s/%s", groupURL, packageName, packageVersion, packageArchitecture)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+ })
+ })
+ }
+}
diff --git a/tests/integration/api_packages_rubygems_test.go b/tests/integration/api_packages_rubygems_test.go
new file mode 100644
index 0000000..eb3dc0e
--- /dev/null
+++ b/tests/integration/api_packages_rubygems_test.go
@@ -0,0 +1,398 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "crypto/md5"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "mime/multipart"
+ "net/http"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/packages/rubygems"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageRubyGems(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ packageName := "gitea"
+ packageVersion := "1.0.5"
+ packageFilename := "gitea-1.0.5.gem"
+ packageDependency := "runtime-dep:>= 1.2.0&< 2.0"
+ rubyRequirements := "ruby:>= 2.3.0"
+ sep := "---"
+
+ gemContent, _ := base64.StdEncoding.DecodeString(`bWV0YWRhdGEuZ3oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAwMDA0NDQAMDAwMDAw
+MAAwMDAwMDAwADAwMDAwMDAxMDQxADE0MTEwNzcyMzY2ADAxMzQ0MQAgMAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB1c3RhcgAwMHdoZWVsAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAd2hlZWwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwMDAwMDAwADAwMDAw
+MDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf
+iwgA9vQjYQID1VVNb9QwEL37V5he9pRsmlJAFlQckCoOXAriQIUix5nNmsYf2JOqKwS/nYmz2d3Q
+qqCCKpFdadfjmfdm5nmcLMv4k9DXm6Wrv4BCcQ5GiPcelF5pJVE7y6w0IHirESS7hhDJJu4I+jhu
+Mc53Tsd5kZ8y30lcuWAEH2KY7HHtQhQs4+cJkwwuwNdeB6JhtbaNDoLTL1MQsFJrqQnr8jNrJJJH
+WZTHWfEiK094UYj0zYvp4Z9YAx5sA1ZpSCS3M30zeWwo2bG60FvUBjIKJts2GwMW76r0Yr9NzjN3
+YhwsGX2Ozl4dpcWwvK9d43PQtDIv9igvHwSyIIwFmXHjqTqxLY8MPkCADmQk80p2EfZ6VbM6/ue6
+/1D0Bq7/qeA/zh6W82leHmhFWUHn/JbsEfT6q7QbiCpoj8l0QcEUFLmX6kq2wBEiMjBSd+Pwt7T5
+Ot0kuXYMbkD1KOuOBnWYb7hBsAP4bhlkFRqnqpWefMZ/pHCn6+WIFGq2dgY8EQq+RvRRLJcTyZJ1
+WhHqGPTu7QdmACXdJFLwb9+ZdxErbSPKrqsMxJhAWCJ1qaqRdtu6yktcT/STsamG0qp7rsa5EL/K
+MBua30uw4ynzExqYWRJDfx8/kQWN3PwsDh2jYLr1W+pZcAmCs9splvnz/Flesqhbq21bXcGG/OLh
++2fv/JTF3hgZyCW9OaZjxoZjdnBGfgKpxZyJ1QYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGF0
+YS50YXIuZ3oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAwMDA0NDQAMDAwMDAwMAAw
+MDAwMDAwADAwMDAwMDAwMjQyADE0MTEwNzcyMzY2ADAxMzM2MQAgMAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB1c3RhcgAwMHdoZWVsAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAd2hlZWwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwMDAwMDAwADAwMDAwMDAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfiwgA
+9vQjYQID7M/NCsMgDABgz32KrA/QxersK/Q17ExXIcyhlr7+HLv1sJ02KPhBCPk5JOyn881nsl2c
+xI+gRDRaC3zbZ8RBCamlxGHolTFlX11kLwDFH6wp21hO2RYi/rD3bb5/7iCubFOCMbBtABzNkIjn
+bvGlAnisOUE7EnOALUR2p7b06e6aV4iqqqrquJ4AAAD//wMA+sA/NQAIAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGNoZWNr
+c3Vtcy55YW1sLmd6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwMDAwNDQ0ADAwMDAwMDAAMDAw
+MDAwMAAwMDAwMDAwMDQ1MAAxNDExMDc3MjM2NgAwMTQ2MTIAIDAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAdXN0YXIAMDB3aGVlbAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAHdoZWVsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDAwMDAwMAAwMDAwMDAwAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4sIAPb0
+I2ECA2WQOa4UQAxE8znFXGCQ21vbPyMj5wRuL0Qk6EecnmZCyKyy9FSvXq/X4/u3ryj68Xg+f/Zn
+VHzGlx+/P57qvU4XxWalBKftSXOgCjNYkdRycrC5Axem+W4HqS12PNEv7836jF9vnlHxwSyxKY+y
+go0cPblyHzkrZ4HF1GSVhe7mOOoasXNk2fnbUxb+19Pp9tobD/QlJKMX7y204PREh6nQ5hG9Alw6
+x4TnmtA+aekGfm6wAseog2LSgpR4Q7cYnAH3K4qAQa6A6JCC1gpuY7P+9YxE5SZ+j0eVGbaBTwBQ
+iIqRUyyzLCoFCBdYNWxniapTavD97blXTzFvgoVoAsKBAtlU48cdaOmeZDpwV01OtcGwjscfeUrY
+B9QBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`)
+ checksum := fmt.Sprintf("%x", sha256.Sum256(gemContent))
+
+ holaPackageName := "hola"
+ holaPackageVersion := "0.0.1"
+ // holaPackageFilename := "hola-0.0.1.gem"
+ holaPackageDependency := "example:~> 1.1&>= 1.1.4,zero:>= 0"
+ holaRubyGemsRequirements := "rubygems:= 1.2.3"
+ // sep := "---"
+
+ holaGemContent, _ := base64.StdEncoding.DecodeString(`bWV0YWRhdGEuZ3oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAwMDA0NDQAMDAwMDAw
+MAAwMDAwMDAwADAwMDAwMDAwNzYyADE0NjIyMjU1MzY0ADAxMzQ1NAAgMAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB1c3RhcgAwMHdoZWVsAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAd2hlZWwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwMDAwMDAwADAwMDAw
+MDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf
+iwgA9FpJZgID1VVNb9QwEL37V7i95JQ02W0PWGpPSHBCAiQOIBRNnNmNqT+C7aBdEPx2xtnNbtNW
+BVokRBLJ8Xhm/Oa9eJLnOT/xQ7M9c80nlFG8QCPE2x6lWikJUTnLLBgUvHMa2Bf0gUzinph3uyXG
++cGpLMqiYr2GuHLeCJ5iGAyxcz4IlvNXSl7z1wN4sNGlBefx86A8CtYo2yovOI1Moo+17EBRyg8f
+WQuR4CzKxXleXuTVM16WYnyKcrr4e9Zij7ZFKxWOe90F/Hzy2BLmXY24AdNrpPkeiEEb7yv2zXGZ
+nGfutFuy5HSf/rg6HSdp+hBju+vAW1YVVXbMcnX5qCyUpDgnc9z2VJrwg43KpNp6jx41QiDzCnTA
+o2b1rJD/uvDflPwrevfX9H4k4KzM/pFOTwDcYpBe9alDCIYGlKZhg3KI0Gg6c+mo4iaiTSGHqYfa
+t07WKzX57N5ILq2as9RkCt+wzhnsYU2NQCtJKfa+BiPQ8QfBv31nvQuxVjZE0Lo2GMLoP2Z3I6xd
+zL7yuofYTftMxrZONdcPdLU5j7dZnHH4awZn/M0grNGEp8HILrM/RVEVi2LJ5l9SIuwOnmWxLBYX
+LKi1VXZdX+NWsHDzF3HDlYXBGPBbwV+SlicsIql0VPsn64DO03AGAAAAAAAAAAAAAAAAAAAAAGRh
+dGEudGFyLmd6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwMDAwNDQ0ADAwMDAwMDAA
+MDAwMDAwMAAwMDAwMDAwMDE1MQAxNDYyMjI1NTM2NAAwMTMzNjIAIDAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAdXN0YXIAMDB3aGVlbAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAHdoZWVsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDAwMDAwMAAwMDAwMDAw
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4sI
+APRaSWYCA8rJTNLPyM9J1CtKYqAVMDA0MDAzMWEwgAB0Gsw2NDEzMjIyNTU2A6ozNDY2NmFQMGCg
+AygtLkksAjqlPCM1NQePOkLy6J4bBaNgFIyCQQ4AAAAA//8DAMJiTFMABgAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABjaGVj
+a3N1bXMueWFtbC5negAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDAwMDQ0NAAwMDAwMDAwADAw
+MDAwMDAAMDAwMDAwMDA0NTMAMTQ2MjIyNTUzNjQAMDE0NjE3ACAwAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHVzdGFyADAwd2hlZWwAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAB3aGVlbAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAwMDAwMDAAMDAwMDAwMAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB+LCAD0
+WklmAgNlkD2uFDAMBvs9xV5gke04/nkdHT0nsGObigZtxekJr4QijaWMZr7X6/X4/u0rbfl4PJ8/
++x0V7/jy4/fH0xIRx4PkkBT7Qt8cG0DSNq2iBIkMwD0ETCCgQ6Xh5WZWeHmfrHf8+uSRBivatKy8
+n6UT249x1CbVnAEHsbSDNEJAfRDuOytsa27767mR/vPkPtXpakdXyRBdsXti1lkjiye7uCeyHath
+7VzMRxAOxNo3L31AiJu7ryPe7BsZR91Tcp21chYaSyvDwZaQE+SxlOn09fqnM8nUVcUb5CSEbljA
+LH4HrZhC5YJMNyRevKZtKiqTZroEkKvuoHmS0iYWQFXYnTqOz0Wpxqw6RqnHhf0uah45WkjqtfPx
+B6h0MiLUAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==`)
+ holaChecksum := fmt.Sprintf("%x", sha256.Sum256(holaGemContent))
+
+ root := fmt.Sprintf("/api/packages/%s/rubygems", user.Name)
+
+ uploadFile := func(t *testing.T, content []byte, expectedStatus int) {
+ req := NewRequestWithBody(t, "POST", fmt.Sprintf("%s/api/v1/gems", root), bytes.NewReader(content)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, expectedStatus)
+ }
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadFile(t, gemContent, http.StatusCreated)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRubyGems)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.NotNil(t, pd.SemVer)
+ assert.IsType(t, &rubygems.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, packageFilename, pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(4608), pb.Size)
+ })
+
+ t.Run("UploadExists", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadFile(t, gemContent, http.StatusConflict)
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/gems/%s", root, packageFilename)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, gemContent, resp.Body.Bytes())
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRubyGems)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+ assert.Equal(t, int64(1), pvs[0].DownloadCount)
+ })
+
+ t.Run("DownloadGemspec", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/quick/Marshal.4.8/%sspec.rz", root, packageFilename)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ b, _ := base64.StdEncoding.DecodeString(`eJxi4Si1EndPzbWyCi5ITc5My0xOLMnMz2M8zMIRLeGpxGWsZ6RnzGbF5hqSyempxJWeWZKayGbN
+EBJqJQjWFZZaVJyZnxfN5qnEZahnoGcKkjTwVBJyB6lUKEhMzk5MTwULGngqcRaVJlWCONEMBp5K
+DGAWSKc7zFhPJamg0qRK99TcYphehZLU4hKInFhGSUlBsZW+PtgZepn5+iDxECRzDUDGcfh6hoA4
+gAAAAP//MS06Gw==`)
+ assert.Equal(t, b, resp.Body.Bytes())
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRubyGems)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+ assert.Equal(t, int64(1), pvs[0].DownloadCount)
+ })
+
+ t.Run("EnumeratePackages", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ enumeratePackages := func(t *testing.T, endpoint string, expectedContent []byte) {
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s", root, endpoint)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, expectedContent, resp.Body.Bytes())
+ }
+
+ b, _ := base64.StdEncoding.DecodeString(`H4sICAAAAAAA/3NwZWNzLjQuOABi4Yhmi+bwVOJKzyxJTWSzYnMNCbUSdE/NtbIKSy0qzszPi2bzVOIy1DPQM2WzZgjxVOIsKk2qBDEBAQAA///xOEYKOwAAAA==`)
+ enumeratePackages(t, "specs.4.8.gz", b)
+ b, _ = base64.StdEncoding.DecodeString(`H4sICAAAAAAA/2xhdGVzdF9zcGVjcy40LjgAYuGIZovm8FTiSs8sSU1ks2JzDQm1EnRPzbWyCkstKs7Mz4tm81TiMtQz0DNls2YI8VTiLCpNqgQxAQEAAP//8ThGCjsAAAA=`)
+ enumeratePackages(t, "latest_specs.4.8.gz", b)
+ b, _ = base64.StdEncoding.DecodeString(`H4sICAAAAAAA/3ByZXJlbGVhc2Vfc3BlY3MuNC44AGLhiGYABAAA//9snXr5BAAAAA==`)
+ enumeratePackages(t, "prerelease_specs.4.8.gz", b)
+ })
+
+ t.Run("UploadHola", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadFile(t, holaGemContent, http.StatusCreated)
+ })
+
+ t.Run("PackageInfo", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/info/%s", root, packageName)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+ expected := fmt.Sprintf("%s\n%s %s|checksum:%s,%s\n",
+ sep, packageVersion, packageDependency, checksum, rubyRequirements)
+ assert.Equal(t, expected, resp.Body.String())
+ })
+
+ t.Run("HolaPackageInfo", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/info/%s", root, holaPackageName)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+ expected := fmt.Sprintf("%s\n%s %s|checksum:%s,%s\n",
+ sep, holaPackageVersion, holaPackageDependency, holaChecksum, holaRubyGemsRequirements)
+ assert.Equal(t, expected, resp.Body.String())
+ })
+ t.Run("Versions", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ versionsReq := NewRequest(t, "GET", fmt.Sprintf("%s/versions", root)).
+ AddBasicAuth(user.Name)
+ versionsResp := MakeRequest(t, versionsReq, http.StatusOK)
+ infoReq := NewRequest(t, "GET", fmt.Sprintf("%s/info/%s", root, packageName)).
+ AddBasicAuth(user.Name)
+ infoResp := MakeRequest(t, infoReq, http.StatusOK)
+ holaInfoReq := NewRequest(t, "GET", fmt.Sprintf("%s/info/%s", root, holaPackageName)).
+ AddBasicAuth(user.Name)
+ holaInfoResp := MakeRequest(t, holaInfoReq, http.StatusOK)
+
+ // expected := fmt.Sprintf("%s\n%s %s %x\n",
+ // sep, packageName, packageVersion, md5.Sum(infoResp.Body.Bytes()))
+ lines := versionsResp.Body.String()
+ assert.ElementsMatch(t, strings.Split(lines, "\n"), []string{
+ sep,
+ fmt.Sprintf("%s %s %x", packageName, packageVersion, md5.Sum(infoResp.Body.Bytes())),
+ fmt.Sprintf("%s %s %x", holaPackageName, holaPackageVersion, md5.Sum(holaInfoResp.Body.Bytes())),
+ "",
+ })
+ })
+
+ t.Run("DeleteHola", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ body := bytes.Buffer{}
+ writer := multipart.NewWriter(&body)
+ writer.WriteField("gem_name", holaPackageName)
+ writer.WriteField("version", holaPackageVersion)
+ writer.Close()
+
+ req := NewRequestWithBody(t, "DELETE", fmt.Sprintf("%s/api/v1/gems/yank", root), &body).
+ SetHeader("Content-Type", writer.FormDataContentType()).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ body := bytes.Buffer{}
+ writer := multipart.NewWriter(&body)
+ writer.WriteField("gem_name", packageName)
+ writer.WriteField("version", packageVersion)
+ writer.Close()
+
+ req := NewRequestWithBody(t, "DELETE", fmt.Sprintf("%s/api/v1/gems/yank", root), &body).
+ SetHeader("Content-Type", writer.FormDataContentType()).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusOK)
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeRubyGems)
+ require.NoError(t, err)
+ assert.Empty(t, pvs)
+ })
+
+ t.Run("NonExistingGem", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/info/%s", root, packageName)).
+ AddBasicAuth(user.Name)
+ _ = MakeRequest(t, req, http.StatusNotFound)
+ })
+ t.Run("EmptyVersions", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/versions", root)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, sep+"\n", resp.Body.String())
+ })
+}
diff --git a/tests/integration/api_packages_swift_test.go b/tests/integration/api_packages_swift_test.go
new file mode 100644
index 0000000..5b6229f
--- /dev/null
+++ b/tests/integration/api_packages_swift_test.go
@@ -0,0 +1,327 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/zip"
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ swift_module "code.gitea.io/gitea/modules/packages/swift"
+ "code.gitea.io/gitea/modules/setting"
+ swift_router "code.gitea.io/gitea/routers/api/packages/swift"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageSwift(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ packageScope := "test-scope"
+ packageName := "test_package"
+ packageID := packageScope + "." + packageName
+ packageVersion := "1.0.3"
+ packageAuthor := "KN4CK3R"
+ packageDescription := "Gitea Test Package"
+ packageRepositoryURL := "https://gitea.io/gitea/gitea"
+ contentManifest1 := "// swift-tools-version:5.7\n//\n// Package.swift"
+ contentManifest2 := "// swift-tools-version:5.6\n//\n// Package@swift-5.6.swift"
+
+ url := fmt.Sprintf("/api/packages/%s/swift", user.Name)
+
+ t.Run("CheckAcceptMediaType", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ for _, sub := range []string{
+ "/scope/package",
+ "/scope/package.json",
+ "/scope/package/1.0.0",
+ "/scope/package/1.0.0.json",
+ "/scope/package/1.0.0.zip",
+ "/scope/package/1.0.0/Package.swift",
+ "/identifiers",
+ } {
+ req := NewRequest(t, "GET", url+sub)
+ req.Header.Add("Accept", "application/unknown")
+ resp := MakeRequest(t, req, http.StatusBadRequest)
+
+ assert.Equal(t, "1", resp.Header().Get("Content-Version"))
+ assert.Equal(t, "application/problem+json", resp.Header().Get("Content-Type"))
+ }
+
+ req := NewRequestWithBody(t, "PUT", url+"/scope/package/1.0.0", strings.NewReader("")).
+ AddBasicAuth(user.Name).
+ SetHeader("Accept", "application/unknown")
+ resp := MakeRequest(t, req, http.StatusBadRequest)
+
+ assert.Equal(t, "1", resp.Header().Get("Content-Version"))
+ assert.Equal(t, "application/problem+json", resp.Header().Get("Content-Type"))
+ })
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ uploadPackage := func(t *testing.T, url string, expectedStatus int, sr io.Reader, metadata string) {
+ var body bytes.Buffer
+ mpw := multipart.NewWriter(&body)
+
+ part, _ := mpw.CreateFormFile("source-archive", "source-archive.zip")
+ io.Copy(part, sr)
+
+ if metadata != "" {
+ mpw.WriteField("metadata", metadata)
+ }
+
+ mpw.Close()
+
+ req := NewRequestWithBody(t, "PUT", url, &body).
+ SetHeader("Content-Type", mpw.FormDataContentType()).
+ SetHeader("Accept", swift_router.AcceptJSON).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, expectedStatus)
+ }
+
+ createArchive := func(files map[string]string) *bytes.Buffer {
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ for filename, content := range files {
+ w, _ := zw.Create(filename)
+ w.Write([]byte(content))
+ }
+ zw.Close()
+ return &buf
+ }
+
+ for _, triple := range []string{"/sc_ope/package/1.0.0", "/scope/pack~age/1.0.0", "/scope/package/1_0.0"} {
+ req := NewRequestWithBody(t, "PUT", url+triple, bytes.NewReader([]byte{})).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusBadRequest)
+
+ assert.Equal(t, "1", resp.Header().Get("Content-Version"))
+ assert.Equal(t, "application/problem+json", resp.Header().Get("Content-Type"))
+ }
+
+ uploadURL := fmt.Sprintf("%s/%s/%s/%s", url, packageScope, packageName, packageVersion)
+
+ req := NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader([]byte{}))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ uploadPackage(
+ t,
+ uploadURL,
+ http.StatusCreated,
+ createArchive(map[string]string{
+ "Package.swift": contentManifest1,
+ "Package@swift-5.6.swift": contentManifest2,
+ }),
+ `{"name":"`+packageName+`","version":"`+packageVersion+`","description":"`+packageDescription+`","codeRepository":"`+packageRepositoryURL+`","author":{"givenName":"`+packageAuthor+`"},"repositoryURLs":["`+packageRepositoryURL+`"]}`,
+ )
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeSwift)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.NotNil(t, pd.SemVer)
+ assert.Equal(t, packageID, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+ assert.IsType(t, &swift_module.Metadata{}, pd.Metadata)
+ metadata := pd.Metadata.(*swift_module.Metadata)
+ assert.Equal(t, packageDescription, metadata.Description)
+ assert.Len(t, metadata.Manifests, 2)
+ assert.Equal(t, contentManifest1, metadata.Manifests[""].Content)
+ assert.Equal(t, contentManifest2, metadata.Manifests["5.6"].Content)
+ assert.Len(t, pd.VersionProperties, 1)
+ assert.Equal(t, packageRepositoryURL, pd.VersionProperties.GetByName(swift_module.PropertyRepositoryURL))
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, fmt.Sprintf("%s-%s.zip", packageName, packageVersion), pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ uploadPackage(
+ t,
+ uploadURL,
+ http.StatusConflict,
+ createArchive(map[string]string{
+ "Package.swift": contentManifest1,
+ }),
+ "",
+ )
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/%s.zip", url, packageScope, packageName, packageVersion)).
+ AddBasicAuth(user.Name).
+ SetHeader("Accept", swift_router.AcceptZip)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "1", resp.Header().Get("Content-Version"))
+ assert.Equal(t, "application/zip", resp.Header().Get("Content-Type"))
+
+ pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeSwift, packageID, packageVersion)
+ assert.NotNil(t, pv)
+ require.NoError(t, err)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pv)
+ require.NoError(t, err)
+ assert.Equal(t, "sha256="+pd.Files[0].Blob.HashSHA256, resp.Header().Get("Digest"))
+ })
+
+ t.Run("EnumeratePackageVersions", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s", url, packageScope, packageName)).
+ AddBasicAuth(user.Name).
+ SetHeader("Accept", swift_router.AcceptJSON)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ versionURL := setting.AppURL + url[1:] + fmt.Sprintf("/%s/%s/%s", packageScope, packageName, packageVersion)
+
+ assert.Equal(t, "1", resp.Header().Get("Content-Version"))
+ assert.Equal(t, fmt.Sprintf(`<%s>; rel="latest-version"`, versionURL), resp.Header().Get("Link"))
+
+ body := resp.Body.String()
+
+ var result *swift_router.EnumeratePackageVersionsResponse
+ DecodeJSON(t, resp, &result)
+
+ assert.Len(t, result.Releases, 1)
+ assert.Contains(t, result.Releases, packageVersion)
+ assert.Equal(t, versionURL, result.Releases[packageVersion].URL)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s.json", url, packageScope, packageName)).
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, body, resp.Body.String())
+ })
+
+ t.Run("PackageVersionMetadata", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/%s", url, packageScope, packageName, packageVersion)).
+ AddBasicAuth(user.Name).
+ SetHeader("Accept", swift_router.AcceptJSON)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "1", resp.Header().Get("Content-Version"))
+
+ body := resp.Body.String()
+
+ var result *swift_router.PackageVersionMetadataResponse
+ DecodeJSON(t, resp, &result)
+
+ pv, err := packages.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages.TypeSwift, packageID, packageVersion)
+ assert.NotNil(t, pv)
+ require.NoError(t, err)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pv)
+ require.NoError(t, err)
+
+ assert.Equal(t, packageID, result.ID)
+ assert.Equal(t, packageVersion, result.Version)
+ assert.Len(t, result.Resources, 1)
+ assert.Equal(t, "source-archive", result.Resources[0].Name)
+ assert.Equal(t, "application/zip", result.Resources[0].Type)
+ assert.Equal(t, pd.Files[0].Blob.HashSHA256, result.Resources[0].Checksum)
+ assert.Equal(t, "SoftwareSourceCode", result.Metadata.Type)
+ assert.Equal(t, packageName, result.Metadata.Name)
+ assert.Equal(t, packageVersion, result.Metadata.Version)
+ assert.Equal(t, packageDescription, result.Metadata.Description)
+ assert.Equal(t, "Swift", result.Metadata.ProgrammingLanguage.Name)
+ assert.Equal(t, packageAuthor, result.Metadata.Author.GivenName)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s/%s.json", url, packageScope, packageName, packageVersion)).
+ AddBasicAuth(user.Name)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, body, resp.Body.String())
+ })
+
+ t.Run("DownloadManifest", func(t *testing.T) {
+ manifestURL := fmt.Sprintf("%s/%s/%s/%s/Package.swift", url, packageScope, packageName, packageVersion)
+
+ t.Run("Default", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", manifestURL).
+ AddBasicAuth(user.Name).
+ SetHeader("Accept", swift_router.AcceptSwift)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "1", resp.Header().Get("Content-Version"))
+ assert.Equal(t, "text/x-swift", resp.Header().Get("Content-Type"))
+ assert.Equal(t, contentManifest1, resp.Body.String())
+ })
+
+ t.Run("DifferentVersion", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", manifestURL+"?swift-version=5.6").
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "1", resp.Header().Get("Content-Version"))
+ assert.Equal(t, "text/x-swift", resp.Header().Get("Content-Type"))
+ assert.Equal(t, contentManifest2, resp.Body.String())
+
+ req = NewRequest(t, "GET", manifestURL+"?swift-version=5.6.0").
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("Redirect", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", manifestURL+"?swift-version=1.0").
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+
+ assert.Equal(t, "1", resp.Header().Get("Content-Version"))
+ assert.Equal(t, setting.AppURL+url[1:]+fmt.Sprintf("/%s/%s/%s/Package.swift", packageScope, packageName, packageVersion), resp.Header().Get("Location"))
+ })
+ })
+
+ t.Run("LookupPackageIdentifiers", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", url+"/identifiers").
+ SetHeader("Accept", swift_router.AcceptJSON)
+ resp := MakeRequest(t, req, http.StatusBadRequest)
+
+ assert.Equal(t, "1", resp.Header().Get("Content-Version"))
+ assert.Equal(t, "application/problem+json", resp.Header().Get("Content-Type"))
+
+ req = NewRequest(t, "GET", url+"/identifiers?url=https://unknown.host/")
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", url+"/identifiers?url="+packageRepositoryURL).
+ SetHeader("Accept", swift_router.AcceptJSON)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ var result *swift_router.LookupPackageIdentifiersResponse
+ DecodeJSON(t, resp, &result)
+
+ assert.Len(t, result.Identifiers, 1)
+ assert.Equal(t, packageID, result.Identifiers[0])
+ })
+}
diff --git a/tests/integration/api_packages_test.go b/tests/integration/api_packages_test.go
new file mode 100644
index 0000000..27aed0f
--- /dev/null
+++ b/tests/integration/api_packages_test.go
@@ -0,0 +1,647 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ container_model "code.gitea.io/gitea/models/packages/container"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ packages_service "code.gitea.io/gitea/services/packages"
+ packages_cleanup_service "code.gitea.io/gitea/services/packages/cleanup"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageAPI(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
+ session := loginUser(t, user.Name)
+ tokenReadPackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadPackage)
+ tokenDeletePackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWritePackage)
+
+ packageName := "test-package"
+ packageVersion := "1.0.3"
+ filename := "file.bin"
+
+ url := fmt.Sprintf("/api/packages/%s/generic/%s/%s/%s", user.Name, packageName, packageVersion, filename)
+ req := NewRequestWithBody(t, "PUT", url, bytes.NewReader([]byte{})).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ t.Run("ListPackages", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s", user.Name)).
+ AddTokenAuth(tokenReadPackage)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiPackages []*api.Package
+ DecodeJSON(t, resp, &apiPackages)
+
+ assert.Len(t, apiPackages, 1)
+ assert.Equal(t, string(packages_model.TypeGeneric), apiPackages[0].Type)
+ assert.Equal(t, packageName, apiPackages[0].Name)
+ assert.Equal(t, packageVersion, apiPackages[0].Version)
+ assert.NotNil(t, apiPackages[0].Creator)
+ assert.Equal(t, user.Name, apiPackages[0].Creator.UserName)
+ })
+
+ t.Run("GetPackage", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/dummy/%s/%s", user.Name, packageName, packageVersion)).
+ AddTokenAuth(tokenReadPackage)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)).
+ AddTokenAuth(tokenReadPackage)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var p *api.Package
+ DecodeJSON(t, resp, &p)
+
+ assert.Equal(t, string(packages_model.TypeGeneric), p.Type)
+ assert.Equal(t, packageName, p.Name)
+ assert.Equal(t, packageVersion, p.Version)
+ assert.NotNil(t, p.Creator)
+ assert.Equal(t, user.Name, p.Creator.UserName)
+
+ t.Run("RepositoryLink", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ p, err := packages_model.GetPackageByName(db.DefaultContext, user.ID, packages_model.TypeGeneric, packageName)
+ require.NoError(t, err)
+
+ // no repository link
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)).
+ AddTokenAuth(tokenReadPackage)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var ap1 *api.Package
+ DecodeJSON(t, resp, &ap1)
+ assert.Nil(t, ap1.Repository)
+
+ // link to public repository
+ require.NoError(t, packages_model.SetRepositoryLink(db.DefaultContext, p.ID, 1))
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)).
+ AddTokenAuth(tokenReadPackage)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ var ap2 *api.Package
+ DecodeJSON(t, resp, &ap2)
+ assert.NotNil(t, ap2.Repository)
+ assert.EqualValues(t, 1, ap2.Repository.ID)
+
+ // link to private repository
+ require.NoError(t, packages_model.SetRepositoryLink(db.DefaultContext, p.ID, 2))
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)).
+ AddTokenAuth(tokenReadPackage)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ var ap3 *api.Package
+ DecodeJSON(t, resp, &ap3)
+ assert.Nil(t, ap3.Repository)
+
+ require.NoError(t, packages_model.UnlinkRepositoryFromAllPackages(db.DefaultContext, 2))
+ })
+ })
+
+ t.Run("ListPackageFiles", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/dummy/%s/%s/files", user.Name, packageName, packageVersion)).
+ AddTokenAuth(tokenReadPackage)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s/files", user.Name, packageName, packageVersion)).
+ AddTokenAuth(tokenReadPackage)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var files []*api.PackageFile
+ DecodeJSON(t, resp, &files)
+
+ assert.Len(t, files, 1)
+ assert.Equal(t, int64(0), files[0].Size)
+ assert.Equal(t, filename, files[0].Name)
+ assert.Equal(t, "d41d8cd98f00b204e9800998ecf8427e", files[0].HashMD5)
+ assert.Equal(t, "da39a3ee5e6b4b0d3255bfef95601890afd80709", files[0].HashSHA1)
+ assert.Equal(t, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", files[0].HashSHA256)
+ assert.Equal(t, "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", files[0].HashSHA512)
+ })
+
+ t.Run("DeletePackage", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/packages/%s/dummy/%s/%s", user.Name, packageName, packageVersion)).
+ AddTokenAuth(tokenDeletePackage)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/packages/%s/generic/%s/%s", user.Name, packageName, packageVersion)).
+ AddTokenAuth(tokenDeletePackage)
+ MakeRequest(t, req, http.StatusNoContent)
+ })
+}
+
+func TestPackageAccess(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ inactive := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 9})
+ limitedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 33})
+ privateUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 31})
+ privateOrgMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23}) // user has package write access
+ limitedOrgMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 36}) // user has package write access
+ publicOrgMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 25}) // user has package read access
+ privateOrgNoMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 35})
+ limitedOrgNoMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22})
+ publicOrgNoMember := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17})
+
+ uploadPackage := func(doer, owner *user_model.User, filename string, expectedStatus int) {
+ url := fmt.Sprintf("/api/packages/%s/generic/test-package/1.0/%s.bin", owner.Name, filename)
+ req := NewRequestWithBody(t, "PUT", url, bytes.NewReader([]byte{1}))
+ if doer != nil {
+ req.AddBasicAuth(doer.Name)
+ }
+ MakeRequest(t, req, expectedStatus)
+ }
+
+ downloadPackage := func(doer, owner *user_model.User, expectedStatus int) {
+ url := fmt.Sprintf("/api/packages/%s/generic/test-package/1.0/admin.bin", owner.Name)
+ req := NewRequest(t, "GET", url)
+ if doer != nil {
+ req.AddBasicAuth(doer.Name)
+ }
+ MakeRequest(t, req, expectedStatus)
+ }
+
+ type Target struct {
+ Owner *user_model.User
+ ExpectedStatus int
+ }
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ cases := []struct {
+ Doer *user_model.User
+ Filename string
+ Targets []Target
+ }{
+ { // Admins can upload to every owner
+ Doer: admin,
+ Filename: "admin",
+ Targets: []Target{
+ {admin, http.StatusCreated},
+ {inactive, http.StatusCreated},
+ {user, http.StatusCreated},
+ {limitedUser, http.StatusCreated},
+ {privateUser, http.StatusCreated},
+ {privateOrgMember, http.StatusCreated},
+ {limitedOrgMember, http.StatusCreated},
+ {publicOrgMember, http.StatusCreated},
+ {privateOrgNoMember, http.StatusCreated},
+ {limitedOrgNoMember, http.StatusCreated},
+ {publicOrgNoMember, http.StatusCreated},
+ },
+ },
+ { // Without credentials no upload should be possible
+ Doer: nil,
+ Filename: "nil",
+ Targets: []Target{
+ {admin, http.StatusUnauthorized},
+ {inactive, http.StatusUnauthorized},
+ {user, http.StatusUnauthorized},
+ {limitedUser, http.StatusUnauthorized},
+ {privateUser, http.StatusUnauthorized},
+ {privateOrgMember, http.StatusUnauthorized},
+ {limitedOrgMember, http.StatusUnauthorized},
+ {publicOrgMember, http.StatusUnauthorized},
+ {privateOrgNoMember, http.StatusUnauthorized},
+ {limitedOrgNoMember, http.StatusUnauthorized},
+ {publicOrgNoMember, http.StatusUnauthorized},
+ },
+ },
+ { // Inactive users can't upload anywhere
+ Doer: inactive,
+ Filename: "inactive",
+ Targets: []Target{
+ {admin, http.StatusUnauthorized},
+ {inactive, http.StatusUnauthorized},
+ {user, http.StatusUnauthorized},
+ {limitedUser, http.StatusUnauthorized},
+ {privateUser, http.StatusUnauthorized},
+ {privateOrgMember, http.StatusUnauthorized},
+ {limitedOrgMember, http.StatusUnauthorized},
+ {publicOrgMember, http.StatusUnauthorized},
+ {privateOrgNoMember, http.StatusUnauthorized},
+ {limitedOrgNoMember, http.StatusUnauthorized},
+ {publicOrgNoMember, http.StatusUnauthorized},
+ },
+ },
+ { // Normal users can upload to self and orgs in which they are members and have package write access
+ Doer: user,
+ Filename: "user",
+ Targets: []Target{
+ {admin, http.StatusUnauthorized},
+ {inactive, http.StatusUnauthorized},
+ {user, http.StatusCreated},
+ {limitedUser, http.StatusUnauthorized},
+ {privateUser, http.StatusUnauthorized},
+ {privateOrgMember, http.StatusCreated},
+ {limitedOrgMember, http.StatusCreated},
+ {publicOrgMember, http.StatusUnauthorized},
+ {privateOrgNoMember, http.StatusUnauthorized},
+ {limitedOrgNoMember, http.StatusUnauthorized},
+ {publicOrgNoMember, http.StatusUnauthorized},
+ },
+ },
+ }
+
+ for _, c := range cases {
+ for _, t := range c.Targets {
+ uploadPackage(c.Doer, t.Owner, c.Filename, t.ExpectedStatus)
+ }
+ }
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ cases := []struct {
+ Doer *user_model.User
+ Filename string
+ Targets []Target
+ }{
+ { // Admins can access everything
+ Doer: admin,
+ Targets: []Target{
+ {admin, http.StatusOK},
+ {inactive, http.StatusOK},
+ {user, http.StatusOK},
+ {limitedUser, http.StatusOK},
+ {privateUser, http.StatusOK},
+ {privateOrgMember, http.StatusOK},
+ {limitedOrgMember, http.StatusOK},
+ {publicOrgMember, http.StatusOK},
+ {privateOrgNoMember, http.StatusOK},
+ {limitedOrgNoMember, http.StatusOK},
+ {publicOrgNoMember, http.StatusOK},
+ },
+ },
+ { // Without credentials only public owners are accessible
+ Doer: nil,
+ Targets: []Target{
+ {admin, http.StatusOK},
+ {inactive, http.StatusOK},
+ {user, http.StatusOK},
+ {limitedUser, http.StatusUnauthorized},
+ {privateUser, http.StatusUnauthorized},
+ {privateOrgMember, http.StatusUnauthorized},
+ {limitedOrgMember, http.StatusUnauthorized},
+ {publicOrgMember, http.StatusOK},
+ {privateOrgNoMember, http.StatusUnauthorized},
+ {limitedOrgNoMember, http.StatusUnauthorized},
+ {publicOrgNoMember, http.StatusOK},
+ },
+ },
+ { // Inactive users have no access
+ Doer: inactive,
+ Targets: []Target{
+ {admin, http.StatusUnauthorized},
+ {inactive, http.StatusUnauthorized},
+ {user, http.StatusUnauthorized},
+ {limitedUser, http.StatusUnauthorized},
+ {privateUser, http.StatusUnauthorized},
+ {privateOrgMember, http.StatusUnauthorized},
+ {limitedOrgMember, http.StatusUnauthorized},
+ {publicOrgMember, http.StatusUnauthorized},
+ {privateOrgNoMember, http.StatusUnauthorized},
+ {limitedOrgNoMember, http.StatusUnauthorized},
+ {publicOrgNoMember, http.StatusUnauthorized},
+ },
+ },
+ { // Normal users can access self, public or limited users/orgs and private orgs in which they are members
+ Doer: user,
+ Targets: []Target{
+ {admin, http.StatusOK},
+ {inactive, http.StatusOK},
+ {user, http.StatusOK},
+ {limitedUser, http.StatusOK},
+ {privateUser, http.StatusUnauthorized},
+ {privateOrgMember, http.StatusOK},
+ {limitedOrgMember, http.StatusOK},
+ {publicOrgMember, http.StatusOK},
+ {privateOrgNoMember, http.StatusUnauthorized},
+ {limitedOrgNoMember, http.StatusOK},
+ {publicOrgNoMember, http.StatusOK},
+ },
+ },
+ }
+
+ for _, c := range cases {
+ for _, target := range c.Targets {
+ downloadPackage(c.Doer, target.Owner, target.ExpectedStatus)
+ }
+ }
+ })
+
+ t.Run("API", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, user.Name)
+ tokenReadPackage := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadPackage)
+
+ for _, target := range []Target{
+ {admin, http.StatusOK},
+ {inactive, http.StatusOK},
+ {user, http.StatusOK},
+ {limitedUser, http.StatusOK},
+ {privateUser, http.StatusForbidden},
+ {privateOrgMember, http.StatusOK},
+ {limitedOrgMember, http.StatusOK},
+ {publicOrgMember, http.StatusOK},
+ {privateOrgNoMember, http.StatusForbidden},
+ {limitedOrgNoMember, http.StatusOK},
+ {publicOrgNoMember, http.StatusOK},
+ } {
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/packages/%s", target.Owner.Name)).
+ AddTokenAuth(tokenReadPackage)
+ MakeRequest(t, req, target.ExpectedStatus)
+ }
+ })
+}
+
+func TestPackageQuota(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ limitTotalOwnerCount, limitTotalOwnerSize := setting.Packages.LimitTotalOwnerCount, setting.Packages.LimitTotalOwnerSize
+
+ // Exceeded quota result in StatusForbidden for normal users but admins are always allowed to upload.
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10})
+
+ t.Run("Common", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ limitSizeGeneric := setting.Packages.LimitSizeGeneric
+
+ uploadPackage := func(doer *user_model.User, version string, expectedStatus int) {
+ url := fmt.Sprintf("/api/packages/%s/generic/test-package/%s/file.bin", user.Name, version)
+ req := NewRequestWithBody(t, "PUT", url, bytes.NewReader([]byte{1})).
+ AddBasicAuth(doer.Name)
+ MakeRequest(t, req, expectedStatus)
+ }
+
+ setting.Packages.LimitTotalOwnerCount = 0
+ uploadPackage(user, "1.0", http.StatusForbidden)
+ uploadPackage(admin, "1.0", http.StatusCreated)
+ setting.Packages.LimitTotalOwnerCount = limitTotalOwnerCount
+
+ setting.Packages.LimitTotalOwnerSize = 0
+ uploadPackage(user, "1.1", http.StatusForbidden)
+ uploadPackage(admin, "1.1", http.StatusCreated)
+ setting.Packages.LimitTotalOwnerSize = limitTotalOwnerSize
+
+ setting.Packages.LimitSizeGeneric = 0
+ uploadPackage(user, "1.2", http.StatusForbidden)
+ uploadPackage(admin, "1.2", http.StatusCreated)
+ setting.Packages.LimitSizeGeneric = limitSizeGeneric
+ })
+
+ t.Run("Container", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ limitSizeContainer := setting.Packages.LimitSizeContainer
+
+ uploadBlob := func(doer *user_model.User, data string, expectedStatus int) {
+ url := fmt.Sprintf("/v2/%s/quota-test/blobs/uploads?digest=sha256:%x", user.Name, sha256.Sum256([]byte(data)))
+ req := NewRequestWithBody(t, "POST", url, strings.NewReader(data)).
+ AddBasicAuth(doer.Name)
+ MakeRequest(t, req, expectedStatus)
+ }
+
+ setting.Packages.LimitTotalOwnerSize = 0
+ uploadBlob(user, "2", http.StatusForbidden)
+ uploadBlob(admin, "2", http.StatusCreated)
+ setting.Packages.LimitTotalOwnerSize = limitTotalOwnerSize
+
+ setting.Packages.LimitSizeContainer = 0
+ uploadBlob(user, "3", http.StatusForbidden)
+ uploadBlob(admin, "3", http.StatusCreated)
+ setting.Packages.LimitSizeContainer = limitSizeContainer
+ })
+}
+
+func TestPackageCleanup(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ duration, _ := time.ParseDuration("-1h")
+
+ t.Run("Common", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Upload and delete a generic package and upload a container blob
+ data, _ := util.CryptoRandomBytes(5)
+ url := fmt.Sprintf("/api/packages/%s/generic/cleanup-test/1.1.1/file.bin", user.Name)
+ req := NewRequestWithBody(t, "PUT", url, bytes.NewReader(data)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequest(t, "DELETE", url).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ data, _ = util.CryptoRandomBytes(5)
+ url = fmt.Sprintf("/v2/%s/cleanup-test/blobs/uploads?digest=sha256:%x", user.Name, sha256.Sum256(data))
+ req = NewRequestWithBody(t, "POST", url, bytes.NewReader(data)).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ unittest.AssertExistsAndLoadBean(t, &packages_model.Package{Name: "cleanup-test"})
+
+ pbs, err := packages_model.FindExpiredUnreferencedBlobs(db.DefaultContext, duration)
+ require.NoError(t, err)
+ assert.NotEmpty(t, pbs)
+
+ _, err = packages_model.GetInternalVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, "cleanup-test", container_model.UploadVersion)
+ require.NoError(t, err)
+
+ err = packages_cleanup_service.CleanupTask(db.DefaultContext, duration)
+ require.NoError(t, err)
+
+ pbs, err = packages_model.FindExpiredUnreferencedBlobs(db.DefaultContext, duration)
+ require.NoError(t, err)
+ assert.Empty(t, pbs)
+
+ unittest.AssertNotExistsBean(t, &packages_model.Package{Name: "cleanup-test"})
+
+ _, err = packages_model.GetInternalVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeContainer, "cleanup-test", container_model.UploadVersion)
+ require.ErrorIs(t, err, packages_model.ErrPackageNotExist)
+ })
+
+ t.Run("CleanupRules", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ type version struct {
+ Version string
+ ShouldExist bool
+ Created int64
+ }
+
+ cases := []struct {
+ Name string
+ Versions []version
+ Rule *packages_model.PackageCleanupRule
+ }{
+ {
+ Name: "Disabled",
+ Versions: []version{
+ {Version: "keep", ShouldExist: true},
+ },
+ Rule: &packages_model.PackageCleanupRule{
+ Enabled: false,
+ },
+ },
+ {
+ Name: "KeepCount",
+ Versions: []version{
+ {Version: "keep", ShouldExist: true},
+ {Version: "v1.0", ShouldExist: true},
+ {Version: "test-3", ShouldExist: false, Created: 1},
+ {Version: "test-4", ShouldExist: false, Created: 1},
+ },
+ Rule: &packages_model.PackageCleanupRule{
+ Enabled: true,
+ KeepCount: 2,
+ },
+ },
+ {
+ Name: "KeepPattern",
+ Versions: []version{
+ {Version: "keep", ShouldExist: true},
+ {Version: "v1.0", ShouldExist: false},
+ },
+ Rule: &packages_model.PackageCleanupRule{
+ Enabled: true,
+ KeepPattern: "k.+p",
+ },
+ },
+ {
+ Name: "RemoveDays",
+ Versions: []version{
+ {Version: "keep", ShouldExist: true},
+ {Version: "v1.0", ShouldExist: false, Created: 1},
+ },
+ Rule: &packages_model.PackageCleanupRule{
+ Enabled: true,
+ RemoveDays: 60,
+ },
+ },
+ {
+ Name: "RemovePattern",
+ Versions: []version{
+ {Version: "test", ShouldExist: true},
+ {Version: "test-3", ShouldExist: false},
+ {Version: "test-4", ShouldExist: false},
+ },
+ Rule: &packages_model.PackageCleanupRule{
+ Enabled: true,
+ RemovePattern: `t[e]+st-\d+`,
+ },
+ },
+ {
+ Name: "MatchFullName",
+ Versions: []version{
+ {Version: "keep", ShouldExist: true},
+ {Version: "test", ShouldExist: false},
+ },
+ Rule: &packages_model.PackageCleanupRule{
+ Enabled: true,
+ RemovePattern: `package/test|different/keep`,
+ MatchFullName: true,
+ },
+ },
+ {
+ Name: "Mixed",
+ Versions: []version{
+ {Version: "keep", ShouldExist: true, Created: time.Now().Add(time.Duration(10000)).Unix()},
+ {Version: "dummy", ShouldExist: true, Created: 1},
+ {Version: "test-3", ShouldExist: true},
+ {Version: "test-4", ShouldExist: false, Created: 1},
+ },
+ Rule: &packages_model.PackageCleanupRule{
+ Enabled: true,
+ KeepCount: 1,
+ KeepPattern: `dummy`,
+ RemoveDays: 7,
+ RemovePattern: `t[e]+st-\d+`,
+ },
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.Name, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ for _, v := range c.Versions {
+ url := fmt.Sprintf("/api/packages/%s/generic/package/%s/file.bin", user.Name, v.Version)
+ req := NewRequestWithBody(t, "PUT", url, bytes.NewReader([]byte{1})).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusCreated)
+
+ if v.Created != 0 {
+ pv, err := packages_model.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeGeneric, "package", v.Version)
+ require.NoError(t, err)
+ _, err = db.GetEngine(db.DefaultContext).Exec("UPDATE package_version SET created_unix = ? WHERE id = ?", v.Created, pv.ID)
+ require.NoError(t, err)
+ }
+ }
+
+ c.Rule.OwnerID = user.ID
+ c.Rule.Type = packages_model.TypeGeneric
+
+ pcr, err := packages_model.InsertCleanupRule(db.DefaultContext, c.Rule)
+ require.NoError(t, err)
+
+ err = packages_cleanup_service.CleanupTask(db.DefaultContext, duration)
+ require.NoError(t, err)
+
+ for _, v := range c.Versions {
+ pv, err := packages_model.GetVersionByNameAndVersion(db.DefaultContext, user.ID, packages_model.TypeGeneric, "package", v.Version)
+ if v.ShouldExist {
+ require.NoError(t, err)
+ err = packages_service.DeletePackageVersionAndReferences(db.DefaultContext, pv)
+ require.NoError(t, err)
+ } else {
+ require.ErrorIs(t, err, packages_model.ErrPackageNotExist)
+ }
+ }
+
+ require.NoError(t, packages_model.DeleteCleanupRuleByID(db.DefaultContext, pcr.ID))
+ })
+ }
+ })
+}
diff --git a/tests/integration/api_packages_vagrant_test.go b/tests/integration/api_packages_vagrant_test.go
new file mode 100644
index 0000000..b446466
--- /dev/null
+++ b/tests/integration/api_packages_vagrant_test.go
@@ -0,0 +1,172 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/json"
+ vagrant_module "code.gitea.io/gitea/modules/packages/vagrant"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPackageVagrant(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ token := "Bearer " + getUserToken(t, user.Name, auth_model.AccessTokenScopeWritePackage)
+
+ packageName := "test_package"
+ packageVersion := "1.0.1"
+ packageDescription := "Test Description"
+ packageProvider := "virtualbox"
+
+ filename := fmt.Sprintf("%s.box", packageProvider)
+
+ infoContent, _ := json.Marshal(map[string]string{
+ "description": packageDescription,
+ })
+
+ var buf bytes.Buffer
+ zw := gzip.NewWriter(&buf)
+ archive := tar.NewWriter(zw)
+ archive.WriteHeader(&tar.Header{
+ Name: "info.json",
+ Mode: 0o600,
+ Size: int64(len(infoContent)),
+ })
+ archive.Write(infoContent)
+ archive.Close()
+ zw.Close()
+ content := buf.Bytes()
+
+ root := fmt.Sprintf("/api/packages/%s/vagrant", user.Name)
+
+ t.Run("Authenticate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ authenticateURL := fmt.Sprintf("%s/authenticate", root)
+
+ req := NewRequest(t, "GET", authenticateURL)
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "GET", authenticateURL).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ })
+
+ boxURL := fmt.Sprintf("%s/%s", root, packageName)
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "HEAD", boxURL)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ uploadURL := fmt.Sprintf("%s/%s/%s", boxURL, packageVersion, filename)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequest(t, "HEAD", boxURL)
+ resp := MakeRequest(t, req, http.StatusOK)
+ assert.True(t, strings.HasPrefix(resp.Header().Get("Content-Type"), "application/json"))
+
+ pvs, err := packages.GetVersionsByPackageType(db.DefaultContext, user.ID, packages.TypeVagrant)
+ require.NoError(t, err)
+ assert.Len(t, pvs, 1)
+
+ pd, err := packages.GetPackageDescriptor(db.DefaultContext, pvs[0])
+ require.NoError(t, err)
+ assert.NotNil(t, pd.SemVer)
+ assert.IsType(t, &vagrant_module.Metadata{}, pd.Metadata)
+ assert.Equal(t, packageName, pd.Package.Name)
+ assert.Equal(t, packageVersion, pd.Version.Version)
+
+ pfs, err := packages.GetFilesByVersionID(db.DefaultContext, pvs[0].ID)
+ require.NoError(t, err)
+ assert.Len(t, pfs, 1)
+ assert.Equal(t, filename, pfs[0].Name)
+ assert.True(t, pfs[0].IsLead)
+
+ pb, err := packages.GetBlobByID(db.DefaultContext, pfs[0].BlobID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(len(content)), pb.Size)
+
+ req = NewRequestWithBody(t, "PUT", uploadURL, bytes.NewReader(content)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusConflict)
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/%s/%s", boxURL, packageVersion, filename))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, content, resp.Body.Bytes())
+ })
+
+ t.Run("EnumeratePackageVersions", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", boxURL)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ type providerData struct {
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Checksum string `json:"checksum"`
+ ChecksumType string `json:"checksum_type"`
+ }
+
+ type versionMetadata struct {
+ Version string `json:"version"`
+ Status string `json:"status"`
+ DescriptionHTML string `json:"description_html,omitempty"`
+ DescriptionMarkdown string `json:"description_markdown,omitempty"`
+ Providers []*providerData `json:"providers"`
+ }
+
+ type packageMetadata struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ ShortDescription string `json:"short_description,omitempty"`
+ Versions []*versionMetadata `json:"versions"`
+ }
+
+ var result packageMetadata
+ DecodeJSON(t, resp, &result)
+
+ assert.Equal(t, packageName, result.Name)
+ assert.Equal(t, packageDescription, result.Description)
+ assert.Len(t, result.Versions, 1)
+ version := result.Versions[0]
+ assert.Equal(t, packageVersion, version.Version)
+ assert.Equal(t, "active", version.Status)
+ assert.Len(t, version.Providers, 1)
+ provider := version.Providers[0]
+ assert.Equal(t, packageProvider, provider.Name)
+ assert.Equal(t, "sha512", provider.ChecksumType)
+ assert.Equal(t, "259bebd6160acad695016d22a45812e26f187aaf78e71a4c23ee3201528346293f991af3468a8c6c5d2a21d7d9e1bdc1bf79b87110b2fddfcc5a0d45963c7c30", provider.Checksum)
+ })
+}
diff --git a/tests/integration/api_private_serv_test.go b/tests/integration/api_private_serv_test.go
new file mode 100644
index 0000000..3339fc4
--- /dev/null
+++ b/tests/integration/api_private_serv_test.go
@@ -0,0 +1,154 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "net/url"
+ "testing"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/perm"
+ "code.gitea.io/gitea/modules/private"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIPrivateNoServ(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ key, user, err := private.ServNoCommand(ctx, 1)
+ require.NoError(t, err)
+ assert.Equal(t, int64(2), user.ID)
+ assert.Equal(t, "user2", user.Name)
+ assert.Equal(t, int64(1), key.ID)
+ assert.Equal(t, "user2@localhost", key.Name)
+
+ deployKey, err := asymkey_model.AddDeployKey(ctx, 1, "test-deploy", "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment", false)
+ require.NoError(t, err)
+
+ key, user, err = private.ServNoCommand(ctx, deployKey.KeyID)
+ require.NoError(t, err)
+ assert.Empty(t, user)
+ assert.Equal(t, deployKey.KeyID, key.ID)
+ assert.Equal(t, "test-deploy", key.Name)
+ })
+}
+
+func TestAPIPrivateServ(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ // Can push to a repo we own
+ results, extra := private.ServCommand(ctx, 1, "user2", "repo1", perm.AccessModeWrite, "git-upload-pack", "")
+ require.NoError(t, extra.Error)
+ assert.False(t, results.IsWiki)
+ assert.Zero(t, results.DeployKeyID)
+ assert.Equal(t, int64(1), results.KeyID)
+ assert.Equal(t, "user2@localhost", results.KeyName)
+ assert.Equal(t, "user2", results.UserName)
+ assert.Equal(t, int64(2), results.UserID)
+ assert.Equal(t, "user2", results.OwnerName)
+ assert.Equal(t, "repo1", results.RepoName)
+ assert.Equal(t, int64(1), results.RepoID)
+
+ // Cannot push to a private repo we're not associated with
+ results, extra = private.ServCommand(ctx, 1, "user15", "big_test_private_1", perm.AccessModeWrite, "git-upload-pack", "")
+ require.Error(t, extra.Error)
+ assert.Empty(t, results)
+
+ // Cannot pull from a private repo we're not associated with
+ results, extra = private.ServCommand(ctx, 1, "user15", "big_test_private_1", perm.AccessModeRead, "git-upload-pack", "")
+ require.Error(t, extra.Error)
+ assert.Empty(t, results)
+
+ // Can pull from a public repo we're not associated with
+ results, extra = private.ServCommand(ctx, 1, "user15", "big_test_public_1", perm.AccessModeRead, "git-upload-pack", "")
+ require.NoError(t, extra.Error)
+ assert.False(t, results.IsWiki)
+ assert.Zero(t, results.DeployKeyID)
+ assert.Equal(t, int64(1), results.KeyID)
+ assert.Equal(t, "user2@localhost", results.KeyName)
+ assert.Equal(t, "user2", results.UserName)
+ assert.Equal(t, int64(2), results.UserID)
+ assert.Equal(t, "user15", results.OwnerName)
+ assert.Equal(t, "big_test_public_1", results.RepoName)
+ assert.Equal(t, int64(17), results.RepoID)
+
+ // Cannot push to a public repo we're not associated with
+ results, extra = private.ServCommand(ctx, 1, "user15", "big_test_public_1", perm.AccessModeWrite, "git-upload-pack", "")
+ require.Error(t, extra.Error)
+ assert.Empty(t, results)
+
+ // Add reading deploy key
+ deployKey, err := asymkey_model.AddDeployKey(ctx, 19, "test-deploy", "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment", true)
+ require.NoError(t, err)
+
+ // Can pull from repo we're a deploy key for
+ results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_1", perm.AccessModeRead, "git-upload-pack", "")
+ require.NoError(t, extra.Error)
+ assert.False(t, results.IsWiki)
+ assert.NotZero(t, results.DeployKeyID)
+ assert.Equal(t, deployKey.KeyID, results.KeyID)
+ assert.Equal(t, "test-deploy", results.KeyName)
+ assert.Equal(t, "user15", results.UserName)
+ assert.Equal(t, int64(15), results.UserID)
+ assert.Equal(t, "user15", results.OwnerName)
+ assert.Equal(t, "big_test_private_1", results.RepoName)
+ assert.Equal(t, int64(19), results.RepoID)
+
+ // Cannot push to a private repo with reading key
+ results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_1", perm.AccessModeWrite, "git-upload-pack", "")
+ require.Error(t, extra.Error)
+ assert.Empty(t, results)
+
+ // Cannot pull from a private repo we're not associated with
+ results, extra = private.ServCommand(ctx, deployKey.ID, "user15", "big_test_private_2", perm.AccessModeRead, "git-upload-pack", "")
+ require.Error(t, extra.Error)
+ assert.Empty(t, results)
+
+ // Cannot pull from a public repo we're not associated with
+ results, extra = private.ServCommand(ctx, deployKey.ID, "user15", "big_test_public_1", perm.AccessModeRead, "git-upload-pack", "")
+ require.Error(t, extra.Error)
+ assert.Empty(t, results)
+
+ // Add writing deploy key
+ deployKey, err = asymkey_model.AddDeployKey(ctx, 20, "test-deploy", "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBGXEEzWmm1dxb+57RoK5KVCL0w2eNv9cqJX2AGGVlkFsVDhOXHzsadS3LTK4VlEbbrDMJdoti9yM8vclA8IeRacAAAAEc3NoOg== nocomment", false)
+ require.NoError(t, err)
+
+ // Cannot push to a private repo with reading key
+ results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_1", perm.AccessModeWrite, "git-upload-pack", "")
+ require.Error(t, extra.Error)
+ assert.Empty(t, results)
+
+ // Can pull from repo we're a writing deploy key for
+ results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_2", perm.AccessModeRead, "git-upload-pack", "")
+ require.NoError(t, extra.Error)
+ assert.False(t, results.IsWiki)
+ assert.NotZero(t, results.DeployKeyID)
+ assert.Equal(t, deployKey.KeyID, results.KeyID)
+ assert.Equal(t, "test-deploy", results.KeyName)
+ assert.Equal(t, "user15", results.UserName)
+ assert.Equal(t, int64(15), results.UserID)
+ assert.Equal(t, "user15", results.OwnerName)
+ assert.Equal(t, "big_test_private_2", results.RepoName)
+ assert.Equal(t, int64(20), results.RepoID)
+
+ // Can push to repo we're a writing deploy key for
+ results, extra = private.ServCommand(ctx, deployKey.KeyID, "user15", "big_test_private_2", perm.AccessModeWrite, "git-upload-pack", "")
+ require.NoError(t, extra.Error)
+ assert.False(t, results.IsWiki)
+ assert.NotZero(t, results.DeployKeyID)
+ assert.Equal(t, deployKey.KeyID, results.KeyID)
+ assert.Equal(t, "test-deploy", results.KeyName)
+ assert.Equal(t, "user15", results.UserName)
+ assert.Equal(t, int64(15), results.UserID)
+ assert.Equal(t, "user15", results.OwnerName)
+ assert.Equal(t, "big_test_private_2", results.RepoName)
+ assert.Equal(t, int64(20), results.RepoID)
+ })
+}
diff --git a/tests/integration/api_pull_commits_test.go b/tests/integration/api_pull_commits_test.go
new file mode 100644
index 0000000..d62b9d9
--- /dev/null
+++ b/tests/integration/api_pull_commits_test.go
@@ -0,0 +1,46 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIPullCommits(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
+ require.NoError(t, pr.LoadIssue(db.DefaultContext))
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pr.HeadRepoID})
+
+ req := NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/%s/pulls/%d/commits", repo.OwnerName, repo.Name, pr.Index)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var commits []*api.Commit
+ DecodeJSON(t, resp, &commits)
+
+ if !assert.Len(t, commits, 2) {
+ return
+ }
+
+ assert.Equal(t, "5f22f7d0d95d614d25a5b68592adb345a4b5c7fd", commits[0].SHA)
+ assert.Equal(t, "4a357436d925b5c974181ff12a994538ddc5a269", commits[1].SHA)
+
+ assert.NotEmpty(t, commits[0].Files)
+ assert.NotEmpty(t, commits[1].Files)
+ assert.NotNil(t, commits[0].RepoCommit.Verification)
+ assert.NotNil(t, commits[1].RepoCommit.Verification)
+}
+
+// TODO add tests for already merged PR and closed PR
diff --git a/tests/integration/api_pull_review_test.go b/tests/integration/api_pull_review_test.go
new file mode 100644
index 0000000..b66e65e
--- /dev/null
+++ b/tests/integration/api_pull_review_test.go
@@ -0,0 +1,610 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/json"
+ api "code.gitea.io/gitea/modules/structs"
+ issue_service "code.gitea.io/gitea/services/issue"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "xorm.io/builder"
+)
+
+func TestAPIPullReviewCreateDeleteComment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ pullIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
+ require.NoError(t, pullIssue.LoadAttributes(db.DefaultContext))
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue.RepoID})
+
+ username := "user2"
+ session := loginUser(t, username)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ // as of e522e774cae2240279fc48c349fc513c9d3353ee
+ // There should be no reason for CreateComment to behave differently
+ // depending on the event associated with the review. But the logic of the implementation
+ // at this point in time is very involved and deserves these seemingly redundant
+ // test.
+ for _, event := range []api.ReviewStateType{
+ api.ReviewStatePending,
+ api.ReviewStateRequestChanges,
+ api.ReviewStateApproved,
+ api.ReviewStateComment,
+ } {
+ t.Run("Event_"+string(event), func(t *testing.T) {
+ path := "README.md"
+ var review api.PullReview
+ var reviewLine int64 = 1
+
+ // cleanup
+ {
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll)
+
+ req := NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/pulls/%d/reviews", repo.FullName(), pullIssue.Index).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var reviews []*api.PullReview
+ DecodeJSON(t, resp, &reviews)
+ for _, review := range reviews {
+ if review.State == api.ReviewStateRequestReview {
+ continue
+ }
+ req := NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/pulls/%d/reviews/%d", repo.FullName(), pullIssue.Index, review.ID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ }
+ }
+
+ requireReviewCount := func(count int) {
+ req := NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/pulls/%d/reviews", repo.FullName(), pullIssue.Index).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var reviews []*api.PullReview
+ DecodeJSON(t, resp, &reviews)
+ require.Len(t, reviews, count)
+ }
+
+ {
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/pulls/%d/reviews", repo.FullName(), pullIssue.Index), &api.CreatePullReviewOptions{
+ Body: "body1",
+ Event: event,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &review)
+ require.EqualValues(t, string(event), review.State)
+ require.EqualValues(t, 0, review.CodeCommentsCount)
+ }
+
+ {
+ req := NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/pulls/%d/reviews/%d", repo.FullName(), pullIssue.Index, review.ID).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var getReview api.PullReview
+ DecodeJSON(t, resp, &getReview)
+ require.EqualValues(t, getReview, review)
+ }
+ requireReviewCount(2)
+
+ newCommentBody := "first new line"
+ var reviewComment api.PullReviewComment
+
+ {
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/pulls/%d/reviews/%d/comments", repo.FullName(), pullIssue.Index, review.ID), &api.CreatePullReviewCommentOptions{
+ Path: path,
+ Body: newCommentBody,
+ OldLineNum: reviewLine,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &reviewComment)
+ assert.EqualValues(t, review.ID, reviewComment.ReviewID)
+ assert.EqualValues(t, newCommentBody, reviewComment.Body)
+ assert.EqualValues(t, reviewLine, reviewComment.OldLineNum)
+ assert.EqualValues(t, 0, reviewComment.LineNum)
+ assert.EqualValues(t, path, reviewComment.Path)
+ }
+
+ {
+ req := NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/pulls/%d/reviews/%d/comments/%d", repo.FullName(), pullIssue.Index, review.ID, reviewComment.ID).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var comment api.PullReviewComment
+ DecodeJSON(t, resp, &comment)
+ assert.EqualValues(t, reviewComment, comment)
+ }
+
+ {
+ req := NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/pulls/%d/reviews/%d/comments/%d", repo.FullName(), pullIssue.Index, review.ID, reviewComment.ID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ }
+
+ {
+ req := NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/pulls/%d/reviews/%d/comments/%d", repo.FullName(), pullIssue.Index, review.ID, reviewComment.ID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ }
+
+ {
+ req := NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/pulls/%d/reviews/%d", repo.FullName(), pullIssue.Index, review.ID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ }
+ requireReviewCount(1)
+ })
+ }
+}
+
+func TestAPIPullReview(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ pullIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
+ require.NoError(t, pullIssue.LoadAttributes(db.DefaultContext))
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue.RepoID})
+
+ // test ListPullReviews
+ session := loginUser(t, "user2")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var reviews []*api.PullReview
+ DecodeJSON(t, resp, &reviews)
+ if !assert.Len(t, reviews, 8) {
+ return
+ }
+ for _, r := range reviews {
+ assert.EqualValues(t, pullIssue.HTMLURL(), r.HTMLPullURL)
+ }
+ assert.EqualValues(t, 8, reviews[3].ID)
+ assert.EqualValues(t, "APPROVED", reviews[3].State)
+ assert.EqualValues(t, 0, reviews[3].CodeCommentsCount)
+ assert.True(t, reviews[3].Stale)
+ assert.False(t, reviews[3].Official)
+
+ assert.EqualValues(t, 10, reviews[5].ID)
+ assert.EqualValues(t, "REQUEST_CHANGES", reviews[5].State)
+ assert.EqualValues(t, 1, reviews[5].CodeCommentsCount)
+ assert.EqualValues(t, -1, reviews[5].Reviewer.ID) // ghost user
+ assert.False(t, reviews[5].Stale)
+ assert.True(t, reviews[5].Official)
+
+ // test GetPullReview
+ req = NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/%s/pulls/%d/reviews/%d", repo.OwnerName, repo.Name, pullIssue.Index, reviews[3].ID).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var review api.PullReview
+ DecodeJSON(t, resp, &review)
+ assert.EqualValues(t, *reviews[3], review)
+
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/pulls/%d/reviews/%d", repo.OwnerName, repo.Name, pullIssue.Index, reviews[5].ID).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &review)
+ assert.EqualValues(t, *reviews[5], review)
+
+ // test GetPullReviewComments
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 7})
+ req = NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/%s/pulls/%d/reviews/%d/comments", repo.OwnerName, repo.Name, pullIssue.Index, 10).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var reviewComments []*api.PullReviewComment
+ DecodeJSON(t, resp, &reviewComments)
+ assert.Len(t, reviewComments, 1)
+ assert.EqualValues(t, "Ghost", reviewComments[0].Poster.UserName)
+ assert.EqualValues(t, "a review from a deleted user", reviewComments[0].Body)
+ assert.EqualValues(t, comment.ID, reviewComments[0].ID)
+ assert.EqualValues(t, comment.UpdatedUnix, reviewComments[0].Updated.Unix())
+ assert.EqualValues(t, comment.HTMLURL(db.DefaultContext), reviewComments[0].HTMLURL)
+
+ // test CreatePullReview
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{
+ Body: "body1",
+ // Event: "" # will result in PENDING
+ Comments: []api.CreatePullReviewComment{
+ {
+ Path: "README.md",
+ Body: "first new line",
+ OldLineNum: 0,
+ NewLineNum: 1,
+ }, {
+ Path: "README.md",
+ Body: "first old line",
+ OldLineNum: 1,
+ NewLineNum: 0,
+ }, {
+ Path: "iso-8859-1.txt",
+ Body: "this line contains a non-utf-8 character",
+ OldLineNum: 0,
+ NewLineNum: 1,
+ },
+ },
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &review)
+ assert.EqualValues(t, 6, review.ID)
+ assert.EqualValues(t, "PENDING", review.State)
+ assert.EqualValues(t, 3, review.CodeCommentsCount)
+
+ // test SubmitPullReview
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/%d", repo.OwnerName, repo.Name, pullIssue.Index, review.ID), &api.SubmitPullReviewOptions{
+ Event: "APPROVED",
+ Body: "just two nits",
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &review)
+ assert.EqualValues(t, 6, review.ID)
+ assert.EqualValues(t, "APPROVED", review.State)
+ assert.EqualValues(t, 3, review.CodeCommentsCount)
+
+ // test dismiss review
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/%d/dismissals", repo.OwnerName, repo.Name, pullIssue.Index, review.ID), &api.DismissPullReviewOptions{
+ Message: "test",
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &review)
+ assert.EqualValues(t, 6, review.ID)
+ assert.True(t, review.Dismissed)
+
+ // test dismiss review
+ req = NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews/%d/undismissals", repo.OwnerName, repo.Name, pullIssue.Index, review.ID)).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &review)
+ assert.EqualValues(t, 6, review.ID)
+ assert.False(t, review.Dismissed)
+
+ // test DeletePullReview
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{
+ Body: "just a comment",
+ Event: "COMMENT",
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &review)
+ assert.EqualValues(t, "COMMENT", review.State)
+ assert.EqualValues(t, 0, review.CodeCommentsCount)
+ req = NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/%s/pulls/%d/reviews/%d", repo.OwnerName, repo.Name, pullIssue.Index, review.ID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // test CreatePullReview Comment without body but with comments
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{
+ // Body: "",
+ Event: "COMMENT",
+ Comments: []api.CreatePullReviewComment{
+ {
+ Path: "README.md",
+ Body: "first new line",
+ OldLineNum: 0,
+ NewLineNum: 1,
+ }, {
+ Path: "README.md",
+ Body: "first old line",
+ OldLineNum: 1,
+ NewLineNum: 0,
+ },
+ },
+ }).AddTokenAuth(token)
+ var commentReview api.PullReview
+
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &commentReview)
+ assert.EqualValues(t, "COMMENT", commentReview.State)
+ assert.EqualValues(t, 2, commentReview.CodeCommentsCount)
+ assert.Empty(t, commentReview.Body)
+ assert.False(t, commentReview.Dismissed)
+
+ // test CreatePullReview Comment with body but without comments
+ commentBody := "This is a body of the comment."
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{
+ Body: commentBody,
+ Event: "COMMENT",
+ Comments: []api.CreatePullReviewComment{},
+ }).AddTokenAuth(token)
+
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &commentReview)
+ assert.EqualValues(t, "COMMENT", commentReview.State)
+ assert.EqualValues(t, 0, commentReview.CodeCommentsCount)
+ assert.EqualValues(t, commentBody, commentReview.Body)
+ assert.False(t, commentReview.Dismissed)
+
+ // test CreatePullReview Comment without body and no comments
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{
+ Body: "",
+ Event: "COMMENT",
+ Comments: []api.CreatePullReviewComment{},
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusUnprocessableEntity)
+ errMap := make(map[string]any)
+ json.Unmarshal(resp.Body.Bytes(), &errMap)
+ assert.EqualValues(t, "review event COMMENT requires a body or a comment", errMap["message"].(string))
+
+ // test get review requests
+ // to make it simple, use same api with get review
+ pullIssue12 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 12})
+ require.NoError(t, pullIssue12.LoadAttributes(db.DefaultContext))
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue12.RepoID})
+
+ req = NewRequestf(t, http.MethodGet, "/api/v1/repos/%s/%s/pulls/%d/reviews", repo3.OwnerName, repo3.Name, pullIssue12.Index).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &reviews)
+ assert.EqualValues(t, 11, reviews[0].ID)
+ assert.EqualValues(t, "REQUEST_REVIEW", reviews[0].State)
+ assert.EqualValues(t, 0, reviews[0].CodeCommentsCount)
+ assert.False(t, reviews[0].Stale)
+ assert.True(t, reviews[0].Official)
+ assert.EqualValues(t, "test_team", reviews[0].ReviewerTeam.Name)
+
+ assert.EqualValues(t, 12, reviews[1].ID)
+ assert.EqualValues(t, "REQUEST_REVIEW", reviews[1].State)
+ assert.EqualValues(t, 0, reviews[0].CodeCommentsCount)
+ assert.False(t, reviews[1].Stale)
+ assert.True(t, reviews[1].Official)
+ assert.EqualValues(t, 1, reviews[1].Reviewer.ID)
+}
+
+func TestAPIPullReviewRequest(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ pullIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
+ require.NoError(t, pullIssue.LoadAttributes(db.DefaultContext))
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue.RepoID})
+
+ // Test add Review Request
+ session := loginUser(t, "user2")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{"user4@example.com", "user8"},
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ // poster of pr can't be reviewer
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{"user1"},
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ // test user not exist
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{"testOther"},
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test Remove Review Request
+ session2 := loginUser(t, "user4")
+ token2 := getTokenForLoggedInUser(t, session2, auth_model.AccessTokenScopeWriteRepository)
+
+ req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{"user4"},
+ }).AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // doer is not admin
+ req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{"user8"},
+ }).AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{"user8"},
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // a collaborator can add/remove a review request
+ pullIssue21 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 21})
+ require.NoError(t, pullIssue21.LoadAttributes(db.DefaultContext))
+ pull21Repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue21.RepoID}) // repo60
+ user38Session := loginUser(t, "user38")
+ user38Token := getTokenForLoggedInUser(t, user38Session, auth_model.AccessTokenScopeWriteRepository)
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", pull21Repo.OwnerName, pull21Repo.Name, pullIssue21.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{"user4@example.com"},
+ }).AddTokenAuth(user38Token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", pull21Repo.OwnerName, pull21Repo.Name, pullIssue21.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{"user4@example.com"},
+ }).AddTokenAuth(user38Token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // the poster of the PR can add/remove a review request
+ user39Session := loginUser(t, "user39")
+ user39Token := getTokenForLoggedInUser(t, user39Session, auth_model.AccessTokenScopeWriteRepository)
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", pull21Repo.OwnerName, pull21Repo.Name, pullIssue21.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{"user8"},
+ }).AddTokenAuth(user39Token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", pull21Repo.OwnerName, pull21Repo.Name, pullIssue21.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{"user8"},
+ }).AddTokenAuth(user39Token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // user with read permission on pull requests unit can add/remove a review request
+ pullIssue22 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 22})
+ require.NoError(t, pullIssue22.LoadAttributes(db.DefaultContext))
+ pull22Repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue22.RepoID}) // repo61
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", pull22Repo.OwnerName, pull22Repo.Name, pullIssue22.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{"user38"},
+ }).AddTokenAuth(user39Token) // user39 is from a team with read permission on pull requests unit
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", pull22Repo.OwnerName, pull22Repo.Name, pullIssue22.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{"user38"},
+ }).AddTokenAuth(user39Token) // user39 is from a team with read permission on pull requests unit
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Test team review request
+ pullIssue12 := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 12})
+ require.NoError(t, pullIssue12.LoadAttributes(db.DefaultContext))
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue12.RepoID})
+
+ // Test add Team Review Request
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo3.OwnerName, repo3.Name, pullIssue12.Index), &api.PullReviewRequestOptions{
+ TeamReviewers: []string{"team1", "owners"},
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ // Test add Team Review Request to not allowned
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo3.OwnerName, repo3.Name, pullIssue12.Index), &api.PullReviewRequestOptions{
+ TeamReviewers: []string{"test_team"},
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ // Test add Team Review Request to not exist
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo3.OwnerName, repo3.Name, pullIssue12.Index), &api.PullReviewRequestOptions{
+ TeamReviewers: []string{"not_exist_team"},
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test Remove team Review Request
+ req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo3.OwnerName, repo3.Name, pullIssue12.Index), &api.PullReviewRequestOptions{
+ TeamReviewers: []string{"team1"},
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // empty request test
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo3.OwnerName, repo3.Name, pullIssue12.Index), &api.PullReviewRequestOptions{}).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequestWithJSON(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo3.OwnerName, repo3.Name, pullIssue12.Index), &api.PullReviewRequestOptions{}).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+}
+
+func TestAPIPullReviewStayDismissed(t *testing.T) {
+ // This test against issue https://github.com/go-gitea/gitea/issues/28542
+ // where old reviews surface after a review request got dismissed.
+ defer tests.PrepareTestEnv(t)()
+ pullIssue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 3})
+ require.NoError(t, pullIssue.LoadAttributes(db.DefaultContext))
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pullIssue.RepoID})
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session2 := loginUser(t, user2.LoginName)
+ token2 := getTokenForLoggedInUser(t, session2, auth_model.AccessTokenScopeWriteRepository)
+ user8 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8})
+ session8 := loginUser(t, user8.LoginName)
+ token8 := getTokenForLoggedInUser(t, session8, auth_model.AccessTokenScopeWriteRepository)
+
+ // user2 request user8
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{user8.LoginName},
+ }).AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusCreated)
+
+ reviewsCountCheck(t,
+ "check we have only one review request",
+ pullIssue.ID, user8.ID, 0, 1, 1, false)
+
+ // user2 request user8 again, it is expected to be ignored
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{user8.LoginName},
+ }).AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusCreated)
+
+ reviewsCountCheck(t,
+ "check we have only one review request, even after re-request it again",
+ pullIssue.ID, user8.ID, 0, 1, 1, false)
+
+ // user8 reviews it as accept
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{
+ Event: "APPROVED",
+ Body: "lgtm",
+ }).AddTokenAuth(token8)
+ MakeRequest(t, req, http.StatusOK)
+
+ reviewsCountCheck(t,
+ "check we have one valid approval",
+ pullIssue.ID, user8.ID, 0, 0, 1, true)
+
+ // emulate of auto-dismiss lgtm on a protected branch that where a pull just got an update
+ _, err := db.GetEngine(db.DefaultContext).Where("issue_id = ? AND reviewer_id = ?", pullIssue.ID, user8.ID).
+ Cols("dismissed").Update(&issues_model.Review{Dismissed: true})
+ require.NoError(t, err)
+
+ // user2 request user8 again
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/requested_reviewers", repo.OwnerName, repo.Name, pullIssue.Index), &api.PullReviewRequestOptions{
+ Reviewers: []string{user8.LoginName},
+ }).AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusCreated)
+
+ reviewsCountCheck(t,
+ "check we have no valid approval and one review request",
+ pullIssue.ID, user8.ID, 1, 1, 2, false)
+
+ // user8 dismiss review
+ _, err = issue_service.ReviewRequest(db.DefaultContext, pullIssue, user8, user8, false)
+ require.NoError(t, err)
+
+ reviewsCountCheck(t,
+ "check new review request is now dismissed",
+ pullIssue.ID, user8.ID, 1, 0, 1, false)
+
+ // add a new valid approval
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{
+ Event: "APPROVED",
+ Body: "lgtm",
+ }).AddTokenAuth(token8)
+ MakeRequest(t, req, http.StatusOK)
+
+ reviewsCountCheck(t,
+ "check that old reviews requests are deleted",
+ pullIssue.ID, user8.ID, 1, 0, 2, true)
+
+ // now add a change request witch should dismiss the approval
+ req = NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/reviews", repo.OwnerName, repo.Name, pullIssue.Index), &api.CreatePullReviewOptions{
+ Event: "REQUEST_CHANGES",
+ Body: "please change XYZ",
+ }).AddTokenAuth(token8)
+ MakeRequest(t, req, http.StatusOK)
+
+ reviewsCountCheck(t,
+ "check that old reviews are dismissed",
+ pullIssue.ID, user8.ID, 2, 0, 3, false)
+}
+
+func reviewsCountCheck(t *testing.T, name string, issueID, reviewerID int64, expectedDismissed, expectedRequested, expectedTotal int, expectApproval bool) {
+ t.Run(name, func(t *testing.T) {
+ unittest.AssertCountByCond(t, "review", builder.Eq{
+ "issue_id": issueID,
+ "reviewer_id": reviewerID,
+ "dismissed": true,
+ }, expectedDismissed)
+
+ unittest.AssertCountByCond(t, "review", builder.Eq{
+ "issue_id": issueID,
+ "reviewer_id": reviewerID,
+ }, expectedTotal)
+
+ unittest.AssertCountByCond(t, "review", builder.Eq{
+ "issue_id": issueID,
+ "reviewer_id": reviewerID,
+ "type": issues_model.ReviewTypeRequest,
+ }, expectedRequested)
+
+ approvalCount := 0
+ if expectApproval {
+ approvalCount = 1
+ }
+ unittest.AssertCountByCond(t, "review", builder.Eq{
+ "issue_id": issueID,
+ "reviewer_id": reviewerID,
+ "type": issues_model.ReviewTypeApprove,
+ "dismissed": false,
+ }, approvalCount)
+ })
+}
diff --git a/tests/integration/api_pull_test.go b/tests/integration/api_pull_test.go
new file mode 100644
index 0000000..51c25fb
--- /dev/null
+++ b/tests/integration/api_pull_test.go
@@ -0,0 +1,311 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/forms"
+ issue_service "code.gitea.io/gitea/services/issue"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIViewPulls(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ ctx := NewAPITestContext(t, "user2", repo.Name, auth_model.AccessTokenScopeReadRepository)
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/pulls?state=all", owner.Name, repo.Name).
+ AddTokenAuth(ctx.Token)
+ resp := ctx.Session.MakeRequest(t, req, http.StatusOK)
+
+ var pulls []*api.PullRequest
+ DecodeJSON(t, resp, &pulls)
+ expectedLen := unittest.GetCount(t, &issues_model.Issue{RepoID: repo.ID}, unittest.Cond("is_pull = ?", true))
+ assert.Len(t, pulls, expectedLen)
+
+ pull := pulls[0]
+ if assert.EqualValues(t, 5, pull.ID) {
+ resp = ctx.Session.MakeRequest(t, NewRequest(t, "GET", pull.DiffURL), http.StatusOK)
+ _, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ // TODO: use diff to generate stats to test against
+
+ t.Run(fmt.Sprintf("APIGetPullFiles_%d", pull.ID),
+ doAPIGetPullFiles(ctx, pull, func(t *testing.T, files []*api.ChangedFile) {
+ if assert.Len(t, files, 1) {
+ assert.Equal(t, "File-WoW", files[0].Filename)
+ assert.Empty(t, files[0].PreviousFilename)
+ assert.EqualValues(t, 1, files[0].Additions)
+ assert.EqualValues(t, 1, files[0].Changes)
+ assert.EqualValues(t, 0, files[0].Deletions)
+ assert.Equal(t, "added", files[0].Status)
+ }
+ }))
+ }
+}
+
+func TestAPIViewPullsByBaseHead(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ ctx := NewAPITestContext(t, "user2", repo.Name, auth_model.AccessTokenScopeReadRepository)
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/pulls/master/branch2", owner.Name, repo.Name).
+ AddTokenAuth(ctx.Token)
+ resp := ctx.Session.MakeRequest(t, req, http.StatusOK)
+
+ pull := &api.PullRequest{}
+ DecodeJSON(t, resp, pull)
+ assert.EqualValues(t, 3, pull.Index)
+ assert.EqualValues(t, 2, pull.ID)
+
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/pulls/master/branch-not-exist", owner.Name, repo.Name).
+ AddTokenAuth(ctx.Token)
+ ctx.Session.MakeRequest(t, req, http.StatusNotFound)
+}
+
+// TestAPIMergePullWIP ensures that we can't merge a WIP pull request
+func TestAPIMergePullWIP(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{Status: issues_model.PullRequestStatusMergeable}, unittest.Cond("has_merged = ?", false))
+ pr.LoadIssue(db.DefaultContext)
+ issue_service.ChangeTitle(db.DefaultContext, pr.Issue, owner, setting.Repository.PullRequest.WorkInProgressPrefixes[0]+" "+pr.Issue.Title)
+
+ // force reload
+ pr.LoadAttributes(db.DefaultContext)
+
+ assert.Contains(t, pr.Issue.Title, setting.Repository.PullRequest.WorkInProgressPrefixes[0])
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner.Name, repo.Name, pr.Index), &forms.MergePullRequestForm{
+ MergeMessageField: pr.Issue.Title,
+ Do: string(repo_model.MergeStyleMerge),
+ }).AddTokenAuth(token)
+
+ MakeRequest(t, req, http.StatusMethodNotAllowed)
+}
+
+func TestAPICreatePullSuccess(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ repo10 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
+ // repo10 have code, pulls units.
+ repo11 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
+ // repo11 only have code unit but should still create pulls
+ owner10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo10.OwnerID})
+ owner11 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo11.OwnerID})
+
+ session := loginUser(t, owner11.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner10.Name, repo10.Name), &api.CreatePullRequestOption{
+ Head: fmt.Sprintf("%s:master", owner11.Name),
+ Base: "master",
+ Title: "create a failure pr",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+ MakeRequest(t, req, http.StatusUnprocessableEntity) // second request should fail
+}
+
+func TestAPICreatePullSameRepoSuccess(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner.Name, repo.Name), &api.CreatePullRequestOption{
+ Head: fmt.Sprintf("%s:pr-to-update", owner.Name),
+ Base: "master",
+ Title: "successfully create a PR between branches of the same repository",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+ MakeRequest(t, req, http.StatusUnprocessableEntity) // second request should fail
+}
+
+func TestAPICreatePullWithFieldsSuccess(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ // repo10 have code, pulls units.
+ repo10 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
+ owner10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo10.OwnerID})
+ // repo11 only have code unit but should still create pulls
+ repo11 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
+ owner11 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo11.OwnerID})
+
+ session := loginUser(t, owner11.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ opts := &api.CreatePullRequestOption{
+ Head: fmt.Sprintf("%s:master", owner11.Name),
+ Base: "master",
+ Title: "create a failure pr",
+ Body: "foobaaar",
+ Milestone: 5,
+ Assignees: []string{owner10.Name},
+ Labels: []int64{5},
+ }
+
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner10.Name, repo10.Name), opts).
+ AddTokenAuth(token)
+
+ res := MakeRequest(t, req, http.StatusCreated)
+ pull := new(api.PullRequest)
+ DecodeJSON(t, res, pull)
+
+ assert.NotNil(t, pull.Milestone)
+ assert.EqualValues(t, opts.Milestone, pull.Milestone.ID)
+ if assert.Len(t, pull.Assignees, 1) {
+ assert.EqualValues(t, opts.Assignees[0], owner10.Name)
+ }
+ assert.NotNil(t, pull.Labels)
+ assert.EqualValues(t, opts.Labels[0], pull.Labels[0].ID)
+}
+
+func TestAPICreatePullWithFieldsFailure(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ // repo10 have code, pulls units.
+ repo10 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
+ owner10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo10.OwnerID})
+ // repo11 only have code unit but should still create pulls
+ repo11 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 11})
+ owner11 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo11.OwnerID})
+
+ session := loginUser(t, owner11.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ opts := &api.CreatePullRequestOption{
+ Head: fmt.Sprintf("%s:master", owner11.Name),
+ Base: "master",
+ }
+
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner10.Name, repo10.Name), opts).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+ opts.Title = "is required"
+
+ opts.Milestone = 666
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+ opts.Milestone = 5
+
+ opts.Assignees = []string{"qweruqweroiuyqweoiruywqer"}
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+ opts.Assignees = []string{owner10.LoginName}
+
+ opts.Labels = []int64{55555}
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+ opts.Labels = []int64{5}
+}
+
+func TestAPIEditPull(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ repo10 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})
+ owner10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo10.OwnerID})
+
+ session := loginUser(t, owner10.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ title := "create a success pr"
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", owner10.Name, repo10.Name), &api.CreatePullRequestOption{
+ Head: "develop",
+ Base: "master",
+ Title: title,
+ }).AddTokenAuth(token)
+ apiPull := new(api.PullRequest)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, apiPull)
+ assert.EqualValues(t, "master", apiPull.Base.Name)
+
+ newTitle := "edit a this pr"
+ newBody := "edited body"
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", owner10.Name, repo10.Name, apiPull.Index)
+ req = NewRequestWithJSON(t, http.MethodPatch, urlStr, &api.EditPullRequestOption{
+ Base: "feature/1",
+ Title: newTitle,
+ Body: &newBody,
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, apiPull)
+ assert.EqualValues(t, "feature/1", apiPull.Base.Name)
+ // check comment history
+ pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: apiPull.ID})
+ err := pull.LoadIssue(db.DefaultContext)
+ require.NoError(t, err)
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{IssueID: pull.Issue.ID, OldTitle: title, NewTitle: newTitle})
+ unittest.AssertExistsAndLoadBean(t, &issues_model.ContentHistory{IssueID: pull.Issue.ID, ContentText: newBody, IsFirstCreated: false})
+
+ // verify the idempotency of a state change
+ pullState := string(apiPull.State)
+ req = NewRequestWithJSON(t, http.MethodPatch, urlStr, &api.EditPullRequestOption{
+ State: &pullState,
+ }).AddTokenAuth(token)
+ apiPullIdempotent := new(api.PullRequest)
+ resp = MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, apiPullIdempotent)
+ assert.EqualValues(t, apiPull.State, apiPullIdempotent.State)
+
+ req = NewRequestWithJSON(t, http.MethodPatch, urlStr, &api.EditPullRequestOption{
+ Base: "not-exist",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+}
+
+func TestAPIForkDifferentName(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // Step 1: get a repo and a user that can fork this repo
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+ // Step 2: fork this repo with another name
+ forkName := "myfork"
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/forks", owner.Name, repo.Name),
+ &api.CreateForkOption{Name: &forkName}).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusAccepted)
+
+ // Step 3: make a PR onto the original repo, it should succeed
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/pulls?state=all", owner.Name, repo.Name),
+ &api.CreatePullRequestOption{Head: user.Name + ":master", Base: "master", Title: "hi"}).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+}
+
+func doAPIGetPullFiles(ctx APITestContext, pr *api.PullRequest, callback func(*testing.T, []*api.ChangedFile)) func(*testing.T) {
+ return func(t *testing.T) {
+ req := NewRequest(t, http.MethodGet, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/files", ctx.Username, ctx.Reponame, pr.Index)).
+ AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode == 0 {
+ ctx.ExpectedCode = http.StatusOK
+ }
+ resp := ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+
+ files := make([]*api.ChangedFile, 0, 1)
+ DecodeJSON(t, resp, &files)
+
+ if callback != nil {
+ callback(t, files)
+ }
+ }
+}
diff --git a/tests/integration/api_push_mirror_test.go b/tests/integration/api_push_mirror_test.go
new file mode 100644
index 0000000..f2135ce
--- /dev/null
+++ b/tests/integration/api_push_mirror_test.go
@@ -0,0 +1,291 @@
+// Copyright The Forgejo Authors
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/services/migrations"
+ mirror_service "code.gitea.io/gitea/services/mirror"
+ repo_service "code.gitea.io/gitea/services/repository"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIPushMirror(t *testing.T) {
+ onGiteaRun(t, testAPIPushMirror)
+}
+
+func testAPIPushMirror(t *testing.T, u *url.URL) {
+ defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
+ defer test.MockVariableValue(&setting.Mirror.Enabled, true)()
+ defer test.MockProtect(&mirror_service.AddPushMirrorRemote)()
+ defer test.MockProtect(&repo_model.DeletePushMirrors)()
+
+ require.NoError(t, migrations.Init())
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: srcRepo.OwnerID})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/push_mirrors", owner.Name, srcRepo.Name)
+
+ mirrorRepo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, user, repo_service.CreateRepoOptions{
+ Name: "test-push-mirror",
+ })
+ require.NoError(t, err)
+ remoteAddress := fmt.Sprintf("%s%s/%s", u.String(), url.PathEscape(user.Name), url.PathEscape(mirrorRepo.Name))
+
+ deletePushMirrors := repo_model.DeletePushMirrors
+ deletePushMirrorsError := errors.New("deletePushMirrorsError")
+ deletePushMirrorsFail := func(ctx context.Context, opts repo_model.PushMirrorOptions) error {
+ return deletePushMirrorsError
+ }
+
+ addPushMirrorRemote := mirror_service.AddPushMirrorRemote
+ addPushMirrorRemoteError := errors.New("addPushMirrorRemoteError")
+ addPushMirrorRemoteFail := func(ctx context.Context, m *repo_model.PushMirror, addr string) error {
+ return addPushMirrorRemoteError
+ }
+
+ for _, testCase := range []struct {
+ name string
+ message string
+ status int
+ mirrorCount int
+ setup func()
+ }{
+ {
+ name: "success",
+ status: http.StatusOK,
+ mirrorCount: 1,
+ setup: func() {
+ mirror_service.AddPushMirrorRemote = addPushMirrorRemote
+ repo_model.DeletePushMirrors = deletePushMirrors
+ },
+ },
+ {
+ name: "fail to add and delete",
+ message: deletePushMirrorsError.Error(),
+ status: http.StatusInternalServerError,
+ mirrorCount: 1,
+ setup: func() {
+ mirror_service.AddPushMirrorRemote = addPushMirrorRemoteFail
+ repo_model.DeletePushMirrors = deletePushMirrorsFail
+ },
+ },
+ {
+ name: "fail to add",
+ message: addPushMirrorRemoteError.Error(),
+ status: http.StatusInternalServerError,
+ mirrorCount: 0,
+ setup: func() {
+ mirror_service.AddPushMirrorRemote = addPushMirrorRemoteFail
+ repo_model.DeletePushMirrors = deletePushMirrors
+ },
+ },
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ testCase.setup()
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreatePushMirrorOption{
+ RemoteAddress: remoteAddress,
+ Interval: "8h",
+ }).AddTokenAuth(token)
+
+ resp := MakeRequest(t, req, testCase.status)
+ if testCase.message != "" {
+ err := api.APIError{}
+ DecodeJSON(t, resp, &err)
+ assert.EqualValues(t, testCase.message, err.Message)
+ }
+
+ req = NewRequest(t, "GET", urlStr).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var pushMirrors []*api.PushMirror
+ DecodeJSON(t, resp, &pushMirrors)
+ if assert.Len(t, pushMirrors, testCase.mirrorCount) && testCase.mirrorCount > 0 {
+ pushMirror := pushMirrors[0]
+ assert.EqualValues(t, remoteAddress, pushMirror.RemoteAddress)
+
+ repo_model.DeletePushMirrors = deletePushMirrors
+ req = NewRequest(t, "DELETE", fmt.Sprintf("%s/%s", urlStr, pushMirror.RemoteName)).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ }
+ })
+ }
+}
+
+func TestAPIPushMirrorSSH(t *testing.T) {
+ _, err := exec.LookPath("ssh")
+ if err != nil {
+ t.Skip("SSH executable not present")
+ }
+
+ onGiteaRun(t, func(t *testing.T, _ *url.URL) {
+ defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
+ defer test.MockVariableValue(&setting.Mirror.Enabled, true)()
+ defer test.MockVariableValue(&setting.SSH.RootPath, t.TempDir())()
+ require.NoError(t, migrations.Init())
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+ assert.False(t, srcRepo.HasWiki())
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ pushToRepo, _, f := tests.CreateDeclarativeRepoWithOptions(t, user, tests.DeclarativeRepoOptions{
+ Name: optional.Some("push-mirror-test"),
+ AutoInit: optional.Some(false),
+ EnabledUnits: optional.Some([]unit.Type{unit.TypeCode}),
+ })
+ defer f()
+
+ sshURL := fmt.Sprintf("ssh://%s@%s/%s.git", setting.SSH.User, net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort)), pushToRepo.FullName())
+
+ t.Run("Mutual exclusive", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName()), &api.CreatePushMirrorOption{
+ RemoteAddress: sshURL,
+ Interval: "8h",
+ UseSSH: true,
+ RemoteUsername: "user",
+ RemotePassword: "password",
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusBadRequest)
+
+ var apiError api.APIError
+ DecodeJSON(t, resp, &apiError)
+ assert.EqualValues(t, "'use_ssh' is mutually exclusive with 'remote_username' and 'remote_passoword'", apiError.Message)
+ })
+
+ t.Run("SSH not available", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&git.HasSSHExecutable, false)()
+
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName()), &api.CreatePushMirrorOption{
+ RemoteAddress: sshURL,
+ Interval: "8h",
+ UseSSH: true,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusBadRequest)
+
+ var apiError api.APIError
+ DecodeJSON(t, resp, &apiError)
+ assert.EqualValues(t, "SSH authentication not available.", apiError.Message)
+ })
+
+ t.Run("Normal", func(t *testing.T) {
+ var pushMirror *repo_model.PushMirror
+ t.Run("Adding", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName()), &api.CreatePushMirrorOption{
+ RemoteAddress: sshURL,
+ Interval: "8h",
+ UseSSH: true,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ pushMirror = unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{RepoID: srcRepo.ID})
+ assert.NotEmpty(t, pushMirror.PrivateKey)
+ assert.NotEmpty(t, pushMirror.PublicKey)
+ })
+
+ publickey := pushMirror.GetPublicKey()
+ t.Run("Publickey", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/push_mirrors", srcRepo.FullName())).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var pushMirrors []*api.PushMirror
+ DecodeJSON(t, resp, &pushMirrors)
+ assert.Len(t, pushMirrors, 1)
+ assert.EqualValues(t, publickey, pushMirrors[0].PublicKey)
+ })
+
+ t.Run("Add deploy key", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/keys", pushToRepo.FullName()), &api.CreateKeyOption{
+ Title: "push mirror key",
+ Key: publickey,
+ ReadOnly: false,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "push mirror key", RepoID: pushToRepo.ID})
+ })
+
+ t.Run("Synchronize", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/push_mirrors-sync", srcRepo.FullName())).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("Check mirrored content", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ sha := "1032bbf17fbc0d9c95bb5418dabe8f8c99278700"
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/commits?limit=1", srcRepo.FullName())).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var commitList []*api.Commit
+ DecodeJSON(t, resp, &commitList)
+
+ assert.Len(t, commitList, 1)
+ assert.EqualValues(t, sha, commitList[0].SHA)
+
+ assert.Eventually(t, func() bool {
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/commits?limit=1", srcRepo.FullName())).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var commitList []*api.Commit
+ DecodeJSON(t, resp, &commitList)
+
+ return len(commitList) != 0 && commitList[0].SHA == sha
+ }, time.Second*30, time.Second)
+ })
+
+ t.Run("Check known host keys", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ knownHosts, err := os.ReadFile(filepath.Join(setting.SSH.RootPath, "known_hosts"))
+ require.NoError(t, err)
+
+ publicKey, err := os.ReadFile(setting.SSH.ServerHostKeys[0] + ".pub")
+ require.NoError(t, err)
+
+ assert.Contains(t, string(knownHosts), string(publicKey))
+ })
+ })
+ })
+}
diff --git a/tests/integration/api_quota_management_test.go b/tests/integration/api_quota_management_test.go
new file mode 100644
index 0000000..6337e66
--- /dev/null
+++ b/tests/integration/api_quota_management_test.go
@@ -0,0 +1,846 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ quota_model "code.gitea.io/gitea/models/quota"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIQuotaDisabled(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Quota.Enabled, false)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+ session := loginUser(t, user.Name)
+
+ req := NewRequest(t, "GET", "/api/v1/user/quota")
+ session.MakeRequest(t, req, http.StatusNotFound)
+}
+
+func apiCreateUser(t *testing.T, username string) func() {
+ t.Helper()
+
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+ session := loginUser(t, admin.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll)
+
+ mustChangePassword := false
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/users", api.CreateUserOption{
+ Email: "api+" + username + "@example.com",
+ Username: username,
+ Password: "password",
+ MustChangePassword: &mustChangePassword,
+ }).AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusCreated)
+
+ return func() {
+ req := NewRequest(t, "DELETE", "/api/v1/admin/users/"+username+"?purge=true").AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusNoContent)
+ }
+}
+
+func TestAPIQuotaCreateGroupWithRules(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Quota.Enabled, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ // Create two rules in advance
+ unlimited := int64(-1)
+ defer createQuotaRule(t, api.CreateQuotaRuleOptions{
+ Name: "unlimited",
+ Limit: &unlimited,
+ Subjects: []string{"size:all"},
+ })()
+ zero := int64(0)
+ defer createQuotaRule(t, api.CreateQuotaRuleOptions{
+ Name: "deny-git-lfs",
+ Limit: &zero,
+ Subjects: []string{"size:git:lfs"},
+ })()
+
+ // Log in as admin
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+ adminSession := loginUser(t, admin.Name)
+ adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll)
+
+ // Create a new group, with rules specified
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{
+ Name: "group-with-rules",
+ Rules: []api.CreateQuotaRuleOptions{
+ // First: an existing group, unlimited, name only
+ {
+ Name: "unlimited",
+ },
+ // Second: an existing group, deny-git-lfs, with different params
+ {
+ Name: "deny-git-lfs",
+ Limit: &unlimited,
+ },
+ // Third: an entirely new group
+ {
+ Name: "new-rule",
+ Subjects: []string{"size:assets:all"},
+ },
+ },
+ }).AddTokenAuth(adminToken)
+ resp := adminSession.MakeRequest(t, req, http.StatusCreated)
+ defer func() {
+ req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/group-with-rules").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/new-rule").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+ }()
+
+ // Verify that we created a group with rules included
+ var q api.QuotaGroup
+ DecodeJSON(t, resp, &q)
+
+ assert.Equal(t, "group-with-rules", q.Name)
+ assert.Len(t, q.Rules, 3)
+
+ // Verify that the previously existing rules are unchanged
+ rule, err := quota_model.GetRuleByName(db.DefaultContext, "unlimited")
+ require.NoError(t, err)
+ assert.NotNil(t, rule)
+ assert.EqualValues(t, -1, rule.Limit)
+ assert.EqualValues(t, quota_model.LimitSubjects{quota_model.LimitSubjectSizeAll}, rule.Subjects)
+
+ rule, err = quota_model.GetRuleByName(db.DefaultContext, "deny-git-lfs")
+ require.NoError(t, err)
+ assert.NotNil(t, rule)
+ assert.EqualValues(t, 0, rule.Limit)
+ assert.EqualValues(t, quota_model.LimitSubjects{quota_model.LimitSubjectSizeGitLFS}, rule.Subjects)
+
+ // Verify that the new rule was also created
+ rule, err = quota_model.GetRuleByName(db.DefaultContext, "new-rule")
+ require.NoError(t, err)
+ assert.NotNil(t, rule)
+ assert.EqualValues(t, 0, rule.Limit)
+ assert.EqualValues(t, quota_model.LimitSubjects{quota_model.LimitSubjectSizeAssetsAll}, rule.Subjects)
+
+ t.Run("invalid rule spec", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{
+ Name: "group-with-invalid-rule-spec",
+ Rules: []api.CreateQuotaRuleOptions{
+ {
+ Name: "rule-with-wrong-spec",
+ Subjects: []string{"valid:false"},
+ },
+ },
+ }).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+}
+
+func TestAPIQuotaEmptyState(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Quota.Enabled, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ username := "quota-empty-user"
+ defer apiCreateUser(t, username)()
+ session := loginUser(t, username)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll)
+
+ t.Run("#/admin/users/quota-empty-user/quota", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+ adminSession := loginUser(t, admin.Name)
+ adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll)
+
+ req := NewRequest(t, "GET", "/api/v1/admin/users/quota-empty-user/quota").AddTokenAuth(adminToken)
+ resp := adminSession.MakeRequest(t, req, http.StatusOK)
+
+ var q api.QuotaInfo
+ DecodeJSON(t, resp, &q)
+
+ assert.EqualValues(t, api.QuotaUsed{}, q.Used)
+ assert.Empty(t, q.Groups)
+ })
+
+ t.Run("#/user/quota", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/quota").AddTokenAuth(token)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ var q api.QuotaInfo
+ DecodeJSON(t, resp, &q)
+
+ assert.EqualValues(t, api.QuotaUsed{}, q.Used)
+ assert.Empty(t, q.Groups)
+
+ t.Run("#/user/quota/artifacts", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/quota/artifacts").AddTokenAuth(token)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ var q api.QuotaUsedArtifactList
+ DecodeJSON(t, resp, &q)
+
+ assert.Empty(t, q)
+ })
+
+ t.Run("#/user/quota/attachments", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/quota/attachments").AddTokenAuth(token)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ var q api.QuotaUsedAttachmentList
+ DecodeJSON(t, resp, &q)
+
+ assert.Empty(t, q)
+ })
+
+ t.Run("#/user/quota/packages", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/quota/packages").AddTokenAuth(token)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ var q api.QuotaUsedPackageList
+ DecodeJSON(t, resp, &q)
+
+ assert.Empty(t, q)
+ })
+ })
+}
+
+func createQuotaRule(t *testing.T, opts api.CreateQuotaRuleOptions) func() {
+ t.Helper()
+
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+ adminSession := loginUser(t, admin.Name)
+ adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll)
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", opts).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusCreated)
+
+ return func() {
+ req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/rules/%s", opts.Name).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+ }
+}
+
+func createQuotaGroup(t *testing.T, name string) func() {
+ t.Helper()
+
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+ adminSession := loginUser(t, admin.Name)
+ adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll)
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{
+ Name: name,
+ }).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusCreated)
+
+ return func() {
+ req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/%s", name).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+ }
+}
+
+func TestAPIQuotaAdminRoutesRules(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Quota.Enabled, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+ adminSession := loginUser(t, admin.Name)
+ adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll)
+
+ zero := int64(0)
+ oneKb := int64(1024)
+
+ t.Run("adminCreateQuotaRule", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", api.CreateQuotaRuleOptions{
+ Name: "deny-all",
+ Limit: &zero,
+ Subjects: []string{"size:all"},
+ }).AddTokenAuth(adminToken)
+ resp := adminSession.MakeRequest(t, req, http.StatusCreated)
+ defer func() {
+ req := NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/deny-all").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+ }()
+
+ var q api.QuotaRuleInfo
+ DecodeJSON(t, resp, &q)
+
+ assert.Equal(t, "deny-all", q.Name)
+ assert.EqualValues(t, 0, q.Limit)
+ assert.EqualValues(t, []string{"size:all"}, q.Subjects)
+
+ rule, err := quota_model.GetRuleByName(db.DefaultContext, "deny-all")
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, rule.Limit)
+
+ t.Run("unhappy path", func(t *testing.T) {
+ t.Run("missing options", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", nil).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+
+ t.Run("invalid subjects", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", api.CreateQuotaRuleOptions{
+ Name: "invalid-subjects",
+ Limit: &zero,
+ Subjects: []string{"valid:false"},
+ }).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+
+ t.Run("trying to add an existing rule", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ rule := api.CreateQuotaRuleOptions{
+ Name: "double-rule",
+ Limit: &zero,
+ }
+
+ defer createQuotaRule(t, rule)()
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/rules", rule).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusConflict)
+ })
+ })
+ })
+
+ t.Run("adminDeleteQuotaRule", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ createQuotaRule(t, api.CreateQuotaRuleOptions{
+ Name: "deny-all",
+ Limit: &zero,
+ Subjects: []string{"size:all"},
+ })
+
+ req := NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/deny-all").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ rule, err := quota_model.GetRuleByName(db.DefaultContext, "deny-all")
+ require.NoError(t, err)
+ assert.Nil(t, rule)
+
+ t.Run("unhappy path", func(t *testing.T) {
+ t.Run("nonexistent rule", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", "/api/v1/admin/quota/rules/does-not-exist").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+ })
+
+ t.Run("adminEditQuotaRule", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ defer createQuotaRule(t, api.CreateQuotaRuleOptions{
+ Name: "deny-all",
+ Limit: &zero,
+ Subjects: []string{"size:all"},
+ })()
+
+ req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/deny-all", api.EditQuotaRuleOptions{
+ Limit: &oneKb,
+ }).AddTokenAuth(adminToken)
+ resp := adminSession.MakeRequest(t, req, http.StatusOK)
+
+ var q api.QuotaRuleInfo
+ DecodeJSON(t, resp, &q)
+ assert.EqualValues(t, 1024, q.Limit)
+
+ rule, err := quota_model.GetRuleByName(db.DefaultContext, "deny-all")
+ require.NoError(t, err)
+ assert.EqualValues(t, 1024, rule.Limit)
+
+ t.Run("no options", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/deny-all", nil).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("unhappy path", func(t *testing.T) {
+ t.Run("nonexistent rule", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/does-not-exist", api.EditQuotaRuleOptions{
+ Limit: &oneKb,
+ }).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("invalid subjects", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/quota/rules/deny-all", api.EditQuotaRuleOptions{
+ Subjects: &[]string{"valid:false"},
+ }).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+ })
+ })
+
+ t.Run("adminListQuotaRules", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ defer createQuotaRule(t, api.CreateQuotaRuleOptions{
+ Name: "deny-all",
+ Limit: &zero,
+ Subjects: []string{"size:all"},
+ })()
+
+ req := NewRequest(t, "GET", "/api/v1/admin/quota/rules").AddTokenAuth(adminToken)
+ resp := adminSession.MakeRequest(t, req, http.StatusOK)
+
+ var rules []api.QuotaRuleInfo
+ DecodeJSON(t, resp, &rules)
+
+ assert.Len(t, rules, 1)
+ assert.Equal(t, "deny-all", rules[0].Name)
+ assert.EqualValues(t, 0, rules[0].Limit)
+ })
+}
+
+func TestAPIQuotaAdminRoutesGroups(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Quota.Enabled, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+ adminSession := loginUser(t, admin.Name)
+ adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll)
+
+ zero := int64(0)
+
+ ruleDenyAll := api.CreateQuotaRuleOptions{
+ Name: "deny-all",
+ Limit: &zero,
+ Subjects: []string{"size:all"},
+ }
+
+ username := "quota-test-user"
+ defer apiCreateUser(t, username)()
+
+ t.Run("adminCreateQuotaGroup", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{
+ Name: "default",
+ }).AddTokenAuth(adminToken)
+ resp := adminSession.MakeRequest(t, req, http.StatusCreated)
+ defer func() {
+ req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+ }()
+
+ var q api.QuotaGroup
+ DecodeJSON(t, resp, &q)
+
+ assert.Equal(t, "default", q.Name)
+ assert.Empty(t, q.Rules)
+
+ group, err := quota_model.GetGroupByName(db.DefaultContext, "default")
+ require.NoError(t, err)
+ assert.Equal(t, "default", group.Name)
+ assert.Empty(t, group.Rules)
+
+ t.Run("unhappy path", func(t *testing.T) {
+ t.Run("missing options", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", nil).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+
+ t.Run("trying to add an existing group", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ defer createQuotaGroup(t, "duplicate")()
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/quota/groups", api.CreateQuotaGroupOptions{
+ Name: "duplicate",
+ }).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusConflict)
+ })
+ })
+ })
+
+ t.Run("adminDeleteQuotaGroup", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ createQuotaGroup(t, "default")
+
+ req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ group, err := quota_model.GetGroupByName(db.DefaultContext, "default")
+ require.NoError(t, err)
+ assert.Nil(t, group)
+
+ t.Run("unhappy path", func(t *testing.T) {
+ t.Run("non-existing group", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/does-not-exist").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+ })
+
+ t.Run("adminAddRuleToQuotaGroup", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer createQuotaGroup(t, "default")()
+ defer createQuotaRule(t, ruleDenyAll)()
+
+ req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ group, err := quota_model.GetGroupByName(db.DefaultContext, "default")
+ require.NoError(t, err)
+ assert.Len(t, group.Rules, 1)
+ assert.Equal(t, "deny-all", group.Rules[0].Name)
+
+ t.Run("unhappy path", func(t *testing.T) {
+ t.Run("non-existing group", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/does-not-exist/rules/deny-all").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("non-existing rule", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/does-not-exist").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+ })
+
+ t.Run("adminRemoveRuleFromQuotaGroup", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer createQuotaGroup(t, "default")()
+ defer createQuotaRule(t, ruleDenyAll)()
+
+ req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ group, err := quota_model.GetGroupByName(db.DefaultContext, "default")
+ require.NoError(t, err)
+ assert.Equal(t, "default", group.Name)
+ assert.Empty(t, group.Rules)
+
+ t.Run("unhappy path", func(t *testing.T) {
+ t.Run("non-existing group", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/does-not-exist/rules/deny-all").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("non-existing rule", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/rules/does-not-exist").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("rule not in group", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer createQuotaRule(t, api.CreateQuotaRuleOptions{
+ Name: "rule-not-in-group",
+ Limit: &zero,
+ })()
+
+ req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/rules/rule-not-in-group").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+ })
+
+ t.Run("adminGetQuotaGroup", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer createQuotaGroup(t, "default")()
+ defer createQuotaRule(t, ruleDenyAll)()
+
+ req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "GET", "/api/v1/admin/quota/groups/default").AddTokenAuth(adminToken)
+ resp := adminSession.MakeRequest(t, req, http.StatusOK)
+
+ var q api.QuotaGroup
+ DecodeJSON(t, resp, &q)
+
+ assert.Equal(t, "default", q.Name)
+ assert.Len(t, q.Rules, 1)
+ assert.Equal(t, "deny-all", q.Rules[0].Name)
+
+ t.Run("unhappy path", func(t *testing.T) {
+ t.Run("non-existing group", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/admin/quota/groups/does-not-exist").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+ })
+
+ t.Run("adminListQuotaGroups", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer createQuotaGroup(t, "default")()
+ defer createQuotaRule(t, ruleDenyAll)()
+
+ req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/rules/deny-all").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "GET", "/api/v1/admin/quota/groups").AddTokenAuth(adminToken)
+ resp := adminSession.MakeRequest(t, req, http.StatusOK)
+
+ var q api.QuotaGroupList
+ DecodeJSON(t, resp, &q)
+
+ assert.Len(t, q, 1)
+ assert.Equal(t, "default", q[0].Name)
+ assert.Len(t, q[0].Rules, 1)
+ assert.Equal(t, "deny-all", q[0].Rules[0].Name)
+ })
+
+ t.Run("adminAddUserToQuotaGroup", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer createQuotaGroup(t, "default")()
+
+ req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username})
+
+ groups, err := quota_model.GetGroupsForUser(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.Len(t, groups, 1)
+ assert.Equal(t, "default", groups[0].Name)
+
+ t.Run("unhappy path", func(t *testing.T) {
+ t.Run("non-existing group", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/does-not-exist/users/%s", username).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("non-existing user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/users/this-user-does-not-exist").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("user already added", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/users/user1").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "PUT", "/api/v1/admin/quota/groups/default/users/user1").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusConflict)
+ })
+ })
+ })
+
+ t.Run("adminRemoveUserFromQuotaGroup", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer createQuotaGroup(t, "default")()
+
+ req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username})
+ groups, err := quota_model.GetGroupsForUser(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.Empty(t, groups)
+
+ t.Run("unhappy path", func(t *testing.T) {
+ t.Run("non-existing group", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/does-not-exist/users/%s", username).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("non-existing user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/users/does-not-exist").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("user not in group", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", "/api/v1/admin/quota/groups/default/users/user1").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+ })
+
+ t.Run("adminListUsersInQuotaGroup", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer createQuotaGroup(t, "default")()
+
+ req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/default/users/%s", username).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "GET", "/api/v1/admin/quota/groups/default/users").AddTokenAuth(adminToken)
+ resp := adminSession.MakeRequest(t, req, http.StatusOK)
+
+ var q []api.User
+ DecodeJSON(t, resp, &q)
+
+ assert.Len(t, q, 1)
+ assert.Equal(t, username, q[0].UserName)
+
+ t.Run("unhappy path", func(t *testing.T) {
+ t.Run("non-existing group", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/admin/quota/groups/does-not-exist/users").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+ })
+
+ t.Run("adminSetUserQuotaGroups", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer createQuotaGroup(t, "default")()
+ defer createQuotaGroup(t, "test-1")()
+ defer createQuotaGroup(t, "test-2")()
+
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/admin/users/%s/quota/groups", username), api.SetUserQuotaGroupsOptions{
+ Groups: &[]string{"default", "test-1", "test-2"},
+ }).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username})
+
+ groups, err := quota_model.GetGroupsForUser(db.DefaultContext, user.ID)
+ require.NoError(t, err)
+ assert.Len(t, groups, 3)
+
+ t.Run("unhappy path", func(t *testing.T) {
+ t.Run("non-existing user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/admin/users/does-not-exist/quota/groups", api.SetUserQuotaGroupsOptions{
+ Groups: &[]string{"default", "test-1", "test-2"},
+ }).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("non-existing group", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/admin/users/%s/quota/groups", username), api.SetUserQuotaGroupsOptions{
+ Groups: &[]string{"default", "test-1", "test-2", "this-group-does-not-exist"},
+ }).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+ })
+ })
+}
+
+func TestAPIQuotaUserRoutes(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Quota.Enabled, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+ adminSession := loginUser(t, admin.Name)
+ adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeAll)
+
+ // Create a test user
+ username := "quota-test-user-routes"
+ defer apiCreateUser(t, username)()
+ session := loginUser(t, username)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeAll)
+
+ // Set up rules & groups for the user
+ defer createQuotaGroup(t, "user-routes-deny")()
+ defer createQuotaGroup(t, "user-routes-1kb")()
+
+ zero := int64(0)
+ ruleDenyAll := api.CreateQuotaRuleOptions{
+ Name: "user-routes-deny-all",
+ Limit: &zero,
+ Subjects: []string{"size:all"},
+ }
+ defer createQuotaRule(t, ruleDenyAll)()
+ oneKb := int64(1024)
+ rule1KbStuff := api.CreateQuotaRuleOptions{
+ Name: "user-routes-1kb",
+ Limit: &oneKb,
+ Subjects: []string{"size:assets:attachments:releases", "size:assets:packages:all", "size:git:lfs"},
+ }
+ defer createQuotaRule(t, rule1KbStuff)()
+
+ req := NewRequest(t, "PUT", "/api/v1/admin/quota/groups/user-routes-deny/rules/user-routes-deny-all").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+ req = NewRequest(t, "PUT", "/api/v1/admin/quota/groups/user-routes-1kb/rules/user-routes-1kb").AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/user-routes-deny/users/%s", username).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+ req = NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/user-routes-1kb/users/%s", username).AddTokenAuth(adminToken)
+ adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ t.Run("userGetQuota", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/quota").AddTokenAuth(token)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ var q api.QuotaInfo
+ DecodeJSON(t, resp, &q)
+
+ assert.Len(t, q.Groups, 2)
+ assert.Len(t, q.Groups[0].Rules, 1)
+ assert.Len(t, q.Groups[1].Rules, 1)
+ })
+}
diff --git a/tests/integration/api_quota_use_test.go b/tests/integration/api_quota_use_test.go
new file mode 100644
index 0000000..11cbdcf
--- /dev/null
+++ b/tests/integration/api_quota_use_test.go
@@ -0,0 +1,1436 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ quota_model "code.gitea.io/gitea/models/quota"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/migration"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/forms"
+ repo_service "code.gitea.io/gitea/services/repository"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type quotaEnvUser struct {
+ User *user_model.User
+ Session *TestSession
+ Token string
+}
+
+type quotaEnvOrgs struct {
+ Unlimited api.Organization
+ Limited api.Organization
+}
+
+type quotaEnv struct {
+ Admin quotaEnvUser
+ User quotaEnvUser
+ Dummy quotaEnvUser
+
+ Repo *repo_model.Repository
+ Orgs quotaEnvOrgs
+
+ cleanups []func()
+}
+
+func (e *quotaEnv) APIPathForRepo(uriFormat string, a ...any) string {
+ path := fmt.Sprintf(uriFormat, a...)
+ return fmt.Sprintf("/api/v1/repos/%s/%s%s", e.User.User.Name, e.Repo.Name, path)
+}
+
+func (e *quotaEnv) Cleanup() {
+ for i := len(e.cleanups) - 1; i >= 0; i-- {
+ e.cleanups[i]()
+ }
+}
+
+func (e *quotaEnv) WithoutQuota(t *testing.T, task func(), rules ...string) {
+ rule := "all"
+ if rules != nil {
+ rule = rules[0]
+ }
+ defer e.SetRuleLimit(t, rule, -1)()
+ task()
+}
+
+func (e *quotaEnv) SetupWithSingleQuotaRule(t *testing.T) {
+ t.Helper()
+
+ cleaner := test.MockVariableValue(&setting.Quota.Enabled, true)
+ e.cleanups = append(e.cleanups, cleaner)
+ cleaner = test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())
+ e.cleanups = append(e.cleanups, cleaner)
+
+ // Create a default group
+ cleaner = createQuotaGroup(t, "default")
+ e.cleanups = append(e.cleanups, cleaner)
+
+ // Create a single all-encompassing rule
+ unlimited := int64(-1)
+ ruleAll := api.CreateQuotaRuleOptions{
+ Name: "all",
+ Limit: &unlimited,
+ Subjects: []string{"size:all"},
+ }
+ cleaner = createQuotaRule(t, ruleAll)
+ e.cleanups = append(e.cleanups, cleaner)
+
+ // Add these rules to the group
+ cleaner = e.AddRuleToGroup(t, "default", "all")
+ e.cleanups = append(e.cleanups, cleaner)
+
+ // Add the user to the quota group
+ cleaner = e.AddUserToGroup(t, "default", e.User.User.Name)
+ e.cleanups = append(e.cleanups, cleaner)
+}
+
+func (e *quotaEnv) AddDummyUser(t *testing.T, username string) {
+ t.Helper()
+
+ userCleanup := apiCreateUser(t, username)
+ e.Dummy.User = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username})
+ e.Dummy.Session = loginUser(t, e.Dummy.User.Name)
+ e.Dummy.Token = getTokenForLoggedInUser(t, e.Dummy.Session, auth_model.AccessTokenScopeAll)
+ e.cleanups = append(e.cleanups, userCleanup)
+
+ // Add the user to the "limited" group. See AddLimitedOrg
+ cleaner := e.AddUserToGroup(t, "limited", username)
+ e.cleanups = append(e.cleanups, cleaner)
+}
+
+func (e *quotaEnv) AddLimitedOrg(t *testing.T) {
+ t.Helper()
+
+ // Create the limited org
+ req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", api.CreateOrgOption{
+ UserName: "limited-org",
+ }).AddTokenAuth(e.User.Token)
+ resp := e.User.Session.MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, &e.Orgs.Limited)
+ e.cleanups = append(e.cleanups, func() {
+ req := NewRequest(t, "DELETE", "/api/v1/orgs/limited-org").
+ AddTokenAuth(e.Admin.Token)
+ e.Admin.Session.MakeRequest(t, req, http.StatusNoContent)
+ })
+
+ // Create a group for the org
+ cleaner := createQuotaGroup(t, "limited")
+ e.cleanups = append(e.cleanups, cleaner)
+
+ // Create a single all-encompassing rule
+ zero := int64(0)
+ ruleDenyAll := api.CreateQuotaRuleOptions{
+ Name: "deny-all",
+ Limit: &zero,
+ Subjects: []string{"size:all"},
+ }
+ cleaner = createQuotaRule(t, ruleDenyAll)
+ e.cleanups = append(e.cleanups, cleaner)
+
+ // Add these rules to the group
+ cleaner = e.AddRuleToGroup(t, "limited", "deny-all")
+ e.cleanups = append(e.cleanups, cleaner)
+
+ // Add the user to the quota group
+ cleaner = e.AddUserToGroup(t, "limited", e.Orgs.Limited.UserName)
+ e.cleanups = append(e.cleanups, cleaner)
+}
+
+func (e *quotaEnv) AddUnlimitedOrg(t *testing.T) {
+ t.Helper()
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/orgs", api.CreateOrgOption{
+ UserName: "unlimited-org",
+ }).AddTokenAuth(e.User.Token)
+ resp := e.User.Session.MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, &e.Orgs.Unlimited)
+ e.cleanups = append(e.cleanups, func() {
+ req := NewRequest(t, "DELETE", "/api/v1/orgs/unlimited-org").
+ AddTokenAuth(e.Admin.Token)
+ e.Admin.Session.MakeRequest(t, req, http.StatusNoContent)
+ })
+}
+
+func (e *quotaEnv) SetupWithMultipleQuotaRules(t *testing.T) {
+ t.Helper()
+
+ cleaner := test.MockVariableValue(&setting.Quota.Enabled, true)
+ e.cleanups = append(e.cleanups, cleaner)
+ cleaner = test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())
+ e.cleanups = append(e.cleanups, cleaner)
+
+ // Create a default group
+ cleaner = createQuotaGroup(t, "default")
+ e.cleanups = append(e.cleanups, cleaner)
+
+ // Create three rules: all, repo-size, and asset-size
+ zero := int64(0)
+ ruleAll := api.CreateQuotaRuleOptions{
+ Name: "all",
+ Limit: &zero,
+ Subjects: []string{"size:all"},
+ }
+ cleaner = createQuotaRule(t, ruleAll)
+ e.cleanups = append(e.cleanups, cleaner)
+
+ fifteenMb := int64(1024 * 1024 * 15)
+ ruleRepoSize := api.CreateQuotaRuleOptions{
+ Name: "repo-size",
+ Limit: &fifteenMb,
+ Subjects: []string{"size:repos:all"},
+ }
+ cleaner = createQuotaRule(t, ruleRepoSize)
+ e.cleanups = append(e.cleanups, cleaner)
+
+ ruleAssetSize := api.CreateQuotaRuleOptions{
+ Name: "asset-size",
+ Limit: &fifteenMb,
+ Subjects: []string{"size:assets:all"},
+ }
+ cleaner = createQuotaRule(t, ruleAssetSize)
+ e.cleanups = append(e.cleanups, cleaner)
+
+ // Add these rules to the group
+ cleaner = e.AddRuleToGroup(t, "default", "all")
+ e.cleanups = append(e.cleanups, cleaner)
+ cleaner = e.AddRuleToGroup(t, "default", "repo-size")
+ e.cleanups = append(e.cleanups, cleaner)
+ cleaner = e.AddRuleToGroup(t, "default", "asset-size")
+ e.cleanups = append(e.cleanups, cleaner)
+
+ // Add the user to the quota group
+ cleaner = e.AddUserToGroup(t, "default", e.User.User.Name)
+ e.cleanups = append(e.cleanups, cleaner)
+}
+
+func (e *quotaEnv) AddUserToGroup(t *testing.T, group, user string) func() {
+ t.Helper()
+
+ req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/%s/users/%s", group, user).AddTokenAuth(e.Admin.Token)
+ e.Admin.Session.MakeRequest(t, req, http.StatusNoContent)
+
+ return func() {
+ req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/%s/users/%s", group, user).AddTokenAuth(e.Admin.Token)
+ e.Admin.Session.MakeRequest(t, req, http.StatusNoContent)
+ }
+}
+
+func (e *quotaEnv) SetRuleLimit(t *testing.T, rule string, limit int64) func() {
+ t.Helper()
+
+ originalRule, err := quota_model.GetRuleByName(db.DefaultContext, rule)
+ require.NoError(t, err)
+ assert.NotNil(t, originalRule)
+
+ req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/admin/quota/rules/%s", rule), api.EditQuotaRuleOptions{
+ Limit: &limit,
+ }).AddTokenAuth(e.Admin.Token)
+ e.Admin.Session.MakeRequest(t, req, http.StatusOK)
+
+ return func() {
+ e.SetRuleLimit(t, rule, originalRule.Limit)
+ }
+}
+
+func (e *quotaEnv) RemoveRuleFromGroup(t *testing.T, group, rule string) {
+ t.Helper()
+
+ req := NewRequestf(t, "DELETE", "/api/v1/admin/quota/groups/%s/rules/%s", group, rule).AddTokenAuth(e.Admin.Token)
+ e.Admin.Session.MakeRequest(t, req, http.StatusNoContent)
+}
+
+func (e *quotaEnv) AddRuleToGroup(t *testing.T, group, rule string) func() {
+ t.Helper()
+
+ req := NewRequestf(t, "PUT", "/api/v1/admin/quota/groups/%s/rules/%s", group, rule).AddTokenAuth(e.Admin.Token)
+ e.Admin.Session.MakeRequest(t, req, http.StatusNoContent)
+
+ return func() {
+ e.RemoveRuleFromGroup(t, group, rule)
+ }
+}
+
+func prepareQuotaEnv(t *testing.T, username string) *quotaEnv {
+ t.Helper()
+
+ env := quotaEnv{}
+
+ // Set up the admin user
+ env.Admin.User = unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+ env.Admin.Session = loginUser(t, env.Admin.User.Name)
+ env.Admin.Token = getTokenForLoggedInUser(t, env.Admin.Session, auth_model.AccessTokenScopeAll)
+
+ // Create a test user
+ userCleanup := apiCreateUser(t, username)
+ env.User.User = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username})
+ env.User.Session = loginUser(t, env.User.User.Name)
+ env.User.Token = getTokenForLoggedInUser(t, env.User.Session, auth_model.AccessTokenScopeAll)
+ env.cleanups = append(env.cleanups, userCleanup)
+
+ // Create a repository
+ repo, _, repoCleanup := tests.CreateDeclarativeRepoWithOptions(t, env.User.User, tests.DeclarativeRepoOptions{})
+ env.Repo = repo
+ env.cleanups = append(env.cleanups, repoCleanup)
+
+ return &env
+}
+
+func TestAPIQuotaUserCleanSlate(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ defer test.MockVariableValue(&setting.Quota.Enabled, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ env := prepareQuotaEnv(t, "qt-clean-slate")
+ defer env.Cleanup()
+
+ t.Run("branch creation", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Create a branch
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{
+ BranchName: "branch-to-delete",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusCreated)
+ })
+ })
+}
+
+func TestAPIQuotaEnforcement(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ testAPIQuotaEnforcement(t)
+ })
+}
+
+func TestAPIQuotaCountsTowardsCorrectUser(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := prepareQuotaEnv(t, "quota-correct-user-test")
+ defer env.Cleanup()
+ env.SetupWithSingleQuotaRule(t)
+
+ // Create a new group, with size:all set to 0
+ defer createQuotaGroup(t, "limited")()
+ zero := int64(0)
+ defer createQuotaRule(t, api.CreateQuotaRuleOptions{
+ Name: "limited",
+ Limit: &zero,
+ Subjects: []string{"size:all"},
+ })()
+ defer env.AddRuleToGroup(t, "limited", "limited")()
+
+ // Add the admin user to it
+ defer env.AddUserToGroup(t, "limited", env.Admin.User.Name)()
+
+ // Add the admin user as collaborator to our repo
+ perm := "admin"
+ req := NewRequestWithJSON(t, "PUT",
+ env.APIPathForRepo("/collaborators/%s", env.Admin.User.Name),
+ api.AddCollaboratorOption{
+ Permission: &perm,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusNoContent)
+
+ // Now, try to push something as admin!
+ req = NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{
+ BranchName: "admin-branch",
+ }).AddTokenAuth(env.Admin.Token)
+ env.Admin.Session.MakeRequest(t, req, http.StatusCreated)
+ })
+}
+
+func TestAPIQuotaError(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := prepareQuotaEnv(t, "quota-enforcement")
+ defer env.Cleanup()
+ env.SetupWithSingleQuotaRule(t)
+ env.AddUnlimitedOrg(t)
+ env.AddLimitedOrg(t)
+
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{
+ Organization: &env.Orgs.Limited.UserName,
+ }).AddTokenAuth(env.User.Token)
+ resp := env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+
+ var msg context.APIQuotaExceeded
+ DecodeJSON(t, resp, &msg)
+
+ assert.EqualValues(t, env.Orgs.Limited.ID, msg.UserID)
+ assert.Equal(t, env.Orgs.Limited.UserName, msg.UserName)
+ })
+}
+
+func testAPIQuotaEnforcement(t *testing.T) {
+ env := prepareQuotaEnv(t, "quota-enforcement")
+ defer env.Cleanup()
+ env.SetupWithSingleQuotaRule(t)
+ env.AddUnlimitedOrg(t)
+ env.AddLimitedOrg(t)
+ env.AddDummyUser(t, "qe-dummy")
+
+ t.Run("#/user/repos", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer env.SetRuleLimit(t, "all", 0)()
+
+ t.Run("CREATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", api.CreateRepoOption{
+ Name: "quota-exceeded",
+ AutoInit: true,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("LIST", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/repos").AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ })
+
+ t.Run("#/orgs/{org}/repos", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer env.SetRuleLimit(t, "all", 0)
+
+ assertCreateRepo := func(t *testing.T, orgName, repoName string, expectedStatus int) func() {
+ t.Helper()
+
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/repos", orgName), api.CreateRepoOption{
+ Name: repoName,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, expectedStatus)
+
+ return func() {
+ req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s", orgName, repoName).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusNoContent)
+ }
+ }
+
+ t.Run("limited", func(t *testing.T) {
+ t.Run("LIST", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", env.Orgs.Unlimited.UserName).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("CREATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertCreateRepo(t, env.Orgs.Limited.UserName, "test-repo", http.StatusRequestEntityTooLarge)
+ })
+ })
+
+ t.Run("unlimited", func(t *testing.T) {
+ t.Run("CREATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ defer assertCreateRepo(t, env.Orgs.Unlimited.UserName, "test-repo", http.StatusCreated)()
+ })
+ })
+ })
+
+ t.Run("#/repos/migrate", func(t *testing.T) {
+ t.Run("to:limited", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer env.SetRuleLimit(t, "all", 0)()
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate", api.MigrateRepoOptions{
+ CloneAddr: env.Repo.HTMLURL() + ".git",
+ RepoName: "quota-migrate",
+ Service: "forgejo",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("to:unlimited", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer env.SetRuleLimit(t, "all", 0)()
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate", api.MigrateRepoOptions{
+ CloneAddr: "an-invalid-address",
+ RepoName: "quota-migrate",
+ RepoOwner: env.Orgs.Unlimited.UserName,
+ Service: "forgejo",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+ })
+
+ t.Run("#/repos/{template_owner}/{template_repo}/generate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Create a template repository
+ template, _, cleanup := tests.CreateDeclarativeRepoWithOptions(t, env.User.User, tests.DeclarativeRepoOptions{
+ IsTemplate: optional.Some(true),
+ })
+ defer cleanup()
+
+ // Drop the quota to 0
+ defer env.SetRuleLimit(t, "all", 0)()
+
+ t.Run("to: limited", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", template.APIURL()+"/generate", api.GenerateRepoOption{
+ Owner: env.User.User.Name,
+ Name: "generated-repo",
+ GitContent: true,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("to: unlimited", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", template.APIURL()+"/generate", api.GenerateRepoOption{
+ Owner: env.Orgs.Unlimited.UserName,
+ Name: "generated-repo",
+ GitContent: true,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/generated-repo", env.Orgs.Unlimited.UserName).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusNoContent)
+ })
+ })
+
+ t.Run("#/repos/{username}/{reponame}", func(t *testing.T) {
+ // Lets create a new repo to play with.
+ repo, _, repoCleanup := tests.CreateDeclarativeRepoWithOptions(t, env.User.User, tests.DeclarativeRepoOptions{})
+ defer repoCleanup()
+
+ // Drop the quota to 0
+ defer env.SetRuleLimit(t, "all", 0)()
+
+ deleteRepo := func(t *testing.T, path string) {
+ t.Helper()
+
+ req := NewRequestf(t, "DELETE", "/api/v1/repos/%s", path).
+ AddTokenAuth(env.Admin.Token)
+ env.Admin.Session.MakeRequest(t, req, http.StatusNoContent)
+ }
+
+ t.Run("GET", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s", env.User.User.Name, repo.Name).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("PATCH", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ desc := "Some description"
+ req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", env.User.User.Name, repo.Name), api.EditRepoOption{
+ Description: &desc,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("DELETE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s", env.User.User.Name, repo.Name).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusNoContent)
+ })
+
+ t.Run("branches", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Create a branch we can delete later
+ env.WithoutQuota(t, func() {
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{
+ BranchName: "to-delete",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusCreated)
+ })
+
+ t.Run("LIST", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/branches")).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("CREATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{
+ BranchName: "quota-exceeded",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("{branch}", func(t *testing.T) {
+ t.Run("GET", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/branches/to-delete")).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("DELETE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", env.APIPathForRepo("/branches/to-delete")).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusNoContent)
+ })
+ })
+ })
+
+ t.Run("contents", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ var fileSha string
+
+ // Create a file to play with
+ env.WithoutQuota(t, func() {
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/contents/plaything.txt"), api.CreateFileOptions{
+ ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")),
+ }).AddTokenAuth(env.User.Token)
+ resp := env.User.Session.MakeRequest(t, req, http.StatusCreated)
+
+ var r api.FileResponse
+ DecodeJSON(t, resp, &r)
+
+ fileSha = r.Content.SHA
+ })
+
+ t.Run("LIST", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/contents")).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("CREATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/contents"), api.ChangeFilesOptions{
+ Files: []*api.ChangeFileOperation{
+ {
+ Operation: "create",
+ Path: "quota-exceeded.txt",
+ },
+ },
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("{filepath}", func(t *testing.T) {
+ t.Run("GET", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/contents/plaything.txt")).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("CREATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/contents/plaything.txt"), api.CreateFileOptions{
+ ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")),
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+ t.Run("UPDATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "PUT", env.APIPathForRepo("/contents/plaything.txt"), api.UpdateFileOptions{
+ ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")),
+ DeleteFileOptions: api.DeleteFileOptions{
+ SHA: fileSha,
+ },
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+ t.Run("DELETE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Deleting a file fails, because it creates a new commit,
+ // which would increase the quota use.
+ req := NewRequestWithJSON(t, "DELETE", env.APIPathForRepo("/contents/plaything.txt"), api.DeleteFileOptions{
+ SHA: fileSha,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+ })
+ })
+
+ t.Run("diffpatch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "PUT", env.APIPathForRepo("/contents/README.md"), api.UpdateFileOptions{
+ ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")),
+ DeleteFileOptions: api.DeleteFileOptions{
+ SHA: "c0ffeebabe",
+ },
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("forks", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ t.Run("as: limited user", func(t *testing.T) {
+ // Our current user (env.User) is already limited here.
+
+ t.Run("into: limited org", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{
+ Organization: &env.Orgs.Limited.UserName,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("into: unlimited org", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{
+ Organization: &env.Orgs.Unlimited.UserName,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusAccepted)
+
+ deleteRepo(t, env.Orgs.Unlimited.UserName+"/"+env.Repo.Name)
+ })
+ })
+ t.Run("as: unlimited user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Lift the quota limits on our current user temporarily
+ defer env.SetRuleLimit(t, "all", -1)()
+
+ t.Run("into: limited org", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{
+ Organization: &env.Orgs.Limited.UserName,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("into: unlimited org", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{
+ Organization: &env.Orgs.Unlimited.UserName,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusAccepted)
+
+ deleteRepo(t, env.Orgs.Unlimited.UserName+"/"+env.Repo.Name)
+ })
+ })
+ })
+
+ t.Run("mirror-sync", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ var mirrorRepo *repo_model.Repository
+ env.WithoutQuota(t, func() {
+ // Create a mirror repo
+ opts := migration.MigrateOptions{
+ RepoName: "test_mirror",
+ Description: "Test mirror",
+ Private: false,
+ Mirror: true,
+ CloneAddr: repo_model.RepoPath(env.User.User.Name, env.Repo.Name),
+ Wiki: true,
+ Releases: false,
+ }
+
+ repo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, env.User.User, env.User.User, repo_service.CreateRepoOptions{
+ Name: opts.RepoName,
+ Description: opts.Description,
+ IsPrivate: opts.Private,
+ IsMirror: opts.Mirror,
+ Status: repo_model.RepositoryBeingMigrated,
+ })
+ require.NoError(t, err)
+
+ mirrorRepo = repo
+ })
+
+ req := NewRequestf(t, "POST", "/api/v1/repos/%s/mirror-sync", mirrorRepo.FullName()).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("issues", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Create an issue play with
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/issues"), api.CreateIssueOption{
+ Title: "quota test issue",
+ }).AddTokenAuth(env.User.Token)
+ resp := env.User.Session.MakeRequest(t, req, http.StatusCreated)
+
+ var issue api.Issue
+ DecodeJSON(t, resp, &issue)
+
+ createAsset := func(filename string) (*bytes.Buffer, string) {
+ buff := generateImg()
+ body := &bytes.Buffer{}
+
+ // Setup multi-part
+ writer := multipart.NewWriter(body)
+ part, _ := writer.CreateFormFile("attachment", filename)
+ io.Copy(part, &buff)
+ writer.Close()
+
+ return body, writer.FormDataContentType()
+ }
+
+ t.Run("{index}/assets", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ t.Run("LIST", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/issues/%d/assets", issue.Index)).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("CREATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ body, contentType := createAsset("overquota.png")
+ req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/issues/%d/assets", issue.Index), body).
+ AddTokenAuth(env.User.Token)
+ req.Header.Add("Content-Type", contentType)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("{attachment_id}", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ var issueAsset api.Attachment
+ env.WithoutQuota(t, func() {
+ body, contentType := createAsset("test.png")
+ req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/issues/%d/assets", issue.Index), body).
+ AddTokenAuth(env.User.Token)
+ req.Header.Add("Content-Type", contentType)
+ resp := env.User.Session.MakeRequest(t, req, http.StatusCreated)
+
+ DecodeJSON(t, resp, &issueAsset)
+ })
+
+ t.Run("GET", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/issues/%d/assets/%d", issue.Index, issueAsset.ID)).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("UPDATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/issues/%d/assets/%d", issue.Index, issueAsset.ID), api.EditAttachmentOptions{
+ Name: "new-name.png",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusCreated)
+ })
+ t.Run("DELETE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", env.APIPathForRepo("/issues/%d/assets/%d", issue.Index, issueAsset.ID)).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusNoContent)
+ })
+ })
+ })
+
+ t.Run("comments/{id}/assets", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Create a new comment!
+ var comment api.Comment
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/issues/%d/comments", issue.Index), api.CreateIssueCommentOption{
+ Body: "This is a comment",
+ }).AddTokenAuth(env.User.Token)
+ resp := env.User.Session.MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, &comment)
+
+ t.Run("LIST", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/issues/comments/%d/assets", comment.ID)).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("CREATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ body, contentType := createAsset("overquota.png")
+ req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/issues/comments/%d/assets", comment.ID), body).
+ AddTokenAuth(env.User.Token)
+ req.Header.Add("Content-Type", contentType)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("{attachment_id}", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ var attachment api.Attachment
+ env.WithoutQuota(t, func() {
+ body, contentType := createAsset("test.png")
+ req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/issues/comments/%d/assets", comment.ID), body).
+ AddTokenAuth(env.User.Token)
+ req.Header.Add("Content-Type", contentType)
+ resp := env.User.Session.MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, &attachment)
+ })
+
+ t.Run("GET", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/issues/comments/%d/assets/%d", comment.ID, attachment.ID)).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("UPDATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/issues/comments/%d/assets/%d", comment.ID, attachment.ID), api.EditAttachmentOptions{
+ Name: "new-name.png",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusCreated)
+ })
+ t.Run("DELETE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", env.APIPathForRepo("/issues/comments/%d/assets/%d", comment.ID, attachment.ID)).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusNoContent)
+ })
+ })
+ })
+ })
+
+ t.Run("pulls", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Fork the repository into the unlimited org first
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/forks"), api.CreateForkOption{
+ Organization: &env.Orgs.Unlimited.UserName,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusAccepted)
+
+ defer deleteRepo(t, env.Orgs.Unlimited.UserName+"/"+env.Repo.Name)
+
+ // Create a pull request!
+ //
+ // Creating a pull request this way does not increase the space of
+ // the base repo, so is not subject to quota enforcement.
+
+ req = NewRequestWithJSON(t, "POST", env.APIPathForRepo("/pulls"), api.CreatePullRequestOption{
+ Base: "main",
+ Title: "test-pr",
+ Head: fmt.Sprintf("%s:main", env.Orgs.Unlimited.UserName),
+ }).AddTokenAuth(env.User.Token)
+ resp := env.User.Session.MakeRequest(t, req, http.StatusCreated)
+
+ var pr api.PullRequest
+ DecodeJSON(t, resp, &pr)
+
+ t.Run("{index}", func(t *testing.T) {
+ t.Run("GET", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/pulls/%d", pr.Index)).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("UPDATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/pulls/%d", pr.Index), api.EditPullRequestOption{
+ Title: "Updated title",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusCreated)
+ })
+
+ t.Run("merge", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/pulls/%d/merge", pr.Index), forms.MergePullRequestForm{
+ Do: "merge",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+ })
+ })
+
+ t.Run("releases", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ var releaseID int64
+
+ // Create a release so that there's something to play with.
+ env.WithoutQuota(t, func() {
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/releases"), api.CreateReleaseOption{
+ TagName: "play-release-tag",
+ Title: "play-release",
+ }).AddTokenAuth(env.User.Token)
+ resp := env.User.Session.MakeRequest(t, req, http.StatusCreated)
+
+ var q api.Release
+ DecodeJSON(t, resp, &q)
+
+ releaseID = q.ID
+ })
+
+ t.Run("LIST", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/releases")).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("CREATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/releases"), api.CreateReleaseOption{
+ TagName: "play-release-tag-two",
+ Title: "play-release-two",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("tags/{tag}", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Create a release for our subtests
+ env.WithoutQuota(t, func() {
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/releases"), api.CreateReleaseOption{
+ TagName: "play-release-tag-subtest",
+ Title: "play-release-subtest",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusCreated)
+ })
+
+ t.Run("GET", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/releases/tags/play-release-tag-subtest")).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("DELETE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", env.APIPathForRepo("/releases/tags/play-release-tag-subtest")).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusNoContent)
+ })
+ })
+
+ t.Run("{id}", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ var tmpReleaseID int64
+
+ // Create a release so that there's something to play with.
+ env.WithoutQuota(t, func() {
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/releases"), api.CreateReleaseOption{
+ TagName: "tmp-tag",
+ Title: "tmp-release",
+ }).AddTokenAuth(env.User.Token)
+ resp := env.User.Session.MakeRequest(t, req, http.StatusCreated)
+
+ var q api.Release
+ DecodeJSON(t, resp, &q)
+
+ tmpReleaseID = q.ID
+ })
+
+ t.Run("GET", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/releases/%d", tmpReleaseID)).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("UPDATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/releases/%d", tmpReleaseID), api.EditReleaseOption{
+ TagName: "tmp-tag-two",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+ t.Run("DELETE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", env.APIPathForRepo("/releases/%d", tmpReleaseID)).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusNoContent)
+ })
+
+ t.Run("assets", func(t *testing.T) {
+ t.Run("LIST", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/releases/%d/assets", releaseID)).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("CREATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ body := strings.NewReader("hello world")
+ req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/releases/%d/assets?name=bar.txt", releaseID), body).
+ AddTokenAuth(env.User.Token)
+ req.Header.Add("Content-Type", "text/plain")
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("{attachment_id}", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ var attachmentID int64
+
+ // Create an attachment to play with
+ env.WithoutQuota(t, func() {
+ body := strings.NewReader("hello world")
+ req := NewRequestWithBody(t, "POST", env.APIPathForRepo("/releases/%d/assets?name=foo.txt", releaseID), body).
+ AddTokenAuth(env.User.Token)
+ req.Header.Add("Content-Type", "text/plain")
+ resp := env.User.Session.MakeRequest(t, req, http.StatusCreated)
+
+ var q api.Attachment
+ DecodeJSON(t, resp, &q)
+
+ attachmentID = q.ID
+ })
+
+ t.Run("GET", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/releases/%d/assets/%d", releaseID, attachmentID)).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("UPDATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "PATCH", env.APIPathForRepo("/releases/%d/assets/%d", releaseID, attachmentID), api.EditAttachmentOptions{
+ Name: "new-name.txt",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusCreated)
+ })
+ t.Run("DELETE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", env.APIPathForRepo("/releases/%d/assets/%d", releaseID, attachmentID)).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusNoContent)
+ })
+ })
+ })
+ })
+ })
+
+ t.Run("tags", func(t *testing.T) {
+ t.Run("LIST", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/tags")).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("CREATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/tags"), api.CreateTagOption{
+ TagName: "tag-quota-test",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("{tag}", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ env.WithoutQuota(t, func() {
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/tags"), api.CreateTagOption{
+ TagName: "tag-quota-test-2",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusCreated)
+ })
+
+ t.Run("GET", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", env.APIPathForRepo("/tags/tag-quota-test-2")).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("DELETE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", env.APIPathForRepo("/tags/tag-quota-test-2")).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusNoContent)
+ })
+ })
+ })
+
+ t.Run("transfer", func(t *testing.T) {
+ t.Run("to: limited", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Create a repository to transfer
+ repo, _, cleanup := tests.CreateDeclarativeRepoWithOptions(t, env.User.User, tests.DeclarativeRepoOptions{})
+ defer cleanup()
+
+ // Initiate repo transfer
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", env.User.User.Name, repo.Name), api.TransferRepoOption{
+ NewOwner: env.Dummy.User.Name,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+
+ // Initiate it outside of quotas, so we can test accept/reject.
+ env.WithoutQuota(t, func() {
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", env.User.User.Name, repo.Name), api.TransferRepoOption{
+ NewOwner: env.Dummy.User.Name,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusCreated)
+ }, "deny-all") // a bit of a hack, sorry!
+
+ // Try to accept the repo transfer
+ req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept", env.User.User.Name, repo.Name)).
+ AddTokenAuth(env.Dummy.Token)
+ env.Dummy.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+
+ // Then reject it.
+ req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject", env.User.User.Name, repo.Name)).
+ AddTokenAuth(env.Dummy.Token)
+ env.Dummy.Session.MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("to: unlimited", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Disable the quota for the dummy user
+ defer env.SetRuleLimit(t, "deny-all", -1)()
+
+ // Create a repository to transfer
+ repo, _, cleanup := tests.CreateDeclarativeRepoWithOptions(t, env.User.User, tests.DeclarativeRepoOptions{})
+ defer cleanup()
+
+ // Initiate repo transfer
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", env.User.User.Name, repo.Name), api.TransferRepoOption{
+ NewOwner: env.Dummy.User.Name,
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusCreated)
+
+ // Accept the repo transfer
+ req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept", env.User.User.Name, repo.Name)).
+ AddTokenAuth(env.Dummy.Token)
+ env.Dummy.Session.MakeRequest(t, req, http.StatusAccepted)
+ })
+ })
+ })
+
+ t.Run("#/packages/{owner}/{type}/{name}/{version}", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer env.SetRuleLimit(t, "all", 0)()
+
+ // Create a generic package to play with
+ env.WithoutQuota(t, func() {
+ body := strings.NewReader("forgejo is awesome")
+ req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/generic/quota-test/1.0.0/test.txt", env.User.User.Name), body).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusCreated)
+ })
+
+ t.Run("CREATE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ body := strings.NewReader("forgejo is awesome")
+ req := NewRequestWithBody(t, "PUT", fmt.Sprintf("/api/packages/%s/generic/quota-test/1.0.0/overquota.txt", env.User.User.Name), body).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("GET", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestf(t, "GET", "/api/v1/packages/%s/generic/quota-test/1.0.0", env.User.User.Name).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusOK)
+ })
+ t.Run("DELETE", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestf(t, "DELETE", "/api/v1/packages/%s/generic/quota-test/1.0.0", env.User.User.Name).
+ AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusNoContent)
+ })
+ })
+}
+
+func TestAPIQuotaOrgQuotaQuery(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := prepareQuotaEnv(t, "quota-enforcement")
+ defer env.Cleanup()
+
+ env.SetupWithSingleQuotaRule(t)
+ env.AddUnlimitedOrg(t)
+ env.AddLimitedOrg(t)
+
+ // Look at the quota use of our user, and the unlimited org, for later
+ // comparison.
+ var userInfo api.QuotaInfo
+ req := NewRequest(t, "GET", "/api/v1/user/quota").AddTokenAuth(env.User.Token)
+ resp := env.User.Session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &userInfo)
+
+ var orgInfo api.QuotaInfo
+ req = NewRequestf(t, "GET", "/api/v1/orgs/%s/quota", env.Orgs.Unlimited.Name).
+ AddTokenAuth(env.User.Token)
+ resp = env.User.Session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &orgInfo)
+
+ assert.Positive(t, userInfo.Used.Size.Repos.Public)
+ assert.EqualValues(t, 0, orgInfo.Used.Size.Repos.Public)
+ })
+}
+
+func TestAPIQuotaUserBasics(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := prepareQuotaEnv(t, "quota-enforcement")
+ defer env.Cleanup()
+
+ env.SetupWithMultipleQuotaRules(t)
+
+ t.Run("quota usage change", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/quota").AddTokenAuth(env.User.Token)
+ resp := env.User.Session.MakeRequest(t, req, http.StatusOK)
+
+ var q api.QuotaInfo
+ DecodeJSON(t, resp, &q)
+
+ assert.Positive(t, q.Used.Size.Repos.Public)
+ assert.Empty(t, q.Groups[0].Name)
+ assert.Empty(t, q.Groups[0].Rules[0].Name)
+
+ t.Run("admin view", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestf(t, "GET", "/api/v1/admin/users/%s/quota", env.User.User.Name).AddTokenAuth(env.Admin.Token)
+ resp := env.Admin.Session.MakeRequest(t, req, http.StatusOK)
+
+ var q api.QuotaInfo
+ DecodeJSON(t, resp, &q)
+
+ assert.Positive(t, q.Used.Size.Repos.Public)
+
+ assert.NotEmpty(t, q.Groups[0].Name)
+ assert.NotEmpty(t, q.Groups[0].Rules[0].Name)
+ })
+ })
+
+ t.Run("quota check passing", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/quota/check?subject=size:repos:all").AddTokenAuth(env.User.Token)
+ resp := env.User.Session.MakeRequest(t, req, http.StatusOK)
+
+ var q bool
+ DecodeJSON(t, resp, &q)
+
+ assert.True(t, q)
+ })
+
+ t.Run("quota check failing after limit change", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer env.SetRuleLimit(t, "repo-size", 0)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/quota/check?subject=size:repos:all").AddTokenAuth(env.User.Token)
+ resp := env.User.Session.MakeRequest(t, req, http.StatusOK)
+
+ var q bool
+ DecodeJSON(t, resp, &q)
+
+ assert.False(t, q)
+ })
+
+ t.Run("quota enforcement", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer env.SetRuleLimit(t, "repo-size", 0)()
+
+ t.Run("repoCreateFile", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/contents/new-file.txt"), api.CreateFileOptions{
+ ContentBase64: base64.StdEncoding.EncodeToString([]byte("hello world")),
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("repoCreateBranch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{
+ BranchName: "new-branch",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("repoDeleteBranch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Temporarily disable quota checking
+ defer env.SetRuleLimit(t, "repo-size", -1)()
+ defer env.SetRuleLimit(t, "all", -1)()
+
+ // Create a branch
+ req := NewRequestWithJSON(t, "POST", env.APIPathForRepo("/branches"), api.CreateBranchRepoOption{
+ BranchName: "branch-to-delete",
+ }).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusCreated)
+
+ // Set the limit back. No need to defer, the first one will set it
+ // back to the correct value.
+ env.SetRuleLimit(t, "all", 0)
+ env.SetRuleLimit(t, "repo-size", 0)
+
+ // Deleting a branch does not incur quota enforcement
+ req = NewRequest(t, "DELETE", env.APIPathForRepo("/branches/branch-to-delete")).AddTokenAuth(env.User.Token)
+ env.User.Session.MakeRequest(t, req, http.StatusNoContent)
+ })
+ })
+ })
+}
diff --git a/tests/integration/api_releases_test.go b/tests/integration/api_releases_test.go
new file mode 100644
index 0000000..1bd8a64
--- /dev/null
+++ b/tests/integration/api_releases_test.go
@@ -0,0 +1,473 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIListReleases(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeReadRepository)
+
+ link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/releases", user2.Name, repo.Name))
+ resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
+ var apiReleases []*api.Release
+ DecodeJSON(t, resp, &apiReleases)
+ if assert.Len(t, apiReleases, 3) {
+ for _, release := range apiReleases {
+ switch release.ID {
+ case 1:
+ assert.False(t, release.IsDraft)
+ assert.False(t, release.IsPrerelease)
+ assert.True(t, strings.HasSuffix(release.UploadURL, "/api/v1/repos/user2/repo1/releases/1/assets"), release.UploadURL)
+ case 4:
+ assert.True(t, release.IsDraft)
+ assert.False(t, release.IsPrerelease)
+ assert.True(t, strings.HasSuffix(release.UploadURL, "/api/v1/repos/user2/repo1/releases/4/assets"), release.UploadURL)
+ case 5:
+ assert.False(t, release.IsDraft)
+ assert.True(t, release.IsPrerelease)
+ assert.True(t, strings.HasSuffix(release.UploadURL, "/api/v1/repos/user2/repo1/releases/5/assets"), release.UploadURL)
+ default:
+ require.NoError(t, fmt.Errorf("unexpected release: %v", release))
+ }
+ }
+ }
+
+ // test filter
+ testFilterByLen := func(auth bool, query url.Values, expectedLength int, msgAndArgs ...string) {
+ link.RawQuery = query.Encode()
+ req := NewRequest(t, "GET", link.String())
+ if auth {
+ req.AddTokenAuth(token)
+ }
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiReleases)
+ assert.Len(t, apiReleases, expectedLength, msgAndArgs)
+ }
+
+ testFilterByLen(false, url.Values{"draft": {"true"}}, 0, "anon should not see drafts")
+ testFilterByLen(true, url.Values{"draft": {"true"}}, 1, "repo owner should see drafts")
+ testFilterByLen(true, url.Values{"draft": {"false"}}, 2, "exclude drafts")
+ testFilterByLen(true, url.Values{"draft": {"false"}, "pre-release": {"false"}}, 1, "exclude drafts and pre-releases")
+ testFilterByLen(true, url.Values{"pre-release": {"true"}}, 1, "only get pre-release")
+ testFilterByLen(true, url.Values{"draft": {"true"}, "pre-release": {"true"}}, 0, "there is no pre-release draft")
+}
+
+func createNewReleaseUsingAPI(t *testing.T, token string, owner *user_model.User, repo *repo_model.Repository, name, target, title, desc string) *api.Release {
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases", owner.Name, repo.Name)
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateReleaseOption{
+ TagName: name,
+ Title: title,
+ Note: desc,
+ IsDraft: false,
+ IsPrerelease: false,
+ Target: target,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var newRelease api.Release
+ DecodeJSON(t, resp, &newRelease)
+ rel := &repo_model.Release{
+ ID: newRelease.ID,
+ TagName: newRelease.TagName,
+ Title: newRelease.Title,
+ }
+ unittest.AssertExistsAndLoadBean(t, rel)
+ assert.EqualValues(t, newRelease.Note, rel.Note)
+
+ return &newRelease
+}
+
+func TestAPICreateAndUpdateRelease(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, owner.LowerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ err = gitRepo.CreateTag("v0.0.1", "master")
+ require.NoError(t, err)
+
+ target, err := gitRepo.GetTagCommitID("v0.0.1")
+ require.NoError(t, err)
+
+ newRelease := createNewReleaseUsingAPI(t, token, owner, repo, "v0.0.1", target, "v0.0.1", "test")
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d", owner.Name, repo.Name, newRelease.ID)
+ req := NewRequest(t, "GET", urlStr).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var release api.Release
+ DecodeJSON(t, resp, &release)
+
+ assert.Equal(t, newRelease.TagName, release.TagName)
+ assert.Equal(t, newRelease.Title, release.Title)
+ assert.Equal(t, newRelease.Note, release.Note)
+ assert.False(t, newRelease.HideArchiveLinks)
+
+ hideArchiveLinks := true
+
+ req = NewRequestWithJSON(t, "PATCH", urlStr, &api.EditReleaseOption{
+ TagName: release.TagName,
+ Title: release.Title,
+ Note: "updated",
+ IsDraft: &release.IsDraft,
+ IsPrerelease: &release.IsPrerelease,
+ Target: release.Target,
+ HideArchiveLinks: &hideArchiveLinks,
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &newRelease)
+ rel := &repo_model.Release{
+ ID: newRelease.ID,
+ TagName: newRelease.TagName,
+ Title: newRelease.Title,
+ }
+ unittest.AssertExistsAndLoadBean(t, rel)
+ assert.EqualValues(t, rel.Note, newRelease.Note)
+ assert.True(t, newRelease.HideArchiveLinks)
+}
+
+func TestAPICreateProtectedTagRelease(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
+ writer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
+ session := loginUser(t, writer.LowerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ commit, err := gitRepo.GetBranchCommit("master")
+ require.NoError(t, err)
+
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/releases", repo.OwnerName, repo.Name), &api.CreateReleaseOption{
+ TagName: "v0.0.1",
+ Title: "v0.0.1",
+ IsDraft: false,
+ IsPrerelease: false,
+ Target: commit.ID.String(),
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+}
+
+func TestAPICreateReleaseToDefaultBranch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, owner.LowerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ createNewReleaseUsingAPI(t, token, owner, repo, "v0.0.1", "", "v0.0.1", "test")
+}
+
+func TestAPICreateReleaseToDefaultBranchOnExistingTag(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, owner.LowerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ err = gitRepo.CreateTag("v0.0.1", "master")
+ require.NoError(t, err)
+
+ createNewReleaseUsingAPI(t, token, owner, repo, "v0.0.1", "", "v0.0.1", "test")
+}
+
+func TestAPICreateReleaseGivenInvalidTarget(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, owner.LowerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases", owner.Name, repo.Name)
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateReleaseOption{
+ TagName: "i-point-to-an-invalid-target",
+ Title: "Invalid Target",
+ Target: "invalid-target",
+ }).AddTokenAuth(token)
+
+ MakeRequest(t, req, http.StatusNotFound)
+}
+
+func TestAPIGetLatestRelease(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/latest", owner.Name, repo.Name))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var release *api.Release
+ DecodeJSON(t, resp, &release)
+
+ assert.Equal(t, "testing-release", release.Title)
+}
+
+func TestAPIGetReleaseByTag(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ tag := "v1.1"
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, tag))
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var release *api.Release
+ DecodeJSON(t, resp, &release)
+
+ assert.Equal(t, "testing-release", release.Title)
+
+ nonexistingtag := "nonexistingtag"
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, nonexistingtag))
+ resp = MakeRequest(t, req, http.StatusNotFound)
+
+ var err *api.APIError
+ DecodeJSON(t, resp, &err)
+ assert.NotEmpty(t, err.Message)
+}
+
+func TestAPIDeleteReleaseByTagName(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, owner.LowerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test")
+
+ // delete release
+ req := NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/%s/releases/tags/release-tag", owner.Name, repo.Name).
+ AddTokenAuth(token)
+ _ = MakeRequest(t, req, http.StatusNoContent)
+
+ // make sure release is deleted
+ req = NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/%s/releases/tags/release-tag", owner.Name, repo.Name).
+ AddTokenAuth(token)
+ _ = MakeRequest(t, req, http.StatusNotFound)
+
+ // delete release tag too
+ req = NewRequestf(t, http.MethodDelete, "/api/v1/repos/%s/%s/tags/release-tag", owner.Name, repo.Name).
+ AddTokenAuth(token)
+ _ = MakeRequest(t, req, http.StatusNoContent)
+}
+
+func TestAPIUploadAssetRelease(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, owner.LowerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test")
+
+ filename := "image.png"
+ buff := generateImg()
+
+ assetURL := fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets", owner.Name, repo.Name, r.ID)
+
+ t.Run("multipart/form-data", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ body := &bytes.Buffer{}
+
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("attachment", filename)
+ require.NoError(t, err)
+ _, err = io.Copy(part, bytes.NewReader(buff.Bytes()))
+ require.NoError(t, err)
+ err = writer.Close()
+ require.NoError(t, err)
+
+ req := NewRequestWithBody(t, http.MethodPost, assetURL, bytes.NewReader(body.Bytes())).
+ AddTokenAuth(token).
+ SetHeader("Content-Type", writer.FormDataContentType())
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var attachment *api.Attachment
+ DecodeJSON(t, resp, &attachment)
+
+ assert.EqualValues(t, filename, attachment.Name)
+ assert.EqualValues(t, 104, attachment.Size)
+
+ req = NewRequestWithBody(t, http.MethodPost, assetURL+"?name=test-asset", bytes.NewReader(body.Bytes())).
+ AddTokenAuth(token).
+ SetHeader("Content-Type", writer.FormDataContentType())
+ resp = MakeRequest(t, req, http.StatusCreated)
+
+ var attachment2 *api.Attachment
+ DecodeJSON(t, resp, &attachment2)
+
+ assert.EqualValues(t, "test-asset", attachment2.Name)
+ assert.EqualValues(t, 104, attachment2.Size)
+ })
+
+ t.Run("application/octet-stream", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithBody(t, http.MethodPost, assetURL, bytes.NewReader(buff.Bytes())).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequestWithBody(t, http.MethodPost, assetURL+"?name=stream.bin", bytes.NewReader(buff.Bytes())).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var attachment *api.Attachment
+ DecodeJSON(t, resp, &attachment)
+
+ assert.EqualValues(t, "stream.bin", attachment.Name)
+ assert.EqualValues(t, 104, attachment.Size)
+ assert.EqualValues(t, "attachment", attachment.Type)
+ })
+}
+
+func TestAPIGetReleaseArchiveDownloadCount(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, owner.LowerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ name := "ReleaseDownloadCount"
+
+ createNewReleaseUsingAPI(t, token, owner, repo, name, "", name, "test")
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, name)
+
+ req := NewRequest(t, "GET", urlStr)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var release *api.Release
+ DecodeJSON(t, resp, &release)
+
+ // Check if everything defaults to 0
+ assert.Equal(t, int64(0), release.ArchiveDownloadCount.TarGz)
+ assert.Equal(t, int64(0), release.ArchiveDownloadCount.Zip)
+
+ // Download the tarball to increase the count
+ MakeRequest(t, NewRequest(t, "GET", release.TarURL), http.StatusOK)
+
+ // Check if the count has increased
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &release)
+
+ assert.Equal(t, int64(1), release.ArchiveDownloadCount.TarGz)
+ assert.Equal(t, int64(0), release.ArchiveDownloadCount.Zip)
+}
+
+func TestAPIExternalAssetRelease(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, owner.LowerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test")
+
+ req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset&external_url=https%%3A%%2F%%2Fforgejo.org%%2F", owner.Name, repo.Name, r.ID)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var attachment *api.Attachment
+ DecodeJSON(t, resp, &attachment)
+
+ assert.EqualValues(t, "test-asset", attachment.Name)
+ assert.EqualValues(t, 0, attachment.Size)
+ assert.EqualValues(t, "https://forgejo.org/", attachment.DownloadURL)
+ assert.EqualValues(t, "external", attachment.Type)
+}
+
+func TestAPIDuplicateAssetRelease(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, owner.LowerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test")
+
+ filename := "image.png"
+ buff := generateImg()
+ body := &bytes.Buffer{}
+
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("attachment", filename)
+ require.NoError(t, err)
+ _, err = io.Copy(part, &buff)
+ require.NoError(t, err)
+ err = writer.Close()
+ require.NoError(t, err)
+
+ req := NewRequestWithBody(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset&external_url=https%%3A%%2F%%2Fforgejo.org%%2F", owner.Name, repo.Name, r.ID), body).
+ AddTokenAuth(token)
+ req.Header.Add("Content-Type", writer.FormDataContentType())
+ MakeRequest(t, req, http.StatusBadRequest)
+}
+
+func TestAPIMissingAssetRelease(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, owner.LowerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ r := createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test")
+
+ req := NewRequest(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/releases/%d/assets?name=test-asset", owner.Name, repo.Name, r.ID)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusBadRequest)
+}
diff --git a/tests/integration/api_repo_activities_test.go b/tests/integration/api_repo_activities_test.go
new file mode 100644
index 0000000..dbdedec
--- /dev/null
+++ b/tests/integration/api_repo_activities_test.go
@@ -0,0 +1,88 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIRepoActivitiyFeeds(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ repo, _, f := tests.CreateDeclarativeRepoWithOptions(t, owner, tests.DeclarativeRepoOptions{})
+ defer f()
+
+ feedURL := fmt.Sprintf("/api/v1/repos/%s/activities/feeds", repo.FullName())
+ assertAndReturnActivities := func(t *testing.T, length int) []api.Activity {
+ t.Helper()
+
+ req := NewRequest(t, "GET", feedURL)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var activities []api.Activity
+ DecodeJSON(t, resp, &activities)
+
+ assert.Len(t, activities, length)
+
+ return activities
+ }
+ createIssue := func(t *testing.T) {
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/issues?state=all", repo.FullName())
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{
+ Title: "ActivityFeed test",
+ Body: "Nothing to see here!",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+ }
+
+ t.Run("repo creation", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Upon repo creation, there's a single activity.
+ assertAndReturnActivities(t, 1)
+ })
+
+ t.Run("single watcher, single issue", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // After creating an issue, we'll have two activities.
+ createIssue(t)
+ assertAndReturnActivities(t, 2)
+ })
+
+ t.Run("a new watcher, no new activities", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ watcher := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ watcherSession := loginUser(t, watcher.Name)
+ watcherToken := getTokenForLoggedInUser(t, watcherSession, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeReadUser)
+
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/subscription", repo.FullName())).
+ AddTokenAuth(watcherToken)
+ MakeRequest(t, req, http.StatusOK)
+
+ assertAndReturnActivities(t, 2)
+ })
+
+ t.Run("multiple watchers, new issue", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // After creating a second issue, we'll have three activities, even
+ // though we have multiple watchers.
+ createIssue(t)
+ assertAndReturnActivities(t, 3)
+ })
+}
diff --git a/tests/integration/api_repo_archive_test.go b/tests/integration/api_repo_archive_test.go
new file mode 100644
index 0000000..a16136a
--- /dev/null
+++ b/tests/integration/api_repo_archive_test.go
@@ -0,0 +1,65 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIDownloadArchive(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user2.LowerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master.zip", user2.Name, repo.Name))
+ resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
+ bs, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ assert.Len(t, bs, 320)
+ assert.EqualValues(t, "application/zip", resp.Header().Get("Content-Type"))
+
+ link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master.tar.gz", user2.Name, repo.Name))
+ resp = MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
+ bs, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ assert.Len(t, bs, 266)
+ assert.EqualValues(t, "application/gzip", resp.Header().Get("Content-Type"))
+
+ // Must return a link to a commit ID as the "immutable" archive link
+ linkHeaderRe := regexp.MustCompile(`<(?P<url>https?://.*/api/v1/repos/user2/repo1/archive/[a-f0-9]+\.tar\.gz.*)>; rel="immutable"`)
+ m := linkHeaderRe.FindStringSubmatch(resp.Header().Get("Link"))
+ assert.NotEmpty(t, m[1])
+ resp = MakeRequest(t, NewRequest(t, "GET", m[1]).AddTokenAuth(token), http.StatusOK)
+ bs2, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ // The locked URL should give the same bytes as the non-locked one
+ assert.EqualValues(t, bs, bs2)
+
+ link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master.bundle", user2.Name, repo.Name))
+ resp = MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
+ bs, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ assert.Len(t, bs, 382)
+ assert.EqualValues(t, "application/octet-stream", resp.Header().Get("Content-Type"))
+
+ link, _ = url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/archive/master", user2.Name, repo.Name))
+ MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusBadRequest)
+}
diff --git a/tests/integration/api_repo_avatar_test.go b/tests/integration/api_repo_avatar_test.go
new file mode 100644
index 0000000..8ee256e
--- /dev/null
+++ b/tests/integration/api_repo_avatar_test.go
@@ -0,0 +1,81 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "os"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIUpdateRepoAvatar(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeWriteRepository)
+
+ // Test what happens if you use a valid image
+ avatar, err := os.ReadFile("tests/integration/avatar.png")
+ require.NoError(t, err)
+ if err != nil {
+ assert.FailNow(t, "Unable to open avatar.png")
+ }
+
+ opts := api.UpdateRepoAvatarOption{
+ Image: base64.StdEncoding.EncodeToString(avatar),
+ }
+
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/avatar", repo.OwnerName, repo.Name), &opts).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Test what happens if you don't have a valid Base64 string
+ opts = api.UpdateRepoAvatarOption{
+ Image: "Invalid",
+ }
+
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/avatar", repo.OwnerName, repo.Name), &opts).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ // Test what happens if you use a file that is not an image
+ text, err := os.ReadFile("tests/integration/README.md")
+ require.NoError(t, err)
+ if err != nil {
+ assert.FailNow(t, "Unable to open README.md")
+ }
+
+ opts = api.UpdateRepoAvatarOption{
+ Image: base64.StdEncoding.EncodeToString(text),
+ }
+
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/avatar", repo.OwnerName, repo.Name), &opts).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusInternalServerError)
+}
+
+func TestAPIDeleteRepoAvatar(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeWriteRepository)
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/avatar", repo.OwnerName, repo.Name)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+}
diff --git a/tests/integration/api_repo_branch_test.go b/tests/integration/api_repo_branch_test.go
new file mode 100644
index 0000000..8315902
--- /dev/null
+++ b/tests/integration/api_repo_branch_test.go
@@ -0,0 +1,131 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIRepoBranchesPlain(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ session := loginUser(t, user1.LowerName)
+
+ // public only token should be forbidden
+ publicOnlyToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopePublicOnly, auth_model.AccessTokenScopeWriteRepository)
+ link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches", repo3.Name)) // a plain repo
+ MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden)
+
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
+ bs, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ var branches []*api.Branch
+ require.NoError(t, json.Unmarshal(bs, &branches))
+ assert.Len(t, branches, 2)
+ assert.EqualValues(t, "test_branch", branches[0].Name)
+ assert.EqualValues(t, "master", branches[1].Name)
+
+ link2, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches/test_branch", repo3.Name))
+ MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden)
+
+ resp = MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(token), http.StatusOK)
+ bs, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ var branch api.Branch
+ require.NoError(t, json.Unmarshal(bs, &branch))
+ assert.EqualValues(t, "test_branch", branch.Name)
+
+ MakeRequest(t, NewRequest(t, "POST", link.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden)
+
+ req := NewRequest(t, "POST", link.String()).AddTokenAuth(token)
+ req.Header.Add("Content-Type", "application/json")
+ req.Body = io.NopCloser(bytes.NewBufferString(`{"new_branch_name":"test_branch2", "old_branch_name": "test_branch", "old_ref_name":"refs/heads/test_branch"}`))
+ resp = MakeRequest(t, req, http.StatusCreated)
+ bs, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ var branch2 api.Branch
+ require.NoError(t, json.Unmarshal(bs, &branch2))
+ assert.EqualValues(t, "test_branch2", branch2.Name)
+ assert.EqualValues(t, branch.Commit.ID, branch2.Commit.ID)
+
+ resp = MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
+ bs, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ branches = []*api.Branch{}
+ require.NoError(t, json.Unmarshal(bs, &branches))
+ assert.Len(t, branches, 3)
+ assert.EqualValues(t, "test_branch", branches[0].Name)
+ assert.EqualValues(t, "test_branch2", branches[1].Name)
+ assert.EqualValues(t, "master", branches[2].Name)
+
+ link3, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches/test_branch2", repo3.Name))
+ MakeRequest(t, NewRequest(t, "DELETE", link3.String()), http.StatusNotFound)
+ MakeRequest(t, NewRequest(t, "DELETE", link3.String()).AddTokenAuth(publicOnlyToken), http.StatusForbidden)
+
+ MakeRequest(t, NewRequest(t, "DELETE", link3.String()).AddTokenAuth(token), http.StatusNoContent)
+ require.NoError(t, err)
+ })
+}
+
+func TestAPIRepoBranchesMirror(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo5 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ session := loginUser(t, user1.LowerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches", repo5.Name)) // a mirror repo
+ resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
+ bs, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ var branches []*api.Branch
+ require.NoError(t, json.Unmarshal(bs, &branches))
+ assert.Len(t, branches, 2)
+ assert.EqualValues(t, "test_branch", branches[0].Name)
+ assert.EqualValues(t, "master", branches[1].Name)
+
+ link2, _ := url.Parse(fmt.Sprintf("/api/v1/repos/org3/%s/branches/test_branch", repo5.Name))
+ resp = MakeRequest(t, NewRequest(t, "GET", link2.String()).AddTokenAuth(token), http.StatusOK)
+ bs, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ var branch api.Branch
+ require.NoError(t, json.Unmarshal(bs, &branch))
+ assert.EqualValues(t, "test_branch", branch.Name)
+
+ req := NewRequest(t, "POST", link.String()).AddTokenAuth(token)
+ req.Header.Add("Content-Type", "application/json")
+ req.Body = io.NopCloser(bytes.NewBufferString(`{"new_branch_name":"test_branch2", "old_branch_name": "test_branch", "old_ref_name":"refs/heads/test_branch"}`))
+ resp = MakeRequest(t, req, http.StatusForbidden)
+ bs, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ assert.EqualValues(t, "{\"message\":\"Git Repository is a mirror.\",\"url\":\""+setting.AppURL+"api/swagger\"}\n", string(bs))
+
+ resp = MakeRequest(t, NewRequest(t, "DELETE", link2.String()).AddTokenAuth(token), http.StatusForbidden)
+ bs, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ assert.EqualValues(t, "{\"message\":\"Git Repository is a mirror.\",\"url\":\""+setting.AppURL+"api/swagger\"}\n", string(bs))
+}
diff --git a/tests/integration/api_repo_collaborator_test.go b/tests/integration/api_repo_collaborator_test.go
new file mode 100644
index 0000000..59cf85f
--- /dev/null
+++ b/tests/integration/api_repo_collaborator_test.go
@@ -0,0 +1,138 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/perm"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIRepoCollaboratorPermission(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+ repo2Owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo2.OwnerID})
+
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
+ user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ user10 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10})
+ user11 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 11})
+
+ testCtx := NewAPITestContext(t, repo2Owner.Name, repo2.Name, auth_model.AccessTokenScopeWriteRepository)
+
+ t.Run("RepoOwnerShouldBeOwner", func(t *testing.T) {
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, repo2Owner.Name).
+ AddTokenAuth(testCtx.Token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var repoPermission api.RepoCollaboratorPermission
+ DecodeJSON(t, resp, &repoPermission)
+
+ assert.Equal(t, "owner", repoPermission.Permission)
+ })
+
+ t.Run("CollaboratorWithReadAccess", func(t *testing.T) {
+ t.Run("AddUserAsCollaboratorWithReadAccess", doAPIAddCollaborator(testCtx, user4.Name, perm.AccessModeRead))
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, user4.Name).
+ AddTokenAuth(testCtx.Token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var repoPermission api.RepoCollaboratorPermission
+ DecodeJSON(t, resp, &repoPermission)
+
+ assert.Equal(t, "read", repoPermission.Permission)
+ })
+
+ t.Run("CollaboratorWithWriteAccess", func(t *testing.T) {
+ t.Run("AddUserAsCollaboratorWithWriteAccess", doAPIAddCollaborator(testCtx, user4.Name, perm.AccessModeWrite))
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, user4.Name).
+ AddTokenAuth(testCtx.Token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var repoPermission api.RepoCollaboratorPermission
+ DecodeJSON(t, resp, &repoPermission)
+
+ assert.Equal(t, "write", repoPermission.Permission)
+ })
+
+ t.Run("CollaboratorWithAdminAccess", func(t *testing.T) {
+ t.Run("AddUserAsCollaboratorWithAdminAccess", doAPIAddCollaborator(testCtx, user4.Name, perm.AccessModeAdmin))
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, user4.Name).
+ AddTokenAuth(testCtx.Token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var repoPermission api.RepoCollaboratorPermission
+ DecodeJSON(t, resp, &repoPermission)
+
+ assert.Equal(t, "admin", repoPermission.Permission)
+ })
+
+ t.Run("CollaboratorNotFound", func(t *testing.T) {
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, "non-existent-user").
+ AddTokenAuth(testCtx.Token)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("CollaboratorCanQueryItsPermissions", func(t *testing.T) {
+ t.Run("AddUserAsCollaboratorWithReadAccess", doAPIAddCollaborator(testCtx, user5.Name, perm.AccessModeRead))
+
+ _session := loginUser(t, user5.Name)
+ _testCtx := NewAPITestContext(t, user5.Name, repo2.Name, auth_model.AccessTokenScopeReadRepository)
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, user5.Name).
+ AddTokenAuth(_testCtx.Token)
+ resp := _session.MakeRequest(t, req, http.StatusOK)
+
+ var repoPermission api.RepoCollaboratorPermission
+ DecodeJSON(t, resp, &repoPermission)
+
+ assert.Equal(t, "read", repoPermission.Permission)
+ })
+
+ t.Run("CollaboratorCanQueryItsPermissions", func(t *testing.T) {
+ t.Run("AddUserAsCollaboratorWithReadAccess", doAPIAddCollaborator(testCtx, user5.Name, perm.AccessModeRead))
+
+ _session := loginUser(t, user5.Name)
+ _testCtx := NewAPITestContext(t, user5.Name, repo2.Name, auth_model.AccessTokenScopeReadRepository)
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, user5.Name).
+ AddTokenAuth(_testCtx.Token)
+ resp := _session.MakeRequest(t, req, http.StatusOK)
+
+ var repoPermission api.RepoCollaboratorPermission
+ DecodeJSON(t, resp, &repoPermission)
+
+ assert.Equal(t, "read", repoPermission.Permission)
+ })
+
+ t.Run("RepoAdminCanQueryACollaboratorsPermissions", func(t *testing.T) {
+ t.Run("AddUserAsCollaboratorWithAdminAccess", doAPIAddCollaborator(testCtx, user10.Name, perm.AccessModeAdmin))
+ t.Run("AddUserAsCollaboratorWithReadAccess", doAPIAddCollaborator(testCtx, user11.Name, perm.AccessModeRead))
+
+ _session := loginUser(t, user10.Name)
+ _testCtx := NewAPITestContext(t, user10.Name, repo2.Name, auth_model.AccessTokenScopeReadRepository)
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/collaborators/%s/permission", repo2Owner.Name, repo2.Name, user11.Name).
+ AddTokenAuth(_testCtx.Token)
+ resp := _session.MakeRequest(t, req, http.StatusOK)
+
+ var repoPermission api.RepoCollaboratorPermission
+ DecodeJSON(t, resp, &repoPermission)
+
+ assert.Equal(t, "read", repoPermission.Permission)
+ })
+ })
+}
diff --git a/tests/integration/api_repo_compare_test.go b/tests/integration/api_repo_compare_test.go
new file mode 100644
index 0000000..f3188eb
--- /dev/null
+++ b/tests/integration/api_repo_compare_test.go
@@ -0,0 +1,38 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPICompareBranches(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ repoName := "repo20"
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/user2/%s/compare/add-csv...remove-files-b", repoName).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiResp *api.Compare
+ DecodeJSON(t, resp, &apiResp)
+
+ assert.Equal(t, 2, apiResp.TotalCommits)
+ assert.Len(t, apiResp.Commits, 2)
+}
diff --git a/tests/integration/api_repo_edit_test.go b/tests/integration/api_repo_edit_test.go
new file mode 100644
index 0000000..7de8910
--- /dev/null
+++ b/tests/integration/api_repo_edit_test.go
@@ -0,0 +1,368 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// getRepoEditOptionFromRepo gets the options for an existing repo exactly as is
+func getRepoEditOptionFromRepo(repo *repo_model.Repository) *api.EditRepoOption {
+ name := repo.Name
+ description := repo.Description
+ website := repo.Website
+ private := repo.IsPrivate
+ hasIssues := false
+ var internalTracker *api.InternalTracker
+ var externalTracker *api.ExternalTracker
+ if unit, err := repo.GetUnit(db.DefaultContext, unit_model.TypeIssues); err == nil {
+ config := unit.IssuesConfig()
+ hasIssues = true
+ internalTracker = &api.InternalTracker{
+ EnableTimeTracker: config.EnableTimetracker,
+ AllowOnlyContributorsToTrackTime: config.AllowOnlyContributorsToTrackTime,
+ EnableIssueDependencies: config.EnableDependencies,
+ }
+ } else if unit, err := repo.GetUnit(db.DefaultContext, unit_model.TypeExternalTracker); err == nil {
+ config := unit.ExternalTrackerConfig()
+ hasIssues = true
+ externalTracker = &api.ExternalTracker{
+ ExternalTrackerURL: config.ExternalTrackerURL,
+ ExternalTrackerFormat: config.ExternalTrackerFormat,
+ ExternalTrackerStyle: config.ExternalTrackerStyle,
+ ExternalTrackerRegexpPattern: config.ExternalTrackerRegexpPattern,
+ }
+ }
+ hasWiki := false
+ var externalWiki *api.ExternalWiki
+ if _, err := repo.GetUnit(db.DefaultContext, unit_model.TypeWiki); err == nil {
+ hasWiki = true
+ } else if unit, err := repo.GetUnit(db.DefaultContext, unit_model.TypeExternalWiki); err == nil {
+ hasWiki = true
+ config := unit.ExternalWikiConfig()
+ externalWiki = &api.ExternalWiki{
+ ExternalWikiURL: config.ExternalWikiURL,
+ }
+ }
+ defaultBranch := repo.DefaultBranch
+ hasPullRequests := false
+ ignoreWhitespaceConflicts := false
+ allowMerge := false
+ allowRebase := false
+ allowRebaseMerge := false
+ allowSquash := false
+ allowFastForwardOnly := false
+ if unit, err := repo.GetUnit(db.DefaultContext, unit_model.TypePullRequests); err == nil {
+ config := unit.PullRequestsConfig()
+ hasPullRequests = true
+ ignoreWhitespaceConflicts = config.IgnoreWhitespaceConflicts
+ allowMerge = config.AllowMerge
+ allowRebase = config.AllowRebase
+ allowRebaseMerge = config.AllowRebaseMerge
+ allowSquash = config.AllowSquash
+ allowFastForwardOnly = config.AllowFastForwardOnly
+ }
+ archived := repo.IsArchived
+ return &api.EditRepoOption{
+ Name: &name,
+ Description: &description,
+ Website: &website,
+ Private: &private,
+ HasIssues: &hasIssues,
+ ExternalTracker: externalTracker,
+ InternalTracker: internalTracker,
+ HasWiki: &hasWiki,
+ ExternalWiki: externalWiki,
+ DefaultBranch: &defaultBranch,
+ HasPullRequests: &hasPullRequests,
+ IgnoreWhitespaceConflicts: &ignoreWhitespaceConflicts,
+ AllowMerge: &allowMerge,
+ AllowRebase: &allowRebase,
+ AllowRebaseMerge: &allowRebaseMerge,
+ AllowSquash: &allowSquash,
+ AllowFastForwardOnly: &allowFastForwardOnly,
+ Archived: &archived,
+ }
+}
+
+// getNewRepoEditOption Gets the options to change everything about an existing repo by adding to strings or changing
+// the boolean
+func getNewRepoEditOption(opts *api.EditRepoOption) *api.EditRepoOption {
+ // Gives a new property to everything
+ name := *opts.Name + "renamed"
+ description := "new description"
+ website := "http://wwww.newwebsite.com"
+ private := !*opts.Private
+ hasIssues := !*opts.HasIssues
+ hasWiki := !*opts.HasWiki
+ defaultBranch := "master"
+ hasPullRequests := !*opts.HasPullRequests
+ ignoreWhitespaceConflicts := !*opts.IgnoreWhitespaceConflicts
+ allowMerge := !*opts.AllowMerge
+ allowRebase := !*opts.AllowRebase
+ allowRebaseMerge := !*opts.AllowRebaseMerge
+ allowSquash := !*opts.AllowSquash
+ archived := !*opts.Archived
+
+ return &api.EditRepoOption{
+ Name: &name,
+ Description: &description,
+ Website: &website,
+ Private: &private,
+ DefaultBranch: &defaultBranch,
+ HasIssues: &hasIssues,
+ HasWiki: &hasWiki,
+ HasPullRequests: &hasPullRequests,
+ IgnoreWhitespaceConflicts: &ignoreWhitespaceConflicts,
+ AllowMerge: &allowMerge,
+ AllowRebase: &allowRebase,
+ AllowRebaseMerge: &allowRebaseMerge,
+ AllowSquash: &allowSquash,
+ Archived: &archived,
+ }
+}
+
+func TestAPIRepoEdit(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ bFalse, bTrue := false, true
+
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
+ repo15 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 15}) // empty repo
+ repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
+
+ // Get user2's token
+ session := loginUser(t, user2.Name)
+ token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ // Get user4's token
+ session = loginUser(t, user4.Name)
+ token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ // Test editing a repo1 which user2 owns, changing name and many properties
+ origRepoEditOption := getRepoEditOptionFromRepo(repo1)
+ repoEditOption := getNewRepoEditOption(origRepoEditOption)
+ req := NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo1.Name), &repoEditOption).
+ AddTokenAuth(token2)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var repo api.Repository
+ DecodeJSON(t, resp, &repo)
+ assert.NotNil(t, repo)
+ // check response
+ assert.Equal(t, *repoEditOption.Name, repo.Name)
+ assert.Equal(t, *repoEditOption.Description, repo.Description)
+ assert.Equal(t, *repoEditOption.Website, repo.Website)
+ assert.Equal(t, *repoEditOption.Archived, repo.Archived)
+ // check repo1 from database
+ repo1edited := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ repo1editedOption := getRepoEditOptionFromRepo(repo1edited)
+ assert.Equal(t, *repoEditOption.Name, *repo1editedOption.Name)
+ assert.Equal(t, *repoEditOption.Description, *repo1editedOption.Description)
+ assert.Equal(t, *repoEditOption.Website, *repo1editedOption.Website)
+ assert.Equal(t, *repoEditOption.Archived, *repo1editedOption.Archived)
+ assert.Equal(t, *repoEditOption.Private, *repo1editedOption.Private)
+ assert.Equal(t, *repoEditOption.HasWiki, *repo1editedOption.HasWiki)
+
+ // Test editing repo1 to use internal issue and wiki (default)
+ *repoEditOption.HasIssues = true
+ repoEditOption.ExternalTracker = nil
+ repoEditOption.InternalTracker = &api.InternalTracker{
+ EnableTimeTracker: false,
+ AllowOnlyContributorsToTrackTime: false,
+ EnableIssueDependencies: false,
+ }
+ *repoEditOption.HasWiki = true
+ repoEditOption.ExternalWiki = nil
+ url := fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, *repoEditOption.Name)
+ req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &repo)
+ assert.NotNil(t, repo)
+ // check repo1 was written to database
+ repo1edited = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ repo1editedOption = getRepoEditOptionFromRepo(repo1edited)
+ assert.True(t, *repo1editedOption.HasIssues)
+ assert.Nil(t, repo1editedOption.ExternalTracker)
+ assert.Equal(t, *repo1editedOption.InternalTracker, *repoEditOption.InternalTracker)
+ assert.True(t, *repo1editedOption.HasWiki)
+ assert.Nil(t, repo1editedOption.ExternalWiki)
+
+ // Test editing repo1 to use external issue and wiki
+ repoEditOption.ExternalTracker = &api.ExternalTracker{
+ ExternalTrackerURL: "http://www.somewebsite.com",
+ ExternalTrackerFormat: "http://www.somewebsite.com/{user}/{repo}?issue={index}",
+ ExternalTrackerStyle: "alphanumeric",
+ }
+ repoEditOption.ExternalWiki = &api.ExternalWiki{
+ ExternalWikiURL: "http://www.somewebsite.com",
+ }
+ req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &repo)
+ assert.NotNil(t, repo)
+ // check repo1 was written to database
+ repo1edited = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ repo1editedOption = getRepoEditOptionFromRepo(repo1edited)
+ assert.True(t, *repo1editedOption.HasIssues)
+ assert.Equal(t, *repo1editedOption.ExternalTracker, *repoEditOption.ExternalTracker)
+ assert.True(t, *repo1editedOption.HasWiki)
+ assert.Equal(t, *repo1editedOption.ExternalWiki, *repoEditOption.ExternalWiki)
+
+ repoEditOption.ExternalTracker.ExternalTrackerStyle = "regexp"
+ repoEditOption.ExternalTracker.ExternalTrackerRegexpPattern = `(\d+)`
+ req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &repo)
+ assert.NotNil(t, repo)
+ repo1edited = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ repo1editedOption = getRepoEditOptionFromRepo(repo1edited)
+ assert.True(t, *repo1editedOption.HasIssues)
+ assert.Equal(t, *repo1editedOption.ExternalTracker, *repoEditOption.ExternalTracker)
+
+ // Do some tests with invalid URL for external tracker and wiki
+ repoEditOption.ExternalTracker.ExternalTrackerURL = "htp://www.somewebsite.com"
+ req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+ repoEditOption.ExternalTracker.ExternalTrackerURL = "http://www.somewebsite.com"
+ repoEditOption.ExternalTracker.ExternalTrackerFormat = "http://www.somewebsite.com/{user/{repo}?issue={index}"
+ req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+ repoEditOption.ExternalTracker.ExternalTrackerFormat = "http://www.somewebsite.com/{user}/{repo}?issue={index}"
+ repoEditOption.ExternalWiki.ExternalWikiURL = "htp://www.somewebsite.com"
+ req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ // Test small repo change through API with issue and wiki option not set; They shall not be touched.
+ *repoEditOption.Description = "small change"
+ repoEditOption.HasIssues = nil
+ repoEditOption.ExternalTracker = nil
+ repoEditOption.HasWiki = nil
+ repoEditOption.ExternalWiki = nil
+ req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &repo)
+ assert.NotNil(t, repo)
+ // check repo1 was written to database
+ repo1edited = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ repo1editedOption = getRepoEditOptionFromRepo(repo1edited)
+ assert.Equal(t, *repo1editedOption.Description, *repoEditOption.Description)
+ assert.True(t, *repo1editedOption.HasIssues)
+ assert.NotNil(t, *repo1editedOption.ExternalTracker)
+ assert.True(t, *repo1editedOption.HasWiki)
+ assert.NotNil(t, *repo1editedOption.ExternalWiki)
+
+ // reset repo in db
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, *repoEditOption.Name), &origRepoEditOption).
+ AddTokenAuth(token2)
+ _ = MakeRequest(t, req, http.StatusOK)
+
+ // Test editing a non-existing repo
+ name := "repodoesnotexist"
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, name), &api.EditRepoOption{Name: &name}).
+ AddTokenAuth(token2)
+ _ = MakeRequest(t, req, http.StatusNotFound)
+
+ // Test editing repo16 by user4 who does not have write access
+ origRepoEditOption = getRepoEditOptionFromRepo(repo16)
+ repoEditOption = getNewRepoEditOption(origRepoEditOption)
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo16.Name), &repoEditOption).
+ AddTokenAuth(token4)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Tests a repo with no token given so will fail
+ origRepoEditOption = getRepoEditOptionFromRepo(repo16)
+ repoEditOption = getNewRepoEditOption(origRepoEditOption)
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo16.Name), &repoEditOption)
+ _ = MakeRequest(t, req, http.StatusNotFound)
+
+ // Test using access token for a private repo that the user of the token owns
+ origRepoEditOption = getRepoEditOptionFromRepo(repo16)
+ repoEditOption = getNewRepoEditOption(origRepoEditOption)
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo16.Name), &repoEditOption).
+ AddTokenAuth(token2)
+ _ = MakeRequest(t, req, http.StatusOK)
+ // reset repo in db
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, *repoEditOption.Name), &origRepoEditOption).
+ AddTokenAuth(token2)
+ _ = MakeRequest(t, req, http.StatusOK)
+
+ // Test making a repo public that is private
+ repo16 = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16})
+ assert.True(t, repo16.IsPrivate)
+ repoEditOption = &api.EditRepoOption{
+ Private: &bFalse,
+ }
+ url = fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo16.Name)
+ req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption).
+ AddTokenAuth(token2)
+ _ = MakeRequest(t, req, http.StatusOK)
+ repo16 = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16})
+ assert.False(t, repo16.IsPrivate)
+ // Make it private again
+ repoEditOption.Private = &bTrue
+ req = NewRequestWithJSON(t, "PATCH", url, &repoEditOption).
+ AddTokenAuth(token2)
+ _ = MakeRequest(t, req, http.StatusOK)
+
+ // Test to change empty repo
+ assert.False(t, repo15.IsArchived)
+ url = fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo15.Name)
+ req = NewRequestWithJSON(t, "PATCH", url, &api.EditRepoOption{
+ Archived: &bTrue,
+ }).AddTokenAuth(token2)
+ _ = MakeRequest(t, req, http.StatusOK)
+ repo15 = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 15})
+ assert.True(t, repo15.IsArchived)
+ req = NewRequestWithJSON(t, "PATCH", url, &api.EditRepoOption{
+ Archived: &bFalse,
+ }).AddTokenAuth(token2)
+ _ = MakeRequest(t, req, http.StatusOK)
+
+ // Test using org repo "org3/repo3" where user2 is a collaborator
+ origRepoEditOption = getRepoEditOptionFromRepo(repo3)
+ repoEditOption = getNewRepoEditOption(origRepoEditOption)
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", org3.Name, repo3.Name), &repoEditOption).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusOK)
+ // reset repo in db
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", org3.Name, *repoEditOption.Name), &origRepoEditOption).
+ AddTokenAuth(token2)
+ _ = MakeRequest(t, req, http.StatusOK)
+
+ // Test using org repo "org3/repo3" with no user token
+ origRepoEditOption = getRepoEditOptionFromRepo(repo3)
+ repoEditOption = getNewRepoEditOption(origRepoEditOption)
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", org3.Name, repo3.Name), &repoEditOption)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test using repo "user2/repo1" where user4 is a NOT collaborator
+ origRepoEditOption = getRepoEditOptionFromRepo(repo1)
+ repoEditOption = getNewRepoEditOption(origRepoEditOption)
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/repos/%s/%s", user2.Name, repo1.Name), &repoEditOption).
+ AddTokenAuth(token4)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+}
diff --git a/tests/integration/api_repo_file_create_test.go b/tests/integration/api_repo_file_create_test.go
new file mode 100644
index 0000000..c7c30db
--- /dev/null
+++ b/tests/integration/api_repo_file_create_test.go
@@ -0,0 +1,312 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ stdCtx "context"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/context"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func getCreateFileOptions() api.CreateFileOptions {
+ content := "This is new text"
+ contentEncoded := base64.StdEncoding.EncodeToString([]byte(content))
+ return api.CreateFileOptions{
+ FileOptions: api.FileOptions{
+ BranchName: "master",
+ NewBranchName: "master",
+ Message: "Making this new file new/file.txt",
+ Author: api.Identity{
+ Name: "Anne Doe",
+ Email: "annedoe@example.com",
+ },
+ Committer: api.Identity{
+ Name: "John Doe",
+ Email: "johndoe@example.com",
+ },
+ Dates: api.CommitDateOptions{
+ Author: time.Unix(946684810, 0),
+ Committer: time.Unix(978307190, 0),
+ },
+ },
+ ContentBase64: contentEncoded,
+ }
+}
+
+func getExpectedFileResponseForCreate(repoFullName, commitID, treePath, latestCommitSHA string) *api.FileResponse {
+ sha := "a635aa942442ddfdba07468cf9661c08fbdf0ebf"
+ if len(latestCommitSHA) > len(sha) {
+ // repository is in SHA256 format
+ sha = "3edd190f61237b7a0a5c49aa47fb58b2ec14d53a2afc90803bc713fab5d5aec0"
+ }
+ encoding := "base64"
+ content := "VGhpcyBpcyBuZXcgdGV4dA=="
+ selfURL := setting.AppURL + "api/v1/repos/" + repoFullName + "/contents/" + treePath + "?ref=master"
+ htmlURL := setting.AppURL + repoFullName + "/src/branch/master/" + treePath
+ gitURL := setting.AppURL + "api/v1/repos/" + repoFullName + "/git/blobs/" + sha
+ downloadURL := setting.AppURL + repoFullName + "/raw/branch/master/" + treePath
+ return &api.FileResponse{
+ Content: &api.ContentsResponse{
+ Name: filepath.Base(treePath),
+ Path: treePath,
+ SHA: sha,
+ LastCommitSHA: latestCommitSHA,
+ Size: 16,
+ Type: "file",
+ Encoding: &encoding,
+ Content: &content,
+ URL: &selfURL,
+ HTMLURL: &htmlURL,
+ GitURL: &gitURL,
+ DownloadURL: &downloadURL,
+ Links: &api.FileLinksResponse{
+ Self: &selfURL,
+ GitURL: &gitURL,
+ HTMLURL: &htmlURL,
+ },
+ },
+ Commit: &api.FileCommitResponse{
+ CommitMeta: api.CommitMeta{
+ URL: setting.AppURL + "api/v1/repos/" + repoFullName + "/git/commits/" + commitID,
+ SHA: commitID,
+ },
+ HTMLURL: setting.AppURL + repoFullName + "/commit/" + commitID,
+ Author: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "Anne Doe",
+ Email: "annedoe@example.com",
+ },
+ Date: "2000-01-01T00:00:10Z",
+ },
+ Committer: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "John Doe",
+ Email: "johndoe@example.com",
+ },
+ Date: "2000-12-31T23:59:50Z",
+ },
+ Message: "Updates README.md\n",
+ },
+ Verification: &api.PayloadCommitVerification{
+ Verified: false,
+ Reason: "gpg.error.not_signed_commit",
+ Signature: "",
+ Payload: "",
+ },
+ }
+}
+
+func BenchmarkAPICreateFileSmall(b *testing.B) {
+ onGiteaRun(b, func(b *testing.B, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(b, &user_model.User{ID: 2}) // owner of the repo1 & repo16
+ repo1 := unittest.AssertExistsAndLoadBean(b, &repo_model.Repository{ID: 1}) // public repo
+
+ b.ResetTimer()
+ for n := 0; n < b.N; n++ {
+ treePath := fmt.Sprintf("update/file%d.txt", n)
+ _, _ = createFileInBranch(user2, repo1, treePath, repo1.DefaultBranch, treePath)
+ }
+ })
+}
+
+func BenchmarkAPICreateFileMedium(b *testing.B) {
+ data := make([]byte, 10*1024*1024)
+
+ onGiteaRun(b, func(b *testing.B, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(b, &user_model.User{ID: 2}) // owner of the repo1 & repo16
+ repo1 := unittest.AssertExistsAndLoadBean(b, &repo_model.Repository{ID: 1}) // public repo
+
+ b.ResetTimer()
+ for n := 0; n < b.N; n++ {
+ treePath := fmt.Sprintf("update/file%d.txt", n)
+ copy(data, treePath)
+ _, _ = createFileInBranch(user2, repo1, treePath, repo1.DefaultBranch, treePath)
+ }
+ })
+}
+
+func TestAPICreateFile(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
+ repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
+ fileID := 0
+
+ // Get user2's token
+ session := loginUser(t, user2.Name)
+ token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ // Get user4's token
+ session = loginUser(t, user4.Name)
+ token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+ // Test creating a file in repo1 which user2 owns, try both with branch and empty branch
+ for _, branch := range [...]string{
+ "master", // Branch
+ "", // Empty branch
+ } {
+ createFileOptions := getCreateFileOptions()
+ createFileOptions.BranchName = branch
+ fileID++
+ treePath := fmt.Sprintf("new/file%d.txt", fileID)
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &createFileOptions).
+ AddTokenAuth(token2)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ gitRepo, _ := gitrepo.OpenRepository(stdCtx.Background(), repo1)
+ commitID, _ := gitRepo.GetBranchCommitID(createFileOptions.NewBranchName)
+ latestCommit, _ := gitRepo.GetCommitByPath(treePath)
+ expectedFileResponse := getExpectedFileResponseForCreate("user2/repo1", commitID, treePath, latestCommit.ID.String())
+ var fileResponse api.FileResponse
+ DecodeJSON(t, resp, &fileResponse)
+ assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content)
+ assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA)
+ assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL)
+ assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, fileResponse.Commit.Author.Email)
+ assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, fileResponse.Commit.Author.Name)
+ assert.EqualValues(t, expectedFileResponse.Commit.Author.Date, fileResponse.Commit.Author.Date)
+ assert.EqualValues(t, expectedFileResponse.Commit.Committer.Email, fileResponse.Commit.Committer.Email)
+ assert.EqualValues(t, expectedFileResponse.Commit.Committer.Name, fileResponse.Commit.Committer.Name)
+ assert.EqualValues(t, expectedFileResponse.Commit.Committer.Date, fileResponse.Commit.Committer.Date)
+ gitRepo.Close()
+ }
+
+ // Test creating a file in a new branch
+ createFileOptions := getCreateFileOptions()
+ createFileOptions.BranchName = repo1.DefaultBranch
+ createFileOptions.NewBranchName = "new_branch"
+ fileID++
+ treePath := fmt.Sprintf("new/file%d.txt", fileID)
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &createFileOptions).
+ AddTokenAuth(token2)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var fileResponse api.FileResponse
+ DecodeJSON(t, resp, &fileResponse)
+ expectedSHA := "a635aa942442ddfdba07468cf9661c08fbdf0ebf"
+ expectedHTMLURL := fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/new_branch/new/file%d.txt", fileID)
+ expectedDownloadURL := fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/new_branch/new/file%d.txt", fileID)
+ assert.EqualValues(t, expectedSHA, fileResponse.Content.SHA)
+ assert.EqualValues(t, expectedHTMLURL, *fileResponse.Content.HTMLURL)
+ assert.EqualValues(t, expectedDownloadURL, *fileResponse.Content.DownloadURL)
+ assert.EqualValues(t, createFileOptions.Message+"\n", fileResponse.Commit.Message)
+
+ // Test creating a file without a message
+ createFileOptions = getCreateFileOptions()
+ createFileOptions.Message = ""
+ fileID++
+ treePath = fmt.Sprintf("new/file%d.txt", fileID)
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &createFileOptions).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, &fileResponse)
+ expectedMessage := "Add " + treePath + "\n"
+ assert.EqualValues(t, expectedMessage, fileResponse.Commit.Message)
+
+ // Test trying to create a file that already exists, should fail
+ createFileOptions = getCreateFileOptions()
+ treePath = "README.md"
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &createFileOptions).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusUnprocessableEntity)
+ expectedAPIError := context.APIError{
+ Message: "repository file already exists [path: " + treePath + "]",
+ URL: setting.API.SwaggerURL,
+ }
+ var apiError context.APIError
+ DecodeJSON(t, resp, &apiError)
+ assert.Equal(t, expectedAPIError, apiError)
+
+ // Test creating a file in repo1 by user4 who does not have write access
+ createFileOptions = getCreateFileOptions()
+ fileID++
+ treePath = fmt.Sprintf("new/file%d.txt", fileID)
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &createFileOptions).
+ AddTokenAuth(token4)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Tests a repo with no token given so will fail
+ createFileOptions = getCreateFileOptions()
+ fileID++
+ treePath = fmt.Sprintf("new/file%d.txt", fileID)
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &createFileOptions)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test using access token for a private repo that the user of the token owns
+ createFileOptions = getCreateFileOptions()
+ fileID++
+ treePath = fmt.Sprintf("new/file%d.txt", fileID)
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &createFileOptions).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusCreated)
+
+ // Test using org repo "org3/repo3" where user2 is a collaborator
+ createFileOptions = getCreateFileOptions()
+ fileID++
+ treePath = fmt.Sprintf("new/file%d.txt", fileID)
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath), &createFileOptions).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusCreated)
+
+ // Test using org repo "org3/repo3" with no user token
+ createFileOptions = getCreateFileOptions()
+ fileID++
+ treePath = fmt.Sprintf("new/file%d.txt", fileID)
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath), &createFileOptions)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test using repo "user2/repo1" where user4 is a NOT collaborator
+ createFileOptions = getCreateFileOptions()
+ fileID++
+ treePath = fmt.Sprintf("new/file%d.txt", fileID)
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &createFileOptions).
+ AddTokenAuth(token4)
+ MakeRequest(t, req, http.StatusForbidden)
+
+ // Test creating a file in an empty repository
+ forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) {
+ reponame := "empty-repo-" + objectFormat.Name()
+ doAPICreateRepository(NewAPITestContext(t, "user2", reponame, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser), true, objectFormat)(t)
+ createFileOptions = getCreateFileOptions()
+ fileID++
+ treePath = fmt.Sprintf("new/file%d.txt", fileID)
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, reponame, treePath), &createFileOptions).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusCreated)
+ emptyRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: reponame}) // public repo
+ gitRepo, _ := gitrepo.OpenRepository(stdCtx.Background(), emptyRepo)
+ commitID, _ := gitRepo.GetBranchCommitID(createFileOptions.NewBranchName)
+ latestCommit, _ := gitRepo.GetCommitByPath(treePath)
+ expectedFileResponse := getExpectedFileResponseForCreate("user2/"+reponame, commitID, treePath, latestCommit.ID.String())
+ DecodeJSON(t, resp, &fileResponse)
+ assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content)
+ assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA)
+ assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL)
+ assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, fileResponse.Commit.Author.Email)
+ assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, fileResponse.Commit.Author.Name)
+ assert.EqualValues(t, expectedFileResponse.Commit.Author.Date, fileResponse.Commit.Author.Date)
+ assert.EqualValues(t, expectedFileResponse.Commit.Committer.Email, fileResponse.Commit.Committer.Email)
+ assert.EqualValues(t, expectedFileResponse.Commit.Committer.Name, fileResponse.Commit.Committer.Name)
+ assert.EqualValues(t, expectedFileResponse.Commit.Committer.Date, fileResponse.Commit.Committer.Date)
+ gitRepo.Close()
+ })
+ })
+}
diff --git a/tests/integration/api_repo_file_delete_test.go b/tests/integration/api_repo_file_delete_test.go
new file mode 100644
index 0000000..7c93307
--- /dev/null
+++ b/tests/integration/api_repo_file_delete_test.go
@@ -0,0 +1,167 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func getDeleteFileOptions() *api.DeleteFileOptions {
+ return &api.DeleteFileOptions{
+ FileOptions: api.FileOptions{
+ BranchName: "master",
+ NewBranchName: "master",
+ Message: "Removing the file new/file.txt",
+ Author: api.Identity{
+ Name: "John Doe",
+ Email: "johndoe@example.com",
+ },
+ Committer: api.Identity{
+ Name: "Jane Doe",
+ Email: "janedoe@example.com",
+ },
+ },
+ SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
+ }
+}
+
+func TestAPIDeleteFile(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
+ repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
+ fileID := 0
+
+ // Get user2's token
+ session := loginUser(t, user2.Name)
+ token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ // Get user4's token
+ session = loginUser(t, user4.Name)
+ token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ // Test deleting a file in repo1 which user2 owns, try both with branch and empty branch
+ for _, branch := range [...]string{
+ "master", // Branch
+ "", // Empty branch
+ } {
+ fileID++
+ treePath := fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(user2, repo1, treePath)
+ deleteFileOptions := getDeleteFileOptions()
+ deleteFileOptions.BranchName = branch
+ req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &deleteFileOptions).
+ AddTokenAuth(token2)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var fileResponse api.FileResponse
+ DecodeJSON(t, resp, &fileResponse)
+ assert.NotNil(t, fileResponse)
+ assert.Nil(t, fileResponse.Content)
+ }
+
+ // Test deleting file and making the delete in a new branch
+ fileID++
+ treePath := fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(user2, repo1, treePath)
+ deleteFileOptions := getDeleteFileOptions()
+ deleteFileOptions.BranchName = repo1.DefaultBranch
+ deleteFileOptions.NewBranchName = "new_branch"
+ req := NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &deleteFileOptions).
+ AddTokenAuth(token2)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var fileResponse api.FileResponse
+ DecodeJSON(t, resp, &fileResponse)
+ assert.NotNil(t, fileResponse)
+ assert.Nil(t, fileResponse.Content)
+ assert.EqualValues(t, deleteFileOptions.Message+"\n", fileResponse.Commit.Message)
+
+ // Test deleting file without a message
+ fileID++
+ treePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(user2, repo1, treePath)
+ deleteFileOptions = getDeleteFileOptions()
+ deleteFileOptions.Message = ""
+ req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &deleteFileOptions).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &fileResponse)
+ expectedMessage := "Delete " + treePath + "\n"
+ assert.EqualValues(t, expectedMessage, fileResponse.Commit.Message)
+
+ // Test deleting a file with the wrong SHA
+ fileID++
+ treePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(user2, repo1, treePath)
+ deleteFileOptions = getDeleteFileOptions()
+ deleteFileOptions.SHA = "badsha"
+ req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &deleteFileOptions).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ // Test creating a file in repo16 by user4 who does not have write access
+ fileID++
+ treePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(user2, repo16, treePath)
+ deleteFileOptions = getDeleteFileOptions()
+ req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &deleteFileOptions).
+ AddTokenAuth(token4)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Tests a repo with no token given so will fail
+ fileID++
+ treePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(user2, repo16, treePath)
+ deleteFileOptions = getDeleteFileOptions()
+ req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &deleteFileOptions)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test using access token for a private repo that the user of the token owns
+ fileID++
+ treePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(user2, repo16, treePath)
+ deleteFileOptions = getDeleteFileOptions()
+ req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &deleteFileOptions).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusOK)
+
+ // Test using org repo "org3/repo3" where user2 is a collaborator
+ fileID++
+ treePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(org3, repo3, treePath)
+ deleteFileOptions = getDeleteFileOptions()
+ req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath), &deleteFileOptions).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusOK)
+
+ // Test using org repo "org3/repo3" with no user token
+ fileID++
+ treePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(org3, repo3, treePath)
+ deleteFileOptions = getDeleteFileOptions()
+ req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath), &deleteFileOptions)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test using repo "user2/repo1" where user4 is a NOT collaborator
+ fileID++
+ treePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(user2, repo1, treePath)
+ deleteFileOptions = getDeleteFileOptions()
+ req = NewRequestWithJSON(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &deleteFileOptions).
+ AddTokenAuth(token4)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+}
diff --git a/tests/integration/api_repo_file_get_test.go b/tests/integration/api_repo_file_get_test.go
new file mode 100644
index 0000000..1a4e670
--- /dev/null
+++ b/tests/integration/api_repo_file_get_test.go
@@ -0,0 +1,52 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/git"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIGetRawFileOrLFS(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // Test with raw file
+ req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/media/README.md")
+ resp := MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, "# repo1\n\nDescription for repo1", resp.Body.String())
+
+ // Test with LFS
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ httpContext := NewAPITestContext(t, "user2", "repo-lfs-test", auth_model.AccessTokenScopeWriteRepository)
+ doAPICreateRepository(httpContext, false, git.Sha1ObjectFormat, func(t *testing.T, repository api.Repository) { // FIXME: use forEachObjectFormat
+ u.Path = httpContext.GitPath()
+ dstPath := t.TempDir()
+
+ u.Path = httpContext.GitPath()
+ u.User = url.UserPassword("user2", userPassword)
+
+ t.Run("Clone", doGitClone(dstPath, u))
+
+ dstPath2 := t.TempDir()
+
+ t.Run("Partial Clone", doPartialGitClone(dstPath2, u))
+
+ lfs, _ := lfsCommitAndPushTest(t, dstPath)
+
+ reqLFS := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/media/"+lfs)
+ respLFS := MakeRequestNilResponseRecorder(t, reqLFS, http.StatusOK)
+ assert.Equal(t, littleSize, respLFS.Length)
+
+ doAPIDeleteRepository(httpContext)
+ })
+ })
+}
diff --git a/tests/integration/api_repo_file_helpers.go b/tests/integration/api_repo_file_helpers.go
new file mode 100644
index 0000000..4350092
--- /dev/null
+++ b/tests/integration/api_repo_file_helpers.go
@@ -0,0 +1,61 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ api "code.gitea.io/gitea/modules/structs"
+ files_service "code.gitea.io/gitea/services/repository/files"
+)
+
+func createFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName, content string) (*api.FilesResponse, error) {
+ opts := &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: treePath,
+ ContentReader: strings.NewReader(content),
+ },
+ },
+ OldBranch: branchName,
+ Author: nil,
+ Committer: nil,
+ }
+ return files_service.ChangeRepoFiles(git.DefaultContext, repo, user, opts)
+}
+
+func deleteFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName string) (*api.FilesResponse, error) {
+ opts := &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "delete",
+ TreePath: treePath,
+ },
+ },
+ OldBranch: branchName,
+ Author: nil,
+ Committer: nil,
+ }
+ return files_service.ChangeRepoFiles(git.DefaultContext, repo, user, opts)
+}
+
+func createOrReplaceFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName, content string) error {
+ _, err := deleteFileInBranch(user, repo, treePath, branchName)
+
+ if err != nil && !models.IsErrRepoFileDoesNotExist(err) {
+ return err
+ }
+
+ _, err = createFileInBranch(user, repo, treePath, branchName, content)
+ return err
+}
+
+func createFile(user *user_model.User, repo *repo_model.Repository, treePath string) (*api.FilesResponse, error) {
+ return createFileInBranch(user, repo, treePath, repo.DefaultBranch, "This is a NEW file")
+}
diff --git a/tests/integration/api_repo_file_update_test.go b/tests/integration/api_repo_file_update_test.go
new file mode 100644
index 0000000..ac28e0c
--- /dev/null
+++ b/tests/integration/api_repo_file_update_test.go
@@ -0,0 +1,275 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ stdCtx "context"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/context"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func getUpdateFileOptions() *api.UpdateFileOptions {
+ content := "This is updated text"
+ contentEncoded := base64.StdEncoding.EncodeToString([]byte(content))
+ return &api.UpdateFileOptions{
+ DeleteFileOptions: api.DeleteFileOptions{
+ FileOptions: api.FileOptions{
+ BranchName: "master",
+ NewBranchName: "master",
+ Message: "My update of new/file.txt",
+ Author: api.Identity{
+ Name: "John Doe",
+ Email: "johndoe@example.com",
+ },
+ Committer: api.Identity{
+ Name: "Anne Doe",
+ Email: "annedoe@example.com",
+ },
+ },
+ SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
+ },
+ ContentBase64: contentEncoded,
+ }
+}
+
+func getExpectedFileResponseForUpdate(commitID, treePath, lastCommitSHA string) *api.FileResponse {
+ sha := "08bd14b2e2852529157324de9c226b3364e76136"
+ encoding := "base64"
+ content := "VGhpcyBpcyB1cGRhdGVkIHRleHQ="
+ selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=master"
+ htmlURL := setting.AppURL + "user2/repo1/src/branch/master/" + treePath
+ gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/" + sha
+ downloadURL := setting.AppURL + "user2/repo1/raw/branch/master/" + treePath
+ return &api.FileResponse{
+ Content: &api.ContentsResponse{
+ Name: filepath.Base(treePath),
+ Path: treePath,
+ SHA: sha,
+ LastCommitSHA: lastCommitSHA,
+ Type: "file",
+ Size: 20,
+ Encoding: &encoding,
+ Content: &content,
+ URL: &selfURL,
+ HTMLURL: &htmlURL,
+ GitURL: &gitURL,
+ DownloadURL: &downloadURL,
+ Links: &api.FileLinksResponse{
+ Self: &selfURL,
+ GitURL: &gitURL,
+ HTMLURL: &htmlURL,
+ },
+ },
+ Commit: &api.FileCommitResponse{
+ CommitMeta: api.CommitMeta{
+ URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/" + commitID,
+ SHA: commitID,
+ },
+ HTMLURL: setting.AppURL + "user2/repo1/commit/" + commitID,
+ Author: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "John Doe",
+ Email: "johndoe@example.com",
+ },
+ },
+ Committer: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "Anne Doe",
+ Email: "annedoe@example.com",
+ },
+ },
+ Message: "My update of README.md\n",
+ },
+ Verification: &api.PayloadCommitVerification{
+ Verified: false,
+ Reason: "gpg.error.not_signed_commit",
+ Signature: "",
+ Payload: "",
+ },
+ }
+}
+
+func TestAPIUpdateFile(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
+ repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
+ fileID := 0
+
+ // Get user2's token
+ session := loginUser(t, user2.Name)
+ token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ // Get user4's token
+ session = loginUser(t, user4.Name)
+ token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ // Test updating a file in repo1 which user2 owns, try both with branch and empty branch
+ for _, branch := range [...]string{
+ "master", // Branch
+ "", // Empty branch
+ } {
+ fileID++
+ treePath := fmt.Sprintf("update/file%d.txt", fileID)
+ createFile(user2, repo1, treePath)
+ updateFileOptions := getUpdateFileOptions()
+ updateFileOptions.BranchName = branch
+ req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &updateFileOptions).
+ AddTokenAuth(token2)
+ resp := MakeRequest(t, req, http.StatusOK)
+ gitRepo, _ := gitrepo.OpenRepository(stdCtx.Background(), repo1)
+ commitID, _ := gitRepo.GetBranchCommitID(updateFileOptions.NewBranchName)
+ lasCommit, _ := gitRepo.GetCommitByPath(treePath)
+ expectedFileResponse := getExpectedFileResponseForUpdate(commitID, treePath, lasCommit.ID.String())
+ var fileResponse api.FileResponse
+ DecodeJSON(t, resp, &fileResponse)
+ assert.EqualValues(t, expectedFileResponse.Content, fileResponse.Content)
+ assert.EqualValues(t, expectedFileResponse.Commit.SHA, fileResponse.Commit.SHA)
+ assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, fileResponse.Commit.HTMLURL)
+ assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, fileResponse.Commit.Author.Email)
+ assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, fileResponse.Commit.Author.Name)
+ gitRepo.Close()
+ }
+
+ // Test updating a file in a new branch
+ updateFileOptions := getUpdateFileOptions()
+ updateFileOptions.BranchName = repo1.DefaultBranch
+ updateFileOptions.NewBranchName = "new_branch"
+ fileID++
+ treePath := fmt.Sprintf("update/file%d.txt", fileID)
+ createFile(user2, repo1, treePath)
+ req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &updateFileOptions).
+ AddTokenAuth(token2)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var fileResponse api.FileResponse
+ DecodeJSON(t, resp, &fileResponse)
+ expectedSHA := "08bd14b2e2852529157324de9c226b3364e76136"
+ expectedHTMLURL := fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/new_branch/update/file%d.txt", fileID)
+ expectedDownloadURL := fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/new_branch/update/file%d.txt", fileID)
+ assert.EqualValues(t, expectedSHA, fileResponse.Content.SHA)
+ assert.EqualValues(t, expectedHTMLURL, *fileResponse.Content.HTMLURL)
+ assert.EqualValues(t, expectedDownloadURL, *fileResponse.Content.DownloadURL)
+ assert.EqualValues(t, updateFileOptions.Message+"\n", fileResponse.Commit.Message)
+
+ // Test updating a file and renaming it
+ updateFileOptions = getUpdateFileOptions()
+ updateFileOptions.BranchName = repo1.DefaultBranch
+ fileID++
+ treePath = fmt.Sprintf("update/file%d.txt", fileID)
+ createFile(user2, repo1, treePath)
+ updateFileOptions.FromPath = treePath
+ treePath = "rename/" + treePath
+ req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &updateFileOptions).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &fileResponse)
+ expectedSHA = "08bd14b2e2852529157324de9c226b3364e76136"
+ expectedHTMLURL = fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/master/rename/update/file%d.txt", fileID)
+ expectedDownloadURL = fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/master/rename/update/file%d.txt", fileID)
+ assert.EqualValues(t, expectedSHA, fileResponse.Content.SHA)
+ assert.EqualValues(t, expectedHTMLURL, *fileResponse.Content.HTMLURL)
+ assert.EqualValues(t, expectedDownloadURL, *fileResponse.Content.DownloadURL)
+
+ // Test updating a file without a message
+ updateFileOptions = getUpdateFileOptions()
+ updateFileOptions.Message = ""
+ updateFileOptions.BranchName = repo1.DefaultBranch
+ fileID++
+ treePath = fmt.Sprintf("update/file%d.txt", fileID)
+ createFile(user2, repo1, treePath)
+ req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &updateFileOptions).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &fileResponse)
+ expectedMessage := "Update " + treePath + "\n"
+ assert.EqualValues(t, expectedMessage, fileResponse.Commit.Message)
+
+ // Test updating a file with the wrong SHA
+ fileID++
+ treePath = fmt.Sprintf("update/file%d.txt", fileID)
+ createFile(user2, repo1, treePath)
+ updateFileOptions = getUpdateFileOptions()
+ correctSHA := updateFileOptions.SHA
+ updateFileOptions.SHA = "badsha"
+ req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &updateFileOptions).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusUnprocessableEntity)
+ expectedAPIError := context.APIError{
+ Message: "sha does not match [given: " + updateFileOptions.SHA + ", expected: " + correctSHA + "]",
+ URL: setting.API.SwaggerURL,
+ }
+ var apiError context.APIError
+ DecodeJSON(t, resp, &apiError)
+ assert.Equal(t, expectedAPIError, apiError)
+
+ // Test creating a file in repo1 by user4 who does not have write access
+ fileID++
+ treePath = fmt.Sprintf("update/file%d.txt", fileID)
+ createFile(user2, repo16, treePath)
+ updateFileOptions = getUpdateFileOptions()
+ req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &updateFileOptions).
+ AddTokenAuth(token4)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Tests a repo with no token given so will fail
+ fileID++
+ treePath = fmt.Sprintf("update/file%d.txt", fileID)
+ createFile(user2, repo16, treePath)
+ updateFileOptions = getUpdateFileOptions()
+ req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &updateFileOptions)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test using access token for a private repo that the user of the token owns
+ fileID++
+ treePath = fmt.Sprintf("update/file%d.txt", fileID)
+ createFile(user2, repo16, treePath)
+ updateFileOptions = getUpdateFileOptions()
+ req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath), &updateFileOptions).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusOK)
+
+ // Test using org repo "org3/repo3" where user2 is a collaborator
+ fileID++
+ treePath = fmt.Sprintf("update/file%d.txt", fileID)
+ createFile(org3, repo3, treePath)
+ updateFileOptions = getUpdateFileOptions()
+ req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath), &updateFileOptions).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusOK)
+
+ // Test using org repo "org3/repo3" with no user token
+ fileID++
+ treePath = fmt.Sprintf("update/file%d.txt", fileID)
+ createFile(org3, repo3, treePath)
+ updateFileOptions = getUpdateFileOptions()
+ req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath), &updateFileOptions)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test using repo "user2/repo1" where user4 is a NOT collaborator
+ fileID++
+ treePath = fmt.Sprintf("update/file%d.txt", fileID)
+ createFile(user2, repo1, treePath)
+ updateFileOptions = getUpdateFileOptions()
+ req = NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath), &updateFileOptions).
+ AddTokenAuth(token4)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+}
diff --git a/tests/integration/api_repo_files_change_test.go b/tests/integration/api_repo_files_change_test.go
new file mode 100644
index 0000000..fb3ae5e
--- /dev/null
+++ b/tests/integration/api_repo_files_change_test.go
@@ -0,0 +1,311 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ stdCtx "context"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "net/url"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/context"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func getChangeFilesOptions() *api.ChangeFilesOptions {
+ newContent := "This is new text"
+ updateContent := "This is updated text"
+ newContentEncoded := base64.StdEncoding.EncodeToString([]byte(newContent))
+ updateContentEncoded := base64.StdEncoding.EncodeToString([]byte(updateContent))
+ return &api.ChangeFilesOptions{
+ FileOptions: api.FileOptions{
+ BranchName: "master",
+ NewBranchName: "master",
+ Message: "My update of new/file.txt",
+ Author: api.Identity{
+ Name: "Anne Doe",
+ Email: "annedoe@example.com",
+ },
+ Committer: api.Identity{
+ Name: "John Doe",
+ Email: "johndoe@example.com",
+ },
+ },
+ Files: []*api.ChangeFileOperation{
+ {
+ Operation: "create",
+ ContentBase64: newContentEncoded,
+ },
+ {
+ Operation: "update",
+ ContentBase64: updateContentEncoded,
+ SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
+ },
+ {
+ Operation: "delete",
+ SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
+ },
+ },
+ }
+}
+
+func TestAPIChangeFiles(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
+ repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
+ fileID := 0
+
+ // Get user2's token
+ session := loginUser(t, user2.Name)
+ token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ // Get user4's token
+ session = loginUser(t, user4.Name)
+ token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ // Test changing files in repo1 which user2 owns, try both with branch and empty branch
+ for _, branch := range [...]string{
+ "master", // Branch
+ "", // Empty branch
+ } {
+ fileID++
+ createTreePath := fmt.Sprintf("new/file%d.txt", fileID)
+ updateTreePath := fmt.Sprintf("update/file%d.txt", fileID)
+ deleteTreePath := fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(user2, repo1, updateTreePath)
+ createFile(user2, repo1, deleteTreePath)
+ changeFilesOptions := getChangeFilesOptions()
+ changeFilesOptions.BranchName = branch
+ changeFilesOptions.Files[0].Path = createTreePath
+ changeFilesOptions.Files[1].Path = updateTreePath
+ changeFilesOptions.Files[2].Path = deleteTreePath
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo1.Name), &changeFilesOptions).
+ AddTokenAuth(token2)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ gitRepo, _ := gitrepo.OpenRepository(stdCtx.Background(), repo1)
+ commitID, _ := gitRepo.GetBranchCommitID(changeFilesOptions.NewBranchName)
+ createLasCommit, _ := gitRepo.GetCommitByPath(createTreePath)
+ updateLastCommit, _ := gitRepo.GetCommitByPath(updateTreePath)
+ expectedCreateFileResponse := getExpectedFileResponseForCreate(fmt.Sprintf("%v/%v", user2.Name, repo1.Name), commitID, createTreePath, createLasCommit.ID.String())
+ expectedUpdateFileResponse := getExpectedFileResponseForUpdate(commitID, updateTreePath, updateLastCommit.ID.String())
+ var filesResponse api.FilesResponse
+ DecodeJSON(t, resp, &filesResponse)
+
+ // check create file
+ assert.EqualValues(t, expectedCreateFileResponse.Content, filesResponse.Files[0])
+
+ // check update file
+ assert.EqualValues(t, expectedUpdateFileResponse.Content, filesResponse.Files[1])
+
+ // test commit info
+ assert.EqualValues(t, expectedCreateFileResponse.Commit.SHA, filesResponse.Commit.SHA)
+ assert.EqualValues(t, expectedCreateFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL)
+ assert.EqualValues(t, expectedCreateFileResponse.Commit.Author.Email, filesResponse.Commit.Author.Email)
+ assert.EqualValues(t, expectedCreateFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name)
+ assert.EqualValues(t, expectedCreateFileResponse.Commit.Committer.Email, filesResponse.Commit.Committer.Email)
+ assert.EqualValues(t, expectedCreateFileResponse.Commit.Committer.Name, filesResponse.Commit.Committer.Name)
+
+ // test delete file
+ assert.Nil(t, filesResponse.Files[2])
+
+ gitRepo.Close()
+ }
+
+ // Test changing files in a new branch
+ changeFilesOptions := getChangeFilesOptions()
+ changeFilesOptions.BranchName = repo1.DefaultBranch
+ changeFilesOptions.NewBranchName = "new_branch"
+ fileID++
+ createTreePath := fmt.Sprintf("new/file%d.txt", fileID)
+ updateTreePath := fmt.Sprintf("update/file%d.txt", fileID)
+ deleteTreePath := fmt.Sprintf("delete/file%d.txt", fileID)
+ changeFilesOptions.Files[0].Path = createTreePath
+ changeFilesOptions.Files[1].Path = updateTreePath
+ changeFilesOptions.Files[2].Path = deleteTreePath
+ createFile(user2, repo1, updateTreePath)
+ createFile(user2, repo1, deleteTreePath)
+ url := fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo1.Name)
+ req := NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
+ AddTokenAuth(token2)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var filesResponse api.FilesResponse
+ DecodeJSON(t, resp, &filesResponse)
+ expectedCreateSHA := "a635aa942442ddfdba07468cf9661c08fbdf0ebf"
+ expectedCreateHTMLURL := fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/new_branch/new/file%d.txt", fileID)
+ expectedCreateDownloadURL := fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/new_branch/new/file%d.txt", fileID)
+ expectedUpdateSHA := "08bd14b2e2852529157324de9c226b3364e76136"
+ expectedUpdateHTMLURL := fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/new_branch/update/file%d.txt", fileID)
+ expectedUpdateDownloadURL := fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/new_branch/update/file%d.txt", fileID)
+ assert.EqualValues(t, expectedCreateSHA, filesResponse.Files[0].SHA)
+ assert.EqualValues(t, expectedCreateHTMLURL, *filesResponse.Files[0].HTMLURL)
+ assert.EqualValues(t, expectedCreateDownloadURL, *filesResponse.Files[0].DownloadURL)
+ assert.EqualValues(t, expectedUpdateSHA, filesResponse.Files[1].SHA)
+ assert.EqualValues(t, expectedUpdateHTMLURL, *filesResponse.Files[1].HTMLURL)
+ assert.EqualValues(t, expectedUpdateDownloadURL, *filesResponse.Files[1].DownloadURL)
+ assert.Nil(t, filesResponse.Files[2])
+
+ assert.EqualValues(t, changeFilesOptions.Message+"\n", filesResponse.Commit.Message)
+
+ // Test updating a file and renaming it
+ changeFilesOptions = getChangeFilesOptions()
+ changeFilesOptions.BranchName = repo1.DefaultBranch
+ fileID++
+ updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
+ createFile(user2, repo1, updateTreePath)
+ changeFilesOptions.Files = []*api.ChangeFileOperation{changeFilesOptions.Files[1]}
+ changeFilesOptions.Files[0].FromPath = updateTreePath
+ changeFilesOptions.Files[0].Path = "rename/" + updateTreePath
+ req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, &filesResponse)
+ expectedUpdateSHA = "08bd14b2e2852529157324de9c226b3364e76136"
+ expectedUpdateHTMLURL = fmt.Sprintf(setting.AppURL+"user2/repo1/src/branch/master/rename/update/file%d.txt", fileID)
+ expectedUpdateDownloadURL = fmt.Sprintf(setting.AppURL+"user2/repo1/raw/branch/master/rename/update/file%d.txt", fileID)
+ assert.EqualValues(t, expectedUpdateSHA, filesResponse.Files[0].SHA)
+ assert.EqualValues(t, expectedUpdateHTMLURL, *filesResponse.Files[0].HTMLURL)
+ assert.EqualValues(t, expectedUpdateDownloadURL, *filesResponse.Files[0].DownloadURL)
+
+ // Test updating a file without a message
+ changeFilesOptions = getChangeFilesOptions()
+ changeFilesOptions.Message = ""
+ changeFilesOptions.BranchName = repo1.DefaultBranch
+ fileID++
+ createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
+ updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
+ deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ changeFilesOptions.Files[0].Path = createTreePath
+ changeFilesOptions.Files[1].Path = updateTreePath
+ changeFilesOptions.Files[2].Path = deleteTreePath
+ createFile(user2, repo1, updateTreePath)
+ createFile(user2, repo1, deleteTreePath)
+ req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, &filesResponse)
+ expectedMessage := fmt.Sprintf("Add %v\nUpdate %v\nDelete %v\n", createTreePath, updateTreePath, deleteTreePath)
+ assert.EqualValues(t, expectedMessage, filesResponse.Commit.Message)
+
+ // Test updating a file with the wrong SHA
+ fileID++
+ updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
+ createFile(user2, repo1, updateTreePath)
+ changeFilesOptions = getChangeFilesOptions()
+ changeFilesOptions.Files = []*api.ChangeFileOperation{changeFilesOptions.Files[1]}
+ changeFilesOptions.Files[0].Path = updateTreePath
+ correctSHA := changeFilesOptions.Files[0].SHA
+ changeFilesOptions.Files[0].SHA = "badsha"
+ req = NewRequestWithJSON(t, "POST", url, &changeFilesOptions).
+ AddTokenAuth(token2)
+ resp = MakeRequest(t, req, http.StatusUnprocessableEntity)
+ expectedAPIError := context.APIError{
+ Message: "sha does not match [given: " + changeFilesOptions.Files[0].SHA + ", expected: " + correctSHA + "]",
+ URL: setting.API.SwaggerURL,
+ }
+ var apiError context.APIError
+ DecodeJSON(t, resp, &apiError)
+ assert.Equal(t, expectedAPIError, apiError)
+
+ // Test creating a file in repo1 by user4 who does not have write access
+ fileID++
+ createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
+ updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
+ deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(user2, repo16, updateTreePath)
+ createFile(user2, repo16, deleteTreePath)
+ changeFilesOptions = getChangeFilesOptions()
+ changeFilesOptions.Files[0].Path = createTreePath
+ changeFilesOptions.Files[1].Path = updateTreePath
+ changeFilesOptions.Files[2].Path = deleteTreePath
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo16.Name), &changeFilesOptions).
+ AddTokenAuth(token4)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Tests a repo with no token given so will fail
+ fileID++
+ createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
+ updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
+ deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(user2, repo16, updateTreePath)
+ createFile(user2, repo16, deleteTreePath)
+ changeFilesOptions = getChangeFilesOptions()
+ changeFilesOptions.Files[0].Path = createTreePath
+ changeFilesOptions.Files[1].Path = updateTreePath
+ changeFilesOptions.Files[2].Path = deleteTreePath
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo16.Name), &changeFilesOptions)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test using access token for a private repo that the user of the token owns
+ fileID++
+ createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
+ updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
+ deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(user2, repo16, updateTreePath)
+ createFile(user2, repo16, deleteTreePath)
+ changeFilesOptions = getChangeFilesOptions()
+ changeFilesOptions.Files[0].Path = createTreePath
+ changeFilesOptions.Files[1].Path = updateTreePath
+ changeFilesOptions.Files[2].Path = deleteTreePath
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo16.Name), &changeFilesOptions).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusCreated)
+
+ // Test using org repo "org3/repo3" where user2 is a collaborator
+ fileID++
+ createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
+ updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
+ deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(org3, repo3, updateTreePath)
+ createFile(org3, repo3, deleteTreePath)
+ changeFilesOptions = getChangeFilesOptions()
+ changeFilesOptions.Files[0].Path = createTreePath
+ changeFilesOptions.Files[1].Path = updateTreePath
+ changeFilesOptions.Files[2].Path = deleteTreePath
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", org3.Name, repo3.Name), &changeFilesOptions).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusCreated)
+
+ // Test using org repo "org3/repo3" with no user token
+ fileID++
+ createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
+ updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
+ deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(org3, repo3, updateTreePath)
+ createFile(org3, repo3, deleteTreePath)
+ changeFilesOptions = getChangeFilesOptions()
+ changeFilesOptions.Files[0].Path = createTreePath
+ changeFilesOptions.Files[1].Path = updateTreePath
+ changeFilesOptions.Files[2].Path = deleteTreePath
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", org3.Name, repo3.Name), &changeFilesOptions)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test using repo "user2/repo1" where user4 is a NOT collaborator
+ fileID++
+ createTreePath = fmt.Sprintf("new/file%d.txt", fileID)
+ updateTreePath = fmt.Sprintf("update/file%d.txt", fileID)
+ deleteTreePath = fmt.Sprintf("delete/file%d.txt", fileID)
+ createFile(user2, repo1, updateTreePath)
+ createFile(user2, repo1, deleteTreePath)
+ changeFilesOptions = getChangeFilesOptions()
+ changeFilesOptions.Files[0].Path = createTreePath
+ changeFilesOptions.Files[1].Path = updateTreePath
+ changeFilesOptions.Files[2].Path = deleteTreePath
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/contents", user2.Name, repo1.Name), &changeFilesOptions).
+ AddTokenAuth(token4)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+}
diff --git a/tests/integration/api_repo_get_contents_list_test.go b/tests/integration/api_repo_get_contents_list_test.go
new file mode 100644
index 0000000..e76ccd9
--- /dev/null
+++ b/tests/integration/api_repo_get_contents_list_test.go
@@ -0,0 +1,172 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ repo_service "code.gitea.io/gitea/services/repository"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func getExpectedContentsListResponseForContents(ref, refType, lastCommitSHA string) []*api.ContentsResponse {
+ treePath := "README.md"
+ sha := "4b4851ad51df6a7d9f25c979345979eaeb5b349f"
+ selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=" + ref
+ htmlURL := setting.AppURL + "user2/repo1/src/" + refType + "/" + ref + "/" + treePath
+ gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/" + sha
+ downloadURL := setting.AppURL + "user2/repo1/raw/" + refType + "/" + ref + "/" + treePath
+ return []*api.ContentsResponse{
+ {
+ Name: filepath.Base(treePath),
+ Path: treePath,
+ SHA: sha,
+ LastCommitSHA: lastCommitSHA,
+ Type: "file",
+ Size: 30,
+ URL: &selfURL,
+ HTMLURL: &htmlURL,
+ GitURL: &gitURL,
+ DownloadURL: &downloadURL,
+ Links: &api.FileLinksResponse{
+ Self: &selfURL,
+ GitURL: &gitURL,
+ HTMLURL: &htmlURL,
+ },
+ },
+ }
+}
+
+func TestAPIGetContentsList(t *testing.T) {
+ onGiteaRun(t, testAPIGetContentsList)
+}
+
+func testAPIGetContentsList(t *testing.T, u *url.URL) {
+ /*** SETUP ***/
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
+ repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
+ treePath := "" // root dir
+
+ // Get user2's token
+ session := loginUser(t, user2.Name)
+ token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+ // Get user4's token
+ session = loginUser(t, user4.Name)
+ token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ // Get the commit ID of the default branch
+ gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo1)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ // Make a new branch in repo1
+ newBranch := "test_branch"
+ err = repo_service.CreateNewBranch(git.DefaultContext, user2, repo1, gitRepo, repo1.DefaultBranch, newBranch)
+ require.NoError(t, err)
+
+ commitID, _ := gitRepo.GetBranchCommitID(repo1.DefaultBranch)
+ // Make a new tag in repo1
+ newTag := "test_tag"
+ err = gitRepo.CreateTag(newTag, commitID)
+ require.NoError(t, err)
+ /*** END SETUP ***/
+
+ // ref is default ref
+ ref := repo1.DefaultBranch
+ refType := "branch"
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var contentsListResponse []*api.ContentsResponse
+ DecodeJSON(t, resp, &contentsListResponse)
+ assert.NotNil(t, contentsListResponse)
+ lastCommit, err := gitRepo.GetCommitByPath("README.md")
+ require.NoError(t, err)
+ expectedContentsListResponse := getExpectedContentsListResponseForContents(ref, refType, lastCommit.ID.String())
+ assert.EqualValues(t, expectedContentsListResponse, contentsListResponse)
+
+ // No ref
+ refType = "branch"
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &contentsListResponse)
+ assert.NotNil(t, contentsListResponse)
+
+ expectedContentsListResponse = getExpectedContentsListResponseForContents(repo1.DefaultBranch, refType, lastCommit.ID.String())
+ assert.EqualValues(t, expectedContentsListResponse, contentsListResponse)
+
+ // ref is the branch we created above in setup
+ ref = newBranch
+ refType = "branch"
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &contentsListResponse)
+ assert.NotNil(t, contentsListResponse)
+ branchCommit, err := gitRepo.GetBranchCommit(ref)
+ require.NoError(t, err)
+ lastCommit, err = branchCommit.GetCommitByPath("README.md")
+ require.NoError(t, err)
+ expectedContentsListResponse = getExpectedContentsListResponseForContents(ref, refType, lastCommit.ID.String())
+ assert.EqualValues(t, expectedContentsListResponse, contentsListResponse)
+
+ // ref is the new tag we created above in setup
+ ref = newTag
+ refType = "tag"
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &contentsListResponse)
+ assert.NotNil(t, contentsListResponse)
+ tagCommit, err := gitRepo.GetTagCommit(ref)
+ require.NoError(t, err)
+ lastCommit, err = tagCommit.GetCommitByPath("README.md")
+ require.NoError(t, err)
+ expectedContentsListResponse = getExpectedContentsListResponseForContents(ref, refType, lastCommit.ID.String())
+ assert.EqualValues(t, expectedContentsListResponse, contentsListResponse)
+
+ // ref is a commit
+ ref = commitID
+ refType = "commit"
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &contentsListResponse)
+ assert.NotNil(t, contentsListResponse)
+ expectedContentsListResponse = getExpectedContentsListResponseForContents(ref, refType, commitID)
+ assert.EqualValues(t, expectedContentsListResponse, contentsListResponse)
+
+ // Test file contents a file with a bad ref
+ ref = "badref"
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test accessing private ref with user token that does not have access - should fail
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath).
+ AddTokenAuth(token4)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test access private ref of owner of token
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/readme.md", user2.Name, repo16.Name).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusOK)
+
+ // Test access of org org3 private repo file by owner user2
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusOK)
+}
diff --git a/tests/integration/api_repo_get_contents_test.go b/tests/integration/api_repo_get_contents_test.go
new file mode 100644
index 0000000..cb321b8
--- /dev/null
+++ b/tests/integration/api_repo_get_contents_test.go
@@ -0,0 +1,196 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "io"
+ "net/http"
+ "net/url"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ repo_service "code.gitea.io/gitea/services/repository"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func getExpectedContentsResponseForContents(ref, refType, lastCommitSHA string) *api.ContentsResponse {
+ treePath := "README.md"
+ sha := "4b4851ad51df6a7d9f25c979345979eaeb5b349f"
+ encoding := "base64"
+ content := "IyByZXBvMQoKRGVzY3JpcHRpb24gZm9yIHJlcG8x"
+ selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=" + ref
+ htmlURL := setting.AppURL + "user2/repo1/src/" + refType + "/" + ref + "/" + treePath
+ gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/" + sha
+ downloadURL := setting.AppURL + "user2/repo1/raw/" + refType + "/" + ref + "/" + treePath
+ return &api.ContentsResponse{
+ Name: treePath,
+ Path: treePath,
+ SHA: sha,
+ LastCommitSHA: lastCommitSHA,
+ Type: "file",
+ Size: 30,
+ Encoding: &encoding,
+ Content: &content,
+ URL: &selfURL,
+ HTMLURL: &htmlURL,
+ GitURL: &gitURL,
+ DownloadURL: &downloadURL,
+ Links: &api.FileLinksResponse{
+ Self: &selfURL,
+ GitURL: &gitURL,
+ HTMLURL: &htmlURL,
+ },
+ }
+}
+
+func TestAPIGetContents(t *testing.T) {
+ onGiteaRun(t, testAPIGetContents)
+}
+
+func testAPIGetContents(t *testing.T, u *url.URL) {
+ /*** SETUP ***/
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3, is an org
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
+ repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
+ treePath := "README.md"
+
+ // Get user2's token
+ session := loginUser(t, user2.Name)
+ token2 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+ // Get user4's token
+ session = loginUser(t, user4.Name)
+ token4 := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ // Get the commit ID of the default branch
+ gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo1)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ // Make a new branch in repo1
+ newBranch := "test_branch"
+ err = repo_service.CreateNewBranch(git.DefaultContext, user2, repo1, gitRepo, repo1.DefaultBranch, newBranch)
+ require.NoError(t, err)
+
+ commitID, err := gitRepo.GetBranchCommitID(repo1.DefaultBranch)
+ require.NoError(t, err)
+ // Make a new tag in repo1
+ newTag := "test_tag"
+ err = gitRepo.CreateTag(newTag, commitID)
+ require.NoError(t, err)
+ /*** END SETUP ***/
+
+ // ref is default ref
+ ref := repo1.DefaultBranch
+ refType := "branch"
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var contentsResponse api.ContentsResponse
+ DecodeJSON(t, resp, &contentsResponse)
+ assert.NotNil(t, contentsResponse)
+ lastCommit, _ := gitRepo.GetCommitByPath("README.md")
+ expectedContentsResponse := getExpectedContentsResponseForContents(ref, refType, lastCommit.ID.String())
+ assert.EqualValues(t, *expectedContentsResponse, contentsResponse)
+
+ // No ref
+ refType = "branch"
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo1.Name, treePath)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &contentsResponse)
+ assert.NotNil(t, contentsResponse)
+ expectedContentsResponse = getExpectedContentsResponseForContents(repo1.DefaultBranch, refType, lastCommit.ID.String())
+ assert.EqualValues(t, *expectedContentsResponse, contentsResponse)
+
+ // ref is the branch we created above in setup
+ ref = newBranch
+ refType = "branch"
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &contentsResponse)
+ assert.NotNil(t, contentsResponse)
+ branchCommit, _ := gitRepo.GetBranchCommit(ref)
+ lastCommit, _ = branchCommit.GetCommitByPath("README.md")
+ expectedContentsResponse = getExpectedContentsResponseForContents(ref, refType, lastCommit.ID.String())
+ assert.EqualValues(t, *expectedContentsResponse, contentsResponse)
+
+ // ref is the new tag we created above in setup
+ ref = newTag
+ refType = "tag"
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &contentsResponse)
+ assert.NotNil(t, contentsResponse)
+ tagCommit, _ := gitRepo.GetTagCommit(ref)
+ lastCommit, _ = tagCommit.GetCommitByPath("README.md")
+ expectedContentsResponse = getExpectedContentsResponseForContents(ref, refType, lastCommit.ID.String())
+ assert.EqualValues(t, *expectedContentsResponse, contentsResponse)
+
+ // ref is a commit
+ ref = commitID
+ refType = "commit"
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &contentsResponse)
+ assert.NotNil(t, contentsResponse)
+ expectedContentsResponse = getExpectedContentsResponseForContents(ref, refType, commitID)
+ assert.EqualValues(t, *expectedContentsResponse, contentsResponse)
+
+ // Test file contents a file with a bad ref
+ ref = "badref"
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s?ref=%s", user2.Name, repo1.Name, treePath, ref)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test accessing private ref with user token that does not have access - should fail
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", user2.Name, repo16.Name, treePath).
+ AddTokenAuth(token4)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test access private ref of owner of token
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/readme.md", user2.Name, repo16.Name).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusOK)
+
+ // Test access of org org3 private repo file by owner user2
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/contents/%s", org3.Name, repo3.Name, treePath).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusOK)
+}
+
+func TestAPIGetContentsRefFormats(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ file := "README.md"
+ sha := "65f1bf27bc3bf70f64657658635e66094edbcb4d"
+ content := "# repo1\n\nDescription for repo1"
+
+ noRef := setting.AppURL + "api/v1/repos/user2/repo1/raw/" + file
+ refInPath := setting.AppURL + "api/v1/repos/user2/repo1/raw/" + sha + "/" + file
+ refInQuery := setting.AppURL + "api/v1/repos/user2/repo1/raw/" + file + "?ref=" + sha
+
+ resp := MakeRequest(t, NewRequest(t, http.MethodGet, noRef), http.StatusOK)
+ raw, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ assert.EqualValues(t, content, string(raw))
+
+ resp = MakeRequest(t, NewRequest(t, http.MethodGet, refInPath), http.StatusOK)
+ raw, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ assert.EqualValues(t, content, string(raw))
+
+ resp = MakeRequest(t, NewRequest(t, http.MethodGet, refInQuery), http.StatusOK)
+ raw, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ assert.EqualValues(t, content, string(raw))
+ })
+}
diff --git a/tests/integration/api_repo_git_blobs_test.go b/tests/integration/api_repo_git_blobs_test.go
new file mode 100644
index 0000000..184362e
--- /dev/null
+++ b/tests/integration/api_repo_git_blobs_test.go
@@ -0,0 +1,80 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIReposGitBlobs(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
+ repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
+ repo1ReadmeSHA := "65f1bf27bc3bf70f64657658635e66094edbcb4d"
+ repo3ReadmeSHA := "d56a3073c1dbb7b15963110a049d50cdb5db99fc"
+ repo16ReadmeSHA := "f90451c72ef61a7645293d17b47be7a8e983da57"
+ badSHA := "0000000000000000000000000000000000000000"
+
+ // Login as User2.
+ session := loginUser(t, user2.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ // Test a public repo that anyone can GET the blob of
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", user2.Name, repo1.Name, repo1ReadmeSHA)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var gitBlobResponse api.GitBlobResponse
+ DecodeJSON(t, resp, &gitBlobResponse)
+ assert.NotNil(t, gitBlobResponse)
+ expectedContent := "dHJlZSAyYTJmMWQ0NjcwNzI4YTJlMTAwNDllMzQ1YmQ3YTI3NjQ2OGJlYWI2CmF1dGhvciB1c2VyMSA8YWRkcmVzczFAZXhhbXBsZS5jb20+IDE0ODk5NTY0NzkgLTA0MDAKY29tbWl0dGVyIEV0aGFuIEtvZW5pZyA8ZXRoYW50a29lbmlnQGdtYWlsLmNvbT4gMTQ4OTk1NjQ3OSAtMDQwMAoKSW5pdGlhbCBjb21taXQK"
+ assert.Equal(t, expectedContent, gitBlobResponse.Content)
+
+ // Tests a private repo with no token so will fail
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", user2.Name, repo16.Name, repo16ReadmeSHA)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Test using access token for a private repo that the user of the token owns
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", user2.Name, repo16.Name, repo16ReadmeSHA).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ // Test using bad sha
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", user2.Name, repo1.Name, badSHA)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ // Test using org repo "org3/repo3" where user2 is a collaborator
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", org3.Name, repo3.Name, repo3ReadmeSHA).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ // Test using org repo "org3/repo3" where user2 is a collaborator
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", org3.Name, repo3.Name, repo3ReadmeSHA).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ // Test using org repo "org3/repo3" with no user token
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/%s", org3.Name, repo3ReadmeSHA, repo3.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Login as User4.
+ session = loginUser(t, user4.Name)
+ token4 := getTokenForLoggedInUser(t, session)
+
+ // Test using org repo "org3/repo3" where user4 is a NOT collaborator
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/blobs/d56a3073c1dbb7b15963110a049d50cdb5db99fc?access=%s", org3.Name, repo3.Name, token4)
+ MakeRequest(t, req, http.StatusNotFound)
+}
diff --git a/tests/integration/api_repo_git_commits_test.go b/tests/integration/api_repo_git_commits_test.go
new file mode 100644
index 0000000..c4c626e
--- /dev/null
+++ b/tests/integration/api_repo_git_commits_test.go
@@ -0,0 +1,233 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func compareCommitFiles(t *testing.T, expect []string, files []*api.CommitAffectedFiles) {
+ var actual []string
+ for i := range files {
+ actual = append(actual, files[i].Filename)
+ }
+ assert.ElementsMatch(t, expect, actual)
+}
+
+func TestAPIReposGitCommits(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ // check invalid requests
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/commits/12345", user.Name).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/commits/..", user.Name).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/commits/branch-not-exist", user.Name).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ for _, ref := range [...]string{
+ "master", // Branch
+ "v1.1", // Tag
+ "65f1", // short sha
+ "65f1bf27bc3bf70f64657658635e66094edbcb4d", // full sha
+ } {
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/commits/%s", user.Name, ref).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ }
+}
+
+func TestAPIReposGitCommitList(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ // Test getting commits (Page 1)
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo20/commits?not=master&sha=remove-files-a", user.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiData []api.Commit
+ DecodeJSON(t, resp, &apiData)
+
+ assert.Len(t, apiData, 2)
+ assert.EqualValues(t, "cfe3b3c1fd36fba04f9183287b106497e1afe986", apiData[0].CommitMeta.SHA)
+ compareCommitFiles(t, []string{"link_hi", "test.csv"}, apiData[0].Files)
+ assert.EqualValues(t, "c8e31bc7688741a5287fcde4fbb8fc129ca07027", apiData[1].CommitMeta.SHA)
+ compareCommitFiles(t, []string{"test.csv"}, apiData[1].Files)
+
+ assert.EqualValues(t, "2", resp.Header().Get("X-Total"))
+}
+
+func TestAPIReposGitCommitListNotMaster(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ // Test getting commits (Page 1)
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits", user.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiData []api.Commit
+ DecodeJSON(t, resp, &apiData)
+
+ assert.Len(t, apiData, 3)
+ assert.EqualValues(t, "69554a64c1e6030f051e5c3f94bfbd773cd6a324", apiData[0].CommitMeta.SHA)
+ compareCommitFiles(t, []string{"readme.md"}, apiData[0].Files)
+ assert.EqualValues(t, "27566bd5738fc8b4e3fef3c5e72cce608537bd95", apiData[1].CommitMeta.SHA)
+ compareCommitFiles(t, []string{"readme.md"}, apiData[1].Files)
+ assert.EqualValues(t, "5099b81332712fe655e34e8dd63574f503f61811", apiData[2].CommitMeta.SHA)
+ compareCommitFiles(t, []string{"readme.md"}, apiData[2].Files)
+
+ assert.EqualValues(t, "3", resp.Header().Get("X-Total"))
+}
+
+func TestAPIReposGitCommitListPage2Empty(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ // Test getting commits (Page=2)
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits?page=2", user.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiData []api.Commit
+ DecodeJSON(t, resp, &apiData)
+
+ assert.Empty(t, apiData)
+}
+
+func TestAPIReposGitCommitListDifferentBranch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ // Test getting commits (Page=1, Branch=good-sign)
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits?sha=good-sign", user.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiData []api.Commit
+ DecodeJSON(t, resp, &apiData)
+
+ assert.Len(t, apiData, 1)
+ assert.Equal(t, "f27c2b2b03dcab38beaf89b0ab4ff61f6de63441", apiData[0].CommitMeta.SHA)
+ compareCommitFiles(t, []string{"readme.md"}, apiData[0].Files)
+}
+
+func TestAPIReposGitCommitListWithoutSelectFields(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ // Test getting commits without files, verification, and stats
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits?sha=good-sign&stat=false&files=false&verification=false", user.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiData []api.Commit
+ DecodeJSON(t, resp, &apiData)
+
+ assert.Len(t, apiData, 1)
+ assert.Equal(t, "f27c2b2b03dcab38beaf89b0ab4ff61f6de63441", apiData[0].CommitMeta.SHA)
+ assert.Equal(t, (*api.CommitStats)(nil), apiData[0].Stats)
+ assert.Equal(t, (*api.PayloadCommitVerification)(nil), apiData[0].RepoCommit.Verification)
+ assert.Equal(t, ([]*api.CommitAffectedFiles)(nil), apiData[0].Files)
+}
+
+func TestDownloadCommitDiffOrPatch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ // Test getting diff
+ reqDiff := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/git/commits/f27c2b2b03dcab38beaf89b0ab4ff61f6de63441.diff", user.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, reqDiff, http.StatusOK)
+ assert.EqualValues(t,
+ "commit f27c2b2b03dcab38beaf89b0ab4ff61f6de63441\nAuthor: User2 <user2@example.com>\nDate: Sun Aug 6 19:55:01 2017 +0200\n\n good signed commit\n\ndiff --git a/readme.md b/readme.md\nnew file mode 100644\nindex 0000000..458121c\n--- /dev/null\n+++ b/readme.md\n@@ -0,0 +1 @@\n+good sign\n",
+ resp.Body.String())
+
+ // Test getting patch
+ reqPatch := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/git/commits/f27c2b2b03dcab38beaf89b0ab4ff61f6de63441.patch", user.Name).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, reqPatch, http.StatusOK)
+ assert.EqualValues(t,
+ "From f27c2b2b03dcab38beaf89b0ab4ff61f6de63441 Mon Sep 17 00:00:00 2001\nFrom: User2 <user2@example.com>\nDate: Sun, 6 Aug 2017 19:55:01 +0200\nSubject: [PATCH] good signed commit\n\n---\n readme.md | 1 +\n 1 file changed, 1 insertion(+)\n create mode 100644 readme.md\n\ndiff --git a/readme.md b/readme.md\nnew file mode 100644\nindex 0000000..458121c\n--- /dev/null\n+++ b/readme.md\n@@ -0,0 +1 @@\n+good sign\n",
+ resp.Body.String())
+}
+
+func TestGetFileHistory(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo16/commits?path=readme.md&sha=good-sign", user.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiData []api.Commit
+ DecodeJSON(t, resp, &apiData)
+
+ assert.Len(t, apiData, 1)
+ assert.Equal(t, "f27c2b2b03dcab38beaf89b0ab4ff61f6de63441", apiData[0].CommitMeta.SHA)
+ compareCommitFiles(t, []string{"readme.md"}, apiData[0].Files)
+
+ assert.EqualValues(t, "1", resp.Header().Get("X-Total"))
+}
+
+func TestGetFileHistoryNotOnMaster(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo20/commits?path=test.csv&sha=add-csv&not=master", user.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiData []api.Commit
+ DecodeJSON(t, resp, &apiData)
+
+ assert.Len(t, apiData, 1)
+ assert.Equal(t, "c8e31bc7688741a5287fcde4fbb8fc129ca07027", apiData[0].CommitMeta.SHA)
+ compareCommitFiles(t, []string{"test.csv"}, apiData[0].Files)
+
+ assert.EqualValues(t, "1", resp.Header().Get("X-Total"))
+}
diff --git a/tests/integration/api_repo_git_hook_test.go b/tests/integration/api_repo_git_hook_test.go
new file mode 100644
index 0000000..9917b41
--- /dev/null
+++ b/tests/integration/api_repo_git_hook_test.go
@@ -0,0 +1,196 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+const testHookContent = `#!/bin/bash
+
+echo Hello, World!
+`
+
+func TestAPIListGitHooks(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ // user1 is an admin user
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git", owner.Name, repo.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiGitHooks []*api.GitHook
+ DecodeJSON(t, resp, &apiGitHooks)
+ assert.Len(t, apiGitHooks, 3)
+ for _, apiGitHook := range apiGitHooks {
+ if apiGitHook.Name == "pre-receive" {
+ assert.True(t, apiGitHook.IsActive)
+ assert.Equal(t, testHookContent, apiGitHook.Content)
+ } else {
+ assert.False(t, apiGitHook.IsActive)
+ assert.Empty(t, apiGitHook.Content)
+ }
+ }
+}
+
+func TestAPIListGitHooksNoHooks(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ // user1 is an admin user
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git", owner.Name, repo.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiGitHooks []*api.GitHook
+ DecodeJSON(t, resp, &apiGitHooks)
+ assert.Len(t, apiGitHooks, 3)
+ for _, apiGitHook := range apiGitHooks {
+ assert.False(t, apiGitHook.IsActive)
+ assert.Empty(t, apiGitHook.Content)
+ }
+}
+
+func TestAPIListGitHooksNoAccess(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git", owner.Name, repo.Name).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+}
+
+func TestAPIGetGitHook(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ // user1 is an admin user
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiGitHook *api.GitHook
+ DecodeJSON(t, resp, &apiGitHook)
+ assert.True(t, apiGitHook.IsActive)
+ assert.Equal(t, testHookContent, apiGitHook.Content)
+}
+
+func TestAPIGetGitHookNoAccess(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+}
+
+func TestAPIEditGitHook(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ // user1 is an admin user
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/git/pre-receive",
+ owner.Name, repo.Name)
+ req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditGitHookOption{
+ Content: testHookContent,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiGitHook *api.GitHook
+ DecodeJSON(t, resp, &apiGitHook)
+ assert.True(t, apiGitHook.IsActive)
+ assert.Equal(t, testHookContent, apiGitHook.Content)
+
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var apiGitHook2 *api.GitHook
+ DecodeJSON(t, resp, &apiGitHook2)
+ assert.True(t, apiGitHook2.IsActive)
+ assert.Equal(t, testHookContent, apiGitHook2.Content)
+}
+
+func TestAPIEditGitHookNoAccess(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name)
+ req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditGitHookOption{
+ Content: testHookContent,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+}
+
+func TestAPIDeleteGitHook(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ // user1 is an admin user
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiGitHook2 *api.GitHook
+ DecodeJSON(t, resp, &apiGitHook2)
+ assert.False(t, apiGitHook2.IsActive)
+ assert.Empty(t, apiGitHook2.Content)
+}
+
+func TestAPIDeleteGitHookNoAccess(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/hooks/git/pre-receive", owner.Name, repo.Name).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+}
diff --git a/tests/integration/api_repo_git_notes_test.go b/tests/integration/api_repo_git_notes_test.go
new file mode 100644
index 0000000..9f3e927
--- /dev/null
+++ b/tests/integration/api_repo_git_notes_test.go
@@ -0,0 +1,46 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIReposGitNotes(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ // check invalid requests
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/notes/12345", user.Name).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/notes/..", user.Name).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ // check valid request
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/notes/65f1bf27bc3bf70f64657658635e66094edbcb4d", user.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiData api.Note
+ DecodeJSON(t, resp, &apiData)
+ assert.Equal(t, "This is a test note\n", apiData.Message)
+ assert.NotEmpty(t, apiData.Commit.Files)
+ assert.NotNil(t, apiData.Commit.RepoCommit.Verification)
+ })
+}
diff --git a/tests/integration/api_repo_git_ref_test.go b/tests/integration/api_repo_git_ref_test.go
new file mode 100644
index 0000000..875752a
--- /dev/null
+++ b/tests/integration/api_repo_git_ref_test.go
@@ -0,0 +1,39 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/tests"
+)
+
+func TestAPIReposGitRefs(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ for _, ref := range [...]string{
+ "refs/heads/master", // Branch
+ "refs/tags/v1.1", // Tag
+ } {
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/%s", user.Name, ref).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ }
+ // Test getting all refs
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/refs", user.Name).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ // Test getting non-existent refs
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/git/refs/heads/unknown", user.Name).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+}
diff --git a/tests/integration/api_repo_git_tags_test.go b/tests/integration/api_repo_git_tags_test.go
new file mode 100644
index 0000000..c5883a8
--- /dev/null
+++ b/tests/integration/api_repo_git_tags_test.go
@@ -0,0 +1,88 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIGitTags(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ // Set up git config for the tagger
+ _ = git.NewCommand(git.DefaultContext, "config", "user.name").AddDynamicArguments(user.Name).Run(&git.RunOpts{Dir: repo.RepoPath()})
+ _ = git.NewCommand(git.DefaultContext, "config", "user.email").AddDynamicArguments(user.Email).Run(&git.RunOpts{Dir: repo.RepoPath()})
+
+ gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo)
+ defer gitRepo.Close()
+
+ commit, _ := gitRepo.GetBranchCommit("master")
+ lTagName := "lightweightTag"
+ gitRepo.CreateTag(lTagName, commit.ID.String())
+
+ aTagName := "annotatedTag"
+ aTagMessage := "my annotated message"
+ gitRepo.CreateAnnotatedTag(aTagName, aTagMessage, commit.ID.String())
+ aTag, _ := gitRepo.GetTag(aTagName)
+
+ // SHOULD work for annotated tags
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/tags/%s", user.Name, repo.Name, aTag.ID.String()).
+ AddTokenAuth(token)
+ res := MakeRequest(t, req, http.StatusOK)
+
+ var tag *api.AnnotatedTag
+ DecodeJSON(t, res, &tag)
+
+ assert.Equal(t, aTagName, tag.Tag)
+ assert.Equal(t, aTag.ID.String(), tag.SHA)
+ assert.Equal(t, commit.ID.String(), tag.Object.SHA)
+ assert.Equal(t, aTagMessage+"\n", tag.Message)
+ assert.Equal(t, user.Name, tag.Tagger.Name)
+ assert.Equal(t, user.Email, tag.Tagger.Email)
+ assert.Equal(t, util.URLJoin(repo.APIURL(), "git/tags", aTag.ID.String()), tag.URL)
+
+ // Should NOT work for lightweight tags
+ badReq := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/tags/%s", user.Name, repo.Name, commit.ID.String()).
+ AddTokenAuth(token)
+ MakeRequest(t, badReq, http.StatusBadRequest)
+}
+
+func TestAPIDeleteTagByName(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, owner.LowerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ req := NewRequest(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/tags/delete-tag", owner.Name, repo.Name)).
+ AddTokenAuth(token)
+ _ = MakeRequest(t, req, http.StatusNoContent)
+
+ // Make sure that actual releases can't be deleted outright
+ createNewReleaseUsingAPI(t, token, owner, repo, "release-tag", "", "Release Tag", "test")
+
+ req = NewRequest(t, http.MethodDelete, fmt.Sprintf("/api/v1/repos/%s/%s/tags/release-tag", owner.Name, repo.Name)).
+ AddTokenAuth(token)
+ _ = MakeRequest(t, req, http.StatusConflict)
+}
diff --git a/tests/integration/api_repo_git_trees_test.go b/tests/integration/api_repo_git_trees_test.go
new file mode 100644
index 0000000..8eec6d8
--- /dev/null
+++ b/tests/integration/api_repo_git_trees_test.go
@@ -0,0 +1,77 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/tests"
+)
+
+func TestAPIReposGitTrees(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo1 & repo16
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of the repo3
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // owner of neither repos
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // public repo
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // public repo
+ repo16 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 16}) // private repo
+ repo1TreeSHA := "65f1bf27bc3bf70f64657658635e66094edbcb4d"
+ repo3TreeSHA := "2a47ca4b614a9f5a43abbd5ad851a54a616ffee6"
+ repo16TreeSHA := "69554a64c1e6030f051e5c3f94bfbd773cd6a324"
+ badSHA := "0000000000000000000000000000000000000000"
+
+ // Login as User2.
+ session := loginUser(t, user2.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ // Test a public repo that anyone can GET the tree of
+ for _, ref := range [...]string{
+ "master", // Branch
+ repo1TreeSHA, // Tree SHA
+ } {
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", user2.Name, repo1.Name, ref)
+ MakeRequest(t, req, http.StatusOK)
+ }
+
+ // Tests a private repo with no token so will fail
+ for _, ref := range [...]string{
+ "master", // Branch
+ repo1TreeSHA, // Tag
+ } {
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", user2.Name, repo16.Name, ref)
+ MakeRequest(t, req, http.StatusNotFound)
+ }
+
+ // Test using access token for a private repo that the user of the token owns
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", user2.Name, repo16.Name, repo16TreeSHA).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ // Test using bad sha
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", user2.Name, repo1.Name, badSHA)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ // Test using org repo "org3/repo3" where user2 is a collaborator
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", org3.Name, repo3.Name, repo3TreeSHA).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ // Test using org repo "org3/repo3" with no user token
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/%s", org3.Name, repo3TreeSHA, repo3.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Login as User4.
+ session = loginUser(t, user4.Name)
+ token4 := getTokenForLoggedInUser(t, session)
+
+ // Test using org repo "org3/repo3" where user4 is a NOT collaborator
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/git/trees/d56a3073c1dbb7b15963110a049d50cdb5db99fc?access=%s", org3.Name, repo3.Name, token4)
+ MakeRequest(t, req, http.StatusNotFound)
+}
diff --git a/tests/integration/api_repo_hook_test.go b/tests/integration/api_repo_hook_test.go
new file mode 100644
index 0000000..9ae8119
--- /dev/null
+++ b/tests/integration/api_repo_hook_test.go
@@ -0,0 +1,45 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPICreateHook(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 37})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ // user1 is an admin user
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/%s", owner.Name, repo.Name, "hooks"), api.CreateHookOption{
+ Type: "gitea",
+ Config: api.CreateHookOptionConfig{
+ "content_type": "json",
+ "url": "http://example.com/",
+ },
+ AuthorizationHeader: "Bearer s3cr3t",
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var apiHook *api.Hook
+ DecodeJSON(t, resp, &apiHook)
+ assert.Equal(t, "http://example.com/", apiHook.Config["url"])
+ assert.Equal(t, "http://example.com/", apiHook.URL)
+ assert.Equal(t, "Bearer s3cr3t", apiHook.AuthorizationHeader)
+}
diff --git a/tests/integration/api_repo_languages_test.go b/tests/integration/api_repo_languages_test.go
new file mode 100644
index 0000000..3572e2a
--- /dev/null
+++ b/tests/integration/api_repo_languages_test.go
@@ -0,0 +1,50 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRepoLanguages(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user2")
+
+ // Request editor page
+ req := NewRequest(t, "GET", "/user2/repo1/_new/master/")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ lastCommit := doc.GetInputValueByName("last_commit")
+ assert.NotEmpty(t, lastCommit)
+
+ // Save new file to master branch
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/_new/master/", map[string]string{
+ "_csrf": doc.GetCSRF(),
+ "last_commit": lastCommit,
+ "tree_path": "test.go",
+ "content": "package main",
+ "commit_choice": "direct",
+ "commit_mail_id": "3",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // let gitea calculate language stats
+ time.Sleep(time.Second)
+
+ // Save new file to master branch
+ req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/languages")
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ var languages map[string]int64
+ DecodeJSON(t, resp, &languages)
+
+ assert.InDeltaMapValues(t, map[string]int64{"Go": 12}, languages, 0)
+ })
+}
diff --git a/tests/integration/api_repo_lfs_locks_test.go b/tests/integration/api_repo_lfs_locks_test.go
new file mode 100644
index 0000000..4ba01e6
--- /dev/null
+++ b/tests/integration/api_repo_lfs_locks_test.go
@@ -0,0 +1,181 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/lfs"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPILFSLocksNotStarted(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ setting.LFS.StartServer = false
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ req := NewRequestf(t, "GET", "/%s/%s.git/info/lfs/locks", user.Name, repo.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequestf(t, "POST", "/%s/%s.git/info/lfs/locks", user.Name, repo.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequestf(t, "GET", "/%s/%s.git/info/lfs/locks/verify", user.Name, repo.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequestf(t, "GET", "/%s/%s.git/info/lfs/locks/10/unlock", user.Name, repo.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+}
+
+func TestAPILFSLocksNotLogin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ setting.LFS.StartServer = true
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ req := NewRequestf(t, "GET", "/%s/%s.git/info/lfs/locks", user.Name, repo.Name)
+ req.Header.Set("Accept", lfs.MediaType)
+ resp := MakeRequest(t, req, http.StatusUnauthorized)
+ var lfsLockError api.LFSLockError
+ DecodeJSON(t, resp, &lfsLockError)
+ assert.Equal(t, "You must have pull access to list locks", lfsLockError.Message)
+}
+
+func TestAPILFSLocksLogged(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ setting.LFS.StartServer = true
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // in org 3
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // in org 3
+
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // own by org 3
+
+ tests := []struct {
+ user *user_model.User
+ repo *repo_model.Repository
+ path string
+ httpResult int
+ addTime []int
+ }{
+ {user: user2, repo: repo1, path: "foo/bar.zip", httpResult: http.StatusCreated, addTime: []int{0}},
+ {user: user2, repo: repo1, path: "path/test", httpResult: http.StatusCreated, addTime: []int{0}},
+ {user: user2, repo: repo1, path: "path/test", httpResult: http.StatusConflict},
+ {user: user2, repo: repo1, path: "Foo/BaR.zip", httpResult: http.StatusConflict},
+ {user: user2, repo: repo1, path: "Foo/Test/../subFOlder/../Relative/../BaR.zip", httpResult: http.StatusConflict},
+ {user: user4, repo: repo1, path: "FoO/BaR.zip", httpResult: http.StatusUnauthorized},
+ {user: user4, repo: repo1, path: "path/test-user4", httpResult: http.StatusUnauthorized},
+ {user: user2, repo: repo1, path: "patH/Test-user4", httpResult: http.StatusCreated, addTime: []int{0}},
+ {user: user2, repo: repo1, path: "some/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/long/path", httpResult: http.StatusCreated, addTime: []int{0}},
+
+ {user: user2, repo: repo3, path: "test/foo/bar.zip", httpResult: http.StatusCreated, addTime: []int{1, 2}},
+ {user: user4, repo: repo3, path: "test/foo/bar.zip", httpResult: http.StatusConflict},
+ {user: user4, repo: repo3, path: "test/foo/bar.bin", httpResult: http.StatusCreated, addTime: []int{1, 2}},
+ }
+
+ resultsTests := []struct {
+ user *user_model.User
+ repo *repo_model.Repository
+ totalCount int
+ oursCount int
+ theirsCount int
+ locksOwners []*user_model.User
+ locksTimes []time.Time
+ }{
+ {user: user2, repo: repo1, totalCount: 4, oursCount: 4, theirsCount: 0, locksOwners: []*user_model.User{user2, user2, user2, user2}, locksTimes: []time.Time{}},
+ {user: user2, repo: repo3, totalCount: 2, oursCount: 1, theirsCount: 1, locksOwners: []*user_model.User{user2, user4}, locksTimes: []time.Time{}},
+ {user: user4, repo: repo3, totalCount: 2, oursCount: 1, theirsCount: 1, locksOwners: []*user_model.User{user2, user4}, locksTimes: []time.Time{}},
+ }
+
+ deleteTests := []struct {
+ user *user_model.User
+ repo *repo_model.Repository
+ lockID string
+ }{}
+
+ // create locks
+ for _, test := range tests {
+ session := loginUser(t, test.user.Name)
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks", test.repo.FullName()), map[string]string{"path": test.path})
+ req.Header.Set("Accept", lfs.AcceptHeader)
+ req.Header.Set("Content-Type", lfs.MediaType)
+ resp := session.MakeRequest(t, req, test.httpResult)
+ if len(test.addTime) > 0 {
+ var lfsLock api.LFSLockResponse
+ DecodeJSON(t, resp, &lfsLock)
+ assert.Equal(t, test.user.Name, lfsLock.Lock.Owner.Name)
+ assert.EqualValues(t, lfsLock.Lock.LockedAt.Format(time.RFC3339), lfsLock.Lock.LockedAt.Format(time.RFC3339Nano)) // locked at should be rounded to second
+ for _, id := range test.addTime {
+ resultsTests[id].locksTimes = append(resultsTests[id].locksTimes, time.Now())
+ }
+ }
+ }
+
+ // check creation
+ for _, test := range resultsTests {
+ session := loginUser(t, test.user.Name)
+ req := NewRequestf(t, "GET", "/%s.git/info/lfs/locks", test.repo.FullName())
+ req.Header.Set("Accept", lfs.AcceptHeader)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ var lfsLocks api.LFSLockList
+ DecodeJSON(t, resp, &lfsLocks)
+ assert.Len(t, lfsLocks.Locks, test.totalCount)
+ for i, lock := range lfsLocks.Locks {
+ assert.EqualValues(t, test.locksOwners[i].Name, lock.Owner.Name)
+ assert.WithinDuration(t, test.locksTimes[i], lock.LockedAt, 10*time.Second)
+ assert.EqualValues(t, lock.LockedAt.Format(time.RFC3339), lock.LockedAt.Format(time.RFC3339Nano)) // locked at should be rounded to second
+ }
+
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks/verify", test.repo.FullName()), map[string]string{})
+ req.Header.Set("Accept", lfs.AcceptHeader)
+ req.Header.Set("Content-Type", lfs.MediaType)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ var lfsLocksVerify api.LFSLockListVerify
+ DecodeJSON(t, resp, &lfsLocksVerify)
+ assert.Len(t, lfsLocksVerify.Ours, test.oursCount)
+ assert.Len(t, lfsLocksVerify.Theirs, test.theirsCount)
+ for _, lock := range lfsLocksVerify.Ours {
+ assert.EqualValues(t, test.user.Name, lock.Owner.Name)
+ deleteTests = append(deleteTests, struct {
+ user *user_model.User
+ repo *repo_model.Repository
+ lockID string
+ }{test.user, test.repo, lock.ID})
+ }
+ for _, lock := range lfsLocksVerify.Theirs {
+ assert.NotEqual(t, test.user.DisplayName(), lock.Owner.Name)
+ }
+ }
+
+ // remove all locks
+ for _, test := range deleteTests {
+ session := loginUser(t, test.user.Name)
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks/%s/unlock", test.repo.FullName(), test.lockID), map[string]string{})
+ req.Header.Set("Accept", lfs.AcceptHeader)
+ req.Header.Set("Content-Type", lfs.MediaType)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ var lfsLockRep api.LFSLockResponse
+ DecodeJSON(t, resp, &lfsLockRep)
+ assert.Equal(t, test.lockID, lfsLockRep.Lock.ID)
+ assert.Equal(t, test.user.Name, lfsLockRep.Lock.Owner.Name)
+ }
+
+ // check that we don't have any lock
+ for _, test := range resultsTests {
+ session := loginUser(t, test.user.Name)
+ req := NewRequestf(t, "GET", "/%s.git/info/lfs/locks", test.repo.FullName())
+ req.Header.Set("Accept", lfs.AcceptHeader)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ var lfsLocks api.LFSLockList
+ DecodeJSON(t, resp, &lfsLocks)
+ assert.Empty(t, lfsLocks.Locks)
+ }
+}
diff --git a/tests/integration/api_repo_lfs_migrate_test.go b/tests/integration/api_repo_lfs_migrate_test.go
new file mode 100644
index 0000000..de85b91
--- /dev/null
+++ b/tests/integration/api_repo_lfs_migrate_test.go
@@ -0,0 +1,55 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "path"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/lfs"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/migrations"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIRepoLFSMigrateLocal(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ oldImportLocalPaths := setting.ImportLocalPaths
+ oldAllowLocalNetworks := setting.Migrations.AllowLocalNetworks
+ setting.ImportLocalPaths = true
+ setting.Migrations.AllowLocalNetworks = true
+ require.NoError(t, migrations.Init())
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate", &api.MigrateRepoOptions{
+ CloneAddr: path.Join(setting.RepoRootPath, "migration/lfs-test.git"),
+ RepoOwnerID: user.ID,
+ RepoName: "lfs-test-local",
+ LFS: true,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, NoExpectedStatus)
+ assert.EqualValues(t, http.StatusCreated, resp.Code)
+
+ store := lfs.NewContentStore()
+ ok, _ := store.Verify(lfs.Pointer{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab041", Size: 6})
+ assert.True(t, ok)
+ ok, _ = store.Verify(lfs.Pointer{Oid: "d6f175817f886ec6fbbc1515326465fa96c3bfd54a4ea06cfd6dbbd8340e0152", Size: 6})
+ assert.True(t, ok)
+
+ setting.ImportLocalPaths = oldImportLocalPaths
+ setting.Migrations.AllowLocalNetworks = oldAllowLocalNetworks
+ require.NoError(t, migrations.Init()) // reset old migration settings
+}
diff --git a/tests/integration/api_repo_lfs_test.go b/tests/integration/api_repo_lfs_test.go
new file mode 100644
index 0000000..7a2a92d
--- /dev/null
+++ b/tests/integration/api_repo_lfs_test.go
@@ -0,0 +1,487 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "net/http"
+ "path"
+ "strconv"
+ "strings"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/lfs"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPILFSNotStarted(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.LFS.StartServer = false
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ req := NewRequestf(t, "POST", "/%s/%s.git/info/lfs/objects/batch", user.Name, repo.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequestf(t, "PUT", "/%s/%s.git/info/lfs/objects/oid/10", user.Name, repo.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequestf(t, "GET", "/%s/%s.git/info/lfs/objects/oid/name", user.Name, repo.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequestf(t, "GET", "/%s/%s.git/info/lfs/objects/oid", user.Name, repo.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequestf(t, "POST", "/%s/%s.git/info/lfs/verify", user.Name, repo.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+}
+
+func TestAPILFSMediaType(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.LFS.StartServer = true
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ req := NewRequestf(t, "POST", "/%s/%s.git/info/lfs/objects/batch", user.Name, repo.Name)
+ MakeRequest(t, req, http.StatusUnsupportedMediaType)
+ req = NewRequestf(t, "POST", "/%s/%s.git/info/lfs/verify", user.Name, repo.Name)
+ MakeRequest(t, req, http.StatusUnsupportedMediaType)
+}
+
+func createLFSTestRepository(t *testing.T, name string) *repo_model.Repository {
+ ctx := NewAPITestContext(t, "user2", "lfs-"+name+"-repo", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ t.Run("CreateRepo", doAPICreateRepository(ctx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat
+
+ repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "lfs-"+name+"-repo")
+ require.NoError(t, err)
+
+ return repo
+}
+
+func TestAPILFSBatch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.LFS.StartServer = true
+
+ repo := createLFSTestRepository(t, "batch")
+
+ content := []byte("dummy1")
+ oid := storeObjectInRepo(t, repo.ID, &content)
+ defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid)
+
+ session := loginUser(t, "user2")
+
+ newRequest := func(t testing.TB, br *lfs.BatchRequest) *RequestWrapper {
+ return NewRequestWithJSON(t, "POST", "/user2/lfs-batch-repo.git/info/lfs/objects/batch", br).
+ SetHeader("Accept", lfs.AcceptHeader).
+ SetHeader("Content-Type", lfs.MediaType)
+ }
+ decodeResponse := func(t *testing.T, b *bytes.Buffer) *lfs.BatchResponse {
+ var br lfs.BatchResponse
+
+ require.NoError(t, json.Unmarshal(b.Bytes(), &br))
+ return &br
+ }
+
+ t.Run("InvalidJsonRequest", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, nil)
+
+ session.MakeRequest(t, req, http.StatusBadRequest)
+ })
+
+ t.Run("InvalidOperation", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, &lfs.BatchRequest{
+ Operation: "dummy",
+ })
+
+ session.MakeRequest(t, req, http.StatusBadRequest)
+ })
+
+ t.Run("InvalidPointer", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, &lfs.BatchRequest{
+ Operation: "download",
+ Objects: []lfs.Pointer{
+ {Oid: "dummy"},
+ {Oid: oid, Size: -1},
+ },
+ })
+
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ br := decodeResponse(t, resp.Body)
+ assert.Len(t, br.Objects, 2)
+ assert.Equal(t, "dummy", br.Objects[0].Oid)
+ assert.Equal(t, oid, br.Objects[1].Oid)
+ assert.Equal(t, int64(0), br.Objects[0].Size)
+ assert.Equal(t, int64(-1), br.Objects[1].Size)
+ assert.NotNil(t, br.Objects[0].Error)
+ assert.NotNil(t, br.Objects[1].Error)
+ assert.Equal(t, http.StatusUnprocessableEntity, br.Objects[0].Error.Code)
+ assert.Equal(t, http.StatusUnprocessableEntity, br.Objects[1].Error.Code)
+ assert.Equal(t, "Oid or size are invalid", br.Objects[0].Error.Message)
+ assert.Equal(t, "Oid or size are invalid", br.Objects[1].Error.Message)
+ })
+
+ t.Run("PointerSizeMismatch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, &lfs.BatchRequest{
+ Operation: "download",
+ Objects: []lfs.Pointer{
+ {Oid: oid, Size: 1},
+ },
+ })
+
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ br := decodeResponse(t, resp.Body)
+ assert.Len(t, br.Objects, 1)
+ assert.NotNil(t, br.Objects[0].Error)
+ assert.Equal(t, http.StatusUnprocessableEntity, br.Objects[0].Error.Code)
+ assert.Equal(t, "Object "+oid+" is not 1 bytes", br.Objects[0].Error.Message)
+ })
+
+ t.Run("Download", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ t.Run("PointerNotInStore", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, &lfs.BatchRequest{
+ Operation: "download",
+ Objects: []lfs.Pointer{
+ {Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6},
+ },
+ })
+
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ br := decodeResponse(t, resp.Body)
+ assert.Len(t, br.Objects, 1)
+ assert.NotNil(t, br.Objects[0].Error)
+ assert.Equal(t, http.StatusNotFound, br.Objects[0].Error.Code)
+ })
+
+ t.Run("MetaNotFound", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ p := lfs.Pointer{Oid: "05eeb4eb5be71f2dd291ca39157d6d9effd7d1ea19cbdc8a99411fe2a8f26a00", Size: 6}
+
+ contentStore := lfs.NewContentStore()
+ exist, err := contentStore.Exists(p)
+ require.NoError(t, err)
+ assert.False(t, exist)
+ err = contentStore.Put(p, bytes.NewReader([]byte("dummy0")))
+ require.NoError(t, err)
+
+ req := newRequest(t, &lfs.BatchRequest{
+ Operation: "download",
+ Objects: []lfs.Pointer{p},
+ })
+
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ br := decodeResponse(t, resp.Body)
+ assert.Len(t, br.Objects, 1)
+ assert.NotNil(t, br.Objects[0].Error)
+ assert.Equal(t, http.StatusNotFound, br.Objects[0].Error.Code)
+ })
+
+ t.Run("Success", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, &lfs.BatchRequest{
+ Operation: "download",
+ Objects: []lfs.Pointer{
+ {Oid: oid, Size: 6},
+ },
+ })
+
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ br := decodeResponse(t, resp.Body)
+ assert.Len(t, br.Objects, 1)
+ assert.Nil(t, br.Objects[0].Error)
+ assert.Contains(t, br.Objects[0].Actions, "download")
+ l := br.Objects[0].Actions["download"]
+ assert.NotNil(t, l)
+ assert.NotEmpty(t, l.Href)
+ })
+ })
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ t.Run("FileTooBig", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ oldMaxFileSize := setting.LFS.MaxFileSize
+ setting.LFS.MaxFileSize = 2
+
+ req := newRequest(t, &lfs.BatchRequest{
+ Operation: "upload",
+ Objects: []lfs.Pointer{
+ {Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6},
+ },
+ })
+
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ br := decodeResponse(t, resp.Body)
+ assert.Len(t, br.Objects, 1)
+ assert.NotNil(t, br.Objects[0].Error)
+ assert.Equal(t, http.StatusUnprocessableEntity, br.Objects[0].Error.Code)
+ assert.Equal(t, "Size must be less than or equal to 2", br.Objects[0].Error.Message)
+
+ setting.LFS.MaxFileSize = oldMaxFileSize
+ })
+
+ t.Run("AddMeta", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ p := lfs.Pointer{Oid: "05eeb4eb5be71f2dd291ca39157d6d9effd7d1ea19cbdc8a99411fe2a8f26a00", Size: 6}
+
+ contentStore := lfs.NewContentStore()
+ exist, err := contentStore.Exists(p)
+ require.NoError(t, err)
+ assert.True(t, exist)
+
+ repo2 := createLFSTestRepository(t, "batch2")
+ content := []byte("dummy0")
+ storeObjectInRepo(t, repo2.ID, &content)
+
+ meta, err := git_model.GetLFSMetaObjectByOid(db.DefaultContext, repo.ID, p.Oid)
+ assert.Nil(t, meta)
+ assert.Equal(t, git_model.ErrLFSObjectNotExist, err)
+
+ req := newRequest(t, &lfs.BatchRequest{
+ Operation: "upload",
+ Objects: []lfs.Pointer{p},
+ })
+
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ br := decodeResponse(t, resp.Body)
+ assert.Len(t, br.Objects, 1)
+ assert.Nil(t, br.Objects[0].Error)
+ assert.Empty(t, br.Objects[0].Actions)
+
+ meta, err = git_model.GetLFSMetaObjectByOid(db.DefaultContext, repo.ID, p.Oid)
+ require.NoError(t, err)
+ assert.NotNil(t, meta)
+
+ // Cleanup
+ err = contentStore.Delete(p.RelativePath())
+ require.NoError(t, err)
+ })
+
+ t.Run("AlreadyExists", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, &lfs.BatchRequest{
+ Operation: "upload",
+ Objects: []lfs.Pointer{
+ {Oid: oid, Size: 6},
+ },
+ })
+
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ br := decodeResponse(t, resp.Body)
+ assert.Len(t, br.Objects, 1)
+ assert.Nil(t, br.Objects[0].Error)
+ assert.Empty(t, br.Objects[0].Actions)
+ })
+
+ t.Run("NewFile", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, &lfs.BatchRequest{
+ Operation: "upload",
+ Objects: []lfs.Pointer{
+ {Oid: "d6f175817f886ec6fbbc1515326465fa96c3bfd54a4ea06cfd6dbbd8340e0153", Size: 1},
+ },
+ })
+
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ br := decodeResponse(t, resp.Body)
+ assert.Len(t, br.Objects, 1)
+ assert.Nil(t, br.Objects[0].Error)
+ assert.Contains(t, br.Objects[0].Actions, "upload")
+ ul := br.Objects[0].Actions["upload"]
+ assert.NotNil(t, ul)
+ assert.NotEmpty(t, ul.Href)
+ assert.Contains(t, br.Objects[0].Actions, "verify")
+ vl := br.Objects[0].Actions["verify"]
+ assert.NotNil(t, vl)
+ assert.NotEmpty(t, vl.Href)
+ })
+ })
+}
+
+func TestAPILFSUpload(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.LFS.StartServer = true
+
+ repo := createLFSTestRepository(t, "upload")
+
+ content := []byte("dummy3")
+ oid := storeObjectInRepo(t, repo.ID, &content)
+ defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid)
+
+ session := loginUser(t, "user2")
+
+ newRequest := func(t testing.TB, p lfs.Pointer, content string) *RequestWrapper {
+ return NewRequestWithBody(t, "PUT", path.Join("/user2/lfs-upload-repo.git/info/lfs/objects/", p.Oid, strconv.FormatInt(p.Size, 10)), strings.NewReader(content))
+ }
+
+ t.Run("InvalidPointer", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, lfs.Pointer{Oid: "dummy"}, "")
+
+ session.MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+
+ t.Run("AlreadyExistsInStore", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ p := lfs.Pointer{Oid: "83de2e488b89a0aa1c97496b888120a28b0c1e15463a4adb8405578c540f36d4", Size: 6}
+
+ contentStore := lfs.NewContentStore()
+ exist, err := contentStore.Exists(p)
+ require.NoError(t, err)
+ assert.False(t, exist)
+ err = contentStore.Put(p, bytes.NewReader([]byte("dummy5")))
+ require.NoError(t, err)
+
+ meta, err := git_model.GetLFSMetaObjectByOid(db.DefaultContext, repo.ID, p.Oid)
+ assert.Nil(t, meta)
+ assert.Equal(t, git_model.ErrLFSObjectNotExist, err)
+
+ t.Run("InvalidAccess", func(t *testing.T) {
+ req := newRequest(t, p, "invalid")
+ session.MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+
+ t.Run("ValidAccess", func(t *testing.T) {
+ req := newRequest(t, p, "dummy5")
+
+ session.MakeRequest(t, req, http.StatusOK)
+ meta, err = git_model.GetLFSMetaObjectByOid(db.DefaultContext, repo.ID, p.Oid)
+ require.NoError(t, err)
+ assert.NotNil(t, meta)
+ })
+
+ // Cleanup
+ err = contentStore.Delete(p.RelativePath())
+ require.NoError(t, err)
+ })
+
+ t.Run("MetaAlreadyExists", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, lfs.Pointer{Oid: oid, Size: 6}, "")
+
+ session.MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("HashMismatch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, lfs.Pointer{Oid: "2581dd7bbc1fe44726de4b7dd806a087a978b9c5aec0a60481259e34be09b06a", Size: 1}, "a")
+
+ session.MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+
+ t.Run("SizeMismatch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, lfs.Pointer{Oid: "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb", Size: 2}, "a")
+
+ session.MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+
+ t.Run("Success", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ p := lfs.Pointer{Oid: "6ccce4863b70f258d691f59609d31b4502e1ba5199942d3bc5d35d17a4ce771d", Size: 5}
+
+ req := newRequest(t, p, "gitea")
+
+ session.MakeRequest(t, req, http.StatusOK)
+
+ contentStore := lfs.NewContentStore()
+ exist, err := contentStore.Exists(p)
+ require.NoError(t, err)
+ assert.True(t, exist)
+
+ meta, err := git_model.GetLFSMetaObjectByOid(db.DefaultContext, repo.ID, p.Oid)
+ require.NoError(t, err)
+ assert.NotNil(t, meta)
+ })
+}
+
+func TestAPILFSVerify(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.LFS.StartServer = true
+
+ repo := createLFSTestRepository(t, "verify")
+
+ content := []byte("dummy3")
+ oid := storeObjectInRepo(t, repo.ID, &content)
+ defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid)
+
+ session := loginUser(t, "user2")
+
+ newRequest := func(t testing.TB, p *lfs.Pointer) *RequestWrapper {
+ return NewRequestWithJSON(t, "POST", "/user2/lfs-verify-repo.git/info/lfs/verify", p).
+ SetHeader("Accept", lfs.AcceptHeader).
+ SetHeader("Content-Type", lfs.MediaType)
+ }
+
+ t.Run("InvalidJsonRequest", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, nil)
+
+ session.MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+
+ t.Run("InvalidPointer", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, &lfs.Pointer{})
+
+ session.MakeRequest(t, req, http.StatusUnprocessableEntity)
+ })
+
+ t.Run("PointerNotExisting", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, &lfs.Pointer{Oid: "fb8f7d8435968c4f82a726a92395be4d16f2f63116caf36c8ad35c60831ab042", Size: 6})
+
+ session.MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("Success", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := newRequest(t, &lfs.Pointer{Oid: oid, Size: 6})
+
+ session.MakeRequest(t, req, http.StatusOK)
+ })
+}
diff --git a/tests/integration/api_repo_raw_test.go b/tests/integration/api_repo_raw_test.go
new file mode 100644
index 0000000..e5f83d1
--- /dev/null
+++ b/tests/integration/api_repo_raw_test.go
@@ -0,0 +1,40 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIReposRaw(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+
+ for _, ref := range [...]string{
+ "master", // Branch
+ "v1.1", // Tag
+ "65f1bf27bc3bf70f64657658635e66094edbcb4d", // Commit
+ } {
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/raw/%s/README.md", user.Name, ref).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ assert.EqualValues(t, "file", resp.Header().Get("x-gitea-object-type"))
+ }
+ // Test default branch
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/repo1/raw/README.md", user.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ assert.EqualValues(t, "file", resp.Header().Get("x-gitea-object-type"))
+}
diff --git a/tests/integration/api_repo_secrets_test.go b/tests/integration/api_repo_secrets_test.go
new file mode 100644
index 0000000..c3074d9
--- /dev/null
+++ b/tests/integration/api_repo_secrets_test.go
@@ -0,0 +1,112 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+)
+
+func TestAPIRepoSecrets(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ t.Run("List", func(t *testing.T) {
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/actions/secrets", repo.FullName())).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("Create", func(t *testing.T) {
+ cases := []struct {
+ Name string
+ ExpectedStatus int
+ }{
+ {
+ Name: "",
+ ExpectedStatus: http.StatusMethodNotAllowed,
+ },
+ {
+ Name: "-",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "_",
+ ExpectedStatus: http.StatusCreated,
+ },
+ {
+ Name: "secret",
+ ExpectedStatus: http.StatusCreated,
+ },
+ {
+ Name: "2secret",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "GITEA_secret",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "GITHUB_secret",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ }
+
+ for _, c := range cases {
+ req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/actions/secrets/%s", repo.FullName(), c.Name), api.CreateOrUpdateSecretOption{
+ Data: "data",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, c.ExpectedStatus)
+ }
+ })
+
+ t.Run("Update", func(t *testing.T) {
+ name := "update_secret"
+ url := fmt.Sprintf("/api/v1/repos/%s/actions/secrets/%s", repo.FullName(), name)
+
+ req := NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{
+ Data: "initial",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{
+ Data: "changed",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ name := "delete_secret"
+ url := fmt.Sprintf("/api/v1/repos/%s/actions/secrets/%s", repo.FullName(), name)
+
+ req := NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{
+ Data: "initial",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequest(t, "DELETE", url).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "DELETE", url).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/actions/secrets/000", repo.FullName())).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusBadRequest)
+ })
+}
diff --git a/tests/integration/api_repo_tags_test.go b/tests/integration/api_repo_tags_test.go
new file mode 100644
index 0000000..09f17ef
--- /dev/null
+++ b/tests/integration/api_repo_tags_test.go
@@ -0,0 +1,123 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIRepoTags(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ repoName := "repo1"
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/tags", user.Name, repoName).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var tags []*api.Tag
+ DecodeJSON(t, resp, &tags)
+
+ assert.Len(t, tags, 1)
+ assert.Equal(t, "v1.1", tags[0].Name)
+ assert.Equal(t, "Initial commit", tags[0].Message)
+ assert.Equal(t, "65f1bf27bc3bf70f64657658635e66094edbcb4d", tags[0].Commit.SHA)
+ assert.Equal(t, setting.AppURL+"api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d", tags[0].Commit.URL)
+ assert.Equal(t, setting.AppURL+"user2/repo1/archive/v1.1.zip", tags[0].ZipballURL)
+ assert.Equal(t, setting.AppURL+"user2/repo1/archive/v1.1.tar.gz", tags[0].TarballURL)
+
+ newTag := createNewTagUsingAPI(t, token, user.Name, repoName, "gitea/22", "", "nice!\nand some text")
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &tags)
+ assert.Len(t, tags, 2)
+ for _, tag := range tags {
+ if tag.Name != "v1.1" {
+ assert.EqualValues(t, newTag.Name, tag.Name)
+ assert.EqualValues(t, newTag.Message, tag.Message)
+ assert.EqualValues(t, "nice!\nand some text", tag.Message)
+ assert.EqualValues(t, newTag.Commit.SHA, tag.Commit.SHA)
+ }
+ }
+
+ // get created tag
+ req = NewRequestf(t, "GET", "/api/v1/repos/%s/%s/tags/%s", user.Name, repoName, newTag.Name).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ var tag *api.Tag
+ DecodeJSON(t, resp, &tag)
+ assert.EqualValues(t, newTag, tag)
+
+ // delete tag
+ delReq := NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/tags/%s", user.Name, repoName, newTag.Name).
+ AddTokenAuth(token)
+ MakeRequest(t, delReq, http.StatusNoContent)
+
+ // check if it's gone
+ MakeRequest(t, req, http.StatusNotFound)
+}
+
+func createNewTagUsingAPI(t *testing.T, token, ownerName, repoName, name, target, msg string) *api.Tag {
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/tags", ownerName, repoName)
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateTagOption{
+ TagName: name,
+ Message: msg,
+ Target: target,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var respObj api.Tag
+ DecodeJSON(t, resp, &respObj)
+ return &respObj
+}
+
+func TestAPIGetTagArchiveDownloadCount(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Login as User2.
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ repoName := "repo1"
+ tagName := "TagDownloadCount"
+
+ createNewTagUsingAPI(t, token, user.Name, repoName, tagName, "", "")
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/tags/%s?token=%s", user.Name, repoName, tagName, token)
+
+ req := NewRequest(t, "GET", urlStr)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var tagInfo *api.Tag
+ DecodeJSON(t, resp, &tagInfo)
+
+ // Check if everything defaults to 0
+ assert.Equal(t, int64(0), tagInfo.ArchiveDownloadCount.TarGz)
+ assert.Equal(t, int64(0), tagInfo.ArchiveDownloadCount.Zip)
+
+ // Download the tarball to increase the count
+ MakeRequest(t, NewRequest(t, "GET", tagInfo.TarballURL), http.StatusOK)
+
+ // Check if the count has increased
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &tagInfo)
+
+ assert.Equal(t, int64(1), tagInfo.ArchiveDownloadCount.TarGz)
+ assert.Equal(t, int64(0), tagInfo.ArchiveDownloadCount.Zip)
+}
diff --git a/tests/integration/api_repo_teams_test.go b/tests/integration/api_repo_teams_test.go
new file mode 100644
index 0000000..91bfd66
--- /dev/null
+++ b/tests/integration/api_repo_teams_test.go
@@ -0,0 +1,82 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIRepoTeams(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // publicOrgRepo = org3/repo21
+ publicOrgRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 32})
+ // user4
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ // ListTeams
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/teams", publicOrgRepo.FullName())).
+ AddTokenAuth(token)
+ res := MakeRequest(t, req, http.StatusOK)
+ var teams []*api.Team
+ DecodeJSON(t, res, &teams)
+ if assert.Len(t, teams, 2) {
+ assert.EqualValues(t, "Owners", teams[0].Name)
+ assert.True(t, teams[0].CanCreateOrgRepo)
+ assert.True(t, util.SliceSortedEqual(unit.AllUnitKeyNames(), teams[0].Units))
+ assert.EqualValues(t, "owner", teams[0].Permission)
+
+ assert.EqualValues(t, "test_team", teams[1].Name)
+ assert.False(t, teams[1].CanCreateOrgRepo)
+ assert.EqualValues(t, []string{"repo.issues"}, teams[1].Units)
+ assert.EqualValues(t, "write", teams[1].Permission)
+ }
+
+ // IsTeam
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/teams/%s", publicOrgRepo.FullName(), "Test_Team")).
+ AddTokenAuth(token)
+ res = MakeRequest(t, req, http.StatusOK)
+ var team *api.Team
+ DecodeJSON(t, res, &team)
+ assert.EqualValues(t, teams[1], team)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/teams/%s", publicOrgRepo.FullName(), "NonExistingTeam")).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // AddTeam with user4
+ req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/teams/%s", publicOrgRepo.FullName(), "team1")).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+
+ // AddTeam with user2
+ user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session = loginUser(t, user.Name)
+ token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/teams/%s", publicOrgRepo.FullName(), "team1")).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ MakeRequest(t, req, http.StatusUnprocessableEntity) // test duplicate request
+
+ // DeleteTeam
+ req = NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/teams/%s", publicOrgRepo.FullName(), "team1")).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ MakeRequest(t, req, http.StatusUnprocessableEntity) // test duplicate request
+}
diff --git a/tests/integration/api_repo_test.go b/tests/integration/api_repo_test.go
new file mode 100644
index 0000000..b635b70
--- /dev/null
+++ b/tests/integration/api_repo_test.go
@@ -0,0 +1,766 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ repo_service "code.gitea.io/gitea/services/repository"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIUserReposNotLogin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ req := NewRequestf(t, "GET", "/api/v1/users/%s/repos", user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiRepos []api.Repository
+ DecodeJSON(t, resp, &apiRepos)
+ expectedLen := unittest.GetCount(t, repo_model.Repository{OwnerID: user.ID},
+ unittest.Cond("is_private = ?", false))
+ assert.Len(t, apiRepos, expectedLen)
+ for _, repo := range apiRepos {
+ assert.EqualValues(t, user.ID, repo.Owner.ID)
+ assert.False(t, repo.Private)
+ }
+}
+
+func TestAPIUserReposWithWrongToken(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ wrongToken := fmt.Sprintf("Bearer %s", "wrong_token")
+ req := NewRequestf(t, "GET", "/api/v1/users/%s/repos", user.Name).
+ AddTokenAuth(wrongToken)
+ resp := MakeRequest(t, req, http.StatusUnauthorized)
+
+ assert.Contains(t, resp.Body.String(), "user does not exist")
+}
+
+func TestAPISearchRepo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ const keyword = "test"
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/search?q=%s", keyword)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var body api.SearchResults
+ DecodeJSON(t, resp, &body)
+ assert.NotEmpty(t, body.Data)
+ for _, repo := range body.Data {
+ assert.Contains(t, repo.Name, keyword)
+ assert.False(t, repo.Private)
+ }
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 16})
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 18})
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 20})
+ orgUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17})
+
+ oldAPIDefaultNum := setting.API.DefaultPagingNum
+ defer func() {
+ setting.API.DefaultPagingNum = oldAPIDefaultNum
+ }()
+ setting.API.DefaultPagingNum = 10
+
+ // Map of expected results, where key is user for login
+ type expectedResults map[*user_model.User]struct {
+ count int
+ repoOwnerID int64
+ repoName string
+ includesPrivate bool
+ }
+
+ testCases := []struct {
+ name, requestURL string
+ expectedResults
+ }{
+ {
+ name: "RepositoriesMax50", requestURL: "/api/v1/repos/search?limit=50&private=false", expectedResults: expectedResults{
+ nil: {count: 37},
+ user: {count: 37},
+ user2: {count: 37},
+ },
+ },
+ {
+ name: "RepositoriesMax10", requestURL: "/api/v1/repos/search?limit=10&private=false", expectedResults: expectedResults{
+ nil: {count: 10},
+ user: {count: 10},
+ user2: {count: 10},
+ },
+ },
+ {
+ name: "RepositoriesDefault", requestURL: "/api/v1/repos/search?default&private=false", expectedResults: expectedResults{
+ nil: {count: 10},
+ user: {count: 10},
+ user2: {count: 10},
+ },
+ },
+ {
+ name: "RepositoriesByName", requestURL: fmt.Sprintf("/api/v1/repos/search?q=%s&private=false", "big_test_"), expectedResults: expectedResults{
+ nil: {count: 7, repoName: "big_test_"},
+ user: {count: 7, repoName: "big_test_"},
+ user2: {count: 7, repoName: "big_test_"},
+ },
+ },
+ {
+ name: "RepositoriesByName", requestURL: fmt.Sprintf("/api/v1/repos/search?q=%s&private=false", "user2/big_test_"), expectedResults: expectedResults{
+ user2: {count: 2, repoName: "big_test_"},
+ },
+ },
+ {
+ name: "RepositoriesAccessibleAndRelatedToUser", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d", user.ID), expectedResults: expectedResults{
+ nil: {count: 5},
+ user: {count: 9, includesPrivate: true},
+ user2: {count: 6, includesPrivate: true},
+ },
+ },
+ {
+ name: "RepositoriesAccessibleAndRelatedToUser2", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d", user2.ID), expectedResults: expectedResults{
+ nil: {count: 1},
+ user: {count: 2, includesPrivate: true},
+ user2: {count: 2, includesPrivate: true},
+ user4: {count: 1},
+ },
+ },
+ {
+ name: "RepositoriesAccessibleAndRelatedToUser3", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d", org3.ID), expectedResults: expectedResults{
+ nil: {count: 1},
+ user: {count: 4, includesPrivate: true},
+ user2: {count: 3, includesPrivate: true},
+ org3: {count: 4, includesPrivate: true},
+ },
+ },
+ {
+ name: "RepositoriesOwnedByOrganization", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d", orgUser.ID), expectedResults: expectedResults{
+ nil: {count: 1, repoOwnerID: orgUser.ID},
+ user: {count: 2, repoOwnerID: orgUser.ID, includesPrivate: true},
+ user2: {count: 1, repoOwnerID: orgUser.ID},
+ },
+ },
+ {name: "RepositoriesAccessibleAndRelatedToUser4", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d", user4.ID), expectedResults: expectedResults{
+ nil: {count: 3},
+ user: {count: 4, includesPrivate: true},
+ user4: {count: 7, includesPrivate: true},
+ }},
+ {name: "RepositoriesAccessibleAndRelatedToUser4/SearchModeSource", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d&mode=%s", user4.ID, "source"), expectedResults: expectedResults{
+ nil: {count: 0},
+ user: {count: 1, includesPrivate: true},
+ user4: {count: 1, includesPrivate: true},
+ }},
+ {name: "RepositoriesAccessibleAndRelatedToUser4/SearchModeFork", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d&mode=%s", user4.ID, "fork"), expectedResults: expectedResults{
+ nil: {count: 1},
+ user: {count: 1},
+ user4: {count: 2, includesPrivate: true},
+ }},
+ {name: "RepositoriesAccessibleAndRelatedToUser4/SearchModeFork/Exclusive", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d&mode=%s&exclusive=1", user4.ID, "fork"), expectedResults: expectedResults{
+ nil: {count: 1},
+ user: {count: 1},
+ user4: {count: 2, includesPrivate: true},
+ }},
+ {name: "RepositoriesAccessibleAndRelatedToUser4/SearchModeMirror", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d&mode=%s", user4.ID, "mirror"), expectedResults: expectedResults{
+ nil: {count: 2},
+ user: {count: 2},
+ user4: {count: 4, includesPrivate: true},
+ }},
+ {name: "RepositoriesAccessibleAndRelatedToUser4/SearchModeMirror/Exclusive", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d&mode=%s&exclusive=1", user4.ID, "mirror"), expectedResults: expectedResults{
+ nil: {count: 1},
+ user: {count: 1},
+ user4: {count: 2, includesPrivate: true},
+ }},
+ {name: "RepositoriesAccessibleAndRelatedToUser4/SearchModeCollaborative", requestURL: fmt.Sprintf("/api/v1/repos/search?uid=%d&mode=%s", user4.ID, "collaborative"), expectedResults: expectedResults{
+ nil: {count: 0},
+ user: {count: 1, includesPrivate: true},
+ user4: {count: 1, includesPrivate: true},
+ }},
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ for userToLogin, expected := range testCase.expectedResults {
+ var testName string
+ var userID int64
+ var token string
+ if userToLogin != nil && userToLogin.ID > 0 {
+ testName = fmt.Sprintf("LoggedUser%d", userToLogin.ID)
+ session := loginUser(t, userToLogin.Name)
+ token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+ userID = userToLogin.ID
+ } else {
+ testName = "AnonymousUser"
+ _ = emptyTestSession(t)
+ }
+
+ t.Run(testName, func(t *testing.T) {
+ request := NewRequest(t, "GET", testCase.requestURL).
+ AddTokenAuth(token)
+ response := MakeRequest(t, request, http.StatusOK)
+
+ var body api.SearchResults
+ DecodeJSON(t, response, &body)
+
+ repoNames := make([]string, 0, len(body.Data))
+ for _, repo := range body.Data {
+ repoNames = append(repoNames, fmt.Sprintf("%d:%s:%t", repo.ID, repo.FullName, repo.Private))
+ }
+ assert.Len(t, repoNames, expected.count)
+ for _, repo := range body.Data {
+ r := getRepo(t, repo.ID)
+ hasAccess, err := access_model.HasAccess(db.DefaultContext, userID, r)
+ require.NoError(t, err, "Error when checking if User: %d has access to %s: %v", userID, repo.FullName, err)
+ assert.True(t, hasAccess, "User: %d does not have access to %s", userID, repo.FullName)
+
+ assert.NotEmpty(t, repo.Name)
+ assert.Equal(t, repo.Name, r.Name)
+
+ if len(expected.repoName) > 0 {
+ assert.Contains(t, repo.Name, expected.repoName)
+ }
+
+ if expected.repoOwnerID > 0 {
+ assert.Equal(t, expected.repoOwnerID, repo.Owner.ID)
+ }
+
+ if !expected.includesPrivate {
+ assert.False(t, repo.Private, "User: %d not expecting private repository: %s", userID, repo.FullName)
+ }
+ }
+ })
+ }
+ })
+ }
+}
+
+var repoCache = make(map[int64]*repo_model.Repository)
+
+func getRepo(t *testing.T, repoID int64) *repo_model.Repository {
+ if _, ok := repoCache[repoID]; !ok {
+ repoCache[repoID] = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: repoID})
+ }
+ return repoCache[repoID]
+}
+
+func TestAPIViewRepo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ var repo api.Repository
+
+ req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1")
+ resp := MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &repo)
+ assert.EqualValues(t, 1, repo.ID)
+ assert.EqualValues(t, "repo1", repo.Name)
+ assert.EqualValues(t, 2, repo.Releases)
+ assert.EqualValues(t, 1, repo.OpenIssues)
+ assert.EqualValues(t, 3, repo.OpenPulls)
+
+ req = NewRequest(t, "GET", "/api/v1/repos/user12/repo10")
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &repo)
+ assert.EqualValues(t, 10, repo.ID)
+ assert.EqualValues(t, "repo10", repo.Name)
+ assert.EqualValues(t, 1, repo.OpenPulls)
+ assert.EqualValues(t, 1, repo.Forks)
+
+ req = NewRequest(t, "GET", "/api/v1/repos/user5/repo4")
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &repo)
+ assert.EqualValues(t, 4, repo.ID)
+ assert.EqualValues(t, "repo4", repo.Name)
+ assert.EqualValues(t, 1, repo.Stars)
+}
+
+func TestAPIOrgRepos(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ // org3 is an Org. Check their repos.
+ sourceOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3})
+
+ expectedResults := map[*user_model.User]struct {
+ count int
+ includesPrivate bool
+ }{
+ user: {count: 1},
+ user: {count: 3, includesPrivate: true},
+ user2: {count: 3, includesPrivate: true},
+ org3: {count: 1},
+ }
+
+ for userToLogin, expected := range expectedResults {
+ testName := fmt.Sprintf("LoggedUser%d", userToLogin.ID)
+ session := loginUser(t, userToLogin.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization)
+
+ t.Run(testName, func(t *testing.T) {
+ req := NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", sourceOrg.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiRepos []*api.Repository
+ DecodeJSON(t, resp, &apiRepos)
+ assert.Len(t, apiRepos, expected.count)
+ for _, repo := range apiRepos {
+ if !expected.includesPrivate {
+ assert.False(t, repo.Private)
+ }
+ }
+ })
+ }
+}
+
+// See issue #28483. Tests to make sure we consider more than just code unit-enabled repositories.
+func TestAPIOrgReposWithCodeUnitDisabled(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ repo21 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: "repo21"})
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo21.OwnerID})
+
+ // Disable code repository unit.
+ var units []unit_model.Type
+ units = append(units, unit_model.TypeCode)
+
+ if err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo21, nil, units); err != nil {
+ assert.Fail(t, "should have been able to delete code repository unit; failed to %v", err)
+ }
+ assert.False(t, repo21.UnitEnabled(db.DefaultContext, unit_model.TypeCode))
+
+ session := loginUser(t, "user2")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization)
+
+ req := NewRequestf(t, "GET", "/api/v1/orgs/%s/repos", org3.Name).
+ AddTokenAuth(token)
+
+ resp := MakeRequest(t, req, http.StatusOK)
+ var apiRepos []*api.Repository
+ DecodeJSON(t, resp, &apiRepos)
+
+ var repoNames []string
+ for _, r := range apiRepos {
+ repoNames = append(repoNames, r.Name)
+ }
+
+ assert.Contains(t, repoNames, repo21.Name)
+}
+
+func TestAPIGetRepoByIDUnauthorized(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+ req := NewRequest(t, "GET", "/api/v1/repositories/2").
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+}
+
+func TestAPIRepoMigrate(t *testing.T) {
+ testCases := []struct {
+ ctxUserID, userID int64
+ cloneURL, repoName string
+ expectedStatus int
+ }{
+ {ctxUserID: 1, userID: 2, cloneURL: "https://github.com/go-gitea/test_repo.git", repoName: "git-admin", expectedStatus: http.StatusCreated},
+ {ctxUserID: 2, userID: 2, cloneURL: "https://github.com/go-gitea/test_repo.git", repoName: "git-own", expectedStatus: http.StatusCreated},
+ {ctxUserID: 2, userID: 1, cloneURL: "https://github.com/go-gitea/test_repo.git", repoName: "git-bad", expectedStatus: http.StatusForbidden},
+ {ctxUserID: 2, userID: 3, cloneURL: "https://github.com/go-gitea/test_repo.git", repoName: "git-org", expectedStatus: http.StatusCreated},
+ {ctxUserID: 2, userID: 6, cloneURL: "https://github.com/go-gitea/test_repo.git", repoName: "git-bad-org", expectedStatus: http.StatusForbidden},
+ {ctxUserID: 2, userID: 3, cloneURL: "https://localhost:3000/user/test_repo.git", repoName: "private-ip", expectedStatus: http.StatusUnprocessableEntity},
+ {ctxUserID: 2, userID: 3, cloneURL: "https://10.0.0.1/user/test_repo.git", repoName: "private-ip", expectedStatus: http.StatusUnprocessableEntity},
+ }
+
+ defer tests.PrepareTestEnv(t)()
+ for _, testCase := range testCases {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: testCase.ctxUserID})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate", &api.MigrateRepoOptions{
+ CloneAddr: testCase.cloneURL,
+ RepoOwnerID: testCase.userID,
+ RepoName: testCase.repoName,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, NoExpectedStatus)
+ if resp.Code == http.StatusUnprocessableEntity {
+ respJSON := map[string]string{}
+ DecodeJSON(t, resp, &respJSON)
+ switch respJSON["message"] {
+ case "Remote visit addressed rate limitation.":
+ t.Log("test hit github rate limitation")
+ case "You can not import from disallowed hosts.":
+ assert.EqualValues(t, "private-ip", testCase.repoName)
+ default:
+ assert.FailNow(t, "unexpected error '%v' on url '%s'", respJSON["message"], testCase.cloneURL)
+ }
+ } else {
+ assert.EqualValues(t, testCase.expectedStatus, resp.Code)
+ }
+ }
+}
+
+func TestAPIRepoMigrateConflict(t *testing.T) {
+ onGiteaRun(t, testAPIRepoMigrateConflict)
+}
+
+func testAPIRepoMigrateConflict(t *testing.T, u *url.URL) {
+ username := "user2"
+ baseAPITestContext := NewAPITestContext(t, username, "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+ u.Path = baseAPITestContext.GitPath()
+
+ t.Run("Existing", func(t *testing.T) {
+ httpContext := baseAPITestContext
+
+ httpContext.Reponame = "repo-tmp-17"
+ t.Run("CreateRepo", doAPICreateRepository(httpContext, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat
+
+ user, err := user_model.GetUserByName(db.DefaultContext, httpContext.Username)
+ require.NoError(t, err)
+ userID := user.ID
+
+ cloneURL := "https://github.com/go-gitea/test_repo.git"
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/repos/migrate",
+ &api.MigrateRepoOptions{
+ CloneAddr: cloneURL,
+ RepoOwnerID: userID,
+ RepoName: httpContext.Reponame,
+ }).
+ AddTokenAuth(httpContext.Token)
+ resp := httpContext.Session.MakeRequest(t, req, http.StatusConflict)
+ respJSON := map[string]string{}
+ DecodeJSON(t, resp, &respJSON)
+ assert.Equal(t, "The repository with the same name already exists.", respJSON["message"])
+ })
+}
+
+// mirror-sync must fail with "400 (Bad Request)" when an attempt is made to
+// sync a non-mirror repository.
+func TestAPIMirrorSyncNonMirrorRepo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ var repo api.Repository
+ req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1")
+ resp := MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &repo)
+ assert.False(t, repo.Mirror)
+
+ req = NewRequestf(t, "POST", "/api/v1/repos/user2/repo1/mirror-sync").
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusBadRequest)
+ errRespJSON := map[string]string{}
+ DecodeJSON(t, resp, &errRespJSON)
+ assert.Equal(t, "Repository is not a mirror", errRespJSON["message"])
+}
+
+func TestAPIOrgRepoCreate(t *testing.T) {
+ testCases := []struct {
+ ctxUserID int64
+ orgName, repoName string
+ expectedStatus int
+ }{
+ {ctxUserID: 1, orgName: "org3", repoName: "repo-admin", expectedStatus: http.StatusCreated},
+ {ctxUserID: 2, orgName: "org3", repoName: "repo-own", expectedStatus: http.StatusCreated},
+ {ctxUserID: 2, orgName: "org6", repoName: "repo-bad-org", expectedStatus: http.StatusForbidden},
+ {ctxUserID: 28, orgName: "org3", repoName: "repo-creator", expectedStatus: http.StatusCreated},
+ {ctxUserID: 28, orgName: "org6", repoName: "repo-not-creator", expectedStatus: http.StatusForbidden},
+ }
+
+ defer tests.PrepareTestEnv(t)()
+ for _, testCase := range testCases {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: testCase.ctxUserID})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/org/%s/repos", testCase.orgName), &api.CreateRepoOption{
+ Name: testCase.repoName,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, testCase.expectedStatus)
+ }
+}
+
+func TestAPIRepoCreateConflict(t *testing.T) {
+ onGiteaRun(t, testAPIRepoCreateConflict)
+}
+
+func testAPIRepoCreateConflict(t *testing.T, u *url.URL) {
+ username := "user2"
+ baseAPITestContext := NewAPITestContext(t, username, "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+ u.Path = baseAPITestContext.GitPath()
+
+ t.Run("Existing", func(t *testing.T) {
+ httpContext := baseAPITestContext
+
+ httpContext.Reponame = "repo-tmp-17"
+ t.Run("CreateRepo", doAPICreateRepository(httpContext, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos",
+ &api.CreateRepoOption{
+ Name: httpContext.Reponame,
+ }).
+ AddTokenAuth(httpContext.Token)
+ resp := httpContext.Session.MakeRequest(t, req, http.StatusConflict)
+ respJSON := map[string]string{}
+ DecodeJSON(t, resp, &respJSON)
+ assert.Equal(t, "The repository with the same name already exists.", respJSON["message"])
+ })
+}
+
+func TestAPIRepoTransfer(t *testing.T) {
+ testCases := []struct {
+ ctxUserID int64
+ newOwner string
+ teams *[]int64
+ expectedStatus int
+ }{
+ // Disclaimer for test story: "user1" is an admin, "user2" is normal user and part of in owner team of org "org3"
+ // Transfer to a user with teams in another org should fail
+ {ctxUserID: 1, newOwner: "org3", teams: &[]int64{5}, expectedStatus: http.StatusForbidden},
+ // Transfer to a user with non-existent team IDs should fail
+ {ctxUserID: 1, newOwner: "user2", teams: &[]int64{2}, expectedStatus: http.StatusUnprocessableEntity},
+ // Transfer should go through
+ {ctxUserID: 1, newOwner: "org3", teams: &[]int64{2}, expectedStatus: http.StatusAccepted},
+ // Let user transfer it back to himself
+ {ctxUserID: 2, newOwner: "user2", expectedStatus: http.StatusAccepted},
+ // And revert transfer
+ {ctxUserID: 2, newOwner: "org3", teams: &[]int64{2}, expectedStatus: http.StatusAccepted},
+ // Cannot start transfer to an existing repo
+ {ctxUserID: 2, newOwner: "org3", teams: nil, expectedStatus: http.StatusUnprocessableEntity},
+ // Start transfer, repo is now in pending transfer mode
+ {ctxUserID: 2, newOwner: "org6", teams: nil, expectedStatus: http.StatusCreated},
+ }
+
+ defer tests.PrepareTestEnv(t)()
+
+ // create repo to move
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ repoName := "moveME"
+ apiRepo := new(api.Repository)
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{
+ Name: repoName,
+ Description: "repo move around",
+ Private: false,
+ Readme: "Default",
+ AutoInit: true,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, apiRepo)
+
+ // start testing
+ for _, testCase := range testCases {
+ user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: testCase.ctxUserID})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
+ session = loginUser(t, user.Name)
+ token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", repo.OwnerName, repo.Name), &api.TransferRepoOption{
+ NewOwner: testCase.newOwner,
+ TeamIDs: testCase.teams,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, testCase.expectedStatus)
+ }
+
+ // cleanup
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
+ _ = repo_service.DeleteRepositoryDirectly(db.DefaultContext, user, repo.ID)
+}
+
+func transfer(t *testing.T) *repo_model.Repository {
+ // create repo to move
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ repoName := "moveME"
+ apiRepo := new(api.Repository)
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/repos", &api.CreateRepoOption{
+ Name: repoName,
+ Description: "repo move around",
+ Private: false,
+ Readme: "Default",
+ AutoInit: true,
+ }).AddTokenAuth(token)
+
+ resp := MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, apiRepo)
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: apiRepo.ID})
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer", repo.OwnerName, repo.Name), &api.TransferRepoOption{
+ NewOwner: "user4",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ return repo
+}
+
+func TestAPIAcceptTransfer(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := transfer(t)
+
+ // try to accept with not authorized user
+ session := loginUser(t, "user2")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject", repo.OwnerName, repo.Name)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+
+ // try to accept repo that's not marked as transferred
+ req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept", "user2", "repo1")).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // accept transfer
+ session = loginUser(t, "user4")
+ token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+ req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/accept", repo.OwnerName, repo.Name)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusAccepted)
+ apiRepo := new(api.Repository)
+ DecodeJSON(t, resp, apiRepo)
+ assert.Equal(t, "user4", apiRepo.Owner.UserName)
+}
+
+func TestAPIRejectTransfer(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := transfer(t)
+
+ // try to reject with not authorized user
+ session := loginUser(t, "user2")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject", repo.OwnerName, repo.Name)).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+
+ // try to reject repo that's not marked as transferred
+ req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject", "user2", "repo1")).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // reject transfer
+ session = loginUser(t, "user4")
+ token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ req = NewRequest(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/transfer/reject", repo.OwnerName, repo.Name)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ apiRepo := new(api.Repository)
+ DecodeJSON(t, resp, apiRepo)
+ assert.Equal(t, "user2", apiRepo.Owner.UserName)
+}
+
+func TestAPIGenerateRepo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ templateRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 44})
+
+ // user
+ repo := new(api.Repository)
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/generate", templateRepo.OwnerName, templateRepo.Name), &api.GenerateRepoOption{
+ Owner: user.Name,
+ Name: "new-repo",
+ Description: "test generate repo",
+ Private: false,
+ GitContent: true,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, repo)
+
+ assert.Equal(t, "new-repo", repo.Name)
+
+ // org
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/generate", templateRepo.OwnerName, templateRepo.Name), &api.GenerateRepoOption{
+ Owner: "org3",
+ Name: "new-repo",
+ Description: "test generate repo",
+ Private: false,
+ GitContent: true,
+ }).AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, repo)
+
+ assert.Equal(t, "new-repo", repo.Name)
+}
+
+func TestAPIRepoGetReviewers(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/reviewers", user.Name, repo.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var reviewers []*api.User
+ DecodeJSON(t, resp, &reviewers)
+ if assert.Len(t, reviewers, 3) {
+ assert.ElementsMatch(t, []int64{1, 4, 11}, []int64{reviewers[0].ID, reviewers[1].ID, reviewers[2].ID})
+ }
+}
+
+func TestAPIRepoGetAssignees(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadRepository)
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ req := NewRequestf(t, "GET", "/api/v1/repos/%s/%s/assignees", user.Name, repo.Name).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var assignees []*api.User
+ DecodeJSON(t, resp, &assignees)
+ assert.Len(t, assignees, 1)
+}
+
+func TestAPIViewRepoObjectFormat(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ var repo api.Repository
+
+ req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1")
+ resp := MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &repo)
+ assert.EqualValues(t, "sha1", repo.ObjectFormatName)
+}
+
+func TestAPIRepoCommitPull(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ var pr api.PullRequest
+ req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/1a8823cd1a9549fde083f992f6b9b87a7ab74fb3/pull")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &pr)
+ assert.EqualValues(t, 1, pr.ID)
+
+ req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/not-a-commit/pull")
+ MakeRequest(t, req, http.StatusNotFound)
+}
diff --git a/tests/integration/api_repo_topic_test.go b/tests/integration/api_repo_topic_test.go
new file mode 100644
index 0000000..dcb8ae0
--- /dev/null
+++ b/tests/integration/api_repo_topic_test.go
@@ -0,0 +1,194 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPITopicSearchPaging(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ var topics struct {
+ TopicNames []*api.TopicResponse `json:"topics"`
+ }
+
+ // Add 20 unique topics to user2/repo2, and 20 unique ones to user2/repo3
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ token2 := getUserToken(t, user2.Name, auth_model.AccessTokenScopeWriteRepository)
+ repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+ for i := 0; i < 20; i++ {
+ req := NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/paging-topic-%d", user2.Name, repo2.Name, i).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNoContent)
+ req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/paging-topic-%d", user2.Name, repo3.Name, i+30).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNoContent)
+ }
+
+ res := MakeRequest(t, NewRequest(t, "GET", "/api/v1/topics/search"), http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ assert.Len(t, topics.TopicNames, 30)
+
+ res = MakeRequest(t, NewRequest(t, "GET", "/api/v1/topics/search?page=2"), http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ assert.NotEmpty(t, topics.TopicNames)
+}
+
+func TestAPITopicSearch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ searchURL, _ := url.Parse("/api/v1/topics/search")
+ var topics struct {
+ TopicNames []*api.TopicResponse `json:"topics"`
+ }
+
+ query := url.Values{"page": []string{"1"}, "limit": []string{"4"}}
+
+ searchURL.RawQuery = query.Encode()
+ res := MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ assert.Len(t, topics.TopicNames, 4)
+ assert.EqualValues(t, "6", res.Header().Get("x-total-count"))
+
+ query.Add("q", "topic")
+ searchURL.RawQuery = query.Encode()
+ res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ assert.Len(t, topics.TopicNames, 2)
+
+ query.Set("q", "database")
+ searchURL.RawQuery = query.Encode()
+ res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ if assert.Len(t, topics.TopicNames, 1) {
+ assert.EqualValues(t, 2, topics.TopicNames[0].ID)
+ assert.EqualValues(t, "database", topics.TopicNames[0].Name)
+ assert.EqualValues(t, 1, topics.TopicNames[0].RepoCount)
+ }
+}
+
+func TestAPIRepoTopic(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of repo2
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}) // owner of repo3
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4}) // write access to repo 3
+ repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3})
+
+ // Get user2's token
+ token2 := getUserToken(t, user2.Name, auth_model.AccessTokenScopeWriteRepository)
+
+ // Test read topics using login
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/topics", user2.Name, repo2.Name)).
+ AddTokenAuth(token2)
+ res := MakeRequest(t, req, http.StatusOK)
+ var topics *api.TopicName
+ DecodeJSON(t, res, &topics)
+ assert.ElementsMatch(t, []string{"topicname1", "topicname2"}, topics.TopicNames)
+
+ // Test delete a topic
+ req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/topics/%s", user2.Name, repo2.Name, "Topicname1").
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Test add an existing topic
+ req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s", user2.Name, repo2.Name, "Golang").
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Test add a topic
+ req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s", user2.Name, repo2.Name, "topicName3").
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ url := fmt.Sprintf("/api/v1/repos/%s/%s/topics", user2.Name, repo2.Name)
+
+ // Test read topics using token
+ req = NewRequest(t, "GET", url).
+ AddTokenAuth(token2)
+ res = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ assert.ElementsMatch(t, []string{"topicname2", "golang", "topicname3"}, topics.TopicNames)
+
+ // Test replace topics
+ newTopics := []string{" windows ", " ", "MAC "}
+ req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{
+ Topics: newTopics,
+ }).AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNoContent)
+ req = NewRequest(t, "GET", url).
+ AddTokenAuth(token2)
+ res = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ assert.ElementsMatch(t, []string{"windows", "mac"}, topics.TopicNames)
+
+ // Test replace topics with something invalid
+ newTopics = []string{"topicname1", "topicname2", "topicname!"}
+ req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{
+ Topics: newTopics,
+ }).AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+ req = NewRequest(t, "GET", url).
+ AddTokenAuth(token2)
+ res = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ assert.ElementsMatch(t, []string{"windows", "mac"}, topics.TopicNames)
+
+ // Test with some topics multiple times, less than 25 unique
+ newTopics = []string{"t1", "t2", "t1", "t3", "t4", "t5", "t6", "t7", "t8", "t9", "t10", "t11", "t12", "t13", "t14", "t15", "t16", "17", "t18", "t19", "t20", "t21", "t22", "t23", "t24", "t25"}
+ req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{
+ Topics: newTopics,
+ }).AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNoContent)
+ req = NewRequest(t, "GET", url).
+ AddTokenAuth(token2)
+ res = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ assert.Len(t, topics.TopicNames, 25)
+
+ // Test writing more topics than allowed
+ newTopics = append(newTopics, "t26")
+ req = NewRequestWithJSON(t, "PUT", url, &api.RepoTopicOptions{
+ Topics: newTopics,
+ }).AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ // Test add a topic when there is already maximum
+ req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s", user2.Name, repo2.Name, "t26").
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ // Test delete a topic that repo doesn't have
+ req = NewRequestf(t, "DELETE", "/api/v1/repos/%s/%s/topics/%s", user2.Name, repo2.Name, "Topicname1").
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // Get user4's token
+ token4 := getUserToken(t, user4.Name, auth_model.AccessTokenScopeWriteRepository)
+
+ // Test read topics with write access
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/topics", org3.Name, repo3.Name)).
+ AddTokenAuth(token4)
+ res = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ assert.Empty(t, topics.TopicNames)
+
+ // Test add a topic to repo with write access (requires repo admin access)
+ req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/%s", org3.Name, repo3.Name, "topicName").
+ AddTokenAuth(token4)
+ MakeRequest(t, req, http.StatusForbidden)
+}
diff --git a/tests/integration/api_repo_variables_test.go b/tests/integration/api_repo_variables_test.go
new file mode 100644
index 0000000..7847962
--- /dev/null
+++ b/tests/integration/api_repo_variables_test.go
@@ -0,0 +1,149 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+)
+
+func TestAPIRepoVariables(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ t.Run("CreateRepoVariable", func(t *testing.T) {
+ cases := []struct {
+ Name string
+ ExpectedStatus int
+ }{
+ {
+ Name: "-",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "_",
+ ExpectedStatus: http.StatusNoContent,
+ },
+ {
+ Name: "TEST_VAR",
+ ExpectedStatus: http.StatusNoContent,
+ },
+ {
+ Name: "test_var",
+ ExpectedStatus: http.StatusConflict,
+ },
+ {
+ Name: "ci",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "123var",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "var@test",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "github_var",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "gitea_var",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ }
+
+ for _, c := range cases {
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/actions/variables/%s", repo.FullName(), c.Name), api.CreateVariableOption{
+ Value: "value",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, c.ExpectedStatus)
+ }
+ })
+
+ t.Run("UpdateRepoVariable", func(t *testing.T) {
+ variableName := "test_update_var"
+ url := fmt.Sprintf("/api/v1/repos/%s/actions/variables/%s", repo.FullName(), variableName)
+ req := NewRequestWithJSON(t, "POST", url, api.CreateVariableOption{
+ Value: "initial_val",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ cases := []struct {
+ Name string
+ UpdateName string
+ ExpectedStatus int
+ }{
+ {
+ Name: "not_found_var",
+ ExpectedStatus: http.StatusNotFound,
+ },
+ {
+ Name: variableName,
+ UpdateName: "1invalid",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: variableName,
+ UpdateName: "invalid@name",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: variableName,
+ UpdateName: "ci",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: variableName,
+ UpdateName: "updated_var_name",
+ ExpectedStatus: http.StatusNoContent,
+ },
+ {
+ Name: variableName,
+ ExpectedStatus: http.StatusNotFound,
+ },
+ {
+ Name: "updated_var_name",
+ ExpectedStatus: http.StatusNoContent,
+ },
+ }
+
+ for _, c := range cases {
+ req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/actions/variables/%s", repo.FullName(), c.Name), api.UpdateVariableOption{
+ Name: c.UpdateName,
+ Value: "updated_val",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, c.ExpectedStatus)
+ }
+ })
+
+ t.Run("DeleteRepoVariable", func(t *testing.T) {
+ variableName := "test_delete_var"
+ url := fmt.Sprintf("/api/v1/repos/%s/actions/variables/%s", repo.FullName(), variableName)
+
+ req := NewRequestWithJSON(t, "POST", url, api.CreateVariableOption{
+ Value: "initial_val",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "DELETE", url).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "DELETE", url).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+}
diff --git a/tests/integration/api_settings_test.go b/tests/integration/api_settings_test.go
new file mode 100644
index 0000000..9881578
--- /dev/null
+++ b/tests/integration/api_settings_test.go
@@ -0,0 +1,64 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIExposedSettings(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ ui := new(api.GeneralUISettings)
+ req := NewRequest(t, "GET", "/api/v1/settings/ui")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &ui)
+ assert.Len(t, ui.AllowedReactions, len(setting.UI.Reactions))
+ assert.ElementsMatch(t, setting.UI.Reactions, ui.AllowedReactions)
+
+ apiSettings := new(api.GeneralAPISettings)
+ req = NewRequest(t, "GET", "/api/v1/settings/api")
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &apiSettings)
+ assert.EqualValues(t, &api.GeneralAPISettings{
+ MaxResponseItems: setting.API.MaxResponseItems,
+ DefaultPagingNum: setting.API.DefaultPagingNum,
+ DefaultGitTreesPerPage: setting.API.DefaultGitTreesPerPage,
+ DefaultMaxBlobSize: setting.API.DefaultMaxBlobSize,
+ }, apiSettings)
+
+ repo := new(api.GeneralRepoSettings)
+ req = NewRequest(t, "GET", "/api/v1/settings/repository")
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &repo)
+ assert.EqualValues(t, &api.GeneralRepoSettings{
+ MirrorsDisabled: !setting.Mirror.Enabled,
+ HTTPGitDisabled: setting.Repository.DisableHTTPGit,
+ MigrationsDisabled: setting.Repository.DisableMigrations,
+ TimeTrackingDisabled: false,
+ LFSDisabled: !setting.LFS.StartServer,
+ }, repo)
+
+ attachment := new(api.GeneralAttachmentSettings)
+ req = NewRequest(t, "GET", "/api/v1/settings/attachment")
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &attachment)
+ assert.EqualValues(t, &api.GeneralAttachmentSettings{
+ Enabled: setting.Attachment.Enabled,
+ AllowedTypes: setting.Attachment.AllowedTypes,
+ MaxFiles: setting.Attachment.MaxFiles,
+ MaxSize: setting.Attachment.MaxSize,
+ }, attachment)
+}
diff --git a/tests/integration/api_team_test.go b/tests/integration/api_team_test.go
new file mode 100644
index 0000000..4fee39d
--- /dev/null
+++ b/tests/integration/api_team_test.go
@@ -0,0 +1,321 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "sort"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/convert"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPITeam(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ teamUser := unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{ID: 1})
+ team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamUser.TeamID})
+ org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: teamUser.OrgID})
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: teamUser.UID})
+
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization)
+ req := NewRequestf(t, "GET", "/api/v1/teams/%d", teamUser.TeamID).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiTeam api.Team
+ DecodeJSON(t, resp, &apiTeam)
+ assert.EqualValues(t, team.ID, apiTeam.ID)
+ assert.Equal(t, team.Name, apiTeam.Name)
+ assert.EqualValues(t, convert.ToOrganization(db.DefaultContext, org), apiTeam.Organization)
+
+ // non team member user will not access the teams details
+ teamUser2 := unittest.AssertExistsAndLoadBean(t, &organization.TeamUser{ID: 3})
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: teamUser2.UID})
+
+ session = loginUser(t, user2.Name)
+ token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization)
+ req = NewRequestf(t, "GET", "/api/v1/teams/%d", teamUser.TeamID).
+ AddTokenAuth(token)
+ _ = MakeRequest(t, req, http.StatusForbidden)
+
+ req = NewRequestf(t, "GET", "/api/v1/teams/%d", teamUser.TeamID)
+ _ = MakeRequest(t, req, http.StatusUnauthorized)
+
+ // Get an admin user able to create, update and delete teams.
+ user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ session = loginUser(t, user.Name)
+ token = getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization)
+
+ org = unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 6})
+
+ // Create team.
+ teamToCreate := &api.CreateTeamOption{
+ Name: "team1",
+ Description: "team one",
+ IncludesAllRepositories: true,
+ Permission: "write",
+ Units: []string{"repo.code", "repo.issues"},
+ }
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", org.Name), teamToCreate).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusCreated)
+ apiTeam = api.Team{}
+ DecodeJSON(t, resp, &apiTeam)
+ checkTeamResponse(t, "CreateTeam1", &apiTeam, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories,
+ teamToCreate.Permission, teamToCreate.Units, nil)
+ checkTeamBean(t, apiTeam.ID, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories,
+ teamToCreate.Permission, teamToCreate.Units, nil)
+ teamID := apiTeam.ID
+
+ // Edit team.
+ editDescription := "team 1"
+ editFalse := false
+ teamToEdit := &api.EditTeamOption{
+ Name: "teamone",
+ Description: &editDescription,
+ Permission: "admin",
+ IncludesAllRepositories: &editFalse,
+ Units: []string{"repo.code", "repo.pulls", "repo.releases"},
+ }
+
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/teams/%d", teamID), teamToEdit).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ apiTeam = api.Team{}
+ DecodeJSON(t, resp, &apiTeam)
+ checkTeamResponse(t, "EditTeam1", &apiTeam, teamToEdit.Name, *teamToEdit.Description, *teamToEdit.IncludesAllRepositories,
+ teamToEdit.Permission, unit.AllUnitKeyNames(), nil)
+ checkTeamBean(t, apiTeam.ID, teamToEdit.Name, *teamToEdit.Description, *teamToEdit.IncludesAllRepositories,
+ teamToEdit.Permission, unit.AllUnitKeyNames(), nil)
+
+ // Edit team Description only
+ editDescription = "first team"
+ teamToEditDesc := api.EditTeamOption{Description: &editDescription}
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/teams/%d", teamID), teamToEditDesc).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ apiTeam = api.Team{}
+ DecodeJSON(t, resp, &apiTeam)
+ checkTeamResponse(t, "EditTeam1_DescOnly", &apiTeam, teamToEdit.Name, *teamToEditDesc.Description, *teamToEdit.IncludesAllRepositories,
+ teamToEdit.Permission, unit.AllUnitKeyNames(), nil)
+ checkTeamBean(t, apiTeam.ID, teamToEdit.Name, *teamToEditDesc.Description, *teamToEdit.IncludesAllRepositories,
+ teamToEdit.Permission, unit.AllUnitKeyNames(), nil)
+
+ // Read team.
+ teamRead := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID})
+ require.NoError(t, teamRead.LoadUnits(db.DefaultContext))
+ req = NewRequestf(t, "GET", "/api/v1/teams/%d", teamID).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ apiTeam = api.Team{}
+ DecodeJSON(t, resp, &apiTeam)
+ checkTeamResponse(t, "ReadTeam1", &apiTeam, teamRead.Name, *teamToEditDesc.Description, teamRead.IncludesAllRepositories,
+ teamRead.AccessMode.String(), teamRead.GetUnitNames(), teamRead.GetUnitsMap())
+
+ // Delete team.
+ req = NewRequestf(t, "DELETE", "/api/v1/teams/%d", teamID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ unittest.AssertNotExistsBean(t, &organization.Team{ID: teamID})
+
+ // create team again via UnitsMap
+ // Create team.
+ teamToCreate = &api.CreateTeamOption{
+ Name: "team2",
+ Description: "team two",
+ IncludesAllRepositories: true,
+ Permission: "write",
+ UnitsMap: map[string]string{"repo.code": "read", "repo.issues": "write", "repo.wiki": "none"},
+ }
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", org.Name), teamToCreate).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusCreated)
+ apiTeam = api.Team{}
+ DecodeJSON(t, resp, &apiTeam)
+ checkTeamResponse(t, "CreateTeam2", &apiTeam, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories,
+ "read", nil, teamToCreate.UnitsMap)
+ checkTeamBean(t, apiTeam.ID, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories,
+ "read", nil, teamToCreate.UnitsMap)
+ teamID = apiTeam.ID
+
+ // Edit team.
+ editDescription = "team 1"
+ editFalse = false
+ teamToEdit = &api.EditTeamOption{
+ Name: "teamtwo",
+ Description: &editDescription,
+ Permission: "write",
+ IncludesAllRepositories: &editFalse,
+ UnitsMap: map[string]string{"repo.code": "read", "repo.pulls": "read", "repo.releases": "write"},
+ }
+
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/teams/%d", teamID), teamToEdit).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ apiTeam = api.Team{}
+ DecodeJSON(t, resp, &apiTeam)
+ checkTeamResponse(t, "EditTeam2", &apiTeam, teamToEdit.Name, *teamToEdit.Description, *teamToEdit.IncludesAllRepositories,
+ "read", nil, teamToEdit.UnitsMap)
+ checkTeamBean(t, apiTeam.ID, teamToEdit.Name, *teamToEdit.Description, *teamToEdit.IncludesAllRepositories,
+ "read", nil, teamToEdit.UnitsMap)
+
+ // Edit team Description only
+ editDescription = "second team"
+ teamToEditDesc = api.EditTeamOption{Description: &editDescription}
+ req = NewRequestWithJSON(t, "PATCH", fmt.Sprintf("/api/v1/teams/%d", teamID), teamToEditDesc).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ apiTeam = api.Team{}
+ DecodeJSON(t, resp, &apiTeam)
+ checkTeamResponse(t, "EditTeam2_DescOnly", &apiTeam, teamToEdit.Name, *teamToEditDesc.Description, *teamToEdit.IncludesAllRepositories,
+ "read", nil, teamToEdit.UnitsMap)
+ checkTeamBean(t, apiTeam.ID, teamToEdit.Name, *teamToEditDesc.Description, *teamToEdit.IncludesAllRepositories,
+ "read", nil, teamToEdit.UnitsMap)
+
+ // Read team.
+ teamRead = unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID})
+ req = NewRequestf(t, "GET", "/api/v1/teams/%d", teamID).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ apiTeam = api.Team{}
+ DecodeJSON(t, resp, &apiTeam)
+ require.NoError(t, teamRead.LoadUnits(db.DefaultContext))
+ checkTeamResponse(t, "ReadTeam2", &apiTeam, teamRead.Name, *teamToEditDesc.Description, teamRead.IncludesAllRepositories,
+ teamRead.AccessMode.String(), teamRead.GetUnitNames(), teamRead.GetUnitsMap())
+
+ // Delete team.
+ req = NewRequestf(t, "DELETE", "/api/v1/teams/%d", teamID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ unittest.AssertNotExistsBean(t, &organization.Team{ID: teamID})
+
+ // Create admin team
+ teamToCreate = &api.CreateTeamOption{
+ Name: "teamadmin",
+ Description: "team admin",
+ IncludesAllRepositories: true,
+ Permission: "admin",
+ }
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", org.Name), teamToCreate).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusCreated)
+ apiTeam = api.Team{}
+ DecodeJSON(t, resp, &apiTeam)
+ for _, ut := range unit.AllRepoUnitTypes {
+ up := perm.AccessModeAdmin
+ if ut == unit.TypeExternalTracker || ut == unit.TypeExternalWiki {
+ up = perm.AccessModeRead
+ }
+ unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{
+ OrgID: org.ID,
+ TeamID: apiTeam.ID,
+ Type: ut,
+ AccessMode: up,
+ })
+ }
+ teamID = apiTeam.ID
+
+ // Delete team.
+ req = NewRequestf(t, "DELETE", "/api/v1/teams/%d", teamID).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ unittest.AssertNotExistsBean(t, &organization.Team{ID: teamID})
+}
+
+func checkTeamResponse(t *testing.T, testName string, apiTeam *api.Team, name, description string, includesAllRepositories bool, permission string, units []string, unitsMap map[string]string) {
+ t.Run(testName, func(t *testing.T) {
+ assert.Equal(t, name, apiTeam.Name, "name")
+ assert.Equal(t, description, apiTeam.Description, "description")
+ assert.Equal(t, includesAllRepositories, apiTeam.IncludesAllRepositories, "includesAllRepositories")
+ assert.Equal(t, permission, apiTeam.Permission, "permission")
+ if units != nil {
+ sort.StringSlice(units).Sort()
+ sort.StringSlice(apiTeam.Units).Sort()
+ assert.EqualValues(t, units, apiTeam.Units, "units")
+ }
+ if unitsMap != nil {
+ assert.EqualValues(t, unitsMap, apiTeam.UnitsMap, "unitsMap")
+ }
+ })
+}
+
+func checkTeamBean(t *testing.T, id int64, name, description string, includesAllRepositories bool, permission string, units []string, unitsMap map[string]string) {
+ team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: id})
+ require.NoError(t, team.LoadUnits(db.DefaultContext), "LoadUnits")
+ apiTeam, err := convert.ToTeam(db.DefaultContext, team)
+ require.NoError(t, err)
+ checkTeamResponse(t, fmt.Sprintf("checkTeamBean/%s_%s", name, description), apiTeam, name, description, includesAllRepositories, permission, units, unitsMap)
+}
+
+type TeamSearchResults struct {
+ OK bool `json:"ok"`
+ Data []*api.Team `json:"data"`
+}
+
+func TestAPITeamSearch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17})
+
+ var results TeamSearchResults
+
+ token := getUserToken(t, user.Name, auth_model.AccessTokenScopeReadOrganization)
+ req := NewRequestf(t, "GET", "/api/v1/orgs/%s/teams/search?q=%s", org.Name, "_team").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &results)
+ assert.NotEmpty(t, results.Data)
+ assert.Len(t, results.Data, 1)
+ assert.Equal(t, "test_team", results.Data[0].Name)
+
+ // no access if not organization member
+ user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ token5 := getUserToken(t, user5.Name, auth_model.AccessTokenScopeReadOrganization)
+
+ req = NewRequestf(t, "GET", "/api/v1/orgs/%s/teams/search?q=%s", org.Name, "team").
+ AddTokenAuth(token5)
+ MakeRequest(t, req, http.StatusForbidden)
+}
+
+func TestAPIGetTeamRepo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
+ teamRepo := unittest.AssertExistsAndLoadBean(t, &repo.Repository{ID: 24})
+ team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 5})
+
+ var results api.Repository
+
+ token := getUserToken(t, user.Name, auth_model.AccessTokenScopeReadOrganization)
+ req := NewRequestf(t, "GET", "/api/v1/teams/%d/repos/%s/", team.ID, teamRepo.FullName()).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &results)
+ assert.Equal(t, "big_test_private_4", teamRepo.Name)
+
+ // no access if not organization member
+ user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ token5 := getUserToken(t, user5.Name, auth_model.AccessTokenScopeReadOrganization)
+
+ req = NewRequestf(t, "GET", "/api/v1/teams/%d/repos/%s/", team.ID, teamRepo.FullName()).
+ AddTokenAuth(token5)
+ MakeRequest(t, req, http.StatusNotFound)
+}
diff --git a/tests/integration/api_team_user_test.go b/tests/integration/api_team_user_test.go
new file mode 100644
index 0000000..6c80bc9
--- /dev/null
+++ b/tests/integration/api_team_user_test.go
@@ -0,0 +1,49 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/services/convert"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPITeamUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ normalUsername := "user2"
+ session := loginUser(t, normalUsername)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization)
+ req := NewRequest(t, "GET", "/api/v1/teams/1/members/user1").
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", "/api/v1/teams/1/members/user2").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var user2 *api.User
+ DecodeJSON(t, resp, &user2)
+ user2.Created = user2.Created.In(time.Local)
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+
+ expectedUser := convert.ToUser(db.DefaultContext, user, user)
+
+ // test time via unix timestamp
+ assert.EqualValues(t, expectedUser.LastLogin.Unix(), user2.LastLogin.Unix())
+ assert.EqualValues(t, expectedUser.Created.Unix(), user2.Created.Unix())
+ expectedUser.LastLogin = user2.LastLogin
+ expectedUser.Created = user2.Created
+
+ assert.Equal(t, expectedUser, user2)
+}
diff --git a/tests/integration/api_token_test.go b/tests/integration/api_token_test.go
new file mode 100644
index 0000000..01d18ef
--- /dev/null
+++ b/tests/integration/api_token_test.go
@@ -0,0 +1,565 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TestAPICreateAndDeleteToken tests that token that was just created can be deleted
+func TestAPICreateAndDeleteToken(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ newAccessToken := createAPIAccessTokenWithoutCleanUp(t, "test-key-1", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
+ deleteAPIAccessToken(t, newAccessToken, user)
+
+ newAccessToken = createAPIAccessTokenWithoutCleanUp(t, "test-key-2", user, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
+ deleteAPIAccessToken(t, newAccessToken, user)
+}
+
+// TestAPIDeleteMissingToken ensures that error is thrown when token not found
+func TestAPIDeleteMissingToken(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ req := NewRequestf(t, "DELETE", "/api/v1/users/user1/tokens/%d", unittest.NonexistentID).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNotFound)
+}
+
+// TestAPIGetTokensPermission ensures that only the admin can get tokens from other users
+func TestAPIGetTokensPermission(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // admin can get tokens for other users
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ req := NewRequest(t, "GET", "/api/v1/users/user2/tokens").
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusOK)
+
+ // non-admin can get tokens for himself
+ user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ req = NewRequest(t, "GET", "/api/v1/users/user2/tokens").
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusOK)
+
+ // non-admin can't get tokens for other users
+ user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
+ req = NewRequest(t, "GET", "/api/v1/users/user2/tokens").
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusForbidden)
+}
+
+// TestAPIDeleteTokensPermission ensures that only the admin can delete tokens from other users
+func TestAPIDeleteTokensPermission(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ user4 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
+
+ // admin can delete tokens for other users
+ createAPIAccessTokenWithoutCleanUp(t, "test-key-1", user2, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
+ req := NewRequest(t, "DELETE", "/api/v1/users/"+user2.LoginName+"/tokens/test-key-1").
+ AddBasicAuth(admin.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // non-admin can delete tokens for himself
+ createAPIAccessTokenWithoutCleanUp(t, "test-key-2", user2, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
+ req = NewRequest(t, "DELETE", "/api/v1/users/"+user2.LoginName+"/tokens/test-key-2").
+ AddBasicAuth(user2.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // non-admin can't delete tokens for other users
+ createAPIAccessTokenWithoutCleanUp(t, "test-key-3", user2, []auth_model.AccessTokenScope{auth_model.AccessTokenScopeAll})
+ req = NewRequest(t, "DELETE", "/api/v1/users/"+user2.LoginName+"/tokens/test-key-3").
+ AddBasicAuth(user4.Name)
+ MakeRequest(t, req, http.StatusForbidden)
+}
+
+type permission struct {
+ category auth_model.AccessTokenScopeCategory
+ level auth_model.AccessTokenScopeLevel
+}
+
+type requiredScopeTestCase struct {
+ url string
+ method string
+ requiredPermissions []permission
+}
+
+func (c *requiredScopeTestCase) Name() string {
+ return fmt.Sprintf("%v %v", c.method, c.url)
+}
+
+// TestAPIDeniesPermissionBasedOnTokenScope tests that API routes forbid access
+// when the correct token scope is not included.
+func TestAPIDeniesPermissionBasedOnTokenScope(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // We'll assert that each endpoint, when fetched with a token with all
+ // scopes *except* the ones specified, a forbidden status code is returned.
+ //
+ // This is to protect against endpoints having their access check copied
+ // from other endpoints and not updated.
+ //
+ // Test cases are in alphabetical order by URL.
+ testCases := []requiredScopeTestCase{
+ {
+ "/api/v1/admin/emails",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryAdmin,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/admin/users",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryAdmin,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/admin/users",
+ "POST",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryAdmin,
+ auth_model.Write,
+ },
+ },
+ },
+ {
+ "/api/v1/admin/users/user2",
+ "PATCH",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryAdmin,
+ auth_model.Write,
+ },
+ },
+ },
+ {
+ "/api/v1/admin/users/user2/orgs",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryAdmin,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/admin/users/user2/orgs",
+ "POST",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryAdmin,
+ auth_model.Write,
+ },
+ },
+ },
+ {
+ "/api/v1/admin/orgs",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryAdmin,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/notifications",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryNotification,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/notifications",
+ "PUT",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryNotification,
+ auth_model.Write,
+ },
+ },
+ },
+ {
+ "/api/v1/org/org1/repos",
+ "POST",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryOrganization,
+ auth_model.Write,
+ },
+ {
+ auth_model.AccessTokenScopeCategoryRepository,
+ auth_model.Write,
+ },
+ },
+ },
+ {
+ "/api/v1/packages/user1/type/name/1",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryPackage,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/packages/user1/type/name/1",
+ "DELETE",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryPackage,
+ auth_model.Write,
+ },
+ },
+ },
+ {
+ "/api/v1/repos/user1/repo1",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryRepository,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/repos/user1/repo1",
+ "PATCH",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryRepository,
+ auth_model.Write,
+ },
+ },
+ },
+ {
+ "/api/v1/repos/user1/repo1",
+ "DELETE",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryRepository,
+ auth_model.Write,
+ },
+ },
+ },
+ {
+ "/api/v1/repos/user1/repo1/branches",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryRepository,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/repos/user1/repo1/archive/foo",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryRepository,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/repos/user1/repo1/issues",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryIssue,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/repos/user1/repo1/media/foo",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryRepository,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/repos/user1/repo1/raw/foo",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryRepository,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/repos/user1/repo1/teams",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryRepository,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/repos/user1/repo1/teams/team1",
+ "PUT",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryRepository,
+ auth_model.Write,
+ },
+ },
+ },
+ {
+ "/api/v1/repos/user1/repo1/transfer",
+ "POST",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryRepository,
+ auth_model.Write,
+ },
+ },
+ },
+ // Private repo
+ {
+ "/api/v1/repos/user2/repo2",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryRepository,
+ auth_model.Read,
+ },
+ },
+ },
+ // Private repo
+ {
+ "/api/v1/repos/user2/repo2",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryRepository,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/user",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryUser,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/user/emails",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryUser,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/user/emails",
+ "POST",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryUser,
+ auth_model.Write,
+ },
+ },
+ },
+ {
+ "/api/v1/user/emails",
+ "DELETE",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryUser,
+ auth_model.Write,
+ },
+ },
+ },
+ {
+ "/api/v1/user/applications/oauth2",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryUser,
+ auth_model.Read,
+ },
+ },
+ },
+ {
+ "/api/v1/user/applications/oauth2",
+ "POST",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryUser,
+ auth_model.Write,
+ },
+ },
+ },
+ {
+ "/api/v1/users/search",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryUser,
+ auth_model.Read,
+ },
+ },
+ },
+ // Private user
+ {
+ "/api/v1/users/user31",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryUser,
+ auth_model.Read,
+ },
+ },
+ },
+ // Private user
+ {
+ "/api/v1/users/user31/gpg_keys",
+ "GET",
+ []permission{
+ {
+ auth_model.AccessTokenScopeCategoryUser,
+ auth_model.Read,
+ },
+ },
+ },
+ }
+
+ // User needs to be admin so that we can verify that tokens without admin
+ // scopes correctly deny access.
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ assert.True(t, user.IsAdmin, "User needs to be admin")
+
+ for _, testCase := range testCases {
+ runTestCase(t, &testCase, user)
+ }
+}
+
+// runTestCase Helper function to run a single test case.
+func runTestCase(t *testing.T, testCase *requiredScopeTestCase, user *user_model.User) {
+ t.Run(testCase.Name(), func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Create a token with all scopes NOT required by the endpoint.
+ var unauthorizedScopes []auth_model.AccessTokenScope
+ for _, category := range auth_model.AllAccessTokenScopeCategories {
+ // For permissions, Write > Read > NoAccess. So we need to
+ // find the minimum required, and only grant permission up to but
+ // not including the minimum required.
+ minRequiredLevel := auth_model.Write
+ categoryIsRequired := false
+ for _, requiredPermission := range testCase.requiredPermissions {
+ if requiredPermission.category != category {
+ continue
+ }
+ categoryIsRequired = true
+ if requiredPermission.level < minRequiredLevel {
+ minRequiredLevel = requiredPermission.level
+ }
+ }
+ unauthorizedLevel := auth_model.Write
+ if categoryIsRequired {
+ if minRequiredLevel == auth_model.Read {
+ unauthorizedLevel = auth_model.NoAccess
+ } else if minRequiredLevel == auth_model.Write {
+ unauthorizedLevel = auth_model.Read
+ } else {
+ assert.FailNow(t, "Invalid test case: Unknown access token scope level: %v", minRequiredLevel)
+ }
+ }
+
+ if unauthorizedLevel == auth_model.NoAccess {
+ continue
+ }
+ cateogoryUnauthorizedScopes := auth_model.GetRequiredScopes(
+ unauthorizedLevel,
+ category)
+ unauthorizedScopes = append(unauthorizedScopes, cateogoryUnauthorizedScopes...)
+ }
+
+ accessToken := createAPIAccessTokenWithoutCleanUp(t, "test-token", user, unauthorizedScopes)
+ defer deleteAPIAccessToken(t, accessToken, user)
+
+ // Request the endpoint. Verify that permission is denied.
+ req := NewRequest(t, testCase.method, testCase.url).
+ AddTokenAuth(accessToken.Token)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+}
+
+// createAPIAccessTokenWithoutCleanUp Create an API access token and assert that
+// creation succeeded. The caller is responsible for deleting the token.
+func createAPIAccessTokenWithoutCleanUp(t *testing.T, tokenName string, user *user_model.User, scopes []auth_model.AccessTokenScope) api.AccessToken {
+ payload := map[string]any{
+ "name": tokenName,
+ "scopes": scopes,
+ }
+
+ log.Debug("Requesting creation of token with scopes: %v", scopes)
+ req := NewRequestWithJSON(t, "POST", "/api/v1/users/"+user.LoginName+"/tokens", payload).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var newAccessToken api.AccessToken
+ DecodeJSON(t, resp, &newAccessToken)
+ unittest.AssertExistsAndLoadBean(t, &auth_model.AccessToken{
+ ID: newAccessToken.ID,
+ Name: newAccessToken.Name,
+ Token: newAccessToken.Token,
+ UID: user.ID,
+ })
+
+ return newAccessToken
+}
+
+// deleteAPIAccessToken deletes an API access token and assert that deletion succeeded.
+func deleteAPIAccessToken(t *testing.T, accessToken api.AccessToken, user *user_model.User) {
+ req := NewRequestf(t, "DELETE", "/api/v1/users/"+user.LoginName+"/tokens/%d", accessToken.ID).
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ unittest.AssertNotExistsBean(t, &auth_model.AccessToken{ID: accessToken.ID})
+}
diff --git a/tests/integration/api_twofa_test.go b/tests/integration/api_twofa_test.go
new file mode 100644
index 0000000..0bb2025
--- /dev/null
+++ b/tests/integration/api_twofa_test.go
@@ -0,0 +1,60 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/pquerna/otp/totp"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPITwoFactor(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 16})
+
+ req := NewRequest(t, "GET", "/api/v1/user").
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusOK)
+
+ otpKey, err := totp.Generate(totp.GenerateOpts{
+ SecretSize: 40,
+ Issuer: "gitea-test",
+ AccountName: user.Name,
+ })
+ require.NoError(t, err)
+
+ tfa := &auth_model.TwoFactor{
+ UID: user.ID,
+ }
+ require.NoError(t, tfa.SetSecret(otpKey.Secret()))
+
+ require.NoError(t, auth_model.NewTwoFactor(db.DefaultContext, tfa))
+
+ req = NewRequest(t, "GET", "/api/v1/user").
+ AddBasicAuth(user.Name)
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ passcode, err := totp.GenerateCode(otpKey.Secret(), time.Now())
+ require.NoError(t, err)
+
+ req = NewRequest(t, "GET", "/api/v1/user").
+ AddBasicAuth(user.Name)
+ req.Header.Set("X-Gitea-OTP", passcode)
+ MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequestf(t, "GET", "/api/v1/user").
+ AddBasicAuth(user.Name)
+ req.Header.Set("X-Forgejo-OTP", passcode)
+ MakeRequest(t, req, http.StatusOK)
+}
diff --git a/tests/integration/api_user_avatar_test.go b/tests/integration/api_user_avatar_test.go
new file mode 100644
index 0000000..22dc09a
--- /dev/null
+++ b/tests/integration/api_user_avatar_test.go
@@ -0,0 +1,77 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "encoding/base64"
+ "net/http"
+ "os"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIUpdateUserAvatar(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ normalUsername := "user2"
+ session := loginUser(t, normalUsername)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+
+ // Test what happens if you use a valid image
+ avatar, err := os.ReadFile("tests/integration/avatar.png")
+ require.NoError(t, err)
+ if err != nil {
+ assert.FailNow(t, "Unable to open avatar.png")
+ }
+
+ // Test what happens if you don't have a valid Base64 string
+ opts := api.UpdateUserAvatarOption{
+ Image: base64.StdEncoding.EncodeToString(avatar),
+ }
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/avatar", &opts).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ opts = api.UpdateUserAvatarOption{
+ Image: "Invalid",
+ }
+
+ req = NewRequestWithJSON(t, "POST", "/api/v1/user/avatar", &opts).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ // Test what happens if you use a file that is not an image
+ text, err := os.ReadFile("tests/integration/README.md")
+ require.NoError(t, err)
+ if err != nil {
+ assert.FailNow(t, "Unable to open README.md")
+ }
+
+ opts = api.UpdateUserAvatarOption{
+ Image: base64.StdEncoding.EncodeToString(text),
+ }
+
+ req = NewRequestWithJSON(t, "POST", "/api/v1/user/avatar", &opts).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusInternalServerError)
+}
+
+func TestAPIDeleteUserAvatar(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ normalUsername := "user2"
+ session := loginUser(t, normalUsername)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+
+ req := NewRequest(t, "DELETE", "/api/v1/user/avatar").
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+}
diff --git a/tests/integration/api_user_email_test.go b/tests/integration/api_user_email_test.go
new file mode 100644
index 0000000..6441e2e
--- /dev/null
+++ b/tests/integration/api_user_email_test.go
@@ -0,0 +1,129 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIListEmails(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ normalUsername := "user2"
+ session := loginUser(t, normalUsername)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
+
+ req := NewRequest(t, "GET", "/api/v1/user/emails").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var emails []*api.Email
+ DecodeJSON(t, resp, &emails)
+
+ assert.EqualValues(t, []*api.Email{
+ {
+ Email: "user2@example.com",
+ Verified: true,
+ Primary: true,
+ },
+ {
+ Email: "user2-2@example.com",
+ Verified: false,
+ Primary: false,
+ },
+ }, emails)
+}
+
+func TestAPIAddEmail(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ normalUsername := "user2"
+ session := loginUser(t, normalUsername)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+
+ opts := api.CreateEmailOption{
+ Emails: []string{"user101@example.com"},
+ }
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/emails", &opts).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+
+ opts = api.CreateEmailOption{
+ Emails: []string{"user2-3@example.com"},
+ }
+ req = NewRequestWithJSON(t, "POST", "/api/v1/user/emails", &opts).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var emails []*api.Email
+ DecodeJSON(t, resp, &emails)
+ assert.EqualValues(t, []*api.Email{
+ {
+ Email: "user2@example.com",
+ Verified: true,
+ Primary: true,
+ },
+ {
+ Email: "user2-2@example.com",
+ Verified: false,
+ Primary: false,
+ },
+ {
+ Email: "user2-3@example.com",
+ Verified: true,
+ Primary: false,
+ },
+ }, emails)
+
+ opts = api.CreateEmailOption{
+ Emails: []string{"notAEmail"},
+ }
+ req = NewRequestWithJSON(t, "POST", "/api/v1/user/emails", &opts).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusUnprocessableEntity)
+}
+
+func TestAPIDeleteEmail(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ normalUsername := "user2"
+ session := loginUser(t, normalUsername)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+
+ opts := api.DeleteEmailOption{
+ Emails: []string{"user2-3@example.com"},
+ }
+ req := NewRequestWithJSON(t, "DELETE", "/api/v1/user/emails", &opts).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ opts = api.DeleteEmailOption{
+ Emails: []string{"user2-2@example.com"},
+ }
+ req = NewRequestWithJSON(t, "DELETE", "/api/v1/user/emails", &opts).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "GET", "/api/v1/user/emails").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var emails []*api.Email
+ DecodeJSON(t, resp, &emails)
+ assert.EqualValues(t, []*api.Email{
+ {
+ Email: "user2@example.com",
+ Verified: true,
+ Primary: true,
+ },
+ }, emails)
+}
diff --git a/tests/integration/api_user_follow_test.go b/tests/integration/api_user_follow_test.go
new file mode 100644
index 0000000..68443ef
--- /dev/null
+++ b/tests/integration/api_user_follow_test.go
@@ -0,0 +1,121 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIFollow(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user1 := "user4"
+ user2 := "user10"
+
+ session1 := loginUser(t, user1)
+ token1 := getTokenForLoggedInUser(t, session1, auth_model.AccessTokenScopeReadUser)
+
+ session2 := loginUser(t, user2)
+ token2 := getTokenForLoggedInUser(t, session2, auth_model.AccessTokenScopeWriteUser)
+
+ t.Run("Follow", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/following/%s", user1)).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNoContent)
+ })
+
+ t.Run("ListFollowing", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/following", user2)).
+ AddTokenAuth(token2)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var users []api.User
+ DecodeJSON(t, resp, &users)
+ assert.Len(t, users, 1)
+ assert.Equal(t, user1, users[0].UserName)
+ })
+
+ t.Run("ListMyFollowing", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/following").
+ AddTokenAuth(token2)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var users []api.User
+ DecodeJSON(t, resp, &users)
+ assert.Len(t, users, 1)
+ assert.Equal(t, user1, users[0].UserName)
+ })
+
+ t.Run("ListFollowers", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/followers", user1)).
+ AddTokenAuth(token1)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var users []api.User
+ DecodeJSON(t, resp, &users)
+ assert.Len(t, users, 1)
+ assert.Equal(t, user2, users[0].UserName)
+ })
+
+ t.Run("ListMyFollowers", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/followers").
+ AddTokenAuth(token1)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var users []api.User
+ DecodeJSON(t, resp, &users)
+ assert.Len(t, users, 1)
+ assert.Equal(t, user2, users[0].UserName)
+ })
+
+ t.Run("CheckFollowing", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/following/%s", user2, user1)).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/following/%s", user1, user2)).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("CheckMyFollowing", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/following/%s", user1)).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/following/%s", user2)).
+ AddTokenAuth(token1)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("Unfollow", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/following/%s", user1)).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNoContent)
+ })
+}
diff --git a/tests/integration/api_user_heatmap_test.go b/tests/integration/api_user_heatmap_test.go
new file mode 100644
index 0000000..a235367
--- /dev/null
+++ b/tests/integration/api_user_heatmap_test.go
@@ -0,0 +1,39 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestUserHeatmap(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminUsername := "user1"
+ normalUsername := "user2"
+ token := getUserToken(t, adminUsername, auth_model.AccessTokenScopeReadUser)
+
+ fakeNow := time.Date(2011, 10, 20, 0, 0, 0, 0, time.Local)
+ timeutil.MockSet(fakeNow)
+ defer timeutil.MockUnset()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/heatmap", normalUsername)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var heatmap []*activities_model.UserHeatmapData
+ DecodeJSON(t, resp, &heatmap)
+ var dummyheatmap []*activities_model.UserHeatmapData
+ dummyheatmap = append(dummyheatmap, &activities_model.UserHeatmapData{Timestamp: 1603227600, Contributions: 1})
+
+ assert.Equal(t, dummyheatmap, heatmap)
+}
diff --git a/tests/integration/api_user_info_test.go b/tests/integration/api_user_info_test.go
new file mode 100644
index 0000000..89f7266
--- /dev/null
+++ b/tests/integration/api_user_info_test.go
@@ -0,0 +1,70 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIUserInfo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := "user1"
+ user2 := "user31"
+
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"})
+
+ session := loginUser(t, user)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
+
+ t.Run("GetInfo", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s", user2)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var u api.User
+ DecodeJSON(t, resp, &u)
+ assert.Equal(t, user2, u.UserName)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s", user2))
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // test if the placaholder Mail is returned if a User is not logged in
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s", org3.Name))
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &u)
+ assert.Equal(t, org3.GetPlaceholderEmail(), u.Email)
+
+ // Test if the correct Mail is returned if a User is logged in
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s", org3.Name)).
+ AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &u)
+ assert.Equal(t, org3.GetEmail(), u.Email)
+ })
+
+ t.Run("GetAuthenticatedUser", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var u api.User
+ DecodeJSON(t, resp, &u)
+ assert.Equal(t, user, u.UserName)
+ })
+}
diff --git a/tests/integration/api_user_org_perm_test.go b/tests/integration/api_user_org_perm_test.go
new file mode 100644
index 0000000..85bb1db
--- /dev/null
+++ b/tests/integration/api_user_org_perm_test.go
@@ -0,0 +1,153 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type apiUserOrgPermTestCase struct {
+ LoginUser string
+ User string
+ Organization string
+ ExpectedOrganizationPermissions api.OrganizationPermissions
+}
+
+func TestTokenNeeded(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/users/user1/orgs/org6/permissions")
+ MakeRequest(t, req, http.StatusUnauthorized)
+}
+
+func sampleTest(t *testing.T, auoptc apiUserOrgPermTestCase) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, auoptc.LoginUser)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadUser)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/orgs/%s/permissions", auoptc.User, auoptc.Organization)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var apiOP api.OrganizationPermissions
+ DecodeJSON(t, resp, &apiOP)
+ assert.Equal(t, auoptc.ExpectedOrganizationPermissions.IsOwner, apiOP.IsOwner)
+ assert.Equal(t, auoptc.ExpectedOrganizationPermissions.IsAdmin, apiOP.IsAdmin)
+ assert.Equal(t, auoptc.ExpectedOrganizationPermissions.CanWrite, apiOP.CanWrite)
+ assert.Equal(t, auoptc.ExpectedOrganizationPermissions.CanRead, apiOP.CanRead)
+ assert.Equal(t, auoptc.ExpectedOrganizationPermissions.CanCreateRepository, apiOP.CanCreateRepository)
+}
+
+func TestWithOwnerUser(t *testing.T) {
+ sampleTest(t, apiUserOrgPermTestCase{
+ LoginUser: "user2",
+ User: "user2",
+ Organization: "org3",
+ ExpectedOrganizationPermissions: api.OrganizationPermissions{
+ IsOwner: true,
+ IsAdmin: true,
+ CanWrite: true,
+ CanRead: true,
+ CanCreateRepository: true,
+ },
+ })
+}
+
+func TestCanWriteUser(t *testing.T) {
+ sampleTest(t, apiUserOrgPermTestCase{
+ LoginUser: "user4",
+ User: "user4",
+ Organization: "org3",
+ ExpectedOrganizationPermissions: api.OrganizationPermissions{
+ IsOwner: false,
+ IsAdmin: false,
+ CanWrite: true,
+ CanRead: true,
+ CanCreateRepository: false,
+ },
+ })
+}
+
+func TestAdminUser(t *testing.T) {
+ sampleTest(t, apiUserOrgPermTestCase{
+ LoginUser: "user1",
+ User: "user28",
+ Organization: "org3",
+ ExpectedOrganizationPermissions: api.OrganizationPermissions{
+ IsOwner: false,
+ IsAdmin: true,
+ CanWrite: true,
+ CanRead: true,
+ CanCreateRepository: true,
+ },
+ })
+}
+
+func TestAdminCanNotCreateRepo(t *testing.T) {
+ sampleTest(t, apiUserOrgPermTestCase{
+ LoginUser: "user1",
+ User: "user28",
+ Organization: "org6",
+ ExpectedOrganizationPermissions: api.OrganizationPermissions{
+ IsOwner: false,
+ IsAdmin: true,
+ CanWrite: true,
+ CanRead: true,
+ CanCreateRepository: false,
+ },
+ })
+}
+
+func TestCanReadUser(t *testing.T) {
+ sampleTest(t, apiUserOrgPermTestCase{
+ LoginUser: "user1",
+ User: "user24",
+ Organization: "org25",
+ ExpectedOrganizationPermissions: api.OrganizationPermissions{
+ IsOwner: false,
+ IsAdmin: false,
+ CanWrite: false,
+ CanRead: true,
+ CanCreateRepository: false,
+ },
+ })
+}
+
+func TestUnknowUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization)
+
+ req := NewRequest(t, "GET", "/api/v1/users/unknow/orgs/org25/permissions").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusNotFound)
+
+ var apiError api.APIError
+ DecodeJSON(t, resp, &apiError)
+ assert.Equal(t, "user redirect does not exist [name: unknow]", apiError.Message)
+}
+
+func TestUnknowOrganization(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopeReadOrganization)
+
+ req := NewRequest(t, "GET", "/api/v1/users/user1/orgs/unknow/permissions").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusNotFound)
+ var apiError api.APIError
+ DecodeJSON(t, resp, &apiError)
+ assert.Equal(t, "GetUserByName", apiError.Message)
+}
diff --git a/tests/integration/api_user_orgs_test.go b/tests/integration/api_user_orgs_test.go
new file mode 100644
index 0000000..e311994
--- /dev/null
+++ b/tests/integration/api_user_orgs_test.go
@@ -0,0 +1,132 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestUserOrgs(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminUsername := "user1"
+ normalUsername := "user2"
+ privateMemberUsername := "user4"
+ unrelatedUsername := "user5"
+
+ orgs := getUserOrgs(t, adminUsername, normalUsername)
+
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"})
+ org17 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org17"})
+
+ assert.Equal(t, []*api.Organization{
+ {
+ ID: 17,
+ Name: org17.Name,
+ UserName: org17.Name,
+ FullName: org17.FullName,
+ Email: org17.Email,
+ AvatarURL: org17.AvatarLink(db.DefaultContext),
+ Description: "",
+ Website: "",
+ Location: "",
+ Visibility: "public",
+ },
+ {
+ ID: 3,
+ Name: org3.Name,
+ UserName: org3.Name,
+ FullName: org3.FullName,
+ Email: org3.Email,
+ AvatarURL: org3.AvatarLink(db.DefaultContext),
+ Description: "",
+ Website: "",
+ Location: "",
+ Visibility: "public",
+ },
+ }, orgs)
+
+ // user itself should get it's org's he is a member of
+ orgs = getUserOrgs(t, privateMemberUsername, privateMemberUsername)
+ assert.Len(t, orgs, 1)
+
+ // unrelated user should not get private org membership of privateMemberUsername
+ orgs = getUserOrgs(t, unrelatedUsername, privateMemberUsername)
+ assert.Empty(t, orgs)
+
+ // not authenticated call should not be allowed
+ testUserOrgsUnauthenticated(t, privateMemberUsername)
+}
+
+func getUserOrgs(t *testing.T, userDoer, userCheck string) (orgs []*api.Organization) {
+ token := ""
+ if len(userDoer) != 0 {
+ token = getUserToken(t, userDoer, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadUser)
+ }
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/orgs", userCheck)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &orgs)
+ return orgs
+}
+
+func testUserOrgsUnauthenticated(t *testing.T, userCheck string) {
+ session := emptyTestSession(t)
+ req := NewRequestf(t, "GET", "/api/v1/users/%s/orgs", userCheck)
+ session.MakeRequest(t, req, http.StatusUnauthorized)
+}
+
+func TestMyOrgs(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/orgs")
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ normalUsername := "user2"
+ token := getUserToken(t, normalUsername, auth_model.AccessTokenScopeReadOrganization, auth_model.AccessTokenScopeReadUser)
+ req = NewRequest(t, "GET", "/api/v1/user/orgs").
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var orgs []*api.Organization
+ DecodeJSON(t, resp, &orgs)
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"})
+ org17 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org17"})
+
+ assert.Equal(t, []*api.Organization{
+ {
+ ID: 17,
+ Name: org17.Name,
+ UserName: org17.Name,
+ FullName: org17.FullName,
+ Email: org17.Email,
+ AvatarURL: org17.AvatarLink(db.DefaultContext),
+ Description: "",
+ Website: "",
+ Location: "",
+ Visibility: "public",
+ },
+ {
+ ID: 3,
+ Name: org3.Name,
+ UserName: org3.Name,
+ FullName: org3.FullName,
+ Email: org3.Email,
+ AvatarURL: org3.AvatarLink(db.DefaultContext),
+ Description: "",
+ Website: "",
+ Location: "",
+ Visibility: "public",
+ },
+ }, orgs)
+}
diff --git a/tests/integration/api_user_search_test.go b/tests/integration/api_user_search_test.go
new file mode 100644
index 0000000..97c42aa
--- /dev/null
+++ b/tests/integration/api_user_search_test.go
@@ -0,0 +1,145 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type SearchResults struct {
+ OK bool `json:"ok"`
+ Data []*api.User `json:"data"`
+}
+
+func TestAPIUserSearchLoggedIn(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminUsername := "user1"
+ session := loginUser(t, adminUsername)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
+ query := "user2"
+ req := NewRequestf(t, "GET", "/api/v1/users/search?q=%s", query).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var results SearchResults
+ DecodeJSON(t, resp, &results)
+ assert.NotEmpty(t, results.Data)
+ for _, user := range results.Data {
+ assert.Contains(t, user.UserName, query)
+ assert.NotEmpty(t, user.Email)
+ }
+
+ publicToken := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser, auth_model.AccessTokenScopePublicOnly)
+ req = NewRequestf(t, "GET", "/api/v1/users/search?q=%s", query).
+ AddTokenAuth(publicToken)
+ resp = MakeRequest(t, req, http.StatusOK)
+ results = SearchResults{}
+ DecodeJSON(t, resp, &results)
+ assert.NotEmpty(t, results.Data)
+ for _, user := range results.Data {
+ assert.Contains(t, user.UserName, query)
+ assert.NotEmpty(t, user.Email)
+ assert.Equal(t, "public", user.Visibility)
+ }
+}
+
+func TestAPIUserSearchNotLoggedIn(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ query := "user2"
+ req := NewRequestf(t, "GET", "/api/v1/users/search?q=%s", query)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var results SearchResults
+ DecodeJSON(t, resp, &results)
+ assert.NotEmpty(t, results.Data)
+ var modelUser *user_model.User
+ for _, user := range results.Data {
+ assert.Contains(t, user.UserName, query)
+ modelUser = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID})
+ assert.EqualValues(t, modelUser.GetPlaceholderEmail(), user.Email)
+ }
+}
+
+func TestAPIUserSearchPaged(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.API.DefaultPagingNum, 5)()
+
+ req := NewRequest(t, "GET", "/api/v1/users/search?limit=1")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var limitedResults SearchResults
+ DecodeJSON(t, resp, &limitedResults)
+ assert.Len(t, limitedResults.Data, 1)
+
+ req = NewRequest(t, "GET", "/api/v1/users/search")
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ var results SearchResults
+ DecodeJSON(t, resp, &results)
+ assert.Len(t, results.Data, 5)
+}
+
+func TestAPIUserSearchSystemUsers(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ for _, systemUser := range []*user_model.User{
+ user_model.NewGhostUser(),
+ user_model.NewActionsUser(),
+ } {
+ t.Run(systemUser.Name, func(t *testing.T) {
+ req := NewRequestf(t, "GET", "/api/v1/users/search?uid=%d", systemUser.ID)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var results SearchResults
+ DecodeJSON(t, resp, &results)
+ assert.NotEmpty(t, results.Data)
+ if assert.Len(t, results.Data, 1) {
+ user := results.Data[0]
+ assert.EqualValues(t, user.UserName, systemUser.Name)
+ assert.EqualValues(t, user.ID, systemUser.ID)
+ }
+ })
+ }
+}
+
+func TestAPIUserSearchAdminLoggedInUserHidden(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminUsername := "user1"
+ session := loginUser(t, adminUsername)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
+ query := "user31"
+ req := NewRequestf(t, "GET", "/api/v1/users/search?q=%s", query).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var results SearchResults
+ DecodeJSON(t, resp, &results)
+ assert.NotEmpty(t, results.Data)
+ for _, user := range results.Data {
+ assert.Contains(t, user.UserName, query)
+ assert.NotEmpty(t, user.Email)
+ assert.EqualValues(t, "private", user.Visibility)
+ }
+}
+
+func TestAPIUserSearchNotLoggedInUserHidden(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ query := "user31"
+ req := NewRequestf(t, "GET", "/api/v1/users/search?q=%s", query)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var results SearchResults
+ DecodeJSON(t, resp, &results)
+ assert.Empty(t, results.Data)
+}
diff --git a/tests/integration/api_user_secrets_test.go b/tests/integration/api_user_secrets_test.go
new file mode 100644
index 0000000..56bf30e
--- /dev/null
+++ b/tests/integration/api_user_secrets_test.go
@@ -0,0 +1,101 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+)
+
+func TestAPIUserSecrets(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+
+ t.Run("Create", func(t *testing.T) {
+ cases := []struct {
+ Name string
+ ExpectedStatus int
+ }{
+ {
+ Name: "",
+ ExpectedStatus: http.StatusNotFound,
+ },
+ {
+ Name: "-",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "_",
+ ExpectedStatus: http.StatusCreated,
+ },
+ {
+ Name: "secret",
+ ExpectedStatus: http.StatusCreated,
+ },
+ {
+ Name: "2secret",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "GITEA_secret",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "GITHUB_secret",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ }
+
+ for _, c := range cases {
+ req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/user/actions/secrets/%s", c.Name), api.CreateOrUpdateSecretOption{
+ Data: "data",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, c.ExpectedStatus)
+ }
+ })
+
+ t.Run("Update", func(t *testing.T) {
+ name := "update_secret"
+ url := fmt.Sprintf("/api/v1/user/actions/secrets/%s", name)
+
+ req := NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{
+ Data: "initial",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{
+ Data: "changed",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ name := "delete_secret"
+ url := fmt.Sprintf("/api/v1/user/actions/secrets/%s", name)
+
+ req := NewRequestWithJSON(t, "PUT", url, api.CreateOrUpdateSecretOption{
+ Data: "initial",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+
+ req = NewRequest(t, "DELETE", url).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "DELETE", url).
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "DELETE", "/api/v1/user/actions/secrets/000").
+ AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusBadRequest)
+ })
+}
diff --git a/tests/integration/api_user_star_test.go b/tests/integration/api_user_star_test.go
new file mode 100644
index 0000000..aafe9cf
--- /dev/null
+++ b/tests/integration/api_user_star_test.go
@@ -0,0 +1,118 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIStar(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := "user1"
+ repo := "user2/repo1"
+
+ session := loginUser(t, user)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
+ tokenWithUserScope := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser, auth_model.AccessTokenScopeWriteRepository)
+
+ assertDisabledStarsNotFound := func(t *testing.T, req *RequestWrapper) {
+ t.Helper()
+
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.Repository.DisableStars, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ MakeRequest(t, req, http.StatusNotFound)
+ }
+
+ t.Run("Star", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/user/starred/%s", repo)).
+ AddTokenAuth(tokenWithUserScope)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ t.Run("disabled stars", func(t *testing.T) {
+ assertDisabledStarsNotFound(t, req)
+ })
+ })
+
+ t.Run("GetStarredRepos", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/starred", user)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "1", resp.Header().Get("X-Total-Count"))
+
+ var repos []api.Repository
+ DecodeJSON(t, resp, &repos)
+ assert.Len(t, repos, 1)
+ assert.Equal(t, repo, repos[0].FullName)
+
+ t.Run("disabled stars", func(t *testing.T) {
+ assertDisabledStarsNotFound(t, req)
+ })
+ })
+
+ t.Run("GetMyStarredRepos", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/starred").
+ AddTokenAuth(tokenWithUserScope)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "1", resp.Header().Get("X-Total-Count"))
+
+ var repos []api.Repository
+ DecodeJSON(t, resp, &repos)
+ assert.Len(t, repos, 1)
+ assert.Equal(t, repo, repos[0].FullName)
+
+ t.Run("disabled stars", func(t *testing.T) {
+ assertDisabledStarsNotFound(t, req)
+ })
+ })
+
+ t.Run("IsStarring", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/starred/%s", repo)).
+ AddTokenAuth(tokenWithUserScope)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/user/starred/%s", repo+"notexisting")).
+ AddTokenAuth(tokenWithUserScope)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ t.Run("disabled stars", func(t *testing.T) {
+ assertDisabledStarsNotFound(t, req)
+ })
+ })
+
+ t.Run("Unstar", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/user/starred/%s", repo)).
+ AddTokenAuth(tokenWithUserScope)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ t.Run("disabled stars", func(t *testing.T) {
+ assertDisabledStarsNotFound(t, req)
+ })
+ })
+}
diff --git a/tests/integration/api_user_variables_test.go b/tests/integration/api_user_variables_test.go
new file mode 100644
index 0000000..9fd84dd
--- /dev/null
+++ b/tests/integration/api_user_variables_test.go
@@ -0,0 +1,144 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+)
+
+func TestAPIUserVariables(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+
+ t.Run("CreateUserVariable", func(t *testing.T) {
+ cases := []struct {
+ Name string
+ ExpectedStatus int
+ }{
+ {
+ Name: "-",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "_",
+ ExpectedStatus: http.StatusNoContent,
+ },
+ {
+ Name: "TEST_VAR",
+ ExpectedStatus: http.StatusNoContent,
+ },
+ {
+ Name: "test_var",
+ ExpectedStatus: http.StatusConflict,
+ },
+ {
+ Name: "ci",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "123var",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "var@test",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "github_var",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: "gitea_var",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ }
+
+ for _, c := range cases {
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/user/actions/variables/%s", c.Name), api.CreateVariableOption{
+ Value: "value",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, c.ExpectedStatus)
+ }
+ })
+
+ t.Run("UpdateUserVariable", func(t *testing.T) {
+ variableName := "test_update_var"
+ url := fmt.Sprintf("/api/v1/user/actions/variables/%s", variableName)
+ req := NewRequestWithJSON(t, "POST", url, api.CreateVariableOption{
+ Value: "initial_val",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ cases := []struct {
+ Name string
+ UpdateName string
+ ExpectedStatus int
+ }{
+ {
+ Name: "not_found_var",
+ ExpectedStatus: http.StatusNotFound,
+ },
+ {
+ Name: variableName,
+ UpdateName: "1invalid",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: variableName,
+ UpdateName: "invalid@name",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: variableName,
+ UpdateName: "ci",
+ ExpectedStatus: http.StatusBadRequest,
+ },
+ {
+ Name: variableName,
+ UpdateName: "updated_var_name",
+ ExpectedStatus: http.StatusNoContent,
+ },
+ {
+ Name: variableName,
+ ExpectedStatus: http.StatusNotFound,
+ },
+ {
+ Name: "updated_var_name",
+ ExpectedStatus: http.StatusNoContent,
+ },
+ }
+
+ for _, c := range cases {
+ req := NewRequestWithJSON(t, "PUT", fmt.Sprintf("/api/v1/user/actions/variables/%s", c.Name), api.UpdateVariableOption{
+ Name: c.UpdateName,
+ Value: "updated_val",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, c.ExpectedStatus)
+ }
+ })
+
+ t.Run("DeleteRepoVariable", func(t *testing.T) {
+ variableName := "test_delete_var"
+ url := fmt.Sprintf("/api/v1/user/actions/variables/%s", variableName)
+
+ req := NewRequestWithJSON(t, "POST", url, api.CreateVariableOption{
+ Value: "initial_val",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "DELETE", url).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ req = NewRequest(t, "DELETE", url).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+}
diff --git a/tests/integration/api_user_watch_test.go b/tests/integration/api_user_watch_test.go
new file mode 100644
index 0000000..953e005
--- /dev/null
+++ b/tests/integration/api_user_watch_test.go
@@ -0,0 +1,88 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestAPIWatch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := "user1"
+ repo := "user2/repo1"
+
+ session := loginUser(t, user)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
+ tokenWithRepoScope := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeReadUser)
+
+ t.Run("Watch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/subscription", repo)).
+ AddTokenAuth(tokenWithRepoScope)
+ MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("GetWatchedRepos", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/users/%s/subscriptions", user)).
+ AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "1", resp.Header().Get("X-Total-Count"))
+
+ var repos []api.Repository
+ DecodeJSON(t, resp, &repos)
+ assert.Len(t, repos, 1)
+ assert.Equal(t, repo, repos[0].FullName)
+ })
+
+ t.Run("GetMyWatchedRepos", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user/subscriptions").
+ AddTokenAuth(tokenWithRepoScope)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "1", resp.Header().Get("X-Total-Count"))
+
+ var repos []api.Repository
+ DecodeJSON(t, resp, &repos)
+ assert.Len(t, repos, 1)
+ assert.Equal(t, repo, repos[0].FullName)
+ })
+
+ t.Run("IsWatching", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/subscription", repo))
+ MakeRequest(t, req, http.StatusUnauthorized)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/subscription", repo)).
+ AddTokenAuth(tokenWithRepoScope)
+ MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/subscription", repo+"notexisting")).
+ AddTokenAuth(tokenWithRepoScope)
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("Unwatch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/subscription", repo)).
+ AddTokenAuth(tokenWithRepoScope)
+ MakeRequest(t, req, http.StatusNoContent)
+ })
+}
diff --git a/tests/integration/api_wiki_test.go b/tests/integration/api_wiki_test.go
new file mode 100644
index 0000000..e5eb7a5
--- /dev/null
+++ b/tests/integration/api_wiki_test.go
@@ -0,0 +1,411 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/optional"
+ api "code.gitea.io/gitea/modules/structs"
+ repo_service "code.gitea.io/gitea/services/repository"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIRenameWikiBranch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ username := "user2"
+ session := loginUser(t, username)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ repoURLStr := fmt.Sprintf("/api/v1/repos/%s/%s", username, "repo1")
+ wikiBranch := "wiki"
+ req := NewRequestWithJSON(t, "PATCH", repoURLStr, &api.EditRepoOption{
+ WikiBranch: &wikiBranch,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ assert.Equal(t, "wiki", repo.WikiBranch)
+
+ req = NewRequest(t, "GET", repoURLStr)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var repoData *api.Repository
+ DecodeJSON(t, resp, &repoData)
+ assert.Equal(t, "wiki", repoData.WikiBranch)
+}
+
+func TestAPIGetWikiPage(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ username := "user2"
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/Home", username, "repo1")
+
+ req := NewRequest(t, "GET", urlStr)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var page *api.WikiPage
+ DecodeJSON(t, resp, &page)
+
+ assert.Equal(t, &api.WikiPage{
+ WikiPageMetaData: &api.WikiPageMetaData{
+ Title: "Home",
+ HTMLURL: page.HTMLURL,
+ SubURL: "Home",
+ LastCommit: &api.WikiCommit{
+ ID: "2c54faec6c45d31c1abfaecdab471eac6633738a",
+ Author: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "Ethan Koenig",
+ Email: "ethantkoenig@gmail.com",
+ },
+ Date: "2017-11-27T04:31:18Z",
+ },
+ Committer: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "Ethan Koenig",
+ Email: "ethantkoenig@gmail.com",
+ },
+ Date: "2017-11-27T04:31:18Z",
+ },
+ Message: "Add Home.md\n",
+ },
+ },
+ ContentBase64: base64.RawStdEncoding.EncodeToString(
+ []byte("# Home page\n\nThis is the home page!\n"),
+ ),
+ CommitCount: 1,
+ Sidebar: "",
+ Footer: "",
+ }, page)
+}
+
+func TestAPIListWikiPages(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ username := "user2"
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/pages", username, "repo1")
+
+ req := NewRequest(t, "GET", urlStr)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var meta []*api.WikiPageMetaData
+ DecodeJSON(t, resp, &meta)
+
+ dummymeta := []*api.WikiPageMetaData{
+ {
+ Title: "Home",
+ HTMLURL: meta[0].HTMLURL,
+ SubURL: "Home",
+ LastCommit: &api.WikiCommit{
+ ID: "2c54faec6c45d31c1abfaecdab471eac6633738a",
+ Author: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "Ethan Koenig",
+ Email: "ethantkoenig@gmail.com",
+ },
+ Date: "2017-11-27T04:31:18Z",
+ },
+ Committer: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "Ethan Koenig",
+ Email: "ethantkoenig@gmail.com",
+ },
+ Date: "2017-11-27T04:31:18Z",
+ },
+ Message: "Add Home.md\n",
+ },
+ },
+ {
+ Title: "Page With Image",
+ HTMLURL: meta[1].HTMLURL,
+ SubURL: "Page-With-Image",
+ LastCommit: &api.WikiCommit{
+ ID: "0cf15c3f66ec8384480ed9c3cf87c9e97fbb0ec3",
+ Author: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "Gabriel Silva Simões",
+ Email: "simoes.sgabriel@gmail.com",
+ },
+ Date: "2019-01-25T01:41:55Z",
+ },
+ Committer: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "Gabriel Silva Simões",
+ Email: "simoes.sgabriel@gmail.com",
+ },
+ Date: "2019-01-25T01:41:55Z",
+ },
+ Message: "Add jpeg.jpg and page with image\n",
+ },
+ },
+ {
+ Title: "Page With Spaced Name",
+ HTMLURL: meta[2].HTMLURL,
+ SubURL: "Page-With-Spaced-Name",
+ LastCommit: &api.WikiCommit{
+ ID: "c10d10b7e655b3dab1f53176db57c8219a5488d6",
+ Author: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "Gabriel Silva Simões",
+ Email: "simoes.sgabriel@gmail.com",
+ },
+ Date: "2019-01-25T01:39:51Z",
+ },
+ Committer: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "Gabriel Silva Simões",
+ Email: "simoes.sgabriel@gmail.com",
+ },
+ Date: "2019-01-25T01:39:51Z",
+ },
+ Message: "Add page with spaced name\n",
+ },
+ },
+ {
+ Title: "Unescaped File",
+ HTMLURL: meta[3].HTMLURL,
+ SubURL: "Unescaped-File",
+ LastCommit: &api.WikiCommit{
+ ID: "0dca5bd9b5d7ef937710e056f575e86c0184ba85",
+ Author: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "6543",
+ Email: "6543@obermui.de",
+ },
+ Date: "2021-07-19T16:42:46Z",
+ },
+ Committer: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "6543",
+ Email: "6543@obermui.de",
+ },
+ Date: "2021-07-19T16:42:46Z",
+ },
+ Message: "add unescaped file\n",
+ },
+ },
+ }
+
+ assert.Equal(t, dummymeta, meta)
+}
+
+func TestAPINewWikiPage(t *testing.T) {
+ for _, title := range []string{
+ "New page",
+ "&&&&",
+ } {
+ defer tests.PrepareTestEnv(t)()
+ username := "user2"
+ session := loginUser(t, username)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/new", username, "repo1")
+
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateWikiPageOptions{
+ Title: title,
+ ContentBase64: base64.StdEncoding.EncodeToString([]byte("Wiki page content for API unit tests")),
+ Message: "",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusCreated)
+ }
+}
+
+func TestAPIEditWikiPage(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ username := "user2"
+ session := loginUser(t, username)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/Page-With-Spaced-Name", username, "repo1")
+
+ req := NewRequestWithJSON(t, "PATCH", urlStr, &api.CreateWikiPageOptions{
+ Title: "edited title",
+ ContentBase64: base64.StdEncoding.EncodeToString([]byte("Edited wiki page content for API unit tests")),
+ Message: "",
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+}
+
+func TestAPIEditOtherWikiPage(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // (drive-by-user) user, session, and token for a drive-by wiki editor
+ username := "drive-by-user"
+ req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
+ "user_name": username,
+ "email": "drive-by@example.com",
+ "password": "examplePassword!1",
+ "retype": "examplePassword!1",
+ })
+ MakeRequest(t, req, http.StatusSeeOther)
+ session := loginUserWithPassword(t, username, "examplePassword!1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ // (user2) user for the user whose wiki we're going to edit (as drive-by-user)
+ otherUsername := "user2"
+
+ // Creating a new Wiki page on user2's repo as user1 fails
+ testCreateWiki := func(expectedStatusCode int) {
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/new", otherUsername, "repo1")
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateWikiPageOptions{
+ Title: "Globally Edited Page",
+ ContentBase64: base64.StdEncoding.EncodeToString([]byte("Wiki page content for API unit tests")),
+ Message: "",
+ }).AddTokenAuth(token)
+ session.MakeRequest(t, req, expectedStatusCode)
+ }
+ testCreateWiki(http.StatusForbidden)
+
+ // Update the repo settings for user2's repo to enable globally writeable wiki
+ ctx := context.Background()
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ var units []repo_model.RepoUnit
+ units = append(units, repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unit_model.TypeWiki,
+ Config: new(repo_model.UnitConfig),
+ DefaultPermissions: repo_model.UnitAccessModeWrite,
+ })
+ err := repo_service.UpdateRepositoryUnits(ctx, repo, units, nil)
+ require.NoError(t, err)
+
+ // Creating a new Wiki page on user2's repo works now
+ testCreateWiki(http.StatusCreated)
+}
+
+func TestAPISetWikiGlobalEditability(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ // Create a new repository for testing purposes
+ repo, _, f := tests.CreateDeclarativeRepo(t, user, "", []unit_model.Type{
+ unit_model.TypeCode,
+ unit_model.TypeWiki,
+ }, nil, nil)
+ defer f()
+ urlStr := fmt.Sprintf("/api/v1/repos/%s", repo.FullName())
+
+ assertGlobalEditability := func(t *testing.T, editability bool) {
+ t.Helper()
+
+ req := NewRequest(t, "GET", urlStr)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var opts api.Repository
+ DecodeJSON(t, resp, &opts)
+
+ assert.Equal(t, opts.GloballyEditableWiki, editability)
+ }
+
+ t.Run("api includes GloballyEditableWiki", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertGlobalEditability(t, false)
+ })
+
+ t.Run("api can turn on GloballyEditableWiki", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ globallyEditable := true
+ req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditRepoOption{
+ GloballyEditableWiki: &globallyEditable,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ assertGlobalEditability(t, true)
+ })
+
+ t.Run("disabling the wiki disables GloballyEditableWiki", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ hasWiki := false
+ req := NewRequestWithJSON(t, "PATCH", urlStr, &api.EditRepoOption{
+ HasWiki: &hasWiki,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ assertGlobalEditability(t, false)
+ })
+}
+
+func TestAPIListPageRevisions(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ username := "user2"
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/revisions/Home", username, "repo1")
+
+ req := NewRequest(t, "GET", urlStr)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var revisions *api.WikiCommitList
+ DecodeJSON(t, resp, &revisions)
+
+ dummyrevisions := &api.WikiCommitList{
+ WikiCommits: []*api.WikiCommit{
+ {
+ ID: "2c54faec6c45d31c1abfaecdab471eac6633738a",
+ Author: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "Ethan Koenig",
+ Email: "ethantkoenig@gmail.com",
+ },
+ Date: "2017-11-27T04:31:18Z",
+ },
+ Committer: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "Ethan Koenig",
+ Email: "ethantkoenig@gmail.com",
+ },
+ Date: "2017-11-27T04:31:18Z",
+ },
+ Message: "Add Home.md\n",
+ },
+ },
+ Count: 1,
+ }
+
+ assert.Equal(t, dummyrevisions, revisions)
+}
+
+func TestAPIWikiNonMasterBranch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ repo, _, f := tests.CreateDeclarativeRepoWithOptions(t, user, tests.DeclarativeRepoOptions{
+ WikiBranch: optional.Some("main"),
+ })
+ defer f()
+
+ uris := []string{
+ "revisions/Home",
+ "pages",
+ "page/Home",
+ }
+ baseURL := fmt.Sprintf("/api/v1/repos/%s/wiki", repo.FullName())
+ for _, uri := range uris {
+ t.Run(uri, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestf(t, "GET", "%s/%s", baseURL, uri)
+ MakeRequest(t, req, http.StatusOK)
+ })
+ }
+}
diff --git a/tests/integration/archived_labels_display_test.go b/tests/integration/archived_labels_display_test.go
new file mode 100644
index 0000000..c9748f8
--- /dev/null
+++ b/tests/integration/archived_labels_display_test.go
@@ -0,0 +1,71 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestArchivedLabelVisualProperties(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user2")
+
+ // Create labels
+ session.MakeRequest(t, NewRequestWithValues(t, "POST", "user2/repo1/labels/new", map[string]string{
+ "_csrf": GetCSRF(t, session, "user2/repo1/labels"),
+ "title": "active_label",
+ "description": "",
+ "color": "#aa00aa",
+ }), http.StatusSeeOther)
+ session.MakeRequest(t, NewRequestWithValues(t, "POST", "user2/repo1/labels/new", map[string]string{
+ "_csrf": GetCSRF(t, session, "user2/repo1/labels"),
+ "title": "archived_label",
+ "description": "",
+ "color": "#00aa00",
+ }), http.StatusSeeOther)
+
+ // Get ID of label to archive it
+ var id string
+ doc := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", "user2/repo1/labels"), http.StatusOK).Body)
+ doc.Find(".issue-label-list .item").Each(func(i int, s *goquery.Selection) {
+ label := s.Find(".label-title .label")
+ if label.Text() == "archived_label" {
+ href, _ := s.Find(".label-issues a.open-issues").Attr("href")
+ hrefParts := strings.Split(href, "=")
+ id = hrefParts[len(hrefParts)-1]
+ }
+ })
+
+ // Make label archived
+ session.MakeRequest(t, NewRequestWithValues(t, "POST", "user2/repo1/labels/edit", map[string]string{
+ "_csrf": GetCSRF(t, session, "user2/repo1/labels"),
+ "id": id,
+ "title": "archived_label",
+ "is_archived": "on",
+ "description": "",
+ "color": "#00aa00",
+ }), http.StatusSeeOther)
+
+ // Test label properties
+ doc = NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", "user2/repo1/labels"), http.StatusOK).Body)
+ doc.Find(".issue-label-list .item").Each(func(i int, s *goquery.Selection) {
+ label := s.Find(".label-title .label")
+ style, _ := label.Attr("style")
+
+ if label.Text() == "active_label" {
+ assert.False(t, label.HasClass("archived-label"))
+ assert.Contains(t, style, "background-color: #aa00aaff")
+ } else if label.Text() == "archived_label" {
+ assert.True(t, label.HasClass("archived-label"))
+ assert.Contains(t, style, "background-color: #00aa007f")
+ }
+ })
+ })
+}
diff --git a/tests/integration/attachment_test.go b/tests/integration/attachment_test.go
new file mode 100644
index 0000000..7cbc254
--- /dev/null
+++ b/tests/integration/attachment_test.go
@@ -0,0 +1,138 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "image"
+ "image/png"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "strings"
+ "testing"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func generateImg() bytes.Buffer {
+ // Generate image
+ myImage := image.NewRGBA(image.Rect(0, 0, 32, 32))
+ var buff bytes.Buffer
+ png.Encode(&buff, myImage)
+ return buff
+}
+
+func createAttachment(t *testing.T, session *TestSession, repoURL, filename string, buff bytes.Buffer, expectedStatus int) string {
+ body := &bytes.Buffer{}
+
+ // Setup multi-part
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("file", filename)
+ require.NoError(t, err)
+ _, err = io.Copy(part, &buff)
+ require.NoError(t, err)
+ err = writer.Close()
+ require.NoError(t, err)
+
+ csrf := GetCSRF(t, session, repoURL)
+
+ req := NewRequestWithBody(t, "POST", repoURL+"/issues/attachments", body)
+ req.Header.Add("X-Csrf-Token", csrf)
+ req.Header.Add("Content-Type", writer.FormDataContentType())
+ resp := session.MakeRequest(t, req, expectedStatus)
+
+ if expectedStatus != http.StatusOK {
+ return ""
+ }
+ var obj map[string]string
+ DecodeJSON(t, resp, &obj)
+ return obj["uuid"]
+}
+
+func TestCreateAnonymousAttachment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := emptyTestSession(t)
+ // this test is not right because it just doesn't pass the CSRF validation
+ createAttachment(t, session, "user2/repo1", "image.png", generateImg(), http.StatusBadRequest)
+}
+
+func TestCreateIssueAttachment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ const repoURL = "user2/repo1"
+ session := loginUser(t, "user2")
+ uuid := createAttachment(t, session, repoURL, "image.png", generateImg(), http.StatusOK)
+
+ req := NewRequest(t, "GET", repoURL+"/issues/new")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ link, exists := htmlDoc.doc.Find("form#new-issue").Attr("action")
+ assert.True(t, exists, "The template has changed")
+
+ postData := map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "title": "New Issue With Attachment",
+ "content": "some content",
+ "files": uuid,
+ }
+
+ req = NewRequestWithValues(t, "POST", link, postData)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ test.RedirectURL(resp) // check that redirect URL exists
+
+ // Validate that attachment is available
+ req = NewRequest(t, "GET", "/attachments/"+uuid)
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // anonymous visit should be allowed because user2/repo1 is a public repository
+ MakeRequest(t, req, http.StatusOK)
+}
+
+func TestGetAttachment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ adminSession := loginUser(t, "user1")
+ user2Session := loginUser(t, "user2")
+ user8Session := loginUser(t, "user8")
+ emptySession := emptyTestSession(t)
+ testCases := []struct {
+ name string
+ uuid string
+ createFile bool
+ session *TestSession
+ want int
+ }{
+ {"LinkedIssueUUID", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", true, user2Session, http.StatusOK},
+ {"LinkedCommentUUID", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a17", true, user2Session, http.StatusOK},
+ {"linked_release_uuid", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a19", true, user2Session, http.StatusOK},
+ {"NotExistingUUID", "b0eebc99-9c0b-4ef8-bb6d-6bb9bd380a18", false, user2Session, http.StatusNotFound},
+ {"FileMissing", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a18", false, user2Session, http.StatusInternalServerError},
+ {"NotLinked", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20", true, user2Session, http.StatusNotFound},
+ {"NotLinkedAccessibleByUploader", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a20", true, user8Session, http.StatusOK},
+ {"PublicByNonLogged", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11", true, emptySession, http.StatusOK},
+ {"PrivateByNonLogged", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, emptySession, http.StatusNotFound},
+ {"PrivateAccessibleByAdmin", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, adminSession, http.StatusOK},
+ {"PrivateAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user2Session, http.StatusOK},
+ {"RepoNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12", true, user8Session, http.StatusNotFound},
+ {"OrgNotAccessibleByUser", "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a21", true, user8Session, http.StatusNotFound},
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Write empty file to be available for response
+ if tc.createFile {
+ _, err := storage.Attachments.Save(repo_model.AttachmentRelativePath(tc.uuid), strings.NewReader("hello world"), -1)
+ require.NoError(t, err)
+ }
+ // Actual test
+ req := NewRequest(t, "GET", "/attachments/"+tc.uuid)
+ tc.session.MakeRequest(t, req, tc.want)
+ })
+ }
+}
diff --git a/tests/integration/auth_ldap_test.go b/tests/integration/auth_ldap_test.go
new file mode 100644
index 0000000..04c8a4b
--- /dev/null
+++ b/tests/integration/auth_ldap_test.go
@@ -0,0 +1,566 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "net/http"
+ "os"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models"
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/services/auth"
+ "code.gitea.io/gitea/services/auth/source/ldap"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type ldapUser struct {
+ UserName string
+ Password string
+ FullName string
+ Email string
+ OtherEmails []string
+ IsAdmin bool
+ IsRestricted bool
+ SSHKeys []string
+}
+
+var gitLDAPUsers = []ldapUser{
+ {
+ UserName: "professor",
+ Password: "professor",
+ FullName: "Hubert Farnsworth",
+ Email: "professor@planetexpress.com",
+ OtherEmails: []string{"hubert@planetexpress.com"},
+ IsAdmin: true,
+ },
+ {
+ UserName: "hermes",
+ Password: "hermes",
+ FullName: "Conrad Hermes",
+ Email: "hermes@planetexpress.com",
+ SSHKeys: []string{
+ "SHA256:qLY06smKfHoW/92yXySpnxFR10QFrLdRjf/GNPvwcW8",
+ "SHA256:QlVTuM5OssDatqidn2ffY+Lc4YA5Fs78U+0KOHI51jQ",
+ "SHA256:DXdeUKYOJCSSmClZuwrb60hUq7367j4fA+udNC3FdRI",
+ },
+ IsAdmin: true,
+ },
+ {
+ UserName: "fry",
+ Password: "fry",
+ FullName: "Philip Fry",
+ Email: "fry@planetexpress.com",
+ },
+ {
+ UserName: "leela",
+ Password: "leela",
+ FullName: "Leela Turanga",
+ Email: "leela@planetexpress.com",
+ IsRestricted: true,
+ },
+ {
+ UserName: "bender",
+ Password: "bender",
+ FullName: "Bender Rodríguez",
+ Email: "bender@planetexpress.com",
+ },
+}
+
+var otherLDAPUsers = []ldapUser{
+ {
+ UserName: "zoidberg",
+ Password: "zoidberg",
+ FullName: "John Zoidberg",
+ Email: "zoidberg@planetexpress.com",
+ },
+ {
+ UserName: "amy",
+ Password: "amy",
+ FullName: "Amy Kroker",
+ Email: "amy@planetexpress.com",
+ },
+}
+
+func skipLDAPTests() bool {
+ return os.Getenv("TEST_LDAP") != "1"
+}
+
+func getLDAPServerHost() string {
+ host := os.Getenv("TEST_LDAP_HOST")
+ if len(host) == 0 {
+ host = "ldap"
+ }
+ return host
+}
+
+func getLDAPServerPort() string {
+ port := os.Getenv("TEST_LDAP_PORT")
+ if len(port) == 0 {
+ port = "389"
+ }
+ return port
+}
+
+func buildAuthSourceLDAPPayload(csrf, sshKeyAttribute, mailKeyAttribute, defaultDomainName, groupFilter, groupTeamMap, groupTeamMapRemoval string) map[string]string {
+ // Modify user filter to test group filter explicitly
+ userFilter := "(&(objectClass=inetOrgPerson)(memberOf=cn=git,ou=people,dc=planetexpress,dc=com)(uid=%s))"
+ if groupFilter != "" {
+ userFilter = "(&(objectClass=inetOrgPerson)(uid=%s))"
+ }
+
+ if len(mailKeyAttribute) == 0 {
+ mailKeyAttribute = "mail"
+ }
+
+ return map[string]string{
+ "_csrf": csrf,
+ "type": "2",
+ "name": "ldap",
+ "host": getLDAPServerHost(),
+ "port": getLDAPServerPort(),
+ "bind_dn": "uid=gitea,ou=service,dc=planetexpress,dc=com",
+ "bind_password": "password",
+ "user_base": "ou=people,dc=planetexpress,dc=com",
+ "filter": userFilter,
+ "admin_filter": "(memberOf=cn=admin_staff,ou=people,dc=planetexpress,dc=com)",
+ "restricted_filter": "(uid=leela)",
+ "attribute_username": "uid",
+ "attribute_name": "givenName",
+ "attribute_surname": "sn",
+ "attribute_mail": mailKeyAttribute,
+ "attribute_ssh_public_key": sshKeyAttribute,
+ "default_domain_name": defaultDomainName,
+ "is_sync_enabled": "on",
+ "is_active": "on",
+ "groups_enabled": "on",
+ "group_dn": "ou=people,dc=planetexpress,dc=com",
+ "group_member_uid": "member",
+ "group_filter": groupFilter,
+ "group_team_map": groupTeamMap,
+ "group_team_map_removal": groupTeamMapRemoval,
+ "user_uid": "DN",
+ }
+}
+
+func addAuthSourceLDAP(t *testing.T, sshKeyAttribute, mailKeyAttribute, defaultDomainName, groupFilter string, groupMapParams ...string) {
+ groupTeamMapRemoval := "off"
+ groupTeamMap := ""
+ if len(groupMapParams) == 2 {
+ groupTeamMapRemoval = groupMapParams[0]
+ groupTeamMap = groupMapParams[1]
+ }
+ session := loginUser(t, "user1")
+ csrf := GetCSRF(t, session, "/admin/auths/new")
+ req := NewRequestWithValues(t, "POST", "/admin/auths/new", buildAuthSourceLDAPPayload(csrf, sshKeyAttribute, mailKeyAttribute, defaultDomainName, groupFilter, groupTeamMap, groupTeamMapRemoval))
+ session.MakeRequest(t, req, http.StatusSeeOther)
+}
+
+func TestLDAPUserSignin(t *testing.T) {
+ if skipLDAPTests() {
+ t.Skip()
+ return
+ }
+ defer tests.PrepareTestEnv(t)()
+ addAuthSourceLDAP(t, "", "", "", "")
+
+ u := gitLDAPUsers[0]
+
+ session := loginUserWithPassword(t, u.UserName, u.Password)
+ req := NewRequest(t, "GET", "/user/settings")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ assert.Equal(t, u.UserName, htmlDoc.GetInputValueByName("name"))
+ assert.Equal(t, u.FullName, htmlDoc.GetInputValueByName("full_name"))
+ assert.Equal(t, u.Email, htmlDoc.Find("#signed-user-email").Text())
+}
+
+func TestLDAPAuthChange(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ addAuthSourceLDAP(t, "", "", "", "")
+
+ session := loginUser(t, "user1")
+ req := NewRequest(t, "GET", "/admin/auths")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ href, exists := doc.Find("table.table td a").Attr("href")
+ if !exists {
+ assert.True(t, exists, "No authentication source found")
+ return
+ }
+
+ req = NewRequest(t, "GET", href)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ doc = NewHTMLParser(t, resp.Body)
+ csrf := doc.GetCSRF()
+ host, _ := doc.Find(`input[name="host"]`).Attr("value")
+ assert.Equal(t, host, getLDAPServerHost())
+ binddn, _ := doc.Find(`input[name="bind_dn"]`).Attr("value")
+ assert.Equal(t, "uid=gitea,ou=service,dc=planetexpress,dc=com", binddn)
+
+ req = NewRequestWithValues(t, "POST", href, buildAuthSourceLDAPPayload(csrf, "", "", "", "", "", "off"))
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ req = NewRequest(t, "GET", href)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ doc = NewHTMLParser(t, resp.Body)
+ host, _ = doc.Find(`input[name="host"]`).Attr("value")
+ assert.Equal(t, host, getLDAPServerHost())
+ binddn, _ = doc.Find(`input[name="bind_dn"]`).Attr("value")
+ assert.Equal(t, "uid=gitea,ou=service,dc=planetexpress,dc=com", binddn)
+ domainname, _ := doc.Find(`input[name="default_domain_name"]`).Attr("value")
+ assert.Equal(t, "", domainname)
+
+ req = NewRequestWithValues(t, "POST", href, buildAuthSourceLDAPPayload(csrf, "", "", "test.org", "", "", "off"))
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ req = NewRequest(t, "GET", href)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ doc = NewHTMLParser(t, resp.Body)
+ host, _ = doc.Find(`input[name="host"]`).Attr("value")
+ assert.Equal(t, host, getLDAPServerHost())
+ binddn, _ = doc.Find(`input[name="bind_dn"]`).Attr("value")
+ assert.Equal(t, "uid=gitea,ou=service,dc=planetexpress,dc=com", binddn)
+ domainname, _ = doc.Find(`input[name="default_domain_name"]`).Attr("value")
+ assert.Equal(t, "test.org", domainname)
+}
+
+func TestLDAPUserSync(t *testing.T) {
+ if skipLDAPTests() {
+ t.Skip()
+ return
+ }
+ defer tests.PrepareTestEnv(t)()
+ addAuthSourceLDAP(t, "", "", "", "")
+ err := auth.SyncExternalUsers(context.Background(), true)
+ require.NoError(t, err)
+
+ // Check if users exists
+ for _, gitLDAPUser := range gitLDAPUsers {
+ dbUser, err := user_model.GetUserByName(db.DefaultContext, gitLDAPUser.UserName)
+ require.NoError(t, err)
+ assert.Equal(t, gitLDAPUser.UserName, dbUser.Name)
+ assert.Equal(t, gitLDAPUser.Email, dbUser.Email)
+ assert.Equal(t, gitLDAPUser.IsAdmin, dbUser.IsAdmin)
+ assert.Equal(t, gitLDAPUser.IsRestricted, dbUser.IsRestricted)
+ }
+
+ // Check if no users exist
+ for _, otherLDAPUser := range otherLDAPUsers {
+ _, err := user_model.GetUserByName(db.DefaultContext, otherLDAPUser.UserName)
+ assert.True(t, user_model.IsErrUserNotExist(err))
+ }
+}
+
+func TestLDAPUserSyncWithEmptyUsernameAttribute(t *testing.T) {
+ if skipLDAPTests() {
+ t.Skip()
+ return
+ }
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+ csrf := GetCSRF(t, session, "/admin/auths/new")
+ payload := buildAuthSourceLDAPPayload(csrf, "", "", "", "", "", "")
+ payload["attribute_username"] = ""
+ req := NewRequestWithValues(t, "POST", "/admin/auths/new", payload)
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ for _, u := range gitLDAPUsers {
+ req := NewRequest(t, "GET", "/admin/users?q="+u.UserName)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ tr := htmlDoc.doc.Find("table.table tbody tr")
+ assert.Equal(t, 0, tr.Length())
+ }
+
+ for _, u := range gitLDAPUsers {
+ req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{
+ "_csrf": csrf,
+ "user_name": u.UserName,
+ "password": u.Password,
+ })
+ MakeRequest(t, req, http.StatusSeeOther)
+ }
+
+ auth.SyncExternalUsers(context.Background(), true)
+
+ authSource := unittest.AssertExistsAndLoadBean(t, &auth_model.Source{
+ Name: payload["name"],
+ })
+ unittest.AssertCount(t, &user_model.User{
+ LoginType: auth_model.LDAP,
+ LoginSource: authSource.ID,
+ }, len(gitLDAPUsers))
+
+ for _, u := range gitLDAPUsers {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{
+ Name: u.UserName,
+ })
+ assert.True(t, user.IsActive)
+ }
+}
+
+func TestLDAPUserSyncWithGroupFilter(t *testing.T) {
+ if skipLDAPTests() {
+ t.Skip()
+ return
+ }
+ defer tests.PrepareTestEnv(t)()
+ addAuthSourceLDAP(t, "", "", "", "(cn=git)")
+
+ // Assert a user not a member of the LDAP group "cn=git" cannot login
+ // This test may look like TestLDAPUserSigninFailed but it is not.
+ // The later test uses user filter containing group membership filter (memberOf)
+ // This test is for the case when LDAP user records may not be linked with
+ // all groups the user is a member of, the user filter is modified accordingly inside
+ // the addAuthSourceLDAP based on the value of the groupFilter
+ u := otherLDAPUsers[0]
+ testLoginFailed(t, u.UserName, u.Password, translation.NewLocale("en-US").TrString("form.username_password_incorrect"))
+
+ auth.SyncExternalUsers(context.Background(), true)
+
+ // Assert members of LDAP group "cn=git" are added
+ for _, gitLDAPUser := range gitLDAPUsers {
+ unittest.BeanExists(t, &user_model.User{
+ Name: gitLDAPUser.UserName,
+ })
+ }
+
+ // Assert everyone else is not added
+ for _, gitLDAPUser := range otherLDAPUsers {
+ unittest.AssertNotExistsBean(t, &user_model.User{
+ Name: gitLDAPUser.UserName,
+ })
+ }
+
+ ldapSource := unittest.AssertExistsAndLoadBean(t, &auth_model.Source{
+ Name: "ldap",
+ })
+ ldapConfig := ldapSource.Cfg.(*ldap.Source)
+ ldapConfig.GroupFilter = "(cn=ship_crew)"
+ auth_model.UpdateSource(db.DefaultContext, ldapSource)
+
+ auth.SyncExternalUsers(context.Background(), true)
+
+ for _, gitLDAPUser := range gitLDAPUsers {
+ if gitLDAPUser.UserName == "fry" || gitLDAPUser.UserName == "leela" || gitLDAPUser.UserName == "bender" {
+ // Assert members of the LDAP group "cn-ship_crew" are still active
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{
+ Name: gitLDAPUser.UserName,
+ })
+ assert.True(t, user.IsActive, "User %s should be active", gitLDAPUser.UserName)
+ } else {
+ // Assert everyone else is inactive
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{
+ Name: gitLDAPUser.UserName,
+ })
+ assert.False(t, user.IsActive, "User %s should be inactive", gitLDAPUser.UserName)
+ }
+ }
+}
+
+func TestLDAPUserSigninFailed(t *testing.T) {
+ if skipLDAPTests() {
+ t.Skip()
+ return
+ }
+ defer tests.PrepareTestEnv(t)()
+ addAuthSourceLDAP(t, "", "", "", "")
+
+ u := otherLDAPUsers[0]
+ testLoginFailed(t, u.UserName, u.Password, translation.NewLocale("en-US").TrString("form.username_password_incorrect"))
+}
+
+func TestLDAPUserSSHKeySync(t *testing.T) {
+ if skipLDAPTests() {
+ t.Skip()
+ return
+ }
+ defer tests.PrepareTestEnv(t)()
+ addAuthSourceLDAP(t, "sshPublicKey", "", "", "")
+
+ auth.SyncExternalUsers(context.Background(), true)
+
+ // Check if users has SSH keys synced
+ for _, u := range gitLDAPUsers {
+ if len(u.SSHKeys) == 0 {
+ continue
+ }
+ session := loginUserWithPassword(t, u.UserName, u.Password)
+
+ req := NewRequest(t, "GET", "/user/settings/keys")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ divs := htmlDoc.doc.Find("#keys-ssh .flex-item .flex-item-body:not(:last-child)")
+
+ syncedKeys := make([]string, divs.Length())
+ for i := 0; i < divs.Length(); i++ {
+ syncedKeys[i] = strings.TrimSpace(divs.Eq(i).Text())
+ }
+
+ assert.ElementsMatch(t, u.SSHKeys, syncedKeys, "Unequal number of keys synchronized for user: %s", u.UserName)
+ }
+}
+
+func TestLDAPGroupTeamSyncAddMember(t *testing.T) {
+ if skipLDAPTests() {
+ t.Skip()
+ return
+ }
+ defer tests.PrepareTestEnv(t)()
+ addAuthSourceLDAP(t, "", "", "", "", "on", `{"cn=ship_crew,ou=people,dc=planetexpress,dc=com":{"org26": ["team11"]},"cn=admin_staff,ou=people,dc=planetexpress,dc=com": {"non-existent": ["non-existent"]}}`)
+ org, err := organization.GetOrgByName(db.DefaultContext, "org26")
+ require.NoError(t, err)
+ team, err := organization.GetTeam(db.DefaultContext, org.ID, "team11")
+ require.NoError(t, err)
+ auth.SyncExternalUsers(context.Background(), true)
+ for _, gitLDAPUser := range gitLDAPUsers {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{
+ Name: gitLDAPUser.UserName,
+ })
+ usersOrgs, err := db.Find[organization.Organization](db.DefaultContext, organization.FindOrgOptions{
+ UserID: user.ID,
+ IncludePrivate: true,
+ })
+ require.NoError(t, err)
+ allOrgTeams, err := organization.GetUserOrgTeams(db.DefaultContext, org.ID, user.ID)
+ require.NoError(t, err)
+ if user.Name == "fry" || user.Name == "leela" || user.Name == "bender" {
+ // assert members of LDAP group "cn=ship_crew" are added to mapped teams
+ assert.Len(t, usersOrgs, 1, "User [%s] should be member of one organization", user.Name)
+ assert.Equal(t, "org26", usersOrgs[0].Name, "Membership should be added to the right organization")
+ isMember, err := organization.IsTeamMember(db.DefaultContext, usersOrgs[0].ID, team.ID, user.ID)
+ require.NoError(t, err)
+ assert.True(t, isMember, "Membership should be added to the right team")
+ err = models.RemoveTeamMember(db.DefaultContext, team, user.ID)
+ require.NoError(t, err)
+ err = models.RemoveOrgUser(db.DefaultContext, usersOrgs[0].ID, user.ID)
+ require.NoError(t, err)
+ } else {
+ // assert members of LDAP group "cn=admin_staff" keep initial team membership since mapped team does not exist
+ assert.Empty(t, usersOrgs, "User should be member of no organization")
+ isMember, err := organization.IsTeamMember(db.DefaultContext, org.ID, team.ID, user.ID)
+ require.NoError(t, err)
+ assert.False(t, isMember, "User should no be added to this team")
+ assert.Empty(t, allOrgTeams, "User should not be added to any team")
+ }
+ }
+}
+
+func TestLDAPGroupTeamSyncRemoveMember(t *testing.T) {
+ if skipLDAPTests() {
+ t.Skip()
+ return
+ }
+ defer tests.PrepareTestEnv(t)()
+ addAuthSourceLDAP(t, "", "", "", "", "on", `{"cn=dispatch,ou=people,dc=planetexpress,dc=com": {"org26": ["team11"]}}`)
+ org, err := organization.GetOrgByName(db.DefaultContext, "org26")
+ require.NoError(t, err)
+ team, err := organization.GetTeam(db.DefaultContext, org.ID, "team11")
+ require.NoError(t, err)
+ loginUserWithPassword(t, gitLDAPUsers[0].UserName, gitLDAPUsers[0].Password)
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{
+ Name: gitLDAPUsers[0].UserName,
+ })
+ err = organization.AddOrgUser(db.DefaultContext, org.ID, user.ID)
+ require.NoError(t, err)
+ err = models.AddTeamMember(db.DefaultContext, team, user.ID)
+ require.NoError(t, err)
+ isMember, err := organization.IsOrganizationMember(db.DefaultContext, org.ID, user.ID)
+ require.NoError(t, err)
+ assert.True(t, isMember, "User should be member of this organization")
+ isMember, err = organization.IsTeamMember(db.DefaultContext, org.ID, team.ID, user.ID)
+ require.NoError(t, err)
+ assert.True(t, isMember, "User should be member of this team")
+ // assert team member "professor" gets removed from org26 team11
+ loginUserWithPassword(t, gitLDAPUsers[0].UserName, gitLDAPUsers[0].Password)
+ isMember, err = organization.IsOrganizationMember(db.DefaultContext, org.ID, user.ID)
+ require.NoError(t, err)
+ assert.False(t, isMember, "User membership should have been removed from organization")
+ isMember, err = organization.IsTeamMember(db.DefaultContext, org.ID, team.ID, user.ID)
+ require.NoError(t, err)
+ assert.False(t, isMember, "User membership should have been removed from team")
+}
+
+func TestLDAPPreventInvalidGroupTeamMap(t *testing.T) {
+ if skipLDAPTests() {
+ t.Skip()
+ return
+ }
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+ csrf := GetCSRF(t, session, "/admin/auths/new")
+ req := NewRequestWithValues(t, "POST", "/admin/auths/new", buildAuthSourceLDAPPayload(csrf, "", "", "", "", `{"NOT_A_VALID_JSON"["MISSING_DOUBLE_POINT"]}`, "off"))
+ session.MakeRequest(t, req, http.StatusOK) // StatusOK = failed, StatusSeeOther = ok
+}
+
+func TestLDAPUserSyncInvalidMail(t *testing.T) {
+ if skipLDAPTests() {
+ t.Skip()
+ return
+ }
+ defer tests.PrepareTestEnv(t)()
+ addAuthSourceLDAP(t, "", "nonexisting", "", "")
+ auth.SyncExternalUsers(context.Background(), true)
+
+ // Check if users exists
+ for _, gitLDAPUser := range gitLDAPUsers {
+ dbUser, err := user_model.GetUserByName(db.DefaultContext, gitLDAPUser.UserName)
+ require.NoError(t, err)
+ assert.Equal(t, gitLDAPUser.UserName, dbUser.Name)
+ assert.Equal(t, gitLDAPUser.UserName+"@localhost.local", dbUser.Email)
+ assert.Equal(t, gitLDAPUser.IsAdmin, dbUser.IsAdmin)
+ assert.Equal(t, gitLDAPUser.IsRestricted, dbUser.IsRestricted)
+ }
+
+ // Check if no users exist
+ for _, otherLDAPUser := range otherLDAPUsers {
+ _, err := user_model.GetUserByName(db.DefaultContext, otherLDAPUser.UserName)
+ assert.True(t, user_model.IsErrUserNotExist(err))
+ }
+}
+
+func TestLDAPUserSyncInvalidMailDefaultDomain(t *testing.T) {
+ if skipLDAPTests() {
+ t.Skip()
+ return
+ }
+ defer tests.PrepareTestEnv(t)()
+ addAuthSourceLDAP(t, "", "nonexisting", "test.org", "")
+ auth.SyncExternalUsers(context.Background(), true)
+
+ // Check if users exists
+ for _, gitLDAPUser := range gitLDAPUsers {
+ dbUser, err := user_model.GetUserByName(db.DefaultContext, gitLDAPUser.UserName)
+ require.NoError(t, err)
+ assert.Equal(t, gitLDAPUser.UserName, dbUser.Name)
+ assert.Equal(t, gitLDAPUser.UserName+"@test.org", dbUser.Email)
+ assert.Equal(t, gitLDAPUser.IsAdmin, dbUser.IsAdmin)
+ assert.Equal(t, gitLDAPUser.IsRestricted, dbUser.IsRestricted)
+ }
+
+ // Check if no users exist
+ for _, otherLDAPUser := range otherLDAPUsers {
+ _, err := user_model.GetUserByName(db.DefaultContext, otherLDAPUser.UserName)
+ assert.True(t, user_model.IsErrUserNotExist(err))
+ }
+}
diff --git a/tests/integration/auth_token_test.go b/tests/integration/auth_token_test.go
new file mode 100644
index 0000000..2c39c87
--- /dev/null
+++ b/tests/integration/auth_token_test.go
@@ -0,0 +1,164 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "encoding/hex"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/timeutil"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// GetSessionForLTACookie returns a new session with only the LTA cookie being set.
+func GetSessionForLTACookie(t *testing.T, ltaCookie *http.Cookie) *TestSession {
+ t.Helper()
+
+ ch := http.Header{}
+ ch.Add("Cookie", ltaCookie.String())
+ cr := http.Request{Header: ch}
+
+ session := emptyTestSession(t)
+ baseURL, err := url.Parse(setting.AppURL)
+ require.NoError(t, err)
+ session.jar.SetCookies(baseURL, cr.Cookies())
+
+ return session
+}
+
+// GetLTACookieValue returns the value of the LTA cookie.
+func GetLTACookieValue(t *testing.T, sess *TestSession) string {
+ t.Helper()
+
+ rememberCookie := sess.GetCookie(setting.CookieRememberName)
+ assert.NotNil(t, rememberCookie)
+
+ cookieValue, err := url.QueryUnescape(rememberCookie.Value)
+ require.NoError(t, err)
+
+ return cookieValue
+}
+
+// TestSessionCookie checks if the session cookie provides authentication.
+func TestSessionCookie(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ sess := loginUser(t, "user1")
+ assert.NotNil(t, sess.GetCookie(setting.SessionConfig.CookieName))
+
+ req := NewRequest(t, "GET", "/user/settings")
+ sess.MakeRequest(t, req, http.StatusOK)
+}
+
+// TestLTACookie checks if the LTA cookie that's returned is valid, exists in the database
+// and provides authentication of no session cookie is present.
+func TestLTACookie(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ sess := emptyTestSession(t)
+
+ req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{
+ "_csrf": GetCSRF(t, sess, "/user/login"),
+ "user_name": user.Name,
+ "password": userPassword,
+ "remember": "true",
+ })
+ sess.MakeRequest(t, req, http.StatusSeeOther)
+
+ // Checks if the database entry exist for the user.
+ ltaCookieValue := GetLTACookieValue(t, sess)
+ lookupKey, validator, found := strings.Cut(ltaCookieValue, ":")
+ assert.True(t, found)
+ rawValidator, err := hex.DecodeString(validator)
+ require.NoError(t, err)
+ unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{LookupKey: lookupKey, HashedValidator: auth.HashValidator(rawValidator), UID: user.ID})
+
+ // Check if the LTA cookie it provides authentication.
+ // If LTA cookie provides authentication /user/login shouldn't return status 200.
+ session := GetSessionForLTACookie(t, sess.GetCookie(setting.CookieRememberName))
+ req = NewRequest(t, "GET", "/user/login")
+ session.MakeRequest(t, req, http.StatusSeeOther)
+}
+
+// TestLTAPasswordChange checks that LTA doesn't provide authentication when a
+// password change has happened and that the new LTA does provide authentication.
+func TestLTAPasswordChange(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ sess := loginUserWithPasswordRemember(t, user.Name, userPassword, true)
+ oldRememberCookie := sess.GetCookie(setting.CookieRememberName)
+ assert.NotNil(t, oldRememberCookie)
+
+ // Make a simple password change.
+ req := NewRequestWithValues(t, "POST", "/user/settings/account", map[string]string{
+ "_csrf": GetCSRF(t, sess, "/user/settings/account"),
+ "old_password": userPassword,
+ "password": "password2",
+ "retype": "password2",
+ })
+ sess.MakeRequest(t, req, http.StatusSeeOther)
+ rememberCookie := sess.GetCookie(setting.CookieRememberName)
+ assert.NotNil(t, rememberCookie)
+
+ // Check if the password really changed.
+ assert.NotEqualValues(t, unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}).Passwd, user.Passwd)
+
+ // /user/settings/account should provide with a new LTA cookie, so check for that.
+ // If LTA cookie provides authentication /user/login shouldn't return status 200.
+ session := GetSessionForLTACookie(t, rememberCookie)
+ req = NewRequest(t, "GET", "/user/login")
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // Check if the old LTA token is invalidated.
+ session = GetSessionForLTACookie(t, oldRememberCookie)
+ req = NewRequest(t, "GET", "/user/login")
+ session.MakeRequest(t, req, http.StatusOK)
+}
+
+// TestLTAExpiry tests that the LTA expiry works.
+func TestLTAExpiry(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ sess := loginUserWithPasswordRemember(t, user.Name, userPassword, true)
+
+ ltaCookieValie := GetLTACookieValue(t, sess)
+ lookupKey, _, found := strings.Cut(ltaCookieValie, ":")
+ assert.True(t, found)
+
+ // Ensure it's not expired.
+ lta := unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey})
+ assert.False(t, lta.IsExpired())
+
+ // Manually stub LTA's expiry.
+ _, err := db.GetEngine(db.DefaultContext).ID(lta.ID).Table("forgejo_auth_token").Cols("expiry").Update(&auth.AuthorizationToken{Expiry: timeutil.TimeStampNow()})
+ require.NoError(t, err)
+
+ // Ensure it's expired.
+ lta = unittest.AssertExistsAndLoadBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey})
+ assert.True(t, lta.IsExpired())
+
+ // Should return 200 OK, because LTA doesn't provide authorization anymore.
+ session := GetSessionForLTACookie(t, sess.GetCookie(setting.CookieRememberName))
+ req := NewRequest(t, "GET", "/user/login")
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // Ensure it's deleted.
+ unittest.AssertNotExistsBean(t, &auth.AuthorizationToken{UID: user.ID, LookupKey: lookupKey})
+}
diff --git a/tests/integration/avatar.png b/tests/integration/avatar.png
new file mode 100644
index 0000000..dfd2125
--- /dev/null
+++ b/tests/integration/avatar.png
Binary files differ
diff --git a/tests/integration/benchmarks_test.go b/tests/integration/benchmarks_test.go
new file mode 100644
index 0000000..62da761
--- /dev/null
+++ b/tests/integration/benchmarks_test.go
@@ -0,0 +1,69 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "math/rand/v2"
+ "net/http"
+ "net/url"
+ "testing"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ api "code.gitea.io/gitea/modules/structs"
+)
+
+// StringWithCharset random string (from https://www.calhoun.io/creating-random-strings-in-go/)
+func StringWithCharset(length int, charset string) string {
+ b := make([]byte, length)
+ for i := range b {
+ b[i] = charset[rand.IntN(len(charset))]
+ }
+ return string(b)
+}
+
+func BenchmarkRepoBranchCommit(b *testing.B) {
+ onGiteaRun(b, func(b *testing.B, u *url.URL) {
+ samples := []int64{1, 2, 3}
+ b.ResetTimer()
+
+ for _, repoID := range samples {
+ b.StopTimer()
+ repo := unittest.AssertExistsAndLoadBean(b, &repo_model.Repository{ID: repoID})
+ b.StartTimer()
+ b.Run(repo.Name, func(b *testing.B) {
+ session := loginUser(b, "user2")
+ b.ResetTimer()
+ b.Run("CreateBranch", func(b *testing.B) {
+ b.StopTimer()
+ branchName := StringWithCharset(5+rand.IntN(10), "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
+ b.StartTimer()
+ for i := 0; i < b.N; i++ {
+ b.Run("new_"+branchName, func(b *testing.B) {
+ b.Skip("benchmark broken") // TODO fix
+ testAPICreateBranch(b, session, repo.OwnerName, repo.Name, repo.DefaultBranch, "new_"+branchName, http.StatusCreated)
+ })
+ }
+ })
+ b.Run("GetBranches", func(b *testing.B) {
+ req := NewRequestf(b, "GET", "/api/v1/repos/%s/branches", repo.FullName())
+ session.MakeRequest(b, req, http.StatusOK)
+ })
+ b.Run("AccessCommits", func(b *testing.B) {
+ var branches []*api.Branch
+ req := NewRequestf(b, "GET", "/api/v1/repos/%s/branches", repo.FullName())
+ resp := session.MakeRequest(b, req, http.StatusOK)
+ DecodeJSON(b, resp, &branches)
+ b.ResetTimer() // We measure from here
+ if len(branches) != 0 {
+ for i := 0; i < b.N; i++ {
+ req := NewRequestf(b, "GET", "/api/v1/repos/%s/commits?sha=%s", repo.FullName(), branches[i%len(branches)].Name)
+ session.MakeRequest(b, req, http.StatusOK)
+ }
+ }
+ })
+ })
+ }
+ })
+}
diff --git a/tests/integration/block_test.go b/tests/integration/block_test.go
new file mode 100644
index 0000000..b17a445
--- /dev/null
+++ b/tests/integration/block_test.go
@@ -0,0 +1,454 @@
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "path"
+ "strconv"
+ "testing"
+
+ "code.gitea.io/gitea/models/activities"
+ "code.gitea.io/gitea/models/db"
+ issue_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/translation"
+ forgejo_context "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func BlockUser(t *testing.T, doer, blockedUser *user_model.User) {
+ t.Helper()
+
+ unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})
+
+ session := loginUser(t, doer.Name)
+ req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
+ "_csrf": GetCSRF(t, session, "/"+blockedUser.Name),
+ "action": "block",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID}))
+}
+
+// TestBlockUser ensures that users can execute blocking related actions can
+// happen under the correct conditions.
+func TestBlockUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 8})
+ blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ session := loginUser(t, doer.Name)
+
+ t.Run("Block", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ BlockUser(t, doer, blockedUser)
+ })
+
+ // Unblock user.
+ t.Run("Unblock", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
+ "_csrf": GetCSRF(t, session, "/"+blockedUser.Name),
+ "action": "unblock",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: doer.ID})
+ })
+
+ t.Run("Organization as target", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
+
+ t.Run("Block", func(t *testing.T) {
+ req := NewRequestWithValues(t, "POST", "/"+targetOrg.Name, map[string]string{
+ "_csrf": GetCSRF(t, session, "/"+targetOrg.Name),
+ "action": "block",
+ })
+ resp := session.MakeRequest(t, req, http.StatusBadRequest)
+
+ assert.Contains(t, resp.Body.String(), "Action \\\"block\\\" failed")
+ })
+
+ t.Run("Unblock", func(t *testing.T) {
+ req := NewRequestWithValues(t, "POST", "/"+targetOrg.Name, map[string]string{
+ "_csrf": GetCSRF(t, session, "/"+targetOrg.Name),
+ "action": "unblock",
+ })
+ resp := session.MakeRequest(t, req, http.StatusBadRequest)
+
+ assert.Contains(t, resp.Body.String(), "Action \\\"unblock\\\" failed")
+ })
+ })
+}
+
+// TestBlockUserFromOrganization ensures that an organisation can block and unblock an user.
+func TestBlockUserFromOrganization(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
+ blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17, Type: user_model.UserTypeOrganization})
+ unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID})
+ session := loginUser(t, doer.Name)
+
+ t.Run("Block user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/block", map[string]string{
+ "_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
+ "uname": blockedUser.Name,
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ assert.True(t, unittest.BeanExists(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID}))
+ })
+
+ t.Run("Unblock user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/unblock", map[string]string{
+ "_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
+ "user_id": strconv.FormatInt(blockedUser.ID, 10),
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: org.ID})
+ })
+
+ t.Run("Organization as target", func(t *testing.T) {
+ targetOrg := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
+
+ t.Run("Block", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/block", map[string]string{
+ "_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
+ "uname": targetOrg.Name,
+ })
+ session.MakeRequest(t, req, http.StatusInternalServerError)
+ unittest.AssertNotExistsBean(t, &user_model.BlockedUser{BlockID: blockedUser.ID, UserID: targetOrg.ID})
+ })
+
+ t.Run("Unblock", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", org.OrganisationLink()+"/settings/blocked_users/unblock", map[string]string{
+ "_csrf": GetCSRF(t, session, org.OrganisationLink()+"/settings/blocked_users"),
+ "user_id": strconv.FormatInt(targetOrg.ID, 10),
+ })
+ session.MakeRequest(t, req, http.StatusInternalServerError)
+ })
+ })
+}
+
+// TestBlockActions ensures that certain actions cannot be performed as a doer
+// and as a blocked user and are handled cleanly after the blocking has taken
+// place.
+func TestBlockActions(t *testing.T) {
+ defer tests.AddFixtures("tests/integration/fixtures/TestBlockActions/")()
+ defer tests.PrepareTestEnv(t)()
+
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ blockedUser2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10})
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: doer.ID})
+ repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2, OwnerID: doer.ID})
+ repo7 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 7, OwnerID: blockedUser2.ID})
+ issue4 := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 4, RepoID: repo2.ID})
+ issue4URL := fmt.Sprintf("/%s/issues/%d", repo2.FullName(), issue4.Index)
+ repo42 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 42, OwnerID: doer.ID})
+ issue10 := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 10, RepoID: repo42.ID}, unittest.Cond("poster_id != ?", doer.ID))
+ issue10URL := fmt.Sprintf("/%s/issues/%d", repo42.FullName(), issue10.Index)
+ // NOTE: Sessions shouldn't be shared, because in some situations flash
+ // messages are persistent and that would interfere with accurate test
+ // results.
+
+ BlockUser(t, doer, blockedUser)
+ BlockUser(t, doer, blockedUser2)
+
+ type errorJSON struct {
+ Error string `json:"errorMessage"`
+ }
+ locale := translation.NewLocale("en-US")
+
+ // Ensures that issue creation on doer's owned repositories are blocked.
+ t.Run("Issue creation", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, blockedUser.Name)
+ link := fmt.Sprintf("%s/issues/new", repo2.FullName())
+
+ req := NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": GetCSRF(t, session, link),
+ "title": "Title",
+ "content": "Hello!",
+ })
+ resp := session.MakeRequest(t, req, http.StatusBadRequest)
+
+ var errorResp errorJSON
+ DecodeJSON(t, resp, &errorResp)
+
+ assert.EqualValues(t, locale.Tr("repo.issues.blocked_by_user"), errorResp.Error)
+ })
+
+ // Ensures that pull creation on doer's owned repositories are blocked.
+ t.Run("Pull creation", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, blockedUser.Name)
+ link := fmt.Sprintf("%s/compare/v1.1...master", repo1.FullName())
+
+ req := NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": GetCSRF(t, session, link),
+ "title": "Title",
+ "content": "Hello!",
+ })
+ resp := session.MakeRequest(t, req, http.StatusBadRequest)
+
+ var errorResp errorJSON
+ DecodeJSON(t, resp, &errorResp)
+
+ assert.EqualValues(t, locale.Tr("repo.pulls.blocked_by_user"), errorResp.Error)
+ })
+
+ // Ensures that comment creation on doer's owned repositories and doer's
+ // posted issues are blocked.
+ t.Run("Comment creation", func(t *testing.T) {
+ expectedMessage := locale.Tr("repo.issues.comment.blocked_by_user")
+
+ t.Run("Blocked by repository owner", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, blockedUser.Name)
+
+ req := NewRequestWithValues(t, "POST", path.Join(issue10URL, "/comments"), map[string]string{
+ "_csrf": GetCSRF(t, session, issue10URL),
+ "content": "Not a kind comment",
+ })
+ resp := session.MakeRequest(t, req, http.StatusBadRequest)
+
+ var errorResp errorJSON
+ DecodeJSON(t, resp, &errorResp)
+
+ assert.EqualValues(t, expectedMessage, errorResp.Error)
+ })
+
+ t.Run("Blocked by issue poster", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo5 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 5})
+ issue15 := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 15, RepoID: repo5.ID, PosterID: doer.ID})
+
+ session := loginUser(t, blockedUser.Name)
+ issueURL := fmt.Sprintf("/%s/%s/issues/%d", url.PathEscape(repo5.OwnerName), url.PathEscape(repo5.Name), issue15.Index)
+
+ req := NewRequestWithValues(t, "POST", path.Join(issueURL, "/comments"), map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ "content": "Not a kind comment",
+ })
+ resp := session.MakeRequest(t, req, http.StatusBadRequest)
+
+ var errorResp errorJSON
+ DecodeJSON(t, resp, &errorResp)
+
+ assert.EqualValues(t, expectedMessage, errorResp.Error)
+ })
+ })
+
+ // Ensures that reactions on doer's owned issues and doer's owned comments are
+ // blocked.
+ t.Run("Add a reaction", func(t *testing.T) {
+ type reactionResponse struct {
+ Empty bool `json:"empty"`
+ }
+
+ t.Run("On a issue", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, blockedUser.Name)
+
+ req := NewRequestWithValues(t, "POST", path.Join(issue4URL, "/reactions/react"), map[string]string{
+ "_csrf": GetCSRF(t, session, issue4URL),
+ "content": "eyes",
+ })
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ var respBody reactionResponse
+ DecodeJSON(t, resp, &respBody)
+
+ assert.True(t, respBody.Empty)
+ })
+
+ t.Run("On a comment", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issue_model.Comment{ID: 1008, PosterID: doer.ID, IssueID: issue4.ID})
+
+ session := loginUser(t, blockedUser.Name)
+
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/comments/%d/reactions/react", repo2.FullName(), comment.ID), map[string]string{
+ "_csrf": GetCSRF(t, session, issue4URL),
+ "content": "eyes",
+ })
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ var respBody reactionResponse
+ DecodeJSON(t, resp, &respBody)
+
+ assert.True(t, respBody.Empty)
+ })
+ })
+
+ // Ensures that the doer and blocked user cannot follow each other.
+ t.Run("Follow", func(t *testing.T) {
+ // Sanity checks to make sure doing these tests are valid.
+ unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID})
+ unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID})
+
+ // Doer cannot follow blocked user.
+ t.Run("Doer follow blocked user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, doer.Name)
+
+ req := NewRequestWithValues(t, "POST", "/"+blockedUser.Name, map[string]string{
+ "_csrf": GetCSRF(t, session, "/"+blockedUser.Name),
+ "action": "follow",
+ })
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Contains(t, htmlDoc.Find("#flash-message").Text(), "You cannot follow this user because you have blocked this user or this user has blocked you.")
+
+ // Assert it still doesn't exist.
+ unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: doer.ID, FollowID: blockedUser.ID})
+ })
+
+ // Blocked user cannot follow doer.
+ t.Run("Blocked user follow doer", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, blockedUser.Name)
+
+ req := NewRequestWithValues(t, "POST", "/"+doer.Name, map[string]string{
+ "_csrf": GetCSRF(t, session, "/"+doer.Name),
+ "action": "follow",
+ })
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Contains(t, htmlDoc.Find("#flash-message").Text(), "You cannot follow this user because you have blocked this user or this user has blocked you.")
+
+ unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: blockedUser.ID, FollowID: doer.ID})
+ })
+ })
+
+ // Ensures that the doer and blocked user cannot add each each other as collaborators.
+ t.Run("Add collaborator", func(t *testing.T) {
+ t.Run("Doer Add BlockedUser", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, doer.Name)
+ link := fmt.Sprintf("/%s/settings/collaboration", repo2.FullName())
+
+ req := NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": GetCSRF(t, session, link),
+ "collaborator": blockedUser2.Name,
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.EqualValues(t, "error%3DCannot%2Badd%2Bthe%2Bcollaborator%252C%2Bbecause%2Bthe%2Brepository%2Bowner%2Bhas%2Bblocked%2Bthem.", flashCookie.Value)
+ })
+
+ t.Run("BlockedUser Add doer", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, blockedUser2.Name)
+ link := fmt.Sprintf("/%s/settings/collaboration", repo7.FullName())
+
+ req := NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": GetCSRF(t, session, link),
+ "collaborator": doer.Name,
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := session.GetCookie(forgejo_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.EqualValues(t, "error%3DCannot%2Badd%2Bthe%2Bcollaborator%252C%2Bbecause%2Bthey%2Bhave%2Bblocked%2Bthe%2Brepository%2Bowner.", flashCookie.Value)
+ })
+ })
+
+ // Ensures that the blocked user cannot transfer a repository to the doer.
+ t.Run("Repository transfer", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, blockedUser2.Name)
+ link := fmt.Sprintf("%s/settings", repo7.FullName())
+
+ req := NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": GetCSRF(t, session, link),
+ "action": "transfer",
+ "repo_name": repo7.FullName(),
+ "new_owner_name": doer.Name,
+ })
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Contains(t,
+ htmlDoc.doc.Find(".ui.negative.message").Text(),
+ translation.NewLocale("en-US").Tr("repo.settings.new_owner_blocked_doer"),
+ )
+ })
+}
+
+func TestBlockedNotification(t *testing.T) {
+ defer tests.AddFixtures("tests/integration/fixtures/TestBlockedNotifications")()
+ defer tests.PrepareTestEnv(t)()
+
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ normalUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ blockedUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 10})
+ issue := unittest.AssertExistsAndLoadBean(t, &issue_model.Issue{ID: 1000})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ issueURL := fmt.Sprintf("%s/issues/%d", repo.FullName(), issue.Index)
+ notificationBean := &activities.Notification{UserID: doer.ID, RepoID: repo.ID, IssueID: issue.ID}
+
+ assert.False(t, user_model.IsBlocked(db.DefaultContext, doer.ID, normalUser.ID))
+ BlockUser(t, doer, blockedUser)
+
+ mentionDoer := func(t *testing.T, session *TestSession) {
+ t.Helper()
+
+ req := NewRequestWithValues(t, "POST", issueURL+"/comments", map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ "content": "I'm annoying. Pinging @" + doer.Name,
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+ }
+
+ t.Run("Blocks notification of blocked user", func(t *testing.T) {
+ session := loginUser(t, blockedUser.Name)
+
+ unittest.AssertNotExistsBean(t, notificationBean)
+ mentionDoer(t, session)
+ unittest.AssertNotExistsBean(t, notificationBean)
+ })
+
+ t.Run("Do not block notifications of normal user", func(t *testing.T) {
+ session := loginUser(t, normalUser.Name)
+
+ unittest.AssertNotExistsBean(t, notificationBean)
+ mentionDoer(t, session)
+ unittest.AssertExistsAndLoadBean(t, notificationBean)
+ })
+}
diff --git a/tests/integration/branches_test.go b/tests/integration/branches_test.go
new file mode 100644
index 0000000..e0482b6
--- /dev/null
+++ b/tests/integration/branches_test.go
@@ -0,0 +1,58 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "testing"
+
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ gitea_context "code.gitea.io/gitea/services/context"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestBranchActions(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user2")
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ branch3 := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{ID: 3, RepoID: repo1.ID})
+ branchesLink := repo1.FullName() + "/branches"
+
+ t.Run("View", func(t *testing.T) {
+ req := NewRequest(t, "GET", branchesLink)
+ MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("Delete branch", func(t *testing.T) {
+ link := fmt.Sprintf("/%s/branches/delete?name=%s", repo1.FullName(), branch3.Name)
+ req := NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": GetCSRF(t, session, branchesLink),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.Contains(t, flashCookie.Value, "success%3DBranch%2B%2522branch2%2522%2Bhas%2Bbeen%2Bdeleted.")
+
+ assert.True(t, unittest.AssertExistsAndLoadBean(t, &git_model.Branch{ID: 3, RepoID: repo1.ID}).IsDeleted)
+ })
+
+ t.Run("Restore branch", func(t *testing.T) {
+ link := fmt.Sprintf("/%s/branches/restore?branch_id=%d&name=%s", repo1.FullName(), branch3.ID, branch3.Name)
+ req := NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": GetCSRF(t, session, branchesLink),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.Contains(t, flashCookie.Value, "success%3DBranch%2B%2522branch2%2522%2Bhas%2Bbeen%2Brestored")
+
+ assert.False(t, unittest.AssertExistsAndLoadBean(t, &git_model.Branch{ID: 3, RepoID: repo1.ID}).IsDeleted)
+ })
+ })
+}
diff --git a/tests/integration/change_default_branch_test.go b/tests/integration/change_default_branch_test.go
new file mode 100644
index 0000000..703834b
--- /dev/null
+++ b/tests/integration/change_default_branch_test.go
@@ -0,0 +1,40 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/tests"
+)
+
+func TestChangeDefaultBranch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ branchesURL := fmt.Sprintf("/%s/%s/settings/branches", owner.Name, repo.Name)
+
+ csrf := GetCSRF(t, session, branchesURL)
+ req := NewRequestWithValues(t, "POST", branchesURL, map[string]string{
+ "_csrf": csrf,
+ "action": "default_branch",
+ "branch": "DefaultBranch",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ csrf = GetCSRF(t, session, branchesURL)
+ req = NewRequestWithValues(t, "POST", branchesURL, map[string]string{
+ "_csrf": csrf,
+ "action": "default_branch",
+ "branch": "does_not_exist",
+ })
+ session.MakeRequest(t, req, http.StatusNotFound)
+}
diff --git a/tests/integration/cmd_admin_test.go b/tests/integration/cmd_admin_test.go
new file mode 100644
index 0000000..576b09e
--- /dev/null
+++ b/tests/integration/cmd_admin_test.go
@@ -0,0 +1,147 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/url"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_Cmd_AdminUser(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ for _, testCase := range []struct {
+ name string
+ options []string
+ mustChangePassword bool
+ }{
+ {
+ name: "default",
+ options: []string{},
+ mustChangePassword: true,
+ },
+ {
+ name: "--must-change-password=false",
+ options: []string{"--must-change-password=false"},
+ mustChangePassword: false,
+ },
+ {
+ name: "--must-change-password=true",
+ options: []string{"--must-change-password=true"},
+ mustChangePassword: true,
+ },
+ {
+ name: "--must-change-password",
+ options: []string{"--must-change-password"},
+ mustChangePassword: true,
+ },
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ name := "testuser"
+
+ options := []string{"user", "create", "--username", name, "--password", "password", "--email", name + "@example.com"}
+ options = append(options, testCase.options...)
+ output, err := runMainApp("admin", options...)
+ require.NoError(t, err)
+ assert.Contains(t, output, "has been successfully created")
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: name})
+ assert.Equal(t, testCase.mustChangePassword, user.MustChangePassword)
+
+ options = []string{"user", "change-password", "--username", name, "--password", "password"}
+ options = append(options, testCase.options...)
+ output, err = runMainApp("admin", options...)
+ require.NoError(t, err)
+ assert.Contains(t, output, "has been successfully updated")
+ user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: name})
+ assert.Equal(t, testCase.mustChangePassword, user.MustChangePassword)
+
+ _, err = runMainApp("admin", "user", "delete", "--username", name)
+ require.NoError(t, err)
+ unittest.AssertNotExistsBean(t, &user_model.User{Name: name})
+ })
+ }
+ })
+}
+
+func Test_Cmd_AdminFirstUser(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ for _, testCase := range []struct {
+ name string
+ options []string
+ mustChangePassword bool
+ isAdmin bool
+ }{
+ {
+ name: "default",
+ options: []string{},
+ mustChangePassword: false,
+ isAdmin: false,
+ },
+ {
+ name: "--must-change-password=false",
+ options: []string{"--must-change-password=false"},
+ mustChangePassword: false,
+ isAdmin: false,
+ },
+ {
+ name: "--must-change-password=true",
+ options: []string{"--must-change-password=true"},
+ mustChangePassword: true,
+ isAdmin: false,
+ },
+ {
+ name: "--must-change-password",
+ options: []string{"--must-change-password"},
+ mustChangePassword: true,
+ isAdmin: false,
+ },
+ {
+ name: "--admin default",
+ options: []string{"--admin"},
+ mustChangePassword: false,
+ isAdmin: true,
+ },
+ {
+ name: "--admin --must-change-password=false",
+ options: []string{"--admin", "--must-change-password=false"},
+ mustChangePassword: false,
+ isAdmin: true,
+ },
+ {
+ name: "--admin --must-change-password=true",
+ options: []string{"--admin", "--must-change-password=true"},
+ mustChangePassword: true,
+ isAdmin: true,
+ },
+ {
+ name: "--admin --must-change-password",
+ options: []string{"--admin", "--must-change-password"},
+ mustChangePassword: true,
+ isAdmin: true,
+ },
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ db.GetEngine(db.DefaultContext).Exec("DELETE FROM `user`")
+ db.GetEngine(db.DefaultContext).Exec("DELETE FROM `email_address`")
+ assert.Equal(t, int64(0), user_model.CountUsers(db.DefaultContext, nil))
+ name := "testuser"
+
+ options := []string{"user", "create", "--username", name, "--password", "password", "--email", name + "@example.com"}
+ options = append(options, testCase.options...)
+ output, err := runMainApp("admin", options...)
+ require.NoError(t, err)
+ assert.Contains(t, output, "has been successfully created")
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: name})
+ assert.Equal(t, testCase.mustChangePassword, user.MustChangePassword)
+ assert.Equal(t, testCase.isAdmin, user.IsAdmin)
+ })
+ }
+ })
+}
diff --git a/tests/integration/cmd_forgejo_actions_test.go b/tests/integration/cmd_forgejo_actions_test.go
new file mode 100644
index 0000000..067cdef
--- /dev/null
+++ b/tests/integration/cmd_forgejo_actions_test.go
@@ -0,0 +1,215 @@
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ gocontext "context"
+ "io"
+ "net/url"
+ "os"
+ "os/exec"
+ "strings"
+ "testing"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_CmdForgejo_Actions(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ token, err := runMainApp("forgejo-cli", "actions", "generate-runner-token")
+ require.NoError(t, err)
+ assert.Len(t, token, 40)
+
+ secret, err := runMainApp("forgejo-cli", "actions", "generate-secret")
+ require.NoError(t, err)
+ assert.Len(t, secret, 40)
+
+ _, err = runMainApp("forgejo-cli", "actions", "register")
+ var exitErr *exec.ExitError
+ require.ErrorAs(t, err, &exitErr)
+ assert.Contains(t, string(exitErr.Stderr), "at least one of the --secret")
+
+ for _, testCase := range []struct {
+ testName string
+ scope string
+ secret string
+ errorMessage string
+ }{
+ {
+ testName: "bad user",
+ scope: "baduser",
+ secret: "0123456789012345678901234567890123456789",
+ errorMessage: "user does not exist",
+ },
+ {
+ testName: "bad repo",
+ scope: "org25/badrepo",
+ secret: "0123456789012345678901234567890123456789",
+ errorMessage: "repository does not exist",
+ },
+ {
+ testName: "secret length != 40",
+ scope: "org25",
+ secret: "0123456789",
+ errorMessage: "40 characters long",
+ },
+ {
+ testName: "secret is not a hexadecimal string",
+ scope: "org25",
+ secret: "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ errorMessage: "must be an hexadecimal string",
+ },
+ } {
+ t.Run(testCase.testName, func(t *testing.T) {
+ output, err := runMainApp("forgejo-cli", "actions", "register", "--secret", testCase.secret, "--scope", testCase.scope)
+ assert.EqualValues(t, "", output)
+
+ var exitErr *exec.ExitError
+ require.ErrorAs(t, err, &exitErr)
+ assert.Contains(t, string(exitErr.Stderr), testCase.errorMessage)
+ })
+ }
+
+ secret = "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD"
+ expecteduuid := "44444444-4444-4444-4444-444444444444"
+
+ for _, testCase := range []struct {
+ testName string
+ secretOption func() string
+ stdin io.Reader
+ }{
+ {
+ testName: "secret from argument",
+ secretOption: func() string {
+ return "--secret=" + secret
+ },
+ },
+ {
+ testName: "secret from stdin",
+ secretOption: func() string {
+ return "--secret-stdin"
+ },
+ stdin: strings.NewReader(secret),
+ },
+ {
+ testName: "secret from file",
+ secretOption: func() string {
+ secretFile := t.TempDir() + "/secret"
+ require.NoError(t, os.WriteFile(secretFile, []byte(secret), 0o644))
+ return "--secret-file=" + secretFile
+ },
+ },
+ } {
+ t.Run(testCase.testName, func(t *testing.T) {
+ uuid, err := runMainAppWithStdin(testCase.stdin, "forgejo-cli", "actions", "register", testCase.secretOption(), "--scope=org26")
+ require.NoError(t, err)
+ assert.EqualValues(t, expecteduuid, uuid)
+ })
+ }
+
+ secret = "0123456789012345678901234567890123456789"
+ expecteduuid = "30313233-3435-3637-3839-303132333435"
+
+ for _, testCase := range []struct {
+ testName string
+ scope string
+ secret string
+ name string
+ labels string
+ version string
+ uuid string
+ }{
+ {
+ testName: "org",
+ scope: "org25",
+ secret: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
+ uuid: "41414141-4141-4141-4141-414141414141",
+ },
+ {
+ testName: "user and repo",
+ scope: "user2/repo2",
+ secret: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
+ uuid: "42424242-4242-4242-4242-424242424242",
+ },
+ {
+ testName: "labels",
+ scope: "org25",
+ name: "runnerName",
+ labels: "label1,label2,label3",
+ version: "v1.2.3",
+ secret: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",
+ uuid: "43434343-4343-4343-4343-434343434343",
+ },
+ {
+ testName: "insert a runner",
+ scope: "user10/repo6",
+ name: "runnerName",
+ labels: "label1,label2,label3",
+ version: "v1.2.3",
+ secret: secret,
+ uuid: expecteduuid,
+ },
+ {
+ testName: "update an existing runner",
+ scope: "user5/repo4",
+ name: "runnerNameChanged",
+ labels: "label1,label2,label3,more,label",
+ version: "v1.2.3-suffix",
+ secret: secret,
+ uuid: expecteduuid,
+ },
+ } {
+ t.Run(testCase.testName, func(t *testing.T) {
+ cmd := []string{
+ "actions", "register",
+ "--secret", testCase.secret, "--scope", testCase.scope,
+ }
+ if testCase.name != "" {
+ cmd = append(cmd, "--name", testCase.name)
+ }
+ if testCase.labels != "" {
+ cmd = append(cmd, "--labels", testCase.labels)
+ }
+ if testCase.version != "" {
+ cmd = append(cmd, "--version", testCase.version)
+ }
+ //
+ // Run twice to verify it is idempotent
+ //
+ for i := 0; i < 2; i++ {
+ uuid, err := runMainApp("forgejo-cli", cmd...)
+ require.NoError(t, err)
+ if assert.EqualValues(t, testCase.uuid, uuid) {
+ ownerName, repoName, found := strings.Cut(testCase.scope, "/")
+ action, err := actions_model.GetRunnerByUUID(gocontext.Background(), uuid)
+ require.NoError(t, err)
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: action.OwnerID})
+ assert.Equal(t, ownerName, user.Name, action.OwnerID)
+
+ if found {
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: action.RepoID})
+ assert.Equal(t, repoName, repo.Name, action.RepoID)
+ }
+ if testCase.name != "" {
+ assert.EqualValues(t, testCase.name, action.Name)
+ }
+ if testCase.labels != "" {
+ labels := strings.Split(testCase.labels, ",")
+ assert.EqualValues(t, labels, action.AgentLabels)
+ }
+ if testCase.version != "" {
+ assert.EqualValues(t, testCase.version, action.Version)
+ }
+ }
+ }
+ })
+ }
+ })
+}
diff --git a/tests/integration/cmd_forgejo_f3_test.go b/tests/integration/cmd_forgejo_f3_test.go
new file mode 100644
index 0000000..9156405
--- /dev/null
+++ b/tests/integration/cmd_forgejo_f3_test.go
@@ -0,0 +1,137 @@
+// Copyright Earl Warren <contact@earl-warren.org>
+// Copyright Loïc Dachary <loic@dachary.org>
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "code.gitea.io/gitea/cmd/forgejo"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/services/f3/driver/options"
+ "code.gitea.io/gitea/tests"
+
+ _ "code.gitea.io/gitea/services/f3/driver"
+ _ "code.gitea.io/gitea/services/f3/driver/tests"
+
+ f3_filesystem_options "code.forgejo.org/f3/gof3/v3/forges/filesystem/options"
+ f3_logger "code.forgejo.org/f3/gof3/v3/logger"
+ f3_options "code.forgejo.org/f3/gof3/v3/options"
+ f3_generic "code.forgejo.org/f3/gof3/v3/tree/generic"
+ f3_tests "code.forgejo.org/f3/gof3/v3/tree/tests/f3"
+ f3_tests_forge "code.forgejo.org/f3/gof3/v3/tree/tests/f3/forge"
+ "github.com/stretchr/testify/require"
+ "github.com/urfave/cli/v2"
+)
+
+func runApp(ctx context.Context, args ...string) (string, error) {
+ l := f3_logger.NewCaptureLogger()
+ ctx = f3_logger.ContextSetLogger(ctx, l)
+ ctx = forgejo.ContextSetNoInit(ctx, true)
+
+ app := cli.NewApp()
+
+ app.Writer = l.GetBuffer()
+ app.ErrWriter = l.GetBuffer()
+
+ defer func() {
+ if r := recover(); r != nil {
+ fmt.Println(l.String())
+ panic(r)
+ }
+ }()
+
+ app.Commands = []*cli.Command{
+ forgejo.SubcmdF3Mirror(ctx),
+ }
+ err := app.Run(args)
+
+ fmt.Println(l.String())
+
+ return l.String(), err
+}
+
+func TestF3_CmdMirror_LocalForgejo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.F3.Enabled, true)()
+
+ ctx := context.Background()
+
+ mirrorOptions := f3_tests_forge.GetFactory(options.Name)().NewOptions(t)
+ mirrorTree := f3_generic.GetFactory("f3")(ctx, mirrorOptions)
+
+ fixtureOptions := f3_tests_forge.GetFactory(f3_filesystem_options.Name)().NewOptions(t)
+ fixtureTree := f3_generic.GetFactory("f3")(ctx, fixtureOptions)
+
+ log := fixtureTree.GetLogger()
+ creator := f3_tests.NewCreator(t, "CmdMirrorLocalForgejo", log)
+
+ log.Trace("======= build fixture")
+
+ var fromPath string
+ {
+ fixtureUserID := "userID01"
+ fixtureProjectID := "projectID01"
+
+ userFormat := creator.GenerateUser()
+ userFormat.SetID(fixtureUserID)
+ users := fixtureTree.MustFind(f3_generic.NewPathFromString("/forge/users"))
+ user := users.CreateChild(ctx)
+ user.FromFormat(userFormat)
+ user.Upsert(ctx)
+ require.EqualValues(t, user.GetID(), users.GetIDFromName(ctx, userFormat.UserName))
+
+ projectFormat := creator.GenerateProject()
+ projectFormat.SetID(fixtureProjectID)
+ projects := user.MustFind(f3_generic.NewPathFromString("projects"))
+ project := projects.CreateChild(ctx)
+ project.FromFormat(projectFormat)
+ project.Upsert(ctx)
+ require.EqualValues(t, project.GetID(), projects.GetIDFromName(ctx, projectFormat.Name))
+
+ fromPath = fmt.Sprintf("/forge/users/%s/projects/%s", userFormat.UserName, projectFormat.Name)
+ }
+
+ log.Trace("======= create mirror")
+
+ var toPath string
+ var projects f3_generic.NodeInterface
+ {
+ userFormat := creator.GenerateUser()
+ users := mirrorTree.MustFind(f3_generic.NewPathFromString("/forge/users"))
+ user := users.CreateChild(ctx)
+ user.FromFormat(userFormat)
+ user.Upsert(ctx)
+ require.EqualValues(t, user.GetID(), users.GetIDFromName(ctx, userFormat.UserName))
+
+ projectFormat := creator.GenerateProject()
+ projects = user.MustFind(f3_generic.NewPathFromString("projects"))
+ project := projects.CreateChild(ctx)
+ project.FromFormat(projectFormat)
+ project.Upsert(ctx)
+ require.EqualValues(t, project.GetID(), projects.GetIDFromName(ctx, projectFormat.Name))
+
+ toPath = fmt.Sprintf("/forge/users/%s/projects/%s", userFormat.UserName, projectFormat.Name)
+ }
+
+ log.Trace("======= mirror %s => %s", fromPath, toPath)
+ output, err := runApp(ctx,
+ "f3", "mirror",
+ "--from-type", f3_filesystem_options.Name,
+ "--from-path", fromPath,
+ "--from-filesystem-directory", fixtureOptions.(f3_options.URLInterface).GetURL(),
+
+ "--to-type", options.Name,
+ "--to-path", toPath,
+ )
+ require.NoError(t, err)
+ log.Trace("======= assert")
+ require.Contains(t, output, fmt.Sprintf("mirror %s", fromPath))
+ projects.List(ctx)
+ require.NotEmpty(t, projects.GetChildren())
+ log.Trace("======= project %s", projects.GetChildren()[0])
+}
diff --git a/tests/integration/cmd_keys_test.go b/tests/integration/cmd_keys_test.go
new file mode 100644
index 0000000..e93a8b5
--- /dev/null
+++ b/tests/integration/cmd_keys_test.go
@@ -0,0 +1,54 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "errors"
+ "net/url"
+ "os/exec"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/util"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_CmdKeys(t *testing.T) {
+ onGiteaRun(t, func(*testing.T, *url.URL) {
+ tests := []struct {
+ name string
+ args []string
+ wantErr bool
+ expectedOutput string
+ }{
+ {"test_empty_1", []string{"--username=git", "--type=test", "--content=test"}, true, ""},
+ {"test_empty_2", []string{"-e", "git", "-u", "git", "-t", "test", "-k", "test"}, true, ""},
+ {
+ "with_key",
+ []string{"-e", "git", "-u", "git", "-t", "ssh-rsa", "-k", "AAAAB3NzaC1yc2EAAAADAQABAAABgQDWVj0fQ5N8wNc0LVNA41wDLYJ89ZIbejrPfg/avyj3u/ZohAKsQclxG4Ju0VirduBFF9EOiuxoiFBRr3xRpqzpsZtnMPkWVWb+akZwBFAx8p+jKdy4QXR/SZqbVobrGwip2UjSrri1CtBxpJikojRIZfCnDaMOyd9Jp6KkujvniFzUWdLmCPxUE9zhTaPu0JsEP7MW0m6yx7ZUhHyfss+NtqmFTaDO+QlMR7L2QkDliN2Jl3Xa3PhuWnKJfWhdAq1Cw4oraKUOmIgXLkuiuxVQ6mD3AiFupkmfqdHq6h+uHHmyQqv3gU+/sD8GbGAhf6ftqhTsXjnv1Aj4R8NoDf9BS6KRkzkeun5UisSzgtfQzjOMEiJtmrep2ZQrMGahrXa+q4VKr0aKJfm+KlLfwm/JztfsBcqQWNcTURiCFqz+fgZw0Ey/de0eyMzldYTdXXNRYCKjs9bvBK+6SSXRM7AhftfQ0ZuoW5+gtinPrnmoOaSCEJbAiEiTO/BzOHgowiM="},
+ false,
+ "# gitea public key\ncommand=\"" + setting.AppPath + " --config=" + util.ShellEscape(setting.CustomConf) + " serv key-1\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty,no-user-rc,restrict ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDWVj0fQ5N8wNc0LVNA41wDLYJ89ZIbejrPfg/avyj3u/ZohAKsQclxG4Ju0VirduBFF9EOiuxoiFBRr3xRpqzpsZtnMPkWVWb+akZwBFAx8p+jKdy4QXR/SZqbVobrGwip2UjSrri1CtBxpJikojRIZfCnDaMOyd9Jp6KkujvniFzUWdLmCPxUE9zhTaPu0JsEP7MW0m6yx7ZUhHyfss+NtqmFTaDO+QlMR7L2QkDliN2Jl3Xa3PhuWnKJfWhdAq1Cw4oraKUOmIgXLkuiuxVQ6mD3AiFupkmfqdHq6h+uHHmyQqv3gU+/sD8GbGAhf6ftqhTsXjnv1Aj4R8NoDf9BS6KRkzkeun5UisSzgtfQzjOMEiJtmrep2ZQrMGahrXa+q4VKr0aKJfm+KlLfwm/JztfsBcqQWNcTURiCFqz+fgZw0Ey/de0eyMzldYTdXXNRYCKjs9bvBK+6SSXRM7AhftfQ0ZuoW5+gtinPrnmoOaSCEJbAiEiTO/BzOHgowiM= user2@localhost\n",
+ },
+ {"invalid", []string{"--not-a-flag=git"}, true, "Incorrect Usage: flag provided but not defined: -not-a-flag\n\n"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ out, err := runMainApp("keys", tt.args...)
+
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ t.Log(string(exitErr.Stderr))
+ }
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ }
+ assert.Equal(t, tt.expectedOutput, out)
+ })
+ }
+ })
+}
diff --git a/tests/integration/codeowner_test.go b/tests/integration/codeowner_test.go
new file mode 100644
index 0000000..71661bc
--- /dev/null
+++ b/tests/integration/codeowner_test.go
@@ -0,0 +1,140 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "os"
+ "path"
+ "strings"
+ "testing"
+ "time"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestCodeOwner(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ // Create the repo.
+ repo, _, f := tests.CreateDeclarativeRepo(t, user2, "",
+ []unit_model.Type{unit_model.TypePullRequests}, nil,
+ []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "CODEOWNERS",
+ ContentReader: strings.NewReader("README.md @user5\ntest-file @user4"),
+ },
+ },
+ )
+ defer f()
+
+ dstPath := t.TempDir()
+ r := fmt.Sprintf("%suser2/%s.git", u.String(), repo.Name)
+ cloneURL, _ := url.Parse(r)
+ cloneURL.User = url.UserPassword("user2", userPassword)
+ require.NoError(t, git.CloneWithArgs(context.Background(), nil, cloneURL.String(), dstPath, git.CloneRepoOptions{}))
+
+ t.Run("Normal", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ err := os.WriteFile(path.Join(dstPath, "README.md"), []byte("## test content"), 0o666)
+ require.NoError(t, err)
+
+ err = git.AddChanges(dstPath, true)
+ require.NoError(t, err)
+
+ err = git.CommitChanges(dstPath, git.CommitChangesOptions{
+ Committer: &git.Signature{
+ Email: "user2@example.com",
+ Name: "user2",
+ When: time.Now(),
+ },
+ Author: &git.Signature{
+ Email: "user2@example.com",
+ Name: "user2",
+ When: time.Now(),
+ },
+ Message: "Add README.",
+ })
+ require.NoError(t, err)
+
+ err = git.NewCommand(git.DefaultContext, "push", "origin", "HEAD:refs/for/main", "-o", "topic=codeowner-normal").Run(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadBranch: "user2/codeowner-normal"})
+ unittest.AssertExistsIf(t, true, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 5})
+ })
+
+ t.Run("Forked repository", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, user2.Name, repo.Name, "user1", "repo1")
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: "repo1"})
+
+ r := fmt.Sprintf("%suser1/repo1.git", u.String())
+ remoteURL, _ := url.Parse(r)
+ remoteURL.User = url.UserPassword("user2", userPassword)
+ doGitAddRemote(dstPath, "forked", remoteURL)(t)
+
+ err := git.NewCommand(git.DefaultContext, "push", "forked", "HEAD:refs/for/main", "-o", "topic=codeowner-forked").Run(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadBranch: "user2/codeowner-forked"})
+ unittest.AssertExistsIf(t, false, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 5})
+ })
+
+ t.Run("Out of date", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Push the changes made from the previous subtest.
+ require.NoError(t, git.NewCommand(git.DefaultContext, "push", "origin").Run(&git.RunOpts{Dir: dstPath}))
+
+ // Reset the tree to the previous commit.
+ require.NoError(t, git.NewCommand(git.DefaultContext, "reset", "--hard", "HEAD~1").Run(&git.RunOpts{Dir: dstPath}))
+
+ err := os.WriteFile(path.Join(dstPath, "test-file"), []byte("## test content"), 0o666)
+ require.NoError(t, err)
+
+ err = git.AddChanges(dstPath, true)
+ require.NoError(t, err)
+
+ err = git.CommitChanges(dstPath, git.CommitChangesOptions{
+ Committer: &git.Signature{
+ Email: "user2@example.com",
+ Name: "user2",
+ When: time.Now(),
+ },
+ Author: &git.Signature{
+ Email: "user2@example.com",
+ Name: "user2",
+ When: time.Now(),
+ },
+ Message: "Add test-file.",
+ })
+ require.NoError(t, err)
+
+ err = git.NewCommand(git.DefaultContext, "push", "origin", "HEAD:refs/for/main", "-o", "topic=codeowner-out-of-date").Run(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadBranch: "user2/codeowner-out-of-date"})
+ unittest.AssertExistsIf(t, true, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 4})
+ unittest.AssertExistsIf(t, false, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 5})
+ })
+ })
+}
diff --git a/tests/integration/compare_test.go b/tests/integration/compare_test.go
new file mode 100644
index 0000000..c65335c
--- /dev/null
+++ b/tests/integration/compare_test.go
@@ -0,0 +1,293 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/test"
+ repo_service "code.gitea.io/gitea/services/repository"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCompareTag(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+ req := NewRequest(t, "GET", "/user2/repo1/compare/v1.1...master")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ selection := htmlDoc.doc.Find(".choose.branch .filter.dropdown")
+ // A dropdown for both base and head.
+ assert.Lenf(t, selection.Nodes, 2, "The template has changed")
+
+ req = NewRequest(t, "GET", "/user2/repo1/compare/invalid")
+ resp = session.MakeRequest(t, req, http.StatusNotFound)
+ assert.False(t, strings.Contains(resp.Body.String(), ">500<"), "expect 404 page not 500")
+}
+
+// Compare with inferred default branch (master)
+func TestCompareDefault(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+ req := NewRequest(t, "GET", "/user2/repo1/compare/v1.1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ selection := htmlDoc.doc.Find(".choose.branch .filter.dropdown")
+ assert.Lenf(t, selection.Nodes, 2, "The template has changed")
+}
+
+// Ensure the comparison matches what we expect
+func inspectCompare(t *testing.T, htmlDoc *HTMLDoc, diffCount int, diffChanges []string) {
+ selection := htmlDoc.doc.Find("#diff-file-boxes").Children()
+
+ assert.Lenf(t, selection.Nodes, diffCount, "Expected %v diffed files, found: %v", diffCount, len(selection.Nodes))
+
+ for _, diffChange := range diffChanges {
+ selection = htmlDoc.doc.Find(fmt.Sprintf("[data-new-filename=\"%s\"]", diffChange))
+ assert.Lenf(t, selection.Nodes, 1, "Expected 1 match for [data-new-filename=\"%s\"], found: %v", diffChange, len(selection.Nodes))
+ }
+}
+
+// Git commit graph for repo20
+// * 8babce9 (origin/remove-files-b) Add a dummy file
+// * b67e43a Delete test.csv and link_hi
+// | * cfe3b3c (origin/remove-files-a) Delete test.csv and link_hi
+// |/
+// * c8e31bc (origin/add-csv) Add test csv file
+// * 808038d (HEAD -> master, origin/master, origin/HEAD) Added test links
+
+func TestCompareBranches(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ // Indirect compare remove-files-b (head) with add-csv (base) branch
+ //
+ // 'link_hi' and 'test.csv' are deleted, 'test.txt' is added
+ req := NewRequest(t, "GET", "/user2/repo20/compare/add-csv...remove-files-b")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ diffCount := 3
+ diffChanges := []string{"link_hi", "test.csv", "test.txt"}
+
+ inspectCompare(t, htmlDoc, diffCount, diffChanges)
+
+ // Indirect compare remove-files-b (head) with remove-files-a (base) branch
+ //
+ // 'link_hi' and 'test.csv' are deleted, 'test.txt' is added
+
+ req = NewRequest(t, "GET", "/user2/repo20/compare/remove-files-a...remove-files-b")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+
+ diffCount = 3
+ diffChanges = []string{"link_hi", "test.csv", "test.txt"}
+
+ inspectCompare(t, htmlDoc, diffCount, diffChanges)
+
+ // Indirect compare remove-files-a (head) with remove-files-b (base) branch
+ //
+ // 'link_hi' and 'test.csv' are deleted
+
+ req = NewRequest(t, "GET", "/user2/repo20/compare/remove-files-b...remove-files-a")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+
+ diffCount = 2
+ diffChanges = []string{"link_hi", "test.csv"}
+
+ inspectCompare(t, htmlDoc, diffCount, diffChanges)
+
+ // Direct compare remove-files-b (head) with remove-files-a (base) branch
+ //
+ // 'test.txt' is deleted
+
+ req = NewRequest(t, "GET", "/user2/repo20/compare/remove-files-b..remove-files-a")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+
+ diffCount = 1
+ diffChanges = []string{"test.txt"}
+
+ inspectCompare(t, htmlDoc, diffCount, diffChanges)
+}
+
+func TestCompareWithPRsDisabled(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testCreateBranch(t, session, "user1", "repo1", "branch/master", "recent-push", http.StatusSeeOther)
+ testEditFile(t, session, "user1", "repo1", "recent-push", "README.md", "Hello recently!\n")
+
+ repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user1", "repo1")
+ require.NoError(t, err)
+
+ defer func() {
+ // Re-enable PRs on the repo
+ err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo,
+ []repo_model.RepoUnit{{
+ RepoID: repo.ID,
+ Type: unit_model.TypePullRequests,
+ }},
+ nil)
+ require.NoError(t, err)
+ }()
+
+ // Disable PRs on the repo
+ err = repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, nil,
+ []unit_model.Type{unit_model.TypePullRequests})
+ require.NoError(t, err)
+
+ t.Run("branch view doesn't offer creating PRs", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user1/repo1/branches")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, "a[href='/user1/repo1/compare/master...recent-push']", false)
+ })
+
+ t.Run("compare doesn't offer local branches", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/compare/master...user1/repo1:recent-push")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ branches := htmlDoc.Find(".choose.branch .menu .reference-list-menu.base-branch-list .item, .choose.branch .menu .reference-list-menu.base-tag-list .item")
+
+ expectedPrefix := "user2:"
+ for i := 0; i < len(branches.Nodes); i++ {
+ assert.True(t, strings.HasPrefix(branches.Eq(i).Text(), expectedPrefix))
+ }
+ })
+
+ t.Run("comparing against a disabled-PR repo is 404", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user1/repo1/compare/master...recent-push")
+ session.MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+}
+
+func TestCompareCrossRepo(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1-copy")
+ testCreateBranch(t, session, "user1", "repo1-copy", "branch/master", "recent-push", http.StatusSeeOther)
+ testEditFile(t, session, "user1", "repo1-copy", "recent-push", "README.md", "Hello recently!\n")
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: "repo1-copy"})
+
+ gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ lastCommit, err := gitRepo.GetBranchCommitID("recent-push")
+ require.NoError(t, err)
+ assert.NotEmpty(t, lastCommit)
+
+ t.Run("view file button links to correct file in fork", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/compare/master...user1/repo1-copy:recent-push")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, "a[href='/user1/repo1-copy/src/commit/"+lastCommit+"/README.md']", true)
+ htmlDoc.AssertElement(t, "a[href='/user1/repo1/src/commit/"+lastCommit+"/README.md']", false)
+ })
+ })
+}
+
+func TestCompareCodeExpand(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ // Create a new repository, with a file that has many lines
+ repo, _, f := tests.CreateDeclarativeRepoWithOptions(t, owner, tests.DeclarativeRepoOptions{
+ Files: optional.Some([]*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "docs.md",
+ ContentReader: strings.NewReader("01\n02\n03\n04\n05\n06\n07\n08\n09\n0a\n0b\n0c\n0d\n0e\n0f\n10\n11\n12\n12\n13\n14\n15\n16\n17\n18\n19\n1a\n1b\n1c\n1d\n1e\n1f\n20\n"),
+ },
+ }),
+ })
+ defer f()
+
+ // Fork the repository
+ forker := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, forker.Name)
+ testRepoFork(t, session, owner.Name, repo.Name, forker.Name, repo.Name+"-copy")
+ testCreateBranch(t, session, forker.Name, repo.Name+"-copy", "branch/main", "code-expand", http.StatusSeeOther)
+
+ // Edit the file, insert a line somewhere in the middle
+ testEditFile(t, session, forker.Name, repo.Name+"-copy", "code-expand", "docs.md",
+ "01\n02\n03\n04\n05\n06\n07\n08\n09\n0a\n0b\n0c\n0d\n0e\n0f\n10\n11\nHELLO WORLD!\n12\n12\n13\n14\n15\n16\n17\n18\n19\n1a\n1b\n1c\n1d\n1e\n1f\n20\n",
+ )
+
+ t.Run("code expander targets the fork", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestf(t, "GET", "%s/%s/compare/main...%s/%s:code-expand",
+ owner.Name, repo.Name, forker.Name, repo.Name+"-copy")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ els := htmlDoc.Find(`button.code-expander-button[hx-get]`)
+
+ // all the links in the comparison should be to the forked repo&branch
+ assert.NotZero(t, els.Length())
+ expectedPrefix := fmt.Sprintf("/%s/%s/blob_excerpt/", forker.Name, repo.Name+"-copy")
+ for i := 0; i < els.Length(); i++ {
+ link := els.Eq(i).AttrOr("hx-get", "")
+ assert.True(t, strings.HasPrefix(link, expectedPrefix))
+ }
+ })
+
+ t.Run("code expander targets the repo in a PR", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Create a pullrequest
+ resp := testPullCreate(t, session, forker.Name, repo.Name+"-copy", false, "main", "code-expand", "This is a pull title")
+
+ // Grab the URL for the PR
+ url := test.RedirectURL(resp) + "/files"
+
+ // Visit the PR's diff
+ req := NewRequest(t, "GET", url)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ els := htmlDoc.Find(`button.code-expander-button[hx-get]`)
+
+ // all the links in the comparison should be to the original repo&branch
+ assert.NotZero(t, els.Length())
+ expectedPrefix := fmt.Sprintf("/%s/%s/blob_excerpt/", owner.Name, repo.Name)
+ for i := 0; i < els.Length(); i++ {
+ link := els.Eq(i).AttrOr("hx-get", "")
+ assert.True(t, strings.HasPrefix(link, expectedPrefix))
+ }
+ })
+ })
+}
diff --git a/tests/integration/cors_test.go b/tests/integration/cors_test.go
new file mode 100644
index 0000000..25dfbab
--- /dev/null
+++ b/tests/integration/cors_test.go
@@ -0,0 +1,94 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCORS(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ t.Run("CORS enabled", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.CORSConfig.Enabled, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ t.Run("API with CORS", func(t *testing.T) {
+ // GET api with no CORS header
+ req := NewRequest(t, "GET", "/api/v1/version")
+ resp := MakeRequest(t, req, http.StatusOK)
+ assert.Empty(t, resp.Header().Get("Access-Control-Allow-Origin"))
+ assert.Contains(t, resp.Header().Values("Vary"), "Origin")
+
+ // OPTIONS api for CORS
+ req = NewRequest(t, "OPTIONS", "/api/v1/version").
+ SetHeader("Origin", "https://example.com").
+ SetHeader("Access-Control-Request-Method", "GET")
+ resp = MakeRequest(t, req, http.StatusOK)
+ assert.NotEmpty(t, resp.Header().Get("Access-Control-Allow-Origin"))
+ assert.Contains(t, resp.Header().Values("Vary"), "Origin")
+ })
+
+ t.Run("Web with CORS", func(t *testing.T) {
+ // GET userinfo with no CORS header
+ req := NewRequest(t, "GET", "/login/oauth/userinfo")
+ resp := MakeRequest(t, req, http.StatusUnauthorized)
+ assert.Empty(t, resp.Header().Get("Access-Control-Allow-Origin"))
+ assert.Contains(t, resp.Header().Values("Vary"), "Origin")
+
+ // OPTIONS userinfo for CORS
+ req = NewRequest(t, "OPTIONS", "/login/oauth/userinfo").
+ SetHeader("Origin", "https://example.com").
+ SetHeader("Access-Control-Request-Method", "GET")
+ resp = MakeRequest(t, req, http.StatusOK)
+ assert.NotEmpty(t, resp.Header().Get("Access-Control-Allow-Origin"))
+ assert.Contains(t, resp.Header().Values("Vary"), "Origin")
+
+ // OPTIONS userinfo for non-CORS
+ req = NewRequest(t, "OPTIONS", "/login/oauth/userinfo")
+ resp = MakeRequest(t, req, http.StatusMethodNotAllowed)
+ assert.NotContains(t, resp.Header().Values("Vary"), "Origin")
+ })
+ })
+
+ t.Run("CORS disabled", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.CORSConfig.Enabled, false)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ t.Run("API without CORS", func(t *testing.T) {
+ req := NewRequest(t, "GET", "/api/v1/version")
+ resp := MakeRequest(t, req, http.StatusOK)
+ assert.Empty(t, resp.Header().Get("Access-Control-Allow-Origin"))
+ assert.Empty(t, resp.Header().Values("Vary"))
+
+ req = NewRequest(t, "OPTIONS", "/api/v1/version").
+ SetHeader("Origin", "https://example.com").
+ SetHeader("Access-Control-Request-Method", "GET")
+ resp = MakeRequest(t, req, http.StatusMethodNotAllowed)
+ assert.Empty(t, resp.Header().Get("Access-Control-Allow-Origin"))
+ assert.Empty(t, resp.Header().Values("Vary"))
+ })
+
+ t.Run("Web without CORS", func(t *testing.T) {
+ req := NewRequest(t, "GET", "/login/oauth/userinfo")
+ resp := MakeRequest(t, req, http.StatusUnauthorized)
+ assert.Empty(t, resp.Header().Get("Access-Control-Allow-Origin"))
+ assert.NotContains(t, resp.Header().Values("Vary"), "Origin")
+
+ req = NewRequest(t, "OPTIONS", "/login/oauth/userinfo").
+ SetHeader("Origin", "https://example.com").
+ SetHeader("Access-Control-Request-Method", "GET")
+ resp = MakeRequest(t, req, http.StatusMethodNotAllowed)
+ assert.Empty(t, resp.Header().Get("Access-Control-Allow-Origin"))
+ assert.NotContains(t, resp.Header().Values("Vary"), "Origin")
+ })
+ })
+}
diff --git a/tests/integration/create_no_session_test.go b/tests/integration/create_no_session_test.go
new file mode 100644
index 0000000..ca2a775
--- /dev/null
+++ b/tests/integration/create_no_session_test.go
@@ -0,0 +1,112 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/tests"
+
+ "code.forgejo.org/go-chi/session"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func getSessionID(t *testing.T, resp *httptest.ResponseRecorder) string {
+ cookies := resp.Result().Cookies()
+ found := false
+ sessionID := ""
+ for _, cookie := range cookies {
+ if cookie.Name == setting.SessionConfig.CookieName {
+ sessionID = cookie.Value
+ found = true
+ }
+ }
+ assert.True(t, found)
+ assert.NotEmpty(t, sessionID)
+ return sessionID
+}
+
+func sessionFile(tmpDir, sessionID string) string {
+ return filepath.Join(tmpDir, sessionID[0:1], sessionID[1:2], sessionID)
+}
+
+func sessionFileExist(t *testing.T, tmpDir, sessionID string) bool {
+ sessionFile := sessionFile(tmpDir, sessionID)
+ _, err := os.Lstat(sessionFile)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return false
+ }
+ require.NoError(t, err)
+ }
+ return true
+}
+
+func TestSessionFileCreation(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ oldSessionConfig := setting.SessionConfig.ProviderConfig
+ defer func() {
+ setting.SessionConfig.ProviderConfig = oldSessionConfig
+ testWebRoutes = routers.NormalRoutes()
+ }()
+
+ var config session.Options
+
+ err := json.Unmarshal([]byte(oldSessionConfig), &config)
+ require.NoError(t, err)
+
+ config.Provider = "file"
+
+ // Now create a temporaryDirectory
+ tmpDir := t.TempDir()
+ config.ProviderConfig = tmpDir
+
+ newConfigBytes, err := json.Marshal(config)
+ require.NoError(t, err)
+
+ setting.SessionConfig.ProviderConfig = string(newConfigBytes)
+
+ testWebRoutes = routers.NormalRoutes()
+
+ t.Run("NoSessionOnViewIssue", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues/1")
+ resp := MakeRequest(t, req, http.StatusOK)
+ sessionID := getSessionID(t, resp)
+
+ // We're not logged in so there should be no session
+ assert.False(t, sessionFileExist(t, tmpDir, sessionID))
+ })
+ t.Run("CreateSessionOnLogin", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user/login")
+ resp := MakeRequest(t, req, http.StatusOK)
+ sessionID := getSessionID(t, resp)
+
+ // We're not logged in so there should be no session
+ assert.False(t, sessionFileExist(t, tmpDir, sessionID))
+
+ doc := NewHTMLParser(t, resp.Body)
+ req = NewRequestWithValues(t, "POST", "/user/login", map[string]string{
+ "_csrf": doc.GetCSRF(),
+ "user_name": "user2",
+ "password": userPassword,
+ })
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ sessionID = getSessionID(t, resp)
+
+ assert.FileExists(t, sessionFile(tmpDir, sessionID))
+ })
+}
diff --git a/tests/integration/csrf_test.go b/tests/integration/csrf_test.go
new file mode 100644
index 0000000..fcb9661
--- /dev/null
+++ b/tests/integration/csrf_test.go
@@ -0,0 +1,34 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCsrfProtection(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // test web form csrf via form
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user.Name)
+ req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
+ "_csrf": "fake_csrf",
+ })
+ resp := session.MakeRequest(t, req, http.StatusBadRequest)
+ assert.Contains(t, resp.Body.String(), "Invalid CSRF token")
+
+ // test web form csrf via header. TODO: should use an UI api to test
+ req = NewRequest(t, "POST", "/user/settings")
+ req.Header.Add("X-Csrf-Token", "fake_csrf")
+ resp = session.MakeRequest(t, req, http.StatusBadRequest)
+ assert.Contains(t, resp.Body.String(), "Invalid CSRF token")
+}
diff --git a/tests/integration/db_collation_test.go b/tests/integration/db_collation_test.go
new file mode 100644
index 0000000..0e5bf00
--- /dev/null
+++ b/tests/integration/db_collation_test.go
@@ -0,0 +1,149 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "xorm.io/xorm"
+)
+
+type TestCollationTbl struct {
+ ID int64
+ Txt string `xorm:"VARCHAR(10) UNIQUE"`
+}
+
+func TestDatabaseCollationSelfCheckUI(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ assertSelfCheckExists := func(exists bool) {
+ expectedHTTPResponse := http.StatusOK
+ if !exists {
+ expectedHTTPResponse = http.StatusNotFound
+ }
+ session := loginUser(t, "user1")
+ req := NewRequest(t, "GET", "/admin/self_check")
+ resp := session.MakeRequest(t, req, expectedHTTPResponse)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, "a.item[href*='/admin/self_check']", exists)
+ }
+
+ if setting.Database.Type.IsMySQL() {
+ assertSelfCheckExists(true)
+ } else {
+ assertSelfCheckExists(false)
+ }
+}
+
+func TestDatabaseCollation(t *testing.T) {
+ x := db.GetEngine(db.DefaultContext).(*xorm.Engine)
+
+ // all created tables should use case-sensitive collation by default
+ _, _ = x.Exec("DROP TABLE IF EXISTS test_collation_tbl")
+ err := x.Sync(&TestCollationTbl{})
+ require.NoError(t, err)
+ _, _ = x.Exec("INSERT INTO test_collation_tbl (txt) VALUES ('main')")
+ _, _ = x.Exec("INSERT INTO test_collation_tbl (txt) VALUES ('Main')") // case-sensitive, so it inserts a new row
+ _, _ = x.Exec("INSERT INTO test_collation_tbl (txt) VALUES ('main')") // duplicate, so it doesn't insert
+ cnt, err := x.Count(&TestCollationTbl{})
+ require.NoError(t, err)
+ assert.EqualValues(t, 2, cnt)
+ _, _ = x.Exec("DROP TABLE IF EXISTS test_collation_tbl")
+
+ // by default, SQLite3 and PostgreSQL are using case-sensitive collations, but MySQL is not.
+ if !setting.Database.Type.IsMySQL() {
+ t.Skip("only MySQL requires the case-sensitive collation check at the moment")
+ return
+ }
+
+ t.Run("Default startup makes database collation case-sensitive", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ r, err := db.CheckCollations(x)
+ require.NoError(t, err)
+ assert.True(t, r.IsCollationCaseSensitive(r.DatabaseCollation))
+ assert.True(t, r.CollationEquals(r.ExpectedCollation, r.DatabaseCollation))
+ assert.NotEmpty(t, r.AvailableCollation)
+ assert.Empty(t, r.InconsistentCollationColumns)
+
+ // and by the way test the helper functions
+ if setting.Database.Type.IsMySQL() {
+ assert.True(t, r.IsCollationCaseSensitive("utf8mb4_bin"))
+ assert.True(t, r.IsCollationCaseSensitive("utf8mb4_xxx_as_cs"))
+ assert.False(t, r.IsCollationCaseSensitive("utf8mb4_general_ci"))
+ assert.True(t, r.CollationEquals("abc", "abc"))
+ assert.True(t, r.CollationEquals("abc", "utf8mb4_abc"))
+ assert.False(t, r.CollationEquals("utf8mb4_general_ci", "utf8mb4_unicode_ci"))
+ } else {
+ assert.Fail(t, "unexpected database type")
+ }
+ })
+
+ t.Run("Convert tables to utf8mb4_bin", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ defer test.MockVariableValue(&setting.Database.CharsetCollation, "utf8mb4_bin")()
+ require.NoError(t, db.ConvertDatabaseTable())
+ time.Sleep(5 * time.Second)
+
+ r, err := db.CheckCollations(x)
+ require.NoError(t, err)
+ assert.Equal(t, "utf8mb4_bin", r.DatabaseCollation)
+ assert.True(t, r.CollationEquals(r.ExpectedCollation, r.DatabaseCollation))
+ assert.Empty(t, r.InconsistentCollationColumns)
+
+ _, _ = x.Exec("DROP TABLE IF EXISTS test_tbl")
+ _, err = x.Exec("CREATE TABLE test_tbl (txt varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL)")
+ require.NoError(t, err)
+ r, err = db.CheckCollations(x)
+ require.NoError(t, err)
+ assert.Contains(t, r.InconsistentCollationColumns, "test_tbl.txt")
+ })
+
+ t.Run("Convert tables to utf8mb4_general_ci", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ defer test.MockVariableValue(&setting.Database.CharsetCollation, "utf8mb4_general_ci")()
+ require.NoError(t, db.ConvertDatabaseTable())
+ time.Sleep(5 * time.Second)
+
+ r, err := db.CheckCollations(x)
+ require.NoError(t, err)
+ assert.Equal(t, "utf8mb4_general_ci", r.DatabaseCollation)
+ assert.True(t, r.CollationEquals(r.ExpectedCollation, r.DatabaseCollation))
+ assert.Empty(t, r.InconsistentCollationColumns)
+
+ _, _ = x.Exec("DROP TABLE IF EXISTS test_tbl")
+ _, err = x.Exec("CREATE TABLE test_tbl (txt varchar(10) COLLATE utf8mb4_bin NOT NULL)")
+ require.NoError(t, err)
+ r, err = db.CheckCollations(x)
+ require.NoError(t, err)
+ assert.Contains(t, r.InconsistentCollationColumns, "test_tbl.txt")
+ })
+
+ t.Run("Convert tables to default case-sensitive collation", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ defer test.MockVariableValue(&setting.Database.CharsetCollation, "")()
+ require.NoError(t, db.ConvertDatabaseTable())
+ time.Sleep(5 * time.Second)
+
+ r, err := db.CheckCollations(x)
+ require.NoError(t, err)
+ assert.True(t, r.IsCollationCaseSensitive(r.DatabaseCollation))
+ assert.True(t, r.CollationEquals(r.ExpectedCollation, r.DatabaseCollation))
+ assert.Empty(t, r.InconsistentCollationColumns)
+ })
+}
diff --git a/tests/integration/delete_user_test.go b/tests/integration/delete_user_test.go
new file mode 100644
index 0000000..fa407a7
--- /dev/null
+++ b/tests/integration/delete_user_test.go
@@ -0,0 +1,63 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/organization"
+ access_model "code.gitea.io/gitea/models/perm/access"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/tests"
+)
+
+func assertUserDeleted(t *testing.T, userID int64, purged bool) {
+ unittest.AssertNotExistsBean(t, &user_model.User{ID: userID})
+ unittest.AssertNotExistsBean(t, &user_model.Follow{UserID: userID})
+ unittest.AssertNotExistsBean(t, &user_model.Follow{FollowID: userID})
+ unittest.AssertNotExistsBean(t, &repo_model.Repository{OwnerID: userID})
+ unittest.AssertNotExistsBean(t, &access_model.Access{UserID: userID})
+ unittest.AssertNotExistsBean(t, &organization.OrgUser{UID: userID})
+ unittest.AssertNotExistsBean(t, &issues_model.IssueUser{UID: userID})
+ unittest.AssertNotExistsBean(t, &organization.TeamUser{UID: userID})
+ unittest.AssertNotExistsBean(t, &repo_model.Star{UID: userID})
+ if purged {
+ unittest.AssertNotExistsBean(t, &issues_model.Issue{PosterID: userID})
+ }
+}
+
+func TestUserDeleteAccount(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user8")
+ csrf := GetCSRF(t, session, "/user/settings/account")
+ urlStr := fmt.Sprintf("/user/settings/account/delete?password=%s", userPassword)
+ req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
+ "_csrf": csrf,
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ assertUserDeleted(t, 8, false)
+ unittest.CheckConsistencyFor(t, &user_model.User{})
+}
+
+func TestUserDeleteAccountStillOwnRepos(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+ csrf := GetCSRF(t, session, "/user/settings/account")
+ urlStr := fmt.Sprintf("/user/settings/account/delete?password=%s", userPassword)
+ req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
+ "_csrf": csrf,
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // user should not have been deleted, because the user still owns repos
+ unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+}
diff --git a/tests/integration/doctor_packages_nuget_test.go b/tests/integration/doctor_packages_nuget_test.go
new file mode 100644
index 0000000..a012567
--- /dev/null
+++ b/tests/integration/doctor_packages_nuget_test.go
@@ -0,0 +1,122 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/zip"
+ "bytes"
+ "fmt"
+ "io"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/log"
+ packages_module "code.gitea.io/gitea/modules/packages"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ doctor "code.gitea.io/gitea/services/doctor"
+ packages_service "code.gitea.io/gitea/services/packages"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDoctorPackagesNuget(t *testing.T) {
+ defer tests.PrepareTestEnv(t, 1)()
+ // use local storage for tests because minio is too flaky
+ defer test.MockVariableValue(&setting.Packages.Storage.Type, setting.LocalStorageType)()
+
+ logger := log.GetLogger("doctor")
+
+ ctx := db.DefaultContext
+
+ packageName := "test.package"
+ packageVersion := "1.0.3"
+ packageAuthors := "KN4CK3R"
+ packageDescription := "Gitea Test Package"
+
+ createPackage := func(id, version string) io.Reader {
+ var buf bytes.Buffer
+ archive := zip.NewWriter(&buf)
+ w, _ := archive.Create("package.nuspec")
+ w.Write([]byte(`<?xml version="1.0" encoding="utf-8"?>
+ <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
+ <metadata>
+ <id>` + id + `</id>
+ <version>` + version + `</version>
+ <authors>` + packageAuthors + `</authors>
+ <description>` + packageDescription + `</description>
+ <dependencies>
+ <group targetFramework=".NETStandard2.0">
+ <dependency id="Microsoft.CSharp" version="4.5.0" />
+ </group>
+ </dependencies>
+ </metadata>
+ </package>`))
+ archive.Close()
+ return &buf
+ }
+
+ pkg := createPackage(packageName, packageVersion)
+
+ pkgBuf, err := packages_module.CreateHashedBufferFromReader(pkg)
+ require.NoError(t, err, "Error creating hashed buffer from nupkg")
+ defer pkgBuf.Close()
+
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ require.NoError(t, err, "Error getting user by ID 2")
+
+ t.Run("PackagesNugetNuspecCheck", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ pi := &packages_service.PackageInfo{
+ Owner: doer,
+ PackageType: packages_model.TypeNuGet,
+ Name: packageName,
+ Version: packageVersion,
+ }
+ _, _, err := packages_service.CreatePackageAndAddFile(
+ ctx,
+ &packages_service.PackageCreationInfo{
+ PackageInfo: *pi,
+ SemverCompatible: true,
+ Creator: doer,
+ Metadata: nil,
+ },
+ &packages_service.PackageFileCreationInfo{
+ PackageFileInfo: packages_service.PackageFileInfo{
+ Filename: strings.ToLower(fmt.Sprintf("%s.%s.nupkg", packageName, packageVersion)),
+ },
+ Creator: doer,
+ Data: pkgBuf,
+ IsLead: true,
+ },
+ )
+ require.NoError(t, err, "Error creating package and adding file")
+
+ require.NoError(t, doctor.PackagesNugetNuspecCheck(ctx, logger, true), "Doctor check failed")
+
+ s, _, pf, err := packages_service.GetFileStreamByPackageNameAndVersion(
+ ctx,
+ &packages_service.PackageInfo{
+ Owner: doer,
+ PackageType: packages_model.TypeNuGet,
+ Name: packageName,
+ Version: packageVersion,
+ },
+ &packages_service.PackageFileInfo{
+ Filename: strings.ToLower(fmt.Sprintf("%s.nuspec", packageName)),
+ },
+ )
+
+ require.NoError(t, err, "Error getting nuspec file stream by package name and version")
+ defer s.Close()
+
+ assert.Equal(t, fmt.Sprintf("%s.nuspec", packageName), pf.Name, "Not a nuspec")
+ })
+}
diff --git a/tests/integration/download_test.go b/tests/integration/download_test.go
new file mode 100644
index 0000000..efe5ac7
--- /dev/null
+++ b/tests/integration/download_test.go
@@ -0,0 +1,93 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDownloadByID(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ // Request raw blob
+ req := NewRequest(t, "GET", "/user2/repo1/raw/blob/4b4851ad51df6a7d9f25c979345979eaeb5b349f")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "# repo1\n\nDescription for repo1", resp.Body.String())
+}
+
+func TestDownloadByIDForSVGUsesSecureHeaders(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ // Request raw blob
+ req := NewRequest(t, "GET", "/user2/repo2/raw/blob/6395b68e1feebb1e4c657b4f9f6ba2676a283c0b")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "default-src 'none'; style-src 'unsafe-inline'; sandbox", resp.Header().Get("Content-Security-Policy"))
+ assert.Equal(t, "image/svg+xml", resp.Header().Get("Content-Type"))
+ assert.Equal(t, "nosniff", resp.Header().Get("X-Content-Type-Options"))
+}
+
+func TestDownloadByIDMedia(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ // Request raw blob
+ req := NewRequest(t, "GET", "/user2/repo1/media/blob/4b4851ad51df6a7d9f25c979345979eaeb5b349f")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "# repo1\n\nDescription for repo1", resp.Body.String())
+}
+
+func TestDownloadByIDMediaForSVGUsesSecureHeaders(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ // Request raw blob
+ req := NewRequest(t, "GET", "/user2/repo2/media/blob/6395b68e1feebb1e4c657b4f9f6ba2676a283c0b")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "default-src 'none'; style-src 'unsafe-inline'; sandbox", resp.Header().Get("Content-Security-Policy"))
+ assert.Equal(t, "image/svg+xml", resp.Header().Get("Content-Type"))
+ assert.Equal(t, "nosniff", resp.Header().Get("X-Content-Type-Options"))
+}
+
+func TestDownloadRawTextFileWithoutMimeTypeMapping(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ req := NewRequest(t, "GET", "/user2/repo2/raw/branch/master/test.xml")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "text/plain; charset=utf-8", resp.Header().Get("Content-Type"))
+}
+
+func TestDownloadRawTextFileWithMimeTypeMapping(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ setting.MimeTypeMap.Map[".xml"] = "text/xml"
+ setting.MimeTypeMap.Enabled = true
+
+ session := loginUser(t, "user2")
+
+ req := NewRequest(t, "GET", "/user2/repo2/raw/branch/master/test.xml")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ assert.Equal(t, "text/xml; charset=utf-8", resp.Header().Get("Content-Type"))
+
+ delete(setting.MimeTypeMap.Map, ".xml")
+ setting.MimeTypeMap.Enabled = false
+}
diff --git a/tests/integration/dump_restore_test.go b/tests/integration/dump_restore_test.go
new file mode 100644
index 0000000..fa65695
--- /dev/null
+++ b/tests/integration/dump_restore_test.go
@@ -0,0 +1,329 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ base "code.gitea.io/gitea/modules/migration"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/services/migrations"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/yaml.v3"
+)
+
+func TestDumpRestore(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ AllowLocalNetworks := setting.Migrations.AllowLocalNetworks
+ setting.Migrations.AllowLocalNetworks = true
+ AppVer := setting.AppVer
+ // Gitea SDK (go-sdk) need to parse the AppVer from server response, so we must set it to a valid version string.
+ setting.AppVer = "1.16.0"
+ defer func() {
+ setting.Migrations.AllowLocalNetworks = AllowLocalNetworks
+ setting.AppVer = AppVer
+ }()
+
+ require.NoError(t, migrations.Init())
+
+ reponame := "repo1"
+
+ basePath, err := os.MkdirTemp("", reponame)
+ require.NoError(t, err)
+ defer util.RemoveAll(basePath)
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: reponame})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ session := loginUser(t, repoOwner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeReadMisc)
+
+ //
+ // Phase 1: dump repo1 from the Gitea instance to the filesystem
+ //
+
+ ctx := context.Background()
+ opts := migrations.MigrateOptions{
+ GitServiceType: structs.GiteaService,
+ Issues: true,
+ PullRequests: true,
+ Labels: true,
+ Milestones: true,
+ Comments: true,
+ AuthToken: token,
+ CloneAddr: repo.CloneLink().HTTPS,
+ RepoName: reponame,
+ }
+ err = migrations.DumpRepository(ctx, basePath, repoOwner.Name, opts)
+ require.NoError(t, err)
+
+ //
+ // Verify desired side effects of the dump
+ //
+ d := filepath.Join(basePath, repo.OwnerName, repo.Name)
+ for _, f := range []string{"repo.yml", "topic.yml", "label.yml", "milestone.yml", "issue.yml"} {
+ assert.FileExists(t, filepath.Join(d, f))
+ }
+
+ //
+ // Phase 2: restore from the filesystem to the Gitea instance in restoredrepo
+ //
+
+ newreponame := "restored"
+ err = migrations.RestoreRepository(ctx, d, repo.OwnerName, newreponame, []string{
+ "labels", "issues", "comments", "milestones", "pull_requests",
+ }, false)
+ require.NoError(t, err)
+
+ newrepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: newreponame})
+
+ //
+ // Phase 3: dump restored from the Gitea instance to the filesystem
+ //
+ opts.RepoName = newreponame
+ opts.CloneAddr = newrepo.CloneLink().HTTPS
+ err = migrations.DumpRepository(ctx, basePath, repoOwner.Name, opts)
+ require.NoError(t, err)
+
+ //
+ // Verify the dump of restored is the same as the dump of repo1
+ //
+ comparator := &compareDump{
+ t: t,
+ basePath: basePath,
+ }
+ comparator.assertEquals(repo, newrepo)
+ })
+}
+
+type compareDump struct {
+ t *testing.T
+ basePath string
+ repoBefore *repo_model.Repository
+ dirBefore string
+ repoAfter *repo_model.Repository
+ dirAfter string
+}
+
+type compareField struct {
+ before any
+ after any
+ ignore bool
+ transform func(string) string
+ nested *compareFields
+}
+
+type compareFields map[string]compareField
+
+func (c *compareDump) replaceRepoName(original string) string {
+ return strings.ReplaceAll(original, c.repoBefore.Name, c.repoAfter.Name)
+}
+
+func (c *compareDump) assertEquals(repoBefore, repoAfter *repo_model.Repository) {
+ c.repoBefore = repoBefore
+ c.dirBefore = filepath.Join(c.basePath, repoBefore.OwnerName, repoBefore.Name)
+ c.repoAfter = repoAfter
+ c.dirAfter = filepath.Join(c.basePath, repoAfter.OwnerName, repoAfter.Name)
+
+ //
+ // base.Repository
+ //
+ _ = c.assertEqual("repo.yml", base.Repository{}, compareFields{
+ "Name": {
+ before: c.repoBefore.Name,
+ after: c.repoAfter.Name,
+ },
+ "CloneURL": {transform: c.replaceRepoName},
+ "OriginalURL": {transform: c.replaceRepoName},
+ })
+
+ //
+ // base.Label
+ //
+ labels, ok := c.assertEqual("label.yml", []base.Label{}, compareFields{}).([]*base.Label)
+ assert.True(c.t, ok)
+ assert.GreaterOrEqual(c.t, len(labels), 1)
+
+ //
+ // base.Milestone
+ //
+ milestones, ok := c.assertEqual("milestone.yml", []base.Milestone{}, compareFields{
+ "Updated": {ignore: true}, // the database updates that field independently
+ }).([]*base.Milestone)
+ assert.True(c.t, ok)
+ assert.GreaterOrEqual(c.t, len(milestones), 1)
+
+ //
+ // base.Issue and the associated comments
+ //
+ issues, ok := c.assertEqual("issue.yml", []base.Issue{}, compareFields{
+ "Assignees": {ignore: true}, // not implemented yet
+ }).([]*base.Issue)
+ assert.True(c.t, ok)
+ assert.GreaterOrEqual(c.t, len(issues), 1)
+ for _, issue := range issues {
+ filename := filepath.Join("comments", fmt.Sprintf("%d.yml", issue.Number))
+ comments, ok := c.assertEqual(filename, []base.Comment{}, compareFields{
+ "Index": {ignore: true},
+ }).([]*base.Comment)
+ assert.True(c.t, ok)
+ for _, comment := range comments {
+ assert.EqualValues(c.t, issue.Number, comment.IssueIndex)
+ }
+ }
+
+ //
+ // base.PullRequest and the associated comments
+ //
+ comparePullRequestBranch := &compareFields{
+ "RepoName": {
+ before: c.repoBefore.Name,
+ after: c.repoAfter.Name,
+ },
+ "CloneURL": {transform: c.replaceRepoName},
+ }
+ prs, ok := c.assertEqual("pull_request.yml", []base.PullRequest{}, compareFields{
+ "Assignees": {ignore: true}, // not implemented yet
+ "Head": {nested: comparePullRequestBranch},
+ "Base": {nested: comparePullRequestBranch},
+ "Labels": {ignore: true}, // because org labels are not handled properly
+ }).([]*base.PullRequest)
+ assert.True(c.t, ok)
+ assert.GreaterOrEqual(c.t, len(prs), 1)
+ for _, pr := range prs {
+ filename := filepath.Join("comments", fmt.Sprintf("%d.yml", pr.Number))
+ comments, ok := c.assertEqual(filename, []base.Comment{}, compareFields{}).([]*base.Comment)
+ assert.True(c.t, ok)
+ for _, comment := range comments {
+ assert.EqualValues(c.t, pr.Number, comment.IssueIndex)
+ }
+ }
+}
+
+func (c *compareDump) assertLoadYAMLFiles(beforeFilename, afterFilename string, before, after any) {
+ _, beforeErr := os.Stat(beforeFilename)
+ _, afterErr := os.Stat(afterFilename)
+ assert.EqualValues(c.t, errors.Is(beforeErr, os.ErrNotExist), errors.Is(afterErr, os.ErrNotExist))
+ if errors.Is(beforeErr, os.ErrNotExist) {
+ return
+ }
+
+ beforeBytes, err := os.ReadFile(beforeFilename)
+ require.NoError(c.t, err)
+ require.NoError(c.t, yaml.Unmarshal(beforeBytes, before))
+ afterBytes, err := os.ReadFile(afterFilename)
+ require.NoError(c.t, err)
+ require.NoError(c.t, yaml.Unmarshal(afterBytes, after))
+}
+
+func (c *compareDump) assertLoadFiles(beforeFilename, afterFilename string, t reflect.Type) (before, after reflect.Value) {
+ var beforePtr, afterPtr reflect.Value
+ if t.Kind() == reflect.Slice {
+ //
+ // Given []Something{} create afterPtr, beforePtr []*Something{}
+ //
+ sliceType := reflect.SliceOf(reflect.PointerTo(t.Elem()))
+ beforeSlice := reflect.MakeSlice(sliceType, 0, 10)
+ beforePtr = reflect.New(beforeSlice.Type())
+ beforePtr.Elem().Set(beforeSlice)
+ afterSlice := reflect.MakeSlice(sliceType, 0, 10)
+ afterPtr = reflect.New(afterSlice.Type())
+ afterPtr.Elem().Set(afterSlice)
+ } else {
+ //
+ // Given Something{} create afterPtr, beforePtr *Something{}
+ //
+ beforePtr = reflect.New(t)
+ afterPtr = reflect.New(t)
+ }
+ c.assertLoadYAMLFiles(beforeFilename, afterFilename, beforePtr.Interface(), afterPtr.Interface())
+ return beforePtr.Elem(), afterPtr.Elem()
+}
+
+func (c *compareDump) assertEqual(filename string, kind any, fields compareFields) (i any) {
+ beforeFilename := filepath.Join(c.dirBefore, filename)
+ afterFilename := filepath.Join(c.dirAfter, filename)
+
+ typeOf := reflect.TypeOf(kind)
+ before, after := c.assertLoadFiles(beforeFilename, afterFilename, typeOf)
+ if typeOf.Kind() == reflect.Slice {
+ i = c.assertEqualSlices(before, after, fields)
+ } else {
+ i = c.assertEqualValues(before, after, fields)
+ }
+ return i
+}
+
+func (c *compareDump) assertEqualSlices(before, after reflect.Value, fields compareFields) any {
+ assert.EqualValues(c.t, before.Len(), after.Len())
+ if before.Len() == after.Len() {
+ for i := 0; i < before.Len(); i++ {
+ _ = c.assertEqualValues(
+ reflect.Indirect(before.Index(i).Elem()),
+ reflect.Indirect(after.Index(i).Elem()),
+ fields)
+ }
+ }
+ return after.Interface()
+}
+
+func (c *compareDump) assertEqualValues(before, after reflect.Value, fields compareFields) any {
+ for _, field := range reflect.VisibleFields(before.Type()) {
+ bf := before.FieldByName(field.Name)
+ bi := bf.Interface()
+ af := after.FieldByName(field.Name)
+ ai := af.Interface()
+ if compare, ok := fields[field.Name]; ok {
+ if compare.ignore == true {
+ //
+ // Ignore
+ //
+ continue
+ }
+ if compare.transform != nil {
+ //
+ // Transform these strings before comparing them
+ //
+ bs, ok := bi.(string)
+ assert.True(c.t, ok)
+ as, ok := ai.(string)
+ assert.True(c.t, ok)
+ assert.EqualValues(c.t, compare.transform(bs), compare.transform(as))
+ continue
+ }
+ if compare.before != nil && compare.after != nil {
+ //
+ // The fields are expected to have different values
+ //
+ assert.EqualValues(c.t, compare.before, bi)
+ assert.EqualValues(c.t, compare.after, ai)
+ continue
+ }
+ if compare.nested != nil {
+ //
+ // The fields are a struct, recurse
+ //
+ c.assertEqualValues(bf, af, *compare.nested)
+ continue
+ }
+ }
+ assert.EqualValues(c.t, bi, ai)
+ }
+ return after.Interface()
+}
diff --git a/tests/integration/easymde_test.go b/tests/integration/easymde_test.go
new file mode 100644
index 0000000..c8203d3
--- /dev/null
+++ b/tests/integration/easymde_test.go
@@ -0,0 +1,25 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+)
+
+func TestEasyMDESwitch(t *testing.T) {
+ session := loginUser(t, "user2")
+ testEasyMDESwitch(t, session, "user2/glob/issues/1", false)
+ testEasyMDESwitch(t, session, "user2/glob/issues/new", false)
+ testEasyMDESwitch(t, session, "user2/glob/wiki?action=_new", true)
+ testEasyMDESwitch(t, session, "user2/glob/releases/new", true)
+}
+
+func testEasyMDESwitch(t *testing.T, session *TestSession, url string, expected bool) {
+ t.Helper()
+ req := NewRequest(t, "GET", url)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ doc.AssertElement(t, ".combo-markdown-editor button.markdown-switch-easymde", expected)
+}
diff --git a/tests/integration/editor_test.go b/tests/integration/editor_test.go
new file mode 100644
index 0000000..4ed6485
--- /dev/null
+++ b/tests/integration/editor_test.go
@@ -0,0 +1,519 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path"
+ "testing"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/translation"
+ gitea_context "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCreateFile(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user2")
+ testCreateFile(t, session, "user2", "repo1", "master", "test.txt", "Content")
+ })
+}
+
+func testCreateFile(t *testing.T, session *TestSession, user, repo, branch, filePath, content string) *httptest.ResponseRecorder {
+ // Request editor page
+ newURL := fmt.Sprintf("/%s/%s/_new/%s/", user, repo, branch)
+ req := NewRequest(t, "GET", newURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ lastCommit := doc.GetInputValueByName("last_commit")
+ assert.NotEmpty(t, lastCommit)
+
+ // Save new file to master branch
+ req = NewRequestWithValues(t, "POST", newURL, map[string]string{
+ "_csrf": doc.GetCSRF(),
+ "last_commit": lastCommit,
+ "tree_path": filePath,
+ "content": content,
+ "commit_choice": "direct",
+ "commit_mail_id": "3",
+ })
+ return session.MakeRequest(t, req, http.StatusSeeOther)
+}
+
+func TestCreateFileOnProtectedBranch(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user2")
+
+ csrf := GetCSRF(t, session, "/user2/repo1/settings/branches")
+ // Change master branch to protected
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{
+ "_csrf": csrf,
+ "rule_name": "master",
+ "enable_push": "true",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ // Check if master branch has been locked successfully
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.EqualValues(t, "success%3DBranch%2Bprotection%2Bfor%2Brule%2B%2522master%2522%2Bhas%2Bbeen%2Bupdated.", flashCookie.Value)
+
+ // Request editor page
+ req = NewRequest(t, "GET", "/user2/repo1/_new/master/")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ lastCommit := doc.GetInputValueByName("last_commit")
+ assert.NotEmpty(t, lastCommit)
+
+ // Save new file to master branch
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/_new/master/", map[string]string{
+ "_csrf": doc.GetCSRF(),
+ "last_commit": lastCommit,
+ "tree_path": "test.txt",
+ "content": "Content",
+ "commit_choice": "direct",
+ "commit_mail_id": "3",
+ })
+
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ // Check body for error message
+ assert.Contains(t, resp.Body.String(), "Cannot commit to protected branch &#34;master&#34;.")
+
+ // remove the protected branch
+ csrf = GetCSRF(t, session, "/user2/repo1/settings/branches")
+
+ // Change master branch to protected
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/1/delete", map[string]string{
+ "_csrf": csrf,
+ })
+
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ res := make(map[string]string)
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
+ assert.EqualValues(t, "/user2/repo1/settings/branches", res["redirect"])
+
+ // Check if master branch has been locked successfully
+ flashCookie = session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.EqualValues(t, "error%3DRemoving%2Bbranch%2Bprotection%2Brule%2B%25221%2522%2Bfailed.", flashCookie.Value)
+ })
+}
+
+func testEditFile(t *testing.T, session *TestSession, user, repo, branch, filePath, newContent string) *httptest.ResponseRecorder {
+ // Get to the 'edit this file' page
+ req := NewRequest(t, "GET", path.Join(user, repo, "_edit", branch, filePath))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ lastCommit := htmlDoc.GetInputValueByName("last_commit")
+ assert.NotEmpty(t, lastCommit)
+
+ // Submit the edits
+ req = NewRequestWithValues(t, "POST", path.Join(user, repo, "_edit", branch, filePath),
+ map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "last_commit": lastCommit,
+ "tree_path": filePath,
+ "content": newContent,
+ "commit_choice": "direct",
+ "commit_mail_id": "-1",
+ },
+ )
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // Verify the change
+ req = NewRequest(t, "GET", path.Join(user, repo, "raw/branch", branch, filePath))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.EqualValues(t, newContent, resp.Body.String())
+
+ return resp
+}
+
+func testEditFileToNewBranch(t *testing.T, session *TestSession, user, repo, branch, targetBranch, filePath, newContent string) *httptest.ResponseRecorder {
+ // Get to the 'edit this file' page
+ req := NewRequest(t, "GET", path.Join(user, repo, "_edit", branch, filePath))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ lastCommit := htmlDoc.GetInputValueByName("last_commit")
+ assert.NotEmpty(t, lastCommit)
+
+ // Submit the edits
+ req = NewRequestWithValues(t, "POST", path.Join(user, repo, "_edit", branch, filePath),
+ map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "last_commit": lastCommit,
+ "tree_path": filePath,
+ "content": newContent,
+ "commit_choice": "commit-to-new-branch",
+ "new_branch_name": targetBranch,
+ "commit_mail_id": "-1",
+ },
+ )
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // Verify the change
+ req = NewRequest(t, "GET", path.Join(user, repo, "raw/branch", targetBranch, filePath))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.EqualValues(t, newContent, resp.Body.String())
+
+ return resp
+}
+
+func TestEditFile(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user2")
+ testEditFile(t, session, "user2", "repo1", "master", "README.md", "Hello, World (Edited)\n")
+ })
+}
+
+func TestEditFileToNewBranch(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user2")
+ testEditFileToNewBranch(t, session, "user2", "repo1", "master", "feature/test", "README.md", "Hello, World (Edited)\n")
+ })
+}
+
+func TestEditorAddTranslation(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+ req := NewRequest(t, "GET", "/user2/repo1/_new/master")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ placeholder, ok := htmlDoc.Find("input[name='commit_summary']").Attr("placeholder")
+ assert.True(t, ok)
+ assert.EqualValues(t, `Add "<filename>"`, placeholder)
+}
+
+func TestCommitMail(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, _ *url.URL) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ // Require that the user has KeepEmailPrivate enabled, because it needs
+ // to be tested that even with this setting enabled, it will use the
+ // provided mail and not revert to the placeholder one.
+ assert.True(t, user.KeepEmailPrivate)
+
+ inactivatedMail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID})
+ assert.False(t, inactivatedMail.IsActivated)
+
+ otherEmail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 1, IsActivated: true})
+ assert.NotEqualValues(t, otherEmail.UID, user.ID)
+
+ primaryEmail := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 3, UID: user.ID, IsActivated: true})
+
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ gitRepo, _ := git.OpenRepository(git.DefaultContext, repo1.RepoPath())
+ defer gitRepo.Close()
+
+ session := loginUser(t, user.Name)
+
+ lastCommitAndCSRF := func(t *testing.T, link string, skipLastCommit bool) (string, string) {
+ t.Helper()
+
+ req := NewRequest(t, "GET", link)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ lastCommit := htmlDoc.GetInputValueByName("last_commit")
+ if !skipLastCommit {
+ assert.NotEmpty(t, lastCommit)
+ }
+
+ return lastCommit, htmlDoc.GetCSRF()
+ }
+
+ type caseOpts struct {
+ link string
+ fileName string
+ base map[string]string
+ skipLastCommit bool
+ }
+
+ // Base2 should have different content, so we can test two 'correct' operations
+ // without the second becoming a noop because no content was changed. If needed,
+ // link2 can point to a new file that's used with base2.
+ assertCase := func(t *testing.T, case1, case2 caseOpts) {
+ t.Helper()
+
+ t.Run("Not activated", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ lastCommit, csrf := lastCommitAndCSRF(t, case1.link, case1.skipLastCommit)
+ baseCopy := case1.base
+ baseCopy["_csrf"] = csrf
+ baseCopy["last_commit"] = lastCommit
+ baseCopy["commit_mail_id"] = fmt.Sprintf("%d", inactivatedMail.ID)
+
+ req := NewRequestWithValues(t, "POST", case1.link, baseCopy)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Contains(t,
+ htmlDoc.doc.Find(".ui.negative.message").Text(),
+ translation.NewLocale("en-US").Tr("repo.editor.invalid_commit_mail"),
+ )
+ })
+
+ t.Run("Not belong to user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ lastCommit, csrf := lastCommitAndCSRF(t, case1.link, case1.skipLastCommit)
+ baseCopy := case1.base
+ baseCopy["_csrf"] = csrf
+ baseCopy["last_commit"] = lastCommit
+ baseCopy["commit_mail_id"] = fmt.Sprintf("%d", otherEmail.ID)
+
+ req := NewRequestWithValues(t, "POST", case1.link, baseCopy)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Contains(t,
+ htmlDoc.doc.Find(".ui.negative.message").Text(),
+ translation.NewLocale("en-US").Tr("repo.editor.invalid_commit_mail"),
+ )
+ })
+
+ t.Run("Placeholder mail", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ lastCommit, csrf := lastCommitAndCSRF(t, case1.link, case1.skipLastCommit)
+ baseCopy := case1.base
+ baseCopy["_csrf"] = csrf
+ baseCopy["last_commit"] = lastCommit
+ baseCopy["commit_mail_id"] = "-1"
+
+ req := NewRequestWithValues(t, "POST", case1.link, baseCopy)
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ if !case2.skipLastCommit {
+ newlastCommit, _ := lastCommitAndCSRF(t, case1.link, false)
+ assert.NotEqualValues(t, newlastCommit, lastCommit)
+ }
+
+ commit, err := gitRepo.GetCommitByPath(case1.fileName)
+ require.NoError(t, err)
+
+ assert.EqualValues(t, "user2", commit.Author.Name)
+ assert.EqualValues(t, "user2@noreply.example.org", commit.Author.Email)
+ assert.EqualValues(t, "user2", commit.Committer.Name)
+ assert.EqualValues(t, "user2@noreply.example.org", commit.Committer.Email)
+ })
+
+ t.Run("Normal", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ lastCommit, csrf := lastCommitAndCSRF(t, case2.link, case2.skipLastCommit)
+ baseCopy := case2.base
+ baseCopy["_csrf"] = csrf
+ baseCopy["last_commit"] = lastCommit
+ baseCopy["commit_mail_id"] = fmt.Sprintf("%d", primaryEmail.ID)
+
+ req := NewRequestWithValues(t, "POST", case2.link, baseCopy)
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ if !case2.skipLastCommit {
+ newlastCommit, _ := lastCommitAndCSRF(t, case2.link, false)
+ assert.NotEqualValues(t, newlastCommit, lastCommit)
+ }
+
+ commit, err := gitRepo.GetCommitByPath(case2.fileName)
+ require.NoError(t, err)
+
+ assert.EqualValues(t, "user2", commit.Author.Name)
+ assert.EqualValues(t, primaryEmail.Email, commit.Author.Email)
+ assert.EqualValues(t, "user2", commit.Committer.Name)
+ assert.EqualValues(t, primaryEmail.Email, commit.Committer.Email)
+ })
+ }
+
+ t.Run("New", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ assertCase(t, caseOpts{
+ fileName: "new_file",
+ link: "user2/repo1/_new/master",
+ base: map[string]string{
+ "tree_path": "new_file",
+ "content": "new_content",
+ "commit_choice": "direct",
+ },
+ }, caseOpts{
+ fileName: "new_file_2",
+ link: "user2/repo1/_new/master",
+ base: map[string]string{
+ "tree_path": "new_file_2",
+ "content": "new_content",
+ "commit_choice": "direct",
+ },
+ },
+ )
+ })
+
+ t.Run("Edit", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ assertCase(t, caseOpts{
+ fileName: "README.md",
+ link: "user2/repo1/_edit/master/README.md",
+ base: map[string]string{
+ "tree_path": "README.md",
+ "content": "Edit content",
+ "commit_choice": "direct",
+ },
+ }, caseOpts{
+ fileName: "README.md",
+ link: "user2/repo1/_edit/master/README.md",
+ base: map[string]string{
+ "tree_path": "README.md",
+ "content": "Other content",
+ "commit_choice": "direct",
+ },
+ },
+ )
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ assertCase(t, caseOpts{
+ fileName: "new_file",
+ link: "user2/repo1/_delete/master/new_file",
+ base: map[string]string{
+ "commit_choice": "direct",
+ },
+ }, caseOpts{
+ fileName: "new_file_2",
+ link: "user2/repo1/_delete/master/new_file_2",
+ base: map[string]string{
+ "commit_choice": "direct",
+ },
+ },
+ )
+ })
+
+ t.Run("Upload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Upload two separate times, so we have two different 'uploads' that can
+ // be used independently of each other.
+ uploadFile := func(t *testing.T, name, content string) string {
+ t.Helper()
+
+ body := &bytes.Buffer{}
+ mpForm := multipart.NewWriter(body)
+ err := mpForm.WriteField("_csrf", GetCSRF(t, session, "/user2/repo1/_upload/master"))
+ require.NoError(t, err)
+
+ file, err := mpForm.CreateFormFile("file", name)
+ require.NoError(t, err)
+
+ io.Copy(file, bytes.NewBufferString(content))
+ require.NoError(t, mpForm.Close())
+
+ req := NewRequestWithBody(t, "POST", "/user2/repo1/upload-file", body)
+ req.Header.Add("Content-Type", mpForm.FormDataContentType())
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ respMap := map[string]string{}
+ DecodeJSON(t, resp, &respMap)
+ return respMap["uuid"]
+ }
+
+ file1UUID := uploadFile(t, "upload_file_1", "Uploaded a file!")
+ file2UUID := uploadFile(t, "upload_file_2", "Uploaded another file!")
+
+ assertCase(t, caseOpts{
+ fileName: "upload_file_1",
+ link: "user2/repo1/_upload/master",
+ skipLastCommit: true,
+ base: map[string]string{
+ "commit_choice": "direct",
+ "files": file1UUID,
+ },
+ }, caseOpts{
+ fileName: "upload_file_2",
+ link: "user2/repo1/_upload/master",
+ skipLastCommit: true,
+ base: map[string]string{
+ "commit_choice": "direct",
+ "files": file2UUID,
+ },
+ },
+ )
+ })
+
+ t.Run("Apply patch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ assertCase(t, caseOpts{
+ fileName: "diff-file-1.txt",
+ link: "user2/repo1/_diffpatch/master",
+ base: map[string]string{
+ "tree_path": "patch",
+ "commit_choice": "direct",
+ "content": `diff --git a/diff-file-1.txt b/diff-file-1.txt
+new file mode 100644
+index 0000000000..50fcd26d6c
+--- /dev/null
++++ b/diff-file-1.txt
+@@ -0,0 +1 @@
++File 1
+`,
+ },
+ }, caseOpts{
+ fileName: "diff-file-2.txt",
+ link: "user2/repo1/_diffpatch/master",
+ base: map[string]string{
+ "tree_path": "patch",
+ "commit_choice": "direct",
+ "content": `diff --git a/diff-file-2.txt b/diff-file-2.txt
+new file mode 100644
+index 0000000000..4475433e27
+--- /dev/null
++++ b/diff-file-2.txt
+@@ -0,0 +1 @@
++File 2
+`,
+ },
+ })
+ })
+
+ t.Run("Cherry pick", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ commitID1, err := gitRepo.GetCommitByPath("diff-file-1.txt")
+ require.NoError(t, err)
+ commitID2, err := gitRepo.GetCommitByPath("diff-file-2.txt")
+ require.NoError(t, err)
+
+ assertCase(t, caseOpts{
+ fileName: "diff-file-1.txt",
+ link: "user2/repo1/_cherrypick/" + commitID1.ID.String() + "/master",
+ base: map[string]string{
+ "commit_choice": "direct",
+ "revert": "true",
+ },
+ }, caseOpts{
+ fileName: "diff-file-2.txt",
+ link: "user2/repo1/_cherrypick/" + commitID2.ID.String() + "/master",
+ base: map[string]string{
+ "commit_choice": "direct",
+ "revert": "true",
+ },
+ })
+ })
+ })
+}
diff --git a/tests/integration/empty_repo_test.go b/tests/integration/empty_repo_test.go
new file mode 100644
index 0000000..4122c78
--- /dev/null
+++ b/tests/integration/empty_repo_test.go
@@ -0,0 +1,138 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "encoding/base64"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEmptyRepo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ subPaths := []string{
+ "commits/master",
+ "raw/foo",
+ "commit/1ae57b34ccf7e18373",
+ "graph",
+ }
+ emptyRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 6})
+ assert.True(t, emptyRepo.IsEmpty)
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: emptyRepo.OwnerID})
+ for _, subPath := range subPaths {
+ req := NewRequestf(t, "GET", "/%s/%s/%s", owner.Name, emptyRepo.Name, subPath)
+ MakeRequest(t, req, http.StatusNotFound)
+ }
+}
+
+func TestEmptyRepoAddFile(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user30")
+ req := NewRequest(t, "GET", "/user30/empty/_new/"+setting.Repository.DefaultBranch)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body).Find(`input[name="commit_choice"]`)
+ assert.Empty(t, doc.AttrOr("checked", "_no_"))
+ req = NewRequestWithValues(t, "POST", "/user30/empty/_new/"+setting.Repository.DefaultBranch, map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings"),
+ "commit_choice": "direct",
+ "tree_path": "test-file.md",
+ "content": "newly-added-test-file",
+ "commit_mail_id": "32",
+ })
+
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ redirect := test.RedirectURL(resp)
+ assert.Equal(t, "/user30/empty/src/branch/"+setting.Repository.DefaultBranch+"/test-file.md", redirect)
+
+ req = NewRequest(t, "GET", redirect)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.Contains(t, resp.Body.String(), "newly-added-test-file")
+}
+
+func TestEmptyRepoUploadFile(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user30")
+ req := NewRequest(t, "GET", "/user30/empty/_new/"+setting.Repository.DefaultBranch)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body).Find(`input[name="commit_choice"]`)
+ assert.Empty(t, doc.AttrOr("checked", "_no_"))
+
+ body := &bytes.Buffer{}
+ mpForm := multipart.NewWriter(body)
+ _ = mpForm.WriteField("_csrf", GetCSRF(t, session, "/user/settings"))
+ file, _ := mpForm.CreateFormFile("file", "uploaded-file.txt")
+ _, _ = io.Copy(file, bytes.NewBufferString("newly-uploaded-test-file"))
+ _ = mpForm.Close()
+
+ req = NewRequestWithBody(t, "POST", "/user30/empty/upload-file", body)
+ req.Header.Add("Content-Type", mpForm.FormDataContentType())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ respMap := map[string]string{}
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &respMap))
+
+ req = NewRequestWithValues(t, "POST", "/user30/empty/_upload/"+setting.Repository.DefaultBranch, map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings"),
+ "commit_choice": "direct",
+ "files": respMap["uuid"],
+ "tree_path": "",
+ "commit_mail_id": "-1",
+ })
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ redirect := test.RedirectURL(resp)
+ assert.Equal(t, "/user30/empty/src/branch/"+setting.Repository.DefaultBranch+"/", redirect)
+
+ req = NewRequest(t, "GET", redirect)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.Contains(t, resp.Body.String(), "uploaded-file.txt")
+}
+
+func TestEmptyRepoAddFileByAPI(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user30")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/repos/user30/empty/contents/new-file.txt", &api.CreateFileOptions{
+ FileOptions: api.FileOptions{
+ NewBranchName: "new_branch",
+ Message: "init",
+ },
+ ContentBase64: base64.StdEncoding.EncodeToString([]byte("newly-added-api-file")),
+ }).AddTokenAuth(token)
+
+ resp := MakeRequest(t, req, http.StatusCreated)
+ var fileResponse api.FileResponse
+ DecodeJSON(t, resp, &fileResponse)
+ expectedHTMLURL := setting.AppURL + "user30/empty/src/branch/new_branch/new-file.txt"
+ assert.EqualValues(t, expectedHTMLURL, *fileResponse.Content.HTMLURL)
+
+ req = NewRequest(t, "GET", "/user30/empty/src/branch/new_branch/new-file.txt")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.Contains(t, resp.Body.String(), "newly-added-api-file")
+
+ req = NewRequest(t, "GET", "/api/v1/repos/user30/empty").
+ AddTokenAuth(token)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ var apiRepo api.Repository
+ DecodeJSON(t, resp, &apiRepo)
+ assert.Equal(t, "new_branch", apiRepo.DefaultBranch)
+}
diff --git a/tests/integration/eventsource_test.go b/tests/integration/eventsource_test.go
new file mode 100644
index 0000000..e081df0
--- /dev/null
+++ b/tests/integration/eventsource_test.go
@@ -0,0 +1,88 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/eventsource"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEventSourceManagerRun(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ manager := eventsource.GetManager()
+
+ eventChan := manager.Register(2)
+ defer func() {
+ manager.Unregister(2, eventChan)
+ // ensure the eventChan is closed
+ for {
+ _, ok := <-eventChan
+ if !ok {
+ break
+ }
+ }
+ }()
+ expectNotificationCountEvent := func(count int64) func() bool {
+ return func() bool {
+ select {
+ case event, ok := <-eventChan:
+ if !ok {
+ return false
+ }
+ data, ok := event.Data.(activities_model.UserIDCount)
+ if !ok {
+ return false
+ }
+ return event.Name == "notification-count" && data.Count == count
+ default:
+ return false
+ }
+ }
+ }
+
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ thread5 := unittest.AssertExistsAndLoadBean(t, &activities_model.Notification{ID: 5})
+ require.NoError(t, thread5.LoadAttributes(db.DefaultContext))
+ session := loginUser(t, user2.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteNotification, auth_model.AccessTokenScopeWriteRepository)
+
+ var apiNL []api.NotificationThread
+
+ // -- mark notifications as read --
+ req := NewRequest(t, "GET", "/api/v1/notifications?status-types=unread").
+ AddTokenAuth(token)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &apiNL)
+ assert.Len(t, apiNL, 2)
+
+ lastReadAt := "2000-01-01T00%3A50%3A01%2B00%3A00" // 946687801 <- only Notification 4 is in this filter ...
+ req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/repos/%s/%s/notifications?last_read_at=%s", user2.Name, repo1.Name, lastReadAt)).
+ AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusResetContent)
+
+ req = NewRequest(t, "GET", "/api/v1/notifications?status-types=unread").
+ AddTokenAuth(token)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiNL)
+ assert.Len(t, apiNL, 1)
+
+ assert.Eventually(t, expectNotificationCountEvent(1), 30*time.Second, 1*time.Second)
+}
diff --git a/tests/integration/explore_code_test.go b/tests/integration/explore_code_test.go
new file mode 100644
index 0000000..d84b47c
--- /dev/null
+++ b/tests/integration/explore_code_test.go
@@ -0,0 +1,31 @@
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestExploreCodeSearchIndexer(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Indexer.RepoIndexerEnabled, true)()
+
+ req := NewRequest(t, "GET", "/explore/code?q=file&fuzzy=true")
+ resp := MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body).Find(".explore")
+
+ msg := doc.
+ Find(".ui.container").
+ Find(".ui.message[data-test-tag=grep]")
+ assert.EqualValues(t, 0, msg.Length())
+
+ doc.Find(".file-body").Each(func(i int, sel *goquery.Selection) {
+ assert.Positive(t, sel.Find(".code-inner").Find(".search-highlight").Length(), 0)
+ })
+}
diff --git a/tests/integration/explore_repos_test.go b/tests/integration/explore_repos_test.go
new file mode 100644
index 0000000..c0179c5
--- /dev/null
+++ b/tests/integration/explore_repos_test.go
@@ -0,0 +1,31 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestExploreRepos(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/explore/repos")
+ MakeRequest(t, req, http.StatusOK)
+
+ t.Run("Persistent parameters", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/explore/repos?topic=1&language=Go")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body).Find("#repo-search-form")
+
+ assert.EqualValues(t, "Go", htmlDoc.Find("input[name='language']").AttrOr("value", "not found"))
+ assert.EqualValues(t, "true", htmlDoc.Find("input[name='topic']").AttrOr("value", "not found"))
+ })
+}
diff --git a/tests/integration/explore_user_test.go b/tests/integration/explore_user_test.go
new file mode 100644
index 0000000..441d89c
--- /dev/null
+++ b/tests/integration/explore_user_test.go
@@ -0,0 +1,44 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestExploreUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ cases := []struct{ sortOrder, expected string }{
+ {"", "?sort=newest&q="},
+ {"newest", "?sort=newest&q="},
+ {"oldest", "?sort=oldest&q="},
+ {"alphabetically", "?sort=alphabetically&q="},
+ {"reversealphabetically", "?sort=reversealphabetically&q="},
+ }
+ for _, c := range cases {
+ req := NewRequest(t, "GET", "/explore/users?sort="+c.sortOrder)
+ resp := MakeRequest(t, req, http.StatusOK)
+ h := NewHTMLParser(t, resp.Body)
+ href, _ := h.Find(`.ui.dropdown .menu a.active.item[href^="?sort="]`).Attr("href")
+ assert.Equal(t, c.expected, href)
+ }
+
+ // these sort orders shouldn't be supported, to avoid leaking user activity
+ cases404 := []string{
+ "/explore/users?sort=lastlogin",
+ "/explore/users?sort=reverselastlogin",
+ "/explore/users?sort=leastupdate",
+ "/explore/users?sort=reverseleastupdate",
+ }
+ for _, c := range cases404 {
+ req := NewRequest(t, "GET", c).SetHeader("Accept", "text/html")
+ MakeRequest(t, req, http.StatusNotFound)
+ }
+}
diff --git a/tests/integration/fixtures/TestAdminDeleteUser/issue.yml b/tests/integration/fixtures/TestAdminDeleteUser/issue.yml
new file mode 100644
index 0000000..02ea88e
--- /dev/null
+++ b/tests/integration/fixtures/TestAdminDeleteUser/issue.yml
@@ -0,0 +1,16 @@
+-
+ id: 1000
+ repo_id: 1000
+ index: 2
+ poster_id: 1000
+ original_author_id: 0
+ name: NAME
+ content: content
+ milestone_id: 0
+ priority: 0
+ is_closed: false
+ is_pull: false
+ num_comments: 0
+ created_unix: 946684830
+ updated_unix: 978307200
+ is_locked: false
diff --git a/tests/integration/fixtures/TestAdminDeleteUser/issue_index.yml b/tests/integration/fixtures/TestAdminDeleteUser/issue_index.yml
new file mode 100644
index 0000000..88aae4d
--- /dev/null
+++ b/tests/integration/fixtures/TestAdminDeleteUser/issue_index.yml
@@ -0,0 +1,3 @@
+-
+ group_id: 1000
+ max_index: 2
diff --git a/tests/integration/fixtures/TestAdminDeleteUser/repository.yml b/tests/integration/fixtures/TestAdminDeleteUser/repository.yml
new file mode 100644
index 0000000..2c12c7e
--- /dev/null
+++ b/tests/integration/fixtures/TestAdminDeleteUser/repository.yml
@@ -0,0 +1,30 @@
+-
+ id: 1000
+ owner_id: 1001
+ owner_name: user1001
+ lower_name: repo1000
+ name: repo1000
+ default_branch: master
+ num_watches: 0
+ num_stars: 0
+ num_forks: 0
+ num_issues: 1
+ num_closed_issues: 0
+ num_pulls: 0
+ num_closed_pulls: 0
+ num_milestones: 0
+ num_closed_milestones: 0
+ num_projects: 0
+ num_closed_projects: 0
+ is_private: false
+ is_empty: false
+ is_archived: false
+ is_mirror: false
+ status: 0
+ is_fork: false
+ fork_id: 0
+ is_template: false
+ template_id: 0
+ size: 0
+ is_fsck_enabled: true
+ close_issues_via_commit_in_any_branch: false
diff --git a/tests/integration/fixtures/TestAdminDeleteUser/user.yml b/tests/integration/fixtures/TestAdminDeleteUser/user.yml
new file mode 100644
index 0000000..9b44a85
--- /dev/null
+++ b/tests/integration/fixtures/TestAdminDeleteUser/user.yml
@@ -0,0 +1,73 @@
+-
+ id: 1000
+ lower_name: user1000
+ name: user1000
+ full_name: User Thousand
+ email: user1000@example.com
+ keep_email_private: false
+ email_notifications_preference: enabled
+ passwd: ZogKvWdyEx:password
+ passwd_hash_algo: dummy
+ must_change_password: false
+ login_source: 0
+ login_name: user1000
+ type: 0
+ salt: ZogKvWdyEx
+ max_repo_creation: -1
+ is_active: true
+ is_admin: false
+ is_restricted: false
+ allow_git_hook: false
+ allow_import_local: false
+ allow_create_organization: true
+ prohibit_login: false
+ avatar: avatar1000
+ avatar_email: user1000@example.com
+ use_custom_avatar: false
+ num_followers: 1
+ num_following: 1
+ num_stars: 0
+ num_repos: 0
+ num_teams: 0
+ num_members: 0
+ visibility: 0
+ repo_admin_change_team_access: false
+ theme: ""
+ keep_activity_private: false
+
+-
+ id: 1001
+ lower_name: user1001
+ name: user1001
+ full_name: User 1001
+ email: user1001@example.com
+ keep_email_private: false
+ email_notifications_preference: enabled
+ passwd: ZogKvWdyEx:password
+ passwd_hash_algo: dummy
+ must_change_password: false
+ login_source: 0
+ login_name: user1001
+ type: 0
+ salt: ZogKvWdyEx
+ max_repo_creation: -1
+ is_active: true
+ is_admin: false
+ is_restricted: false
+ allow_git_hook: false
+ allow_import_local: false
+ allow_create_organization: true
+ prohibit_login: false
+ avatar: avatar1001
+ avatar_email: user1001@example.com
+ use_custom_avatar: false
+ num_followers: 0
+ num_following: 0
+ num_stars: 0
+ num_repos: 1
+ num_teams: 0
+ num_members: 0
+ visibility: 0
+ repo_admin_change_team_access: false
+ theme: ""
+ keep_activity_private: false
diff --git a/tests/integration/fixtures/TestBlockActions/comment.yml b/tests/integration/fixtures/TestBlockActions/comment.yml
new file mode 100644
index 0000000..bf5bc34
--- /dev/null
+++ b/tests/integration/fixtures/TestBlockActions/comment.yml
@@ -0,0 +1,9 @@
+
+-
+ id: 1008
+ type: 0 # comment
+ poster_id: 2
+ issue_id: 4 # in repo_id 2
+ content: "comment in private pository"
+ created_unix: 946684811
+ updated_unix: 946684811
diff --git a/tests/integration/fixtures/TestBlockActions/issue.yml b/tests/integration/fixtures/TestBlockActions/issue.yml
new file mode 100644
index 0000000..f08ef54
--- /dev/null
+++ b/tests/integration/fixtures/TestBlockActions/issue.yml
@@ -0,0 +1,17 @@
+
+-
+ id: 1004
+ repo_id: 2
+ index: 1000
+ poster_id: 2
+ original_author_id: 0
+ name: issue1004
+ content: content for the 1000 fourth issue
+ milestone_id: 0
+ priority: 0
+ is_closed: true
+ is_pull: false
+ num_comments: 1
+ created_unix: 946684830
+ updated_unix: 978307200
+ is_locked: false
diff --git a/tests/integration/fixtures/TestBlockedNotifications/issue.yml b/tests/integration/fixtures/TestBlockedNotifications/issue.yml
new file mode 100644
index 0000000..9524e60
--- /dev/null
+++ b/tests/integration/fixtures/TestBlockedNotifications/issue.yml
@@ -0,0 +1,16 @@
+-
+ id: 1000
+ repo_id: 4
+ index: 1000
+ poster_id: 10
+ original_author_id: 0
+ name: issue for moderation
+ content: Hello there!
+ milestone_id: 0
+ priority: 0
+ is_closed: false
+ is_pull: false
+ num_comments: 0
+ created_unix: 1705939088
+ updated_unix: 1705939088
+ is_locked: false
diff --git a/tests/integration/fixtures/TestCommitRefComment/comment.yml b/tests/integration/fixtures/TestCommitRefComment/comment.yml
new file mode 100644
index 0000000..e2cfa0f
--- /dev/null
+++ b/tests/integration/fixtures/TestCommitRefComment/comment.yml
@@ -0,0 +1,17 @@
+-
+ id: 1000
+ type: 4 # commit ref
+ poster_id: 2
+ issue_id: 2 # in repo_id 2
+ content: 4a357436d925b5c974181ff12a994538ddc5a269
+ created_unix: 1706469348
+ updated_unix: 1706469348
+
+-
+ id: 1001
+ type: 4 # commit ref
+ poster_id: 2
+ issue_id: 1 # in repo_id 2
+ content: 4a357436d925b5c974181ff12a994538ddc5a269
+ created_unix: 1706469348
+ updated_unix: 1706469348
diff --git a/tests/integration/fixtures/TestGetContentHistory/issue_content_history.yml b/tests/integration/fixtures/TestGetContentHistory/issue_content_history.yml
new file mode 100644
index 0000000..6633b50
--- /dev/null
+++ b/tests/integration/fixtures/TestGetContentHistory/issue_content_history.yml
@@ -0,0 +1,17 @@
+-
+ id: 1
+ issue_id: 1
+ comment_id: 3
+ edited_unix: 1687612839
+ content_text: Original Text
+ is_first_created: true
+ is_deleted: false
+
+-
+ id: 2
+ issue_id: 1
+ comment_id: 3
+ edited_unix: 1687612840
+ content_text: "meh..." # This has to be consistent with comment.yml
+ is_first_created: false
+ is_deleted: false
diff --git a/tests/integration/fixtures/TestXSSReviewDismissed/comment.yml b/tests/integration/fixtures/TestXSSReviewDismissed/comment.yml
new file mode 100644
index 0000000..50162a4
--- /dev/null
+++ b/tests/integration/fixtures/TestXSSReviewDismissed/comment.yml
@@ -0,0 +1,9 @@
+-
+ id: 1000
+ type: 32 # dismiss review
+ poster_id: 2
+ issue_id: 2 # in repo_id 1
+ content: "XSS time!"
+ review_id: 1000
+ created_unix: 1700000000
+ updated_unix: 1700000000
diff --git a/tests/integration/fixtures/TestXSSReviewDismissed/review.yml b/tests/integration/fixtures/TestXSSReviewDismissed/review.yml
new file mode 100644
index 0000000..56bc08d
--- /dev/null
+++ b/tests/integration/fixtures/TestXSSReviewDismissed/review.yml
@@ -0,0 +1,8 @@
+-
+ id: 1000
+ type: 1
+ issue_id: 2
+ original_author: "Otto <script class='evil'>alert('Oh no!')</script>"
+ content: "XSS time!"
+ updated_unix: 1700000000
+ created_unix: 1700000000
diff --git a/tests/integration/forgejo_confirmation_repo_test.go b/tests/integration/forgejo_confirmation_repo_test.go
new file mode 100644
index 0000000..598baa4
--- /dev/null
+++ b/tests/integration/forgejo_confirmation_repo_test.go
@@ -0,0 +1,184 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "code.gitea.io/gitea/modules/translation"
+ gitea_context "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDangerZoneConfirmation(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ mustInvalidRepoName := func(resp *httptest.ResponseRecorder) {
+ t.Helper()
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Contains(t,
+ htmlDoc.doc.Find(".ui.negative.message").Text(),
+ translation.NewLocale("en-US").Tr("form.enterred_invalid_repo_name"),
+ )
+ }
+
+ t.Run("Transfer ownership", func(t *testing.T) {
+ session := loginUser(t, "user2")
+
+ t.Run("Fail", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings"),
+ "action": "transfer",
+ "repo_name": "repo1",
+ "new_owner_name": "user1",
+ })
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ mustInvalidRepoName(resp)
+ })
+ t.Run("Pass", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings"),
+ "action": "transfer",
+ "repo_name": "user2/repo1",
+ "new_owner_name": "user1",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.EqualValues(t, "success%3DThis%2Brepository%2Bhas%2Bbeen%2Bmarked%2Bfor%2Btransfer%2Band%2Bawaits%2Bconfirmation%2Bfrom%2B%2522User%2BOne%2522", flashCookie.Value)
+ })
+ })
+
+ t.Run("Convert fork", func(t *testing.T) {
+ session := loginUser(t, "user20")
+
+ t.Run("Fail", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user20/big_test_public_fork_7/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user20/big_test_public_fork_7/settings"),
+ "action": "convert_fork",
+ "repo_name": "big_test_public_fork_7",
+ })
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ mustInvalidRepoName(resp)
+ })
+ t.Run("Pass", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user20/big_test_public_fork_7/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user20/big_test_public_fork_7/settings"),
+ "action": "convert_fork",
+ "repo_name": "user20/big_test_public_fork_7",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.EqualValues(t, "success%3DThe%2Bfork%2Bhas%2Bbeen%2Bconverted%2Binto%2Ba%2Bregular%2Brepository.", flashCookie.Value)
+ })
+ })
+
+ t.Run("Rename wiki branch", func(t *testing.T) {
+ session := loginUser(t, "user2")
+
+ // NOTE: No need to rename the wiki branch here to make the form appear.
+ // We can submit it anyway, even if it doesn't appear on the web.
+
+ t.Run("Fail", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings"),
+ "action": "rename-wiki-branch",
+ "repo_name": "repo1",
+ })
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ mustInvalidRepoName(resp)
+ })
+ t.Run("Pass", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings"),
+ "action": "rename-wiki-branch",
+ "repo_name": "user2/repo1",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.EqualValues(t, "success%3DThe%2Brepository%2Bwiki%2527s%2Bbranch%2Bname%2Bhas%2Bbeen%2Bsuccessfully%2Bnormalized.", flashCookie.Value)
+ })
+ })
+
+ t.Run("Delete wiki", func(t *testing.T) {
+ session := loginUser(t, "user2")
+
+ t.Run("Fail", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings"),
+ "action": "delete-wiki",
+ "repo_name": "repo1",
+ })
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ mustInvalidRepoName(resp)
+ })
+ t.Run("Pass", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings"),
+ "action": "delete-wiki",
+ "repo_name": "user2/repo1",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.EqualValues(t, "success%3DThe%2Brepository%2Bwiki%2Bdata%2Bhas%2Bbeen%2Bdeleted.", flashCookie.Value)
+ })
+ })
+
+ t.Run("Delete", func(t *testing.T) {
+ session := loginUser(t, "user2")
+
+ t.Run("Fail", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings"),
+ "action": "delete",
+ "repo_name": "repo1",
+ })
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ mustInvalidRepoName(resp)
+ })
+ t.Run("Pass", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings"),
+ "action": "delete",
+ "repo_name": "user2/repo1",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.EqualValues(t, "success%3DThe%2Brepository%2Bhas%2Bbeen%2Bdeleted.", flashCookie.Value)
+ })
+ })
+}
diff --git a/tests/integration/forgejo_git_test.go b/tests/integration/forgejo_git_test.go
new file mode 100644
index 0000000..ebad074
--- /dev/null
+++ b/tests/integration/forgejo_git_test.go
@@ -0,0 +1,137 @@
+// Copyright Earl Warren <contact@earl-warren.org>
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "testing"
+ "time"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestActionsUserGit(t *testing.T) {
+ onGiteaRun(t, testActionsUserGit)
+}
+
+func NewActionsUserTestContext(t *testing.T, username, reponame string) APITestContext {
+ t.Helper()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: reponame})
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username})
+
+ task := unittest.AssertExistsAndLoadBean(t, &actions_model.ActionTask{ID: 47})
+ task.RepoID = repo.ID
+ task.OwnerID = repoOwner.ID
+ task.GenerateToken()
+
+ actions_model.UpdateTask(db.DefaultContext, task)
+ return APITestContext{
+ Session: emptyTestSession(t),
+ Token: task.Token,
+ Username: username,
+ Reponame: reponame,
+ }
+}
+
+func testActionsUserGit(t *testing.T, u *url.URL) {
+ username := "user2"
+ reponame := "repo1"
+ httpContext := NewAPITestContext(t, username, reponame, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+ for _, testCase := range []struct {
+ name string
+ head string
+ ctx APITestContext
+ }{
+ {
+ name: "UserTypeIndividual",
+ head: "individualhead",
+ ctx: httpContext,
+ },
+ {
+ name: "ActionsUser",
+ head: "actionsuserhead",
+ ctx: NewActionsUserTestContext(t, username, reponame),
+ },
+ } {
+ t.Run("CreatePR "+testCase.name, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ dstPath := t.TempDir()
+ u.Path = httpContext.GitPath()
+ u.User = url.UserPassword(httpContext.Username, userPassword)
+ t.Run("Clone", doGitClone(dstPath, u))
+ t.Run("PopulateBranch", doActionsUserPopulateBranch(dstPath, &httpContext, "master", testCase.head))
+ t.Run("CreatePR", doActionsUserPR(httpContext, testCase.ctx, "master", testCase.head))
+ })
+ }
+}
+
+func doActionsUserPopulateBranch(dstPath string, ctx *APITestContext, baseBranch, headBranch string) func(t *testing.T) {
+ return func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ t.Run("CreateHeadBranch", doGitCreateBranch(dstPath, headBranch))
+
+ t.Run("AddCommit", func(t *testing.T) {
+ err := os.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content"), 0o666)
+ require.NoError(t, err)
+
+ err = git.AddChanges(dstPath, true)
+ require.NoError(t, err)
+
+ err = git.CommitChanges(dstPath, git.CommitChangesOptions{
+ Committer: &git.Signature{
+ Email: "user2@example.com",
+ Name: "user2",
+ When: time.Now(),
+ },
+ Author: &git.Signature{
+ Email: "user2@example.com",
+ Name: "user2",
+ When: time.Now(),
+ },
+ Message: "Testing commit 1",
+ })
+ require.NoError(t, err)
+ })
+
+ t.Run("Push", func(t *testing.T) {
+ err := git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments("HEAD:refs/heads/" + headBranch).Run(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+ })
+ }
+}
+
+func doActionsUserPR(ctx, doerCtx APITestContext, baseBranch, headBranch string) func(t *testing.T) {
+ return func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ var pr api.PullRequest
+ var err error
+
+ // Create a test pullrequest
+ t.Run("CreatePullRequest", func(t *testing.T) {
+ pr, err = doAPICreatePullRequest(doerCtx, ctx.Username, ctx.Reponame, baseBranch, headBranch)(t)
+ require.NoError(t, err)
+ })
+ doerCtx.ExpectedCode = http.StatusCreated
+ t.Run("AutoMergePR", doAPIAutoMergePullRequest(doerCtx, ctx.Username, ctx.Reponame, pr.Index))
+ // Ensure the PR page works
+ t.Run("EnsureCanSeePull", doEnsureCanSeePull(ctx, pr, true))
+ }
+}
diff --git a/tests/integration/git_clone_wiki_test.go b/tests/integration/git_clone_wiki_test.go
new file mode 100644
index 0000000..ec99374
--- /dev/null
+++ b/tests/integration/git_clone_wiki_test.go
@@ -0,0 +1,52 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func assertFileExist(t *testing.T, p string) {
+ exist, err := util.IsExist(p)
+ require.NoError(t, err)
+ assert.True(t, exist)
+}
+
+func assertFileEqual(t *testing.T, p string, content []byte) {
+ bs, err := os.ReadFile(p)
+ require.NoError(t, err)
+ assert.EqualValues(t, content, bs)
+}
+
+func TestRepoCloneWiki(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ defer tests.PrepareTestEnv(t)()
+
+ dstPath := t.TempDir()
+
+ r := fmt.Sprintf("%suser2/repo1.wiki.git", u.String())
+ u, _ = url.Parse(r)
+ u.User = url.UserPassword("user2", userPassword)
+ t.Run("Clone", func(t *testing.T) {
+ require.NoError(t, git.CloneWithArgs(context.Background(), git.AllowLFSFiltersArgs(), u.String(), dstPath, git.CloneRepoOptions{}))
+ assertFileEqual(t, filepath.Join(dstPath, "Home.md"), []byte("# Home page\n\nThis is the home page!\n"))
+ assertFileExist(t, filepath.Join(dstPath, "Page-With-Image.md"))
+ assertFileExist(t, filepath.Join(dstPath, "Page-With-Spaced-Name.md"))
+ assertFileExist(t, filepath.Join(dstPath, "images"))
+ assertFileExist(t, filepath.Join(dstPath, "jpeg.jpg"))
+ })
+ })
+}
diff --git a/tests/integration/git_helper_for_declarative_test.go b/tests/integration/git_helper_for_declarative_test.go
new file mode 100644
index 0000000..490d4ca
--- /dev/null
+++ b/tests/integration/git_helper_for_declarative_test.go
@@ -0,0 +1,211 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/ssh"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func withKeyFile(t *testing.T, keyname string, callback func(string)) {
+ tmpDir := t.TempDir()
+
+ err := os.Chmod(tmpDir, 0o700)
+ require.NoError(t, err)
+
+ keyFile := filepath.Join(tmpDir, keyname)
+ err = ssh.GenKeyPair(keyFile)
+ require.NoError(t, err)
+
+ err = os.WriteFile(path.Join(tmpDir, "ssh"), []byte("#!/bin/bash\n"+
+ "ssh -o \"UserKnownHostsFile=/dev/null\" -o \"StrictHostKeyChecking=no\" -o \"IdentitiesOnly=yes\" -i \""+keyFile+"\" \"$@\""), 0o700)
+ require.NoError(t, err)
+
+ // Setup ssh wrapper
+ t.Setenv("GIT_SSH", path.Join(tmpDir, "ssh"))
+ t.Setenv("GIT_SSH_COMMAND",
+ "ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -i \""+keyFile+"\"")
+ t.Setenv("GIT_SSH_VARIANT", "ssh")
+
+ callback(keyFile)
+}
+
+func createSSHUrl(gitPath string, u *url.URL) *url.URL {
+ u2 := *u
+ u2.Scheme = "ssh"
+ u2.User = url.User("git")
+ u2.Host = net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort))
+ u2.Path = gitPath
+ return &u2
+}
+
+func onGiteaRun[T testing.TB](t T, callback func(T, *url.URL)) {
+ defer tests.PrepareTestEnv(t, 1)()
+ s := http.Server{
+ Handler: testWebRoutes,
+ }
+
+ u, err := url.Parse(setting.AppURL)
+ require.NoError(t, err)
+ listener, err := net.Listen("tcp", u.Host)
+ i := 0
+ for err != nil && i <= 10 {
+ time.Sleep(100 * time.Millisecond)
+ listener, err = net.Listen("tcp", u.Host)
+ i++
+ }
+ require.NoError(t, err)
+ u.Host = listener.Addr().String()
+
+ defer func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
+ s.Shutdown(ctx)
+ cancel()
+ }()
+
+ go s.Serve(listener)
+ // Started by config go ssh.Listen(setting.SSH.ListenHost, setting.SSH.ListenPort, setting.SSH.ServerCiphers, setting.SSH.ServerKeyExchanges, setting.SSH.ServerMACs)
+
+ callback(t, u)
+}
+
+func doGitClone(dstLocalPath string, u *url.URL) func(*testing.T) {
+ return func(t *testing.T) {
+ t.Helper()
+ require.NoError(t, git.CloneWithArgs(context.Background(), git.AllowLFSFiltersArgs(), u.String(), dstLocalPath, git.CloneRepoOptions{}))
+ exist, err := util.IsExist(filepath.Join(dstLocalPath, "README.md"))
+ require.NoError(t, err)
+ assert.True(t, exist)
+ }
+}
+
+func doPartialGitClone(dstLocalPath string, u *url.URL) func(*testing.T) {
+ return func(t *testing.T) {
+ t.Helper()
+ require.NoError(t, git.CloneWithArgs(context.Background(), git.AllowLFSFiltersArgs(), u.String(), dstLocalPath, git.CloneRepoOptions{
+ Filter: "blob:none",
+ }))
+ exist, err := util.IsExist(filepath.Join(dstLocalPath, "README.md"))
+ require.NoError(t, err)
+ assert.True(t, exist)
+ }
+}
+
+func doGitCloneFail(u *url.URL) func(*testing.T) {
+ return func(t *testing.T) {
+ t.Helper()
+ tmpDir := t.TempDir()
+ require.Error(t, git.Clone(git.DefaultContext, u.String(), tmpDir, git.CloneRepoOptions{}))
+ exist, err := util.IsExist(filepath.Join(tmpDir, "README.md"))
+ require.NoError(t, err)
+ assert.False(t, exist)
+ }
+}
+
+func doGitInitTestRepository(dstPath string, objectFormat git.ObjectFormat) func(*testing.T) {
+ return func(t *testing.T) {
+ t.Helper()
+ // Init repository in dstPath
+ require.NoError(t, git.InitRepository(git.DefaultContext, dstPath, false, objectFormat.Name()))
+ // forcibly set default branch to master
+ _, _, err := git.NewCommand(git.DefaultContext, "symbolic-ref", "HEAD", git.BranchPrefix+"master").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dstPath, "README.md"), []byte(fmt.Sprintf("# Testing Repository\n\nOriginally created in: %s", dstPath)), 0o644))
+ require.NoError(t, git.AddChanges(dstPath, true))
+ signature := git.Signature{
+ Email: "test@example.com",
+ Name: "test",
+ When: time.Now(),
+ }
+ require.NoError(t, git.CommitChanges(dstPath, git.CommitChangesOptions{
+ Committer: &signature,
+ Author: &signature,
+ Message: "Initial Commit",
+ }))
+ }
+}
+
+func doGitAddRemote(dstPath, remoteName string, u *url.URL) func(*testing.T) {
+ return func(t *testing.T) {
+ t.Helper()
+ _, _, err := git.NewCommand(git.DefaultContext, "remote", "add").AddDynamicArguments(remoteName, u.String()).RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+ }
+}
+
+func doGitPushTestRepository(dstPath string, args ...string) func(*testing.T) {
+ return func(t *testing.T) {
+ t.Helper()
+ _, _, err := git.NewCommand(git.DefaultContext, "push", "-u").AddArguments(git.ToTrustedCmdArgs(args)...).RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+ }
+}
+
+func doGitPushTestRepositoryFail(dstPath string, args ...string) func(*testing.T) {
+ return func(t *testing.T) {
+ t.Helper()
+ _, _, err := git.NewCommand(git.DefaultContext, "push").AddArguments(git.ToTrustedCmdArgs(args)...).RunStdString(&git.RunOpts{Dir: dstPath})
+ require.Error(t, err)
+ }
+}
+
+func doGitAddSomeCommits(dstPath, branch string) func(*testing.T) {
+ return func(t *testing.T) {
+ doGitCheckoutBranch(dstPath, branch)(t)
+
+ require.NoError(t, os.WriteFile(filepath.Join(dstPath, fmt.Sprintf("file-%s.txt", branch)), []byte(fmt.Sprintf("file %s", branch)), 0o644))
+ require.NoError(t, git.AddChanges(dstPath, true))
+ signature := git.Signature{
+ Email: "test@test.test",
+ Name: "test",
+ }
+ require.NoError(t, git.CommitChanges(dstPath, git.CommitChangesOptions{
+ Committer: &signature,
+ Author: &signature,
+ Message: fmt.Sprintf("update %s", branch),
+ }))
+ }
+}
+
+func doGitCreateBranch(dstPath, branch string) func(*testing.T) {
+ return func(t *testing.T) {
+ t.Helper()
+ _, _, err := git.NewCommand(git.DefaultContext, "checkout", "-b").AddDynamicArguments(branch).RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+ }
+}
+
+func doGitCheckoutBranch(dstPath string, args ...string) func(*testing.T) {
+ return func(t *testing.T) {
+ t.Helper()
+ _, _, err := git.NewCommandContextNoGlobals(git.DefaultContext, git.AllowLFSFiltersArgs()...).AddArguments("checkout").AddArguments(git.ToTrustedCmdArgs(args)...).RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+ }
+}
+
+func doGitPull(dstPath string, args ...string) func(*testing.T) {
+ return func(t *testing.T) {
+ t.Helper()
+ _, _, err := git.NewCommandContextNoGlobals(git.DefaultContext, git.AllowLFSFiltersArgs()...).AddArguments("pull").AddArguments(git.ToTrustedCmdArgs(args)...).RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+ }
+}
diff --git a/tests/integration/git_push_test.go b/tests/integration/git_push_test.go
new file mode 100644
index 0000000..c9c33dc
--- /dev/null
+++ b/tests/integration/git_push_test.go
@@ -0,0 +1,288 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/url"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ repo_module "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/test"
+ repo_service "code.gitea.io/gitea/services/repository"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func forEachObjectFormat(t *testing.T, f func(t *testing.T, objectFormat git.ObjectFormat)) {
+ for _, objectFormat := range []git.ObjectFormat{git.Sha256ObjectFormat, git.Sha1ObjectFormat} {
+ t.Run(objectFormat.Name(), func(t *testing.T) {
+ f(t, objectFormat)
+ })
+ }
+}
+
+func TestGitPush(t *testing.T) {
+ onGiteaRun(t, testGitPush)
+}
+
+func testGitPush(t *testing.T, u *url.URL) {
+ forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) {
+ t.Run("Push branches at once", func(t *testing.T) {
+ runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) {
+ for i := 0; i < 100; i++ {
+ branchName := fmt.Sprintf("branch-%d", i)
+ pushed = append(pushed, branchName)
+ doGitCreateBranch(gitPath, branchName)(t)
+ }
+ pushed = append(pushed, "master")
+ doGitPushTestRepository(gitPath, "origin", "--all")(t)
+ return pushed, deleted
+ })
+ })
+
+ t.Run("Push branches exists", func(t *testing.T) {
+ runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) {
+ for i := 0; i < 10; i++ {
+ branchName := fmt.Sprintf("branch-%d", i)
+ if i < 5 {
+ pushed = append(pushed, branchName)
+ }
+ doGitCreateBranch(gitPath, branchName)(t)
+ }
+ // only push master and the first 5 branches
+ pushed = append(pushed, "master")
+ args := append([]string{"origin"}, pushed...)
+ doGitPushTestRepository(gitPath, args...)(t)
+
+ pushed = pushed[:0]
+ // do some changes for the first 5 branches created above
+ for i := 0; i < 5; i++ {
+ branchName := fmt.Sprintf("branch-%d", i)
+ pushed = append(pushed, branchName)
+
+ doGitAddSomeCommits(gitPath, branchName)(t)
+ }
+
+ for i := 5; i < 10; i++ {
+ pushed = append(pushed, fmt.Sprintf("branch-%d", i))
+ }
+ pushed = append(pushed, "master")
+
+ // push all, so that master are not changed
+ doGitPushTestRepository(gitPath, "origin", "--all")(t)
+
+ return pushed, deleted
+ })
+ })
+
+ t.Run("Push branches one by one", func(t *testing.T) {
+ runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) {
+ for i := 0; i < 100; i++ {
+ branchName := fmt.Sprintf("branch-%d", i)
+ doGitCreateBranch(gitPath, branchName)(t)
+ doGitPushTestRepository(gitPath, "origin", branchName)(t)
+ pushed = append(pushed, branchName)
+ }
+ return pushed, deleted
+ })
+ })
+
+ t.Run("Delete branches", func(t *testing.T) {
+ runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) {
+ doGitPushTestRepository(gitPath, "origin", "master")(t) // make sure master is the default branch instead of a branch we are going to delete
+ pushed = append(pushed, "master")
+
+ for i := 0; i < 100; i++ {
+ branchName := fmt.Sprintf("branch-%d", i)
+ pushed = append(pushed, branchName)
+ doGitCreateBranch(gitPath, branchName)(t)
+ }
+ doGitPushTestRepository(gitPath, "origin", "--all")(t)
+
+ for i := 0; i < 10; i++ {
+ branchName := fmt.Sprintf("branch-%d", i)
+ doGitPushTestRepository(gitPath, "origin", "--delete", branchName)(t)
+ deleted = append(deleted, branchName)
+ }
+ return pushed, deleted
+ })
+ })
+
+ t.Run("Push to deleted branch", func(t *testing.T) {
+ runTestGitPush(t, u, objectFormat, func(t *testing.T, gitPath string) (pushed, deleted []string) {
+ doGitPushTestRepository(gitPath, "origin", "master")(t) // make sure master is the default branch instead of a branch we are going to delete
+ pushed = append(pushed, "master")
+
+ doGitCreateBranch(gitPath, "branch-1")(t)
+ doGitPushTestRepository(gitPath, "origin", "branch-1")(t)
+ pushed = append(pushed, "branch-1")
+
+ // delete and restore
+ doGitPushTestRepository(gitPath, "origin", "--delete", "branch-1")(t)
+ doGitPushTestRepository(gitPath, "origin", "branch-1")(t)
+
+ return pushed, deleted
+ })
+ })
+ })
+}
+
+func runTestGitPush(t *testing.T, u *url.URL, objectFormat git.ObjectFormat, gitOperation func(t *testing.T, gitPath string) (pushed, deleted []string)) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo, err := repo_service.CreateRepository(db.DefaultContext, user, user, repo_service.CreateRepoOptions{
+ Name: "repo-to-push",
+ Description: "test git push",
+ AutoInit: false,
+ DefaultBranch: "main",
+ IsPrivate: false,
+ ObjectFormatName: objectFormat.Name(),
+ })
+ require.NoError(t, err)
+ require.NotEmpty(t, repo)
+
+ gitPath := t.TempDir()
+
+ doGitInitTestRepository(gitPath, objectFormat)(t)
+
+ oldPath := u.Path
+ oldUser := u.User
+ defer func() {
+ u.Path = oldPath
+ u.User = oldUser
+ }()
+ u.Path = repo.FullName() + ".git"
+ u.User = url.UserPassword(user.LowerName, userPassword)
+
+ doGitAddRemote(gitPath, "origin", u)(t)
+
+ gitRepo, err := git.OpenRepository(git.DefaultContext, gitPath)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ pushedBranches, deletedBranches := gitOperation(t, gitPath)
+
+ dbBranches := make([]*git_model.Branch, 0)
+ require.NoError(t, db.GetEngine(db.DefaultContext).Where("repo_id=?", repo.ID).Find(&dbBranches))
+ assert.Equalf(t, len(pushedBranches), len(dbBranches), "mismatched number of branches in db")
+ dbBranchesMap := make(map[string]*git_model.Branch, len(dbBranches))
+ for _, branch := range dbBranches {
+ dbBranchesMap[branch.Name] = branch
+ }
+
+ deletedBranchesMap := make(map[string]bool, len(deletedBranches))
+ for _, branchName := range deletedBranches {
+ deletedBranchesMap[branchName] = true
+ }
+
+ for _, branchName := range pushedBranches {
+ branch, ok := dbBranchesMap[branchName]
+ deleted := deletedBranchesMap[branchName]
+ assert.True(t, ok, "branch %s not found in database", branchName)
+ assert.Equal(t, deleted, branch.IsDeleted, "IsDeleted of %s is %v, but it's expected to be %v", branchName, branch.IsDeleted, deleted)
+ commitID, err := gitRepo.GetBranchCommitID(branchName)
+ require.NoError(t, err)
+ assert.Equal(t, commitID, branch.CommitID)
+ }
+
+ require.NoError(t, repo_service.DeleteRepositoryDirectly(db.DefaultContext, user, repo.ID))
+}
+
+func TestOptionsGitPush(t *testing.T) {
+ onGiteaRun(t, testOptionsGitPush)
+}
+
+func testOptionsGitPush(t *testing.T, u *url.URL) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) {
+ repo, err := repo_service.CreateRepository(db.DefaultContext, user, user, repo_service.CreateRepoOptions{
+ Name: "repo-to-push",
+ Description: "test git push",
+ AutoInit: false,
+ DefaultBranch: "main",
+ IsPrivate: false,
+ ObjectFormatName: objectFormat.Name(),
+ })
+ require.NoError(t, err)
+ require.NotEmpty(t, repo)
+
+ gitPath := t.TempDir()
+
+ doGitInitTestRepository(gitPath, objectFormat)(t)
+
+ u.Path = repo.FullName() + ".git"
+ u.User = url.UserPassword(user.LowerName, userPassword)
+ doGitAddRemote(gitPath, "origin", u)(t)
+
+ t.Run("Unknown push options are silently ignored", func(t *testing.T) {
+ branchName := "branch0"
+ doGitCreateBranch(gitPath, branchName)(t)
+ doGitPushTestRepository(gitPath, "origin", branchName, "-o", "uknownoption=randomvalue", "-o", "repo.private=true")(t)
+ repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, "repo-to-push")
+ require.NoError(t, err)
+ require.True(t, repo.IsPrivate)
+ require.False(t, repo.IsTemplate)
+ })
+
+ t.Run("Owner sets private & template to true via push options", func(t *testing.T) {
+ branchName := "branch1"
+ doGitCreateBranch(gitPath, branchName)(t)
+ doGitPushTestRepository(gitPath, "origin", branchName, "-o", "repo.private=true", "-o", "repo.template=true")(t)
+ repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, "repo-to-push")
+ require.NoError(t, err)
+ require.True(t, repo.IsPrivate)
+ require.True(t, repo.IsTemplate)
+ })
+
+ t.Run("Owner sets private & template to false via push options", func(t *testing.T) {
+ branchName := "branch2"
+ doGitCreateBranch(gitPath, branchName)(t)
+ doGitPushTestRepository(gitPath, "origin", branchName, "-o", "repo.private=false", "-o", "repo.template=false")(t)
+ repo, err = repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, "repo-to-push")
+ require.NoError(t, err)
+ require.False(t, repo.IsPrivate)
+ require.False(t, repo.IsTemplate)
+ })
+
+ // create a collaborator with write access
+ collaborator := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ u.User = url.UserPassword(collaborator.LowerName, userPassword)
+ doGitAddRemote(gitPath, "collaborator", u)(t)
+ repo_module.AddCollaborator(db.DefaultContext, repo, collaborator)
+
+ t.Run("Collaborator with write access is allowed to push", func(t *testing.T) {
+ branchName := "branch3"
+ doGitCreateBranch(gitPath, branchName)(t)
+ doGitPushTestRepository(gitPath, "collaborator", branchName)(t)
+ })
+
+ t.Run("Collaborator with write access fails to change private & template via push options", func(t *testing.T) {
+ logChecker, cleanup := test.NewLogChecker(log.DEFAULT, log.TRACE)
+ logChecker.Filter("permission denied for changing repo settings").StopMark("Git push options validation")
+ defer cleanup()
+ branchName := "branch4"
+ doGitCreateBranch(gitPath, branchName)(t)
+ doGitPushTestRepositoryFail(gitPath, "collaborator", branchName, "-o", "repo.private=true", "-o", "repo.template=true")(t)
+ repo, err = repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, user.Name, "repo-to-push")
+ require.NoError(t, err)
+ require.False(t, repo.IsPrivate)
+ require.False(t, repo.IsTemplate)
+ logFiltered, logStopped := logChecker.Check(5 * time.Second)
+ assert.True(t, logStopped)
+ assert.True(t, logFiltered[0])
+ })
+
+ require.NoError(t, repo_service.DeleteRepositoryDirectly(db.DefaultContext, user, repo.ID))
+ })
+}
diff --git a/tests/integration/git_smart_http_test.go b/tests/integration/git_smart_http_test.go
new file mode 100644
index 0000000..2b904ed
--- /dev/null
+++ b/tests/integration/git_smart_http_test.go
@@ -0,0 +1,69 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "io"
+ "net/http"
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGitSmartHTTP(t *testing.T) {
+ onGiteaRun(t, testGitSmartHTTP)
+}
+
+func testGitSmartHTTP(t *testing.T, u *url.URL) {
+ kases := []struct {
+ p string
+ code int
+ }{
+ {
+ p: "user2/repo1/info/refs",
+ code: http.StatusOK,
+ },
+ {
+ p: "user2/repo1/HEAD",
+ code: http.StatusOK,
+ },
+ {
+ p: "user2/repo1/objects/info/alternates",
+ code: http.StatusNotFound,
+ },
+ {
+ p: "user2/repo1/objects/info/http-alternates",
+ code: http.StatusNotFound,
+ },
+ {
+ p: "user2/repo1/../../custom/conf/app.ini",
+ code: http.StatusNotFound,
+ },
+ {
+ p: "user2/repo1/objects/info/../../../../custom/conf/app.ini",
+ code: http.StatusNotFound,
+ },
+ {
+ p: `user2/repo1/objects/info/..\..\..\..\custom\conf\app.ini`,
+ code: http.StatusBadRequest,
+ },
+ }
+
+ for _, kase := range kases {
+ t.Run(kase.p, func(t *testing.T) {
+ p := u.String() + kase.p
+ req, err := http.NewRequest("GET", p, nil)
+ require.NoError(t, err)
+ req.SetBasicAuth("user2", userPassword)
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+ assert.EqualValues(t, kase.code, resp.StatusCode)
+ _, err = io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ })
+ }
+}
diff --git a/tests/integration/git_test.go b/tests/integration/git_test.go
new file mode 100644
index 0000000..2c46fdd
--- /dev/null
+++ b/tests/integration/git_test.go
@@ -0,0 +1,1124 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/perm"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/lfs"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ gitea_context "code.gitea.io/gitea/services/context"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ littleSize = 1024 // 1ko
+ bigSize = 128 * 1024 * 1024 // 128Mo
+)
+
+func TestGit(t *testing.T) {
+ onGiteaRun(t, testGit)
+}
+
+func testGit(t *testing.T, u *url.URL) {
+ username := "user2"
+ baseAPITestContext := NewAPITestContext(t, username, "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+ u.Path = baseAPITestContext.GitPath()
+
+ forkedUserCtx := NewAPITestContext(t, "user4", "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+ t.Run("HTTP", func(t *testing.T) {
+ ensureAnonymousClone(t, u)
+ forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) {
+ defer tests.PrintCurrentTest(t)()
+ httpContext := baseAPITestContext
+ httpContext.Reponame = "repo-tmp-17-" + objectFormat.Name()
+ forkedUserCtx.Reponame = httpContext.Reponame
+
+ dstPath := t.TempDir()
+
+ t.Run("CreateRepoInDifferentUser", doAPICreateRepository(forkedUserCtx, false, objectFormat))
+ t.Run("AddUserAsCollaborator", doAPIAddCollaborator(forkedUserCtx, httpContext.Username, perm.AccessModeRead))
+
+ t.Run("ForkFromDifferentUser", doAPIForkRepository(httpContext, forkedUserCtx.Username))
+
+ u.Path = httpContext.GitPath()
+ u.User = url.UserPassword(username, userPassword)
+
+ t.Run("Clone", doGitClone(dstPath, u))
+
+ dstPath2 := t.TempDir()
+
+ t.Run("Partial Clone", doPartialGitClone(dstPath2, u))
+
+ little, big := standardCommitAndPushTest(t, dstPath)
+ littleLFS, bigLFS := lfsCommitAndPushTest(t, dstPath)
+ rawTest(t, &httpContext, little, big, littleLFS, bigLFS)
+ mediaTest(t, &httpContext, little, big, littleLFS, bigLFS)
+
+ t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &httpContext, "test/head"))
+ t.Run("InternalReferences", doInternalReferences(&httpContext, dstPath))
+ t.Run("BranchProtect", doBranchProtect(&httpContext, dstPath))
+ t.Run("AutoMerge", doAutoPRMerge(&httpContext, dstPath))
+ t.Run("CreatePRAndSetManuallyMerged", doCreatePRAndSetManuallyMerged(httpContext, httpContext, dstPath, "master", "test-manually-merge"))
+ t.Run("MergeFork", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ t.Run("CreatePRAndMerge", doMergeFork(httpContext, forkedUserCtx, "master", httpContext.Username+":master"))
+ rawTest(t, &forkedUserCtx, little, big, littleLFS, bigLFS)
+ mediaTest(t, &forkedUserCtx, little, big, littleLFS, bigLFS)
+ })
+
+ t.Run("PushCreate", doPushCreate(httpContext, u, objectFormat))
+ })
+ })
+ t.Run("SSH", func(t *testing.T) {
+ forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) {
+ defer tests.PrintCurrentTest(t)()
+ sshContext := baseAPITestContext
+ sshContext.Reponame = "repo-tmp-18-" + objectFormat.Name()
+ keyname := "my-testing-key"
+ forkedUserCtx.Reponame = sshContext.Reponame
+ t.Run("CreateRepoInDifferentUser", doAPICreateRepository(forkedUserCtx, false, objectFormat))
+ t.Run("AddUserAsCollaborator", doAPIAddCollaborator(forkedUserCtx, sshContext.Username, perm.AccessModeRead))
+ t.Run("ForkFromDifferentUser", doAPIForkRepository(sshContext, forkedUserCtx.Username))
+
+ // Setup key the user ssh key
+ withKeyFile(t, keyname, func(keyFile string) {
+ t.Run("CreateUserKey", doAPICreateUserKey(sshContext, "test-key-"+objectFormat.Name(), keyFile))
+
+ // Setup remote link
+ // TODO: get url from api
+ sshURL := createSSHUrl(sshContext.GitPath(), u)
+
+ // Setup clone folder
+ dstPath := t.TempDir()
+
+ t.Run("Clone", doGitClone(dstPath, sshURL))
+
+ little, big := standardCommitAndPushTest(t, dstPath)
+ littleLFS, bigLFS := lfsCommitAndPushTest(t, dstPath)
+ rawTest(t, &sshContext, little, big, littleLFS, bigLFS)
+ mediaTest(t, &sshContext, little, big, littleLFS, bigLFS)
+
+ t.Run("CreateAgitFlowPull", doCreateAgitFlowPull(dstPath, &sshContext, "test/head2"))
+ t.Run("InternalReferences", doInternalReferences(&sshContext, dstPath))
+ t.Run("BranchProtect", doBranchProtect(&sshContext, dstPath))
+ t.Run("MergeFork", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ t.Run("CreatePRAndMerge", doMergeFork(sshContext, forkedUserCtx, "master", sshContext.Username+":master"))
+ rawTest(t, &forkedUserCtx, little, big, littleLFS, bigLFS)
+ mediaTest(t, &forkedUserCtx, little, big, littleLFS, bigLFS)
+ })
+
+ t.Run("PushCreate", doPushCreate(sshContext, sshURL, objectFormat))
+ })
+ })
+ })
+}
+
+func ensureAnonymousClone(t *testing.T, u *url.URL) {
+ dstLocalPath := t.TempDir()
+ t.Run("CloneAnonymous", doGitClone(dstLocalPath, u))
+}
+
+func standardCommitAndPushTest(t *testing.T, dstPath string) (little, big string) {
+ t.Run("Standard", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ little, big = commitAndPushTest(t, dstPath, "data-file-")
+ })
+ return little, big
+}
+
+func lfsCommitAndPushTest(t *testing.T, dstPath string) (littleLFS, bigLFS string) {
+ t.Run("LFS", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ prefix := "lfs-data-file-"
+ err := git.NewCommand(git.DefaultContext, "lfs").AddArguments("install").Run(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+ _, _, err = git.NewCommand(git.DefaultContext, "lfs").AddArguments("track").AddDynamicArguments(prefix + "*").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+ err = git.AddChanges(dstPath, false, ".gitattributes")
+ require.NoError(t, err)
+
+ err = git.CommitChangesWithArgs(dstPath, git.AllowLFSFiltersArgs(), git.CommitChangesOptions{
+ Committer: &git.Signature{
+ Email: "user2@example.com",
+ Name: "User Two",
+ When: time.Now(),
+ },
+ Author: &git.Signature{
+ Email: "user2@example.com",
+ Name: "User Two",
+ When: time.Now(),
+ },
+ Message: fmt.Sprintf("Testing commit @ %v", time.Now()),
+ })
+ require.NoError(t, err)
+
+ littleLFS, bigLFS = commitAndPushTest(t, dstPath, prefix)
+
+ t.Run("Locks", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ lockTest(t, dstPath)
+ })
+ })
+ return littleLFS, bigLFS
+}
+
+func commitAndPushTest(t *testing.T, dstPath, prefix string) (little, big string) {
+ t.Run("PushCommit", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ t.Run("Little", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ little = doCommitAndPush(t, littleSize, dstPath, prefix)
+ })
+ t.Run("Big", func(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping test in short mode.")
+ return
+ }
+ defer tests.PrintCurrentTest(t)()
+ big = doCommitAndPush(t, bigSize, dstPath, prefix)
+ })
+ })
+ return little, big
+}
+
+func rawTest(t *testing.T, ctx *APITestContext, little, big, littleLFS, bigLFS string) {
+ t.Run("Raw", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ username := ctx.Username
+ reponame := ctx.Reponame
+
+ session := loginUser(t, username)
+
+ // Request raw paths
+ req := NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", little))
+ resp := session.MakeRequestNilResponseRecorder(t, req, http.StatusOK)
+ assert.Equal(t, littleSize, resp.Length)
+
+ if setting.LFS.StartServer {
+ req = NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", littleLFS))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ assert.NotEqual(t, littleSize, resp.Body.Len())
+ assert.LessOrEqual(t, resp.Body.Len(), 1024)
+ if resp.Body.Len() != littleSize && resp.Body.Len() <= 1024 {
+ assert.Contains(t, resp.Body.String(), lfs.MetaFileIdentifier)
+ }
+ }
+
+ if !testing.Short() {
+ req = NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", big))
+ resp := session.MakeRequestNilResponseRecorder(t, req, http.StatusOK)
+ assert.Equal(t, bigSize, resp.Length)
+
+ if setting.LFS.StartServer {
+ req = NewRequest(t, "GET", path.Join("/", username, reponame, "/raw/branch/master/", bigLFS))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ assert.NotEqual(t, bigSize, resp.Body.Len())
+ if resp.Body.Len() != bigSize && resp.Body.Len() <= 1024 {
+ assert.Contains(t, resp.Body.String(), lfs.MetaFileIdentifier)
+ }
+ }
+ }
+ })
+}
+
+func mediaTest(t *testing.T, ctx *APITestContext, little, big, littleLFS, bigLFS string) {
+ t.Run("Media", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ username := ctx.Username
+ reponame := ctx.Reponame
+
+ session := loginUser(t, username)
+
+ // Request media paths
+ req := NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", little))
+ resp := session.MakeRequestNilResponseRecorder(t, req, http.StatusOK)
+ assert.Equal(t, littleSize, resp.Length)
+
+ req = NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", littleLFS))
+ resp = session.MakeRequestNilResponseRecorder(t, req, http.StatusOK)
+ assert.Equal(t, littleSize, resp.Length)
+
+ if !testing.Short() {
+ req = NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", big))
+ resp = session.MakeRequestNilResponseRecorder(t, req, http.StatusOK)
+ assert.Equal(t, bigSize, resp.Length)
+
+ if setting.LFS.StartServer {
+ req = NewRequest(t, "GET", path.Join("/", username, reponame, "/media/branch/master/", bigLFS))
+ resp = session.MakeRequestNilResponseRecorder(t, req, http.StatusOK)
+ assert.Equal(t, bigSize, resp.Length)
+ }
+ }
+ })
+}
+
+func lockTest(t *testing.T, repoPath string) {
+ lockFileTest(t, "README.md", repoPath)
+}
+
+func lockFileTest(t *testing.T, filename, repoPath string) {
+ _, _, err := git.NewCommand(git.DefaultContext, "lfs").AddArguments("locks").RunStdString(&git.RunOpts{Dir: repoPath})
+ require.NoError(t, err)
+ _, _, err = git.NewCommand(git.DefaultContext, "lfs").AddArguments("lock").AddDynamicArguments(filename).RunStdString(&git.RunOpts{Dir: repoPath})
+ require.NoError(t, err)
+ _, _, err = git.NewCommand(git.DefaultContext, "lfs").AddArguments("locks").RunStdString(&git.RunOpts{Dir: repoPath})
+ require.NoError(t, err)
+ _, _, err = git.NewCommand(git.DefaultContext, "lfs").AddArguments("unlock").AddDynamicArguments(filename).RunStdString(&git.RunOpts{Dir: repoPath})
+ require.NoError(t, err)
+}
+
+func doCommitAndPush(t *testing.T, size int, repoPath, prefix string) string {
+ name, err := generateCommitWithNewData(size, repoPath, "user2@example.com", "User Two", prefix)
+ require.NoError(t, err)
+ _, _, err = git.NewCommand(git.DefaultContext, "push", "origin", "master").RunStdString(&git.RunOpts{Dir: repoPath}) // Push
+ require.NoError(t, err)
+ return name
+}
+
+func generateCommitWithNewData(size int, repoPath, email, fullName, prefix string) (string, error) {
+ // Generate random file
+ bufSize := 4 * 1024
+ if bufSize > size {
+ bufSize = size
+ }
+
+ buffer := make([]byte, bufSize)
+
+ tmpFile, err := os.CreateTemp(repoPath, prefix)
+ if err != nil {
+ return "", err
+ }
+ defer tmpFile.Close()
+ written := 0
+ for written < size {
+ n := size - written
+ if n > bufSize {
+ n = bufSize
+ }
+ _, err := rand.Read(buffer[:n])
+ if err != nil {
+ return "", err
+ }
+ n, err = tmpFile.Write(buffer[:n])
+ if err != nil {
+ return "", err
+ }
+ written += n
+ }
+
+ // Commit
+ // Now here we should explicitly allow lfs filters to run
+ globalArgs := git.AllowLFSFiltersArgs()
+ err = git.AddChangesWithArgs(repoPath, globalArgs, false, filepath.Base(tmpFile.Name()))
+ if err != nil {
+ return "", err
+ }
+ err = git.CommitChangesWithArgs(repoPath, globalArgs, git.CommitChangesOptions{
+ Committer: &git.Signature{
+ Email: email,
+ Name: fullName,
+ When: time.Now(),
+ },
+ Author: &git.Signature{
+ Email: email,
+ Name: fullName,
+ When: time.Now(),
+ },
+ Message: fmt.Sprintf("Testing commit @ %v", time.Now()),
+ })
+ return filepath.Base(tmpFile.Name()), err
+}
+
+func doBranchProtect(baseCtx *APITestContext, dstPath string) func(t *testing.T) {
+ return func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ t.Run("CreateBranchProtected", doGitCreateBranch(dstPath, "protected"))
+ t.Run("PushProtectedBranch", doGitPushTestRepository(dstPath, "origin", "protected"))
+
+ ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository)
+
+ t.Run("FailToPushToProtectedBranch", func(t *testing.T) {
+ t.Run("ProtectProtectedBranch", doProtectBranch(ctx, "protected"))
+ t.Run("Create modified-protected-branch", doGitCheckoutBranch(dstPath, "-b", "modified-protected-branch", "protected"))
+ t.Run("GenerateCommit", func(t *testing.T) {
+ _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "branch-data-file-")
+ require.NoError(t, err)
+ })
+
+ doGitPushTestRepositoryFail(dstPath, "origin", "modified-protected-branch:protected")(t)
+ })
+
+ t.Run("PushToUnprotectedBranch", doGitPushTestRepository(dstPath, "origin", "modified-protected-branch:unprotected"))
+
+ t.Run("FailToPushProtectedFilesToProtectedBranch", func(t *testing.T) {
+ t.Run("Create modified-protected-file-protected-branch", doGitCheckoutBranch(dstPath, "-b", "modified-protected-file-protected-branch", "protected"))
+ t.Run("GenerateCommit", func(t *testing.T) {
+ _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "protected-file-")
+ require.NoError(t, err)
+ })
+
+ t.Run("ProtectedFilePathsApplyToAdmins", doProtectBranch(ctx, "protected"))
+ doGitPushTestRepositoryFail(dstPath, "origin", "modified-protected-file-protected-branch:protected")(t)
+
+ doGitCheckoutBranch(dstPath, "protected")(t)
+ doGitPull(dstPath, "origin", "protected")(t)
+ })
+
+ t.Run("PushUnprotectedFilesToProtectedBranch", func(t *testing.T) {
+ t.Run("Create modified-unprotected-file-protected-branch", doGitCheckoutBranch(dstPath, "-b", "modified-unprotected-file-protected-branch", "protected"))
+ t.Run("UnprotectedFilePaths", doProtectBranch(ctx, "protected", parameterProtectBranch{
+ "unprotected_file_patterns": "unprotected-file-*",
+ }))
+ t.Run("GenerateCommit", func(t *testing.T) {
+ _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "unprotected-file-")
+ require.NoError(t, err)
+ })
+ doGitPushTestRepository(dstPath, "origin", "modified-unprotected-file-protected-branch:protected")(t)
+ doGitCheckoutBranch(dstPath, "protected")(t)
+ doGitPull(dstPath, "origin", "protected")(t)
+ })
+
+ user, err := user_model.GetUserByName(db.DefaultContext, baseCtx.Username)
+ require.NoError(t, err)
+ t.Run("WhitelistUsers", doProtectBranch(ctx, "protected", parameterProtectBranch{
+ "enable_push": "whitelist",
+ "enable_whitelist": "on",
+ "whitelist_users": strconv.FormatInt(user.ID, 10),
+ }))
+
+ t.Run("WhitelistedUserFailToForcePushToProtectedBranch", func(t *testing.T) {
+ t.Run("Create toforce", doGitCheckoutBranch(dstPath, "-b", "toforce", "master"))
+ t.Run("GenerateCommit", func(t *testing.T) {
+ _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "branch-data-file-")
+ require.NoError(t, err)
+ })
+ doGitPushTestRepositoryFail(dstPath, "-f", "origin", "toforce:protected")(t)
+ })
+
+ t.Run("WhitelistedUserPushToProtectedBranch", func(t *testing.T) {
+ t.Run("Create topush", doGitCheckoutBranch(dstPath, "-b", "topush", "protected"))
+ t.Run("GenerateCommit", func(t *testing.T) {
+ _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "branch-data-file-")
+ require.NoError(t, err)
+ })
+ doGitPushTestRepository(dstPath, "origin", "topush:protected")(t)
+ })
+ }
+}
+
+type parameterProtectBranch map[string]string
+
+func doProtectBranch(ctx APITestContext, branch string, addParameter ...parameterProtectBranch) func(t *testing.T) {
+ // We are going to just use the owner to set the protection.
+ return func(t *testing.T) {
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: ctx.Reponame, OwnerName: ctx.Username})
+ rule := &git_model.ProtectedBranch{RuleName: branch, RepoID: repo.ID}
+ unittest.LoadBeanIfExists(rule)
+
+ csrf := GetCSRF(t, ctx.Session, fmt.Sprintf("/%s/%s/settings/branches", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)))
+
+ parameter := parameterProtectBranch{
+ "_csrf": csrf,
+ "rule_id": strconv.FormatInt(rule.ID, 10),
+ "rule_name": branch,
+ }
+ if len(addParameter) > 0 {
+ for k, v := range addParameter[0] {
+ parameter[k] = v
+ }
+ }
+
+ // Change branch to protected
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings/branches/edit", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), parameter)
+ ctx.Session.MakeRequest(t, req, http.StatusSeeOther)
+ // Check if master branch has been locked successfully
+ flashCookie := ctx.Session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.EqualValues(t, "success%3DBranch%2Bprotection%2Bfor%2Brule%2B%2522"+url.QueryEscape(branch)+"%2522%2Bhas%2Bbeen%2Bupdated.", flashCookie.Value)
+ }
+}
+
+func doMergeFork(ctx, baseCtx APITestContext, baseBranch, headBranch string) func(t *testing.T) {
+ return func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ var pr api.PullRequest
+ var err error
+
+ // Create a test pull request
+ t.Run("CreatePullRequest", func(t *testing.T) {
+ pr, err = doAPICreatePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, baseBranch, headBranch)(t)
+ require.NoError(t, err)
+ })
+
+ // Ensure the PR page works.
+ // For the base repository owner, the PR is not editable (maintainer edits are not enabled):
+ t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr, false))
+ // For the head repository owner, the PR is editable:
+ headSession := loginUser(t, "user2")
+ headToken := getTokenForLoggedInUser(t, headSession, auth_model.AccessTokenScopeReadRepository, auth_model.AccessTokenScopeReadUser)
+ headCtx := APITestContext{
+ Session: headSession,
+ Token: headToken,
+ Username: baseCtx.Username,
+ Reponame: baseCtx.Reponame,
+ }
+ t.Run("EnsureCanSeePull", doEnsureCanSeePull(headCtx, pr, true))
+
+ // Confirm that there is no AGit Label
+ // TODO: Refactor and move this check to a function
+ t.Run("AGitLabelIsMissing", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, ctx.Username)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", baseCtx.Username, baseCtx.Reponame, pr.Index))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, "#agit-label", false)
+ })
+
+ // Then get the diff string
+ var diffHash string
+ var diffLength int
+ t.Run("GetDiff", func(t *testing.T) {
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d.diff", url.PathEscape(baseCtx.Username), url.PathEscape(baseCtx.Reponame), pr.Index))
+ resp := ctx.Session.MakeRequestNilResponseHashSumRecorder(t, req, http.StatusOK)
+ diffHash = string(resp.Hash.Sum(nil))
+ diffLength = resp.Length
+ })
+
+ // Now: Merge the PR & make sure that doesn't break the PR page or change its diff
+ t.Run("MergePR", doAPIMergePullRequest(baseCtx, baseCtx.Username, baseCtx.Reponame, pr.Index))
+ // for both users the PR is still visible but not editable anymore after it was merged
+ t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr, false))
+ t.Run("EnsureCanSeePull", doEnsureCanSeePull(headCtx, pr, false))
+ t.Run("CheckPR", func(t *testing.T) {
+ oldMergeBase := pr.MergeBase
+ pr2, err := doAPIGetPullRequest(baseCtx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t)
+ require.NoError(t, err)
+ assert.Equal(t, oldMergeBase, pr2.MergeBase)
+ })
+ t.Run("EnsurDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffHash, diffLength))
+
+ // Then: Delete the head branch & make sure that doesn't break the PR page or change its diff
+ t.Run("DeleteHeadBranch", doBranchDelete(baseCtx, baseCtx.Username, baseCtx.Reponame, headBranch))
+ t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr, false))
+ t.Run("EnsureDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffHash, diffLength))
+
+ // Delete the head repository & make sure that doesn't break the PR page or change its diff
+ t.Run("DeleteHeadRepository", doAPIDeleteRepository(ctx))
+ t.Run("EnsureCanSeePull", doEnsureCanSeePull(baseCtx, pr, false))
+ t.Run("EnsureDiffNoChange", doEnsureDiffNoChange(baseCtx, pr, diffHash, diffLength))
+ }
+}
+
+func doCreatePRAndSetManuallyMerged(ctx, baseCtx APITestContext, dstPath, baseBranch, headBranch string) func(t *testing.T) {
+ return func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ var (
+ pr api.PullRequest
+ err error
+ lastCommitID string
+ )
+
+ trueBool := true
+ falseBool := false
+
+ t.Run("AllowSetManuallyMergedAndSwitchOffAutodetectManualMerge", doAPIEditRepository(baseCtx, &api.EditRepoOption{
+ HasPullRequests: &trueBool,
+ AllowManualMerge: &trueBool,
+ AutodetectManualMerge: &falseBool,
+ }))
+
+ t.Run("CreateHeadBranch", doGitCreateBranch(dstPath, headBranch))
+ t.Run("PushToHeadBranch", doGitPushTestRepository(dstPath, "origin", headBranch))
+ t.Run("CreateEmptyPullRequest", func(t *testing.T) {
+ pr, err = doAPICreatePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, baseBranch, headBranch)(t)
+ require.NoError(t, err)
+ })
+ lastCommitID = pr.Base.Sha
+ t.Run("ManuallyMergePR", doAPIManuallyMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, lastCommitID, pr.Index))
+ }
+}
+
+func doEnsureCanSeePull(ctx APITestContext, pr api.PullRequest, editable bool) func(t *testing.T) {
+ return func(t *testing.T) {
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index))
+ ctx.Session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d/files", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index))
+ resp := ctx.Session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ editButtonCount := doc.doc.Find("div.diff-file-header-actions a[href*='/_edit/']").Length()
+ if editable {
+ assert.Positive(t, editButtonCount, 0, "Expected to find a button to edit a file in the PR diff view but there were none")
+ } else {
+ assert.Equal(t, 0, editButtonCount, "Expected not to find any buttons to edit files in PR diff view but there were some")
+ }
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d/commits", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index))
+ ctx.Session.MakeRequest(t, req, http.StatusOK)
+ }
+}
+
+func doEnsureDiffNoChange(ctx APITestContext, pr api.PullRequest, diffHash string, diffLength int) func(t *testing.T) {
+ return func(t *testing.T) {
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d.diff", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr.Index))
+ resp := ctx.Session.MakeRequestNilResponseHashSumRecorder(t, req, http.StatusOK)
+ actual := string(resp.Hash.Sum(nil))
+ actualLength := resp.Length
+
+ equal := diffHash == actual
+ assert.True(t, equal, "Unexpected change in the diff string: expected hash: %s size: %d but was actually: %s size: %d", hex.EncodeToString([]byte(diffHash)), diffLength, hex.EncodeToString([]byte(actual)), actualLength)
+ }
+}
+
+func doPushCreate(ctx APITestContext, u *url.URL, objectFormat git.ObjectFormat) func(t *testing.T) {
+ return func(t *testing.T) {
+ if objectFormat == git.Sha256ObjectFormat {
+ t.Skipf("push-create not supported for %s, see https://codeberg.org/forgejo/forgejo/issues/3783", objectFormat)
+ }
+ defer tests.PrintCurrentTest(t)()
+
+ // create a context for a currently non-existent repository
+ ctx.Reponame = fmt.Sprintf("repo-tmp-push-create-%s", u.Scheme)
+ u.Path = ctx.GitPath()
+
+ // Create a temporary directory
+ tmpDir := t.TempDir()
+
+ // Now create local repository to push as our test and set its origin
+ t.Run("InitTestRepository", doGitInitTestRepository(tmpDir, objectFormat))
+ t.Run("AddRemote", doGitAddRemote(tmpDir, "origin", u))
+
+ // Disable "Push To Create" and attempt to push
+ setting.Repository.EnablePushCreateUser = false
+ t.Run("FailToPushAndCreateTestRepository", doGitPushTestRepositoryFail(tmpDir, "origin", "master"))
+
+ // Enable "Push To Create"
+ setting.Repository.EnablePushCreateUser = true
+
+ // Assert that cloning from a non-existent repository does not create it and that it definitely wasn't create above
+ t.Run("FailToCloneFromNonExistentRepository", doGitCloneFail(u))
+
+ // Then "Push To Create"x
+ t.Run("SuccessfullyPushAndCreateTestRepository", doGitPushTestRepository(tmpDir, "origin", "master"))
+
+ // Finally, fetch repo from database and ensure the correct repository has been created
+ repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, ctx.Username, ctx.Reponame)
+ require.NoError(t, err)
+ assert.False(t, repo.IsEmpty)
+ assert.True(t, repo.IsPrivate)
+
+ // Now add a remote that is invalid to "Push To Create"
+ invalidCtx := ctx
+ invalidCtx.Reponame = fmt.Sprintf("invalid/repo-tmp-push-create-%s", u.Scheme)
+ u.Path = invalidCtx.GitPath()
+ t.Run("AddInvalidRemote", doGitAddRemote(tmpDir, "invalid", u))
+
+ // Fail to "Push To Create" the invalid
+ t.Run("FailToPushAndCreateInvalidTestRepository", doGitPushTestRepositoryFail(tmpDir, "invalid", "master"))
+ }
+}
+
+func doBranchDelete(ctx APITestContext, owner, repo, branch string) func(*testing.T) {
+ return func(t *testing.T) {
+ csrf := GetCSRF(t, ctx.Session, fmt.Sprintf("/%s/%s/branches", url.PathEscape(owner), url.PathEscape(repo)))
+
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/branches/delete?name=%s", url.PathEscape(owner), url.PathEscape(repo), url.QueryEscape(branch)), map[string]string{
+ "_csrf": csrf,
+ })
+ ctx.Session.MakeRequest(t, req, http.StatusOK)
+ }
+}
+
+func doAutoPRMerge(baseCtx *APITestContext, dstPath string) func(t *testing.T) {
+ return func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ ctx := NewAPITestContext(t, baseCtx.Username, baseCtx.Reponame, auth_model.AccessTokenScopeWriteRepository)
+
+ t.Run("CheckoutProtected", doGitCheckoutBranch(dstPath, "protected"))
+ t.Run("PullProtected", doGitPull(dstPath, "origin", "protected"))
+ t.Run("GenerateCommit", func(t *testing.T) {
+ _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", "branch-data-file-")
+ require.NoError(t, err)
+ })
+ t.Run("PushToUnprotectedBranch", doGitPushTestRepository(dstPath, "origin", "protected:unprotected3"))
+ var pr api.PullRequest
+ var err error
+ t.Run("CreatePullRequest", func(t *testing.T) {
+ pr, err = doAPICreatePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, "protected", "unprotected3")(t)
+ require.NoError(t, err)
+ })
+
+ // Request repository commits page
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d/commits", baseCtx.Username, baseCtx.Reponame, pr.Index))
+ resp := ctx.Session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ // Get first commit URL
+ commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Last().Attr("href")
+ assert.True(t, exists)
+ assert.NotEmpty(t, commitURL)
+
+ commitID := path.Base(commitURL)
+
+ addCommitStatus := func(status api.CommitStatusState) func(*testing.T) {
+ return doAPICreateCommitStatus(ctx, commitID, api.CreateStatusOption{
+ State: status,
+ TargetURL: "http://test.ci/",
+ Description: "",
+ Context: "testci",
+ })
+ }
+
+ // Call API to add Pending status for commit
+ t.Run("CreateStatus", addCommitStatus(api.CommitStatusPending))
+
+ // Cancel not existing auto merge
+ ctx.ExpectedCode = http.StatusNotFound
+ t.Run("CancelAutoMergePR", doAPICancelAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
+
+ // Add auto merge request
+ ctx.ExpectedCode = http.StatusCreated
+ t.Run("AutoMergePR", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
+
+ // Can not create schedule twice
+ ctx.ExpectedCode = http.StatusConflict
+ t.Run("AutoMergePRTwice", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
+
+ // Cancel auto merge request
+ ctx.ExpectedCode = http.StatusNoContent
+ t.Run("CancelAutoMergePR", doAPICancelAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
+
+ // Add auto merge request
+ ctx.ExpectedCode = http.StatusCreated
+ t.Run("AutoMergePR", doAPIAutoMergePullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index))
+
+ // Check pr status
+ ctx.ExpectedCode = 0
+ pr, err = doAPIGetPullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t)
+ require.NoError(t, err)
+ assert.False(t, pr.HasMerged)
+
+ // Call API to add Failure status for commit
+ t.Run("CreateStatus", addCommitStatus(api.CommitStatusFailure))
+
+ // Check pr status
+ pr, err = doAPIGetPullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t)
+ require.NoError(t, err)
+ assert.False(t, pr.HasMerged)
+
+ // Call API to add Success status for commit
+ t.Run("CreateStatus", addCommitStatus(api.CommitStatusSuccess))
+
+ // wait to let gitea merge stuff
+ time.Sleep(time.Second)
+
+ // test pr status
+ pr, err = doAPIGetPullRequest(ctx, baseCtx.Username, baseCtx.Reponame, pr.Index)(t)
+ require.NoError(t, err)
+ assert.True(t, pr.HasMerged)
+ }
+}
+
+func doInternalReferences(ctx *APITestContext, dstPath string) func(t *testing.T) {
+ return func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: ctx.Username, Name: ctx.Reponame})
+ pr1 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{HeadRepoID: repo.ID})
+
+ _, stdErr, gitErr := git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments(fmt.Sprintf(":refs/pull/%d/head", pr1.Index)).RunStdString(&git.RunOpts{Dir: dstPath})
+ require.Error(t, gitErr)
+ assert.Contains(t, stdErr, fmt.Sprintf("remote: Forgejo: The deletion of refs/pull/%d/head is skipped as it's an internal reference.", pr1.Index))
+ assert.Contains(t, stdErr, fmt.Sprintf("[remote rejected] refs/pull/%d/head (hook declined)", pr1.Index))
+ }
+}
+
+func doCreateAgitFlowPull(dstPath string, ctx *APITestContext, headBranch string) func(t *testing.T) {
+ return func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // skip this test if git version is low
+ if git.CheckGitVersionAtLeast("2.29") != nil {
+ return
+ }
+
+ gitRepo, err := git.OpenRepository(git.DefaultContext, dstPath)
+ require.NoError(t, err)
+
+ defer gitRepo.Close()
+
+ var (
+ pr1, pr2 *issues_model.PullRequest
+ commit string
+ )
+ repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, ctx.Username, ctx.Reponame)
+ require.NoError(t, err)
+
+ pullNum := unittest.GetCount(t, &issues_model.PullRequest{})
+
+ t.Run("CreateHeadBranch", doGitCreateBranch(dstPath, headBranch))
+
+ t.Run("AddCommit", func(t *testing.T) {
+ err := os.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content"), 0o666)
+ require.NoError(t, err)
+
+ err = git.AddChanges(dstPath, true)
+ require.NoError(t, err)
+
+ err = git.CommitChanges(dstPath, git.CommitChangesOptions{
+ Committer: &git.Signature{
+ Email: "user2@example.com",
+ Name: "user2",
+ When: time.Now(),
+ },
+ Author: &git.Signature{
+ Email: "user2@example.com",
+ Name: "user2",
+ When: time.Now(),
+ },
+ Message: "Testing commit 1",
+ })
+ require.NoError(t, err)
+ commit, err = gitRepo.GetRefCommitID("HEAD")
+ require.NoError(t, err)
+ })
+
+ t.Run("Push", func(t *testing.T) {
+ err := git.NewCommand(git.DefaultContext, "push", "origin", "HEAD:refs/for/master", "-o").AddDynamicArguments("topic=" + headBranch).Run(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+
+ unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+1)
+ pr1 = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ HeadRepoID: repo.ID,
+ Flow: issues_model.PullRequestFlowAGit,
+ })
+ if !assert.NotEmpty(t, pr1) {
+ return
+ }
+ assert.Equal(t, 1, pr1.CommitsAhead)
+ assert.Equal(t, 0, pr1.CommitsBehind)
+
+ prMsg, err := doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr1.Index)(t)
+ require.NoError(t, err)
+
+ assert.Equal(t, "user2/"+headBranch, pr1.HeadBranch)
+ assert.False(t, prMsg.HasMerged)
+ assert.Contains(t, "Testing commit 1", prMsg.Body)
+ assert.Equal(t, commit, prMsg.Head.Sha)
+
+ _, _, err = git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments("HEAD:refs/for/master/test/" + headBranch).RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+
+ unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+2)
+ pr2 = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ HeadRepoID: repo.ID,
+ Index: pr1.Index + 1,
+ Flow: issues_model.PullRequestFlowAGit,
+ })
+ if !assert.NotEmpty(t, pr2) {
+ return
+ }
+ assert.Equal(t, 1, pr2.CommitsAhead)
+ assert.Equal(t, 0, pr2.CommitsBehind)
+ prMsg, err = doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr2.Index)(t)
+ require.NoError(t, err)
+
+ assert.Equal(t, "user2/test/"+headBranch, pr2.HeadBranch)
+ assert.False(t, prMsg.HasMerged)
+ })
+
+ if pr1 == nil || pr2 == nil {
+ return
+ }
+
+ t.Run("AGitLabelIsPresent", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, ctx.Username)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr2.Index))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, "#agit-label", true)
+ })
+
+ t.Run("AddCommit2", func(t *testing.T) {
+ err := os.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content \n ## test content 2"), 0o666)
+ require.NoError(t, err)
+
+ err = git.AddChanges(dstPath, true)
+ require.NoError(t, err)
+
+ err = git.CommitChanges(dstPath, git.CommitChangesOptions{
+ Committer: &git.Signature{
+ Email: "user2@example.com",
+ Name: "user2",
+ When: time.Now(),
+ },
+ Author: &git.Signature{
+ Email: "user2@example.com",
+ Name: "user2",
+ When: time.Now(),
+ },
+ Message: "Testing commit 2\n\nLonger description.",
+ })
+ require.NoError(t, err)
+ commit, err = gitRepo.GetRefCommitID("HEAD")
+ require.NoError(t, err)
+ })
+
+ t.Run("Push2", func(t *testing.T) {
+ err := git.NewCommand(git.DefaultContext, "push", "origin", "HEAD:refs/for/master", "-o").AddDynamicArguments("topic=" + headBranch).Run(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+
+ unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+2)
+ prMsg, err := doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr1.Index)(t)
+ require.NoError(t, err)
+
+ assert.False(t, prMsg.HasMerged)
+ assert.Equal(t, commit, prMsg.Head.Sha)
+
+ pr1 = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ HeadRepoID: repo.ID,
+ Flow: issues_model.PullRequestFlowAGit,
+ Index: pr1.Index,
+ })
+ assert.Equal(t, 2, pr1.CommitsAhead)
+ assert.Equal(t, 0, pr1.CommitsBehind)
+
+ _, _, err = git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments("HEAD:refs/for/master/test/" + headBranch).RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+
+ unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+2)
+ prMsg, err = doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr2.Index)(t)
+ require.NoError(t, err)
+
+ assert.False(t, prMsg.HasMerged)
+ assert.Equal(t, commit, prMsg.Head.Sha)
+ })
+ t.Run("PushParams", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ t.Run("NoParams", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ _, _, gitErr := git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments("HEAD:refs/for/master/" + headBranch + "-implicit").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, gitErr)
+
+ unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+3)
+ pr3 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ HeadRepoID: repo.ID,
+ Flow: issues_model.PullRequestFlowAGit,
+ Index: pr1.Index + 2,
+ })
+ assert.NotEmpty(t, pr3)
+ err := pr3.LoadIssue(db.DefaultContext)
+ require.NoError(t, err)
+
+ _, err2 := doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr3.Index)(t)
+ require.NoError(t, err2)
+
+ assert.Equal(t, "Testing commit 2", pr3.Issue.Title)
+ assert.Contains(t, pr3.Issue.Content, "Longer description.")
+ })
+ t.Run("TitleOverride", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ _, _, gitErr := git.NewCommand(git.DefaultContext, "push", "origin", "-o", "title=my-shiny-title").AddDynamicArguments("HEAD:refs/for/master/" + headBranch + "-implicit-2").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, gitErr)
+
+ unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+4)
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ HeadRepoID: repo.ID,
+ Flow: issues_model.PullRequestFlowAGit,
+ Index: pr1.Index + 3,
+ })
+ assert.NotEmpty(t, pr)
+ err := pr.LoadIssue(db.DefaultContext)
+ require.NoError(t, err)
+
+ _, err = doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr.Index)(t)
+ require.NoError(t, err)
+
+ assert.Equal(t, "my-shiny-title", pr.Issue.Title)
+ assert.Contains(t, pr.Issue.Content, "Longer description.")
+ })
+
+ t.Run("DescriptionOverride", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ _, _, gitErr := git.NewCommand(git.DefaultContext, "push", "origin", "-o", "description=custom").AddDynamicArguments("HEAD:refs/for/master/" + headBranch + "-implicit-3").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, gitErr)
+
+ unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+5)
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ HeadRepoID: repo.ID,
+ Flow: issues_model.PullRequestFlowAGit,
+ Index: pr1.Index + 4,
+ })
+ assert.NotEmpty(t, pr)
+ err := pr.LoadIssue(db.DefaultContext)
+ require.NoError(t, err)
+
+ _, err = doAPIGetPullRequest(*ctx, ctx.Username, ctx.Reponame, pr.Index)(t)
+ require.NoError(t, err)
+
+ assert.Equal(t, "Testing commit 2", pr.Issue.Title)
+ assert.Contains(t, pr.Issue.Content, "custom")
+ })
+ })
+
+ upstreamGitRepo, err := git.OpenRepository(git.DefaultContext, filepath.Join(setting.RepoRootPath, ctx.Username, ctx.Reponame+".git"))
+ require.NoError(t, err)
+ defer upstreamGitRepo.Close()
+
+ t.Run("Force push", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ _, _, gitErr := git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments("HEAD:refs/for/master/" + headBranch + "-force-push").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, gitErr)
+
+ unittest.AssertCount(t, &issues_model.PullRequest{}, pullNum+6)
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ HeadRepoID: repo.ID,
+ Flow: issues_model.PullRequestFlowAGit,
+ Index: pr1.Index + 5,
+ })
+
+ headCommitID, err := upstreamGitRepo.GetRefCommitID(pr.GetGitRefName())
+ require.NoError(t, err)
+
+ _, _, gitErr = git.NewCommand(git.DefaultContext, "reset", "--hard", "HEAD~1").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, gitErr)
+
+ t.Run("Fails", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ _, stdErr, gitErr := git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments("HEAD:refs/for/master/" + headBranch + "-force-push").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.Error(t, gitErr)
+
+ assert.Contains(t, stdErr, "-o force-push=true")
+
+ currentHeadCommitID, err := upstreamGitRepo.GetRefCommitID(pr.GetGitRefName())
+ require.NoError(t, err)
+ assert.EqualValues(t, headCommitID, currentHeadCommitID)
+ })
+ t.Run("Succeeds", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ _, _, gitErr := git.NewCommand(git.DefaultContext, "push", "origin", "-o", "force-push").AddDynamicArguments("HEAD:refs/for/master/" + headBranch + "-force-push").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, gitErr)
+
+ currentHeadCommitID, err := upstreamGitRepo.GetRefCommitID(pr.GetGitRefName())
+ require.NoError(t, err)
+ assert.NotEqualValues(t, headCommitID, currentHeadCommitID)
+ })
+ })
+
+ t.Run("Branch already contains commit", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ branchCommit, err := upstreamGitRepo.GetBranchCommit("master")
+ require.NoError(t, err)
+
+ _, _, gitErr := git.NewCommand(git.DefaultContext, "reset", "--hard").AddDynamicArguments(branchCommit.ID.String() + "~1").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, gitErr)
+
+ _, stdErr, gitErr := git.NewCommand(git.DefaultContext, "push", "origin").AddDynamicArguments("HEAD:refs/for/master/" + headBranch + "-already-contains").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.Error(t, gitErr)
+
+ assert.Contains(t, stdErr, "already contains this commit")
+ })
+
+ t.Run("Merge", doAPIMergePullRequest(*ctx, ctx.Username, ctx.Reponame, pr1.Index))
+
+ t.Run("AGitLabelIsPresent Merged", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, ctx.Username)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/pulls/%d", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame), pr2.Index))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, "#agit-label", true)
+ })
+
+ t.Run("CheckoutMasterAgain", doGitCheckoutBranch(dstPath, "master"))
+ }
+}
+
+func TestDataAsync_Issue29101(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ resp, err := files_service.ChangeRepoFiles(db.DefaultContext, repo, user, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "test.txt",
+ ContentReader: bytes.NewReader(make([]byte, 10000)),
+ },
+ },
+ OldBranch: repo.DefaultBranch,
+ NewBranch: repo.DefaultBranch,
+ })
+ require.NoError(t, err)
+
+ sha := resp.Commit.SHA
+
+ gitRepo, err := gitrepo.OpenRepository(db.DefaultContext, repo)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ commit, err := gitRepo.GetCommit(sha)
+ require.NoError(t, err)
+
+ entry, err := commit.GetTreeEntryByPath("test.txt")
+ require.NoError(t, err)
+
+ b := entry.Blob()
+
+ r, err := b.DataAsync()
+ require.NoError(t, err)
+ defer r.Close()
+
+ r2, err := b.DataAsync()
+ require.NoError(t, err)
+ defer r2.Close()
+ })
+}
diff --git a/tests/integration/goget_test.go b/tests/integration/goget_test.go
new file mode 100644
index 0000000..854f8d7
--- /dev/null
+++ b/tests/integration/goget_test.go
@@ -0,0 +1,61 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGoGet(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/blah/glah/plah?go-get=1")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ expected := fmt.Sprintf(`<!doctype html>
+<html>
+ <head>
+ <meta name="go-import" content="%[1]s:%[2]s/blah/glah git %[3]sblah/glah.git">
+ <meta name="go-source" content="%[1]s:%[2]s/blah/glah _ %[3]sblah/glah/src/branch/master{/dir} %[3]sblah/glah/src/branch/master{/dir}/{file}#L{line}">
+ </head>
+ <body>
+ go get --insecure %[1]s:%[2]s/blah/glah
+ </body>
+</html>`, setting.Domain, setting.HTTPPort, setting.AppURL)
+
+ assert.Equal(t, expected, resp.Body.String())
+}
+
+func TestGoGetForSSH(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ old := setting.Repository.GoGetCloneURLProtocol
+ defer func() {
+ setting.Repository.GoGetCloneURLProtocol = old
+ }()
+ setting.Repository.GoGetCloneURLProtocol = "ssh"
+
+ req := NewRequest(t, "GET", "/blah/glah/plah?go-get=1")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ expected := fmt.Sprintf(`<!doctype html>
+<html>
+ <head>
+ <meta name="go-import" content="%[1]s:%[2]s/blah/glah git ssh://git@%[4]s:%[5]d/blah/glah.git">
+ <meta name="go-source" content="%[1]s:%[2]s/blah/glah _ %[3]sblah/glah/src/branch/master{/dir} %[3]sblah/glah/src/branch/master{/dir}/{file}#L{line}">
+ </head>
+ <body>
+ go get --insecure %[1]s:%[2]s/blah/glah
+ </body>
+</html>`, setting.Domain, setting.HTTPPort, setting.AppURL, setting.SSH.Domain, setting.SSH.Port)
+
+ assert.Equal(t, expected, resp.Body.String())
+}
diff --git a/tests/integration/gpg_git_test.go b/tests/integration/gpg_git_test.go
new file mode 100644
index 0000000..5302997
--- /dev/null
+++ b/tests/integration/gpg_git_test.go
@@ -0,0 +1,304 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "encoding/base64"
+ "fmt"
+ "net/url"
+ "os"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/process"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/ProtonMail/go-crypto/openpgp"
+ "github.com/ProtonMail/go-crypto/openpgp/armor"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGPGGit(t *testing.T) {
+ tmpDir := t.TempDir() // use a temp dir to avoid messing with the user's GPG keyring
+ err := os.Chmod(tmpDir, 0o700)
+ require.NoError(t, err)
+
+ t.Setenv("GNUPGHOME", tmpDir)
+ require.NoError(t, err)
+
+ // Need to create a root key
+ rootKeyPair, err := importTestingKey()
+ require.NoError(t, err, "importTestingKey")
+
+ defer test.MockVariableValue(&setting.Repository.Signing.SigningKey, rootKeyPair.PrimaryKey.KeyIdShortString())()
+ defer test.MockVariableValue(&setting.Repository.Signing.SigningName, "gitea")()
+ defer test.MockVariableValue(&setting.Repository.Signing.SigningEmail, "gitea@fake.local")()
+ defer test.MockVariableValue(&setting.Repository.Signing.InitialCommit, []string{"never"})()
+ defer test.MockVariableValue(&setting.Repository.Signing.CRUDActions, []string{"never"})()
+
+ username := "user2"
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: username})
+ baseAPITestContext := NewAPITestContext(t, username, "repo1")
+
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ u.Path = baseAPITestContext.GitPath()
+
+ t.Run("Unsigned-Initial", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ t.Run("CreateRepository", doAPICreateRepository(testCtx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat
+ t.Run("CheckMasterBranchUnsigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) {
+ assert.NotNil(t, branch.Commit)
+ assert.NotNil(t, branch.Commit.Verification)
+ assert.False(t, branch.Commit.Verification.Verified)
+ assert.Empty(t, branch.Commit.Verification.Signature)
+ }))
+ t.Run("CreateCRUDFile-Never", crudActionCreateFile(
+ t, testCtx, user, "master", "never", "unsigned-never.txt", func(t *testing.T, response api.FileResponse) {
+ assert.False(t, response.Verification.Verified)
+ }))
+ t.Run("CreateCRUDFile-Never", crudActionCreateFile(
+ t, testCtx, user, "never", "never2", "unsigned-never2.txt", func(t *testing.T, response api.FileResponse) {
+ assert.False(t, response.Verification.Verified)
+ }))
+ })
+
+ setting.Repository.Signing.CRUDActions = []string{"parentsigned"}
+ t.Run("Unsigned-Initial-CRUD-ParentSigned", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ t.Run("CreateCRUDFile-ParentSigned", crudActionCreateFile(
+ t, testCtx, user, "master", "parentsigned", "signed-parent.txt", func(t *testing.T, response api.FileResponse) {
+ assert.False(t, response.Verification.Verified)
+ }))
+ t.Run("CreateCRUDFile-ParentSigned", crudActionCreateFile(
+ t, testCtx, user, "parentsigned", "parentsigned2", "signed-parent2.txt", func(t *testing.T, response api.FileResponse) {
+ assert.False(t, response.Verification.Verified)
+ }))
+ })
+
+ setting.Repository.Signing.CRUDActions = []string{"never"}
+ t.Run("Unsigned-Initial-CRUD-Never", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ t.Run("CreateCRUDFile-Never", crudActionCreateFile(
+ t, testCtx, user, "parentsigned", "parentsigned-never", "unsigned-never2.txt", func(t *testing.T, response api.FileResponse) {
+ assert.False(t, response.Verification.Verified)
+ }))
+ })
+
+ setting.Repository.Signing.CRUDActions = []string{"always"}
+ t.Run("Unsigned-Initial-CRUD-Always", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ t.Run("CreateCRUDFile-Always", crudActionCreateFile(
+ t, testCtx, user, "master", "always", "signed-always.txt", func(t *testing.T, response api.FileResponse) {
+ assert.NotNil(t, response.Verification)
+ if response.Verification == nil {
+ assert.FailNow(t, "no verification provided with response! %v", response)
+ }
+ assert.True(t, response.Verification.Verified)
+ if !response.Verification.Verified {
+ t.FailNow()
+ }
+ assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email)
+ }))
+ t.Run("CreateCRUDFile-ParentSigned-always", crudActionCreateFile(
+ t, testCtx, user, "parentsigned", "parentsigned-always", "signed-parent2.txt", func(t *testing.T, response api.FileResponse) {
+ assert.NotNil(t, response.Verification)
+ if response.Verification == nil {
+ assert.FailNow(t, "no verification provided with response! %v", response)
+ }
+ assert.True(t, response.Verification.Verified)
+ if !response.Verification.Verified {
+ t.FailNow()
+ }
+ assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email)
+ }))
+ })
+
+ setting.Repository.Signing.CRUDActions = []string{"parentsigned"}
+ t.Run("Unsigned-Initial-CRUD-ParentSigned", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ t.Run("CreateCRUDFile-Always-ParentSigned", crudActionCreateFile(
+ t, testCtx, user, "always", "always-parentsigned", "signed-always-parentsigned.txt", func(t *testing.T, response api.FileResponse) {
+ assert.NotNil(t, response.Verification)
+ if response.Verification == nil {
+ assert.FailNow(t, "no verification provided with response! %v", response)
+ }
+ assert.True(t, response.Verification.Verified)
+ if !response.Verification.Verified {
+ t.FailNow()
+ }
+ assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email)
+ }))
+ })
+
+ setting.Repository.Signing.InitialCommit = []string{"always"}
+ t.Run("AlwaysSign-Initial", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCtx := NewAPITestContext(t, username, "initial-always", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ t.Run("CreateRepository", doAPICreateRepository(testCtx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat
+ t.Run("CheckMasterBranchSigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) {
+ assert.NotNil(t, branch.Commit)
+ if branch.Commit == nil {
+ assert.FailNow(t, "no commit provided with branch! %v", branch)
+ }
+ assert.NotNil(t, branch.Commit.Verification)
+ if branch.Commit.Verification == nil {
+ assert.FailNow(t, "no verification provided with branch commit! %v", branch.Commit)
+ }
+ assert.True(t, branch.Commit.Verification.Verified)
+ if !branch.Commit.Verification.Verified {
+ t.FailNow()
+ }
+ assert.Equal(t, "gitea@fake.local", branch.Commit.Verification.Signer.Email)
+ }))
+ })
+
+ setting.Repository.Signing.CRUDActions = []string{"never"}
+ t.Run("AlwaysSign-Initial-CRUD-Never", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCtx := NewAPITestContext(t, username, "initial-always-never", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ t.Run("CreateRepository", doAPICreateRepository(testCtx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat
+ t.Run("CreateCRUDFile-Never", crudActionCreateFile(
+ t, testCtx, user, "master", "never", "unsigned-never.txt", func(t *testing.T, response api.FileResponse) {
+ assert.False(t, response.Verification.Verified)
+ }))
+ })
+
+ setting.Repository.Signing.CRUDActions = []string{"parentsigned"}
+ t.Run("AlwaysSign-Initial-CRUD-ParentSigned-On-Always", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCtx := NewAPITestContext(t, username, "initial-always-parent", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ t.Run("CreateRepository", doAPICreateRepository(testCtx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat
+ t.Run("CreateCRUDFile-ParentSigned", crudActionCreateFile(
+ t, testCtx, user, "master", "parentsigned", "signed-parent.txt", func(t *testing.T, response api.FileResponse) {
+ assert.True(t, response.Verification.Verified)
+ if !response.Verification.Verified {
+ t.FailNow()
+ return
+ }
+ assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email)
+ }))
+ })
+
+ setting.Repository.Signing.CRUDActions = []string{"always"}
+ t.Run("AlwaysSign-Initial-CRUD-Always", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCtx := NewAPITestContext(t, username, "initial-always-always", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ t.Run("CreateRepository", doAPICreateRepository(testCtx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat
+ t.Run("CreateCRUDFile-Always", crudActionCreateFile(
+ t, testCtx, user, "master", "always", "signed-always.txt", func(t *testing.T, response api.FileResponse) {
+ assert.True(t, response.Verification.Verified)
+ if !response.Verification.Verified {
+ t.FailNow()
+ return
+ }
+ assert.Equal(t, "gitea@fake.local", response.Verification.Signer.Email)
+ }))
+ })
+
+ setting.Repository.Signing.Merges = []string{"commitssigned"}
+ t.Run("UnsignedMerging", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ t.Run("CreatePullRequest", func(t *testing.T) {
+ pr, err := doAPICreatePullRequest(testCtx, testCtx.Username, testCtx.Reponame, "master", "never2")(t)
+ require.NoError(t, err)
+ t.Run("MergePR", doAPIMergePullRequest(testCtx, testCtx.Username, testCtx.Reponame, pr.Index))
+ })
+ t.Run("CheckMasterBranchUnsigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) {
+ assert.NotNil(t, branch.Commit)
+ assert.NotNil(t, branch.Commit.Verification)
+ assert.False(t, branch.Commit.Verification.Verified)
+ assert.Empty(t, branch.Commit.Verification.Signature)
+ }))
+ })
+
+ setting.Repository.Signing.Merges = []string{"basesigned"}
+ t.Run("BaseSignedMerging", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ t.Run("CreatePullRequest", func(t *testing.T) {
+ pr, err := doAPICreatePullRequest(testCtx, testCtx.Username, testCtx.Reponame, "master", "parentsigned2")(t)
+ require.NoError(t, err)
+ t.Run("MergePR", doAPIMergePullRequest(testCtx, testCtx.Username, testCtx.Reponame, pr.Index))
+ })
+ t.Run("CheckMasterBranchUnsigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) {
+ assert.NotNil(t, branch.Commit)
+ assert.NotNil(t, branch.Commit.Verification)
+ assert.False(t, branch.Commit.Verification.Verified)
+ assert.Empty(t, branch.Commit.Verification.Signature)
+ }))
+ })
+
+ setting.Repository.Signing.Merges = []string{"commitssigned"}
+ t.Run("CommitsSignedMerging", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCtx := NewAPITestContext(t, username, "initial-unsigned", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ t.Run("CreatePullRequest", func(t *testing.T) {
+ pr, err := doAPICreatePullRequest(testCtx, testCtx.Username, testCtx.Reponame, "master", "always-parentsigned")(t)
+ require.NoError(t, err)
+ t.Run("MergePR", doAPIMergePullRequest(testCtx, testCtx.Username, testCtx.Reponame, pr.Index))
+ })
+ t.Run("CheckMasterBranchUnsigned", doAPIGetBranch(testCtx, "master", func(t *testing.T, branch api.Branch) {
+ assert.NotNil(t, branch.Commit)
+ assert.NotNil(t, branch.Commit.Verification)
+ assert.True(t, branch.Commit.Verification.Verified)
+ }))
+ })
+ })
+}
+
+func crudActionCreateFile(_ *testing.T, ctx APITestContext, user *user_model.User, from, to, path string, callback ...func(*testing.T, api.FileResponse)) func(*testing.T) {
+ return doAPICreateFile(ctx, path, &api.CreateFileOptions{
+ FileOptions: api.FileOptions{
+ BranchName: from,
+ NewBranchName: to,
+ Message: fmt.Sprintf("from:%s to:%s path:%s", from, to, path),
+ Author: api.Identity{
+ Name: user.FullName,
+ Email: user.Email,
+ },
+ Committer: api.Identity{
+ Name: user.FullName,
+ Email: user.Email,
+ },
+ },
+ ContentBase64: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("This is new text for %s", path))),
+ }, callback...)
+}
+
+func importTestingKey() (*openpgp.Entity, error) {
+ if _, _, err := process.GetManager().Exec("gpg --import tests/integration/private-testing.key", "gpg", "--import", "tests/integration/private-testing.key"); err != nil {
+ return nil, err
+ }
+ keyringFile, err := os.Open("tests/integration/private-testing.key")
+ if err != nil {
+ return nil, err
+ }
+ defer keyringFile.Close()
+
+ block, err := armor.Decode(keyringFile)
+ if err != nil {
+ return nil, err
+ }
+
+ keyring, err := openpgp.ReadKeyRing(block.Body)
+ if err != nil {
+ return nil, fmt.Errorf("Keyring access failed: '%w'", err)
+ }
+
+ // There should only be one entity in this file.
+ return keyring[0], nil
+}
diff --git a/tests/integration/html_helper.go b/tests/integration/html_helper.go
new file mode 100644
index 0000000..802dcb0
--- /dev/null
+++ b/tests/integration/html_helper.go
@@ -0,0 +1,92 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "fmt"
+ "testing"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// HTMLDoc struct
+type HTMLDoc struct {
+ doc *goquery.Document
+}
+
+// NewHTMLParser parse html file
+func NewHTMLParser(t testing.TB, body *bytes.Buffer) *HTMLDoc {
+ t.Helper()
+ doc, err := goquery.NewDocumentFromReader(body)
+ require.NoError(t, err)
+ return &HTMLDoc{doc: doc}
+}
+
+// GetInputValueByID for get input value by id
+func (doc *HTMLDoc) GetInputValueByID(id string) string {
+ text, _ := doc.doc.Find("#" + id).Attr("value")
+ return text
+}
+
+// GetInputValueByName for get input value by name
+func (doc *HTMLDoc) GetInputValueByName(name string) string {
+ text, _ := doc.doc.Find("input[name=\"" + name + "\"]").Attr("value")
+ return text
+}
+
+func (doc *HTMLDoc) AssertDropdown(t testing.TB, name string) *goquery.Selection {
+ t.Helper()
+
+ dropdownGroup := doc.Find(fmt.Sprintf(".dropdown:has(input[name='%s'])", name))
+ assert.Equal(t, 1, dropdownGroup.Length(), "%s dropdown does not exist", name)
+ return dropdownGroup
+}
+
+// Assert that a dropdown has at least one non-empty option
+func (doc *HTMLDoc) AssertDropdownHasOptions(t testing.TB, dropdownName string) {
+ t.Helper()
+
+ options := doc.AssertDropdown(t, dropdownName).Find(".menu [data-value]:not([data-value=''])")
+ assert.Positive(t, options.Length(), 0, fmt.Sprintf("%s dropdown has no options", dropdownName))
+}
+
+func (doc *HTMLDoc) AssertDropdownHasSelectedOption(t testing.TB, dropdownName, expectedValue string) {
+ t.Helper()
+
+ dropdownGroup := doc.AssertDropdown(t, dropdownName)
+
+ selectedValue, _ := dropdownGroup.Find(fmt.Sprintf("input[name='%s']", dropdownName)).Attr("value")
+ assert.Equal(t, expectedValue, selectedValue, "%s dropdown doesn't have expected value selected", dropdownName)
+
+ dropdownValues := dropdownGroup.Find(".menu [data-value]").Map(func(i int, s *goquery.Selection) string {
+ value, _ := s.Attr("data-value")
+ return value
+ })
+ assert.Contains(t, dropdownValues, expectedValue, "%s dropdown doesn't have an option with expected value", dropdownName)
+}
+
+// Find gets the descendants of each element in the current set of
+// matched elements, filtered by a selector. It returns a new Selection
+// object containing these matched elements.
+func (doc *HTMLDoc) Find(selector string) *goquery.Selection {
+ return doc.doc.Find(selector)
+}
+
+// GetCSRF for getting CSRF token value from input
+func (doc *HTMLDoc) GetCSRF() string {
+ return doc.GetInputValueByName("_csrf")
+}
+
+// AssertElement check if element by selector exists or does not exist depending on checkExists
+func (doc *HTMLDoc) AssertElement(t testing.TB, selector string, checkExists bool) {
+ sel := doc.doc.Find(selector)
+ if checkExists {
+ assert.Equal(t, 1, sel.Length())
+ } else {
+ assert.Equal(t, 0, sel.Length())
+ }
+}
diff --git a/tests/integration/incoming_email_test.go b/tests/integration/incoming_email_test.go
new file mode 100644
index 0000000..fdc0425
--- /dev/null
+++ b/tests/integration/incoming_email_test.go
@@ -0,0 +1,244 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "io"
+ "net"
+ "net/smtp"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/services/mailer/incoming"
+ incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload"
+ token_service "code.gitea.io/gitea/services/mailer/token"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gopkg.in/gomail.v2"
+)
+
+func TestIncomingEmail(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+
+ t.Run("Payload", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1})
+
+ _, err := incoming_payload.CreateReferencePayload(user)
+ require.Error(t, err)
+
+ issuePayload, err := incoming_payload.CreateReferencePayload(issue)
+ require.NoError(t, err)
+ commentPayload, err := incoming_payload.CreateReferencePayload(comment)
+ require.NoError(t, err)
+
+ _, err = incoming_payload.GetReferenceFromPayload(db.DefaultContext, []byte{1, 2, 3})
+ require.Error(t, err)
+
+ ref, err := incoming_payload.GetReferenceFromPayload(db.DefaultContext, issuePayload)
+ require.NoError(t, err)
+ assert.IsType(t, ref, new(issues_model.Issue))
+ assert.EqualValues(t, issue.ID, ref.(*issues_model.Issue).ID)
+
+ ref, err = incoming_payload.GetReferenceFromPayload(db.DefaultContext, commentPayload)
+ require.NoError(t, err)
+ assert.IsType(t, ref, new(issues_model.Comment))
+ assert.EqualValues(t, comment.ID, ref.(*issues_model.Comment).ID)
+ })
+
+ t.Run("Token", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ payload := []byte{1, 2, 3, 4, 5}
+
+ token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload)
+ require.NoError(t, err)
+ assert.NotEmpty(t, token)
+
+ ht, u, p, err := token_service.ExtractToken(db.DefaultContext, token)
+ require.NoError(t, err)
+ assert.Equal(t, token_service.ReplyHandlerType, ht)
+ assert.Equal(t, user.ID, u.ID)
+ assert.Equal(t, payload, p)
+ })
+
+ t.Run("Handler", func(t *testing.T) {
+ t.Run("Reply", func(t *testing.T) {
+ checkReply := func(t *testing.T, payload []byte, issue *issues_model.Issue, commentType issues_model.CommentType) {
+ t.Helper()
+
+ handler := &incoming.ReplyHandler{}
+
+ require.Error(t, handler.Handle(db.DefaultContext, &incoming.MailContent{}, nil, payload))
+ require.NoError(t, handler.Handle(db.DefaultContext, &incoming.MailContent{}, user, payload))
+
+ content := &incoming.MailContent{
+ Content: "reply by mail",
+ Attachments: []*incoming.Attachment{
+ {
+ Name: "attachment.txt",
+ Content: []byte("test"),
+ },
+ },
+ }
+
+ require.NoError(t, handler.Handle(db.DefaultContext, content, user, payload))
+
+ comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{
+ IssueID: issue.ID,
+ Type: commentType,
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, comments)
+ comment := comments[len(comments)-1]
+ assert.Equal(t, user.ID, comment.PosterID)
+ assert.Equal(t, content.Content, comment.Content)
+ require.NoError(t, comment.LoadAttachments(db.DefaultContext))
+ assert.Len(t, comment.Attachments, 1)
+ attachment := comment.Attachments[0]
+ assert.Equal(t, content.Attachments[0].Name, attachment.Name)
+ assert.EqualValues(t, 4, attachment.Size)
+ }
+ t.Run("Issue", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ payload, err := incoming_payload.CreateReferencePayload(issue)
+ require.NoError(t, err)
+
+ checkReply(t, payload, issue, issues_model.CommentTypeComment)
+ })
+
+ t.Run("CodeComment", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 6})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+
+ payload, err := incoming_payload.CreateReferencePayload(comment)
+ require.NoError(t, err)
+
+ checkReply(t, payload, issue, issues_model.CommentTypeCode)
+ })
+
+ t.Run("Comment", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID})
+
+ payload, err := incoming_payload.CreateReferencePayload(comment)
+ require.NoError(t, err)
+
+ checkReply(t, payload, issue, issues_model.CommentTypeComment)
+ })
+ })
+
+ t.Run("Unsubscribe", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ watching, err := issues_model.CheckIssueWatch(db.DefaultContext, user, issue)
+ require.NoError(t, err)
+ assert.True(t, watching)
+
+ handler := &incoming.UnsubscribeHandler{}
+
+ content := &incoming.MailContent{
+ Content: "unsub me",
+ }
+
+ payload, err := incoming_payload.CreateReferencePayload(issue)
+ require.NoError(t, err)
+
+ require.NoError(t, handler.Handle(db.DefaultContext, content, user, payload))
+
+ watching, err = issues_model.CheckIssueWatch(db.DefaultContext, user, issue)
+ require.NoError(t, err)
+ assert.False(t, watching)
+ })
+ })
+
+ if setting.IncomingEmail.Enabled {
+ // This test connects to the configured email server and is currently only enabled for MySql integration tests.
+ // It sends a reply to create a comment. If the comment is not detected after 10 seconds the test fails.
+ t.Run("IMAP", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ payload, err := incoming_payload.CreateReferencePayload(issue)
+ require.NoError(t, err)
+ token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload)
+ require.NoError(t, err)
+
+ msg := gomail.NewMessage()
+ msg.SetHeader("To", strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1))
+ msg.SetHeader("From", user.Email)
+ msg.SetBody("text/plain", token)
+ err = gomail.Send(&smtpTestSender{}, msg)
+ require.NoError(t, err)
+
+ assert.Eventually(t, func() bool {
+ comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{
+ IssueID: issue.ID,
+ Type: issues_model.CommentTypeComment,
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, comments)
+
+ comment := comments[len(comments)-1]
+
+ return comment.PosterID == user.ID && comment.Content == token
+ }, 10*time.Second, 1*time.Second)
+ })
+ }
+}
+
+// A simple SMTP mail sender used for integration tests.
+type smtpTestSender struct{}
+
+func (s *smtpTestSender) Send(from string, to []string, msg io.WriterTo) error {
+ conn, err := net.Dial("tcp", net.JoinHostPort(setting.IncomingEmail.Host, "25"))
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+
+ client, err := smtp.NewClient(conn, setting.IncomingEmail.Host)
+ if err != nil {
+ return err
+ }
+
+ if err = client.Mail(from); err != nil {
+ return err
+ }
+
+ for _, rec := range to {
+ if err = client.Rcpt(rec); err != nil {
+ return err
+ }
+ }
+
+ w, err := client.Data()
+ if err != nil {
+ return err
+ }
+ if _, err := msg.WriteTo(w); err != nil {
+ return err
+ }
+ if err := w.Close(); err != nil {
+ return err
+ }
+
+ return client.Quit()
+}
diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go
new file mode 100644
index 0000000..e43200f
--- /dev/null
+++ b/tests/integration/integration_test.go
@@ -0,0 +1,687 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//nolint:forbidigo
+package integration
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "hash"
+ "hash/fnv"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/cmd"
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/testlogger"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/modules/web"
+ "code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/services/auth/source/remote"
+ gitea_context "code.gitea.io/gitea/services/context"
+ user_service "code.gitea.io/gitea/services/user"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/markbates/goth"
+ "github.com/markbates/goth/gothic"
+ goth_github "github.com/markbates/goth/providers/github"
+ goth_gitlab "github.com/markbates/goth/providers/gitlab"
+ "github.com/santhosh-tekuri/jsonschema/v6"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var testWebRoutes *web.Route
+
+type NilResponseRecorder struct {
+ httptest.ResponseRecorder
+ Length int
+}
+
+func (n *NilResponseRecorder) Write(b []byte) (int, error) {
+ n.Length += len(b)
+ return len(b), nil
+}
+
+// NewRecorder returns an initialized ResponseRecorder.
+func NewNilResponseRecorder() *NilResponseRecorder {
+ return &NilResponseRecorder{
+ ResponseRecorder: *httptest.NewRecorder(),
+ }
+}
+
+type NilResponseHashSumRecorder struct {
+ httptest.ResponseRecorder
+ Hash hash.Hash
+ Length int
+}
+
+func (n *NilResponseHashSumRecorder) Write(b []byte) (int, error) {
+ _, _ = n.Hash.Write(b)
+ n.Length += len(b)
+ return len(b), nil
+}
+
+// NewRecorder returns an initialized ResponseRecorder.
+func NewNilResponseHashSumRecorder() *NilResponseHashSumRecorder {
+ return &NilResponseHashSumRecorder{
+ Hash: fnv.New32(),
+ ResponseRecorder: *httptest.NewRecorder(),
+ }
+}
+
+// runMainApp runs the subcommand and returns its standard output. Any returned error will usually be of type *ExitError. If c.Stderr was nil, Output populates ExitError.Stderr.
+func runMainApp(subcommand string, args ...string) (string, error) {
+ return runMainAppWithStdin(nil, subcommand, args...)
+}
+
+// runMainAppWithStdin runs the subcommand and returns its standard output. Any returned error will usually be of type *ExitError. If c.Stderr was nil, Output populates ExitError.Stderr.
+func runMainAppWithStdin(stdin io.Reader, subcommand string, args ...string) (string, error) {
+ // running the main app directly will very likely mess with the testing setup (logger & co.)
+ // hence we run it as a subprocess and capture its output
+ args = append([]string{subcommand}, args...)
+ cmd := exec.Command(os.Args[0], args...)
+ cmd.Env = append(os.Environ(),
+ "GITEA_TEST_CLI=true",
+ "GITEA_CONF="+setting.CustomConf,
+ "GITEA_WORK_DIR="+setting.AppWorkPath)
+ cmd.Stdin = stdin
+ out, err := cmd.Output()
+ return string(out), err
+}
+
+func TestMain(m *testing.M) {
+ // GITEA_TEST_CLI is set by runMainAppWithStdin
+ // inspired by https://abhinavg.net/2022/05/15/hijack-testmain/
+ if testCLI := os.Getenv("GITEA_TEST_CLI"); testCLI == "true" {
+ app := cmd.NewMainApp("test-version", "integration-test")
+ args := append([]string{
+ "executable-name", // unused, but expected at position 1
+ "--config", os.Getenv("GITEA_CONF"),
+ },
+ os.Args[1:]..., // skip the executable name
+ )
+ if err := cmd.RunMainApp(app, args...); err != nil {
+ panic(err) // should never happen since RunMainApp exits on error
+ }
+ return
+ }
+
+ defer log.GetManager().Close()
+
+ managerCtx, cancel := context.WithCancel(context.Background())
+ graceful.InitManager(managerCtx)
+ defer cancel()
+
+ tests.InitTest(true)
+ testWebRoutes = routers.NormalRoutes()
+
+ // integration test settings...
+ if setting.CfgProvider != nil {
+ testingCfg := setting.CfgProvider.Section("integration-tests")
+ testlogger.SlowTest = testingCfg.Key("SLOW_TEST").MustDuration(testlogger.SlowTest)
+ testlogger.SlowFlush = testingCfg.Key("SLOW_FLUSH").MustDuration(testlogger.SlowFlush)
+ }
+
+ if os.Getenv("GITEA_SLOW_TEST_TIME") != "" {
+ duration, err := time.ParseDuration(os.Getenv("GITEA_SLOW_TEST_TIME"))
+ if err == nil {
+ testlogger.SlowTest = duration
+ }
+ }
+
+ if os.Getenv("GITEA_SLOW_FLUSH_TIME") != "" {
+ duration, err := time.ParseDuration(os.Getenv("GITEA_SLOW_FLUSH_TIME"))
+ if err == nil {
+ testlogger.SlowFlush = duration
+ }
+ }
+
+ os.Unsetenv("GIT_AUTHOR_NAME")
+ os.Unsetenv("GIT_AUTHOR_EMAIL")
+ os.Unsetenv("GIT_AUTHOR_DATE")
+ os.Unsetenv("GIT_COMMITTER_NAME")
+ os.Unsetenv("GIT_COMMITTER_EMAIL")
+ os.Unsetenv("GIT_COMMITTER_DATE")
+
+ err := unittest.InitFixtures(
+ unittest.FixturesOptions{
+ Dir: filepath.Join(filepath.Dir(setting.AppPath), "models/fixtures/"),
+ },
+ )
+ if err != nil {
+ fmt.Printf("Error initializing test database: %v\n", err)
+ os.Exit(1)
+ }
+
+ // FIXME: the console logger is deleted by mistake, so if there is any `log.Fatal`, developers won't see any error message.
+ // Instead, "No tests were found", last nonsense log is "According to the configuration, subsequent logs will not be printed to the console"
+ exitCode := m.Run()
+
+ if err := testlogger.WriterCloser.Reset(); err != nil {
+ fmt.Printf("testlogger.WriterCloser.Reset: error ignored: %v\n", err)
+ }
+
+ if err = util.RemoveAll(setting.Indexer.IssuePath); err != nil {
+ fmt.Printf("util.RemoveAll: %v\n", err)
+ os.Exit(1)
+ }
+ if err = util.RemoveAll(setting.Indexer.RepoPath); err != nil {
+ fmt.Printf("Unable to remove repo indexer: %v\n", err)
+ os.Exit(1)
+ }
+
+ os.Exit(exitCode)
+}
+
+type TestSession struct {
+ jar http.CookieJar
+}
+
+func (s *TestSession) GetCookie(name string) *http.Cookie {
+ baseURL, err := url.Parse(setting.AppURL)
+ if err != nil {
+ return nil
+ }
+
+ for _, c := range s.jar.Cookies(baseURL) {
+ if c.Name == name {
+ return c
+ }
+ }
+ return nil
+}
+
+func (s *TestSession) SetCookie(cookie *http.Cookie) *http.Cookie {
+ baseURL, err := url.Parse(setting.AppURL)
+ if err != nil {
+ return nil
+ }
+
+ s.jar.SetCookies(baseURL, []*http.Cookie{cookie})
+ return nil
+}
+
+func (s *TestSession) MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest.ResponseRecorder {
+ t.Helper()
+ req := rw.Request
+ baseURL, err := url.Parse(setting.AppURL)
+ require.NoError(t, err)
+ for _, c := range s.jar.Cookies(baseURL) {
+ req.AddCookie(c)
+ }
+ resp := MakeRequest(t, rw, expectedStatus)
+
+ ch := http.Header{}
+ ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";"))
+ cr := http.Request{Header: ch}
+ s.jar.SetCookies(baseURL, cr.Cookies())
+
+ return resp
+}
+
+func (s *TestSession) MakeRequestNilResponseRecorder(t testing.TB, rw *RequestWrapper, expectedStatus int) *NilResponseRecorder {
+ t.Helper()
+ req := rw.Request
+ baseURL, err := url.Parse(setting.AppURL)
+ require.NoError(t, err)
+ for _, c := range s.jar.Cookies(baseURL) {
+ req.AddCookie(c)
+ }
+ resp := MakeRequestNilResponseRecorder(t, rw, expectedStatus)
+
+ ch := http.Header{}
+ ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";"))
+ cr := http.Request{Header: ch}
+ s.jar.SetCookies(baseURL, cr.Cookies())
+
+ return resp
+}
+
+func (s *TestSession) MakeRequestNilResponseHashSumRecorder(t testing.TB, rw *RequestWrapper, expectedStatus int) *NilResponseHashSumRecorder {
+ t.Helper()
+ req := rw.Request
+ baseURL, err := url.Parse(setting.AppURL)
+ require.NoError(t, err)
+ for _, c := range s.jar.Cookies(baseURL) {
+ req.AddCookie(c)
+ }
+ resp := MakeRequestNilResponseHashSumRecorder(t, rw, expectedStatus)
+
+ ch := http.Header{}
+ ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";"))
+ cr := http.Request{Header: ch}
+ s.jar.SetCookies(baseURL, cr.Cookies())
+
+ return resp
+}
+
+const userPassword = "password"
+
+func emptyTestSession(t testing.TB) *TestSession {
+ t.Helper()
+ jar, err := cookiejar.New(nil)
+ require.NoError(t, err)
+
+ return &TestSession{jar: jar}
+}
+
+func getUserToken(t testing.TB, userName string, scope ...auth.AccessTokenScope) string {
+ return getTokenForLoggedInUser(t, loginUser(t, userName), scope...)
+}
+
+func mockCompleteUserAuth(mock func(res http.ResponseWriter, req *http.Request) (goth.User, error)) func() {
+ old := gothic.CompleteUserAuth
+ gothic.CompleteUserAuth = mock
+ return func() {
+ gothic.CompleteUserAuth = old
+ }
+}
+
+func addAuthSource(t *testing.T, payload map[string]string) *auth.Source {
+ session := loginUser(t, "user1")
+ payload["_csrf"] = GetCSRF(t, session, "/admin/auths/new")
+ req := NewRequestWithValues(t, "POST", "/admin/auths/new", payload)
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ source, err := auth.GetSourceByName(context.Background(), payload["name"])
+ require.NoError(t, err)
+ return source
+}
+
+func authSourcePayloadOAuth2(name string) map[string]string {
+ return map[string]string{
+ "type": fmt.Sprintf("%d", auth.OAuth2),
+ "name": name,
+ "is_active": "on",
+ }
+}
+
+func authSourcePayloadOpenIDConnect(name, appURL string) map[string]string {
+ payload := authSourcePayloadOAuth2(name)
+ payload["oauth2_provider"] = "openidConnect"
+ payload["open_id_connect_auto_discovery_url"] = appURL + ".well-known/openid-configuration"
+ return payload
+}
+
+func authSourcePayloadGitLab(name string) map[string]string {
+ payload := authSourcePayloadOAuth2(name)
+ payload["oauth2_provider"] = "gitlab"
+ return payload
+}
+
+func authSourcePayloadGitLabCustom(name string) map[string]string {
+ payload := authSourcePayloadGitLab(name)
+ payload["oauth2_use_custom_url"] = "on"
+ payload["oauth2_auth_url"] = goth_gitlab.AuthURL
+ payload["oauth2_token_url"] = goth_gitlab.TokenURL
+ payload["oauth2_profile_url"] = goth_gitlab.ProfileURL
+ return payload
+}
+
+func authSourcePayloadGitHub(name string) map[string]string {
+ payload := authSourcePayloadOAuth2(name)
+ payload["oauth2_provider"] = "github"
+ return payload
+}
+
+func authSourcePayloadGitHubCustom(name string) map[string]string {
+ payload := authSourcePayloadGitHub(name)
+ payload["oauth2_use_custom_url"] = "on"
+ payload["oauth2_auth_url"] = goth_github.AuthURL
+ payload["oauth2_token_url"] = goth_github.TokenURL
+ payload["oauth2_profile_url"] = goth_github.ProfileURL
+ return payload
+}
+
+func createRemoteAuthSource(t *testing.T, name, url, matchingSource string) *auth.Source {
+ require.NoError(t, auth.CreateSource(context.Background(), &auth.Source{
+ Type: auth.Remote,
+ Name: name,
+ IsActive: true,
+ Cfg: &remote.Source{
+ URL: url,
+ MatchingSource: matchingSource,
+ },
+ }))
+ source, err := auth.GetSourceByName(context.Background(), name)
+ require.NoError(t, err)
+ return source
+}
+
+func createUser(ctx context.Context, t testing.TB, user *user_model.User) func() {
+ user.MustChangePassword = false
+ user.LowerName = strings.ToLower(user.Name)
+
+ require.NoError(t, db.Insert(ctx, user))
+
+ if len(user.Email) > 0 {
+ require.NoError(t, user_service.ReplacePrimaryEmailAddress(ctx, user, user.Email))
+ }
+
+ return func() {
+ require.NoError(t, user_service.DeleteUser(ctx, user, true))
+ }
+}
+
+func loginUser(t testing.TB, userName string) *TestSession {
+ t.Helper()
+
+ return loginUserWithPassword(t, userName, userPassword)
+}
+
+func loginUserWithPassword(t testing.TB, userName, password string) *TestSession {
+ t.Helper()
+
+ return loginUserWithPasswordRemember(t, userName, password, false)
+}
+
+func loginUserWithPasswordRemember(t testing.TB, userName, password string, rememberMe bool) *TestSession {
+ t.Helper()
+ req := NewRequest(t, "GET", "/user/login")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ req = NewRequestWithValues(t, "POST", "/user/login", map[string]string{
+ "_csrf": doc.GetCSRF(),
+ "user_name": userName,
+ "password": password,
+ "remember": strconv.FormatBool(rememberMe),
+ })
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+
+ ch := http.Header{}
+ ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";"))
+ cr := http.Request{Header: ch}
+
+ session := emptyTestSession(t)
+
+ baseURL, err := url.Parse(setting.AppURL)
+ require.NoError(t, err)
+ session.jar.SetCookies(baseURL, cr.Cookies())
+
+ return session
+}
+
+// token has to be unique this counter take care of
+var tokenCounter int64
+
+// getTokenForLoggedInUser returns a token for a logged in user.
+// The scope is an optional list of snake_case strings like the frontend form fields,
+// but without the "scope_" prefix.
+func getTokenForLoggedInUser(t testing.TB, session *TestSession, scopes ...auth.AccessTokenScope) string {
+ t.Helper()
+ var token string
+ req := NewRequest(t, "GET", "/user/settings/applications")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ var csrf string
+ for _, cookie := range resp.Result().Cookies() {
+ if cookie.Name != "_csrf" {
+ continue
+ }
+ csrf = cookie.Value
+ break
+ }
+ if csrf == "" {
+ doc := NewHTMLParser(t, resp.Body)
+ csrf = doc.GetCSRF()
+ }
+ assert.NotEmpty(t, csrf)
+ urlValues := url.Values{}
+ urlValues.Add("_csrf", csrf)
+ urlValues.Add("name", fmt.Sprintf("api-testing-token-%d", atomic.AddInt64(&tokenCounter, 1)))
+ for _, scope := range scopes {
+ urlValues.Add("scope", string(scope))
+ }
+ req = NewRequestWithURLValues(t, "POST", "/user/settings/applications", urlValues)
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // Log the flash values on failure
+ if !assert.Equal(t, []string{"/user/settings/applications"}, resp.Result().Header["Location"]) {
+ for _, cookie := range resp.Result().Cookies() {
+ if cookie.Name != gitea_context.CookieNameFlash {
+ continue
+ }
+ flash, _ := url.ParseQuery(cookie.Value)
+ for key, value := range flash {
+ t.Logf("Flash %q: %q", key, value)
+ }
+ }
+ }
+
+ req = NewRequest(t, "GET", "/user/settings/applications")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ token = htmlDoc.doc.Find(".ui.info p").Text()
+ assert.NotEmpty(t, token)
+ return token
+}
+
+type RequestWrapper struct {
+ *http.Request
+}
+
+func (req *RequestWrapper) AddBasicAuth(username string) *RequestWrapper {
+ req.Request.SetBasicAuth(username, userPassword)
+ return req
+}
+
+func (req *RequestWrapper) AddTokenAuth(token string) *RequestWrapper {
+ if token == "" {
+ return req
+ }
+ if !strings.HasPrefix(token, "Bearer ") {
+ token = "Bearer " + token
+ }
+ req.Request.Header.Set("Authorization", token)
+ return req
+}
+
+func (req *RequestWrapper) SetHeader(name, value string) *RequestWrapper {
+ req.Request.Header.Set(name, value)
+ return req
+}
+
+func NewRequest(t testing.TB, method, urlStr string) *RequestWrapper {
+ t.Helper()
+ return NewRequestWithBody(t, method, urlStr, nil)
+}
+
+func NewRequestf(t testing.TB, method, urlFormat string, args ...any) *RequestWrapper {
+ t.Helper()
+ return NewRequest(t, method, fmt.Sprintf(urlFormat, args...))
+}
+
+func NewRequestWithValues(t testing.TB, method, urlStr string, values map[string]string) *RequestWrapper {
+ t.Helper()
+ urlValues := url.Values{}
+ for key, value := range values {
+ urlValues[key] = []string{value}
+ }
+ return NewRequestWithURLValues(t, method, urlStr, urlValues)
+}
+
+func NewRequestWithURLValues(t testing.TB, method, urlStr string, urlValues url.Values) *RequestWrapper {
+ t.Helper()
+ return NewRequestWithBody(t, method, urlStr, bytes.NewBufferString(urlValues.Encode())).
+ SetHeader("Content-Type", "application/x-www-form-urlencoded")
+}
+
+func NewRequestWithJSON(t testing.TB, method, urlStr string, v any) *RequestWrapper {
+ t.Helper()
+
+ jsonBytes, err := json.Marshal(v)
+ require.NoError(t, err)
+ return NewRequestWithBody(t, method, urlStr, bytes.NewBuffer(jsonBytes)).
+ SetHeader("Content-Type", "application/json")
+}
+
+func NewRequestWithBody(t testing.TB, method, urlStr string, body io.Reader) *RequestWrapper {
+ t.Helper()
+ if !strings.HasPrefix(urlStr, "http") && !strings.HasPrefix(urlStr, "/") {
+ urlStr = "/" + urlStr
+ }
+ req, err := http.NewRequest(method, urlStr, body)
+ require.NoError(t, err)
+ req.RequestURI = urlStr
+
+ return &RequestWrapper{req}
+}
+
+const NoExpectedStatus = -1
+
+func MakeRequest(t testing.TB, rw *RequestWrapper, expectedStatus int) *httptest.ResponseRecorder {
+ t.Helper()
+ req := rw.Request
+ recorder := httptest.NewRecorder()
+ if req.RemoteAddr == "" {
+ req.RemoteAddr = "test-mock:12345"
+ }
+ testWebRoutes.ServeHTTP(recorder, req)
+ if expectedStatus != NoExpectedStatus {
+ if !assert.EqualValues(t, expectedStatus, recorder.Code, "Request: %s %s", req.Method, req.URL.String()) {
+ logUnexpectedResponse(t, recorder)
+ }
+ }
+ return recorder
+}
+
+func MakeRequestNilResponseRecorder(t testing.TB, rw *RequestWrapper, expectedStatus int) *NilResponseRecorder {
+ t.Helper()
+ req := rw.Request
+ recorder := NewNilResponseRecorder()
+ testWebRoutes.ServeHTTP(recorder, req)
+ if expectedStatus != NoExpectedStatus {
+ if !assert.EqualValues(t, expectedStatus, recorder.Code,
+ "Request: %s %s", req.Method, req.URL.String()) {
+ logUnexpectedResponse(t, &recorder.ResponseRecorder)
+ }
+ }
+ return recorder
+}
+
+func MakeRequestNilResponseHashSumRecorder(t testing.TB, rw *RequestWrapper, expectedStatus int) *NilResponseHashSumRecorder {
+ t.Helper()
+ req := rw.Request
+ recorder := NewNilResponseHashSumRecorder()
+ testWebRoutes.ServeHTTP(recorder, req)
+ if expectedStatus != NoExpectedStatus {
+ if !assert.EqualValues(t, expectedStatus, recorder.Code,
+ "Request: %s %s", req.Method, req.URL.String()) {
+ logUnexpectedResponse(t, &recorder.ResponseRecorder)
+ }
+ }
+ return recorder
+}
+
+// logUnexpectedResponse logs the contents of an unexpected response.
+func logUnexpectedResponse(t testing.TB, recorder *httptest.ResponseRecorder) {
+ t.Helper()
+ respBytes := recorder.Body.Bytes()
+ if len(respBytes) == 0 {
+ // log the content of the flash cookie
+ for _, cookie := range recorder.Result().Cookies() {
+ if cookie.Name != gitea_context.CookieNameFlash {
+ continue
+ }
+ flash, _ := url.ParseQuery(cookie.Value)
+ for key, value := range flash {
+ // the key is itself url-encoded
+ if flash, err := url.ParseQuery(key); err == nil {
+ for key, value := range flash {
+ t.Logf("FlashCookie %q: %q", key, value)
+ }
+ } else {
+ t.Logf("FlashCookie %q: %q", key, value)
+ }
+ }
+ }
+
+ return
+ } else if len(respBytes) < 500 {
+ // if body is short, just log the whole thing
+ t.Log("Response: ", string(respBytes))
+ return
+ }
+ t.Log("Response length: ", len(respBytes))
+
+ // log the "flash" error message, if one exists
+ // we must create a new buffer, so that we don't "use up" resp.Body
+ htmlDoc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(respBytes))
+ if err != nil {
+ return // probably a non-HTML response
+ }
+ errMsg := htmlDoc.Find(".ui.negative.message").Text()
+ if len(errMsg) > 0 {
+ t.Log("A flash error message was found:", errMsg)
+ }
+}
+
+func DecodeJSON(t testing.TB, resp *httptest.ResponseRecorder, v any) {
+ t.Helper()
+
+ decoder := json.NewDecoder(resp.Body)
+ require.NoError(t, decoder.Decode(v))
+}
+
+func VerifyJSONSchema(t testing.TB, resp *httptest.ResponseRecorder, schemaFile string) {
+ t.Helper()
+
+ schemaFilePath := filepath.Join(filepath.Dir(setting.AppPath), "tests", "integration", "schemas", schemaFile)
+ _, schemaFileErr := os.Stat(schemaFilePath)
+ require.NoError(t, schemaFileErr)
+
+ schema, err := jsonschema.NewCompiler().Compile(schemaFilePath)
+ require.NoError(t, err)
+
+ var data any
+ err = json.Unmarshal(resp.Body.Bytes(), &data)
+ require.NoError(t, err)
+
+ schemaValidation := schema.Validate(data)
+ require.NoError(t, schemaValidation)
+}
+
+func GetCSRF(t testing.TB, session *TestSession, urlStr string) string {
+ t.Helper()
+ req := NewRequest(t, "GET", urlStr)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ return doc.GetCSRF()
+}
+
+func GetHTMLTitle(t testing.TB, session *TestSession, urlStr string) string {
+ t.Helper()
+
+ req := NewRequest(t, "GET", urlStr)
+ var resp *httptest.ResponseRecorder
+ if session == nil {
+ resp = MakeRequest(t, req, http.StatusOK)
+ } else {
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ }
+
+ doc := NewHTMLParser(t, resp.Body)
+ return doc.Find("head title").Text()
+}
diff --git a/tests/integration/issue_subscribe_test.go b/tests/integration/issue_subscribe_test.go
new file mode 100644
index 0000000..32001cd
--- /dev/null
+++ b/tests/integration/issue_subscribe_test.go
@@ -0,0 +1,44 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "path"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIssueSubscribe(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ session := emptyTestSession(t)
+ testIssueSubscribe(t, *session, true)
+ })
+}
+
+func testIssueSubscribe(t *testing.T, session TestSession, unavailable bool) {
+ t.Helper()
+
+ testIssue := "/user2/repo1/issues/1"
+ testPull := "/user2/repo1/pulls/2"
+ selector := ".issue-content-right .watching form"
+
+ resp := session.MakeRequest(t, NewRequest(t, "GET", path.Join(testIssue)), http.StatusOK)
+ area := NewHTMLParser(t, resp.Body).Find(selector)
+ tooltip, exists := area.Attr("data-tooltip-content")
+ assert.EqualValues(t, unavailable, exists)
+ if unavailable {
+ assert.EqualValues(t, "Sign in to subscribe to this issue.", tooltip)
+ }
+
+ resp = session.MakeRequest(t, NewRequest(t, "GET", path.Join(testPull)), http.StatusOK)
+ area = NewHTMLParser(t, resp.Body).Find(selector)
+ tooltip, exists = area.Attr("data-tooltip-content")
+ assert.EqualValues(t, unavailable, exists)
+ if unavailable {
+ assert.EqualValues(t, "Sign in to subscribe to this pull request.", tooltip)
+ }
+}
diff --git a/tests/integration/issue_test.go b/tests/integration/issue_test.go
new file mode 100644
index 0000000..909242e
--- /dev/null
+++ b/tests/integration/issue_test.go
@@ -0,0 +1,1303 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "path"
+ "regexp"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ project_model "code.gitea.io/gitea/models/project"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/indexer/issues"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/references"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func getIssuesSelection(t testing.TB, htmlDoc *HTMLDoc) *goquery.Selection {
+ issueList := htmlDoc.doc.Find("#issue-list")
+ assert.EqualValues(t, 1, issueList.Length())
+ return issueList.Find(".flex-item").Find(".issue-title")
+}
+
+func getIssue(t *testing.T, repoID int64, issueSelection *goquery.Selection) *issues_model.Issue {
+ href, exists := issueSelection.Attr("href")
+ assert.True(t, exists)
+ indexStr := href[strings.LastIndexByte(href, '/')+1:]
+ index, err := strconv.Atoi(indexStr)
+ require.NoError(t, err, "Invalid issue href: %s", href)
+ return unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{RepoID: repoID, Index: int64(index)})
+}
+
+func assertMatch(t testing.TB, issue *issues_model.Issue, keyword string) {
+ matches := strings.Contains(strings.ToLower(issue.Title), keyword) ||
+ strings.Contains(strings.ToLower(issue.Content), keyword)
+ for _, comment := range issue.Comments {
+ matches = matches || strings.Contains(
+ strings.ToLower(comment.Content),
+ keyword,
+ )
+ }
+ assert.True(t, matches)
+}
+
+func TestNoLoginViewIssues(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues")
+ MakeRequest(t, req, http.StatusOK)
+}
+
+func TestViewIssues(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ search := htmlDoc.doc.Find(".list-header-search > .search > .input > input")
+ placeholder, _ := search.Attr("placeholder")
+ assert.Equal(t, "Search issues...", placeholder)
+}
+
+func TestViewIssuesSortByType(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ session := loginUser(t, user.Name)
+ req := NewRequest(t, "GET", repo.Link()+"/issues?type=created_by")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ issuesSelection := getIssuesSelection(t, htmlDoc)
+ expectedNumIssues := unittest.GetCount(t,
+ &issues_model.Issue{RepoID: repo.ID, PosterID: user.ID},
+ unittest.Cond("is_closed=?", false),
+ unittest.Cond("is_pull=?", false),
+ )
+ if expectedNumIssues > setting.UI.IssuePagingNum {
+ expectedNumIssues = setting.UI.IssuePagingNum
+ }
+ assert.EqualValues(t, expectedNumIssues, issuesSelection.Length())
+
+ issuesSelection.Each(func(_ int, selection *goquery.Selection) {
+ issue := getIssue(t, repo.ID, selection)
+ assert.EqualValues(t, user.ID, issue.PosterID)
+ })
+}
+
+func TestViewIssuesKeyword(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{
+ RepoID: repo.ID,
+ Index: 1,
+ })
+ issues.UpdateIssueIndexer(context.Background(), issue.ID)
+ time.Sleep(time.Second * 1)
+
+ const keyword = "first"
+ req := NewRequestf(t, "GET", "%s/issues?q=%s", repo.Link(), keyword)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ issuesSelection := getIssuesSelection(t, htmlDoc)
+ assert.EqualValues(t, 1, issuesSelection.Length())
+ issuesSelection.Each(func(_ int, selection *goquery.Selection) {
+ issue := getIssue(t, repo.ID, selection)
+ assert.False(t, issue.IsClosed)
+ assert.False(t, issue.IsPull)
+ assertMatch(t, issue, keyword)
+ })
+
+ // keyword: 'firstt'
+ // should not match when fuzzy searching is disabled
+ req = NewRequestf(t, "GET", "%s/issues?q=%st&fuzzy=false", repo.Link(), keyword)
+ resp = MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ issuesSelection = getIssuesSelection(t, htmlDoc)
+ assert.EqualValues(t, 0, issuesSelection.Length())
+
+ // should match as 'first' when fuzzy seaeching is enabled
+ for _, fmt := range []string{"%s/issues?q=%st&fuzzy=true", "%s/issues?q=%st"} {
+ req = NewRequestf(t, "GET", fmt, repo.Link(), keyword)
+ resp = MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ issuesSelection = getIssuesSelection(t, htmlDoc)
+ assert.EqualValues(t, 1, issuesSelection.Length())
+ issuesSelection.Each(func(_ int, selection *goquery.Selection) {
+ issue := getIssue(t, repo.ID, selection)
+ assert.False(t, issue.IsClosed)
+ assert.False(t, issue.IsPull)
+ assertMatch(t, issue, keyword)
+ })
+ }
+}
+
+func TestViewIssuesSearchOptions(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ // there are two issues in repo1, both bound to a project. Add one
+ // that is not bound to any project.
+ _, issueNoProject := testIssueWithBean(t, "user2", 1, "Title", "Description")
+
+ t.Run("All issues", func(t *testing.T) {
+ req := NewRequestf(t, "GET", "%s/issues?state=all", repo.Link())
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ issuesSelection := getIssuesSelection(t, htmlDoc)
+ assert.EqualValues(t, 3, issuesSelection.Length())
+ })
+
+ t.Run("Issues with no project", func(t *testing.T) {
+ req := NewRequestf(t, "GET", "%s/issues?state=all&project=-1", repo.Link())
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ issuesSelection := getIssuesSelection(t, htmlDoc)
+ assert.EqualValues(t, 1, issuesSelection.Length())
+ issuesSelection.Each(func(_ int, selection *goquery.Selection) {
+ issue := getIssue(t, repo.ID, selection)
+ assert.Equal(t, issueNoProject.ID, issue.ID)
+ })
+ })
+
+ t.Run("Issues with a specific project", func(t *testing.T) {
+ project := unittest.AssertExistsAndLoadBean(t, &project_model.Project{ID: 1})
+
+ req := NewRequestf(t, "GET", "%s/issues?state=all&project=%d", repo.Link(), project.ID)
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ issuesSelection := getIssuesSelection(t, htmlDoc)
+ assert.EqualValues(t, 2, issuesSelection.Length())
+ found := map[int64]bool{
+ 1: false,
+ 5: false,
+ }
+ issuesSelection.Each(func(_ int, selection *goquery.Selection) {
+ issue := getIssue(t, repo.ID, selection)
+ found[issue.ID] = true
+ })
+ assert.Len(t, found, 2)
+ assert.True(t, found[1])
+ assert.True(t, found[5])
+ })
+}
+
+func TestNoLoginViewIssue(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues/1")
+ MakeRequest(t, req, http.StatusOK)
+}
+
+func testNewIssue(t *testing.T, session *TestSession, user, repo, title, content string) string {
+ req := NewRequest(t, "GET", path.Join(user, repo, "issues", "new"))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action")
+ assert.True(t, exists, "The template has changed")
+ req = NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "title": title,
+ "content": content,
+ })
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ issueURL := test.RedirectURL(resp)
+ req = NewRequest(t, "GET", issueURL)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ val := htmlDoc.doc.Find("#issue-title-display").Text()
+ assert.Contains(t, val, title)
+ // test for first line only and if it contains only letters and spaces
+ contentFirstLine := strings.Split(content, "\n")[0]
+ patNotLetterOrSpace := regexp.MustCompile(`[^\p{L}\s]`)
+ if len(contentFirstLine) != 0 && !patNotLetterOrSpace.MatchString(contentFirstLine) {
+ val = htmlDoc.doc.Find(".comment .render-content p").First().Text()
+ assert.Equal(t, contentFirstLine, val)
+ }
+ return issueURL
+}
+
+func testIssueAddComment(t *testing.T, session *TestSession, issueURL, content, status string) int64 {
+ req := NewRequest(t, "GET", issueURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ link, exists := htmlDoc.doc.Find("#comment-form").Attr("action")
+ assert.True(t, exists, "The template has changed")
+
+ commentCount := htmlDoc.doc.Find(".comment-list .comment .render-content").Length()
+
+ req = NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "content": content,
+ "status": status,
+ })
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc = NewHTMLParser(t, resp.Body)
+
+ val := htmlDoc.doc.Find(".comment-list .comment .render-content p").Eq(commentCount).Text()
+ assert.Equal(t, content, val)
+
+ idAttr, has := htmlDoc.doc.Find(".comment-list .comment").Eq(commentCount).Attr("id")
+ idStr := idAttr[strings.LastIndexByte(idAttr, '-')+1:]
+ assert.True(t, has)
+ id, err := strconv.Atoi(idStr)
+ require.NoError(t, err)
+ return int64(id)
+}
+
+func TestNewIssue(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+ testNewIssue(t, session, "user2", "repo1", "Title", "Description")
+}
+
+func TestIssueCheckboxes(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+ issueURL := testNewIssue(t, session, "user2", "repo1", "Title", `- [x] small x
+- [X] capital X
+- [ ] empty
+ - [x]x without gap
+ - [ ]empty without gap
+- [x]
+x on new line
+- [ ]
+empty on new line
+ - [ ] tabs instead of spaces
+Description`)
+ req := NewRequest(t, "GET", issueURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ issueContent := NewHTMLParser(t, resp.Body).doc.Find(".comment .render-content").First()
+ isCheckBox := func(i int, s *goquery.Selection) bool {
+ typeVal, typeExists := s.Attr("type")
+ return typeExists && typeVal == "checkbox"
+ }
+ isChecked := func(i int, s *goquery.Selection) bool {
+ _, checkedExists := s.Attr("checked")
+ return checkedExists
+ }
+ checkBoxes := issueContent.Find("input").FilterFunction(isCheckBox)
+ assert.Equal(t, 8, checkBoxes.Length())
+ assert.Equal(t, 4, checkBoxes.FilterFunction(isChecked).Length())
+
+ // Issues list should show the correct numbers of checked and total checkboxes
+ repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "repo1")
+ require.NoError(t, err)
+ req = NewRequestf(t, "GET", "%s/issues", repo.Link())
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ issuesSelection := htmlDoc.Find("#issue-list .flex-item")
+ assert.Equal(t, "4 / 8", strings.TrimSpace(issuesSelection.Find(".checklist").Text()))
+ value, _ := issuesSelection.Find("progress").Attr("value")
+ vmax, _ := issuesSelection.Find("progress").Attr("max")
+ assert.Equal(t, "4", value)
+ assert.Equal(t, "8", vmax)
+}
+
+func TestIssueDependencies(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ session := loginUser(t, owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+
+ repo, _, f := tests.CreateDeclarativeRepoWithOptions(t, owner, tests.DeclarativeRepoOptions{})
+ defer f()
+
+ createIssue := func(t *testing.T, title string) api.Issue {
+ t.Helper()
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner.Name, repo.Name)
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{
+ Body: "",
+ Title: title,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var apiIssue api.Issue
+ DecodeJSON(t, resp, &apiIssue)
+
+ return apiIssue
+ }
+ addDependency := func(t *testing.T, issue, dependency api.Issue) {
+ t.Helper()
+
+ urlStr := fmt.Sprintf("/%s/%s/issues/%d/dependency/add", owner.Name, repo.Name, issue.Index)
+ req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
+ "_csrf": GetCSRF(t, session, fmt.Sprintf("/%s/%s/issues/%d", owner.Name, repo.Name, issue.Index)),
+ "newDependency": fmt.Sprintf("%d", dependency.Index),
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ }
+ removeDependency := func(t *testing.T, issue, dependency api.Issue) {
+ t.Helper()
+
+ urlStr := fmt.Sprintf("/%s/%s/issues/%d/dependency/delete", owner.Name, repo.Name, issue.Index)
+ req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
+ "_csrf": GetCSRF(t, session, fmt.Sprintf("/%s/%s/issues/%d", owner.Name, repo.Name, issue.Index)),
+ "removeDependencyID": fmt.Sprintf("%d", dependency.Index),
+ "dependencyType": "blockedBy",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ }
+
+ assertHasDependency := func(t *testing.T, issueID, dependencyID int64, hasDependency bool) {
+ t.Helper()
+
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/dependencies", owner.Name, repo.Name, issueID)
+ req := NewRequest(t, "GET", urlStr)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var issues []api.Issue
+ DecodeJSON(t, resp, &issues)
+
+ if hasDependency {
+ assert.NotEmpty(t, issues)
+ assert.EqualValues(t, issues[0].Index, dependencyID)
+ } else {
+ assert.Empty(t, issues)
+ }
+ }
+
+ t.Run("Add dependency", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ issue1 := createIssue(t, "issue #1")
+ issue2 := createIssue(t, "issue #2")
+ addDependency(t, issue1, issue2)
+
+ assertHasDependency(t, issue1.Index, issue2.Index, true)
+ })
+
+ t.Run("Remove dependency", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ issue1 := createIssue(t, "issue #1")
+ issue2 := createIssue(t, "issue #2")
+ addDependency(t, issue1, issue2)
+ removeDependency(t, issue1, issue2)
+
+ assertHasDependency(t, issue1.Index, issue2.Index, false)
+ })
+}
+
+func TestEditIssue(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+ issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description")
+
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/content", issueURL), map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ "content": "modified content",
+ "context": fmt.Sprintf("/%s/%s", "user2", "repo1"),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequestWithValues(t, "POST", fmt.Sprintf("%s/content", issueURL), map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ "content": "modified content",
+ "context": fmt.Sprintf("/%s/%s", "user2", "repo1"),
+ })
+ session.MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequestWithValues(t, "POST", fmt.Sprintf("%s/content", issueURL), map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ "content": "modified content",
+ "content_version": "1",
+ "context": fmt.Sprintf("/%s/%s", "user2", "repo1"),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+}
+
+func TestIssueCommentClose(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+ issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description")
+ testIssueAddComment(t, session, issueURL, "Test comment 1", "")
+ testIssueAddComment(t, session, issueURL, "Test comment 2", "")
+ testIssueAddComment(t, session, issueURL, "Test comment 3", "close")
+
+ // Validate that issue content has not been updated
+ req := NewRequest(t, "GET", issueURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ val := htmlDoc.doc.Find(".comment-list .comment .render-content p").First().Text()
+ assert.Equal(t, "Description", val)
+}
+
+func TestIssueCommentDelete(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+ issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description")
+ comment1 := "Test comment 1"
+ commentID := testIssueAddComment(t, session, issueURL, comment1, "")
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: commentID})
+ assert.Equal(t, comment1, comment.Content)
+
+ // Using the ID of a comment that does not belong to the repository must fail
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d/delete", "user5", "repo4", commentID), map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ })
+ session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d/delete", "user2", "repo1", commentID), map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+ unittest.AssertNotExistsBean(t, &issues_model.Comment{ID: commentID})
+}
+
+func TestIssueCommentAttachment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ const repoURL = "user2/repo1"
+ const content = "Test comment 4"
+ const status = ""
+ session := loginUser(t, "user2")
+ issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description")
+
+ req := NewRequest(t, "GET", issueURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ link, exists := htmlDoc.doc.Find("#comment-form").Attr("action")
+ assert.True(t, exists, "The template has changed")
+
+ uuid := createAttachment(t, session, repoURL, "image.png", generateImg(), http.StatusOK)
+
+ commentCount := htmlDoc.doc.Find(".comment-list .comment .render-content").Length()
+
+ req = NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "content": content,
+ "status": status,
+ "files": uuid,
+ })
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc = NewHTMLParser(t, resp.Body)
+
+ val := htmlDoc.doc.Find(".comment-list .comment .render-content p").Eq(commentCount).Text()
+ assert.Equal(t, content, val)
+
+ idAttr, has := htmlDoc.doc.Find(".comment-list .comment").Eq(commentCount).Attr("id")
+ idStr := idAttr[strings.LastIndexByte(idAttr, '-')+1:]
+ assert.True(t, has)
+ id, err := strconv.Atoi(idStr)
+ require.NoError(t, err)
+ assert.NotEqual(t, 0, id)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/comments/%d/attachments", "user2", "repo1", id))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // Using the ID of a comment that does not belong to the repository must fail
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s/comments/%d/attachments", "user5", "repo4", id))
+ session.MakeRequest(t, req, http.StatusNotFound)
+}
+
+func TestIssueCommentUpdate(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+ issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description")
+ comment1 := "Test comment 1"
+ commentID := testIssueAddComment(t, session, issueURL, comment1, "")
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: commentID})
+ assert.Equal(t, comment1, comment.Content)
+
+ modifiedContent := comment.Content + "MODIFIED"
+
+ // Using the ID of a comment that does not belong to the repository must fail
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d", "user5", "repo4", commentID), map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ "content": modifiedContent,
+ })
+ session.MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d", "user2", "repo1", commentID), map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ "content": modifiedContent,
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: commentID})
+ assert.Equal(t, modifiedContent, comment.Content)
+
+ // make the comment empty
+ req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d", "user2", "repo1", commentID), map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ "content": "",
+ "content_version": fmt.Sprintf("%d", comment.ContentVersion),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: commentID})
+ assert.Equal(t, "", comment.Content)
+}
+
+func TestIssueCommentUpdateSimultaneously(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+ issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description")
+ comment1 := "Test comment 1"
+ commentID := testIssueAddComment(t, session, issueURL, comment1, "")
+
+ comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: commentID})
+ assert.Equal(t, comment1, comment.Content)
+
+ modifiedContent := comment.Content + "MODIFIED"
+
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d", "user2", "repo1", commentID), map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ "content": modifiedContent,
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ modifiedContent = comment.Content + "2"
+
+ req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d", "user2", "repo1", commentID), map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ "content": modifiedContent,
+ })
+ session.MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/comments/%d", "user2", "repo1", commentID), map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ "content": modifiedContent,
+ "content_version": "1",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ comment = unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: commentID})
+ assert.Equal(t, modifiedContent, comment.Content)
+ assert.Equal(t, 2, comment.ContentVersion)
+}
+
+func TestIssueReaction(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+ issueURL := testNewIssue(t, session, "user2", "repo1", "Title", "Description")
+
+ req := NewRequest(t, "GET", issueURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ req = NewRequestWithValues(t, "POST", path.Join(issueURL, "/reactions/react"), map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "content": "8ball",
+ })
+ session.MakeRequest(t, req, http.StatusInternalServerError)
+ req = NewRequestWithValues(t, "POST", path.Join(issueURL, "/reactions/react"), map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "content": "eyes",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequestWithValues(t, "POST", path.Join(issueURL, "/reactions/unreact"), map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "content": "eyes",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+}
+
+func TestIssueCrossReference(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // Issue that will be referenced
+ _, issueBase := testIssueWithBean(t, "user2", 1, "Title", "Description")
+
+ // Ref from issue title
+ issueRefURL, issueRef := testIssueWithBean(t, "user2", 1, fmt.Sprintf("Title ref #%d", issueBase.Index), "Description")
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{
+ IssueID: issueBase.ID,
+ RefRepoID: 1,
+ RefIssueID: issueRef.ID,
+ RefCommentID: 0,
+ RefIsPull: false,
+ RefAction: references.XRefActionNone,
+ })
+
+ // Edit title, neuter ref
+ testIssueChangeInfo(t, "user2", issueRefURL, "title", "Title no ref")
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{
+ IssueID: issueBase.ID,
+ RefRepoID: 1,
+ RefIssueID: issueRef.ID,
+ RefCommentID: 0,
+ RefIsPull: false,
+ RefAction: references.XRefActionNeutered,
+ })
+
+ // Ref from issue content
+ issueRefURL, issueRef = testIssueWithBean(t, "user2", 1, "TitleXRef", fmt.Sprintf("Description ref #%d", issueBase.Index))
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{
+ IssueID: issueBase.ID,
+ RefRepoID: 1,
+ RefIssueID: issueRef.ID,
+ RefCommentID: 0,
+ RefIsPull: false,
+ RefAction: references.XRefActionNone,
+ })
+
+ // Edit content, neuter ref
+ testIssueChangeInfo(t, "user2", issueRefURL, "content", "Description no ref")
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{
+ IssueID: issueBase.ID,
+ RefRepoID: 1,
+ RefIssueID: issueRef.ID,
+ RefCommentID: 0,
+ RefIsPull: false,
+ RefAction: references.XRefActionNeutered,
+ })
+
+ // Ref from a comment
+ session := loginUser(t, "user2")
+ commentID := testIssueAddComment(t, session, issueRefURL, fmt.Sprintf("Adding ref from comment #%d", issueBase.Index), "")
+ comment := &issues_model.Comment{
+ IssueID: issueBase.ID,
+ RefRepoID: 1,
+ RefIssueID: issueRef.ID,
+ RefCommentID: commentID,
+ RefIsPull: false,
+ RefAction: references.XRefActionNone,
+ }
+ unittest.AssertExistsAndLoadBean(t, comment)
+
+ // Ref from a different repository
+ _, issueRef = testIssueWithBean(t, "user12", 10, "TitleXRef", fmt.Sprintf("Description ref user2/repo1#%d", issueBase.Index))
+ unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{
+ IssueID: issueBase.ID,
+ RefRepoID: 10,
+ RefIssueID: issueRef.ID,
+ RefCommentID: 0,
+ RefIsPull: false,
+ RefAction: references.XRefActionNone,
+ })
+}
+
+func testIssueWithBean(t *testing.T, user string, repoID int64, title, content string) (string, *issues_model.Issue) {
+ session := loginUser(t, user)
+ issueURL := testNewIssue(t, session, user, fmt.Sprintf("repo%d", repoID), title, content)
+ indexStr := issueURL[strings.LastIndexByte(issueURL, '/')+1:]
+ index, err := strconv.Atoi(indexStr)
+ require.NoError(t, err, "Invalid issue href: %s", issueURL)
+ issue := &issues_model.Issue{RepoID: repoID, Index: int64(index)}
+ unittest.AssertExistsAndLoadBean(t, issue)
+ return issueURL, issue
+}
+
+func testIssueChangeInfo(t *testing.T, user, issueURL, info, value string) {
+ session := loginUser(t, user)
+
+ req := NewRequest(t, "GET", issueURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ req = NewRequestWithValues(t, "POST", path.Join(issueURL, info), map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ info: value,
+ })
+ _ = session.MakeRequest(t, req, http.StatusOK)
+}
+
+func TestIssueRedirect(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+
+ // Test external tracker where style not set (shall default numeric)
+ req := NewRequest(t, "GET", path.Join("org26", "repo_external_tracker", "issues", "1"))
+ resp := session.MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, "https://tracker.com/org26/repo_external_tracker/issues/1", test.RedirectURL(resp))
+
+ // Test external tracker with numeric style
+ req = NewRequest(t, "GET", path.Join("org26", "repo_external_tracker_numeric", "issues", "1"))
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, "https://tracker.com/org26/repo_external_tracker_numeric/issues/1", test.RedirectURL(resp))
+
+ // Test external tracker with alphanumeric style (for a pull request)
+ req = NewRequest(t, "GET", path.Join("org26", "repo_external_tracker_alpha", "issues", "1"))
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, "/"+path.Join("org26", "repo_external_tracker_alpha", "pulls", "1"), test.RedirectURL(resp))
+}
+
+func TestSearchIssues(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ expectedIssueCount := 20 // from the fixtures
+ if expectedIssueCount > setting.UI.IssuePagingNum {
+ expectedIssueCount = setting.UI.IssuePagingNum
+ }
+
+ link, _ := url.Parse("/issues/search")
+ req := NewRequest(t, "GET", link.String())
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ var apiIssues []*api.Issue
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, expectedIssueCount)
+
+ since := "2000-01-01T00:50:01+00:00" // 946687801
+ before := time.Unix(999307200, 0).Format(time.RFC3339)
+ query := url.Values{}
+ query.Add("since", since)
+ query.Add("before", before)
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 11)
+ query.Del("since")
+ query.Del("before")
+
+ query.Add("state", "closed")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+
+ query.Set("state", "all")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.EqualValues(t, "22", resp.Header().Get("X-Total-Count"))
+ assert.Len(t, apiIssues, 20)
+
+ query.Add("limit", "5")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.EqualValues(t, "22", resp.Header().Get("X-Total-Count"))
+ assert.Len(t, apiIssues, 5)
+
+ query = url.Values{"assigned": {"true"}, "state": {"all"}}
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+
+ query = url.Values{"milestones": {"milestone1"}, "state": {"all"}}
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 1)
+
+ query = url.Values{"milestones": {"milestone1,milestone3"}, "state": {"all"}}
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+
+ query = url.Values{"owner": {"user2"}} // user
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 8)
+
+ query = url.Values{"owner": {"org3"}} // organization
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 5)
+
+ query = url.Values{"owner": {"org3"}, "team": {"team1"}} // organization + team
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+}
+
+func TestSearchIssuesWithLabels(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ expectedIssueCount := 20 // from the fixtures
+ if expectedIssueCount > setting.UI.IssuePagingNum {
+ expectedIssueCount = setting.UI.IssuePagingNum
+ }
+
+ session := loginUser(t, "user1")
+ link, _ := url.Parse("/issues/search")
+ query := url.Values{}
+ var apiIssues []*api.Issue
+
+ link.RawQuery = query.Encode()
+ req := NewRequest(t, "GET", link.String())
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, expectedIssueCount)
+
+ query.Add("labels", "label1")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+
+ // multiple labels
+ query.Set("labels", "label1,label2")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+
+ // an org label
+ query.Set("labels", "orglabel4")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 1)
+
+ // org and repo label
+ query.Set("labels", "label2,orglabel4")
+ query.Add("state", "all")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+
+ // org and repo label which share the same issue
+ query.Set("labels", "label1,orglabel4")
+ link.RawQuery = query.Encode()
+ req = NewRequest(t, "GET", link.String())
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &apiIssues)
+ assert.Len(t, apiIssues, 2)
+}
+
+func TestGetIssueInfo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+ require.NoError(t, issue.LoadAttributes(db.DefaultContext))
+ assert.Equal(t, int64(1019307200), int64(issue.DeadlineUnix))
+ assert.Equal(t, api.StateOpen, issue.State())
+
+ session := loginUser(t, owner.Name)
+
+ urlStr := fmt.Sprintf("/%s/%s/issues/%d/info", owner.Name, repo.Name, issue.Index)
+ req := NewRequest(t, "GET", urlStr)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ var apiIssue api.Issue
+ DecodeJSON(t, resp, &apiIssue)
+
+ assert.EqualValues(t, issue.ID, apiIssue.ID)
+}
+
+func TestIssuePinMove(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+ issueURL, issue := testIssueWithBean(t, "user2", 1, "Title", "Content")
+ assert.EqualValues(t, 0, issue.PinOrder)
+
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/pin", issueURL), map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+ issue = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID})
+
+ position := 1
+ assert.EqualValues(t, position, issue.PinOrder)
+
+ newPosition := 2
+
+ // Using the ID of an issue that does not belong to the repository must fail
+ {
+ session5 := loginUser(t, "user5")
+ movePinURL := "/user5/repo4/issues/move_pin?_csrf=" + GetCSRF(t, session5, issueURL)
+ req = NewRequestWithJSON(t, "POST", movePinURL, map[string]any{
+ "id": issue.ID,
+ "position": newPosition,
+ })
+ session5.MakeRequest(t, req, http.StatusNotFound)
+
+ issue = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID})
+ assert.EqualValues(t, position, issue.PinOrder)
+ }
+
+ movePinURL := issueURL[:strings.LastIndexByte(issueURL, '/')] + "/move_pin?_csrf=" + GetCSRF(t, session, issueURL)
+ req = NewRequestWithJSON(t, "POST", movePinURL, map[string]any{
+ "id": issue.ID,
+ "position": newPosition,
+ })
+ session.MakeRequest(t, req, http.StatusNoContent)
+
+ issue = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: issue.ID})
+ assert.EqualValues(t, newPosition, issue.PinOrder)
+}
+
+func TestUpdateIssueDeadline(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 10})
+ repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID})
+ require.NoError(t, issueBefore.LoadAttributes(db.DefaultContext))
+ assert.Equal(t, int64(1019307200), int64(issueBefore.DeadlineUnix))
+ assert.Equal(t, api.StateOpen, issueBefore.State())
+
+ session := loginUser(t, owner.Name)
+
+ issueURL := fmt.Sprintf("%s/%s/issues/%d", owner.Name, repoBefore.Name, issueBefore.Index)
+ req := NewRequest(t, "GET", issueURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ urlStr := issueURL + "/deadline?_csrf=" + htmlDoc.GetCSRF()
+ req = NewRequestWithJSON(t, "POST", urlStr, map[string]string{
+ "due_date": "2022-04-06T00:00:00.000Z",
+ })
+
+ resp = session.MakeRequest(t, req, http.StatusCreated)
+ var apiIssue api.IssueDeadline
+ DecodeJSON(t, resp, &apiIssue)
+
+ assert.EqualValues(t, "2022-04-06", apiIssue.Deadline.Format("2006-01-02"))
+}
+
+func TestUpdateIssueTitle(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ issueBefore := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ require.NoError(t, issueBefore.LoadAttributes(db.DefaultContext))
+ assert.Equal(t, "issue1", issueBefore.Title)
+
+ issueTitleUpdateTests := []struct {
+ title string
+ expectedHTTPCode int
+ }{
+ {
+ title: "normal-title",
+ expectedHTTPCode: http.StatusOK,
+ },
+ {
+ title: "extra-long-title-with-exactly-255-chars-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ expectedHTTPCode: http.StatusOK,
+ },
+ {
+ title: "",
+ expectedHTTPCode: http.StatusBadRequest,
+ },
+ {
+ title: " ",
+ expectedHTTPCode: http.StatusBadRequest,
+ },
+ {
+ title: "extra-long-title-over-255-chars-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ expectedHTTPCode: http.StatusBadRequest,
+ },
+ }
+
+ session := loginUser(t, owner.Name)
+ issueURL := fmt.Sprintf("%s/%s/issues/%d", owner.Name, repo.Name, issueBefore.Index)
+ urlStr := issueURL + "/title"
+
+ for _, issueTitleUpdateTest := range issueTitleUpdateTests {
+ req := NewRequestWithValues(t, "POST", urlStr, map[string]string{
+ "title": issueTitleUpdateTest.title,
+ "_csrf": GetCSRF(t, session, issueURL),
+ })
+
+ resp := session.MakeRequest(t, req, issueTitleUpdateTest.expectedHTTPCode)
+
+ // JSON data is received only if the request succeeds
+ if issueTitleUpdateTest.expectedHTTPCode == http.StatusOK {
+ issueAfter := struct {
+ Title string `json:"title"`
+ }{}
+
+ DecodeJSON(t, resp, &issueAfter)
+ assert.EqualValues(t, issueTitleUpdateTest.title, issueAfter.Title)
+ }
+ }
+}
+
+func TestIssueReferenceURL(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/issues/%d", repo.FullName(), issue.Index))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ // the "reference" uses relative URLs, then JS code will convert them to absolute URLs for current origin, in case users are using multiple domains
+ ref, _ := htmlDoc.Find(`.timeline-item.comment.first .reference-issue`).Attr("data-reference")
+ assert.EqualValues(t, "/user2/repo1/issues/1#issue-1", ref)
+
+ ref, _ = htmlDoc.Find(`.timeline-item.comment:not(.first) .reference-issue`).Attr("data-reference")
+ assert.EqualValues(t, "/user2/repo1/issues/1#issuecomment-2", ref)
+}
+
+func TestGetContentHistory(t *testing.T) {
+ defer tests.AddFixtures("tests/integration/fixtures/TestGetContentHistory/")()
+ defer tests.PrepareTestEnv(t)()
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID})
+ issueURL := fmt.Sprintf("%s/issues/%d", repo.FullName(), issue.Index)
+ contentHistory := unittest.AssertExistsAndLoadBean(t, &issues_model.ContentHistory{ID: 2, IssueID: issue.ID})
+ contentHistoryURL := fmt.Sprintf("%s/issues/%d/content-history/detail?comment_id=%d&history_id=%d", repo.FullName(), issue.Index, contentHistory.CommentID, contentHistory.ID)
+
+ type contentHistoryResp struct {
+ CanSoftDelete bool `json:"canSoftDelete"`
+ HistoryID int `json:"historyId"`
+ PrevHistoryID int `json:"prevHistoryId"`
+ }
+
+ testCase := func(t *testing.T, session *TestSession, canDelete bool) {
+ t.Helper()
+ contentHistoryURL := contentHistoryURL + "&_csrf=" + GetCSRF(t, session, issueURL)
+
+ req := NewRequest(t, "GET", contentHistoryURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ var respJSON contentHistoryResp
+ DecodeJSON(t, resp, &respJSON)
+
+ assert.EqualValues(t, canDelete, respJSON.CanSoftDelete)
+ assert.EqualValues(t, contentHistory.ID, respJSON.HistoryID)
+ assert.EqualValues(t, contentHistory.ID-1, respJSON.PrevHistoryID)
+ }
+
+ t.Run("Anonymous", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCase(t, emptyTestSession(t), false)
+ })
+
+ t.Run("Another user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCase(t, loginUser(t, "user8"), false)
+ })
+
+ t.Run("Repo owner", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCase(t, loginUser(t, "user2"), true)
+ })
+
+ t.Run("Poster", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testCase(t, loginUser(t, "user5"), true)
+ })
+}
+
+func TestCommitRefComment(t *testing.T) {
+ defer tests.AddFixtures("tests/integration/fixtures/TestCommitRefComment/")()
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("Pull request", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/pulls/2")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ event := htmlDoc.Find("#issuecomment-1000 .text").Text()
+ assert.Contains(t, event, "referenced this pull request")
+ })
+
+ t.Run("Issue", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues/1")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ event := htmlDoc.Find("#issuecomment-1001 .text").Text()
+ assert.Contains(t, event, "referenced this issue")
+ })
+}
+
+func TestIssueFilterNoFollow(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // Check that every link in the filter list has rel="nofollow".
+ t.Run("Issue lists", func(t *testing.T) {
+ req := NewRequest(t, "GET", "/user2/repo1/issues")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ filterLinks := htmlDoc.Find(".issue-list-toolbar-right a[href*=\"?q=\"], .labels-list a[href*=\"?q=\"]")
+ assert.Positive(t, filterLinks.Length())
+ filterLinks.Each(func(i int, link *goquery.Selection) {
+ rel, has := link.Attr("rel")
+ assert.True(t, has)
+ assert.Equal(t, "nofollow", rel)
+ })
+ })
+
+ t.Run("Issue page", func(t *testing.T) {
+ req := NewRequest(t, "GET", "/user2/repo1/issues/1")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ filterLinks := htmlDoc.Find(".timeline .labels-list a[href*=\"?labels=\"], .issue-content-right .labels-list a[href*=\"?labels=\"]")
+ assert.Positive(t, filterLinks.Length())
+ filterLinks.Each(func(i int, link *goquery.Selection) {
+ rel, has := link.Attr("rel")
+ assert.True(t, has)
+ assert.Equal(t, "nofollow", rel)
+ })
+ })
+}
+
+func TestIssueForm(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user2.Name)
+ repo, _, f := tests.CreateDeclarativeRepo(t, user2, "",
+ []unit_model.Type{unit_model.TypeCode, unit_model.TypeIssues}, nil,
+ []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: ".forgejo/issue_template/test.yaml",
+ ContentReader: strings.NewReader(`name: Test
+about: Hello World
+body:
+ - type: checkboxes
+ id: test
+ attributes:
+ label: Test
+ options:
+ - label: This is a label
+`),
+ },
+ },
+ )
+ defer f()
+
+ t.Run("Choose list", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", repo.Link()+"/issues/new/choose")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, "a[href$='/issues/new?template=.forgejo%2fissue_template%2ftest.yaml']", true)
+ })
+
+ t.Run("Issue template", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", repo.Link()+"/issues/new?template=.forgejo%2fissue_template%2ftest.yaml")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, "#new-issue .field .ui.checkbox input[name='form-field-test-0']", true)
+ checkboxLabel := htmlDoc.Find("#new-issue .field .ui.checkbox label").Text()
+ assert.Contains(t, checkboxLabel, "This is a label")
+ })
+ })
+}
+
+func TestIssueUnsubscription(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ repo, _, f := tests.CreateDeclarativeRepoWithOptions(t, user, tests.DeclarativeRepoOptions{
+ AutoInit: optional.Some(false),
+ })
+ defer f()
+ session := loginUser(t, user.Name)
+
+ issueURL := testNewIssue(t, session, user.Name, repo.Name, "Issue title", "Description")
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/watch", issueURL), map[string]string{
+ "_csrf": GetCSRF(t, session, issueURL),
+ "watch": "0",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+ })
+}
+
+func TestIssueLabelList(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ // The label list should always be present. When no labels are selected, .no-select is visible, otherwise hidden.
+ labelListSelector := ".labels.list .labels-list"
+ hiddenClass := "tw-hidden"
+
+ t.Run("Test label list", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues/1")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, labelListSelector, true)
+ htmlDoc.AssertElement(t, ".labels.list .no-select."+hiddenClass, true)
+ })
+}
+
+func TestIssueUserDashboard(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ session := loginUser(t, user.Name)
+
+ // assert 'created_by' is the default filter
+ const sel = ".dashboard .ui.list-header.dropdown .ui.menu a.active.item[href^='?type=created_by']"
+
+ for _, path := range []string{"/issues", "/pulls"} {
+ req := NewRequest(t, "GET", path)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, sel, true)
+ }
+}
diff --git a/tests/integration/issues_comment_labels_test.go b/tests/integration/issues_comment_labels_test.go
new file mode 100644
index 0000000..5299d8a
--- /dev/null
+++ b/tests/integration/issues_comment_labels_test.go
@@ -0,0 +1,197 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+ "testing"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+)
+
+// TestIssuesCommentLabels is a test for user (role) labels in comment headers in PRs and issues.
+func TestIssuesCommentLabels(t *testing.T) {
+ user := "user2"
+ repo := "repo1"
+
+ ownerTooltip := "This user is the owner of this repository."
+ authorTooltipPR := "This user is the author of this pull request."
+ authorTooltipIssue := "This user is the author of this issue."
+ contributorTooltip := "This user has previously committed in this repository."
+ newContributorTooltip := "This is the first contribution of this user to the repository."
+
+ // Test pulls
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ sessionUser1 := loginUser(t, "user1")
+ sessionUser2 := loginUser(t, "user2")
+ sessionUser11 := loginUser(t, "user11")
+
+ // Open a new PR as user2
+ testEditFileToNewBranch(t, sessionUser2, user, repo, "master", "comment-labels", "README.md", "test of comment labels\naline")
+ sessionUser2.MakeRequest(t, NewRequestWithValues(t, "POST", path.Join(user, repo, "compare", "master...comment-labels"),
+ map[string]string{
+ "_csrf": GetCSRF(t, sessionUser2, path.Join(user, repo, "compare", "master...comment-labels")),
+ "title": "Pull used for testing commit labels",
+ },
+ ), http.StatusOK)
+
+ // Pull number, expected to be 6
+ testID := "6"
+
+ // Add a few comments
+ // (first: Owner)
+ testEasyLeavePRReviewComment(t, sessionUser2, user, repo, testID, "README.md", "1", "New review comment from user2 on this line", "")
+
+ // Have to fetch reply ID for reviews
+ response := sessionUser2.MakeRequest(t, NewRequest(t, "GET", path.Join(user, repo, "pulls", testID)), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+ replyID, _ := page.Find(".comment-form input[name='reply']").Attr("value")
+
+ testEasyLeavePRReviewComment(t, sessionUser2, user, repo, testID, "README.md", "1", "Another review comment from user2 on this line", replyID)
+ testEasyLeavePRComment(t, sessionUser2, user, repo, testID, "New comment from user2 on this PR") // Author, Owner
+ testEasyLeavePRComment(t, sessionUser1, user, repo, testID, "New comment from user1 on this PR") // Contributor
+ testEasyLeavePRComment(t, sessionUser11, user, repo, testID, "New comment from user11 on this PR") // First-time contributor
+
+ // Fetch the PR page
+ response = sessionUser2.MakeRequest(t, NewRequest(t, "GET", path.Join(user, repo, "pulls", testID)), http.StatusOK)
+ page = NewHTMLParser(t, response.Body)
+ commentHeads := page.Find(".timeline .comment .comment-header .comment-header-right")
+ assert.EqualValues(t, 6, commentHeads.Length())
+
+ // Test the first comment and it's label "Owner"
+ labels := commentHeads.Eq(0).Find(".role-label")
+ assert.EqualValues(t, 1, labels.Length())
+ testIssueCommentUserLabel(t, labels.Eq(0), "Owner", ownerTooltip)
+
+ // Test the second (review) comment and it's labels "Author" and "Owner"
+ labels = commentHeads.Eq(1).Find(".role-label")
+ assert.EqualValues(t, 2, labels.Length())
+ testIssueCommentUserLabel(t, labels.Eq(0), "Author", authorTooltipPR)
+ testIssueCommentUserLabel(t, labels.Eq(1), "Owner", ownerTooltip)
+
+ // Test the third (review) comment and it's labels "Author" and "Owner"
+ labels = commentHeads.Eq(2).Find(".role-label")
+ assert.EqualValues(t, 2, labels.Length())
+ testIssueCommentUserLabel(t, labels.Eq(0), "Author", authorTooltipPR)
+ testIssueCommentUserLabel(t, labels.Eq(1), "Owner", ownerTooltip)
+
+ // Test the fourth comment and it's labels "Author" and "Owner"
+ labels = commentHeads.Eq(3).Find(".role-label")
+ assert.EqualValues(t, 2, labels.Length())
+ testIssueCommentUserLabel(t, labels.Eq(0), "Author", authorTooltipPR)
+ testIssueCommentUserLabel(t, labels.Eq(1), "Owner", ownerTooltip)
+
+ // Test the fivth comment and it's label "Contributor"
+ labels = commentHeads.Eq(4).Find(".role-label")
+ assert.EqualValues(t, 1, labels.Length())
+ testIssueCommentUserLabel(t, labels.Eq(0), "Contributor", contributorTooltip)
+
+ // Test the sixth comment and it's label "First-time contributor"
+ labels = commentHeads.Eq(5).Find(".role-label")
+ assert.EqualValues(t, 1, labels.Length())
+ testIssueCommentUserLabel(t, labels.Eq(0), "First-time contributor", newContributorTooltip)
+ })
+
+ // Test issues
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ sessionUser1 := loginUser(t, "user1")
+ sessionUser2 := loginUser(t, "user2")
+ sessionUser5 := loginUser(t, "user5")
+
+ // Open a new issue in the same repo
+ sessionUser2.MakeRequest(t, NewRequestWithValues(t, "POST", path.Join(user, repo, "issues/new"),
+ map[string]string{
+ "_csrf": GetCSRF(t, sessionUser2, path.Join(user, repo)),
+ "title": "Issue used for testing commit labels",
+ },
+ ), http.StatusOK)
+
+ // Issue number, expected to be 6
+ testID := "6"
+ // Add a few comments
+ // (first: Owner)
+ testEasyLeaveIssueComment(t, sessionUser2, user, repo, testID, "New comment from user2 on this issue") // Author, Owner
+ testEasyLeaveIssueComment(t, sessionUser1, user, repo, testID, "New comment from user1 on this issue") // Contributor
+ testEasyLeaveIssueComment(t, sessionUser5, user, repo, testID, "New comment from user5 on this issue") // no labels
+
+ // Fetch the issue page
+ response := sessionUser2.MakeRequest(t, NewRequest(t, "GET", path.Join(user, repo, "issues", testID)), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+ commentHeads := page.Find(".timeline .comment .comment-header .comment-header-right")
+ assert.EqualValues(t, 4, commentHeads.Length())
+
+ // Test the first comment and it's label "Owner"
+ labels := commentHeads.Eq(0).Find(".role-label")
+ assert.EqualValues(t, 1, labels.Length())
+ testIssueCommentUserLabel(t, labels.Eq(0), "Owner", ownerTooltip)
+
+ // Test the second comment and it's labels "Author" and "Owner"
+ labels = commentHeads.Eq(1).Find(".role-label")
+ assert.EqualValues(t, 2, labels.Length())
+ testIssueCommentUserLabel(t, labels.Eq(0), "Author", authorTooltipIssue)
+ testIssueCommentUserLabel(t, labels.Eq(1), "Owner", ownerTooltip)
+
+ // Test the third comment and it's label "Contributor"
+ labels = commentHeads.Eq(2).Find(".role-label")
+ assert.EqualValues(t, 1, labels.Length())
+ testIssueCommentUserLabel(t, labels.Eq(0), "Contributor", contributorTooltip)
+
+ // Test the fifth comment and it's lack of labels
+ labels = commentHeads.Eq(3).Find(".role-label")
+ assert.EqualValues(t, 0, labels.Length())
+ })
+}
+
+// testIssueCommentUserLabel is used to verify properties of a user label from a comment
+func testIssueCommentUserLabel(t *testing.T, label *goquery.Selection, expectedTitle, expectedTooltip string) {
+ t.Helper()
+ title := label.Text()
+ tooltip, exists := label.Attr("data-tooltip-content")
+ assert.True(t, exists)
+ assert.EqualValues(t, expectedTitle, strings.TrimSpace(title))
+ assert.EqualValues(t, expectedTooltip, strings.TrimSpace(tooltip))
+}
+
+// testEasyLeaveIssueComment is used to create a comment on an issue with minimum code and parameters
+func testEasyLeaveIssueComment(t *testing.T, session *TestSession, user, repo, id, message string) {
+ t.Helper()
+ session.MakeRequest(t, NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", id, "comments"), map[string]string{
+ "_csrf": GetCSRF(t, session, path.Join(user, repo, "issues", id)),
+ "content": message,
+ "status": "",
+ }), 200)
+}
+
+// testEasyLeaveIssueComment is used to create a comment on a pull request with minimum code and parameters
+// The POST request is supposed to use "issues" in the path. The CSRF is supposed to be generated for the PR page.
+func testEasyLeavePRComment(t *testing.T, session *TestSession, user, repo, id, message string) {
+ t.Helper()
+ session.MakeRequest(t, NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", id, "comments"), map[string]string{
+ "_csrf": GetCSRF(t, session, path.Join(user, repo, "pulls", id)),
+ "content": message,
+ "status": "",
+ }), 200)
+}
+
+// testEasyLeavePRReviewComment is used to add review comments to specific lines of changed files in the diff of the PR.
+func testEasyLeavePRReviewComment(t *testing.T, session *TestSession, user, repo, id, file, line, message, replyID string) {
+ t.Helper()
+ values := map[string]string{
+ "_csrf": GetCSRF(t, session, path.Join(user, repo, "pulls", id, "files")),
+ "origin": "diff",
+ "side": "proposed",
+ "line": line,
+ "path": file,
+ "content": message,
+ "single_review": "true",
+ }
+ if len(replyID) > 0 {
+ values["reply"] = replyID
+ }
+ session.MakeRequest(t, NewRequestWithValues(t, "POST", path.Join(user, repo, "pulls", id, "files/reviews/comments"), values), http.StatusOK)
+}
diff --git a/tests/integration/last_updated_time_test.go b/tests/integration/last_updated_time_test.go
new file mode 100644
index 0000000..e7bf3a4
--- /dev/null
+++ b/tests/integration/last_updated_time_test.go
@@ -0,0 +1,71 @@
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+ "testing"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRepoLastUpdatedTime(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user := "user2"
+ session := loginUser(t, user)
+
+ req := NewRequest(t, "GET", path.Join("explore", "repos"))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ node := doc.doc.Find(".flex-item-body").First()
+
+ {
+ buf := ""
+ findTextNonNested(t, node, &buf)
+ assert.Equal(t, "Updated", strings.TrimSpace(buf))
+ }
+
+ // Relative time should be present as a descendent
+ {
+ relativeTime := node.Find("relative-time").Text()
+ assert.True(t, strings.HasPrefix(relativeTime, "19")) // ~1970, might underflow with timezone
+ }
+ })
+}
+
+func TestBranchLastUpdatedTime(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user := "user2"
+ repo := "repo1"
+ session := loginUser(t, user)
+
+ req := NewRequest(t, "GET", path.Join(user, repo, "branches"))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ node := doc.doc.Find("p:has(span.commit-message)")
+
+ {
+ buf := ""
+ findTextNonNested(t, node, &buf)
+ assert.True(t, strings.Contains(buf, "Updated"))
+ }
+
+ {
+ relativeTime := node.Find("relative-time").Text()
+ assert.True(t, strings.HasPrefix(relativeTime, "2017"))
+ }
+ })
+}
+
+// Find all text that are direct descendents
+func findTextNonNested(t *testing.T, n *goquery.Selection, buf *string) {
+ t.Helper()
+
+ n.Contents().Each(func(i int, s *goquery.Selection) {
+ if goquery.NodeName(s) == "#text" {
+ *buf += s.Text()
+ }
+ })
+}
diff --git a/tests/integration/lfs_getobject_test.go b/tests/integration/lfs_getobject_test.go
new file mode 100644
index 0000000..351c1a3
--- /dev/null
+++ b/tests/integration/lfs_getobject_test.go
@@ -0,0 +1,228 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "archive/zip"
+ "bytes"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/lfs"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/klauspost/compress/gzhttp"
+ gzipp "github.com/klauspost/compress/gzip"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func storeObjectInRepo(t *testing.T, repositoryID int64, content *[]byte) string {
+ pointer, err := lfs.GeneratePointer(bytes.NewReader(*content))
+ require.NoError(t, err)
+
+ _, err = git_model.NewLFSMetaObject(db.DefaultContext, repositoryID, pointer)
+ require.NoError(t, err)
+ contentStore := lfs.NewContentStore()
+ exist, err := contentStore.Exists(pointer)
+ require.NoError(t, err)
+ if !exist {
+ err := contentStore.Put(pointer, bytes.NewReader(*content))
+ require.NoError(t, err)
+ }
+ return pointer.Oid
+}
+
+func storeAndGetLfsToken(t *testing.T, content *[]byte, extraHeader *http.Header, expectedStatus int, ts ...auth.AccessTokenScope) *httptest.ResponseRecorder {
+ repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "repo1")
+ require.NoError(t, err)
+ oid := storeObjectInRepo(t, repo.ID, content)
+ defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid)
+
+ token := getUserToken(t, "user2", ts...)
+
+ // Request OID
+ req := NewRequest(t, "GET", "/user2/repo1.git/info/lfs/objects/"+oid+"/test")
+ req.Header.Set("Accept-Encoding", "gzip")
+ req.SetBasicAuth("user2", token)
+ if extraHeader != nil {
+ for key, values := range *extraHeader {
+ for _, value := range values {
+ req.Header.Add(key, value)
+ }
+ }
+ }
+
+ resp := MakeRequest(t, req, expectedStatus)
+
+ return resp
+}
+
+func storeAndGetLfs(t *testing.T, content *[]byte, extraHeader *http.Header, expectedStatus int) *httptest.ResponseRecorder {
+ repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "repo1")
+ require.NoError(t, err)
+ oid := storeObjectInRepo(t, repo.ID, content)
+ defer git_model.RemoveLFSMetaObjectByOid(db.DefaultContext, repo.ID, oid)
+
+ session := loginUser(t, "user2")
+
+ // Request OID
+ req := NewRequest(t, "GET", "/user2/repo1.git/info/lfs/objects/"+oid+"/test")
+ req.Header.Set("Accept-Encoding", "gzip")
+ if extraHeader != nil {
+ for key, values := range *extraHeader {
+ for _, value := range values {
+ req.Header.Add(key, value)
+ }
+ }
+ }
+
+ resp := session.MakeRequest(t, req, expectedStatus)
+
+ return resp
+}
+
+func checkResponseTestContentEncoding(t *testing.T, content *[]byte, resp *httptest.ResponseRecorder, expectGzip bool) {
+ contentEncoding := resp.Header().Get("Content-Encoding")
+ if !expectGzip || !setting.EnableGzip {
+ assert.NotContains(t, contentEncoding, "gzip")
+
+ result := resp.Body.Bytes()
+ assert.Equal(t, *content, result)
+ } else {
+ assert.Contains(t, contentEncoding, "gzip")
+ gzippReader, err := gzipp.NewReader(resp.Body)
+ require.NoError(t, err)
+ result, err := io.ReadAll(gzippReader)
+ require.NoError(t, err)
+ assert.Equal(t, *content, result)
+ }
+}
+
+func TestGetLFSSmall(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ content := []byte("A very small file\n")
+
+ resp := storeAndGetLfs(t, &content, nil, http.StatusOK)
+ checkResponseTestContentEncoding(t, &content, resp, false)
+}
+
+func TestGetLFSSmallToken(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ content := []byte("A very small file\n")
+
+ resp := storeAndGetLfsToken(t, &content, nil, http.StatusOK, auth.AccessTokenScopePublicOnly, auth.AccessTokenScopeReadRepository)
+ checkResponseTestContentEncoding(t, &content, resp, false)
+}
+
+func TestGetLFSSmallTokenFail(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ content := []byte("A very small file\n")
+
+ storeAndGetLfsToken(t, &content, nil, http.StatusForbidden, auth.AccessTokenScopeReadNotification)
+}
+
+func TestGetLFSLarge(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ content := make([]byte, gzhttp.DefaultMinSize*10)
+ for i := range content {
+ content[i] = byte(i % 256)
+ }
+
+ resp := storeAndGetLfs(t, &content, nil, http.StatusOK)
+ checkResponseTestContentEncoding(t, &content, resp, true)
+}
+
+func TestGetLFSGzip(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ b := make([]byte, gzhttp.DefaultMinSize*10)
+ for i := range b {
+ b[i] = byte(i % 256)
+ }
+ outputBuffer := bytes.NewBuffer([]byte{})
+ gzippWriter := gzipp.NewWriter(outputBuffer)
+ gzippWriter.Write(b)
+ gzippWriter.Close()
+ content := outputBuffer.Bytes()
+
+ resp := storeAndGetLfs(t, &content, nil, http.StatusOK)
+ checkResponseTestContentEncoding(t, &content, resp, false)
+}
+
+func TestGetLFSZip(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ b := make([]byte, gzhttp.DefaultMinSize*10)
+ for i := range b {
+ b[i] = byte(i % 256)
+ }
+ outputBuffer := bytes.NewBuffer([]byte{})
+ zipWriter := zip.NewWriter(outputBuffer)
+ fileWriter, err := zipWriter.Create("default")
+ require.NoError(t, err)
+ fileWriter.Write(b)
+ zipWriter.Close()
+ content := outputBuffer.Bytes()
+
+ resp := storeAndGetLfs(t, &content, nil, http.StatusOK)
+ checkResponseTestContentEncoding(t, &content, resp, false)
+}
+
+func TestGetLFSRangeNo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ content := []byte("123456789\n")
+
+ resp := storeAndGetLfs(t, &content, nil, http.StatusOK)
+ assert.Equal(t, content, resp.Body.Bytes())
+}
+
+func TestGetLFSRange(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ content := []byte("123456789\n")
+
+ tests := []struct {
+ in string
+ out string
+ status int
+ }{
+ {"bytes=0-0", "1", http.StatusPartialContent},
+ {"bytes=0-1", "12", http.StatusPartialContent},
+ {"bytes=1-1", "2", http.StatusPartialContent},
+ {"bytes=1-3", "234", http.StatusPartialContent},
+ {"bytes=1-", "23456789\n", http.StatusPartialContent},
+ // end-range smaller than start-range is ignored
+ {"bytes=1-0", "23456789\n", http.StatusPartialContent},
+ {"bytes=0-10", "123456789\n", http.StatusPartialContent},
+ // end-range bigger than length-1 is ignored
+ {"bytes=0-11", "123456789\n", http.StatusPartialContent},
+ {"bytes=11-", "Requested Range Not Satisfiable", http.StatusRequestedRangeNotSatisfiable},
+ // incorrect header value cause whole header to be ignored
+ {"bytes=-", "123456789\n", http.StatusOK},
+ {"foobar", "123456789\n", http.StatusOK},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.in, func(t *testing.T) {
+ h := http.Header{
+ "Range": []string{tt.in},
+ }
+ resp := storeAndGetLfs(t, &content, &h, tt.status)
+ if tt.status == http.StatusPartialContent || tt.status == http.StatusOK {
+ assert.Equal(t, tt.out, resp.Body.String())
+ } else {
+ var er lfs.ErrorResponse
+ err := json.Unmarshal(resp.Body.Bytes(), &er)
+ require.NoError(t, err)
+ assert.Equal(t, tt.out, er.Message)
+ }
+ })
+ }
+}
diff --git a/tests/integration/lfs_local_endpoint_test.go b/tests/integration/lfs_local_endpoint_test.go
new file mode 100644
index 0000000..d42888b
--- /dev/null
+++ b/tests/integration/lfs_local_endpoint_test.go
@@ -0,0 +1,113 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "code.gitea.io/gitea/modules/lfs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func str2url(raw string) *url.URL {
+ u, _ := url.Parse(raw)
+ return u
+}
+
+func TestDetermineLocalEndpoint(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ root := t.TempDir()
+
+ rootdotgit := t.TempDir()
+ os.Mkdir(filepath.Join(rootdotgit, ".git"), 0o700)
+
+ lfsroot := t.TempDir()
+
+ // Test cases
+ cases := []struct {
+ cloneurl string
+ lfsurl string
+ expected *url.URL
+ }{
+ // case 0
+ {
+ cloneurl: root,
+ lfsurl: "",
+ expected: str2url(fmt.Sprintf("file://%s", root)),
+ },
+ // case 1
+ {
+ cloneurl: root,
+ lfsurl: lfsroot,
+ expected: str2url(fmt.Sprintf("file://%s", lfsroot)),
+ },
+ // case 2
+ {
+ cloneurl: "https://git.com/repo.git",
+ lfsurl: lfsroot,
+ expected: str2url(fmt.Sprintf("file://%s", lfsroot)),
+ },
+ // case 3
+ {
+ cloneurl: rootdotgit,
+ lfsurl: "",
+ expected: str2url(fmt.Sprintf("file://%s", filepath.Join(rootdotgit, ".git"))),
+ },
+ // case 4
+ {
+ cloneurl: "",
+ lfsurl: rootdotgit,
+ expected: str2url(fmt.Sprintf("file://%s", filepath.Join(rootdotgit, ".git"))),
+ },
+ // case 5
+ {
+ cloneurl: rootdotgit,
+ lfsurl: rootdotgit,
+ expected: str2url(fmt.Sprintf("file://%s", filepath.Join(rootdotgit, ".git"))),
+ },
+ // case 6
+ {
+ cloneurl: fmt.Sprintf("file://%s", root),
+ lfsurl: "",
+ expected: str2url(fmt.Sprintf("file://%s", root)),
+ },
+ // case 7
+ {
+ cloneurl: fmt.Sprintf("file://%s", root),
+ lfsurl: fmt.Sprintf("file://%s", lfsroot),
+ expected: str2url(fmt.Sprintf("file://%s", lfsroot)),
+ },
+ // case 8
+ {
+ cloneurl: root,
+ lfsurl: fmt.Sprintf("file://%s", lfsroot),
+ expected: str2url(fmt.Sprintf("file://%s", lfsroot)),
+ },
+ // case 9
+ {
+ cloneurl: "",
+ lfsurl: "/does/not/exist",
+ expected: nil,
+ },
+ // case 10
+ {
+ cloneurl: "",
+ lfsurl: "file:///does/not/exist",
+ expected: str2url("file:///does/not/exist"),
+ },
+ }
+
+ for n, c := range cases {
+ ep := lfs.DetermineEndpoint(c.cloneurl, c.lfsurl)
+
+ assert.Equal(t, c.expected, ep, "case %d: error should match", n)
+ }
+}
diff --git a/tests/integration/lfs_view_test.go b/tests/integration/lfs_view_test.go
new file mode 100644
index 0000000..06cea0d
--- /dev/null
+++ b/tests/integration/lfs_view_test.go
@@ -0,0 +1,180 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/lfs"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// check that files stored in LFS render properly in the web UI
+func TestLFSRender(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ // check that a markup file is flagged with "Stored in Git LFS" and shows its text
+ t.Run("Markup", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/lfs/src/branch/master/CONTRIBUTING.md")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body).doc
+
+ fileInfo := doc.Find("div.file-info-entry").First().Text()
+ assert.Contains(t, fileInfo, "Stored with Git LFS")
+
+ content := doc.Find("div.file-view").Text()
+ assert.Contains(t, content, "Testing documents in LFS")
+ })
+
+ // check that an image is flagged with "Stored in Git LFS" and renders inline
+ t.Run("Image", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/lfs/src/branch/master/jpeg.jpg")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body).doc
+
+ fileInfo := doc.Find("div.file-info-entry").First().Text()
+ assert.Contains(t, fileInfo, "Stored with Git LFS")
+
+ src, exists := doc.Find(".file-view img").Attr("src")
+ assert.True(t, exists, "The image should be in an <img> tag")
+ assert.Equal(t, "/user2/lfs/media/branch/master/jpeg.jpg", src, "The image should use the /media link because it's in LFS")
+ })
+
+ // check that a binary file is flagged with "Stored in Git LFS" and renders a /media/ link instead of a /raw/ link
+ t.Run("Binary", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/lfs/src/branch/master/crypt.bin")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body).doc
+
+ fileInfo := doc.Find("div.file-info-entry").First().Text()
+ assert.Contains(t, fileInfo, "Stored with Git LFS")
+
+ rawLink, exists := doc.Find("div.file-view > div.view-raw > a").Attr("href")
+ assert.True(t, exists, "Download link should render instead of content because this is a binary file")
+ assert.Equal(t, "/user2/lfs/media/branch/master/crypt.bin", rawLink, "The download link should use the proper /media link because it's in LFS")
+ })
+
+ // check that a directory with a README file shows its text
+ t.Run("Readme", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/lfs/src/branch/master/subdir")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body).doc
+
+ content := doc.Find("div.file-view").Text()
+ assert.Contains(t, content, "Testing READMEs in LFS")
+ })
+
+ t.Run("/settings/lfs/pointers", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // visit /user2/lfs/settings/lfs/pointer
+ req := NewRequest(t, "GET", "/user2/lfs/settings/lfs/pointers")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ // follow the first link to /user2/lfs/settings/lfs/find?oid=....
+ filesTable := NewHTMLParser(t, resp.Body).doc.Find("#lfs-files-table")
+ assert.Contains(t, filesTable.Text(), "Find commits")
+ lfsFind := filesTable.Find(`.primary.button[href^="/user2"]`)
+ assert.Positive(t, lfsFind.Length())
+ lfsFindPath, exists := lfsFind.First().Attr("href")
+ assert.True(t, exists)
+
+ assert.Contains(t, lfsFindPath, "oid=")
+ req = NewRequest(t, "GET", lfsFindPath)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body).doc
+ assert.Equal(t, 1, doc.Find(`.sha.label[href="/user2/lfs/commit/73cf03db6ece34e12bf91e8853dc58f678f2f82d"]`).Length(), "could not find link to commit")
+ })
+
+ // check that an invalid lfs entry defaults to plaintext
+ t.Run("Invalid", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/lfs/src/branch/master/invalid")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body).doc
+
+ content := doc.Find("div.file-view").Text()
+ assert.Contains(t, content, "oid sha256:9d178b5f15046343fd32f451df93acc2bdd9e6373be478b968e4cad6b6647351")
+ })
+}
+
+// TestLFSLockView tests the LFS lock view on settings page of repositories
+func TestLFSLockView(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // in org 3
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 3}) // own by org 3
+ session := loginUser(t, user2.Name)
+
+ // create a lock
+ lockPath := "test_lfs_lock_view.zip"
+ lockID := ""
+ {
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks", repo3.FullName()), map[string]string{"path": lockPath})
+ req.Header.Set("Accept", lfs.AcceptHeader)
+ req.Header.Set("Content-Type", lfs.MediaType)
+ resp := session.MakeRequest(t, req, http.StatusCreated)
+ lockResp := &api.LFSLockResponse{}
+ DecodeJSON(t, resp, lockResp)
+ lockID = lockResp.Lock.ID
+ }
+ defer func() {
+ // release the lock
+ req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s.git/info/lfs/locks/%s/unlock", repo3.FullName(), lockID), map[string]string{})
+ req.Header.Set("Accept", lfs.AcceptHeader)
+ req.Header.Set("Content-Type", lfs.MediaType)
+ session.MakeRequest(t, req, http.StatusOK)
+ }()
+
+ t.Run("owner name", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // make sure the display names are different, or the test is meaningless
+ require.NoError(t, repo3.LoadOwner(context.Background()))
+ require.NotEqual(t, user2.DisplayName(), repo3.Owner.DisplayName())
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/settings/lfs/locks", repo3.FullName()))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body).doc
+
+ tr := doc.Find("table#lfs-files-locks-table tbody tr")
+ require.Equal(t, 1, tr.Length())
+
+ td := tr.First().Find("td")
+ require.Equal(t, 4, td.Length())
+
+ // path
+ assert.Equal(t, lockPath, strings.TrimSpace(td.Eq(0).Text()))
+ // owner name
+ assert.Equal(t, user2.DisplayName(), strings.TrimSpace(td.Eq(1).Text()))
+ })
+}
diff --git a/tests/integration/linguist_test.go b/tests/integration/linguist_test.go
new file mode 100644
index 0000000..73423ee
--- /dev/null
+++ b/tests/integration/linguist_test.go
@@ -0,0 +1,269 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/indexer/stats"
+ "code.gitea.io/gitea/modules/queue"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLinguistSupport(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ /******************
+ ** Preparations **
+ ******************/
+ prep := func(t *testing.T, attribs string) (*repo_model.Repository, string, func()) {
+ t.Helper()
+
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ repo, sha, f := tests.CreateDeclarativeRepo(t, user2, "", nil, nil,
+ []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: ".gitattributes",
+ ContentReader: strings.NewReader(attribs),
+ },
+ {
+ Operation: "create",
+ TreePath: "docs.md",
+ ContentReader: strings.NewReader("This **is** a `markdown` file.\n"),
+ },
+ {
+ Operation: "create",
+ TreePath: "foo.c",
+ ContentReader: strings.NewReader("#include <stdio.h>\nint main() {\n printf(\"Hello world!\n\");\n return 0;\n}\n"),
+ },
+ {
+ Operation: "create",
+ TreePath: "foo.nib",
+ ContentReader: strings.NewReader("Pinky promise, this is not a generated file!\n"),
+ },
+ {
+ Operation: "create",
+ TreePath: ".dot.pas",
+ ContentReader: strings.NewReader("program Hello;\nbegin\n writeln('Hello, world.');\nend.\n"),
+ },
+ {
+ Operation: "create",
+ TreePath: "cpplint.py",
+ ContentReader: strings.NewReader("#! /usr/bin/env python\n\nprint(\"Hello world!\")\n"),
+ },
+ {
+ Operation: "create",
+ TreePath: "some-file.xml",
+ ContentReader: strings.NewReader("<?xml version=\"1.0\"?>\n<foo>\n <bar>Hello</bar>\n</foo>\n"),
+ },
+ })
+
+ return repo, sha, f
+ }
+
+ getFreshLanguageStats := func(t *testing.T, repo *repo_model.Repository, sha string) repo_model.LanguageStatList {
+ t.Helper()
+
+ err := stats.UpdateRepoIndexer(repo)
+ require.NoError(t, err)
+
+ require.NoError(t, queue.GetManager().FlushAll(context.Background(), 10*time.Second))
+
+ status, err := repo_model.GetIndexerStatus(db.DefaultContext, repo, repo_model.RepoIndexerTypeStats)
+ require.NoError(t, err)
+ assert.Equal(t, sha, status.CommitSha)
+ langs, err := repo_model.GetTopLanguageStats(db.DefaultContext, repo, 5)
+ require.NoError(t, err)
+
+ return langs
+ }
+
+ /***********
+ ** Tests **
+ ***********/
+
+ // 1. By default, documentation is not indexed
+ t.Run("default", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo, sha, f := prep(t, "")
+ defer f()
+
+ langs := getFreshLanguageStats(t, repo, sha)
+
+ // While this is a fairly short test, this exercises a number of
+ // things:
+ //
+ // - `.gitattributes` is empty, so `isDetectable.IsFalse()`,
+ // `isVendored.IsTrue()`, and `isDocumentation.IsTrue()` will be
+ // false for every file, because these are only true if an
+ // attribute is explicitly set.
+ //
+ // - There is `.dot.pas`, which would be considered Pascal source,
+ // but it is a dotfile (thus, `enry.IsDotFile()` applies), and as
+ // such, is not considered.
+ //
+ // - `some-file.xml` will be skipped because Enry considers XML
+ // configuration, and `enry.IsConfiguration()` will catch it.
+ //
+ // - `!isVendored.IsFalse()` evaluates to true, so
+ // `analyze.isVendor()` will be called on `cpplint.py`, which will
+ // be considered vendored, even though both the filename and
+ // contents would otherwise make it Python.
+ //
+ // - `!isDocumentation.IsFalse()` evaluates to true, so
+ // `enry.IsDocumentation()` will be called for `docs.md`, and will
+ // be considered documentation, thus, skipped.
+ //
+ // Thus, this exercises all of the conditions in the first big if
+ // that is supposed to filter out files early. With two short asserts!
+
+ assert.Len(t, langs, 1)
+ assert.Equal(t, "C", langs[0].Language)
+ })
+
+ // 2. Marking foo.c as non-detectable
+ t.Run("foo.c non-detectable", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo, sha, f := prep(t, "foo.c linguist-detectable=false\n")
+ defer f()
+
+ langs := getFreshLanguageStats(t, repo, sha)
+ assert.Empty(t, langs)
+ })
+
+ // 3. Marking Markdown detectable
+ t.Run("detectable markdown", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo, sha, f := prep(t, "*.md linguist-detectable\n")
+ defer f()
+
+ langs := getFreshLanguageStats(t, repo, sha)
+ assert.Len(t, langs, 2)
+ assert.Equal(t, "C", langs[0].Language)
+ assert.Equal(t, "Markdown", langs[1].Language)
+ })
+
+ // 4. Marking foo.c as documentation
+ t.Run("foo.c as documentation", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo, sha, f := prep(t, "foo.c linguist-documentation\n")
+ defer f()
+
+ langs := getFreshLanguageStats(t, repo, sha)
+ assert.Empty(t, langs)
+ })
+
+ // 5. Overriding a generated file
+ t.Run("linguist-generated=false", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo, sha, f := prep(t, "foo.nib linguist-generated=false\nfoo.nib linguist-language=Perl\n")
+ defer f()
+
+ langs := getFreshLanguageStats(t, repo, sha)
+ assert.Len(t, langs, 2)
+ assert.Equal(t, "C", langs[0].Language)
+ assert.Equal(t, "Perl", langs[1].Language)
+ })
+
+ // 6. Disabling vendoring for a file
+ t.Run("linguist-vendored=false", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo, sha, f := prep(t, "cpplint.py linguist-vendored=false\n")
+ defer f()
+
+ langs := getFreshLanguageStats(t, repo, sha)
+ assert.Len(t, langs, 2)
+ assert.Equal(t, "C", langs[0].Language)
+ assert.Equal(t, "Python", langs[1].Language)
+ })
+
+ // 7. Disabling vendoring for a file, with -linguist-vendored
+ t.Run("-linguist-vendored", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo, sha, f := prep(t, "cpplint.py -linguist-vendored\n")
+ defer f()
+
+ langs := getFreshLanguageStats(t, repo, sha)
+ assert.Len(t, langs, 2)
+ assert.Equal(t, "C", langs[0].Language)
+ assert.Equal(t, "Python", langs[1].Language)
+ })
+
+ // 8. Marking foo.c as vendored
+ t.Run("foo.c as vendored", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo, sha, f := prep(t, "foo.c linguist-vendored\n")
+ defer f()
+
+ langs := getFreshLanguageStats(t, repo, sha)
+ assert.Empty(t, langs)
+ })
+
+ // 9. Overriding the language
+ t.Run("linguist-language", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo, _, f := prep(t, "foo.c linguist-language=sh\n")
+ defer f()
+
+ assertFileLanguage := func(t *testing.T, uri, expectedLanguage string) {
+ t.Helper()
+
+ req := NewRequest(t, "GET", repo.Link()+uri)
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ language := strings.TrimSpace(htmlDoc.Find(".file-info .file-info-entry:nth-child(3)").Text())
+ assert.Equal(t, expectedLanguage, language)
+ }
+
+ t.Run("file source view", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertFileLanguage(t, "/src/branch/main/foo.c?display=source", "Bash")
+ })
+
+ t.Run("file blame view", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertFileLanguage(t, "/blame/branch/main/foo.c", "Bash")
+ })
+ })
+
+ // 10. Marking a file as non-documentation
+ t.Run("linguist-documentation=false", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo, sha, f := prep(t, "README.md linguist-documentation=false\n")
+ defer f()
+
+ langs := getFreshLanguageStats(t, repo, sha)
+ assert.Len(t, langs, 2)
+ assert.Equal(t, "Markdown", langs[0].Language)
+ assert.Equal(t, "C", langs[1].Language)
+ })
+ })
+}
diff --git a/tests/integration/links_test.go b/tests/integration/links_test.go
new file mode 100644
index 0000000..e9ad933
--- /dev/null
+++ b/tests/integration/links_test.go
@@ -0,0 +1,251 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "path"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ forgejo_context "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestLinksNoLogin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ links := []string{
+ "/explore/repos",
+ "/explore/repos?q=test",
+ "/explore/users",
+ "/explore/users?q=test",
+ "/explore/organizations",
+ "/explore/organizations?q=test",
+ "/",
+ "/user/sign_up",
+ "/user/login",
+ "/user/forgot_password",
+ "/api/swagger",
+ "/user2/repo1",
+ "/user2/repo1/",
+ "/user2/repo1/projects",
+ "/user2/repo1/projects/1",
+ "/.well-known/security.txt",
+ }
+
+ for _, link := range links {
+ req := NewRequest(t, "GET", link)
+ MakeRequest(t, req, http.StatusOK)
+ }
+}
+
+func TestRedirectsNoLogin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ redirects := map[string]string{
+ "/user2/repo1/commits/master": "/user2/repo1/commits/branch/master",
+ "/user2/repo1/src/master": "/user2/repo1/src/branch/master",
+ "/user2/repo1/src/master/file.txt": "/user2/repo1/src/branch/master/file.txt",
+ "/user2/repo1/src/master/directory/file.txt": "/user2/repo1/src/branch/master/directory/file.txt",
+ "/user/avatar/Ghost/-1": "/assets/img/avatar_default.png",
+ "/api/v1/swagger": "/api/swagger",
+ }
+ for link, redirectLink := range redirects {
+ req := NewRequest(t, "GET", link)
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ assert.EqualValues(t, path.Join(setting.AppSubURL, redirectLink), test.RedirectURL(resp))
+ }
+}
+
+func TestNoLoginNotExist(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ links := []string{
+ "/user5/repo4/projects",
+ "/user5/repo4/projects/3",
+ }
+
+ for _, link := range links {
+ req := NewRequest(t, "GET", link)
+ MakeRequest(t, req, http.StatusNotFound)
+ }
+}
+
+func testLinksAsUser(userName string, t *testing.T) {
+ links := []string{
+ "/explore/repos",
+ "/explore/repos?q=test",
+ "/explore/users",
+ "/explore/users?q=test",
+ "/explore/organizations",
+ "/explore/organizations?q=test",
+ "/",
+ "/user/forgot_password",
+ "/api/swagger",
+ "/issues",
+ "/issues?type=your_repositories&repos=[0]&sort=&state=open",
+ "/issues?type=assigned&repos=[0]&sort=&state=open",
+ "/issues?type=your_repositories&repos=[0]&sort=&state=closed",
+ "/issues?type=assigned&repos=[]&sort=&state=closed",
+ "/issues?type=assigned&sort=&state=open",
+ "/issues?type=created_by&repos=[1,2]&sort=&state=closed",
+ "/issues?type=created_by&repos=[1,2]&sort=&state=open",
+ "/pulls",
+ "/pulls?type=your_repositories&repos=[2]&sort=&state=open",
+ "/pulls?type=assigned&repos=[]&sort=&state=open",
+ "/pulls?type=created_by&repos=[0]&sort=&state=open",
+ "/pulls?type=your_repositories&repos=[0]&sort=&state=closed",
+ "/pulls?type=assigned&repos=[0]&sort=&state=closed",
+ "/pulls?type=created_by&repos=[0]&sort=&state=closed",
+ "/milestones",
+ "/milestones?sort=mostcomplete&state=closed",
+ "/milestones?type=your_repositories&sort=mostcomplete&state=closed",
+ "/milestones?sort=&repos=[1]&state=closed",
+ "/milestones?sort=&repos=[1]&state=open",
+ "/milestones?repos=[0]&sort=mostissues&state=open",
+ "/notifications",
+ "/repo/create",
+ "/repo/migrate",
+ "/org/create",
+ "/user2",
+ "/user2?tab=stars",
+ "/user2?tab=activity",
+ "/user/settings",
+ "/user/settings/account",
+ "/user/settings/security",
+ "/user/settings/security/two_factor/enroll",
+ "/user/settings/keys",
+ "/user/settings/organization",
+ "/user/settings/repos",
+ }
+
+ session := loginUser(t, userName)
+ for _, link := range links {
+ req := NewRequest(t, "GET", link)
+ session.MakeRequest(t, req, http.StatusOK)
+ }
+
+ reqAPI := NewRequestf(t, "GET", "/api/v1/users/%s/repos", userName)
+ respAPI := MakeRequest(t, reqAPI, http.StatusOK)
+
+ var apiRepos []*api.Repository
+ DecodeJSON(t, respAPI, &apiRepos)
+
+ repoLinks := []string{
+ "",
+ "/issues",
+ "/pulls",
+ "/commits/branch/master",
+ "/graph",
+ "/settings",
+ "/settings/collaboration",
+ "/settings/branches",
+ "/settings/hooks",
+ // FIXME: below links should return 200 but 404 ??
+ //"/settings/hooks/git",
+ //"/settings/hooks/git/pre-receive",
+ //"/settings/hooks/git/update",
+ //"/settings/hooks/git/post-receive",
+ "/settings/keys",
+ "/releases",
+ "/releases/new",
+ //"/wiki/_pages",
+ "/wiki/?action=_new",
+ "/activity",
+ }
+
+ for _, repo := range apiRepos {
+ for _, link := range repoLinks {
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s%s", userName, repo.Name, link))
+ session.MakeRequest(t, req, http.StatusOK)
+ }
+ }
+}
+
+func TestLinksLogin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ testLinksAsUser("user2", t)
+}
+
+func TestRedirectsWebhooks(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ //
+ // A redirect means the route exists but not if it performs as intended.
+ //
+ for _, kind := range []string{"forgejo", "gitea"} {
+ redirects := []struct {
+ from string
+ to string
+ verb string
+ }{
+ {from: "/user2/repo1/settings/hooks/" + kind + "/new", to: "/user/login", verb: "GET"},
+ {from: "/user/settings/hooks/" + kind + "/new", to: "/user/login", verb: "GET"},
+ {from: "/admin/system-hooks/" + kind + "/new", to: "/user/login", verb: "GET"},
+ {from: "/admin/default-hooks/" + kind + "/new", to: "/user/login", verb: "GET"},
+ }
+ for _, info := range redirects {
+ req := NewRequest(t, info.verb, info.from)
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ assert.EqualValues(t, path.Join(setting.AppSubURL, info.to), test.RedirectURL(resp), info.from)
+ }
+ }
+
+ for _, kind := range []string{"forgejo", "gitea"} {
+ csrf := []struct {
+ from string
+ verb string
+ }{
+ {from: "/user2/repo1/settings/hooks/" + kind + "/new", verb: "POST"},
+ {from: "/admin/hooks/1", verb: "POST"},
+ {from: "/admin/system-hooks/" + kind + "/new", verb: "POST"},
+ {from: "/admin/default-hooks/" + kind + "/new", verb: "POST"},
+ {from: "/user2/repo1/settings/hooks/1", verb: "POST"},
+ }
+ for _, info := range csrf {
+ req := NewRequest(t, info.verb, info.from)
+ resp := MakeRequest(t, req, http.StatusBadRequest)
+ assert.Contains(t, resp.Body.String(), forgejo_context.CsrfErrorString)
+ }
+ }
+}
+
+func TestRepoLinks(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // repo1 has enabled almost features, so we can test most links
+ repoLink := "/user2/repo1"
+ links := []string{
+ "/actions",
+ "/packages",
+ "/projects",
+ }
+
+ // anonymous user
+ for _, link := range links {
+ req := NewRequest(t, "GET", repoLink+link)
+ MakeRequest(t, req, http.StatusOK)
+ }
+
+ // admin/owner user
+ session := loginUser(t, "user1")
+ for _, link := range links {
+ req := NewRequest(t, "GET", repoLink+link)
+ session.MakeRequest(t, req, http.StatusOK)
+ }
+
+ // non-admin non-owner user
+ session = loginUser(t, "user2")
+ for _, link := range links {
+ req := NewRequest(t, "GET", repoLink+link)
+ session.MakeRequest(t, req, http.StatusOK)
+ }
+}
diff --git a/tests/integration/markup_external_test.go b/tests/integration/markup_external_test.go
new file mode 100644
index 0000000..0eaa966
--- /dev/null
+++ b/tests/integration/markup_external_test.go
@@ -0,0 +1,40 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestExternalMarkupRenderer(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ if !setting.Database.Type.IsSQLite3() {
+ t.Skip()
+ return
+ }
+
+ const repoURL = "user30/renderer"
+ req := NewRequest(t, "GET", repoURL+"/src/branch/master/README.html")
+ resp := MakeRequest(t, req, http.StatusOK)
+ assert.EqualValues(t, "text/html; charset=utf-8", resp.Header()["Content-Type"][0])
+
+ bs, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+
+ doc := NewHTMLParser(t, bytes.NewBuffer(bs))
+ div := doc.Find("div.file-view")
+ data, err := div.Html()
+ require.NoError(t, err)
+ assert.EqualValues(t, "<div>\n\ttest external renderer\n</div>", strings.TrimSpace(data))
+}
diff --git a/tests/integration/markup_test.go b/tests/integration/markup_test.go
new file mode 100644
index 0000000..d63190a
--- /dev/null
+++ b/tests/integration/markup_test.go
@@ -0,0 +1,72 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRenderAlertBlocks(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteMisc)
+
+ assertAlertBlock := func(t *testing.T, input, alertType, alertIcon string) {
+ t.Helper()
+
+ blockquoteAttr := fmt.Sprintf(`<blockquote class="attention-header attention-%s"`, strings.ToLower(alertType))
+ classAttr := fmt.Sprintf(`class="attention-%s"`, strings.ToLower(alertType))
+ iconAttr := fmt.Sprintf(`svg octicon-%s`, alertIcon)
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/markdown", &api.MarkdownOption{
+ Text: input,
+ Mode: "markdown",
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ body := resp.Body.String()
+ assert.Contains(t, body, blockquoteAttr)
+ assert.Contains(t, body, classAttr)
+ assert.Contains(t, body, iconAttr)
+ }
+
+ t.Run("legacy style", func(t *testing.T) {
+ for alertType, alertIcon := range map[string]string{"Note": "info", "Warning": "alert"} {
+ t.Run(alertType, func(t *testing.T) {
+ input := fmt.Sprintf(`> **%s**
+>
+> This is a %s.`, alertType, alertType)
+
+ assertAlertBlock(t, input, alertType, alertIcon)
+ })
+ }
+ })
+
+ t.Run("modern style", func(t *testing.T) {
+ for alertType, alertIcon := range map[string]string{
+ "NOTE": "info",
+ "TIP": "light-bulb",
+ "IMPORTANT": "report",
+ "WARNING": "alert",
+ "CAUTION": "stop",
+ } {
+ t.Run(alertType, func(t *testing.T) {
+ input := fmt.Sprintf(`> [!%s]
+>
+> This is a %s.`, alertType, alertType)
+
+ assertAlertBlock(t, input, alertType, alertIcon)
+ })
+ }
+ })
+}
diff --git a/tests/integration/migrate_test.go b/tests/integration/migrate_test.go
new file mode 100644
index 0000000..43cfc4f
--- /dev/null
+++ b/tests/integration/migrate_test.go
@@ -0,0 +1,130 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/services/migrations"
+ "code.gitea.io/gitea/services/repository"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMigrateLocalPath(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
+
+ old := setting.ImportLocalPaths
+ setting.ImportLocalPaths = true
+
+ basePath := t.TempDir()
+
+ lowercasePath := filepath.Join(basePath, "lowercase")
+ err := os.Mkdir(lowercasePath, 0o700)
+ require.NoError(t, err)
+
+ err = migrations.IsMigrateURLAllowed(lowercasePath, adminUser)
+ require.NoError(t, err, "case lowercase path")
+
+ mixedcasePath := filepath.Join(basePath, "mIxeDCaSe")
+ err = os.Mkdir(mixedcasePath, 0o700)
+ require.NoError(t, err)
+
+ err = migrations.IsMigrateURLAllowed(mixedcasePath, adminUser)
+ require.NoError(t, err, "case mixedcase path")
+
+ setting.ImportLocalPaths = old
+}
+
+func TestMigrate(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ AllowLocalNetworks := setting.Migrations.AllowLocalNetworks
+ setting.Migrations.AllowLocalNetworks = true
+ AppVer := setting.AppVer
+ // Gitea SDK (go-sdk) need to parse the AppVer from server response, so we must set it to a valid version string.
+ setting.AppVer = "1.16.0"
+ defer func() {
+ setting.Migrations.AllowLocalNetworks = AllowLocalNetworks
+ setting.AppVer = AppVer
+ migrations.Init()
+ }()
+ require.NoError(t, migrations.Init())
+
+ ownerName := "user2"
+ repoName := "repo1"
+ repoOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: ownerName})
+ session := loginUser(t, ownerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeReadMisc)
+
+ for _, s := range []struct {
+ svc structs.GitServiceType
+ }{
+ {svc: structs.GiteaService},
+ {svc: structs.ForgejoService},
+ } {
+ // Step 0: verify the repo is available
+ req := NewRequestf(t, "GET", "/%s/%s", ownerName, repoName)
+ _ = session.MakeRequest(t, req, http.StatusOK)
+ // Step 1: get the Gitea migration form
+ req = NewRequestf(t, "GET", "/repo/migrate/?service_type=%d", s.svc)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ // Step 2: load the form
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ // Check form title
+ title := htmlDoc.doc.Find("title").Text()
+ assert.Contains(t, title, translation.NewLocale("en-US").TrString("new_migrate.title"))
+ // Get the link of migration button
+ link, exists := htmlDoc.doc.Find(`form.ui.form[action^="/repo/migrate"]`).Attr("action")
+ assert.True(t, exists, "The template has changed")
+ // Step 4: submit the migration to only migrate issues
+ migratedRepoName := "otherrepo"
+ req = NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "service": fmt.Sprintf("%d", s.svc),
+ "clone_addr": fmt.Sprintf("%s%s/%s", u, ownerName, repoName),
+ "auth_token": token,
+ "issues": "on",
+ "repo_name": migratedRepoName,
+ "description": "",
+ "uid": fmt.Sprintf("%d", repoOwner.ID),
+ })
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ // Step 5: a redirection displays the migrated repository
+ loc := resp.Header().Get("Location")
+ assert.EqualValues(t, fmt.Sprintf("/%s/%s", ownerName, migratedRepoName), loc)
+ // Step 6: check the repo was created
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: migratedRepoName})
+
+ // Step 7: delete the repository, so we can test with other services
+ err := repository.DeleteRepository(context.Background(), repoOwner, repo, false)
+ require.NoError(t, err)
+ }
+ })
+}
+
+func Test_UpdateCommentsMigrationsByType(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ err := issues_model.UpdateCommentsMigrationsByType(db.DefaultContext, structs.GithubService, "1", 1)
+ require.NoError(t, err)
+}
diff --git a/tests/integration/migration-test/forgejo-v1.19.0.mysql.sql.gz b/tests/integration/migration-test/forgejo-v1.19.0.mysql.sql.gz
new file mode 100644
index 0000000..4cea13b
--- /dev/null
+++ b/tests/integration/migration-test/forgejo-v1.19.0.mysql.sql.gz
Binary files differ
diff --git a/tests/integration/migration-test/forgejo-v1.19.0.postgres.sql.gz b/tests/integration/migration-test/forgejo-v1.19.0.postgres.sql.gz
new file mode 100644
index 0000000..7fdc409
--- /dev/null
+++ b/tests/integration/migration-test/forgejo-v1.19.0.postgres.sql.gz
Binary files differ
diff --git a/tests/integration/migration-test/forgejo-v1.19.0.sqlite3.sql.gz b/tests/integration/migration-test/forgejo-v1.19.0.sqlite3.sql.gz
new file mode 100644
index 0000000..dba4baf
--- /dev/null
+++ b/tests/integration/migration-test/forgejo-v1.19.0.sqlite3.sql.gz
Binary files differ
diff --git a/tests/integration/migration-test/gitea-v1.6.4.mysql.sql.gz b/tests/integration/migration-test/gitea-v1.6.4.mysql.sql.gz
new file mode 100644
index 0000000..30cca8b
--- /dev/null
+++ b/tests/integration/migration-test/gitea-v1.6.4.mysql.sql.gz
Binary files differ
diff --git a/tests/integration/migration-test/gitea-v1.6.4.postgres.sql.gz b/tests/integration/migration-test/gitea-v1.6.4.postgres.sql.gz
new file mode 100644
index 0000000..bd66f6b
--- /dev/null
+++ b/tests/integration/migration-test/gitea-v1.6.4.postgres.sql.gz
Binary files differ
diff --git a/tests/integration/migration-test/gitea-v1.6.4.sqlite3.sql.gz b/tests/integration/migration-test/gitea-v1.6.4.sqlite3.sql.gz
new file mode 100644
index 0000000..a777c53
--- /dev/null
+++ b/tests/integration/migration-test/gitea-v1.6.4.sqlite3.sql.gz
Binary files differ
diff --git a/tests/integration/migration-test/gitea-v1.7.0.mysql.sql.gz b/tests/integration/migration-test/gitea-v1.7.0.mysql.sql.gz
new file mode 100644
index 0000000..d0ab108
--- /dev/null
+++ b/tests/integration/migration-test/gitea-v1.7.0.mysql.sql.gz
Binary files differ
diff --git a/tests/integration/migration-test/gitea-v1.7.0.postgres.sql.gz b/tests/integration/migration-test/gitea-v1.7.0.postgres.sql.gz
new file mode 100644
index 0000000..e4716c6
--- /dev/null
+++ b/tests/integration/migration-test/gitea-v1.7.0.postgres.sql.gz
Binary files differ
diff --git a/tests/integration/migration-test/gitea-v1.7.0.sqlite3.sql.gz b/tests/integration/migration-test/gitea-v1.7.0.sqlite3.sql.gz
new file mode 100644
index 0000000..3155249
--- /dev/null
+++ b/tests/integration/migration-test/gitea-v1.7.0.sqlite3.sql.gz
Binary files differ
diff --git a/tests/integration/migration-test/migration_test.go b/tests/integration/migration-test/migration_test.go
new file mode 100644
index 0000000..a391296
--- /dev/null
+++ b/tests/integration/migration-test/migration_test.go
@@ -0,0 +1,323 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package migrations
+
+import (
+ "compress/gzip"
+ "context"
+ "database/sql"
+ "fmt"
+ "io"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/migrations"
+ migrate_base "code.gitea.io/gitea/models/migrations/base"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/charset"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/testlogger"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "xorm.io/xorm"
+)
+
+var currentEngine *xorm.Engine
+
+func initMigrationTest(t *testing.T) func() {
+ log.RegisterEventWriter("test", testlogger.NewTestLoggerWriter)
+
+ deferFn := tests.PrintCurrentTest(t, 2)
+ giteaRoot := base.SetupGiteaRoot()
+ if giteaRoot == "" {
+ tests.Printf("Environment variable $GITEA_ROOT not set\n")
+ os.Exit(1)
+ }
+ setting.AppPath = path.Join(giteaRoot, "gitea")
+ if _, err := os.Stat(setting.AppPath); err != nil {
+ tests.Printf("Could not find gitea binary at %s\n", setting.AppPath)
+ os.Exit(1)
+ }
+
+ giteaConf := os.Getenv("GITEA_CONF")
+ if giteaConf == "" {
+ tests.Printf("Environment variable $GITEA_CONF not set\n")
+ os.Exit(1)
+ } else if !path.IsAbs(giteaConf) {
+ setting.CustomConf = path.Join(giteaRoot, giteaConf)
+ } else {
+ setting.CustomConf = giteaConf
+ }
+
+ unittest.InitSettings()
+
+ assert.NotEmpty(t, setting.RepoRootPath)
+ require.NoError(t, util.RemoveAll(setting.RepoRootPath))
+ require.NoError(t, unittest.CopyDir(path.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath))
+ ownerDirs, err := os.ReadDir(setting.RepoRootPath)
+ if err != nil {
+ require.NoError(t, err, "unable to read the new repo root: %v\n", err)
+ }
+ for _, ownerDir := range ownerDirs {
+ if !ownerDir.Type().IsDir() {
+ continue
+ }
+ repoDirs, err := os.ReadDir(filepath.Join(setting.RepoRootPath, ownerDir.Name()))
+ if err != nil {
+ require.NoError(t, err, "unable to read the new repo root: %v\n", err)
+ }
+ for _, repoDir := range repoDirs {
+ _ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "pack"), 0o755)
+ _ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "info"), 0o755)
+ _ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "heads"), 0o755)
+ _ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "tag"), 0o755)
+ }
+ }
+
+ require.NoError(t, git.InitFull(context.Background()))
+ setting.LoadDBSetting()
+ setting.InitLoggersForTest()
+ return deferFn
+}
+
+func availableVersions() ([]string, error) {
+ migrationsDir, err := os.Open("tests/integration/migration-test")
+ if err != nil {
+ return nil, err
+ }
+ defer migrationsDir.Close()
+ versionRE, err := regexp.Compile(".*-v(?P<version>.+)\\." + regexp.QuoteMeta(setting.Database.Type.String()) + "\\.sql.gz")
+ if err != nil {
+ return nil, err
+ }
+
+ filenames, err := migrationsDir.Readdirnames(-1)
+ if err != nil {
+ return nil, err
+ }
+ versions := []string{}
+ for _, filename := range filenames {
+ if versionRE.MatchString(filename) {
+ substrings := versionRE.FindStringSubmatch(filename)
+ versions = append(versions, substrings[1])
+ }
+ }
+ sort.Strings(versions)
+ return versions, nil
+}
+
+func readSQLFromFile(version string) (string, error) {
+ filename := fmt.Sprintf("tests/integration/migration-test/gitea-v%s.%s.sql.gz", version, setting.Database.Type)
+
+ if _, err := os.Stat(filename); os.IsNotExist(err) {
+ filename = fmt.Sprintf("tests/integration/migration-test/forgejo-v%s.%s.sql.gz", version, setting.Database.Type)
+ if _, err := os.Stat(filename); os.IsNotExist(err) {
+ return "", nil
+ }
+ }
+
+ file, err := os.Open(filename)
+ if err != nil {
+ return "", err
+ }
+ defer file.Close()
+
+ gr, err := gzip.NewReader(file)
+ if err != nil {
+ return "", err
+ }
+ defer gr.Close()
+
+ bytes, err := io.ReadAll(gr)
+ if err != nil {
+ return "", err
+ }
+ return string(charset.MaybeRemoveBOM(bytes, charset.ConvertOpts{})), nil
+}
+
+func restoreOldDB(t *testing.T, version string) bool {
+ data, err := readSQLFromFile(version)
+ require.NoError(t, err)
+ if len(data) == 0 {
+ tests.Printf("No db found to restore for %s version: %s\n", setting.Database.Type, version)
+ return false
+ }
+
+ switch {
+ case setting.Database.Type.IsSQLite3():
+ util.Remove(setting.Database.Path)
+ err := os.MkdirAll(path.Dir(setting.Database.Path), os.ModePerm)
+ require.NoError(t, err)
+
+ db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?cache=shared&mode=rwc&_busy_timeout=%d&_txlock=immediate", setting.Database.Path, setting.Database.Timeout))
+ require.NoError(t, err)
+ defer db.Close()
+
+ _, err = db.Exec(data)
+ require.NoError(t, err)
+ db.Close()
+
+ case setting.Database.Type.IsMySQL():
+ db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/",
+ setting.Database.User, setting.Database.Passwd, setting.Database.Host))
+ require.NoError(t, err)
+ defer db.Close()
+
+ databaseName := strings.SplitN(setting.Database.Name, "?", 2)[0]
+
+ _, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", databaseName))
+ require.NoError(t, err)
+
+ _, err = db.Exec(fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", databaseName))
+ require.NoError(t, err)
+ db.Close()
+
+ db, err = sql.Open("mysql", fmt.Sprintf("%s:%s@tcp(%s)/%s",
+ setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name))
+ require.NoError(t, err)
+ defer db.Close()
+
+ _, err = db.Exec(data)
+ require.NoError(t, err)
+ db.Close()
+
+ case setting.Database.Type.IsPostgreSQL():
+ var db *sql.DB
+ var err error
+ if setting.Database.Host[0] == '/' {
+ db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/?sslmode=%s&host=%s",
+ setting.Database.User, setting.Database.Passwd, setting.Database.SSLMode, setting.Database.Host))
+ require.NoError(t, err)
+ } else {
+ db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/?sslmode=%s",
+ setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.SSLMode))
+ require.NoError(t, err)
+ }
+ defer db.Close()
+
+ _, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", setting.Database.Name))
+ require.NoError(t, err)
+
+ _, err = db.Exec(fmt.Sprintf("CREATE DATABASE %s", setting.Database.Name))
+ require.NoError(t, err)
+ db.Close()
+
+ // Check if we need to setup a specific schema
+ if len(setting.Database.Schema) != 0 {
+ if setting.Database.Host[0] == '/' {
+ db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/%s?sslmode=%s&host=%s",
+ setting.Database.User, setting.Database.Passwd, setting.Database.Name, setting.Database.SSLMode, setting.Database.Host))
+ } else {
+ db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s",
+ setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode))
+ }
+ require.NoError(t, err)
+
+ defer db.Close()
+
+ schrows, err := db.Query(fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name = '%s'", setting.Database.Schema))
+ require.NoError(t, err)
+ if !assert.NotEmpty(t, schrows) {
+ return false
+ }
+
+ if !schrows.Next() {
+ // Create and setup a DB schema
+ _, err = db.Exec(fmt.Sprintf("CREATE SCHEMA %s", setting.Database.Schema))
+ require.NoError(t, err)
+ }
+ schrows.Close()
+
+ // Make the user's default search path the created schema; this will affect new connections
+ _, err = db.Exec(fmt.Sprintf(`ALTER USER "%s" SET search_path = %s`, setting.Database.User, setting.Database.Schema))
+ require.NoError(t, err)
+
+ db.Close()
+ }
+
+ if setting.Database.Host[0] == '/' {
+ db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/%s?sslmode=%s&host=%s",
+ setting.Database.User, setting.Database.Passwd, setting.Database.Name, setting.Database.SSLMode, setting.Database.Host))
+ } else {
+ db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s",
+ setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode))
+ }
+ require.NoError(t, err)
+ defer db.Close()
+
+ _, err = db.Exec(data)
+ require.NoError(t, err)
+ db.Close()
+ }
+ return true
+}
+
+func wrappedMigrate(x *xorm.Engine) error {
+ currentEngine = x
+ return migrations.Migrate(x)
+}
+
+func doMigrationTest(t *testing.T, version string) {
+ defer tests.PrintCurrentTest(t)()
+ tests.Printf("Performing migration test for %s version: %s\n", setting.Database.Type, version)
+ if !restoreOldDB(t, version) {
+ return
+ }
+
+ setting.InitSQLLoggersForCli(log.INFO)
+
+ err := db.InitEngineWithMigration(context.Background(), wrappedMigrate)
+ require.NoError(t, err)
+ currentEngine.Close()
+
+ beans, _ := db.NamesToBean()
+
+ err = db.InitEngineWithMigration(context.Background(), func(x *xorm.Engine) error {
+ currentEngine = x
+ return migrate_base.RecreateTables(beans...)(x)
+ })
+ require.NoError(t, err)
+ currentEngine.Close()
+
+ // We do this a second time to ensure that there is not a problem with retained indices
+ err = db.InitEngineWithMigration(context.Background(), func(x *xorm.Engine) error {
+ currentEngine = x
+ return migrate_base.RecreateTables(beans...)(x)
+ })
+ require.NoError(t, err)
+
+ currentEngine.Close()
+}
+
+func TestMigrations(t *testing.T) {
+ defer initMigrationTest(t)()
+
+ dialect := setting.Database.Type
+ versions, err := availableVersions()
+ require.NoError(t, err)
+
+ if len(versions) == 0 {
+ tests.Printf("No old database versions available to migration test for %s\n", dialect)
+ return
+ }
+
+ tests.Printf("Preparing to test %d migrations for %s\n", len(versions), dialect)
+ for _, version := range versions {
+ t.Run(fmt.Sprintf("Migrate-%s-%s", dialect, version), func(t *testing.T) {
+ doMigrationTest(t, version)
+ })
+ }
+}
diff --git a/tests/integration/milestone_test.go b/tests/integration/milestone_test.go
new file mode 100644
index 0000000..ba46740
--- /dev/null
+++ b/tests/integration/milestone_test.go
@@ -0,0 +1,25 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestViewMilestones(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/milestones")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ search := htmlDoc.doc.Find(".list-header-search > .search > .input > input")
+ placeholder, _ := search.Attr("placeholder")
+ assert.Equal(t, "Search milestones...", placeholder)
+}
diff --git a/tests/integration/mirror_pull_test.go b/tests/integration/mirror_pull_test.go
new file mode 100644
index 0000000..60fb47e
--- /dev/null
+++ b/tests/integration/mirror_pull_test.go
@@ -0,0 +1,104 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/migration"
+ mirror_service "code.gitea.io/gitea/services/mirror"
+ release_service "code.gitea.io/gitea/services/release"
+ repo_service "code.gitea.io/gitea/services/repository"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMirrorPull(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ repoPath := repo_model.RepoPath(user.Name, repo.Name)
+
+ opts := migration.MigrateOptions{
+ RepoName: "test_mirror",
+ Description: "Test mirror",
+ Private: false,
+ Mirror: true,
+ CloneAddr: repoPath,
+ Wiki: true,
+ Releases: false,
+ }
+
+ mirrorRepo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, user, repo_service.CreateRepoOptions{
+ Name: opts.RepoName,
+ Description: opts.Description,
+ IsPrivate: opts.Private,
+ IsMirror: opts.Mirror,
+ Status: repo_model.RepositoryBeingMigrated,
+ })
+ require.NoError(t, err)
+ assert.True(t, mirrorRepo.IsMirror, "expected pull-mirror repo to be marked as a mirror immediately after its creation")
+
+ ctx := context.Background()
+
+ mirror, err := repo_service.MigrateRepositoryGitData(ctx, user, mirrorRepo, opts, nil)
+ require.NoError(t, err)
+
+ gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ findOptions := repo_model.FindReleasesOptions{
+ IncludeDrafts: true,
+ IncludeTags: true,
+ RepoID: mirror.ID,
+ }
+ initCount, err := db.Count[repo_model.Release](db.DefaultContext, findOptions)
+ require.NoError(t, err)
+
+ require.NoError(t, release_service.CreateRelease(gitRepo, &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v0.2",
+ Target: "master",
+ Title: "v0.2 is released",
+ Note: "v0.2 is released",
+ IsDraft: false,
+ IsPrerelease: false,
+ IsTag: true,
+ }, "", []*release_service.AttachmentChange{}))
+
+ _, err = repo_model.GetMirrorByRepoID(ctx, mirror.ID)
+ require.NoError(t, err)
+
+ ok := mirror_service.SyncPullMirror(ctx, mirror.ID)
+ assert.True(t, ok)
+
+ count, err := db.Count[repo_model.Release](db.DefaultContext, findOptions)
+ require.NoError(t, err)
+ assert.EqualValues(t, initCount+1, count)
+
+ release, err := repo_model.GetRelease(db.DefaultContext, repo.ID, "v0.2")
+ require.NoError(t, err)
+ require.NoError(t, release_service.DeleteReleaseByID(ctx, repo, release, user, true))
+
+ ok = mirror_service.SyncPullMirror(ctx, mirror.ID)
+ assert.True(t, ok)
+
+ count, err = db.Count[repo_model.Release](db.DefaultContext, findOptions)
+ require.NoError(t, err)
+ assert.EqualValues(t, initCount, count)
+}
diff --git a/tests/integration/mirror_push_test.go b/tests/integration/mirror_push_test.go
new file mode 100644
index 0000000..2dda4d6
--- /dev/null
+++ b/tests/integration/mirror_push_test.go
@@ -0,0 +1,325 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+
+ asymkey_model "code.gitea.io/gitea/models/asymkey"
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ gitea_context "code.gitea.io/gitea/services/context"
+ doctor "code.gitea.io/gitea/services/doctor"
+ "code.gitea.io/gitea/services/migrations"
+ mirror_service "code.gitea.io/gitea/services/mirror"
+ repo_service "code.gitea.io/gitea/services/repository"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMirrorPush(t *testing.T) {
+ onGiteaRun(t, testMirrorPush)
+}
+
+func testMirrorPush(t *testing.T, u *url.URL) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
+
+ require.NoError(t, migrations.Init())
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ mirrorRepo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user, user, repo_service.CreateRepoOptions{
+ Name: "test-push-mirror",
+ })
+ require.NoError(t, err)
+
+ ctx := NewAPITestContext(t, user.LowerName, srcRepo.Name)
+
+ doCreatePushMirror(ctx, fmt.Sprintf("%s%s/%s", u.String(), url.PathEscape(ctx.Username), url.PathEscape(mirrorRepo.Name)), user.LowerName, userPassword)(t)
+ doCreatePushMirror(ctx, fmt.Sprintf("%s%s/%s", u.String(), url.PathEscape(ctx.Username), url.PathEscape("does-not-matter")), user.LowerName, userPassword)(t)
+
+ mirrors, _, err := repo_model.GetPushMirrorsByRepoID(db.DefaultContext, srcRepo.ID, db.ListOptions{})
+ require.NoError(t, err)
+ assert.Len(t, mirrors, 2)
+
+ ok := mirror_service.SyncPushMirror(context.Background(), mirrors[0].ID)
+ assert.True(t, ok)
+
+ srcGitRepo, err := gitrepo.OpenRepository(git.DefaultContext, srcRepo)
+ require.NoError(t, err)
+ defer srcGitRepo.Close()
+
+ srcCommit, err := srcGitRepo.GetBranchCommit("master")
+ require.NoError(t, err)
+
+ mirrorGitRepo, err := gitrepo.OpenRepository(git.DefaultContext, mirrorRepo)
+ require.NoError(t, err)
+ defer mirrorGitRepo.Close()
+
+ mirrorCommit, err := mirrorGitRepo.GetBranchCommit("master")
+ require.NoError(t, err)
+
+ assert.Equal(t, srcCommit.ID, mirrorCommit.ID)
+
+ // Test that we can "repair" push mirrors where the remote doesn't exist in git's state.
+ // To do that, we artificially remove the remote...
+ cmd := git.NewCommand(db.DefaultContext, "remote", "rm").AddDynamicArguments(mirrors[0].RemoteName)
+ _, _, err = cmd.RunStdString(&git.RunOpts{Dir: srcRepo.RepoPath()})
+ require.NoError(t, err)
+
+ // ...then ensure that trying to get its remote address fails
+ _, err = repo_model.GetPushMirrorRemoteAddress(srcRepo.OwnerName, srcRepo.Name, mirrors[0].RemoteName)
+ require.Error(t, err)
+
+ // ...and that we can fix it.
+ err = doctor.FixPushMirrorsWithoutGitRemote(db.DefaultContext, nil, true)
+ require.NoError(t, err)
+
+ // ...and after fixing, we only have one remote
+ mirrors, _, err = repo_model.GetPushMirrorsByRepoID(db.DefaultContext, srcRepo.ID, db.ListOptions{})
+ require.NoError(t, err)
+ assert.Len(t, mirrors, 1)
+
+ // ...one we can get the address of, and it's not the one we removed
+ remoteAddress, err := repo_model.GetPushMirrorRemoteAddress(srcRepo.OwnerName, srcRepo.Name, mirrors[0].RemoteName)
+ require.NoError(t, err)
+ assert.Contains(t, remoteAddress, "does-not-matter")
+
+ // Cleanup
+ doRemovePushMirror(ctx, fmt.Sprintf("%s%s/%s", u.String(), url.PathEscape(ctx.Username), url.PathEscape(mirrorRepo.Name)), user.LowerName, userPassword, int(mirrors[0].ID))(t)
+ mirrors, _, err = repo_model.GetPushMirrorsByRepoID(db.DefaultContext, srcRepo.ID, db.ListOptions{})
+ require.NoError(t, err)
+ assert.Empty(t, mirrors)
+}
+
+func doCreatePushMirror(ctx APITestContext, address, username, password string) func(t *testing.T) {
+ return func(t *testing.T) {
+ csrf := GetCSRF(t, ctx.Session, fmt.Sprintf("/%s/%s/settings", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)))
+
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), map[string]string{
+ "_csrf": csrf,
+ "action": "push-mirror-add",
+ "push_mirror_address": address,
+ "push_mirror_username": username,
+ "push_mirror_password": password,
+ "push_mirror_interval": "0",
+ })
+ ctx.Session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := ctx.Session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.Contains(t, flashCookie.Value, "success")
+ }
+}
+
+func doRemovePushMirror(ctx APITestContext, address, username, password string, pushMirrorID int) func(t *testing.T) {
+ return func(t *testing.T) {
+ csrf := GetCSRF(t, ctx.Session, fmt.Sprintf("/%s/%s/settings", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)))
+
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s/settings", url.PathEscape(ctx.Username), url.PathEscape(ctx.Reponame)), map[string]string{
+ "_csrf": csrf,
+ "action": "push-mirror-remove",
+ "push_mirror_id": strconv.Itoa(pushMirrorID),
+ "push_mirror_address": address,
+ "push_mirror_username": username,
+ "push_mirror_password": password,
+ "push_mirror_interval": "0",
+ })
+ ctx.Session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := ctx.Session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.Contains(t, flashCookie.Value, "success")
+ }
+}
+
+func TestSSHPushMirror(t *testing.T) {
+ _, err := exec.LookPath("ssh")
+ if err != nil {
+ t.Skip("SSH executable not present")
+ }
+
+ onGiteaRun(t, func(t *testing.T, _ *url.URL) {
+ defer test.MockVariableValue(&setting.Migrations.AllowLocalNetworks, true)()
+ defer test.MockVariableValue(&setting.Mirror.Enabled, true)()
+ defer test.MockVariableValue(&setting.SSH.RootPath, t.TempDir())()
+ require.NoError(t, migrations.Init())
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ srcRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+ assert.False(t, srcRepo.HasWiki())
+ sess := loginUser(t, user.Name)
+ pushToRepo, _, f := tests.CreateDeclarativeRepoWithOptions(t, user, tests.DeclarativeRepoOptions{
+ Name: optional.Some("push-mirror-test"),
+ AutoInit: optional.Some(false),
+ EnabledUnits: optional.Some([]unit.Type{unit.TypeCode}),
+ })
+ defer f()
+
+ sshURL := fmt.Sprintf("ssh://%s@%s/%s.git", setting.SSH.User, net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort)), pushToRepo.FullName())
+ t.Run("Mutual exclusive", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{
+ "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())),
+ "action": "push-mirror-add",
+ "push_mirror_address": sshURL,
+ "push_mirror_username": "username",
+ "push_mirror_password": "password",
+ "push_mirror_use_ssh": "true",
+ "push_mirror_interval": "0",
+ })
+ resp := sess.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ errMsg := htmlDoc.Find(".ui.negative.message").Text()
+ assert.Contains(t, errMsg, "Cannot use public key and password based authentication in combination.")
+ })
+
+ inputSelector := `input[id="push_mirror_use_ssh"]`
+
+ t.Run("SSH not available", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&git.HasSSHExecutable, false)()
+
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{
+ "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())),
+ "action": "push-mirror-add",
+ "push_mirror_address": sshURL,
+ "push_mirror_use_ssh": "true",
+ "push_mirror_interval": "0",
+ })
+ resp := sess.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ errMsg := htmlDoc.Find(".ui.negative.message").Text()
+ assert.Contains(t, errMsg, "SSH authentication isn't available.")
+
+ htmlDoc.AssertElement(t, inputSelector, false)
+ })
+
+ t.Run("SSH available", func(t *testing.T) {
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/settings", srcRepo.FullName()))
+ resp := sess.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, inputSelector, true)
+ })
+
+ t.Run("Normal", func(t *testing.T) {
+ var pushMirror *repo_model.PushMirror
+ t.Run("Adding", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{
+ "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())),
+ "action": "push-mirror-add",
+ "push_mirror_address": sshURL,
+ "push_mirror_use_ssh": "true",
+ "push_mirror_interval": "0",
+ })
+ sess.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := sess.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.Contains(t, flashCookie.Value, "success")
+
+ pushMirror = unittest.AssertExistsAndLoadBean(t, &repo_model.PushMirror{RepoID: srcRepo.ID})
+ assert.NotEmpty(t, pushMirror.PrivateKey)
+ assert.NotEmpty(t, pushMirror.PublicKey)
+ })
+
+ publickey := ""
+ t.Run("Publickey", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/settings", srcRepo.FullName()))
+ resp := sess.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ publickey = htmlDoc.Find(".ui.table td a[data-clipboard-text]").AttrOr("data-clipboard-text", "")
+ assert.EqualValues(t, publickey, pushMirror.GetPublicKey())
+ })
+
+ t.Run("Add deploy key", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings/keys", pushToRepo.FullName()), map[string]string{
+ "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings/keys", pushToRepo.FullName())),
+ "title": "push mirror key",
+ "content": publickey,
+ "is_writable": "true",
+ })
+ sess.MakeRequest(t, req, http.StatusSeeOther)
+
+ unittest.AssertExistsAndLoadBean(t, &asymkey_model.DeployKey{Name: "push mirror key", RepoID: pushToRepo.ID})
+ })
+
+ t.Run("Synchronize", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/settings", srcRepo.FullName()), map[string]string{
+ "_csrf": GetCSRF(t, sess, fmt.Sprintf("/%s/settings", srcRepo.FullName())),
+ "action": "push-mirror-sync",
+ "push_mirror_id": strconv.FormatInt(pushMirror.ID, 10),
+ })
+ sess.MakeRequest(t, req, http.StatusSeeOther)
+ })
+
+ t.Run("Check mirrored content", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ shortSHA := "1032bbf17f"
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s", srcRepo.FullName()))
+ resp := sess.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ assert.Contains(t, htmlDoc.Find(".shortsha").Text(), shortSHA)
+
+ assert.Eventually(t, func() bool {
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s", pushToRepo.FullName()))
+ resp = sess.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+
+ return htmlDoc.Find(".shortsha").Text() == shortSHA
+ }, time.Second*30, time.Second)
+ })
+
+ t.Run("Check known host keys", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ knownHosts, err := os.ReadFile(filepath.Join(setting.SSH.RootPath, "known_hosts"))
+ require.NoError(t, err)
+
+ publicKey, err := os.ReadFile(setting.SSH.ServerHostKeys[0] + ".pub")
+ require.NoError(t, err)
+
+ assert.Contains(t, string(knownHosts), string(publicKey))
+ })
+ })
+ })
+}
diff --git a/tests/integration/new_org_test.go b/tests/integration/new_org_test.go
new file mode 100644
index 0000000..ec9f2f2
--- /dev/null
+++ b/tests/integration/new_org_test.go
@@ -0,0 +1,37 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/translation"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewOrganizationForm(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ session := loginUser(t, "user1")
+ locale := translation.NewLocale("en-US")
+
+ response := session.MakeRequest(t, NewRequest(t, "GET", "/org/create"), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+
+ // Verify page title
+ title := page.Find("title").Text()
+ assert.Contains(t, title, locale.TrString("new_org.title"))
+
+ // Verify page form
+ _, exists := page.Find("form[action='/org/create']").Attr("method")
+ assert.True(t, exists)
+
+ // Verify page header
+ header := strings.TrimSpace(page.Find(".form[action='/org/create'] .header").Text())
+ assert.EqualValues(t, locale.TrString("new_org.title"), header)
+ })
+}
diff --git a/tests/integration/nonascii_branches_test.go b/tests/integration/nonascii_branches_test.go
new file mode 100644
index 0000000..8917a9b
--- /dev/null
+++ b/tests/integration/nonascii_branches_test.go
@@ -0,0 +1,214 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "path"
+ "testing"
+
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func testSrcRouteRedirect(t *testing.T, session *TestSession, user, repo, route, expectedLocation string, expectedStatus int) {
+ prefix := path.Join("/", user, repo, "src")
+
+ // Make request
+ req := NewRequest(t, "GET", path.Join(prefix, route))
+ resp := session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // Check Location header
+ location := resp.Header().Get("Location")
+ assert.Equal(t, path.Join(prefix, expectedLocation), location)
+
+ // Perform redirect
+ req = NewRequest(t, "GET", location)
+ session.MakeRequest(t, req, expectedStatus)
+}
+
+func setDefaultBranch(t *testing.T, session *TestSession, user, repo, branch string) {
+ location := path.Join("/", user, repo, "settings/branches")
+ csrf := GetCSRF(t, session, location)
+ req := NewRequestWithValues(t, "POST", location, map[string]string{
+ "_csrf": csrf,
+ "action": "default_branch",
+ "branch": branch,
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+}
+
+func TestNonasciiBranches(t *testing.T) {
+ testRedirects := []struct {
+ from string
+ to string
+ status int
+ }{
+ // Branches
+ {
+ from: "master",
+ to: "branch/master",
+ status: http.StatusOK,
+ },
+ {
+ from: "master/README.md",
+ to: "branch/master/README.md",
+ status: http.StatusOK,
+ },
+ {
+ from: "master/badfile",
+ to: "branch/master/badfile",
+ status: http.StatusNotFound, // it does not exists
+ },
+ {
+ from: "ГлавнаÑВетка",
+ to: "branch/%D0%93%D0%BB%D0%B0%D0%B2%D0%BD%D0%B0%D1%8F%D0%92%D0%B5%D1%82%D0%BA%D0%B0",
+ status: http.StatusOK,
+ },
+ {
+ from: "а/б/в",
+ to: "branch/%D0%B0/%D0%B1/%D0%B2",
+ status: http.StatusOK,
+ },
+ {
+ from: "Grüßen/README.md",
+ to: "branch/Gr%C3%BC%C3%9Fen/README.md",
+ status: http.StatusOK,
+ },
+ {
+ from: "Plus+Is+Not+Space",
+ to: "branch/Plus+Is+Not+Space",
+ status: http.StatusOK,
+ },
+ {
+ from: "Plus+Is+Not+Space/Файл.md",
+ to: "branch/Plus+Is+Not+Space/%D0%A4%D0%B0%D0%B9%D0%BB.md",
+ status: http.StatusOK,
+ },
+ {
+ from: "Plus+Is+Not+Space/and+it+is+valid.md",
+ to: "branch/Plus+Is+Not+Space/and+it+is+valid.md",
+ status: http.StatusOK,
+ },
+ {
+ from: "ブランãƒ",
+ to: "branch/%E3%83%96%E3%83%A9%E3%83%B3%E3%83%81",
+ status: http.StatusOK,
+ },
+ // Tags
+ {
+ from: "ТÑг",
+ to: "tag/%D0%A2%D1%8D%D0%B3",
+ status: http.StatusOK,
+ },
+ {
+ from: "Ð/人",
+ to: "tag/%D0%81/%E4%BA%BA",
+ status: http.StatusOK,
+ },
+ {
+ from: "ã‚¿ã‚°",
+ to: "tag/%E3%82%BF%E3%82%B0",
+ status: http.StatusOK,
+ },
+ {
+ from: "タグ/ファイル.md",
+ to: "tag/%E3%82%BF%E3%82%B0/%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB.md",
+ status: http.StatusOK,
+ },
+ // Files
+ {
+ from: "README.md",
+ to: "branch/Plus+Is+Not+Space/README.md",
+ status: http.StatusOK,
+ },
+ {
+ from: "Файл.md",
+ to: "branch/Plus+Is+Not+Space/%D0%A4%D0%B0%D0%B9%D0%BB.md",
+ status: http.StatusOK,
+ },
+ {
+ from: "ファイル.md",
+ to: "branch/Plus+Is+Not+Space/%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB.md",
+ status: http.StatusNotFound, // it's not on default branch
+ },
+ // Same but url-encoded (few tests)
+ {
+ from: "%E3%83%96%E3%83%A9%E3%83%B3%E3%83%81",
+ to: "branch/%E3%83%96%E3%83%A9%E3%83%B3%E3%83%81",
+ status: http.StatusOK,
+ },
+ {
+ from: "%E3%82%BF%E3%82%b0",
+ to: "tag/%E3%82%BF%E3%82%B0",
+ status: http.StatusOK,
+ },
+ {
+ from: "%D0%A4%D0%B0%D0%B9%D0%BB.md",
+ to: "branch/Plus+Is+Not+Space/%D0%A4%D0%B0%D0%B9%D0%BB.md",
+ status: http.StatusOK,
+ },
+ {
+ from: "%D0%81%2F%E4%BA%BA",
+ to: "tag/%D0%81/%E4%BA%BA",
+ status: http.StatusOK,
+ },
+ {
+ from: "Ð%2F%E4%BA%BA",
+ to: "tag/%D0%81/%E4%BA%BA",
+ status: http.StatusOK,
+ },
+ {
+ from: "Plus+Is+Not+Space/%25%252525mightnotplaywell",
+ to: "branch/Plus+Is+Not+Space/%25%252525mightnotplaywell",
+ status: http.StatusOK,
+ },
+ {
+ from: "Plus+Is+Not+Space/%25253Fisnotaquestion%25253F",
+ to: "branch/Plus+Is+Not+Space/%25253Fisnotaquestion%25253F",
+ status: http.StatusOK,
+ },
+ {
+ from: "Plus+Is+Not+Space/" + url.PathEscape("%3Fis?and#afile"),
+ to: "branch/Plus+Is+Not+Space/" + url.PathEscape("%3Fis?and#afile"),
+ status: http.StatusOK,
+ },
+ {
+ from: "Plus+Is+Not+Space/10%25.md",
+ to: "branch/Plus+Is+Not+Space/10%25.md",
+ status: http.StatusOK,
+ },
+ {
+ from: "Plus+Is+Not+Space/" + url.PathEscape("This+file%20has 1space"),
+ to: "branch/Plus+Is+Not+Space/" + url.PathEscape("This+file%20has 1space"),
+ status: http.StatusOK,
+ },
+ {
+ from: "Plus+Is+Not+Space/" + url.PathEscape("This+file%2520has 2 spaces"),
+ to: "branch/Plus+Is+Not+Space/" + url.PathEscape("This+file%2520has 2 spaces"),
+ status: http.StatusOK,
+ },
+ {
+ from: "Plus+Is+Not+Space/" + url.PathEscape("£15&$6.txt"),
+ to: "branch/Plus+Is+Not+Space/" + url.PathEscape("£15&$6.txt"),
+ status: http.StatusOK,
+ },
+ }
+
+ defer tests.PrepareTestEnv(t)()
+
+ user := "user2"
+ repo := "utf8"
+ session := loginUser(t, user)
+
+ setDefaultBranch(t, session, user, repo, "Plus+Is+Not+Space")
+
+ for _, test := range testRedirects {
+ testSrcRouteRedirect(t, session, user, repo, test.from, test.to, test.status)
+ }
+
+ setDefaultBranch(t, session, user, repo, "master")
+}
diff --git a/tests/integration/oauth_test.go b/tests/integration/oauth_test.go
new file mode 100644
index 0000000..f385b99
--- /dev/null
+++ b/tests/integration/oauth_test.go
@@ -0,0 +1,1323 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers/web/auth"
+ forgejo_context "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/markbates/goth"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAuthorizeNoClientID(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequest(t, "GET", "/login/oauth/authorize")
+ ctx := loginUser(t, "user2")
+ resp := ctx.MakeRequest(t, req, http.StatusBadRequest)
+ assert.Contains(t, resp.Body.String(), "Client ID not registered")
+}
+
+func TestAuthorizeUnregisteredRedirect(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=UNREGISTERED&response_type=code&state=thestate")
+ ctx := loginUser(t, "user1")
+ resp := ctx.MakeRequest(t, req, http.StatusBadRequest)
+ assert.Contains(t, resp.Body.String(), "Unregistered Redirect URI")
+}
+
+func TestAuthorizeUnsupportedResponseType(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=UNEXPECTED&state=thestate")
+ ctx := loginUser(t, "user1")
+ resp := ctx.MakeRequest(t, req, http.StatusSeeOther)
+ u, err := resp.Result().Location()
+ require.NoError(t, err)
+ assert.Equal(t, "unsupported_response_type", u.Query().Get("error"))
+ assert.Equal(t, "Only code response type is supported.", u.Query().Get("error_description"))
+}
+
+func TestAuthorizeUnsupportedCodeChallengeMethod(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=code&state=thestate&code_challenge_method=UNEXPECTED")
+ ctx := loginUser(t, "user1")
+ resp := ctx.MakeRequest(t, req, http.StatusSeeOther)
+ u, err := resp.Result().Location()
+ require.NoError(t, err)
+ assert.Equal(t, "invalid_request", u.Query().Get("error"))
+ assert.Equal(t, "unsupported code challenge method", u.Query().Get("error_description"))
+}
+
+func TestAuthorizeLoginRedirect(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequest(t, "GET", "/login/oauth/authorize")
+ assert.Contains(t, MakeRequest(t, req, http.StatusSeeOther).Body.String(), "/user/login")
+}
+
+func TestAuthorizeShow(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=code&state=thestate")
+ ctx := loginUser(t, "user4")
+ resp := ctx.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, "#authorize-app", true)
+ htmlDoc.GetCSRF()
+}
+
+func TestOAuth_AuthorizeConfidentialTwice(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // da7da3ba-9a13-4167-856f-3899de0b0138 a confidential client in models/fixtures/oauth2_application.yml
+
+ // request authorization for the first time shows the grant page ...
+ authorizeURL := "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=code&state=thestate"
+ req := NewRequest(t, "GET", authorizeURL)
+ ctx := loginUser(t, "user4")
+ resp := ctx.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, "#authorize-app", true)
+
+ // ... and the user grants the authorization
+ req = NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+ "redirect_uri": "a",
+ "state": "thestate",
+ "granted": "true",
+ })
+ resp = ctx.MakeRequest(t, req, http.StatusSeeOther)
+ assert.Contains(t, test.RedirectURL(resp), "code=")
+
+ // request authorization the second time and the grant page is not shown again, redirection happens immediately
+ req = NewRequest(t, "GET", authorizeURL)
+ resp = ctx.MakeRequest(t, req, http.StatusSeeOther)
+ assert.Contains(t, test.RedirectURL(resp), "code=")
+}
+
+func TestOAuth_AuthorizePublicTwice(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // ce5a1322-42a7-11ed-b878-0242ac120002 is a public client in models/fixtures/oauth2_application.yml
+ authorizeURL := "/login/oauth/authorize?client_id=ce5a1322-42a7-11ed-b878-0242ac120002&redirect_uri=b&response_type=code&code_challenge_method=plain&code_challenge=CODE&state=thestate"
+ ctx := loginUser(t, "user4")
+ // a public client must be authorized every time
+ for _, name := range []string{"First", "Second"} {
+ t.Run(name, func(t *testing.T) {
+ req := NewRequest(t, "GET", authorizeURL)
+ resp := ctx.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, "#authorize-app", true)
+
+ req = NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "client_id": "ce5a1322-42a7-11ed-b878-0242ac120002",
+ "redirect_uri": "b",
+ "state": "thestate",
+ "granted": "true",
+ })
+ resp = ctx.MakeRequest(t, req, http.StatusSeeOther)
+ assert.Contains(t, test.RedirectURL(resp), "code=")
+ })
+ }
+}
+
+func TestAuthorizeRedirectWithExistingGrant(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=https%3A%2F%2Fexample.com%2Fxyzzy&response_type=code&state=thestate")
+ ctx := loginUser(t, "user1")
+ resp := ctx.MakeRequest(t, req, http.StatusSeeOther)
+ u, err := resp.Result().Location()
+ require.NoError(t, err)
+ assert.Equal(t, "thestate", u.Query().Get("state"))
+ assert.Greaterf(t, len(u.Query().Get("code")), 30, "authorization code '%s' should be longer then 30", u.Query().Get("code"))
+ u.RawQuery = ""
+ assert.Equal(t, "https://example.com/xyzzy", u.String())
+}
+
+func TestAuthorizePKCERequiredForPublicClient(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=ce5a1322-42a7-11ed-b878-0242ac120002&redirect_uri=http%3A%2F%2F127.0.0.1&response_type=code&state=thestate")
+ ctx := loginUser(t, "user1")
+ resp := ctx.MakeRequest(t, req, http.StatusSeeOther)
+ u, err := resp.Result().Location()
+ require.NoError(t, err)
+ assert.Equal(t, "invalid_request", u.Query().Get("error"))
+ assert.Equal(t, "PKCE is required for public clients", u.Query().Get("error_description"))
+}
+
+func TestAccessTokenExchange(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+ "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
+ "redirect_uri": "a",
+ "code": "authcode",
+ "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
+ })
+ resp := MakeRequest(t, req, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ parsed := new(response)
+
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed))
+ assert.Greater(t, len(parsed.AccessToken), 10)
+ assert.Greater(t, len(parsed.RefreshToken), 10)
+}
+
+func TestAccessTokenExchangeWithPublicClient(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": "ce5a1322-42a7-11ed-b878-0242ac120002",
+ "redirect_uri": "http://127.0.0.1",
+ "code": "authcodepublic",
+ "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
+ })
+ resp := MakeRequest(t, req, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ parsed := new(response)
+
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed))
+ assert.Greater(t, len(parsed.AccessToken), 10)
+ assert.Greater(t, len(parsed.RefreshToken), 10)
+}
+
+func TestAccessTokenExchangeJSON(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequestWithJSON(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+ "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
+ "redirect_uri": "a",
+ "code": "authcode",
+ "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
+ })
+ resp := MakeRequest(t, req, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ parsed := new(response)
+
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed))
+ assert.Greater(t, len(parsed.AccessToken), 10)
+ assert.Greater(t, len(parsed.RefreshToken), 10)
+}
+
+func TestAccessTokenExchangeWithoutPKCE(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+ "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
+ "redirect_uri": "a",
+ "code": "authcode",
+ })
+ resp := MakeRequest(t, req, http.StatusBadRequest)
+ parsedError := new(auth.AccessTokenError)
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
+ assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
+ assert.Equal(t, "failed PKCE code challenge", parsedError.ErrorDescription)
+}
+
+func TestAccessTokenExchangeWithInvalidCredentials(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ // invalid client id
+ req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": "???",
+ "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
+ "redirect_uri": "a",
+ "code": "authcode",
+ "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
+ })
+ resp := MakeRequest(t, req, http.StatusBadRequest)
+ parsedError := new(auth.AccessTokenError)
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
+ assert.Equal(t, "invalid_client", string(parsedError.ErrorCode))
+ assert.Equal(t, "cannot load client with client id: '???'", parsedError.ErrorDescription)
+
+ // invalid client secret
+ req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+ "client_secret": "???",
+ "redirect_uri": "a",
+ "code": "authcode",
+ "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
+ })
+ resp = MakeRequest(t, req, http.StatusBadRequest)
+ parsedError = new(auth.AccessTokenError)
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
+ assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
+ assert.Equal(t, "invalid client secret", parsedError.ErrorDescription)
+
+ // invalid redirect uri
+ req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+ "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
+ "redirect_uri": "???",
+ "code": "authcode",
+ "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
+ })
+ resp = MakeRequest(t, req, http.StatusBadRequest)
+ parsedError = new(auth.AccessTokenError)
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
+ assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
+ assert.Equal(t, "unexpected redirect URI", parsedError.ErrorDescription)
+
+ // invalid authorization code
+ req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+ "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
+ "redirect_uri": "a",
+ "code": "???",
+ "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
+ })
+ resp = MakeRequest(t, req, http.StatusBadRequest)
+ parsedError = new(auth.AccessTokenError)
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
+ assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
+ assert.Equal(t, "client is not authorized", parsedError.ErrorDescription)
+
+ // invalid grant_type
+ req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "???",
+ "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+ "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
+ "redirect_uri": "a",
+ "code": "authcode",
+ "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
+ })
+ resp = MakeRequest(t, req, http.StatusBadRequest)
+ parsedError = new(auth.AccessTokenError)
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
+ assert.Equal(t, "unsupported_grant_type", string(parsedError.ErrorCode))
+ assert.Equal(t, "Only refresh_token or authorization_code grant type is supported", parsedError.ErrorDescription)
+}
+
+func TestAccessTokenExchangeWithBasicAuth(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "redirect_uri": "a",
+ "code": "authcode",
+ "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
+ })
+ req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9")
+ resp := MakeRequest(t, req, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ parsed := new(response)
+
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed))
+ assert.Greater(t, len(parsed.AccessToken), 10)
+ assert.Greater(t, len(parsed.RefreshToken), 10)
+
+ // use wrong client_secret
+ req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "redirect_uri": "a",
+ "code": "authcode",
+ "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
+ })
+ req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OmJsYWJsYQ==")
+ resp = MakeRequest(t, req, http.StatusBadRequest)
+ parsedError := new(auth.AccessTokenError)
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
+ assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
+ assert.Equal(t, "invalid client secret", parsedError.ErrorDescription)
+
+ // missing header
+ req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "redirect_uri": "a",
+ "code": "authcode",
+ "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
+ })
+ resp = MakeRequest(t, req, http.StatusBadRequest)
+ parsedError = new(auth.AccessTokenError)
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
+ assert.Equal(t, "invalid_client", string(parsedError.ErrorCode))
+ assert.Equal(t, "cannot load client with client id: ''", parsedError.ErrorDescription)
+
+ // client_id inconsistent with Authorization header
+ req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "redirect_uri": "a",
+ "code": "authcode",
+ "client_id": "inconsistent",
+ })
+ req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9")
+ resp = MakeRequest(t, req, http.StatusBadRequest)
+ parsedError = new(auth.AccessTokenError)
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
+ assert.Equal(t, "invalid_request", string(parsedError.ErrorCode))
+ assert.Equal(t, "client_id in request body inconsistent with Authorization header", parsedError.ErrorDescription)
+
+ // client_secret inconsistent with Authorization header
+ req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "redirect_uri": "a",
+ "code": "authcode",
+ "client_secret": "inconsistent",
+ })
+ req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9")
+ resp = MakeRequest(t, req, http.StatusBadRequest)
+ parsedError = new(auth.AccessTokenError)
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
+ assert.Equal(t, "invalid_request", string(parsedError.ErrorCode))
+ assert.Equal(t, "client_secret in request body inconsistent with Authorization header", parsedError.ErrorDescription)
+}
+
+func TestRefreshTokenInvalidation(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+ "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
+ "redirect_uri": "a",
+ "code": "authcode",
+ "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
+ })
+ resp := MakeRequest(t, req, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ parsed := new(response)
+
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsed))
+
+ // test without invalidation
+ setting.OAuth2.InvalidateRefreshTokens = false
+
+ req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "refresh_token",
+ "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+ // omit secret
+ "redirect_uri": "a",
+ "refresh_token": parsed.RefreshToken,
+ })
+ resp = MakeRequest(t, req, http.StatusBadRequest)
+ parsedError := new(auth.AccessTokenError)
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
+ assert.Equal(t, "invalid_client", string(parsedError.ErrorCode))
+ assert.Equal(t, "invalid empty client secret", parsedError.ErrorDescription)
+
+ req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "refresh_token",
+ "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+ "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
+ "redirect_uri": "a",
+ "refresh_token": "UNEXPECTED",
+ })
+ resp = MakeRequest(t, req, http.StatusBadRequest)
+ parsedError = new(auth.AccessTokenError)
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
+ assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
+ assert.Equal(t, "unable to parse refresh token", parsedError.ErrorDescription)
+
+ req = NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "refresh_token",
+ "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+ "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
+ "redirect_uri": "a",
+ "refresh_token": parsed.RefreshToken,
+ })
+
+ bs, err := io.ReadAll(req.Body)
+ require.NoError(t, err)
+
+ req.Body = io.NopCloser(bytes.NewReader(bs))
+ MakeRequest(t, req, http.StatusOK)
+
+ req.Body = io.NopCloser(bytes.NewReader(bs))
+ MakeRequest(t, req, http.StatusOK)
+
+ // test with invalidation
+ setting.OAuth2.InvalidateRefreshTokens = true
+ req.Body = io.NopCloser(bytes.NewReader(bs))
+ MakeRequest(t, req, http.StatusOK)
+
+ // repeat request should fail
+ req.Body = io.NopCloser(bytes.NewReader(bs))
+ resp = MakeRequest(t, req, http.StatusBadRequest)
+ parsedError = new(auth.AccessTokenError)
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), parsedError))
+ assert.Equal(t, "unauthorized_client", string(parsedError.ErrorCode))
+ assert.Equal(t, "token was already used", parsedError.ErrorDescription)
+}
+
+func TestSignInOAuthCallbackSignIn(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ //
+ // OAuth2 authentication source GitLab
+ //
+ gitlabName := "gitlab"
+ gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
+
+ //
+ // Create a user as if it had been previously been created by the GitLab
+ // authentication source.
+ //
+ userGitLabUserID := "5678"
+ userGitLab := &user_model.User{
+ Name: "gitlabuser",
+ Email: "gitlabuser@example.com",
+ Passwd: "gitlabuserpassword",
+ Type: user_model.UserTypeIndividual,
+ LoginType: auth_model.OAuth2,
+ LoginSource: gitlab.ID,
+ LoginName: userGitLabUserID,
+ }
+ defer createUser(context.Background(), t, userGitLab)()
+
+ //
+ // A request for user information sent to Goth will return a
+ // goth.User exactly matching the user created above.
+ //
+ defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
+ return goth.User{
+ Provider: gitlabName,
+ UserID: userGitLabUserID,
+ Email: userGitLab.Email,
+ }, nil
+ })()
+ req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName))
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, "/", test.RedirectURL(resp))
+ userAfterLogin := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userGitLab.ID})
+ assert.Greater(t, userAfterLogin.LastLoginUnix, userGitLab.LastLoginUnix)
+}
+
+func TestSignInOAuthCallbackWithoutPKCEWhenUnsupported(t *testing.T) {
+ // https://codeberg.org/forgejo/forgejo/issues/4033
+ defer tests.PrepareTestEnv(t)()
+
+ // Setup authentication source
+ gitlabName := "gitlab"
+ gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
+ // Create a user as if it had been previously been created by the authentication source.
+ userGitLabUserID := "5678"
+ userGitLab := &user_model.User{
+ Name: "gitlabuser",
+ Email: "gitlabuser@example.com",
+ Passwd: "gitlabuserpassword",
+ Type: user_model.UserTypeIndividual,
+ LoginType: auth_model.OAuth2,
+ LoginSource: gitlab.ID,
+ LoginName: userGitLabUserID,
+ }
+ defer createUser(context.Background(), t, userGitLab)()
+
+ // initial redirection (to generate the code_challenge)
+ session := emptyTestSession(t)
+ req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s", gitlabName))
+ resp := session.MakeRequest(t, req, http.StatusTemporaryRedirect)
+ dest, err := url.Parse(resp.Header().Get("Location"))
+ require.NoError(t, err)
+ assert.Empty(t, dest.Query().Get("code_challenge_method"))
+ assert.Empty(t, dest.Query().Get("code_challenge"))
+
+ // callback (to check the initial code_challenge)
+ defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
+ assert.Empty(t, req.URL.Query().Get("code_verifier"))
+ return goth.User{
+ Provider: gitlabName,
+ UserID: userGitLabUserID,
+ Email: userGitLab.Email,
+ }, nil
+ })()
+ req = NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName))
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, "/", test.RedirectURL(resp))
+ unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userGitLab.ID})
+}
+
+func TestSignInOAuthCallbackPKCE(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ // Setup authentication source
+ sourceName := "oidc"
+ authSource := addAuthSource(t, authSourcePayloadOpenIDConnect(sourceName, u.String()))
+ // Create a user as if it had been previously been created by the authentication source.
+ userID := "5678"
+ user := &user_model.User{
+ Name: "oidc.user",
+ Email: "oidc.user@example.com",
+ Passwd: "oidc.userpassword",
+ Type: user_model.UserTypeIndividual,
+ LoginType: auth_model.OAuth2,
+ LoginSource: authSource.ID,
+ LoginName: userID,
+ }
+ defer createUser(context.Background(), t, user)()
+
+ // initial redirection (to generate the code_challenge)
+ session := emptyTestSession(t)
+ req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s", sourceName))
+ resp := session.MakeRequest(t, req, http.StatusTemporaryRedirect)
+ dest, err := url.Parse(resp.Header().Get("Location"))
+ require.NoError(t, err)
+ assert.Equal(t, "S256", dest.Query().Get("code_challenge_method"))
+ codeChallenge := dest.Query().Get("code_challenge")
+ assert.NotEmpty(t, codeChallenge)
+
+ // callback (to check the initial code_challenge)
+ defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
+ codeVerifier := req.URL.Query().Get("code_verifier")
+ assert.NotEmpty(t, codeVerifier)
+ assert.Greater(t, len(codeVerifier), 40, codeVerifier)
+
+ sha2 := sha256.New()
+ io.WriteString(sha2, codeVerifier)
+ assert.Equal(t, codeChallenge, base64.RawURLEncoding.EncodeToString(sha2.Sum(nil)))
+
+ return goth.User{
+ Provider: sourceName,
+ UserID: userID,
+ Email: user.Email,
+ }, nil
+ })()
+ req = NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", sourceName))
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, "/", test.RedirectURL(resp))
+ unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: user.ID})
+ })
+}
+
+func TestSignInOAuthCallbackRedirectToEscaping(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ //
+ // OAuth2 authentication source GitLab
+ //
+ gitlabName := "gitlab"
+ gitlab := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
+
+ //
+ // Create a user as if it had been previously created by the GitLab
+ // authentication source.
+ //
+ userGitLabUserID := "5678"
+ userGitLab := &user_model.User{
+ Name: "gitlabuser",
+ Email: "gitlabuser@example.com",
+ Passwd: "gitlabuserpassword",
+ Type: user_model.UserTypeIndividual,
+ LoginType: auth_model.OAuth2,
+ LoginSource: gitlab.ID,
+ LoginName: userGitLabUserID,
+ }
+ defer createUser(context.Background(), t, userGitLab)()
+
+ //
+ // A request for user information sent to Goth will return a
+ // goth.User exactly matching the user created above.
+ //
+ defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
+ return goth.User{
+ Provider: gitlabName,
+ UserID: userGitLabUserID,
+ Email: userGitLab.Email,
+ }, nil
+ })()
+ req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName))
+ req.AddCookie(&http.Cookie{
+ Name: "redirect_to",
+ Value: "/login/oauth/authorize?redirect_uri=https%3A%2F%2Ftranslate.example.org",
+ Path: "/",
+ })
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+
+ hasNewSessionCookie := false
+ sessionCookieName := setting.SessionConfig.CookieName
+ for _, c := range resp.Result().Cookies() {
+ if c.Name == sessionCookieName {
+ hasNewSessionCookie = true
+ break
+ }
+ t.Log("Got cookie", c.Name)
+ }
+
+ assert.True(t, hasNewSessionCookie, "Session cookie %q is missing", sessionCookieName)
+ assert.Equal(t, "/login/oauth/authorize?redirect_uri=https://translate.example.org", test.RedirectURL(resp))
+}
+
+func TestSignUpViaOAuthWithMissingFields(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ // enable auto-creation of accounts via OAuth2
+ enableAutoRegistration := setting.OAuth2Client.EnableAutoRegistration
+ setting.OAuth2Client.EnableAutoRegistration = true
+ defer func() {
+ setting.OAuth2Client.EnableAutoRegistration = enableAutoRegistration
+ }()
+
+ // OAuth2 authentication source GitLab
+ gitlabName := "gitlab"
+ addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
+ userGitLabUserID := "5678"
+
+ // The Goth User returned by the oauth2 integration is missing
+ // an email address, so we won't be able to automatically create a local account for it.
+ defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
+ return goth.User{
+ Provider: gitlabName,
+ UserID: userGitLabUserID,
+ }, nil
+ })()
+ req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName))
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, "/user/link_account", test.RedirectURL(resp))
+}
+
+func TestOAuth_GrantApplicationOAuth(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/login/oauth/authorize?client_id=da7da3ba-9a13-4167-856f-3899de0b0138&redirect_uri=a&response_type=code&state=thestate")
+ ctx := loginUser(t, "user4")
+ resp := ctx.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, "#authorize-app", true)
+
+ req = NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+ "redirect_uri": "a",
+ "state": "thestate",
+ "granted": "false",
+ })
+ resp = ctx.MakeRequest(t, req, http.StatusSeeOther)
+ assert.Contains(t, test.RedirectURL(resp), "error=access_denied&error_description=the+request+is+denied")
+}
+
+func TestOAuthIntrospection(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ req := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": "da7da3ba-9a13-4167-856f-3899de0b0138",
+ "client_secret": "4MK8Na6R55smdCY0WuCCumZ6hjRPnGY5saWVRHHjJiA=",
+ "redirect_uri": "a",
+ "code": "authcode",
+ "code_verifier": "N1Zo9-8Rfwhkt68r1r29ty8YwIraXR8eh_1Qwxg7yQXsonBt",
+ })
+ resp := MakeRequest(t, req, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ parsed := new(response)
+
+ DecodeJSON(t, resp, parsed)
+ assert.Greater(t, len(parsed.AccessToken), 10)
+ assert.Greater(t, len(parsed.RefreshToken), 10)
+
+ type introspectResponse struct {
+ Active bool `json:"active"`
+ Scope string `json:"scope,omitempty"`
+ Username string `json:"username"`
+ }
+
+ // successful request with a valid client_id/client_secret and a valid token
+ t.Run("successful request with valid token", func(t *testing.T) {
+ req := NewRequestWithValues(t, "POST", "/login/oauth/introspect", map[string]string{
+ "token": parsed.AccessToken,
+ })
+ req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ introspectParsed := new(introspectResponse)
+ DecodeJSON(t, resp, introspectParsed)
+ assert.True(t, introspectParsed.Active)
+ assert.Equal(t, "user1", introspectParsed.Username)
+ })
+
+ // successful request with a valid client_id/client_secret, but an invalid token
+ t.Run("successful request with invalid token", func(t *testing.T) {
+ req := NewRequestWithValues(t, "POST", "/login/oauth/introspect", map[string]string{
+ "token": "xyzzy",
+ })
+ req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpKaUE9")
+ resp := MakeRequest(t, req, http.StatusOK)
+ introspectParsed := new(introspectResponse)
+ DecodeJSON(t, resp, introspectParsed)
+ assert.False(t, introspectParsed.Active)
+ })
+
+ // unsuccessful request with an invalid client_id/client_secret
+ t.Run("unsuccessful request due to invalid basic auth", func(t *testing.T) {
+ req := NewRequestWithValues(t, "POST", "/login/oauth/introspect", map[string]string{
+ "token": parsed.AccessToken,
+ })
+ req.Header.Add("Authorization", "Basic ZGE3ZGEzYmEtOWExMy00MTY3LTg1NmYtMzg5OWRlMGIwMTM4OjRNSzhOYTZSNTVzbWRDWTBXdUNDdW1aNmhqUlBuR1k1c2FXVlJISGpK")
+ resp := MakeRequest(t, req, http.StatusUnauthorized)
+ assert.Contains(t, resp.Body.String(), "no valid authorization")
+ })
+}
+
+func requireCookieCSRF(t *testing.T, resp http.ResponseWriter) string {
+ for _, c := range resp.(*httptest.ResponseRecorder).Result().Cookies() {
+ if c.Name == "_csrf" {
+ return c.Value
+ }
+ }
+ require.True(t, false, "_csrf not found in cookies")
+ return ""
+}
+
+func TestOAuth_GrantScopesReadUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ appBody := api.CreateOAuth2ApplicationOptions{
+ Name: "oauth-provider-scopes-test",
+ RedirectURIs: []string{
+ "a",
+ },
+ ConfidentialClient: true,
+ }
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var app *api.OAuth2Application
+ DecodeJSON(t, resp, &app)
+
+ grant := &auth_model.OAuth2Grant{
+ ApplicationID: app.ID,
+ UserID: user.ID,
+ Scope: "openid profile email read:user",
+ }
+
+ err := db.Insert(db.DefaultContext, grant)
+ require.NoError(t, err)
+
+ assert.Contains(t, grant.Scope, "openid profile email read:user")
+
+ ctx := loginUserWithPasswordRemember(t, user.Name, "password", true)
+
+ authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+ authorizeReq := NewRequest(t, "GET", authorizeURL)
+ authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+ authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&amp")[0]
+ grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{
+ "_csrf": requireCookieCSRF(t, authorizeResp),
+ "client_id": app.ClientID,
+ "redirect_uri": "a",
+ "state": "thestate",
+ "granted": "true",
+ })
+ grantResp := ctx.MakeRequest(t, grantReq, http.StatusBadRequest)
+ assert.NotContains(t, grantResp.Body.String(), forgejo_context.CsrfErrorString)
+
+ accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "_csrf": requireCookieCSRF(t, authorizeResp),
+ "grant_type": "authorization_code",
+ "client_id": app.ClientID,
+ "client_secret": app.ClientSecret,
+ "redirect_uri": "a",
+ "code": authcode,
+ })
+ accessTokenResp := ctx.MakeRequest(t, accessTokenReq, 200)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ parsed := new(response)
+
+ require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+ userReq := NewRequest(t, "GET", "/api/v1/user")
+ userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+ userResp := MakeRequest(t, userReq, http.StatusOK)
+
+ // assert.Contains(t, string(userResp.Body.Bytes()), "blah")
+ type userResponse struct {
+ Login string `json:"login"`
+ Email string `json:"email"`
+ }
+
+ userParsed := new(userResponse)
+ require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), userParsed))
+ assert.Contains(t, userParsed.Email, "user2@example.com")
+}
+
+func TestOAuth_GrantScopesFailReadRepository(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ appBody := api.CreateOAuth2ApplicationOptions{
+ Name: "oauth-provider-scopes-test",
+ RedirectURIs: []string{
+ "a",
+ },
+ ConfidentialClient: true,
+ }
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var app *api.OAuth2Application
+ DecodeJSON(t, resp, &app)
+
+ grant := &auth_model.OAuth2Grant{
+ ApplicationID: app.ID,
+ UserID: user.ID,
+ Scope: "openid profile email read:user",
+ }
+
+ err := db.Insert(db.DefaultContext, grant)
+ require.NoError(t, err)
+
+ assert.Contains(t, grant.Scope, "openid profile email read:user")
+
+ ctx := loginUserWithPasswordRemember(t, user.Name, "password", true)
+
+ authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+ authorizeReq := NewRequest(t, "GET", authorizeURL)
+ authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+ authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&amp")[0]
+ grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{
+ "_csrf": requireCookieCSRF(t, authorizeResp),
+ "client_id": app.ClientID,
+ "redirect_uri": "a",
+ "state": "thestate",
+ "granted": "true",
+ })
+ grantResp := ctx.MakeRequest(t, grantReq, http.StatusBadRequest)
+ assert.NotContains(t, grantResp.Body.String(), forgejo_context.CsrfErrorString)
+
+ accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "_csrf": requireCookieCSRF(t, authorizeResp),
+ "grant_type": "authorization_code",
+ "client_id": app.ClientID,
+ "client_secret": app.ClientSecret,
+ "redirect_uri": "a",
+ "code": authcode,
+ })
+ accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ parsed := new(response)
+
+ require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+ userReq := NewRequest(t, "GET", "/api/v1/users/user2/repos")
+ userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+ userResp := MakeRequest(t, userReq, http.StatusForbidden)
+
+ type userResponse struct {
+ Message string `json:"message"`
+ }
+
+ userParsed := new(userResponse)
+ require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), userParsed))
+ assert.Contains(t, userParsed.Message, "token does not have at least one of required scope(s): [read:repository]")
+}
+
+func TestOAuth_GrantScopesReadRepository(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ appBody := api.CreateOAuth2ApplicationOptions{
+ Name: "oauth-provider-scopes-test",
+ RedirectURIs: []string{
+ "a",
+ },
+ ConfidentialClient: true,
+ }
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var app *api.OAuth2Application
+ DecodeJSON(t, resp, &app)
+
+ grant := &auth_model.OAuth2Grant{
+ ApplicationID: app.ID,
+ UserID: user.ID,
+ Scope: "openid profile email read:user read:repository",
+ }
+
+ err := db.Insert(db.DefaultContext, grant)
+ require.NoError(t, err)
+
+ assert.Contains(t, grant.Scope, "openid profile email read:user read:repository")
+
+ ctx := loginUserWithPasswordRemember(t, user.Name, "password", true)
+
+ authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+ authorizeReq := NewRequest(t, "GET", authorizeURL)
+ authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+ authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&amp")[0]
+ grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{
+ "_csrf": requireCookieCSRF(t, authorizeResp),
+ "client_id": app.ClientID,
+ "redirect_uri": "a",
+ "state": "thestate",
+ "granted": "true",
+ })
+ grantResp := ctx.MakeRequest(t, grantReq, http.StatusBadRequest)
+ assert.NotContains(t, grantResp.Body.String(), forgejo_context.CsrfErrorString)
+
+ accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "_csrf": requireCookieCSRF(t, authorizeResp),
+ "grant_type": "authorization_code",
+ "client_id": app.ClientID,
+ "client_secret": app.ClientSecret,
+ "redirect_uri": "a",
+ "code": authcode,
+ })
+ accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ }
+ parsed := new(response)
+
+ require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+ userReq := NewRequest(t, "GET", "/api/v1/users/user2/repos")
+ userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+ userResp := MakeRequest(t, userReq, http.StatusOK)
+
+ type repos struct {
+ FullRepoName string `json:"full_name"`
+ }
+ var userResponse []*repos
+ require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), &userResponse))
+ if assert.NotEmpty(t, userResponse) {
+ assert.Contains(t, userResponse[0].FullRepoName, "user2/repo1")
+ }
+}
+
+func TestOAuth_GrantScopesReadPrivateGroups(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // setting.OAuth2.EnableAdditionalGrantScopes = true
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
+
+ appBody := api.CreateOAuth2ApplicationOptions{
+ Name: "oauth-provider-scopes-test",
+ RedirectURIs: []string{
+ "a",
+ },
+ ConfidentialClient: true,
+ }
+
+ appReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+ AddBasicAuth(user.Name)
+ appResp := MakeRequest(t, appReq, http.StatusCreated)
+
+ var app *api.OAuth2Application
+ DecodeJSON(t, appResp, &app)
+
+ grant := &auth_model.OAuth2Grant{
+ ApplicationID: app.ID,
+ UserID: user.ID,
+ Scope: "openid profile email groups read:user",
+ }
+
+ err := db.Insert(db.DefaultContext, grant)
+ require.NoError(t, err)
+
+ assert.Contains(t, grant.Scope, "openid profile email groups read:user")
+
+ ctx := loginUserWithPasswordRemember(t, user.Name, "password", true)
+
+ authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+ authorizeReq := NewRequest(t, "GET", authorizeURL)
+ authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+ authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&amp")[0]
+ grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{
+ "_csrf": requireCookieCSRF(t, authorizeResp),
+ "client_id": app.ClientID,
+ "redirect_uri": "a",
+ "state": "thestate",
+ "granted": "true",
+ })
+ grantResp := ctx.MakeRequest(t, grantReq, http.StatusBadRequest)
+ assert.NotContains(t, grantResp.Body.String(), forgejo_context.CsrfErrorString)
+
+ accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "_csrf": requireCookieCSRF(t, authorizeResp),
+ "grant_type": "authorization_code",
+ "client_id": app.ClientID,
+ "client_secret": app.ClientSecret,
+ "redirect_uri": "a",
+ "code": authcode,
+ })
+ accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ IDToken string `json:"id_token,omitempty"`
+ }
+ parsed := new(response)
+ require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+ parts := strings.Split(parsed.IDToken, ".")
+
+ payload, _ := base64.RawURLEncoding.DecodeString(parts[1])
+ type IDTokenClaims struct {
+ Groups []string `json:"groups"`
+ }
+
+ claims := new(IDTokenClaims)
+ require.NoError(t, json.Unmarshal(payload, claims))
+ for _, group := range []string{"limited_org36", "limited_org36:team20writepackage", "org6", "org6:owners", "org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} {
+ assert.Contains(t, claims.Groups, group)
+ }
+}
+
+func TestOAuth_GrantScopesReadOnlyPublicGroups(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.OAuth2.EnableAdditionalGrantScopes = true
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
+
+ appBody := api.CreateOAuth2ApplicationOptions{
+ Name: "oauth-provider-scopes-test",
+ RedirectURIs: []string{
+ "a",
+ },
+ ConfidentialClient: true,
+ }
+
+ appReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+ AddBasicAuth(user.Name)
+ appResp := MakeRequest(t, appReq, http.StatusCreated)
+
+ var app *api.OAuth2Application
+ DecodeJSON(t, appResp, &app)
+
+ grant := &auth_model.OAuth2Grant{
+ ApplicationID: app.ID,
+ UserID: user.ID,
+ Scope: "openid profile email groups read:user",
+ }
+
+ err := db.Insert(db.DefaultContext, grant)
+ require.NoError(t, err)
+
+ assert.Contains(t, grant.Scope, "openid profile email groups read:user")
+
+ ctx := loginUserWithPasswordRemember(t, user.Name, "password", true)
+
+ authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+ authorizeReq := NewRequest(t, "GET", authorizeURL)
+ authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+ authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&amp")[0]
+ grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{
+ "_csrf": requireCookieCSRF(t, authorizeResp),
+ "client_id": app.ClientID,
+ "redirect_uri": "a",
+ "state": "thestate",
+ "granted": "true",
+ })
+ grantResp := ctx.MakeRequest(t, grantReq, http.StatusBadRequest)
+ assert.NotContains(t, grantResp.Body.String(), forgejo_context.CsrfErrorString)
+
+ accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "_csrf": requireCookieCSRF(t, authorizeResp),
+ "grant_type": "authorization_code",
+ "client_id": app.ClientID,
+ "client_secret": app.ClientSecret,
+ "redirect_uri": "a",
+ "code": authcode,
+ })
+ accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ IDToken string `json:"id_token,omitempty"`
+ }
+ parsed := new(response)
+ require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+ parts := strings.Split(parsed.IDToken, ".")
+
+ payload, _ := base64.RawURLEncoding.DecodeString(parts[1])
+ type IDTokenClaims struct {
+ Groups []string `json:"groups"`
+ }
+
+ claims := new(IDTokenClaims)
+ require.NoError(t, json.Unmarshal(payload, claims))
+ for _, privOrg := range []string{"org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} {
+ assert.NotContains(t, claims.Groups, privOrg)
+ }
+
+ userReq := NewRequest(t, "GET", "/login/oauth/userinfo")
+ userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+ userResp := MakeRequest(t, userReq, http.StatusOK)
+
+ type userinfo struct {
+ Groups []string `json:"groups"`
+ }
+ parsedUserInfo := new(userinfo)
+ require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), parsedUserInfo))
+
+ for _, privOrg := range []string{"org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} {
+ assert.NotContains(t, parsedUserInfo.Groups, privOrg)
+ }
+}
+
+func TestOAuth_GrantScopesReadPublicGroupsWithTheReadScope(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.OAuth2.EnableAdditionalGrantScopes = true
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
+
+ appBody := api.CreateOAuth2ApplicationOptions{
+ Name: "oauth-provider-scopes-test",
+ RedirectURIs: []string{
+ "a",
+ },
+ ConfidentialClient: true,
+ }
+
+ appReq := NewRequestWithJSON(t, "POST", "/api/v1/user/applications/oauth2", &appBody).
+ AddBasicAuth(user.Name)
+ appResp := MakeRequest(t, appReq, http.StatusCreated)
+
+ var app *api.OAuth2Application
+ DecodeJSON(t, appResp, &app)
+
+ grant := &auth_model.OAuth2Grant{
+ ApplicationID: app.ID,
+ UserID: user.ID,
+ Scope: "openid profile email groups read:user read:organization",
+ }
+
+ err := db.Insert(db.DefaultContext, grant)
+ require.NoError(t, err)
+
+ assert.Contains(t, grant.Scope, "openid profile email groups read:user read:organization")
+
+ ctx := loginUserWithPasswordRemember(t, user.Name, "password", true)
+
+ authorizeURL := fmt.Sprintf("/login/oauth/authorize?client_id=%s&redirect_uri=a&response_type=code&state=thestate", app.ClientID)
+ authorizeReq := NewRequest(t, "GET", authorizeURL)
+ authorizeResp := ctx.MakeRequest(t, authorizeReq, http.StatusSeeOther)
+
+ authcode := strings.Split(strings.Split(authorizeResp.Body.String(), "?code=")[1], "&amp")[0]
+ grantReq := NewRequestWithValues(t, "POST", "/login/oauth/grant", map[string]string{
+ "_csrf": requireCookieCSRF(t, authorizeResp),
+ "client_id": app.ClientID,
+ "redirect_uri": "a",
+ "state": "thestate",
+ "granted": "true",
+ })
+ grantResp := ctx.MakeRequest(t, grantReq, http.StatusBadRequest)
+ assert.NotContains(t, grantResp.Body.String(), forgejo_context.CsrfErrorString)
+
+ accessTokenReq := NewRequestWithValues(t, "POST", "/login/oauth/access_token", map[string]string{
+ "_csrf": requireCookieCSRF(t, authorizeResp),
+ "grant_type": "authorization_code",
+ "client_id": app.ClientID,
+ "client_secret": app.ClientSecret,
+ "redirect_uri": "a",
+ "code": authcode,
+ })
+ accessTokenResp := ctx.MakeRequest(t, accessTokenReq, http.StatusOK)
+ type response struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ IDToken string `json:"id_token,omitempty"`
+ }
+ parsed := new(response)
+ require.NoError(t, json.Unmarshal(accessTokenResp.Body.Bytes(), parsed))
+ parts := strings.Split(parsed.IDToken, ".")
+
+ payload, _ := base64.RawURLEncoding.DecodeString(parts[1])
+ type IDTokenClaims struct {
+ Groups []string `json:"groups"`
+ }
+
+ claims := new(IDTokenClaims)
+ require.NoError(t, json.Unmarshal(payload, claims))
+ for _, privOrg := range []string{"org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} {
+ assert.Contains(t, claims.Groups, privOrg)
+ }
+
+ userReq := NewRequest(t, "GET", "/login/oauth/userinfo")
+ userReq.SetHeader("Authorization", "Bearer "+parsed.AccessToken)
+ userResp := MakeRequest(t, userReq, http.StatusOK)
+
+ type userinfo struct {
+ Groups []string `json:"groups"`
+ }
+ parsedUserInfo := new(userinfo)
+ require.NoError(t, json.Unmarshal(userResp.Body.Bytes(), parsedUserInfo))
+ for _, privOrg := range []string{"org7", "org7:owners", "privated_org", "privated_org:team14writeauth"} {
+ assert.Contains(t, parsedUserInfo.Groups, privOrg)
+ }
+}
diff --git a/tests/integration/opengraph_test.go b/tests/integration/opengraph_test.go
new file mode 100644
index 0000000..8d29e45
--- /dev/null
+++ b/tests/integration/opengraph_test.go
@@ -0,0 +1,150 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestOpenGraphProperties(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ siteName := "Forgejo: Beyond coding. We Forge."
+
+ cases := []struct {
+ name string
+ url string
+ expected map[string]string
+ }{
+ {
+ name: "website root",
+ url: "/",
+ expected: map[string]string{
+ "og:title": siteName,
+ "og:url": setting.AppURL,
+ "og:description": "Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.",
+ "og:type": "website",
+ "og:image": "/assets/img/logo.png",
+ "og:site_name": siteName,
+ },
+ },
+ {
+ name: "profile page without description",
+ url: "/user30",
+ expected: map[string]string{
+ "og:title": "User Thirty",
+ "og:url": setting.AppURL + "user30",
+ "og:type": "profile",
+ "og:image": "https://secure.gravatar.com/avatar/eae1f44b34ff27284cb0792c7601c89c?d=identicon",
+ "og:site_name": siteName,
+ },
+ },
+ {
+ name: "profile page with description",
+ url: "/the_34-user.with.all.allowedchars",
+ expected: map[string]string{
+ "og:title": "the_1-user.with.all.allowedChars",
+ "og:url": setting.AppURL + "the_34-user.with.all.allowedChars",
+ "og:description": "some [commonmark](https://commonmark.org/)!",
+ "og:type": "profile",
+ "og:image": setting.AppURL + "avatars/avatar34",
+ "og:site_name": siteName,
+ },
+ },
+ {
+ name: "issue",
+ url: "/user2/repo1/issues/1",
+ expected: map[string]string{
+ "og:title": "issue1",
+ "og:url": setting.AppURL + "user2/repo1/issues/1",
+ "og:description": "content for the first issue",
+ "og:type": "object",
+ "og:image": "https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon",
+ "og:site_name": siteName,
+ },
+ },
+ {
+ name: "pull request",
+ url: "/user2/repo1/pulls/2",
+ expected: map[string]string{
+ "og:title": "issue2",
+ "og:url": setting.AppURL + "user2/repo1/pulls/2",
+ "og:description": "content for the second issue",
+ "og:type": "object",
+ "og:image": "https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon",
+ "og:site_name": siteName,
+ },
+ },
+ {
+ name: "file in repo",
+ url: "/user27/repo49/src/branch/master/test/test.txt",
+ expected: map[string]string{
+ "og:title": "repo49/test/test.txt at master",
+ "og:url": setting.AppURL + "/user27/repo49/src/branch/master/test/test.txt",
+ "og:type": "object",
+ "og:image": "https://secure.gravatar.com/avatar/7095710e927665f1bdd1ced94152f232?d=identicon",
+ "og:site_name": siteName,
+ },
+ },
+ {
+ name: "wiki page for repo without description",
+ url: "/user2/repo1/wiki/Page-With-Spaced-Name",
+ expected: map[string]string{
+ "og:title": "Page With Spaced Name",
+ "og:url": setting.AppURL + "/user2/repo1/wiki/Page-With-Spaced-Name",
+ "og:type": "object",
+ "og:image": "https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon",
+ "og:site_name": siteName,
+ },
+ },
+ {
+ name: "index page for repo without description",
+ url: "/user2/repo1",
+ expected: map[string]string{
+ "og:title": "repo1",
+ "og:url": setting.AppURL + "user2/repo1",
+ "og:type": "object",
+ "og:image": "https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?d=identicon",
+ "og:site_name": siteName,
+ },
+ },
+ {
+ name: "index page for repo with description",
+ url: "/user27/repo49",
+ expected: map[string]string{
+ "og:title": "repo49",
+ "og:url": setting.AppURL + "user27/repo49",
+ "og:description": "A wonderful repository with more than just a README.md",
+ "og:type": "object",
+ "og:image": "https://secure.gravatar.com/avatar/7095710e927665f1bdd1ced94152f232?d=identicon",
+ "og:site_name": siteName,
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req := NewRequest(t, "GET", tc.url)
+ resp := MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ foundProps := make(map[string]string)
+ doc.Find("head meta[property^=\"og:\"]").Each(func(_ int, selection *goquery.Selection) {
+ prop, foundProp := selection.Attr("property")
+ assert.True(t, foundProp)
+ content, foundContent := selection.Attr("content")
+ assert.True(t, foundContent, "opengraph meta tag without a content property")
+ foundProps[prop] = content
+ })
+
+ assert.EqualValues(t, tc.expected, foundProps, "mismatching opengraph properties")
+ })
+ }
+}
diff --git a/tests/integration/org_count_test.go b/tests/integration/org_count_test.go
new file mode 100644
index 0000000..e3de925
--- /dev/null
+++ b/tests/integration/org_count_test.go
@@ -0,0 +1,149 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/url"
+ "strings"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestOrgCounts(t *testing.T) {
+ onGiteaRun(t, testOrgCounts)
+}
+
+func testOrgCounts(t *testing.T, u *url.URL) {
+ orgOwner := "user2"
+ orgName := "testOrg"
+ orgCollaborator := "user4"
+ ctx := NewAPITestContext(t, orgOwner, "repo1", auth_model.AccessTokenScopeWriteOrganization)
+
+ var ownerCountRepos map[string]int
+ var collabCountRepos map[string]int
+
+ t.Run("GetTheOwnersNumRepos", doCheckOrgCounts(orgOwner, map[string]int{},
+ false,
+ func(_ *testing.T, calcOrgCounts map[string]int) {
+ ownerCountRepos = calcOrgCounts
+ },
+ ))
+ t.Run("GetTheCollaboratorsNumRepos", doCheckOrgCounts(orgCollaborator, map[string]int{},
+ false,
+ func(_ *testing.T, calcOrgCounts map[string]int) {
+ collabCountRepos = calcOrgCounts
+ },
+ ))
+
+ t.Run("CreatePublicTestOrganization", doAPICreateOrganization(ctx, &api.CreateOrgOption{
+ UserName: orgName,
+ Visibility: "public",
+ }))
+
+ // Following the creation of the organization, the orgName must appear in the counts with 0 repos
+ ownerCountRepos[orgName] = 0
+
+ t.Run("AssertNumRepos0ForTestOrg", doCheckOrgCounts(orgOwner, ownerCountRepos, true))
+
+ // the collaborator is not a collaborator yet
+ t.Run("AssertNoTestOrgReposForCollaborator", doCheckOrgCounts(orgCollaborator, collabCountRepos, true))
+
+ t.Run("CreateOrganizationPrivateRepo", doAPICreateOrganizationRepository(ctx, orgName, &api.CreateRepoOption{
+ Name: "privateTestRepo",
+ AutoInit: true,
+ Private: true,
+ }))
+
+ ownerCountRepos[orgName] = 1
+ t.Run("AssertNumRepos1ForTestOrg", doCheckOrgCounts(orgOwner, ownerCountRepos, true))
+
+ t.Run("AssertNoTestOrgReposForCollaborator", doCheckOrgCounts(orgCollaborator, collabCountRepos, true))
+
+ var testTeam api.Team
+
+ t.Run("CreateTeamForPublicTestOrganization", doAPICreateOrganizationTeam(ctx, orgName, &api.CreateTeamOption{
+ Name: "test",
+ Permission: "read",
+ Units: []string{"repo.code", "repo.issues", "repo.wiki", "repo.pulls", "repo.releases"},
+ CanCreateOrgRepo: true,
+ }, func(_ *testing.T, team api.Team) {
+ testTeam = team
+ }))
+
+ t.Run("AssertNoTestOrgReposForCollaborator", doCheckOrgCounts(orgCollaborator, collabCountRepos, true))
+
+ t.Run("AddCollboratorToTeam", doAPIAddUserToOrganizationTeam(ctx, testTeam.ID, orgCollaborator))
+
+ collabCountRepos[orgName] = 0
+ t.Run("AssertNumRepos0ForTestOrgForCollaborator", doCheckOrgCounts(orgOwner, ownerCountRepos, true))
+
+ // Now create a Public Repo
+ t.Run("CreateOrganizationPublicRepo", doAPICreateOrganizationRepository(ctx, orgName, &api.CreateRepoOption{
+ Name: "publicTestRepo",
+ AutoInit: true,
+ }))
+
+ ownerCountRepos[orgName] = 2
+ t.Run("AssertNumRepos2ForTestOrg", doCheckOrgCounts(orgOwner, ownerCountRepos, true))
+ collabCountRepos[orgName] = 1
+ t.Run("AssertNumRepos1ForTestOrgForCollaborator", doCheckOrgCounts(orgOwner, ownerCountRepos, true))
+
+ // Now add the testTeam to the privateRepo
+ t.Run("AddTestTeamToPrivateRepo", doAPIAddRepoToOrganizationTeam(ctx, testTeam.ID, orgName, "privateTestRepo"))
+
+ t.Run("AssertNumRepos2ForTestOrg", doCheckOrgCounts(orgOwner, ownerCountRepos, true))
+ collabCountRepos[orgName] = 2
+ t.Run("AssertNumRepos2ForTestOrgForCollaborator", doCheckOrgCounts(orgOwner, ownerCountRepos, true))
+}
+
+func doCheckOrgCounts(username string, orgCounts map[string]int, strict bool, callback ...func(*testing.T, map[string]int)) func(t *testing.T) {
+ canonicalCounts := make(map[string]int, len(orgCounts))
+
+ for key, value := range orgCounts {
+ newKey := strings.TrimSpace(strings.ToLower(key))
+ canonicalCounts[newKey] = value
+ }
+
+ return func(t *testing.T) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{
+ Name: username,
+ })
+
+ orgs, err := db.Find[organization.Organization](db.DefaultContext, organization.FindOrgOptions{
+ UserID: user.ID,
+ IncludePrivate: true,
+ })
+ require.NoError(t, err)
+
+ calcOrgCounts := map[string]int{}
+
+ for _, org := range orgs {
+ calcOrgCounts[org.LowerName] = org.NumRepos
+ count, ok := canonicalCounts[org.LowerName]
+ if ok {
+ assert.Equal(t, count, org.NumRepos, "Number of Repos in %s is %d when we expected %d", org.Name, org.NumRepos, count)
+ } else {
+ assert.False(t, strict, "Did not expect to see %s with count %d", org.Name, org.NumRepos)
+ }
+ }
+
+ for key, value := range orgCounts {
+ _, seen := calcOrgCounts[strings.TrimSpace(strings.ToLower(key))]
+ assert.True(t, seen, "Expected to see %s with %d but did not", key, value)
+ }
+
+ if len(callback) > 0 {
+ callback[0](t, calcOrgCounts)
+ }
+ }
+}
diff --git a/tests/integration/org_nav_test.go b/tests/integration/org_nav_test.go
new file mode 100644
index 0000000..37b6292
--- /dev/null
+++ b/tests/integration/org_nav_test.go
@@ -0,0 +1,62 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// This test makes sure that organization members are able to navigate between `/<orgname>` and `/org/<orgname>/<section>` freely.
+// The `/org/<orgname>/<section>` page is only accessible to the members of the organization. It doesn't have
+// a special logic to show the button or not.
+// The `/<orgname>` page utilizes the `IsOrganizationMember` function to show the button for navigation to
+// the organization dashboard. That function is covered by a test and is supposed to be true for the
+// owners/admins/members of the organization.
+func TestOrgNavigationDashboard(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ locale := translation.NewLocale("en-US")
+
+ // Login as the future organization admin and create an organization
+ session1 := loginUser(t, "user2")
+ session1.MakeRequest(t, NewRequestWithValues(t, "POST", "/org/create", map[string]string{
+ "_csrf": GetCSRF(t, session1, "/org/create"),
+ "org_name": "org_navigation_test",
+ "visibility": "0",
+ "repo_admin_change_team_access": "on",
+ }), http.StatusSeeOther)
+
+ // Check if the "Open dashboard" button is available to the org admin (member)
+ resp := session1.MakeRequest(t, NewRequest(t, "GET", "/org_navigation_test"), http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ doc.AssertElement(t, "#org-info a[href='/org/org_navigation_test/dashboard']", true)
+
+ // Verify the "New repository" and "New migration" buttons
+ links := doc.Find(".organization.profile .grid .column .center")
+ assert.EqualValues(t, locale.TrString("new_repo.link"), strings.TrimSpace(links.Find("a[href^='/repo/create?org=']").Text()))
+ assert.EqualValues(t, locale.TrString("new_migrate.link"), strings.TrimSpace(links.Find("a[href^='/repo/migrate?org=']").Text()))
+
+ // Check if the "View <orgname>" button is available on dashboard for the org admin (member)
+ resp = session1.MakeRequest(t, NewRequest(t, "GET", "/org/org_navigation_test/dashboard"), http.StatusOK)
+ doc = NewHTMLParser(t, resp.Body)
+ doc.AssertElement(t, ".dashboard .secondary-nav a[href='/org_navigation_test']", true)
+
+ // Login a non-member user
+ session2 := loginUser(t, "user4")
+
+ // Check if the "Open dashboard" button is available to non-member
+ resp = session2.MakeRequest(t, NewRequest(t, "GET", "/org_navigation_test"), http.StatusOK)
+ doc = NewHTMLParser(t, resp.Body)
+ doc.AssertElement(t, "#org-info a[href='/org/org_navigation_test/dashboard']", false)
+
+ // There's no need to test "View <orgname>" button on dashboard as non-member
+ // because this page is not supposed to be visitable for this user
+}
diff --git a/tests/integration/org_project_test.go b/tests/integration/org_project_test.go
new file mode 100644
index 0000000..31d10f1
--- /dev/null
+++ b/tests/integration/org_project_test.go
@@ -0,0 +1,63 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "slices"
+ "testing"
+
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/tests"
+)
+
+func TestOrgProjectAccess(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ disabledRepoUnits := unit_model.DisabledRepoUnitsGet()
+ unit_model.DisabledRepoUnitsSet(append(slices.Clone(disabledRepoUnits), unit_model.TypeProjects))
+ defer unit_model.DisabledRepoUnitsSet(disabledRepoUnits)
+
+ // repo project, 404
+ req := NewRequest(t, "GET", "/user2/repo1/projects")
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // user project, 200
+ req = NewRequest(t, "GET", "/user2/-/projects")
+ MakeRequest(t, req, http.StatusOK)
+
+ // org project, 200
+ req = NewRequest(t, "GET", "/org3/-/projects")
+ MakeRequest(t, req, http.StatusOK)
+
+ // change the org's visibility to private
+ session := loginUser(t, "user2")
+ req = NewRequestWithValues(t, "POST", "/org/org3/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/org3/-/projects"),
+ "name": "org3",
+ "visibility": "2",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // user4 can still access the org's project because its team(team1) has the permission
+ session = loginUser(t, "user4")
+ req = NewRequest(t, "GET", "/org3/-/projects")
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // disable team1's project unit
+ session = loginUser(t, "user2")
+ req = NewRequestWithValues(t, "POST", "/org/org3/teams/team1/edit", map[string]string{
+ "_csrf": GetCSRF(t, session, "/org3/-/projects"),
+ "team_name": "team1",
+ "repo_access": "specific",
+ "permission": "read",
+ "unit_8": "0",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // user4 can no longer access the org's project
+ session = loginUser(t, "user4")
+ req = NewRequest(t, "GET", "/org3/-/projects")
+ session.MakeRequest(t, req, http.StatusNotFound)
+}
diff --git a/tests/integration/org_team_invite_test.go b/tests/integration/org_team_invite_test.go
new file mode 100644
index 0000000..d04199a
--- /dev/null
+++ b/tests/integration/org_team_invite_test.go
@@ -0,0 +1,379 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestOrgTeamEmailInvite(t *testing.T) {
+ if setting.MailService == nil {
+ t.Skip()
+ return
+ }
+
+ defer tests.PrepareTestEnv(t)()
+
+ org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
+ team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+
+ isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID)
+ require.NoError(t, err)
+ assert.False(t, isMember)
+
+ session := loginUser(t, "user1")
+
+ teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name)
+ csrf := GetCSRF(t, session, teamURL)
+ req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{
+ "_csrf": csrf,
+ "uid": "1",
+ "uname": user.Email,
+ })
+ resp := session.MakeRequest(t, req, http.StatusSeeOther)
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // get the invite token
+ invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID)
+ require.NoError(t, err)
+ assert.Len(t, invites, 1)
+
+ session = loginUser(t, user.Name)
+
+ // join the team
+ inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token)
+ csrf = GetCSRF(t, session, inviteURL)
+ req = NewRequestWithValues(t, "POST", inviteURL, map[string]string{
+ "_csrf": csrf,
+ })
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ isMember, err = organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID)
+ require.NoError(t, err)
+ assert.True(t, isMember)
+}
+
+// Check that users are redirected to accept the invitation correctly after login
+func TestOrgTeamEmailInviteRedirectsExistingUser(t *testing.T) {
+ if setting.MailService == nil {
+ t.Skip()
+ return
+ }
+
+ defer tests.PrepareTestEnv(t)()
+
+ org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
+ team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+
+ isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID)
+ require.NoError(t, err)
+ assert.False(t, isMember)
+
+ // create the invite
+ session := loginUser(t, "user1")
+
+ teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name)
+ req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{
+ "_csrf": GetCSRF(t, session, teamURL),
+ "uid": "1",
+ "uname": user.Email,
+ })
+ resp := session.MakeRequest(t, req, http.StatusSeeOther)
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // get the invite token
+ invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID)
+ require.NoError(t, err)
+ assert.Len(t, invites, 1)
+
+ // accept the invite
+ inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token)
+ req = NewRequest(t, "GET", fmt.Sprintf("/user/login?redirect_to=%s", url.QueryEscape(inviteURL)))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ req = NewRequestWithValues(t, "POST", "/user/login", map[string]string{
+ "_csrf": doc.GetCSRF(),
+ "user_name": "user5",
+ "password": "password",
+ })
+ for _, c := range resp.Result().Cookies() {
+ req.AddCookie(c)
+ }
+
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, inviteURL, test.RedirectURL(resp))
+
+ // complete the login process
+ ch := http.Header{}
+ ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";"))
+ cr := http.Request{Header: ch}
+
+ session = emptyTestSession(t)
+ baseURL, err := url.Parse(setting.AppURL)
+ require.NoError(t, err)
+ session.jar.SetCookies(baseURL, cr.Cookies())
+
+ // make the request
+ req = NewRequestWithValues(t, "POST", test.RedirectURL(resp), map[string]string{
+ "_csrf": GetCSRF(t, session, test.RedirectURL(resp)),
+ })
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ isMember, err = organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID)
+ require.NoError(t, err)
+ assert.True(t, isMember)
+}
+
+// Check that newly signed up users are redirected to accept the invitation correctly
+func TestOrgTeamEmailInviteRedirectsNewUser(t *testing.T) {
+ if setting.MailService == nil {
+ t.Skip()
+ return
+ }
+
+ defer tests.PrepareTestEnv(t)()
+
+ org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
+ team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
+
+ // create the invite
+ session := loginUser(t, "user1")
+
+ teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name)
+ req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{
+ "_csrf": GetCSRF(t, session, teamURL),
+ "uid": "1",
+ "uname": "doesnotexist@example.com",
+ })
+ resp := session.MakeRequest(t, req, http.StatusSeeOther)
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // get the invite token
+ invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID)
+ require.NoError(t, err)
+ assert.Len(t, invites, 1)
+
+ // accept the invite
+ inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token)
+ req = NewRequest(t, "GET", fmt.Sprintf("/user/sign_up?redirect_to=%s", url.QueryEscape(inviteURL)))
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ req = NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
+ "_csrf": doc.GetCSRF(),
+ "user_name": "doesnotexist",
+ "email": "doesnotexist@example.com",
+ "password": "examplePassword!1",
+ "retype": "examplePassword!1",
+ })
+ for _, c := range resp.Result().Cookies() {
+ req.AddCookie(c)
+ }
+
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, inviteURL, test.RedirectURL(resp))
+
+ // complete the signup process
+ ch := http.Header{}
+ ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";"))
+ cr := http.Request{Header: ch}
+
+ session = emptyTestSession(t)
+ baseURL, err := url.Parse(setting.AppURL)
+ require.NoError(t, err)
+ session.jar.SetCookies(baseURL, cr.Cookies())
+
+ // make the redirected request
+ req = NewRequestWithValues(t, "POST", test.RedirectURL(resp), map[string]string{
+ "_csrf": GetCSRF(t, session, test.RedirectURL(resp)),
+ })
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // get the new user
+ newUser, err := user_model.GetUserByName(db.DefaultContext, "doesnotexist")
+ require.NoError(t, err)
+
+ isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, newUser.ID)
+ require.NoError(t, err)
+ assert.True(t, isMember)
+}
+
+// Check that users are redirected correctly after confirming their email
+func TestOrgTeamEmailInviteRedirectsNewUserWithActivation(t *testing.T) {
+ if setting.MailService == nil {
+ t.Skip()
+ return
+ }
+
+ // enable email confirmation temporarily
+ defer func(prevVal bool) {
+ setting.Service.RegisterEmailConfirm = prevVal
+ }(setting.Service.RegisterEmailConfirm)
+ setting.Service.RegisterEmailConfirm = true
+
+ defer tests.PrepareTestEnv(t)()
+
+ org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
+ team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
+
+ // create the invite
+ session := loginUser(t, "user1")
+
+ teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name)
+ req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{
+ "_csrf": GetCSRF(t, session, teamURL),
+ "uid": "1",
+ "uname": "doesnotexist@example.com",
+ })
+ resp := session.MakeRequest(t, req, http.StatusSeeOther)
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // get the invite token
+ invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID)
+ require.NoError(t, err)
+ assert.Len(t, invites, 1)
+
+ // accept the invite
+ inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token)
+ req = NewRequest(t, "GET", fmt.Sprintf("/user/sign_up?redirect_to=%s", url.QueryEscape(inviteURL)))
+ inviteResp := MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ req = NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
+ "_csrf": doc.GetCSRF(),
+ "user_name": "doesnotexist",
+ "email": "doesnotexist@example.com",
+ "password": "examplePassword!1",
+ "retype": "examplePassword!1",
+ })
+ for _, c := range inviteResp.Result().Cookies() {
+ req.AddCookie(c)
+ }
+
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ user, err := user_model.GetUserByName(db.DefaultContext, "doesnotexist")
+ require.NoError(t, err)
+
+ ch := http.Header{}
+ ch.Add("Cookie", strings.Join(resp.Header()["Set-Cookie"], ";"))
+ cr := http.Request{Header: ch}
+
+ session = emptyTestSession(t)
+ baseURL, err := url.Parse(setting.AppURL)
+ require.NoError(t, err)
+ session.jar.SetCookies(baseURL, cr.Cookies())
+
+ activateURL := fmt.Sprintf("/user/activate?code=%s", user.GenerateEmailActivateCode("doesnotexist@example.com"))
+ req = NewRequestWithValues(t, "POST", activateURL, map[string]string{
+ "password": "examplePassword!1",
+ })
+
+ // use the cookies set by the signup request
+ for _, c := range inviteResp.Result().Cookies() {
+ req.AddCookie(c)
+ }
+
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ // should be redirected to accept the invite
+ assert.Equal(t, inviteURL, test.RedirectURL(resp))
+
+ req = NewRequestWithValues(t, "POST", test.RedirectURL(resp), map[string]string{
+ "_csrf": GetCSRF(t, session, test.RedirectURL(resp)),
+ })
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID)
+ require.NoError(t, err)
+ assert.True(t, isMember)
+}
+
+// Test that a logged-in user who navigates to the sign-up link is then redirected using redirect_to
+// For example: an invite may have been created before the user account was created, but they may be
+// accepting the invite after having created an account separately
+func TestOrgTeamEmailInviteRedirectsExistingUserWithLogin(t *testing.T) {
+ if setting.MailService == nil {
+ t.Skip()
+ return
+ }
+
+ defer tests.PrepareTestEnv(t)()
+
+ org := unittest.AssertExistsAndLoadBean(t, &organization.Organization{ID: 3})
+ team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: 2})
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+
+ isMember, err := organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID)
+ require.NoError(t, err)
+ assert.False(t, isMember)
+
+ // create the invite
+ session := loginUser(t, "user1")
+
+ teamURL := fmt.Sprintf("/org/%s/teams/%s", org.Name, team.Name)
+ req := NewRequestWithValues(t, "POST", teamURL+"/action/add", map[string]string{
+ "_csrf": GetCSRF(t, session, teamURL),
+ "uid": "1",
+ "uname": user.Email,
+ })
+ resp := session.MakeRequest(t, req, http.StatusSeeOther)
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // get the invite token
+ invites, err := organization.GetInvitesByTeamID(db.DefaultContext, team.ID)
+ require.NoError(t, err)
+ assert.Len(t, invites, 1)
+
+ // note: the invited user has logged in
+ session = loginUser(t, "user5")
+
+ // accept the invite (note: this uses the sign_up url)
+ inviteURL := fmt.Sprintf("/org/invite/%s", invites[0].Token)
+ req = NewRequest(t, "GET", fmt.Sprintf("/user/sign_up?redirect_to=%s", url.QueryEscape(inviteURL)))
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, inviteURL, test.RedirectURL(resp))
+
+ // make the request
+ req = NewRequestWithValues(t, "POST", test.RedirectURL(resp), map[string]string{
+ "_csrf": GetCSRF(t, session, test.RedirectURL(resp)),
+ })
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ isMember, err = organization.IsTeamMember(db.DefaultContext, team.OrgID, team.ID, user.ID)
+ require.NoError(t, err)
+ assert.True(t, isMember)
+}
diff --git a/tests/integration/org_test.go b/tests/integration/org_test.go
new file mode 100644
index 0000000..ad7e31f
--- /dev/null
+++ b/tests/integration/org_test.go
@@ -0,0 +1,271 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/organization"
+ "code.gitea.io/gitea/models/perm"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestOrgRepos(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ var (
+ users = []string{"user1", "user2"}
+ cases = map[string][]string{
+ "alphabetically": {"repo21", "repo3", "repo5"},
+ "reversealphabetically": {"repo5", "repo3", "repo21"},
+ }
+ )
+
+ for _, user := range users {
+ t.Run(user, func(t *testing.T) {
+ session := loginUser(t, user)
+ for sortBy, repos := range cases {
+ req := NewRequest(t, "GET", "/org3?sort="+sortBy)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ sel := htmlDoc.doc.Find("a.name")
+ assert.Len(t, repos, len(sel.Nodes))
+ for i := 0; i < len(repos); i++ {
+ assert.EqualValues(t, repos[i], strings.TrimSpace(sel.Eq(i).Text()))
+ }
+ }
+ })
+ }
+}
+
+func TestLimitedOrg(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // not logged in user
+ req := NewRequest(t, "GET", "/limited_org")
+ MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", "/limited_org/public_repo_on_limited_org")
+ MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", "/limited_org/private_repo_on_limited_org")
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // login non-org member user
+ session := loginUser(t, "user2")
+ req = NewRequest(t, "GET", "/limited_org")
+ session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", "/limited_org/public_repo_on_limited_org")
+ session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", "/limited_org/private_repo_on_limited_org")
+ session.MakeRequest(t, req, http.StatusNotFound)
+
+ // site admin
+ session = loginUser(t, "user1")
+ req = NewRequest(t, "GET", "/limited_org")
+ session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", "/limited_org/public_repo_on_limited_org")
+ session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", "/limited_org/private_repo_on_limited_org")
+ session.MakeRequest(t, req, http.StatusOK)
+}
+
+func TestPrivateOrg(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // not logged in user
+ req := NewRequest(t, "GET", "/privated_org")
+ MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", "/privated_org/public_repo_on_private_org")
+ MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", "/privated_org/private_repo_on_private_org")
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // login non-org member user
+ session := loginUser(t, "user2")
+ req = NewRequest(t, "GET", "/privated_org")
+ session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", "/privated_org/public_repo_on_private_org")
+ session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", "/privated_org/private_repo_on_private_org")
+ session.MakeRequest(t, req, http.StatusNotFound)
+
+ // non-org member who is collaborator on repo in private org
+ session = loginUser(t, "user4")
+ req = NewRequest(t, "GET", "/privated_org")
+ session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", "/privated_org/public_repo_on_private_org") // colab of this repo
+ session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", "/privated_org/private_repo_on_private_org")
+ session.MakeRequest(t, req, http.StatusNotFound)
+
+ // site admin
+ session = loginUser(t, "user1")
+ req = NewRequest(t, "GET", "/privated_org")
+ session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", "/privated_org/public_repo_on_private_org")
+ session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", "/privated_org/private_repo_on_private_org")
+ session.MakeRequest(t, req, http.StatusOK)
+}
+
+func TestOrgMembers(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // not logged in user
+ req := NewRequest(t, "GET", "/org/org25/members")
+ MakeRequest(t, req, http.StatusOK)
+
+ // org member
+ session := loginUser(t, "user24")
+ req = NewRequest(t, "GET", "/org/org25/members")
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // site admin
+ session = loginUser(t, "user1")
+ req = NewRequest(t, "GET", "/org/org25/members")
+ session.MakeRequest(t, req, http.StatusOK)
+}
+
+func TestOrgRestrictedUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // privated_org is a private org who has id 23
+ orgName := "privated_org"
+
+ // public_repo_on_private_org is a public repo on privated_org
+ repoName := "public_repo_on_private_org"
+
+ // user29 is a restricted user who is not a member of the organization
+ restrictedUser := "user29"
+
+ // #17003 reports a bug whereby adding a restricted user to a read-only team doesn't work
+
+ // assert restrictedUser cannot see the org or the public repo
+ restrictedSession := loginUser(t, restrictedUser)
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s", orgName))
+ restrictedSession.MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s", orgName, repoName))
+ restrictedSession.MakeRequest(t, req, http.StatusNotFound)
+
+ // Therefore create a read-only team
+ adminSession := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeWriteOrganization)
+
+ teamToCreate := &api.CreateTeamOption{
+ Name: "codereader",
+ Description: "Code Reader",
+ IncludesAllRepositories: true,
+ Permission: "read",
+ Units: []string{"repo.code"},
+ }
+
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/orgs/%s/teams", orgName), teamToCreate).
+ AddTokenAuth(token)
+
+ var apiTeam api.Team
+
+ resp := adminSession.MakeRequest(t, req, http.StatusCreated)
+ DecodeJSON(t, resp, &apiTeam)
+ checkTeamResponse(t, "CreateTeam_codereader", &apiTeam, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories,
+ teamToCreate.Permission, teamToCreate.Units, nil)
+ checkTeamBean(t, apiTeam.ID, teamToCreate.Name, teamToCreate.Description, teamToCreate.IncludesAllRepositories,
+ teamToCreate.Permission, teamToCreate.Units, nil)
+ // teamID := apiTeam.ID
+
+ // Now we need to add the restricted user to the team
+ req = NewRequest(t, "PUT", fmt.Sprintf("/api/v1/teams/%d/members/%s", apiTeam.ID, restrictedUser)).
+ AddTokenAuth(token)
+ _ = adminSession.MakeRequest(t, req, http.StatusNoContent)
+
+ // Now we need to check if the restrictedUser can access the repo
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s", orgName))
+ restrictedSession.MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s", orgName, repoName))
+ restrictedSession.MakeRequest(t, req, http.StatusOK)
+}
+
+func TestTeamSearch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 15})
+ org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 17})
+
+ var results TeamSearchResults
+
+ session := loginUser(t, user.Name)
+ csrf := GetCSRF(t, session, "/"+org.Name)
+ req := NewRequestf(t, "GET", "/org/%s/teams/-/search?q=%s", org.Name, "_team")
+ req.Header.Add("X-Csrf-Token", csrf)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &results)
+ assert.NotEmpty(t, results.Data)
+ assert.Len(t, results.Data, 2)
+ assert.Equal(t, "review_team", results.Data[0].Name)
+ assert.Equal(t, "test_team", results.Data[1].Name)
+
+ // no access if not organization member
+ user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ session = loginUser(t, user5.Name)
+ csrf = GetCSRF(t, session, "/"+org.Name)
+ req = NewRequestf(t, "GET", "/org/%s/teams/-/search?q=%s", org.Name, "team")
+ req.Header.Add("X-Csrf-Token", csrf)
+ session.MakeRequest(t, req, http.StatusNotFound)
+}
+
+func TestOrgDashboardLabels(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 4})
+ org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
+ session := loginUser(t, user.Name)
+
+ req := NewRequestf(t, "GET", "/org/%s/issues?labels=3,4", org.Name)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ labelFilterHref, ok := htmlDoc.Find(".list-header-sort a").Attr("href")
+ assert.True(t, ok)
+ assert.Contains(t, labelFilterHref, "labels=3%2c4")
+
+ // Exclude label
+ req = NewRequestf(t, "GET", "/org/%s/issues?labels=3,-4", org.Name)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+
+ labelFilterHref, ok = htmlDoc.Find(".list-header-sort a").Attr("href")
+ assert.True(t, ok)
+ assert.Contains(t, labelFilterHref, "labels=3%2c-4")
+}
+
+func TestOwnerTeamUnit(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3, Type: user_model.UserTypeOrganization})
+ session := loginUser(t, user.Name)
+
+ unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{TeamID: 1, Type: unit.TypeIssues, AccessMode: perm.AccessModeOwner})
+
+ req := NewRequestWithValues(t, "GET", fmt.Sprintf("/org/%s/teams/owners/edit", org.Name), map[string]string{
+ "_csrf": GetCSRF(t, session, fmt.Sprintf("/org/%s/teams/owners/edit", org.Name)),
+ "team_name": "Owners",
+ "Description": "Just a description",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ unittest.AssertExistsAndLoadBean(t, &organization.TeamUnit{TeamID: 1, Type: unit.TypeIssues, AccessMode: perm.AccessModeOwner})
+}
diff --git a/tests/integration/private-testing.key b/tests/integration/private-testing.key
new file mode 100644
index 0000000..b3874ea
--- /dev/null
+++ b/tests/integration/private-testing.key
@@ -0,0 +1,81 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lQVYBGG44vABDAC7VVdrVcU2CzI4P1vm0HtsgRCj9TsCpxjESleIheG/jrLjpVaF
+YrlVKQ0+q6HXOMcbjJnsm+N6hgZNqwaKTNC6+LJZMXHlPG8wUGrHgHyUZ03urYB6
+vjlJ70RUBu1+dB5yJcTOk7kMLx8/is9FlAEEY/G98aviv2m3My6B5SJ2BErjREIw
+eRnWFm+JDcga9nRi8ra/DMac45iQ4IcQcj0NDlCn3aY88nGa6o1+07h7wYwI3t8S
++pfITuJgWf2cYK49v9QVsBMR8XHuS8UDGFuJ1Y4KK5zMHWKhah/6isyWPSgiC0wo
+V7LZDJp/tN8IoQf2fchRQN+x0PBeVXdt3KGXqvsfk7hnwGDKjGMp4nTxL8PFhpG8
+KJP0tTA063bbnrGjVYHaulTBTSKS8R3Zk2utA8JUgTU6tkNFoh8rNLgh2xtw/Ci3
+kvKzTdikWxBfspYgrWloMyCTZwOHssARyarXgtysEI1hNpvgpJo0WZOMurYuFDIB
+kEqgnqe1b1B7ItcAEQEAAQAL/iNebgZkZ7sX6w/mmn3eL+dhCNjD5LPQA6OP2635
+hRFLKmhDn63IYXB8MzV5ZzGA1UrUxX0AQ7cu1cLVPwNelGwwp0+iv7vFqMKI9Fgd
+YKgORw8AsAi8oIlehNqOgkmFN/haPCm6h04PGYnANfkPhA+lpQ81MTw64oVFwwqg
+TdzVW6RED3EidCfRDZblRLoefQPvimRQz7DwYa48zhNjVjaAVOcUuJ26MovKrBNd
+eu/Wr48/MQPez0hw6FnDs9fSAtB/cLmSlSL3yBkDB4RHTne6amvemX5SyQqOSKLJ
+F+YM33yIN3NQNQtJUkjNkBWuIe+s8pxFuKTHNyulCe/ES0ivtnqaCJ/J/PPzn/3t
+2S5f1K26jqJEnu4SfCxG3xTbSMu9DIcDP6BkU6WK9dQCPyfWZ3r3QkgZjHt02HP9
+Gbzh2tSxBO3b4ujysdSB2l78I0s3XLWae6FPNNKG+zmlCV8mUEa+OFVjS60GrX83
+NQVfoyjNdSQkLlg3+bo5DFma+QYAwr/HXi06iC8dh23HkPkYedIOml70SPAQqvVj
+xYtZRRSXo98P+QtA2kX0G3/9f606n2qqA9JXc3m4euvE94oSp708M5xAkSfdsc6B
+QIDNrR5ty+f+WdhZAsW4Gu/XbQ5ndkRReTtc3UtzIrC0zg8egCoE0yMfCJWPS2nF
+QTdlsl+cXDSQj7UMfCP9cKSsTzdEAF/P5ALI7Y+W4va/gy/0czJne+ZNMxPWE+Gs
+00KJCbSfgktnYhVt/XdWKuRZ8ylZBgD2QHts6MHkfno/OUK3wYDB7zLMIBdLltlg
+wvp7CXh8hIxzNqxaAjGus1XAg+/7QbSey/t88CR9XQsekd/L8NIYaFOxSpVAe03V
+RaW2/EXtmKIHKoWBTQJLJle3mp+iUiVjzdmTyUAqhFaCBYVMBlSvBuC99jXnu3U3
+UcUelLDvP2ufMdeXhVU1Anfg45wqvyfPIAhpgYMmyprGpfkd2Sf2W1ThaTec0kI1
+cT7AtkrqijCGDgo9ohl8ojmRhRCl968F/imENQATANdkhbYJ0k1+Ubm690xYNN7u
+d+wnQzS9P/UPpMrC4H2esz9g+Nls7X6/jeGB6K0bpOYAUR1VlRfuXREJcy9bK9Q8
+gzfBC4XWELA726fc9YeJqWH4fI9SFx0AjVVx6VFwSiDcoYbX26CLZN+jY6Gx8kx6
+PrOf4tPCU+8EP5f/tYn/dwN9oQPoyM7bYyN/zcrupLhHON7ryFr++Kpiw0feBGbg
+kEP+0HWJ2cX1MvcqTurx344RVlmnEBesDuFstBhnaXRlYSA8Z2l0ZWFAZmFrZS5s
+b2NhbD6JAc4EEwEKADgWIQT3rIVBIbYw8mUW1p+Z3Yqpy9FcAQUCYbji8AIbAwUL
+CQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCZ3Yqpy9FcAY6AC/9GUc0vGAmZ1N7P
+ThOxy3SvoIWJzycEu6DKdp4FlucKW9Rm66vCwPDg7XcQxZQTIWNPIGB3kln0yRdx
+zRtGLKIDPo2qW8kPrLN3GXToKX2mBb76duaShW34W1rUVY613olmtwLT+QqgRX+H
+x0rNNJloOh3kawwaMoYZy4B2vq7AZ5ybIsT4ROKgKPzAlajI4+jI+qKA5GSyP7Jq
+Tu254BCeg0v51p0VWIbGdgPyVkZkLtrlxN7s8UGDoTUAJgB/K3SOGNtQFSxnJba5
+q0YBxDUScd65b1+YCUHY+3FdC4/5168y4Zic9bBxeVu3jBwSVDvrELsWzIDNgHmP
+eyl/Nv+CTZDDKOtzpS823k7gC129rcxMk0mkIzAt/wG7N4zf0vpt02LZ/Ei/azqK
+xq782Fmc3un+pgQWJrlU2ZT7yHi6aJAfxfDpQZwz8qXGgdaFsumNylEWy9o80pJG
+8RYgM+phZL4INYIiHoWUuz2v+qK9jmhxtTLOpKDXxtGrz6aFWJadBVgEYbji8AEM
+AMDFivCjl7vGACeST4iboZw817uAJFOTOk3uOnXuAx5NLq/DbL3Cyhjictwxhxot
+U1MdAZOSOHlWPBJTiib1145rDTJCH6gwQNVaqn/V0i/Dc2Isua4YF0efztzwD2aH
+NX4RCDp74bQ08YTsAlCWHk7blg3NCU/y4maaxdJ26PsNrIiY0l5SC3oNiEAp2aWP
+Yf+plmQwqk+Z3laB5fkVz8Vca8TZle11/NZVVwrpq8rubPUYHC2KmabFLihcMCGv
+eTt3LCB7tDzohDmX/0vuqTD09YTv5gmIzU/tx4+qH4tVfCK/DKTxsxafY4KZY4kM
+hqrhuGWq8EAu4RUG6AzbSDJZnO1UAfzC9j/8upr3qxOXx/xhWKzixGrRXo9eK5eR
+1pqEj+XGH+f9bQiF/pEIojcUp45S0ZBaSBPj2W7TZbbHzqXYNzmXa3IVdz+9l7MB
+cRfIe67wt4h66/fmATe47KvHNRfKpyhFD2utdOSd61tKXo/bu/5LBath0mxMBPHd
+4QARAQABAAv5Aacf824U/LiW+JU4poVJFofEr22gQhwwIt9rnmZm80ak+L+o9MaR
+CN4WLzJN2X5b1B8FTAXerexR8bPy1QsvaN/yRMT23wW3j0IVVf5tbIM/6m6o5+fP
+zp7S5/zh8OvbXE7v6Qp2C19sgQqB/ugOmff9hSBF18A6II2Wq8uLtgKua5xof1kI
+5/1qNpH1SltcndPPKjbq8D7zk6kjoZCw5PJk1ShVcKwIjzDmS729qezZ6nm6sh7v
+BX70JUdHErQzBtcb+Y39nRC/7aQ/X5s73Iy9OsnAzzTSTtw1RgxgAYXxQKhQN5xP
+rzUdZqCSFicjLAPvY4PxQmIL+DS7tb/rrWUJAfr/9LcrzoOC5LaYFTuykq231ORs
+4oRfHmJqYAiMYQ7iXMtFVspxQWq/8qrBPmmEkS2oAnmd8Ld5hbd7sFBsS5GCW9a3
+UyQQ9WQECyvpgFOR9m746/bFjKMgG+aBHyKvndniF3XWjHWrzrbk5vAViMb+9Al+
+7MxSqZ/oNrvdBgDT6hTMwyNBvQwJ/Lev0S3XPDJmxg+Y8QIrNbBrXjA70yVeLFgr
+emDnfdAwuhmZ5vKRe2YcIyMIOagRIDUEWs8EyCvM2e+bF+I0meQvWT536Cm2TouI
+jCUIip4HRTwe7NAR50OMACtji8sbcmfnIfFMfGUS3dPpNGURhCEHxWB6hlvbkbkV
+CToTlMS/agY0sV4O4kWqWiaKgZRefJSiVfj6RDKs43SbNxhJu+DslU7PPlfv6SFJ
+nX9LWE6daLrpuF0GAOjf+kjqpFFgF50h3B7lCsSfxIKW587z93rkmccGKvZj4Qeq
+ahjekO6kxapYJhtjY9BOQdU0rzEPhh8bF39GE/iCfXVdIh1suqp3uQv9birgkWJN
+CROrHvk5NmlBBb4BDid0hY8hM3lEi+6rK2lhs4krpoHin/h852AI+YBzeAVYSqor
+fqEzCiPlX7f1EI3I6kPnGrgeIWcznOO0yXkM/QuKCDWZlaLDxu7Rc5lBnsmiChrT
+3HwOiyOFfU1Rib/TVQYAng1PxHZfIfC77cblAiv3SXjFtSDIfyueER3Ii11DyEfB
+zco+qbpqYiDEI7yLZFuyExEpT2GbHTTEn28aEZzZBv/aFRnVFPTMiyquFE7QKuLc
+aEpEYZE3qSiAUDAckfDblM1SHZAVP6CaStkoUigtYBND2F316MTNGGLtcJ4y9s1r
+soqvCJ/cx0lR359kljqCHyv+iMqeBttwTGjFbiNJ5as4ATA988FlR6PnB0cr+Lg2
+8X3xiRcAaxlLFcUOifpa3m6JAbYEGAEKACAWIQT3rIVBIbYw8mUW1p+Z3Yqpy9Fc
+AQUCYbji8AIbDAAKCRCZ3Yqpy9FcAT/pDACilZ8zPUs+MwwI0BI6dMWxmhusHwTx
+kdwbxt2TuCQE3DEftCTCaxO5f8hQ6CL9pxYw5mn/6p8ELUpindFxgzpBjUQZyynb
++ZA7LOK5gKw25vGTRcMFiWZOBnMEAifyywmG6XCPtio8i3/In95ix/Adi17tzdpy
+EfFfWTeDocTNPhIPhg9REteZ71eBW3qEbY2iCeG3XSpKhkj6obY7BL8xLT9iaezh
+C6Upzb3gvjEInoaMR2yra9fVugW32lCFgXr6UZ5osBqVNjXGcwBqxg5IAkt4R5v1
+vdt5h69cagkbdS0qSRbS56GctmxVnbWyuAuKON55BDri5BhO3V4GmIXXUW12dQhl
+1/P9+xMjHm424QlGL7jgEzOMR5CJFdDQ+osabA2iZAUEQ7Ut8SgREfCduqKqzJ7z
+Uvb3feuoW45VNBqv7op8hH1S8okFaCTuznrAPqGXxee0I3oTX1lbBW+IySoisWMC
+ZMtt+nu5oJo/m1bvhWiYLhW6WX8TcmRKD3s=
+=V9rS
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/tests/integration/privateactivity_test.go b/tests/integration/privateactivity_test.go
new file mode 100644
index 0000000..5362462
--- /dev/null
+++ b/tests/integration/privateactivity_test.go
@@ -0,0 +1,418 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ activities_model "code.gitea.io/gitea/models/activities"
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+const (
+ privateActivityTestAdmin = "user1"
+ privateActivityTestUser = "user2"
+)
+
+// org3 is an organization so it is not usable here
+const privateActivityTestOtherUser = "user4"
+
+// activity helpers
+
+func testPrivateActivityDoSomethingForActionEntries(t *testing.T) {
+ repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID})
+
+ session := loginUser(t, privateActivityTestUser)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
+ urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues?state=all", owner.Name, repoBefore.Name)
+ req := NewRequestWithJSON(t, "POST", urlStr, &api.CreateIssueOption{
+ Body: "test",
+ Title: "test",
+ }).AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusCreated)
+}
+
+// private activity helpers
+
+func testPrivateActivityHelperEnablePrivateActivity(t *testing.T) {
+ session := loginUser(t, privateActivityTestUser)
+ req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings"),
+ "name": privateActivityTestUser,
+ "email": privateActivityTestUser + "@example.com",
+ "language": "en-US",
+ "keep_activity_private": "1",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+}
+
+func testPrivateActivityHelperHasVisibleActivitiesInHTMLDoc(htmlDoc *HTMLDoc) bool {
+ return htmlDoc.doc.Find("#activity-feed").Find(".flex-item").Length() > 0
+}
+
+func testPrivateActivityHelperHasVisibleActivitiesFromSession(t *testing.T, session *TestSession) bool {
+ req := NewRequestf(t, "GET", "/%s?tab=activity", privateActivityTestUser)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ return testPrivateActivityHelperHasVisibleActivitiesInHTMLDoc(htmlDoc)
+}
+
+func testPrivateActivityHelperHasVisibleActivitiesFromPublic(t *testing.T) bool {
+ req := NewRequestf(t, "GET", "/%s?tab=activity", privateActivityTestUser)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ return testPrivateActivityHelperHasVisibleActivitiesInHTMLDoc(htmlDoc)
+}
+
+// heatmap UI helpers
+
+func testPrivateActivityHelperHasVisibleHeatmapInHTMLDoc(htmlDoc *HTMLDoc) bool {
+ return htmlDoc.doc.Find("#user-heatmap").Length() > 0
+}
+
+func testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t *testing.T, session *TestSession) bool {
+ req := NewRequestf(t, "GET", "/%s?tab=activity", privateActivityTestUser)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ return testPrivateActivityHelperHasVisibleHeatmapInHTMLDoc(htmlDoc)
+}
+
+func testPrivateActivityHelperHasVisibleDashboardHeatmapFromSession(t *testing.T, session *TestSession) bool {
+ req := NewRequest(t, "GET", "/")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ return testPrivateActivityHelperHasVisibleHeatmapInHTMLDoc(htmlDoc)
+}
+
+func testPrivateActivityHelperHasVisibleHeatmapFromPublic(t *testing.T) bool {
+ req := NewRequestf(t, "GET", "/%s?tab=activity", privateActivityTestUser)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ return testPrivateActivityHelperHasVisibleHeatmapInHTMLDoc(htmlDoc)
+}
+
+// heatmap API helpers
+
+func testPrivateActivityHelperHasHeatmapContentFromPublic(t *testing.T) bool {
+ req := NewRequestf(t, "GET", "/api/v1/users/%s/heatmap", privateActivityTestUser)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var items []*activities_model.UserHeatmapData
+ DecodeJSON(t, resp, &items)
+
+ return len(items) != 0
+}
+
+func testPrivateActivityHelperHasHeatmapContentFromSession(t *testing.T, session *TestSession) bool {
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadUser)
+
+ req := NewRequestf(t, "GET", "/api/v1/users/%s/heatmap", privateActivityTestUser).
+ AddTokenAuth(token)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ var items []*activities_model.UserHeatmapData
+ DecodeJSON(t, resp, &items)
+
+ return len(items) != 0
+}
+
+// check activity visibility if the visibility is enabled
+
+func TestPrivateActivityNoVisibleForPublic(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+
+ visible := testPrivateActivityHelperHasVisibleActivitiesFromPublic(t)
+
+ assert.True(t, visible, "user should have visible activities")
+}
+
+func TestPrivateActivityNoVisibleForUserItself(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+
+ session := loginUser(t, privateActivityTestUser)
+ visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session)
+
+ assert.True(t, visible, "user should have visible activities")
+}
+
+func TestPrivateActivityNoVisibleForOtherUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+
+ session := loginUser(t, privateActivityTestOtherUser)
+ visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session)
+
+ assert.True(t, visible, "user should have visible activities")
+}
+
+func TestPrivateActivityNoVisibleForAdmin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+
+ session := loginUser(t, privateActivityTestAdmin)
+ visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session)
+
+ assert.True(t, visible, "user should have visible activities")
+}
+
+// check activity visibility if the visibility is disabled
+
+func TestPrivateActivityYesInvisibleForPublic(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+ testPrivateActivityHelperEnablePrivateActivity(t)
+
+ visible := testPrivateActivityHelperHasVisibleActivitiesFromPublic(t)
+
+ assert.False(t, visible, "user should have no visible activities")
+}
+
+func TestPrivateActivityYesVisibleForUserItself(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+ testPrivateActivityHelperEnablePrivateActivity(t)
+
+ session := loginUser(t, privateActivityTestUser)
+ visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session)
+
+ assert.True(t, visible, "user should have visible activities")
+}
+
+func TestPrivateActivityYesInvisibleForOtherUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+ testPrivateActivityHelperEnablePrivateActivity(t)
+
+ session := loginUser(t, privateActivityTestOtherUser)
+ visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session)
+
+ assert.False(t, visible, "user should have no visible activities")
+}
+
+func TestPrivateActivityYesVisibleForAdmin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+ testPrivateActivityHelperEnablePrivateActivity(t)
+
+ session := loginUser(t, privateActivityTestAdmin)
+ visible := testPrivateActivityHelperHasVisibleActivitiesFromSession(t, session)
+
+ assert.True(t, visible, "user should have visible activities")
+}
+
+// check heatmap visibility if the visibility is enabled
+
+func TestPrivateActivityNoHeatmapVisibleForPublic(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+
+ visible := testPrivateActivityHelperHasVisibleHeatmapFromPublic(t)
+
+ assert.True(t, visible, "user should have visible heatmap")
+}
+
+func TestPrivateActivityNoHeatmapVisibleForUserItselfAtProfile(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+
+ session := loginUser(t, privateActivityTestUser)
+ visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session)
+
+ assert.True(t, visible, "user should have visible heatmap")
+}
+
+func TestPrivateActivityNoHeatmapVisibleForUserItselfAtDashboard(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+
+ session := loginUser(t, privateActivityTestUser)
+ visible := testPrivateActivityHelperHasVisibleDashboardHeatmapFromSession(t, session)
+
+ assert.True(t, visible, "user should have visible heatmap")
+}
+
+func TestPrivateActivityNoHeatmapVisibleForOtherUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+
+ session := loginUser(t, privateActivityTestOtherUser)
+ visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session)
+
+ assert.True(t, visible, "user should have visible heatmap")
+}
+
+func TestPrivateActivityNoHeatmapVisibleForAdmin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+
+ session := loginUser(t, privateActivityTestAdmin)
+ visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session)
+
+ assert.True(t, visible, "user should have visible heatmap")
+}
+
+// check heatmap visibility if the visibility is disabled
+
+func TestPrivateActivityYesHeatmapInvisibleForPublic(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+ testPrivateActivityHelperEnablePrivateActivity(t)
+
+ visible := testPrivateActivityHelperHasVisibleHeatmapFromPublic(t)
+
+ assert.False(t, visible, "user should have no visible heatmap")
+}
+
+func TestPrivateActivityYesHeatmapVisibleForUserItselfAtProfile(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+ testPrivateActivityHelperEnablePrivateActivity(t)
+
+ session := loginUser(t, privateActivityTestUser)
+ visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session)
+
+ assert.True(t, visible, "user should have visible heatmap")
+}
+
+func TestPrivateActivityYesHeatmapVisibleForUserItselfAtDashboard(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+ testPrivateActivityHelperEnablePrivateActivity(t)
+
+ session := loginUser(t, privateActivityTestUser)
+ visible := testPrivateActivityHelperHasVisibleDashboardHeatmapFromSession(t, session)
+
+ assert.True(t, visible, "user should have visible heatmap")
+}
+
+func TestPrivateActivityYesHeatmapInvisibleForOtherUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+ testPrivateActivityHelperEnablePrivateActivity(t)
+
+ session := loginUser(t, privateActivityTestOtherUser)
+ visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session)
+
+ assert.False(t, visible, "user should have no visible heatmap")
+}
+
+func TestPrivateActivityYesHeatmapVisibleForAdmin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+ testPrivateActivityHelperEnablePrivateActivity(t)
+
+ session := loginUser(t, privateActivityTestAdmin)
+ visible := testPrivateActivityHelperHasVisibleProfileHeatmapFromSession(t, session)
+
+ assert.True(t, visible, "user should have visible heatmap")
+}
+
+// check heatmap api provides content if the visibility is enabled
+
+func TestPrivateActivityNoHeatmapHasContentForPublic(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+
+ hasContent := testPrivateActivityHelperHasHeatmapContentFromPublic(t)
+
+ assert.True(t, hasContent, "user should have heatmap content")
+}
+
+func TestPrivateActivityNoHeatmapHasContentForUserItself(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+
+ session := loginUser(t, privateActivityTestUser)
+ hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session)
+
+ assert.True(t, hasContent, "user should have heatmap content")
+}
+
+func TestPrivateActivityNoHeatmapHasContentForOtherUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+
+ session := loginUser(t, privateActivityTestOtherUser)
+ hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session)
+
+ assert.True(t, hasContent, "user should have heatmap content")
+}
+
+func TestPrivateActivityNoHeatmapHasContentForAdmin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+
+ session := loginUser(t, privateActivityTestAdmin)
+ hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session)
+
+ assert.True(t, hasContent, "user should have heatmap content")
+}
+
+// check heatmap api provides no content if the visibility is disabled
+// this should be equal to the hidden heatmap at the UI
+
+func TestPrivateActivityYesHeatmapHasNoContentForPublic(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+ testPrivateActivityHelperEnablePrivateActivity(t)
+
+ hasContent := testPrivateActivityHelperHasHeatmapContentFromPublic(t)
+
+ assert.False(t, hasContent, "user should have no heatmap content")
+}
+
+func TestPrivateActivityYesHeatmapHasNoContentForUserItself(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+ testPrivateActivityHelperEnablePrivateActivity(t)
+
+ session := loginUser(t, privateActivityTestUser)
+ hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session)
+
+ assert.True(t, hasContent, "user should see their own heatmap content")
+}
+
+func TestPrivateActivityYesHeatmapHasNoContentForOtherUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+ testPrivateActivityHelperEnablePrivateActivity(t)
+
+ session := loginUser(t, privateActivityTestOtherUser)
+ hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session)
+
+ assert.False(t, hasContent, "other user should not see heatmap content")
+}
+
+func TestPrivateActivityYesHeatmapHasNoContentForAdmin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testPrivateActivityDoSomethingForActionEntries(t)
+ testPrivateActivityHelperEnablePrivateActivity(t)
+
+ session := loginUser(t, privateActivityTestAdmin)
+ hasContent := testPrivateActivityHelperHasHeatmapContentFromSession(t, session)
+
+ assert.True(t, hasContent, "heatmap should show content for admin")
+}
diff --git a/tests/integration/proctected_branch_test.go b/tests/integration/proctected_branch_test.go
new file mode 100644
index 0000000..9c6e5e3
--- /dev/null
+++ b/tests/integration/proctected_branch_test.go
@@ -0,0 +1,87 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "testing"
+
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestProtectedBranch_AdminEnforcement(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFileToNewBranch(t, session, "user1", "repo1", "master", "add-readme", "README.md", "WIP")
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: 1, Name: "repo1"})
+
+ req := NewRequestWithValues(t, "POST", "user1/repo1/compare/master...add-readme", map[string]string{
+ "_csrf": GetCSRF(t, session, "user1/repo1/compare/master...add-readme"),
+ "title": "pull request",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ t.Run("No protected branch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req = NewRequest(t, "GET", "/user1/repo1/pulls/1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ text := strings.TrimSpace(doc.doc.Find(".merge-section").Text())
+ assert.Contains(t, text, "This pull request can be merged automatically.")
+ assert.Contains(t, text, "'canMergeNow': true")
+ })
+
+ t.Run("Without admin enforcement", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user1/repo1/settings/branches/edit", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user1/repo1/settings/branches/edit"),
+ "rule_name": "master",
+ "required_approvals": "1",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ req = NewRequest(t, "GET", "/user1/repo1/pulls/1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ text := strings.TrimSpace(doc.doc.Find(".merge-section").Text())
+ assert.Contains(t, text, "This pull request doesn't have enough approvals yet. 0 of 1 approvals granted.")
+ assert.Contains(t, text, "'canMergeNow': true")
+ })
+
+ t.Run("With admin enforcement", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ protectedBranch := unittest.AssertExistsAndLoadBean(t, &git_model.ProtectedBranch{RuleName: "master", RepoID: repo.ID})
+ req := NewRequestWithValues(t, "POST", "/user1/repo1/settings/branches/edit", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user1/repo1/settings/branches/edit"),
+ "rule_name": "master",
+ "rule_id": strconv.FormatInt(protectedBranch.ID, 10),
+ "required_approvals": "1",
+ "apply_to_admins": "true",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ req = NewRequest(t, "GET", "/user1/repo1/pulls/1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ text := strings.TrimSpace(doc.doc.Find(".merge-section").Text())
+ assert.Contains(t, text, "This pull request doesn't have enough approvals yet. 0 of 1 approvals granted.")
+ assert.Contains(t, text, "'canMergeNow': false")
+ })
+ })
+}
diff --git a/tests/integration/project_test.go b/tests/integration/project_test.go
new file mode 100644
index 0000000..fc2986e
--- /dev/null
+++ b/tests/integration/project_test.go
@@ -0,0 +1,84 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ project_model "code.gitea.io/gitea/models/project"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPrivateRepoProject(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // not logged in user
+ req := NewRequest(t, "GET", "/user31/-/projects")
+ MakeRequest(t, req, http.StatusNotFound)
+
+ sess := loginUser(t, "user1")
+ req = NewRequest(t, "GET", "/user31/-/projects")
+ sess.MakeRequest(t, req, http.StatusOK)
+}
+
+func TestMoveRepoProjectColumns(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+
+ project1 := project_model.Project{
+ Title: "new created project",
+ RepoID: repo2.ID,
+ Type: project_model.TypeRepository,
+ TemplateType: project_model.TemplateTypeNone,
+ }
+ err := project_model.NewProject(db.DefaultContext, &project1)
+ require.NoError(t, err)
+
+ for i := 0; i < 3; i++ {
+ err = project_model.NewColumn(db.DefaultContext, &project_model.Column{
+ Title: fmt.Sprintf("column %d", i+1),
+ ProjectID: project1.ID,
+ })
+ require.NoError(t, err)
+ }
+
+ columns, err := project1.GetColumns(db.DefaultContext)
+ require.NoError(t, err)
+ assert.Len(t, columns, 3)
+ assert.EqualValues(t, 0, columns[0].Sorting)
+ assert.EqualValues(t, 1, columns[1].Sorting)
+ assert.EqualValues(t, 2, columns[2].Sorting)
+
+ sess := loginUser(t, "user1")
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/projects/%d", repo2.FullName(), project1.ID))
+ resp := sess.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/%s/projects/%d/move?_csrf="+htmlDoc.GetCSRF(), repo2.FullName(), project1.ID), map[string]any{
+ "columns": []map[string]any{
+ {"columnID": columns[1].ID, "sorting": 0},
+ {"columnID": columns[2].ID, "sorting": 1},
+ {"columnID": columns[0].ID, "sorting": 2},
+ },
+ })
+ sess.MakeRequest(t, req, http.StatusOK)
+
+ columnsAfter, err := project1.GetColumns(db.DefaultContext)
+ require.NoError(t, err)
+ assert.Len(t, columns, 3)
+ assert.EqualValues(t, columns[1].ID, columnsAfter[0].ID)
+ assert.EqualValues(t, columns[2].ID, columnsAfter[1].ID)
+ assert.EqualValues(t, columns[0].ID, columnsAfter[2].ID)
+
+ require.NoError(t, project_model.DeleteProjectByID(db.DefaultContext, project1.ID))
+}
diff --git a/tests/integration/pull_commit_test.go b/tests/integration/pull_commit_test.go
new file mode 100644
index 0000000..477f017
--- /dev/null
+++ b/tests/integration/pull_commit_test.go
@@ -0,0 +1,34 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ pull_service "code.gitea.io/gitea/services/pull"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestListPullCommits(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user5")
+ req := NewRequest(t, "GET", "/user2/repo1/pulls/3/commits/list")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ var pullCommitList struct {
+ Commits []pull_service.CommitInfo `json:"commits"`
+ LastReviewCommitSha string `json:"last_review_commit_sha"`
+ }
+ DecodeJSON(t, resp, &pullCommitList)
+
+ if assert.Len(t, pullCommitList.Commits, 2) {
+ assert.Equal(t, "5f22f7d0d95d614d25a5b68592adb345a4b5c7fd", pullCommitList.Commits[0].ID)
+ assert.Equal(t, "4a357436d925b5c974181ff12a994538ddc5a269", pullCommitList.Commits[1].ID)
+ }
+ assert.Equal(t, "4a357436d925b5c974181ff12a994538ddc5a269", pullCommitList.LastReviewCommitSha)
+ })
+}
diff --git a/tests/integration/pull_compare_test.go b/tests/integration/pull_compare_test.go
new file mode 100644
index 0000000..f5baf05
--- /dev/null
+++ b/tests/integration/pull_compare_test.go
@@ -0,0 +1,28 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPullCompare(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+ req := NewRequest(t, "GET", "/user2/repo1/pulls")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ link, exists := htmlDoc.doc.Find(".new-pr-button").Attr("href")
+ assert.True(t, exists, "The template has changed")
+
+ req = NewRequest(t, "GET", link)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.EqualValues(t, http.StatusOK, resp.Code)
+}
diff --git a/tests/integration/pull_create_test.go b/tests/integration/pull_create_test.go
new file mode 100644
index 0000000..b814642
--- /dev/null
+++ b/tests/integration/pull_create_test.go
@@ -0,0 +1,558 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path"
+ "regexp"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/test"
+ repo_service "code.gitea.io/gitea/services/repository"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func testPullCreate(t *testing.T, session *TestSession, user, repo string, toSelf bool, targetBranch, sourceBranch, title string) *httptest.ResponseRecorder {
+ req := NewRequest(t, "GET", path.Join(user, repo))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ // Click the PR button to create a pull
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ link, exists := htmlDoc.doc.Find("#new-pull-request").Attr("href")
+ assert.True(t, exists, "The template has changed")
+
+ targetUser := strings.Split(link, "/")[1]
+ if toSelf && targetUser != user {
+ link = strings.Replace(link, targetUser, user, 1)
+ }
+
+ // get main out of /user/project/main...some:other/branch
+ defaultBranch := regexp.MustCompile(`^.*/(.*)\.\.\.`).FindStringSubmatch(link)[1]
+ if targetBranch != defaultBranch {
+ link = strings.Replace(link, defaultBranch+"...", targetBranch+"...", 1)
+ }
+ if sourceBranch != defaultBranch {
+ if targetUser == user {
+ link = strings.Replace(link, "..."+defaultBranch, "..."+sourceBranch, 1)
+ } else {
+ link = strings.Replace(link, ":"+defaultBranch, ":"+sourceBranch, 1)
+ }
+ }
+
+ req = NewRequest(t, "GET", link)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ // Submit the form for creating the pull
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ link, exists = htmlDoc.doc.Find("form.ui.form").Attr("action")
+ assert.True(t, exists, "The template has changed")
+ req = NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "title": title,
+ })
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ return resp
+}
+
+func testPullCreateDirectly(t *testing.T, session *TestSession, baseRepoOwner, baseRepoName, baseBranch, headRepoOwner, headRepoName, headBranch, title string) *httptest.ResponseRecorder {
+ headCompare := headBranch
+ if headRepoOwner != "" {
+ if headRepoName != "" {
+ headCompare = fmt.Sprintf("%s/%s:%s", headRepoOwner, headRepoName, headBranch)
+ } else {
+ headCompare = fmt.Sprintf("%s:%s", headRepoOwner, headBranch)
+ }
+ }
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/%s/compare/%s...%s", baseRepoOwner, baseRepoName, baseBranch, headCompare))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ // Submit the form for creating the pull
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action")
+ assert.True(t, exists, "The template has changed")
+ req = NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "title": title,
+ })
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ return resp
+}
+
+func TestPullCreate(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n")
+ resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "This is a pull title")
+
+ // check the redirected URL
+ url := test.RedirectURL(resp)
+ assert.Regexp(t, "^/user2/repo1/pulls/[0-9]*$", url)
+
+ // check .diff can be accessed and matches performed change
+ req := NewRequest(t, "GET", url+".diff")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.Regexp(t, `\+Hello, World \(Edited\)`, resp.Body)
+ assert.Regexp(t, "^diff", resp.Body)
+ assert.NotRegexp(t, "diff.*diff", resp.Body) // not two diffs, just one
+
+ // check .patch can be accessed and matches performed change
+ req = NewRequest(t, "GET", url+".patch")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.Regexp(t, `\+Hello, World \(Edited\)`, resp.Body)
+ assert.Regexp(t, "diff", resp.Body)
+ assert.Regexp(t, `Subject: \[PATCH\] Update README.md`, resp.Body)
+ assert.NotRegexp(t, "diff.*diff", resp.Body) // not two diffs, just one
+ })
+}
+
+func TestPullCreateWithPullTemplate(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ baseUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ forkUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ templateCandidates := []string{
+ ".forgejo/PULL_REQUEST_TEMPLATE.md",
+ ".forgejo/pull_request_template.md",
+ ".gitea/PULL_REQUEST_TEMPLATE.md",
+ ".gitea/pull_request_template.md",
+ ".github/PULL_REQUEST_TEMPLATE.md",
+ ".github/pull_request_template.md",
+ }
+
+ createBaseRepo := func(t *testing.T, templateFiles []string, message string) (*repo_model.Repository, func()) {
+ t.Helper()
+
+ changeOps := make([]*files_service.ChangeRepoFile, len(templateFiles))
+ for i, template := range templateFiles {
+ changeOps[i] = &files_service.ChangeRepoFile{
+ Operation: "create",
+ TreePath: template,
+ ContentReader: strings.NewReader(message + " " + template),
+ }
+ }
+
+ repo, _, deferrer := tests.CreateDeclarativeRepo(t, baseUser, "", nil, nil, changeOps)
+
+ return repo, deferrer
+ }
+
+ testPullPreview := func(t *testing.T, session *TestSession, user, repo, message string) {
+ t.Helper()
+
+ req := NewRequest(t, "GET", path.Join(user, repo))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ // Click the PR button to create a pull
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ link, exists := htmlDoc.doc.Find("#new-pull-request").Attr("href")
+ assert.True(t, exists, "The template has changed")
+
+ // Load the pull request preview
+ req = NewRequest(t, "GET", link)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ // Check that the message from the template is present.
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ pullRequestMessage := htmlDoc.doc.Find("textarea[placeholder*='comment']").Text()
+ assert.Equal(t, message, pullRequestMessage)
+ }
+
+ for i, template := range templateCandidates {
+ t.Run(template, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Create the base repository, with the pull request template added.
+ message := fmt.Sprintf("TestPullCreateWithPullTemplate/%s", template)
+ baseRepo, deferrer := createBaseRepo(t, []string{template}, message)
+ defer deferrer()
+
+ // Fork the repository
+ session := loginUser(t, forkUser.Name)
+ testRepoFork(t, session, baseUser.Name, baseRepo.Name, forkUser.Name, baseRepo.Name)
+ forkedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: forkUser.ID, Name: baseRepo.Name})
+
+ // Apply a change to the fork
+ err := createOrReplaceFileInBranch(forkUser, forkedRepo, "README.md", forkedRepo.DefaultBranch, fmt.Sprintf("Hello, World (%d)\n", i))
+ require.NoError(t, err)
+
+ testPullPreview(t, session, forkUser.Name, forkedRepo.Name, message+" "+template)
+ })
+ }
+
+ t.Run("multiple template options", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Create the base repository, with the pull request template added.
+ message := "TestPullCreateWithPullTemplate/multiple"
+ baseRepo, deferrer := createBaseRepo(t, templateCandidates, message)
+ defer deferrer()
+
+ // Fork the repository
+ session := loginUser(t, forkUser.Name)
+ testRepoFork(t, session, baseUser.Name, baseRepo.Name, forkUser.Name, baseRepo.Name)
+ forkedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: forkUser.ID, Name: baseRepo.Name})
+
+ // Apply a change to the fork
+ err := createOrReplaceFileInBranch(forkUser, forkedRepo, "README.md", forkedRepo.DefaultBranch, "Hello, World (%d)\n")
+ require.NoError(t, err)
+
+ // Unlike issues, where all candidates are considered and shown, for
+ // pull request, there's a priority: if there are multiple
+ // templates, only the highest priority one is used.
+ testPullPreview(t, session, forkUser.Name, forkedRepo.Name, message+" .forgejo/PULL_REQUEST_TEMPLATE.md")
+ })
+ })
+}
+
+func TestPullCreate_TitleEscape(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n")
+ resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "<i>XSS PR</i>")
+
+ // check the redirected URL
+ url := test.RedirectURL(resp)
+ assert.Regexp(t, "^/user2/repo1/pulls/[0-9]*$", url)
+
+ // Edit title
+ req := NewRequest(t, "GET", url)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ editTestTitleURL, exists := htmlDoc.doc.Find(".button-row button[data-update-url]").First().Attr("data-update-url")
+ assert.True(t, exists, "The template has changed")
+
+ req = NewRequestWithValues(t, "POST", editTestTitleURL, map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "title": "<u>XSS PR</u>",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", url)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ titleHTML, err := htmlDoc.doc.Find(".comment-list .timeline-item.event .text b").First().Html()
+ require.NoError(t, err)
+ assert.Equal(t, "<strike>&lt;i&gt;XSS PR&lt;/i&gt;</strike>", titleHTML)
+ titleHTML, err = htmlDoc.doc.Find(".comment-list .timeline-item.event .text b").Next().Html()
+ require.NoError(t, err)
+ assert.Equal(t, "&lt;u&gt;XSS PR&lt;/u&gt;", titleHTML)
+ })
+}
+
+func testUIDeleteBranch(t *testing.T, session *TestSession, ownerName, repoName, branchName string) {
+ relURL := "/" + path.Join(ownerName, repoName, "branches")
+ req := NewRequest(t, "GET", relURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ req = NewRequestWithValues(t, "POST", relURL+"/delete", map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "name": branchName,
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+}
+
+func testDeleteRepository(t *testing.T, session *TestSession, ownerName, repoName string) {
+ relURL := "/" + path.Join(ownerName, repoName, "settings")
+ req := NewRequest(t, "GET", relURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ req = NewRequestWithValues(t, "POST", relURL+"?action=delete", map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "repo_name": fmt.Sprintf("%s/%s", ownerName, repoName),
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+}
+
+func TestPullBranchDelete(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testCreateBranch(t, session, "user1", "repo1", "branch/master", "master1", http.StatusSeeOther)
+ testEditFile(t, session, "user1", "repo1", "master1", "README.md", "Hello, World (Edited)\n")
+ resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master1", "This is a pull title")
+
+ // check the redirected URL
+ url := test.RedirectURL(resp)
+ assert.Regexp(t, "^/user2/repo1/pulls/[0-9]*$", url)
+ req := NewRequest(t, "GET", url)
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // delete head branch and confirm pull page is ok
+ testUIDeleteBranch(t, session, "user1", "repo1", "master1")
+ req = NewRequest(t, "GET", url)
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // delete head repository and confirm pull page is ok
+ testDeleteRepository(t, session, "user1", "repo1")
+ req = NewRequest(t, "GET", url)
+ session.MakeRequest(t, req, http.StatusOK)
+ })
+}
+
+func TestRecentlyPushed(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+
+ testCreateBranch(t, session, "user1", "repo1", "branch/master", "recent-push", http.StatusSeeOther)
+ testEditFile(t, session, "user1", "repo1", "recent-push", "README.md", "Hello recently!\n")
+
+ testCreateBranch(t, session, "user2", "repo1", "branch/master", "recent-push-base", http.StatusSeeOther)
+ testEditFile(t, session, "user2", "repo1", "recent-push-base", "README.md", "Hello, recently, from base!\n")
+
+ baseRepo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "repo1")
+ require.NoError(t, err)
+ repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user1", "repo1")
+ require.NoError(t, err)
+
+ enablePRs := func(t *testing.T, repo *repo_model.Repository) {
+ t.Helper()
+
+ err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo,
+ []repo_model.RepoUnit{{
+ RepoID: repo.ID,
+ Type: unit_model.TypePullRequests,
+ }},
+ nil)
+ require.NoError(t, err)
+ }
+
+ disablePRs := func(t *testing.T, repo *repo_model.Repository) {
+ t.Helper()
+
+ err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, nil,
+ []unit_model.Type{unit_model.TypePullRequests})
+ require.NoError(t, err)
+ }
+
+ testBanner := func(t *testing.T) {
+ t.Helper()
+
+ req := NewRequest(t, "GET", "/user1/repo1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ message := strings.TrimSpace(htmlDoc.Find(".ui.message").Text())
+ link, _ := htmlDoc.Find(".ui.message a").Attr("href")
+ expectedMessage := "You pushed on branch recent-push"
+
+ assert.Contains(t, message, expectedMessage)
+ assert.Equal(t, "/user1/repo1/src/branch/recent-push", link)
+ }
+
+ // Test that there's a recently pushed branches banner, and it contains
+ // a link to the branch.
+ t.Run("recently-pushed-banner", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ testBanner(t)
+ })
+
+ // Test that it is still there if the fork has PRs disabled, but the
+ // base repo still has them enabled.
+ t.Run("with-fork-prs-disabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer func() {
+ enablePRs(t, repo)
+ }()
+
+ disablePRs(t, repo)
+ testBanner(t)
+ })
+
+ // Test that it is still there if the fork has PRs enabled, but the base
+ // repo does not.
+ t.Run("with-base-prs-disabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer func() {
+ enablePRs(t, baseRepo)
+ }()
+
+ disablePRs(t, baseRepo)
+ testBanner(t)
+ })
+
+ // Test that the banner is not present if both the base and current
+ // repo have PRs disabled.
+ t.Run("with-prs-disabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer func() {
+ enablePRs(t, baseRepo)
+ enablePRs(t, repo)
+ }()
+
+ disablePRs(t, repo)
+ disablePRs(t, baseRepo)
+
+ req := NewRequest(t, "GET", "/user1/repo1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, ".ui.message", false)
+ })
+
+ // Test that visiting the base repo has the banner too, and includes
+ // recent push notifications from both the fork, and the base repo.
+ t.Run("on the base repo", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Count recently pushed branches on the fork
+ req := NewRequest(t, "GET", "/user1/repo1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, ".ui.message", true)
+
+ // Count recently pushed branches on the base repo
+ req = NewRequest(t, "GET", "/user2/repo1")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ messageCountOnBase := htmlDoc.Find(".ui.message").Length()
+
+ // We have two messages on the base: one from the fork, one on the
+ // base itself.
+ assert.Equal(t, 2, messageCountOnBase)
+ })
+
+ // Test that the banner's links point to the right repos
+ t.Run("link validity", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // We're testing against the origin repo, because that has both
+ // local branches, and another from a fork, so we can test both in
+ // one test!
+
+ req := NewRequest(t, "GET", "/user2/repo1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ messages := htmlDoc.Find(".ui.message")
+
+ prButtons := messages.Find("a[role='button']")
+ branchLinks := messages.Find("a[href*='/src/branch/']")
+
+ // ** base repo tests **
+ basePRLink, _ := prButtons.First().Attr("href")
+ baseBranchLink, _ := branchLinks.First().Attr("href")
+ baseBranchName := branchLinks.First().Text()
+
+ // branch in the same repo does not have a `user/repo:` qualifier.
+ assert.Equal(t, "recent-push-base", baseBranchName)
+ // branch link points to the same repo
+ assert.Equal(t, "/user2/repo1/src/branch/recent-push-base", baseBranchLink)
+ // PR link compares against the correct rep, and unqualified branch name
+ assert.Equal(t, "/user2/repo1/compare/master...recent-push-base", basePRLink)
+
+ // ** forked repo tests **
+ forkPRLink, _ := prButtons.Last().Attr("href")
+ forkBranchLink, _ := branchLinks.Last().Attr("href")
+ forkBranchName := branchLinks.Last().Text()
+
+ // branch in the forked repo has a `user/repo:` qualifier.
+ assert.Equal(t, "user1/repo1:recent-push", forkBranchName)
+ // branch link points to the forked repo
+ assert.Equal(t, "/user1/repo1/src/branch/recent-push", forkBranchLink)
+ // PR link compares against the correct rep, and qualified branch name
+ assert.Equal(t, "/user2/repo1/compare/master...user1/repo1:recent-push", forkPRLink)
+ })
+
+ t.Run("unrelated branches are not shown", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Create a new branch with no relation to the default branch.
+ // 1. Create a new Tree object
+ cmd := git.NewCommand(db.DefaultContext, "write-tree")
+ treeID, _, gitErr := cmd.RunStdString(&git.RunOpts{Dir: repo.RepoPath()})
+ require.NoError(t, gitErr)
+ treeID = strings.TrimSpace(treeID)
+ // 2. Create a new (empty) commit
+ cmd = git.NewCommand(db.DefaultContext, "commit-tree", "-m", "Initial orphan commit").AddDynamicArguments(treeID)
+ commitID, _, gitErr := cmd.RunStdString(&git.RunOpts{Dir: repo.RepoPath()})
+ require.NoError(t, gitErr)
+ commitID = strings.TrimSpace(commitID)
+ // 3. Create a new ref pointing to the orphaned commit
+ cmd = git.NewCommand(db.DefaultContext, "update-ref", "refs/heads/orphan1").AddDynamicArguments(commitID)
+ _, _, gitErr = cmd.RunStdString(&git.RunOpts{Dir: repo.RepoPath()})
+ require.NoError(t, gitErr)
+ // 4. Sync the git repo to the database
+ syncErr := repo_service.AddAllRepoBranchesToSyncQueue(graceful.GetManager().ShutdownContext())
+ require.NoError(t, syncErr)
+ // 5. Add a fresh commit, so that FindRecentlyPushedBranches has
+ // something to find.
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
+ changeResp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, owner,
+ &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "README.md",
+ ContentReader: strings.NewReader("a readme file"),
+ },
+ },
+ Message: "Add README.md",
+ OldBranch: "orphan1",
+ NewBranch: "orphan1",
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, changeResp)
+
+ // Check that we only have 1 message on the main repo, the orphaned
+ // one is not shown.
+ req := NewRequest(t, "GET", "/user1/repo1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, ".ui.message", true)
+ link, _ := htmlDoc.Find(".ui.message a[href*='/src/branch/']").Attr("href")
+ assert.Equal(t, "/user1/repo1/src/branch/recent-push", link)
+ })
+ })
+}
+
+/*
+Setup:
+The base repository is: user2/repo1
+Fork repository to: user1/repo1
+Push extra commit to: user2/repo1, which changes README.md
+Create a PR on user1/repo1
+
+Test checks:
+Check if pull request can be created from base to the fork repository.
+*/
+func TestPullCreatePrFromBaseToFork(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ sessionFork := loginUser(t, "user1")
+ testRepoFork(t, sessionFork, "user2", "repo1", "user1", "repo1")
+
+ // Edit base repository
+ sessionBase := loginUser(t, "user2")
+ testEditFile(t, sessionBase, "user2", "repo1", "master", "README.md", "Hello, World (Edited)\n")
+
+ // Create a PR
+ resp := testPullCreateDirectly(t, sessionFork, "user1", "repo1", "master", "user2", "repo1", "master", "This is a pull title")
+ // check the redirected URL
+ url := test.RedirectURL(resp)
+ assert.Regexp(t, "^/user1/repo1/pulls/[0-9]*$", url)
+ })
+}
diff --git a/tests/integration/pull_diff_test.go b/tests/integration/pull_diff_test.go
new file mode 100644
index 0000000..5411250
--- /dev/null
+++ b/tests/integration/pull_diff_test.go
@@ -0,0 +1,58 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPullDiff_CompletePRDiff(t *testing.T) {
+ doTestPRDiff(t, "/user2/commitsonpr/pulls/1/files", false, []string{"test1.txt", "test10.txt", "test2.txt", "test3.txt", "test4.txt", "test5.txt", "test6.txt", "test7.txt", "test8.txt", "test9.txt"})
+}
+
+func TestPullDiff_SingleCommitPRDiff(t *testing.T) {
+ doTestPRDiff(t, "/user2/commitsonpr/pulls/1/commits/c5626fc9eff57eb1bb7b796b01d4d0f2f3f792a2", true, []string{"test3.txt"})
+}
+
+func TestPullDiff_CommitRangePRDiff(t *testing.T) {
+ doTestPRDiff(t, "/user2/commitsonpr/pulls/1/files/4ca8bcaf27e28504df7bf996819665986b01c847..23576dd018294e476c06e569b6b0f170d0558705", true, []string{"test2.txt", "test3.txt", "test4.txt"})
+}
+
+func TestPullDiff_StartingFromBaseToCommitPRDiff(t *testing.T) {
+ doTestPRDiff(t, "/user2/commitsonpr/pulls/1/files/c5626fc9eff57eb1bb7b796b01d4d0f2f3f792a2", true, []string{"test1.txt", "test2.txt", "test3.txt"})
+}
+
+func doTestPRDiff(t *testing.T, prDiffURL string, reviewBtnDisabled bool, expectedFilenames []string) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ req := NewRequest(t, "GET", "/user2/commitsonpr/pulls")
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // Get the given PR diff url
+ req = NewRequest(t, "GET", prDiffURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ // Assert all files are visible.
+ fileContents := doc.doc.Find(".file-content")
+ numberOfFiles := fileContents.Length()
+
+ assert.Equal(t, len(expectedFilenames), numberOfFiles)
+
+ fileContents.Each(func(i int, s *goquery.Selection) {
+ filename, _ := s.Attr("data-old-filename")
+ assert.Equal(t, expectedFilenames[i], filename)
+ })
+
+ // Ensure the review button is enabled for full PR reviews
+ assert.Equal(t, reviewBtnDisabled, doc.doc.Find(".js-btn-review").HasClass("disabled"))
+}
diff --git a/tests/integration/pull_icon_test.go b/tests/integration/pull_icon_test.go
new file mode 100644
index 0000000..8fde547
--- /dev/null
+++ b/tests/integration/pull_icon_test.go
@@ -0,0 +1,257 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package integration
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ issue_service "code.gitea.io/gitea/services/issue"
+ pull_service "code.gitea.io/gitea/services/pull"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPullRequestIcons(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ repo, _, f := tests.CreateDeclarativeRepo(t, user, "pr-icons", []unit_model.Type{unit_model.TypeCode, unit_model.TypePullRequests}, nil, nil)
+ defer f()
+
+ session := loginUser(t, user.LoginName)
+
+ // Individual PRs
+ t.Run("Open", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pull := createOpenPullRequest(db.DefaultContext, t, user, repo)
+ testPullRequestIcon(t, session, pull, "green", "octicon-git-pull-request")
+ })
+
+ t.Run("WIP (Open)", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pull := createOpenWipPullRequest(db.DefaultContext, t, user, repo)
+ testPullRequestIcon(t, session, pull, "grey", "octicon-git-pull-request-draft")
+ })
+
+ t.Run("Closed", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pull := createClosedPullRequest(db.DefaultContext, t, user, repo)
+ testPullRequestIcon(t, session, pull, "red", "octicon-git-pull-request-closed")
+ })
+
+ t.Run("WIP (Closed)", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pull := createClosedWipPullRequest(db.DefaultContext, t, user, repo)
+ testPullRequestIcon(t, session, pull, "red", "octicon-git-pull-request-closed")
+ })
+
+ t.Run("Merged", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pull := createMergedPullRequest(db.DefaultContext, t, user, repo)
+ testPullRequestIcon(t, session, pull, "purple", "octicon-git-merge")
+ })
+
+ // List
+ req := NewRequest(t, "GET", repo.HTMLURL()+"/pulls?state=all")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ t.Run("List Open", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ testPullRequestListIcon(t, doc, "open", "green", "octicon-git-pull-request")
+ })
+
+ t.Run("List WIP (Open)", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ testPullRequestListIcon(t, doc, "open-wip", "grey", "octicon-git-pull-request-draft")
+ })
+
+ t.Run("List Closed", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ testPullRequestListIcon(t, doc, "closed", "red", "octicon-git-pull-request-closed")
+ })
+
+ t.Run("List Closed (WIP)", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ testPullRequestListIcon(t, doc, "closed-wip", "red", "octicon-git-pull-request-closed")
+ })
+
+ t.Run("List Merged", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ testPullRequestListIcon(t, doc, "merged", "purple", "octicon-git-merge")
+ })
+ })
+}
+
+func testPullRequestIcon(t *testing.T, session *TestSession, pr *issues_model.PullRequest, expectedColor, expectedIcon string) {
+ req := NewRequest(t, "GET", pr.Issue.HTMLURL())
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ doc.AssertElement(t, fmt.Sprintf("div.issue-state-label.%s > svg.%s", expectedColor, expectedIcon), true)
+
+ req = NewRequest(t, "GET", pr.BaseRepo.HTMLURL()+"/branches")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ doc = NewHTMLParser(t, resp.Body)
+ doc.AssertElement(t, fmt.Sprintf(`a[href="/%s/pulls/%d"].%s > svg.%s`, pr.BaseRepo.FullName(), pr.Issue.Index, expectedColor, expectedIcon), true)
+}
+
+func testPullRequestListIcon(t *testing.T, doc *HTMLDoc, name, expectedColor, expectedIcon string) {
+ sel := doc.doc.Find("div#issue-list > div.flex-item").
+ FilterFunction(func(_ int, selection *goquery.Selection) bool {
+ return selection.Find(fmt.Sprintf(`div.flex-item-icon > svg.%s.%s`, expectedColor, expectedIcon)).Length() == 1 &&
+ strings.HasSuffix(selection.Find("a.issue-title").Text(), name)
+ })
+
+ assert.Equal(t, 1, sel.Length())
+}
+
+func createOpenPullRequest(ctx context.Context, t *testing.T, user *user_model.User, repo *repo_model.Repository) *issues_model.PullRequest {
+ pull := createPullRequest(t, user, repo, "open")
+
+ assert.False(t, pull.Issue.IsClosed)
+ assert.False(t, pull.HasMerged)
+ assert.False(t, pull.IsWorkInProgress(ctx))
+
+ return pull
+}
+
+func createOpenWipPullRequest(ctx context.Context, t *testing.T, user *user_model.User, repo *repo_model.Repository) *issues_model.PullRequest {
+ pull := createPullRequest(t, user, repo, "open-wip")
+
+ err := issue_service.ChangeTitle(ctx, pull.Issue, user, "WIP: "+pull.Issue.Title)
+ require.NoError(t, err)
+
+ assert.False(t, pull.Issue.IsClosed)
+ assert.False(t, pull.HasMerged)
+ assert.True(t, pull.IsWorkInProgress(ctx))
+
+ return pull
+}
+
+func createClosedPullRequest(ctx context.Context, t *testing.T, user *user_model.User, repo *repo_model.Repository) *issues_model.PullRequest {
+ pull := createPullRequest(t, user, repo, "closed")
+
+ err := issue_service.ChangeStatus(ctx, pull.Issue, user, "", true)
+ require.NoError(t, err)
+
+ assert.True(t, pull.Issue.IsClosed)
+ assert.False(t, pull.HasMerged)
+ assert.False(t, pull.IsWorkInProgress(ctx))
+
+ return pull
+}
+
+func createClosedWipPullRequest(ctx context.Context, t *testing.T, user *user_model.User, repo *repo_model.Repository) *issues_model.PullRequest {
+ pull := createPullRequest(t, user, repo, "closed-wip")
+
+ err := issue_service.ChangeTitle(ctx, pull.Issue, user, "WIP: "+pull.Issue.Title)
+ require.NoError(t, err)
+
+ err = issue_service.ChangeStatus(ctx, pull.Issue, user, "", true)
+ require.NoError(t, err)
+
+ assert.True(t, pull.Issue.IsClosed)
+ assert.False(t, pull.HasMerged)
+ assert.True(t, pull.IsWorkInProgress(ctx))
+
+ return pull
+}
+
+func createMergedPullRequest(ctx context.Context, t *testing.T, user *user_model.User, repo *repo_model.Repository) *issues_model.PullRequest {
+ pull := createPullRequest(t, user, repo, "merged")
+
+ gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
+ defer gitRepo.Close()
+
+ require.NoError(t, err)
+
+ err = pull_service.Merge(ctx, pull, user, gitRepo, repo_model.MergeStyleMerge, pull.HeadCommitID, "merge", false)
+ require.NoError(t, err)
+
+ assert.False(t, pull.Issue.IsClosed)
+ assert.True(t, pull.CanAutoMerge())
+ assert.False(t, pull.IsWorkInProgress(ctx))
+
+ return pull
+}
+
+func createPullRequest(t *testing.T, user *user_model.User, repo *repo_model.Repository, name string) *issues_model.PullRequest {
+ branch := "branch-" + name
+ title := "Testing " + name
+
+ _, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "update",
+ TreePath: "README.md",
+ ContentReader: strings.NewReader("Update README"),
+ },
+ },
+ Message: "Update README",
+ OldBranch: "main",
+ NewBranch: branch,
+ Author: &files_service.IdentityOptions{
+ Name: user.Name,
+ Email: user.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: user.Name,
+ Email: user.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+
+ require.NoError(t, err)
+
+ pullIssue := &issues_model.Issue{
+ RepoID: repo.ID,
+ Title: title,
+ PosterID: user.ID,
+ Poster: user,
+ IsPull: true,
+ }
+
+ pullRequest := &issues_model.PullRequest{
+ HeadRepoID: repo.ID,
+ BaseRepoID: repo.ID,
+ HeadBranch: branch,
+ BaseBranch: "main",
+ HeadRepo: repo,
+ BaseRepo: repo,
+ Type: issues_model.PullRequestGitea,
+ }
+ err = pull_service.NewPullRequest(git.DefaultContext, repo, pullIssue, nil, nil, pullRequest, nil)
+ require.NoError(t, err)
+
+ return pullRequest
+}
diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go
new file mode 100644
index 0000000..0f25bde
--- /dev/null
+++ b/tests/integration/pull_merge_test.go
@@ -0,0 +1,1152 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models"
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ issues_model "code.gitea.io/gitea/models/issues"
+ pull_model "code.gitea.io/gitea/models/pull"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/hostmatcher"
+ "code.gitea.io/gitea/modules/queue"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/services/automerge"
+ "code.gitea.io/gitea/services/forms"
+ "code.gitea.io/gitea/services/pull"
+ commitstatus_service "code.gitea.io/gitea/services/repository/commitstatus"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ webhook_service "code.gitea.io/gitea/services/webhook"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type optionsPullMerge map[string]string
+
+func testPullMerge(t *testing.T, session *TestSession, user, repo, pullnum string, mergeStyle repo_model.MergeStyle, deleteBranch bool) *httptest.ResponseRecorder {
+ options := optionsPullMerge{
+ "do": string(mergeStyle),
+ }
+ if deleteBranch {
+ options["delete_branch_after_merge"] = "on"
+ }
+
+ return testPullMergeForm(t, session, http.StatusOK, user, repo, pullnum, options)
+}
+
+func testPullMergeForm(t *testing.T, session *TestSession, expectedCode int, user, repo, pullnum string, addOptions optionsPullMerge) *httptest.ResponseRecorder {
+ req := NewRequest(t, "GET", path.Join(user, repo, "pulls", pullnum))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ link := path.Join(user, repo, "pulls", pullnum, "merge")
+
+ options := map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ }
+ for k, v := range addOptions {
+ options[k] = v
+ }
+
+ req = NewRequestWithValues(t, "POST", link, options)
+ resp = session.MakeRequest(t, req, expectedCode)
+
+ if expectedCode == http.StatusOK {
+ respJSON := struct {
+ Redirect string
+ }{}
+ DecodeJSON(t, resp, &respJSON)
+
+ assert.EqualValues(t, fmt.Sprintf("/%s/%s/pulls/%s", user, repo, pullnum), respJSON.Redirect)
+ }
+
+ return resp
+}
+
+func testPullCleanUp(t *testing.T, session *TestSession, user, repo, pullnum string) *httptest.ResponseRecorder {
+ req := NewRequest(t, "GET", path.Join(user, repo, "pulls", pullnum))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ // Click the little button to create a pull
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ link, exists := htmlDoc.doc.Find(".timeline-item .delete-button").Attr("data-url")
+ assert.True(t, exists, "The template has changed, can not find delete button url")
+ req = NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ })
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ return resp
+}
+
+// returns the hook tasks, order by ID desc.
+func retrieveHookTasks(t *testing.T, hookID int64, activateWebhook bool) []*webhook.HookTask {
+ t.Helper()
+ if activateWebhook {
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ t.Cleanup(s.Close)
+ updated, err := db.GetEngine(db.DefaultContext).ID(hookID).Cols("is_active", "url").Update(webhook.Webhook{
+ IsActive: true,
+ URL: s.URL,
+ })
+
+ // allow webhook deliveries on localhost
+ t.Cleanup(test.MockVariableValue(&setting.Webhook.AllowedHostList, hostmatcher.MatchBuiltinLoopback))
+ webhook_service.Init()
+
+ assert.Equal(t, int64(1), updated)
+ require.NoError(t, err)
+ }
+
+ hookTasks, err := webhook.HookTasks(db.DefaultContext, hookID, 1)
+ require.NoError(t, err)
+ return hookTasks
+}
+
+func TestPullMerge(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ hookTasks := retrieveHookTasks(t, 1, true)
+ hookTasksLenBefore := len(hookTasks)
+
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n")
+
+ resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "This is a pull title")
+
+ elem := strings.Split(test.RedirectURL(resp), "/")
+ assert.EqualValues(t, "pulls", elem[3])
+ testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleMerge, false)
+
+ hookTasks = retrieveHookTasks(t, 1, false)
+ assert.Len(t, hookTasks, hookTasksLenBefore+1)
+ })
+}
+
+func TestPullRebase(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ hookTasks := retrieveHookTasks(t, 1, true)
+ hookTasksLenBefore := len(hookTasks)
+
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n")
+
+ resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "This is a pull title")
+
+ elem := strings.Split(test.RedirectURL(resp), "/")
+ assert.EqualValues(t, "pulls", elem[3])
+ testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleRebase, false)
+
+ hookTasks = retrieveHookTasks(t, 1, false)
+ assert.Len(t, hookTasks, hookTasksLenBefore+1)
+ })
+}
+
+func TestPullRebaseMerge(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ hookTasks := retrieveHookTasks(t, 1, true)
+ hookTasksLenBefore := len(hookTasks)
+
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n")
+
+ resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "This is a pull title")
+
+ elem := strings.Split(test.RedirectURL(resp), "/")
+ assert.EqualValues(t, "pulls", elem[3])
+ testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleRebaseMerge, false)
+
+ hookTasks = retrieveHookTasks(t, 1, false)
+ assert.Len(t, hookTasks, hookTasksLenBefore+1)
+ })
+}
+
+func TestPullSquash(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ hookTasks := retrieveHookTasks(t, 1, true)
+ hookTasksLenBefore := len(hookTasks)
+
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n")
+ testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited!)\n")
+
+ resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "This is a pull title")
+
+ elem := strings.Split(test.RedirectURL(resp), "/")
+ assert.EqualValues(t, "pulls", elem[3])
+ testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleSquash, false)
+
+ hookTasks = retrieveHookTasks(t, 1, false)
+ assert.Len(t, hookTasks, hookTasksLenBefore+1)
+ })
+}
+
+func TestPullCleanUpAfterMerge(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFileToNewBranch(t, session, "user1", "repo1", "master", "feature/test", "README.md", "Hello, World (Edited - TestPullCleanUpAfterMerge)\n")
+
+ resp := testPullCreate(t, session, "user1", "repo1", false, "master", "feature/test", "This is a pull title")
+
+ elem := strings.Split(test.RedirectURL(resp), "/")
+ assert.EqualValues(t, "pulls", elem[3])
+ testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleMerge, false)
+
+ // Check PR branch deletion
+ resp = testPullCleanUp(t, session, elem[1], elem[2], elem[4])
+ respJSON := struct {
+ Redirect string
+ }{}
+ DecodeJSON(t, resp, &respJSON)
+
+ assert.NotEmpty(t, respJSON.Redirect, "Redirected URL is not found")
+
+ elem = strings.Split(respJSON.Redirect, "/")
+ assert.EqualValues(t, "pulls", elem[3])
+
+ // Check branch deletion result
+ req := NewRequest(t, "GET", respJSON.Redirect)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ resultMsg := htmlDoc.doc.Find(".ui.message>p").Text()
+
+ assert.EqualValues(t, "Branch \"user1/repo1:feature/test\" has been deleted.", resultMsg)
+ })
+}
+
+func TestCantMergeWorkInProgress(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n")
+
+ resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "[wip] This is a pull title")
+
+ req := NewRequest(t, "GET", test.RedirectURL(resp))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ text := strings.TrimSpace(htmlDoc.doc.Find(".merge-section > .item").Last().Text())
+ assert.NotEmpty(t, text, "Can't find WIP text")
+
+ assert.Contains(t, text, translation.NewLocale("en-US").TrString("repo.pulls.cannot_merge_work_in_progress"), "Unable to find WIP text")
+ assert.Contains(t, text, "[wip]", "Unable to find WIP text")
+ })
+}
+
+func TestCantMergeConflict(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFileToNewBranch(t, session, "user1", "repo1", "master", "conflict", "README.md", "Hello, World (Edited Once)\n")
+ testEditFileToNewBranch(t, session, "user1", "repo1", "master", "base", "README.md", "Hello, World (Edited Twice)\n")
+
+ // Use API to create a conflicting pr
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", "user1", "repo1"), &api.CreatePullRequestOption{
+ Head: "conflict",
+ Base: "base",
+ Title: "create a conflicting pr",
+ }).AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusCreated)
+
+ // Now this PR will be marked conflict - or at least a race will do - so drop down to pure code at this point...
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{
+ Name: "user1",
+ })
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{
+ OwnerID: user1.ID,
+ Name: "repo1",
+ })
+
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ HeadRepoID: repo1.ID,
+ BaseRepoID: repo1.ID,
+ HeadBranch: "conflict",
+ BaseBranch: "base",
+ })
+
+ gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo1)
+ require.NoError(t, err)
+
+ err = pull.Merge(context.Background(), pr, user1, gitRepo, repo_model.MergeStyleMerge, "", "CONFLICT", false)
+ require.Error(t, err, "Merge should return an error due to conflict")
+ assert.True(t, models.IsErrMergeConflicts(err), "Merge error is not a conflict error")
+
+ err = pull.Merge(context.Background(), pr, user1, gitRepo, repo_model.MergeStyleRebase, "", "CONFLICT", false)
+ require.Error(t, err, "Merge should return an error due to conflict")
+ assert.True(t, models.IsErrRebaseConflicts(err), "Merge error is not a conflict error")
+ gitRepo.Close()
+ })
+}
+
+func TestCantMergeUnrelated(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFileToNewBranch(t, session, "user1", "repo1", "master", "base", "README.md", "Hello, World (Edited Twice)\n")
+
+ // Now we want to create a commit on a branch that is totally unrelated to our current head
+ // Drop down to pure code at this point
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{
+ Name: "user1",
+ })
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{
+ OwnerID: user1.ID,
+ Name: "repo1",
+ })
+ path := repo_model.RepoPath(user1.Name, repo1.Name)
+
+ err := git.NewCommand(git.DefaultContext, "read-tree", "--empty").Run(&git.RunOpts{Dir: path})
+ require.NoError(t, err)
+
+ stdin := bytes.NewBufferString("Unrelated File")
+ var stdout strings.Builder
+ err = git.NewCommand(git.DefaultContext, "hash-object", "-w", "--stdin").Run(&git.RunOpts{
+ Dir: path,
+ Stdin: stdin,
+ Stdout: &stdout,
+ })
+
+ require.NoError(t, err)
+ sha := strings.TrimSpace(stdout.String())
+
+ _, _, err = git.NewCommand(git.DefaultContext, "update-index", "--add", "--replace", "--cacheinfo").AddDynamicArguments("100644", sha, "somewher-over-the-rainbow").RunStdString(&git.RunOpts{Dir: path})
+ require.NoError(t, err)
+
+ treeSha, _, err := git.NewCommand(git.DefaultContext, "write-tree").RunStdString(&git.RunOpts{Dir: path})
+ require.NoError(t, err)
+ treeSha = strings.TrimSpace(treeSha)
+
+ commitTimeStr := time.Now().Format(time.RFC3339)
+ doerSig := user1.NewGitSig()
+ env := append(os.Environ(),
+ "GIT_AUTHOR_NAME="+doerSig.Name,
+ "GIT_AUTHOR_EMAIL="+doerSig.Email,
+ "GIT_AUTHOR_DATE="+commitTimeStr,
+ "GIT_COMMITTER_NAME="+doerSig.Name,
+ "GIT_COMMITTER_EMAIL="+doerSig.Email,
+ "GIT_COMMITTER_DATE="+commitTimeStr,
+ )
+
+ messageBytes := new(bytes.Buffer)
+ _, _ = messageBytes.WriteString("Unrelated")
+ _, _ = messageBytes.WriteString("\n")
+
+ stdout.Reset()
+ err = git.NewCommand(git.DefaultContext, "commit-tree").AddDynamicArguments(treeSha).
+ Run(&git.RunOpts{
+ Env: env,
+ Dir: path,
+ Stdin: messageBytes,
+ Stdout: &stdout,
+ })
+ require.NoError(t, err)
+ commitSha := strings.TrimSpace(stdout.String())
+
+ _, _, err = git.NewCommand(git.DefaultContext, "branch", "unrelated").AddDynamicArguments(commitSha).RunStdString(&git.RunOpts{Dir: path})
+ require.NoError(t, err)
+
+ testEditFileToNewBranch(t, session, "user1", "repo1", "master", "conflict", "README.md", "Hello, World (Edited Once)\n")
+
+ // Use API to create a conflicting pr
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", "user1", "repo1"), &api.CreatePullRequestOption{
+ Head: "unrelated",
+ Base: "base",
+ Title: "create an unrelated pr",
+ }).AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusCreated)
+
+ // Now this PR could be marked conflict - or at least a race may occur - so drop down to pure code at this point...
+ gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo1)
+ require.NoError(t, err)
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ HeadRepoID: repo1.ID,
+ BaseRepoID: repo1.ID,
+ HeadBranch: "unrelated",
+ BaseBranch: "base",
+ })
+
+ err = pull.Merge(context.Background(), pr, user1, gitRepo, repo_model.MergeStyleMerge, "", "UNRELATED", false)
+ require.Error(t, err, "Merge should return an error due to unrelated")
+ assert.True(t, models.IsErrMergeUnrelatedHistories(err), "Merge error is not a unrelated histories error")
+ gitRepo.Close()
+ })
+}
+
+func TestFastForwardOnlyMerge(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFileToNewBranch(t, session, "user1", "repo1", "master", "update", "README.md", "Hello, World 2\n")
+
+ // Use API to create a pr from update to master
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", "user1", "repo1"), &api.CreatePullRequestOption{
+ Head: "update",
+ Base: "master",
+ Title: "create a pr that can be fast-forward-only merged",
+ }).AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusCreated)
+
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{
+ Name: "user1",
+ })
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{
+ OwnerID: user1.ID,
+ Name: "repo1",
+ })
+
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ HeadRepoID: repo1.ID,
+ BaseRepoID: repo1.ID,
+ HeadBranch: "update",
+ BaseBranch: "master",
+ })
+
+ gitRepo, err := git.OpenRepository(git.DefaultContext, repo_model.RepoPath(user1.Name, repo1.Name))
+ require.NoError(t, err)
+
+ err = pull.Merge(context.Background(), pr, user1, gitRepo, repo_model.MergeStyleFastForwardOnly, "", "FAST-FORWARD-ONLY", false)
+
+ require.NoError(t, err)
+
+ gitRepo.Close()
+ })
+}
+
+func TestCantFastForwardOnlyMergeDiverging(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFileToNewBranch(t, session, "user1", "repo1", "master", "diverging", "README.md", "Hello, World diverged\n")
+ testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World 2\n")
+
+ // Use API to create a pr from diverging to update
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls", "user1", "repo1"), &api.CreatePullRequestOption{
+ Head: "diverging",
+ Base: "master",
+ Title: "create a pr from a diverging branch",
+ }).AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusCreated)
+
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{
+ Name: "user1",
+ })
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{
+ OwnerID: user1.ID,
+ Name: "repo1",
+ })
+
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ HeadRepoID: repo1.ID,
+ BaseRepoID: repo1.ID,
+ HeadBranch: "diverging",
+ BaseBranch: "master",
+ })
+
+ gitRepo, err := git.OpenRepository(git.DefaultContext, repo_model.RepoPath(user1.Name, repo1.Name))
+ require.NoError(t, err)
+
+ err = pull.Merge(context.Background(), pr, user1, gitRepo, repo_model.MergeStyleFastForwardOnly, "", "DIVERGING", false)
+
+ require.Error(t, err, "Merge should return an error due to being for a diverging branch")
+ assert.True(t, models.IsErrMergeDivergingFastForwardOnly(err), "Merge error is not a diverging fast-forward-only error")
+
+ gitRepo.Close()
+ })
+}
+
+func TestConflictChecking(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ // Create new clean repo to test conflict checking.
+ baseRepo, _, f := tests.CreateDeclarativeRepo(t, user, "conflict-checking", nil, nil, nil)
+ defer f()
+
+ // create a commit on new branch.
+ _, err := files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "important_file",
+ ContentReader: strings.NewReader("Just a non-important file"),
+ },
+ },
+ Message: "Add a important file",
+ OldBranch: "main",
+ NewBranch: "important-secrets",
+ })
+ require.NoError(t, err)
+
+ // create a commit on main branch.
+ _, err = files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "important_file",
+ ContentReader: strings.NewReader("Not the same content :P"),
+ },
+ },
+ Message: "Add a important file",
+ OldBranch: "main",
+ NewBranch: "main",
+ })
+ require.NoError(t, err)
+
+ // create Pull to merge the important-secrets branch into main branch.
+ pullIssue := &issues_model.Issue{
+ RepoID: baseRepo.ID,
+ Title: "PR with conflict!",
+ PosterID: user.ID,
+ Poster: user,
+ IsPull: true,
+ }
+
+ pullRequest := &issues_model.PullRequest{
+ HeadRepoID: baseRepo.ID,
+ BaseRepoID: baseRepo.ID,
+ HeadBranch: "important-secrets",
+ BaseBranch: "main",
+ HeadRepo: baseRepo,
+ BaseRepo: baseRepo,
+ Type: issues_model.PullRequestGitea,
+ }
+ err = pull.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil)
+ require.NoError(t, err)
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "PR with conflict!"})
+ require.NoError(t, issue.LoadPullRequest(db.DefaultContext))
+ conflictingPR := issue.PullRequest
+
+ // Ensure conflictedFiles is populated.
+ assert.Len(t, conflictingPR.ConflictedFiles, 1)
+ // Check if status is correct.
+ assert.Equal(t, issues_model.PullRequestStatusConflict, conflictingPR.Status)
+ // Ensure that mergeable returns false
+ assert.False(t, conflictingPR.Mergeable(db.DefaultContext))
+ })
+}
+
+func TestPullRetargetChildOnBranchDelete(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ session := loginUser(t, "user1")
+ testEditFileToNewBranch(t, session, "user2", "repo1", "master", "base-pr", "README.md", "Hello, World\n(Edited - TestPullRetargetOnCleanup - base PR)\n")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFileToNewBranch(t, session, "user1", "repo1", "base-pr", "child-pr", "README.md", "Hello, World\n(Edited - TestPullRetargetOnCleanup - base PR)\n(Edited - TestPullRetargetOnCleanup - child PR)")
+
+ respBasePR := testPullCreate(t, session, "user2", "repo1", true, "master", "base-pr", "Base Pull Request")
+ elemBasePR := strings.Split(test.RedirectURL(respBasePR), "/")
+ assert.EqualValues(t, "pulls", elemBasePR[3])
+
+ respChildPR := testPullCreate(t, session, "user1", "repo1", false, "base-pr", "child-pr", "Child Pull Request")
+ elemChildPR := strings.Split(test.RedirectURL(respChildPR), "/")
+ assert.EqualValues(t, "pulls", elemChildPR[3])
+
+ testPullMerge(t, session, elemBasePR[1], elemBasePR[2], elemBasePR[4], repo_model.MergeStyleMerge, true)
+
+ // Check child PR
+ req := NewRequest(t, "GET", test.RedirectURL(respChildPR))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ targetBranch := htmlDoc.doc.Find("#branch_target>a").Text()
+ prStatus := strings.TrimSpace(htmlDoc.doc.Find(".issue-title-meta>.issue-state-label").Text())
+
+ assert.EqualValues(t, "master", targetBranch)
+ assert.EqualValues(t, "Open", prStatus)
+ })
+}
+
+func TestPullDontRetargetChildOnWrongRepo(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFileToNewBranch(t, session, "user1", "repo1", "master", "base-pr", "README.md", "Hello, World\n(Edited - TestPullDontRetargetChildOnWrongRepo - base PR)\n")
+ testEditFileToNewBranch(t, session, "user1", "repo1", "base-pr", "child-pr", "README.md", "Hello, World\n(Edited - TestPullDontRetargetChildOnWrongRepo - base PR)\n(Edited - TestPullDontRetargetChildOnWrongRepo - child PR)")
+
+ respBasePR := testPullCreate(t, session, "user1", "repo1", false, "master", "base-pr", "Base Pull Request")
+ elemBasePR := strings.Split(test.RedirectURL(respBasePR), "/")
+ assert.EqualValues(t, "pulls", elemBasePR[3])
+
+ respChildPR := testPullCreate(t, session, "user1", "repo1", true, "base-pr", "child-pr", "Child Pull Request")
+ elemChildPR := strings.Split(test.RedirectURL(respChildPR), "/")
+ assert.EqualValues(t, "pulls", elemChildPR[3])
+
+ testPullMerge(t, session, elemBasePR[1], elemBasePR[2], elemBasePR[4], repo_model.MergeStyleMerge, true)
+
+ // Check child PR
+ req := NewRequest(t, "GET", test.RedirectURL(respChildPR))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ targetBranch := htmlDoc.doc.Find("#branch_target>a").Text()
+ prStatus := strings.TrimSpace(htmlDoc.doc.Find(".issue-title-meta>.issue-state-label").Text())
+
+ assert.EqualValues(t, "base-pr", targetBranch)
+ assert.EqualValues(t, "Closed", prStatus)
+ })
+}
+
+func TestPullMergeIndexerNotifier(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ // create a pull request
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n")
+ createPullResp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "Indexer notifier test pull")
+
+ require.NoError(t, queue.GetManager().FlushAll(context.Background(), 0))
+ time.Sleep(time.Second)
+
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{
+ OwnerName: "user2",
+ Name: "repo1",
+ })
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{
+ RepoID: repo1.ID,
+ Title: "Indexer notifier test pull",
+ IsPull: true,
+ IsClosed: false,
+ })
+
+ // build the request for searching issues
+ link, _ := url.Parse("/api/v1/repos/issues/search")
+ query := url.Values{}
+ query.Add("state", "closed")
+ query.Add("type", "pulls")
+ query.Add("q", "notifier")
+ link.RawQuery = query.Encode()
+
+ // search issues
+ searchIssuesResp := session.MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK)
+ var apiIssuesBefore []*api.Issue
+ DecodeJSON(t, searchIssuesResp, &apiIssuesBefore)
+ assert.Empty(t, apiIssuesBefore)
+
+ // merge the pull request
+ elem := strings.Split(test.RedirectURL(createPullResp), "/")
+ assert.EqualValues(t, "pulls", elem[3])
+ testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleMerge, false)
+
+ // check if the issue is closed
+ issue = unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{
+ ID: issue.ID,
+ })
+ assert.True(t, issue.IsClosed)
+
+ require.NoError(t, queue.GetManager().FlushAll(context.Background(), 0))
+ time.Sleep(time.Second)
+
+ // search issues again
+ searchIssuesResp = session.MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK)
+ var apiIssuesAfter []*api.Issue
+ DecodeJSON(t, searchIssuesResp, &apiIssuesAfter)
+ if assert.Len(t, apiIssuesAfter, 1) {
+ assert.Equal(t, issue.ID, apiIssuesAfter[0].ID)
+ }
+ })
+}
+
+func testResetRepo(t *testing.T, repoPath, branch, commitID string) {
+ f, err := os.OpenFile(filepath.Join(repoPath, "refs", "heads", branch), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
+ require.NoError(t, err)
+ _, err = f.WriteString(commitID + "\n")
+ require.NoError(t, err)
+ f.Close()
+
+ repo, err := git.OpenRepository(context.Background(), repoPath)
+ require.NoError(t, err)
+ defer repo.Close()
+ id, err := repo.GetBranchCommitID(branch)
+ require.NoError(t, err)
+ assert.EqualValues(t, commitID, id)
+}
+
+func TestPullMergeBranchProtect(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ admin := "user1"
+ owner := "user5"
+ notOwner := "user4"
+ repo := "repo4"
+
+ dstPath := t.TempDir()
+
+ u.Path = fmt.Sprintf("%s/%s.git", owner, repo)
+ u.User = url.UserPassword(owner, userPassword)
+
+ t.Run("Clone", doGitClone(dstPath, u))
+
+ for _, testCase := range []struct {
+ name string
+ doer string
+ expectedCode map[string]int
+ filename string
+ protectBranch parameterProtectBranch
+ }{
+ {
+ name: "SuccessAdminNotEnoughMergeRequiredApprovals",
+ doer: admin,
+ expectedCode: map[string]int{"api": http.StatusOK, "web": http.StatusOK},
+ filename: "branch-data-file-",
+ protectBranch: parameterProtectBranch{
+ "required_approvals": "1",
+ "apply_to_admins": "true",
+ },
+ },
+ {
+ name: "FailOwnerProtectedFile",
+ doer: owner,
+ expectedCode: map[string]int{"api": http.StatusMethodNotAllowed, "web": http.StatusBadRequest},
+ filename: "protected-file-",
+ protectBranch: parameterProtectBranch{
+ "protected_file_patterns": "protected-file-*",
+ "apply_to_admins": "true",
+ },
+ },
+ {
+ name: "OwnerProtectedFile",
+ doer: owner,
+ expectedCode: map[string]int{"api": http.StatusOK, "web": http.StatusOK},
+ filename: "protected-file-",
+ protectBranch: parameterProtectBranch{
+ "protected_file_patterns": "protected-file-*",
+ "apply_to_admins": "false",
+ },
+ },
+ {
+ name: "FailNotOwnerProtectedFile",
+ doer: notOwner,
+ expectedCode: map[string]int{"api": http.StatusMethodNotAllowed, "web": http.StatusBadRequest},
+ filename: "protected-file-",
+ protectBranch: parameterProtectBranch{
+ "protected_file_patterns": "protected-file-*",
+ },
+ },
+ {
+ name: "FailOwnerNotEnoughMergeRequiredApprovals",
+ doer: owner,
+ expectedCode: map[string]int{"api": http.StatusMethodNotAllowed, "web": http.StatusBadRequest},
+ filename: "branch-data-file-",
+ protectBranch: parameterProtectBranch{
+ "required_approvals": "1",
+ "apply_to_admins": "true",
+ },
+ },
+ {
+ name: "SuccessOwnerNotEnoughMergeRequiredApprovals",
+ doer: owner,
+ expectedCode: map[string]int{"api": http.StatusOK, "web": http.StatusOK},
+ filename: "branch-data-file-",
+ protectBranch: parameterProtectBranch{
+ "required_approvals": "1",
+ "apply_to_admins": "false",
+ },
+ },
+ {
+ name: "FailNotOwnerNotEnoughMergeRequiredApprovals",
+ doer: notOwner,
+ expectedCode: map[string]int{"api": http.StatusMethodNotAllowed, "web": http.StatusBadRequest},
+ filename: "branch-data-file-",
+ protectBranch: parameterProtectBranch{
+ "required_approvals": "1",
+ "apply_to_admins": "false",
+ },
+ },
+ {
+ name: "SuccessNotOwner",
+ doer: notOwner,
+ expectedCode: map[string]int{"api": http.StatusOK, "web": http.StatusOK},
+ filename: "branch-data-file-",
+ protectBranch: parameterProtectBranch{
+ "required_approvals": "0",
+ },
+ },
+ } {
+ mergeWith := func(t *testing.T, ctx APITestContext, apiOrWeb string, expectedCode int, pr int64) {
+ switch apiOrWeb {
+ case "api":
+ ctx.ExpectedCode = expectedCode
+ doAPIMergePullRequestForm(t, ctx, owner, repo, pr,
+ &forms.MergePullRequestForm{
+ MergeMessageField: "doAPIMergePullRequest Merge",
+ Do: string(repo_model.MergeStyleMerge),
+ ForceMerge: true,
+ })
+ ctx.ExpectedCode = 0
+ case "web":
+ testPullMergeForm(t, ctx.Session, expectedCode, owner, repo, fmt.Sprintf("%d", pr), optionsPullMerge{
+ "do": string(repo_model.MergeStyleMerge),
+ "force_merge": "true",
+ })
+ default:
+ panic(apiOrWeb)
+ }
+ }
+ for _, withAPIOrWeb := range []string{"api", "web"} {
+ t.Run(testCase.name+" "+withAPIOrWeb, func(t *testing.T) {
+ branch := testCase.name + "-" + withAPIOrWeb
+ unprotected := branch + "-unprotected"
+ doGitCheckoutBranch(dstPath, "master")(t)
+ doGitCreateBranch(dstPath, branch)(t)
+ doGitPushTestRepository(dstPath, "origin", branch)(t)
+
+ ctx := NewAPITestContext(t, owner, repo, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ doProtectBranch(ctx, branch, testCase.protectBranch)(t)
+
+ ctx = NewAPITestContext(t, testCase.doer, "not used", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ ctx.Username = owner
+ ctx.Reponame = repo
+ _, err := generateCommitWithNewData(littleSize, dstPath, "user2@example.com", "User Two", testCase.filename)
+ require.NoError(t, err)
+ doGitPushTestRepository(dstPath, "origin", branch+":"+unprotected)(t)
+ pr, err := doAPICreatePullRequest(ctx, owner, repo, branch, unprotected)(t)
+ require.NoError(t, err)
+ mergeWith(t, ctx, withAPIOrWeb, testCase.expectedCode[withAPIOrWeb], pr.Index)
+ })
+ }
+ }
+ })
+}
+
+func TestPullAutoMergeAfterCommitStatusSucceed(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ // create a pull request
+ session := loginUser(t, "user1")
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ forkedName := "repo1-1"
+ testRepoFork(t, session, "user2", "repo1", "user1", forkedName)
+ defer func() {
+ testDeleteRepository(t, session, "user1", forkedName)
+ }()
+ testEditFile(t, session, "user1", forkedName, "master", "README.md", "Hello, World (Edited)\n")
+ testPullCreate(t, session, "user1", forkedName, false, "master", "master", "Indexer notifier test pull")
+
+ baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
+ forkedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: forkedName})
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ BaseRepoID: baseRepo.ID,
+ BaseBranch: "master",
+ HeadRepoID: forkedRepo.ID,
+ HeadBranch: "master",
+ })
+
+ // add protected branch for commit status
+ csrf := GetCSRF(t, session, "/user2/repo1/settings/branches")
+ // Change master branch to protected
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{
+ "_csrf": csrf,
+ "rule_name": "master",
+ "enable_push": "true",
+ "enable_status_check": "true",
+ "status_check_contexts": "gitea/actions",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // first time insert automerge record, return true
+ scheduled, err := automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test")
+ require.NoError(t, err)
+ assert.True(t, scheduled)
+
+ // second time insert automerge record, return false because it does exist
+ scheduled, err = automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test")
+ require.Error(t, err)
+ assert.False(t, scheduled)
+
+ // reload pr again
+ pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID})
+ assert.False(t, pr.HasMerged)
+ assert.Empty(t, pr.MergedCommitID)
+
+ // update commit status to success, then it should be merged automatically
+ baseGitRepo, err := gitrepo.OpenRepository(db.DefaultContext, baseRepo)
+ require.NoError(t, err)
+ sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName())
+ require.NoError(t, err)
+ masterCommitID, err := baseGitRepo.GetBranchCommitID("master")
+ require.NoError(t, err)
+
+ branches, _, err := baseGitRepo.GetBranchNames(0, 100)
+ require.NoError(t, err)
+ assert.ElementsMatch(t, []string{"sub-home-md-img-check", "home-md-img-check", "pr-to-update", "branch2", "DefaultBranch", "develop", "feature/1", "master"}, branches)
+ baseGitRepo.Close()
+ defer func() {
+ testResetRepo(t, baseRepo.RepoPath(), "master", masterCommitID)
+ }()
+
+ err = commitstatus_service.CreateCommitStatus(db.DefaultContext, baseRepo, user1, sha, &git_model.CommitStatus{
+ State: api.CommitStatusSuccess,
+ TargetURL: "https://gitea.com",
+ Context: "gitea/actions",
+ })
+ require.NoError(t, err)
+
+ time.Sleep(2 * time.Second)
+
+ // realod pr again
+ pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID})
+ assert.True(t, pr.HasMerged)
+ assert.NotEmpty(t, pr.MergedCommitID)
+
+ unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{PullID: pr.ID})
+ })
+}
+
+func TestPullAutoMergeAfterCommitStatusSucceedAndApproval(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ // create a pull request
+ session := loginUser(t, "user1")
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ forkedName := "repo1-2"
+ testRepoFork(t, session, "user2", "repo1", "user1", forkedName)
+ defer func() {
+ testDeleteRepository(t, session, "user1", forkedName)
+ }()
+ testEditFile(t, session, "user1", forkedName, "master", "README.md", "Hello, World (Edited)\n")
+ testPullCreate(t, session, "user1", forkedName, false, "master", "master", "Indexer notifier test pull")
+
+ baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
+ forkedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: forkedName})
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ BaseRepoID: baseRepo.ID,
+ BaseBranch: "master",
+ HeadRepoID: forkedRepo.ID,
+ HeadBranch: "master",
+ })
+
+ // add protected branch for commit status
+ csrf := GetCSRF(t, session, "/user2/repo1/settings/branches")
+ // Change master branch to protected
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{
+ "_csrf": csrf,
+ "rule_name": "master",
+ "enable_push": "true",
+ "enable_status_check": "true",
+ "status_check_contexts": "gitea/actions",
+ "required_approvals": "1",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // first time insert automerge record, return true
+ scheduled, err := automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test")
+ require.NoError(t, err)
+ assert.True(t, scheduled)
+
+ // second time insert automerge record, return false because it does exist
+ scheduled, err = automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test")
+ require.Error(t, err)
+ assert.False(t, scheduled)
+
+ // reload pr again
+ pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID})
+ assert.False(t, pr.HasMerged)
+ assert.Empty(t, pr.MergedCommitID)
+
+ // update commit status to success, then it should be merged automatically
+ baseGitRepo, err := gitrepo.OpenRepository(db.DefaultContext, baseRepo)
+ require.NoError(t, err)
+ sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName())
+ require.NoError(t, err)
+ masterCommitID, err := baseGitRepo.GetBranchCommitID("master")
+ require.NoError(t, err)
+ baseGitRepo.Close()
+ defer func() {
+ testResetRepo(t, baseRepo.RepoPath(), "master", masterCommitID)
+ }()
+
+ err = commitstatus_service.CreateCommitStatus(db.DefaultContext, baseRepo, user1, sha, &git_model.CommitStatus{
+ State: api.CommitStatusSuccess,
+ TargetURL: "https://gitea.com",
+ Context: "gitea/actions",
+ })
+ require.NoError(t, err)
+
+ time.Sleep(2 * time.Second)
+
+ // reload pr again
+ pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID})
+ assert.False(t, pr.HasMerged)
+ assert.Empty(t, pr.MergedCommitID)
+
+ // approve the PR from non-author
+ approveSession := loginUser(t, "user2")
+ req = NewRequest(t, "GET", fmt.Sprintf("/user2/repo1/pulls/%d", pr.Index))
+ resp := approveSession.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ testSubmitReview(t, approveSession, htmlDoc.GetCSRF(), "user2", "repo1", strconv.Itoa(int(pr.Index)), sha, "approve", http.StatusOK)
+
+ time.Sleep(2 * time.Second)
+
+ // realod pr again
+ pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID})
+ assert.True(t, pr.HasMerged)
+ assert.NotEmpty(t, pr.MergedCommitID)
+
+ unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{PullID: pr.ID})
+ })
+}
+
+func TestPullAutoMergeAfterCommitStatusSucceedAndApprovalForAgitFlow(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ // create a pull request
+ baseAPITestContext := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+ dstPath := t.TempDir()
+
+ u.Path = baseAPITestContext.GitPath()
+ u.User = url.UserPassword("user2", userPassword)
+
+ t.Run("Clone", doGitClone(dstPath, u))
+
+ err := os.WriteFile(path.Join(dstPath, "test_file"), []byte("## test content"), 0o666)
+ require.NoError(t, err)
+
+ err = git.AddChanges(dstPath, true)
+ require.NoError(t, err)
+
+ err = git.CommitChanges(dstPath, git.CommitChangesOptions{
+ Committer: &git.Signature{
+ Email: "user2@example.com",
+ Name: "user2",
+ When: time.Now(),
+ },
+ Author: &git.Signature{
+ Email: "user2@example.com",
+ Name: "user2",
+ When: time.Now(),
+ },
+ Message: "Testing commit 1",
+ })
+ require.NoError(t, err)
+
+ stderrBuf := &bytes.Buffer{}
+
+ err = git.NewCommand(git.DefaultContext, "push", "origin", "HEAD:refs/for/master", "-o").
+ AddDynamicArguments(`topic=test/head2`).
+ AddArguments("-o").
+ AddDynamicArguments(`title="create a test pull request with agit"`).
+ AddArguments("-o").
+ AddDynamicArguments(`description="This PR is a test pull request which created with agit"`).
+ Run(&git.RunOpts{Dir: dstPath, Stderr: stderrBuf})
+ require.NoError(t, err)
+
+ assert.Contains(t, stderrBuf.String(), setting.AppURL+"user2/repo1/pulls/6")
+
+ baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ Flow: issues_model.PullRequestFlowAGit,
+ BaseRepoID: baseRepo.ID,
+ BaseBranch: "master",
+ HeadRepoID: baseRepo.ID,
+ HeadBranch: "user2/test/head2",
+ })
+
+ session := loginUser(t, "user1")
+ // add protected branch for commit status
+ csrf := GetCSRF(t, session, "/user2/repo1/settings/branches")
+ // Change master branch to protected
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{
+ "_csrf": csrf,
+ "rule_name": "master",
+ "enable_push": "true",
+ "enable_status_check": "true",
+ "status_check_contexts": "gitea/actions",
+ "required_approvals": "1",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ // first time insert automerge record, return true
+ scheduled, err := automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test")
+ require.NoError(t, err)
+ assert.True(t, scheduled)
+
+ // second time insert automerge record, return false because it does exist
+ scheduled, err = automerge.ScheduleAutoMerge(db.DefaultContext, user1, pr, repo_model.MergeStyleMerge, "auto merge test")
+ require.Error(t, err)
+ assert.False(t, scheduled)
+
+ // reload pr again
+ pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID})
+ assert.False(t, pr.HasMerged)
+ assert.Empty(t, pr.MergedCommitID)
+
+ // update commit status to success, then it should be merged automatically
+ baseGitRepo, err := gitrepo.OpenRepository(db.DefaultContext, baseRepo)
+ require.NoError(t, err)
+ sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName())
+ require.NoError(t, err)
+ masterCommitID, err := baseGitRepo.GetBranchCommitID("master")
+ require.NoError(t, err)
+ baseGitRepo.Close()
+ defer func() {
+ testResetRepo(t, baseRepo.RepoPath(), "master", masterCommitID)
+ }()
+
+ err = commitstatus_service.CreateCommitStatus(db.DefaultContext, baseRepo, user1, sha, &git_model.CommitStatus{
+ State: api.CommitStatusSuccess,
+ TargetURL: "https://gitea.com",
+ Context: "gitea/actions",
+ })
+ require.NoError(t, err)
+
+ time.Sleep(2 * time.Second)
+
+ // reload pr again
+ pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID})
+ assert.False(t, pr.HasMerged)
+ assert.Empty(t, pr.MergedCommitID)
+
+ // approve the PR from non-author
+ approveSession := loginUser(t, "user1")
+ req = NewRequest(t, "GET", fmt.Sprintf("/user2/repo1/pulls/%d", pr.Index))
+ resp := approveSession.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ testSubmitReview(t, approveSession, htmlDoc.GetCSRF(), "user2", "repo1", strconv.Itoa(int(pr.Index)), sha, "approve", http.StatusOK)
+
+ time.Sleep(2 * time.Second)
+
+ // realod pr again
+ pr = unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID})
+ assert.True(t, pr.HasMerged)
+ assert.NotEmpty(t, pr.MergedCommitID)
+
+ unittest.AssertNotExistsBean(t, &pull_model.AutoMerge{PullID: pr.ID})
+ })
+}
diff --git a/tests/integration/pull_reopen_test.go b/tests/integration/pull_reopen_test.go
new file mode 100644
index 0000000..e510d59
--- /dev/null
+++ b/tests/integration/pull_reopen_test.go
@@ -0,0 +1,216 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ issues_model "code.gitea.io/gitea/models/issues"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/translation"
+ gitea_context "code.gitea.io/gitea/services/context"
+ issue_service "code.gitea.io/gitea/services/issue"
+ pull_service "code.gitea.io/gitea/services/pull"
+ repo_service "code.gitea.io/gitea/services/repository"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPullrequestReopen(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ org26 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26})
+
+ // Create an base repository.
+ baseRepo, _, f := tests.CreateDeclarativeRepo(t, user2, "reopen-base",
+ []unit_model.Type{unit_model.TypePullRequests}, nil, nil,
+ )
+ defer f()
+
+ // Create a new branch on the base branch, so it can be deleted later.
+ _, err := files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, user2, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "update",
+ TreePath: "README.md",
+ ContentReader: strings.NewReader("New README.md"),
+ },
+ },
+ Message: "Modify README for base",
+ OldBranch: "main",
+ NewBranch: "base-branch",
+ Author: &files_service.IdentityOptions{
+ Name: user2.Name,
+ Email: user2.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: user2.Name,
+ Email: user2.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+
+ // Create an head repository.
+ headRepo, err := repo_service.ForkRepositoryAndUpdates(git.DefaultContext, user2, org26, repo_service.ForkRepoOptions{
+ BaseRepo: baseRepo,
+ Name: "reopen-head",
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, headRepo)
+
+ // Add a change to the head repository, so a pull request can be opened.
+ _, err = files_service.ChangeRepoFiles(git.DefaultContext, headRepo, user2, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "update",
+ TreePath: "README.md",
+ ContentReader: strings.NewReader("Updated README.md"),
+ },
+ },
+ Message: "Modify README for head",
+ OldBranch: "main",
+ NewBranch: "head-branch",
+ Author: &files_service.IdentityOptions{
+ Name: user2.Name,
+ Email: user2.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: user2.Name,
+ Email: user2.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+
+ // Create the pull request.
+ pullIssue := &issues_model.Issue{
+ RepoID: baseRepo.ID,
+ Title: "Testing reopen functionality",
+ PosterID: user2.ID,
+ Poster: user2,
+ IsPull: true,
+ }
+ pullRequest := &issues_model.PullRequest{
+ HeadRepoID: headRepo.ID,
+ BaseRepoID: baseRepo.ID,
+ HeadBranch: "head-branch",
+ BaseBranch: "base-branch",
+ HeadRepo: headRepo,
+ BaseRepo: baseRepo,
+ Type: issues_model.PullRequestGitea,
+ }
+ err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil)
+ require.NoError(t, err)
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "Testing reopen functionality"})
+
+ // Close the PR.
+ err = issue_service.ChangeStatus(db.DefaultContext, issue, user2, "", true)
+ require.NoError(t, err)
+
+ session := loginUser(t, "user2")
+
+ reopenPR := func(t *testing.T, expectedStatus int) *httptest.ResponseRecorder {
+ t.Helper()
+
+ link := fmt.Sprintf("%s/pulls/%d/comments", baseRepo.FullName(), issue.Index)
+ req := NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": GetCSRF(t, session, fmt.Sprintf("%s/pulls/%d", baseRepo.FullName(), issue.Index)),
+ "status": "reopen",
+ })
+ return session.MakeRequest(t, req, expectedStatus)
+ }
+
+ restoreBranch := func(t *testing.T, repoName, branchName string, branchID int64) {
+ t.Helper()
+
+ link := fmt.Sprintf("/%s/branches", repoName)
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/restore?branch_id=%d&name=%s", link, branchID, branchName), map[string]string{
+ "_csrf": GetCSRF(t, session, link),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.Contains(t, flashCookie.Value, "success%3DBranch%2B%2522"+branchName+"%2522%2Bhas%2Bbeen%2Brestored.")
+ }
+
+ deleteBranch := func(t *testing.T, repoName, branchName string) {
+ t.Helper()
+
+ link := fmt.Sprintf("/%s/branches", repoName)
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/delete?name=%s", link, branchName), map[string]string{
+ "_csrf": GetCSRF(t, session, link),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.Contains(t, flashCookie.Value, "success%3DBranch%2B%2522"+branchName+"%2522%2Bhas%2Bbeen%2Bdeleted.")
+ }
+
+ type errorJSON struct {
+ Error string `json:"errorMessage"`
+ }
+
+ t.Run("Base branch deleted", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{Name: "base-branch", RepoID: baseRepo.ID})
+ defer func() {
+ restoreBranch(t, baseRepo.FullName(), branch.Name, branch.ID)
+ }()
+
+ deleteBranch(t, baseRepo.FullName(), branch.Name)
+ resp := reopenPR(t, http.StatusBadRequest)
+
+ var errorResp errorJSON
+ DecodeJSON(t, resp, &errorResp)
+ assert.EqualValues(t, translation.NewLocale("en-US").Tr("repo.pulls.reopen_failed.base_branch"), errorResp.Error)
+ })
+
+ t.Run("Head branch deleted", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ branch := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{Name: "head-branch", RepoID: headRepo.ID})
+ defer func() {
+ restoreBranch(t, headRepo.FullName(), branch.Name, branch.ID)
+ }()
+
+ deleteBranch(t, headRepo.FullName(), branch.Name)
+ resp := reopenPR(t, http.StatusBadRequest)
+
+ var errorResp errorJSON
+ DecodeJSON(t, resp, &errorResp)
+ assert.EqualValues(t, translation.NewLocale("en-US").Tr("repo.pulls.reopen_failed.head_branch"), errorResp.Error)
+ })
+
+ t.Run("Normal", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ reopenPR(t, http.StatusOK)
+ })
+ })
+}
diff --git a/tests/integration/pull_request_task_test.go b/tests/integration/pull_request_task_test.go
new file mode 100644
index 0000000..4366d97
--- /dev/null
+++ b/tests/integration/pull_request_task_test.go
@@ -0,0 +1,109 @@
+// Copyright 2024 The Forgejo Authors
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/log"
+ repo_module "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/modules/timeutil"
+ pull_service "code.gitea.io/gitea/services/pull"
+ repo_service "code.gitea.io/gitea/services/repository"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPullRequestSynchronized(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // unmerged pull request of user2/repo1 from branch2 to master
+ pull := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: 2})
+ // tip of tests/gitea-repositories-meta/user2/repo1 branch2
+ pull.HeadCommitID = "985f0301dba5e7b34be866819cd15ad3d8f508ee"
+ pull.LoadIssue(db.DefaultContext)
+ pull.Issue.Created = timeutil.TimeStampNanoNow()
+ issues_model.UpdateIssueCols(db.DefaultContext, pull.Issue, "created")
+
+ require.Equal(t, pull.HeadRepoID, pull.BaseRepoID)
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: pull.HeadRepoID})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ for _, testCase := range []struct {
+ name string
+ timeNano int64
+ expected bool
+ }{
+ {
+ name: "AddTestPullRequestTask process PR",
+ timeNano: int64(pull.Issue.Created),
+ expected: true,
+ },
+ {
+ name: "AddTestPullRequestTask skip PR",
+ timeNano: 0,
+ expected: false,
+ },
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ logChecker, cleanup := test.NewLogChecker(log.DEFAULT, log.TRACE)
+ logChecker.Filter("Updating PR").StopMark("TestPullRequest ")
+ defer cleanup()
+
+ opt := &repo_module.PushUpdateOptions{
+ PusherID: owner.ID,
+ PusherName: owner.Name,
+ RepoUserName: owner.Name,
+ RepoName: repo.Name,
+ RefFullName: git.RefName("refs/heads/branch2"),
+ OldCommitID: pull.HeadCommitID,
+ NewCommitID: pull.HeadCommitID,
+ TimeNano: testCase.timeNano,
+ }
+ require.NoError(t, repo_service.PushUpdate(opt))
+ logFiltered, logStopped := logChecker.Check(5 * time.Second)
+ assert.True(t, logStopped)
+ assert.Equal(t, testCase.expected, logFiltered[0])
+ })
+ }
+
+ for _, testCase := range []struct {
+ name string
+ olderThan int64
+ expected bool
+ }{
+ {
+ name: "TestPullRequest process PR",
+ olderThan: int64(pull.Issue.Created),
+ expected: true,
+ },
+ {
+ name: "TestPullRequest skip PR",
+ olderThan: int64(pull.Issue.Created) - 1,
+ expected: false,
+ },
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ logChecker, cleanup := test.NewLogChecker(log.DEFAULT, log.TRACE)
+ logChecker.Filter("Updating PR").StopMark("TestPullRequest ")
+ defer cleanup()
+
+ pull_service.TestPullRequest(context.Background(), owner, repo.ID, testCase.olderThan, "branch2", true, pull.HeadCommitID, pull.HeadCommitID)
+ logFiltered, logStopped := logChecker.Check(5 * time.Second)
+ assert.True(t, logStopped)
+ assert.Equal(t, testCase.expected, logFiltered[0])
+ })
+ }
+}
diff --git a/tests/integration/pull_review_test.go b/tests/integration/pull_review_test.go
new file mode 100644
index 0000000..d15a7bd
--- /dev/null
+++ b/tests/integration/pull_review_test.go
@@ -0,0 +1,511 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path"
+ "strconv"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/test"
+ issue_service "code.gitea.io/gitea/services/issue"
+ repo_service "code.gitea.io/gitea/services/repository"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPullView_ReviewerMissed(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user1")
+
+ req := NewRequest(t, "GET", "/pulls")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
+
+ req = NewRequest(t, "GET", "/user2/repo1/pulls/3")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
+
+ // if some reviews are missing, the page shouldn't fail
+ reviews, err := issues_model.FindReviews(db.DefaultContext, issues_model.FindReviewOptions{
+ IssueID: 2,
+ })
+ require.NoError(t, err)
+ for _, r := range reviews {
+ require.NoError(t, issues_model.DeleteReview(db.DefaultContext, r))
+ }
+ req = NewRequest(t, "GET", "/user2/repo1/pulls/2")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.True(t, test.IsNormalPageCompleted(resp.Body.String()))
+}
+
+func loadComment(t *testing.T, commentID string) *issues_model.Comment {
+ t.Helper()
+ id, err := strconv.ParseInt(commentID, 10, 64)
+ require.NoError(t, err)
+ return unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: id})
+}
+
+func TestPullView_ResolveInvalidatedReviewComment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user1")
+
+ req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files")
+ session.MakeRequest(t, req, http.StatusOK)
+
+ t.Run("single outdated review (line 1)", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files/reviews/new_comment")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/comments", map[string]string{
+ "_csrf": doc.GetInputValueByName("_csrf"),
+ "origin": doc.GetInputValueByName("origin"),
+ "latest_commit_id": doc.GetInputValueByName("latest_commit_id"),
+ "side": "proposed",
+ "line": "1",
+ "path": "iso-8859-1.txt",
+ "diff_start_cid": doc.GetInputValueByName("diff_start_cid"),
+ "diff_end_cid": doc.GetInputValueByName("diff_end_cid"),
+ "diff_base_cid": doc.GetInputValueByName("diff_base_cid"),
+ "content": "nitpicking comment",
+ "pending_review": "",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/submit", map[string]string{
+ "_csrf": doc.GetInputValueByName("_csrf"),
+ "commit_id": doc.GetInputValueByName("latest_commit_id"),
+ "content": "looks good",
+ "type": "comment",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // retrieve comment_id by reloading the comment page
+ req = NewRequest(t, "GET", "/user2/repo1/pulls/3")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ doc = NewHTMLParser(t, resp.Body)
+ commentID, ok := doc.Find(`[data-action="Resolve"]`).Attr("data-comment-id")
+ assert.True(t, ok)
+
+ // adjust the database to mark the comment as invalidated
+ // (to invalidate it properly, one should push a commit which should trigger this logic,
+ // in the meantime, use this quick-and-dirty trick)
+ comment := loadComment(t, commentID)
+ require.NoError(t, issues_model.UpdateCommentInvalidate(context.Background(), &issues_model.Comment{
+ ID: comment.ID,
+ Invalidated: true,
+ }))
+
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/resolve_conversation", map[string]string{
+ "_csrf": doc.GetInputValueByName("_csrf"),
+ "origin": "timeline",
+ "action": "Resolve",
+ "comment_id": commentID,
+ })
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ // even on template error, the page returns HTTP 200
+ // count the comments to ensure success.
+ doc = NewHTMLParser(t, resp.Body)
+ assert.Len(t, doc.Find(`.comments > .comment`).Nodes, 1)
+ })
+
+ t.Run("outdated and newer review (line 2)", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files/reviews/new_comment")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ var firstReviewID int64
+ {
+ // first (outdated) review
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/comments", map[string]string{
+ "_csrf": doc.GetInputValueByName("_csrf"),
+ "origin": doc.GetInputValueByName("origin"),
+ "latest_commit_id": doc.GetInputValueByName("latest_commit_id"),
+ "side": "proposed",
+ "line": "2",
+ "path": "iso-8859-1.txt",
+ "diff_start_cid": doc.GetInputValueByName("diff_start_cid"),
+ "diff_end_cid": doc.GetInputValueByName("diff_end_cid"),
+ "diff_base_cid": doc.GetInputValueByName("diff_base_cid"),
+ "content": "nitpicking comment",
+ "pending_review": "",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/submit", map[string]string{
+ "_csrf": doc.GetInputValueByName("_csrf"),
+ "commit_id": doc.GetInputValueByName("latest_commit_id"),
+ "content": "looks good",
+ "type": "comment",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // retrieve comment_id by reloading the comment page
+ req = NewRequest(t, "GET", "/user2/repo1/pulls/3")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ doc = NewHTMLParser(t, resp.Body)
+ commentID, ok := doc.Find(`[data-action="Resolve"]`).Attr("data-comment-id")
+ assert.True(t, ok)
+
+ // adjust the database to mark the comment as invalidated
+ // (to invalidate it properly, one should push a commit which should trigger this logic,
+ // in the meantime, use this quick-and-dirty trick)
+ comment := loadComment(t, commentID)
+ require.NoError(t, issues_model.UpdateCommentInvalidate(context.Background(), &issues_model.Comment{
+ ID: comment.ID,
+ Invalidated: true,
+ }))
+ firstReviewID = comment.ReviewID
+ assert.NotZero(t, firstReviewID)
+ }
+
+ // ID of the first comment for the second (up-to-date) review
+ var commentID string
+
+ {
+ // second (up-to-date) review on the same line
+ // make a second review
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/comments", map[string]string{
+ "_csrf": doc.GetInputValueByName("_csrf"),
+ "origin": doc.GetInputValueByName("origin"),
+ "latest_commit_id": doc.GetInputValueByName("latest_commit_id"),
+ "side": "proposed",
+ "line": "2",
+ "path": "iso-8859-1.txt",
+ "diff_start_cid": doc.GetInputValueByName("diff_start_cid"),
+ "diff_end_cid": doc.GetInputValueByName("diff_end_cid"),
+ "diff_base_cid": doc.GetInputValueByName("diff_base_cid"),
+ "content": "nitpicking comment",
+ "pending_review": "",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/pulls/3/files/reviews/submit", map[string]string{
+ "_csrf": doc.GetInputValueByName("_csrf"),
+ "commit_id": doc.GetInputValueByName("latest_commit_id"),
+ "content": "looks better",
+ "type": "comment",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // retrieve comment_id by reloading the comment page
+ req = NewRequest(t, "GET", "/user2/repo1/pulls/3")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ doc = NewHTMLParser(t, resp.Body)
+
+ commentIDs := doc.Find(`[data-action="Resolve"]`).Map(func(i int, elt *goquery.Selection) string {
+ v, _ := elt.Attr("data-comment-id")
+ return v
+ })
+ assert.Len(t, commentIDs, 2) // 1 for the outdated review, 1 for the current review
+
+ // check that the first comment is for the previous review
+ comment := loadComment(t, commentIDs[0])
+ assert.Equal(t, comment.ReviewID, firstReviewID)
+
+ // check that the second comment is for a different review
+ comment = loadComment(t, commentIDs[1])
+ assert.NotZero(t, comment.ReviewID)
+ assert.NotEqual(t, comment.ReviewID, firstReviewID)
+
+ commentID = commentIDs[1] // save commentID for later
+ }
+
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/issues/resolve_conversation", map[string]string{
+ "_csrf": doc.GetInputValueByName("_csrf"),
+ "origin": "timeline",
+ "action": "Resolve",
+ "comment_id": commentID,
+ })
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ // even on template error, the page returns HTTP 200
+ // count the comments to ensure success.
+ doc = NewHTMLParser(t, resp.Body)
+ comments := doc.Find(`.comments > .comment`)
+ assert.Len(t, comments.Nodes, 1) // the outdated comment belongs to another review and should not be shown
+ })
+
+ t.Run("Files Changed tab", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ for _, c := range []struct {
+ style, outdated string
+ expectedCount int
+ }{
+ {"unified", "true", 3}, // 1 comment on line 1 + 2 comments on line 3
+ {"unified", "false", 1}, // 1 comment on line 3 is not outdated
+ {"split", "true", 3}, // 1 comment on line 1 + 2 comments on line 3
+ {"split", "false", 1}, // 1 comment on line 3 is not outdated
+ } {
+ t.Run(c.style+"+"+c.outdated, func(t *testing.T) {
+ req := NewRequest(t, "GET", "/user2/repo1/pulls/3/files?style="+c.style+"&show-outdated="+c.outdated)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ comments := doc.Find(`.comments > .comment`)
+ assert.Len(t, comments.Nodes, c.expectedCount)
+ })
+ }
+ })
+
+ t.Run("Conversation tab", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ req := NewRequest(t, "GET", "/user2/repo1/pulls/3")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ comments := doc.Find(`.comments > .comment`)
+ assert.Len(t, comments.Nodes, 3) // 1 comment on line 1 + 2 comments on line 3
+ })
+}
+
+func TestPullView_CodeOwner(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ // Create the repo.
+ repo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, user2, user2, repo_service.CreateRepoOptions{
+ Name: "test_codeowner",
+ Readme: "Default",
+ AutoInit: true,
+ ObjectFormatName: git.Sha1ObjectFormat.Name(),
+ DefaultBranch: "master",
+ })
+ require.NoError(t, err)
+
+ // add CODEOWNERS to default branch
+ _, err = files_service.ChangeRepoFiles(db.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+ OldBranch: repo.DefaultBranch,
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "CODEOWNERS",
+ ContentReader: strings.NewReader("README.md @user5\n"),
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ t.Run("First Pull Request", func(t *testing.T) {
+ // create a new branch to prepare for pull request
+ _, err = files_service.ChangeRepoFiles(db.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+ NewBranch: "codeowner-basebranch",
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "update",
+ TreePath: "README.md",
+ ContentReader: strings.NewReader("# This is a new project\n"),
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ // Create a pull request.
+ session := loginUser(t, "user2")
+ testPullCreate(t, session, "user2", "test_codeowner", false, repo.DefaultBranch, "codeowner-basebranch", "Test Pull Request")
+
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadRepoID: repo.ID, HeadBranch: "codeowner-basebranch"})
+ unittest.AssertExistsIf(t, true, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 5})
+ require.NoError(t, pr.LoadIssue(db.DefaultContext))
+
+ err := issue_service.ChangeTitle(db.DefaultContext, pr.Issue, user2, "[WIP] Test Pull Request")
+ require.NoError(t, err)
+ prUpdated1 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID})
+ require.NoError(t, prUpdated1.LoadIssue(db.DefaultContext))
+ assert.EqualValues(t, "[WIP] Test Pull Request", prUpdated1.Issue.Title)
+
+ err = issue_service.ChangeTitle(db.DefaultContext, prUpdated1.Issue, user2, "Test Pull Request2")
+ require.NoError(t, err)
+ prUpdated2 := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{ID: pr.ID})
+ require.NoError(t, prUpdated2.LoadIssue(db.DefaultContext))
+ assert.EqualValues(t, "Test Pull Request2", prUpdated2.Issue.Title)
+ })
+
+ // change the default branch CODEOWNERS file to change README.md's codeowner
+ _, err = files_service.ChangeRepoFiles(db.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "update",
+ TreePath: "CODEOWNERS",
+ ContentReader: strings.NewReader("README.md @user8\n"),
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ t.Run("Second Pull Request", func(t *testing.T) {
+ // create a new branch to prepare for pull request
+ _, err = files_service.ChangeRepoFiles(db.DefaultContext, repo, user2, &files_service.ChangeRepoFilesOptions{
+ NewBranch: "codeowner-basebranch2",
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "update",
+ TreePath: "README.md",
+ ContentReader: strings.NewReader("# This is a new project2\n"),
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ // Create a pull request.
+ session := loginUser(t, "user2")
+ testPullCreate(t, session, "user2", "test_codeowner", false, repo.DefaultBranch, "codeowner-basebranch2", "Test Pull Request2")
+
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadBranch: "codeowner-basebranch2"})
+ unittest.AssertExistsIf(t, true, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 8})
+ })
+
+ t.Run("Forked Repo Pull Request", func(t *testing.T) {
+ user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5})
+ forkedRepo, err := repo_service.ForkRepositoryAndUpdates(db.DefaultContext, user2, user5, repo_service.ForkRepoOptions{
+ BaseRepo: repo,
+ Name: "test_codeowner_fork",
+ })
+ require.NoError(t, err)
+
+ // create a new branch to prepare for pull request
+ _, err = files_service.ChangeRepoFiles(db.DefaultContext, forkedRepo, user5, &files_service.ChangeRepoFilesOptions{
+ NewBranch: "codeowner-basebranch-forked",
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "update",
+ TreePath: "README.md",
+ ContentReader: strings.NewReader("# This is a new forked project\n"),
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ session := loginUser(t, "user5")
+ testPullCreate(t, session, "user5", "test_codeowner_fork", false, forkedRepo.DefaultBranch, "codeowner-basebranch-forked", "Test Pull Request2")
+
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{BaseRepoID: repo.ID, HeadBranch: "codeowner-basebranch-forked"})
+ unittest.AssertExistsIf(t, false, &issues_model.Review{IssueID: pr.IssueID, Type: issues_model.ReviewTypeRequest, ReviewerID: 8})
+ })
+ })
+}
+
+func TestPullView_GivenApproveOrRejectReviewOnClosedPR(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ user1Session := loginUser(t, "user1")
+ user2Session := loginUser(t, "user2")
+
+ // Have user1 create a fork of repo1.
+ testRepoFork(t, user1Session, "user2", "repo1", "user1", "repo1")
+
+ baseRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user2", Name: "repo1"})
+ forkedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: "repo1"})
+ baseGitRepo, err := gitrepo.OpenRepository(db.DefaultContext, baseRepo)
+ require.NoError(t, err)
+ defer baseGitRepo.Close()
+
+ t.Run("Submit approve/reject review on merged PR", func(t *testing.T) {
+ // Create a merged PR (made by user1) in the upstream repo1.
+ testEditFile(t, user1Session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n")
+ resp := testPullCreate(t, user1Session, "user1", "repo1", false, "master", "master", "This is a pull title")
+ elem := strings.Split(test.RedirectURL(resp), "/")
+ assert.EqualValues(t, "pulls", elem[3])
+ testPullMerge(t, user1Session, elem[1], elem[2], elem[4], repo_model.MergeStyleMerge, false)
+
+ // Get the commit SHA
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ BaseRepoID: baseRepo.ID,
+ BaseBranch: "master",
+ HeadRepoID: forkedRepo.ID,
+ HeadBranch: "master",
+ })
+ sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName())
+ require.NoError(t, err)
+
+ // Grab the CSRF token.
+ req := NewRequest(t, "GET", path.Join(elem[1], elem[2], "pulls", elem[4]))
+ resp = user2Session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ // Submit an approve review on the PR.
+ testSubmitReview(t, user2Session, htmlDoc.GetCSRF(), "user2", "repo1", elem[4], sha, "approve", http.StatusOK)
+
+ // Submit a reject review on the PR.
+ testSubmitReview(t, user2Session, htmlDoc.GetCSRF(), "user2", "repo1", elem[4], sha, "reject", http.StatusOK)
+ })
+
+ t.Run("Submit approve/reject review on closed PR", func(t *testing.T) {
+ // Created a closed PR (made by user1) in the upstream repo1.
+ testEditFileToNewBranch(t, user1Session, "user1", "repo1", "master", "a-test-branch", "README.md", "Hello, World (Edited...again)\n")
+ resp := testPullCreate(t, user1Session, "user1", "repo1", false, "master", "a-test-branch", "This is a pull title")
+ elem := strings.Split(test.RedirectURL(resp), "/")
+ assert.EqualValues(t, "pulls", elem[3])
+ testIssueClose(t, user1Session, elem[1], elem[2], elem[4])
+
+ // Get the commit SHA
+ pr := unittest.AssertExistsAndLoadBean(t, &issues_model.PullRequest{
+ BaseRepoID: baseRepo.ID,
+ BaseBranch: "master",
+ HeadRepoID: forkedRepo.ID,
+ HeadBranch: "a-test-branch",
+ })
+ sha, err := baseGitRepo.GetRefCommitID(pr.GetGitRefName())
+ require.NoError(t, err)
+
+ // Grab the CSRF token.
+ req := NewRequest(t, "GET", path.Join(elem[1], elem[2], "pulls", elem[4]))
+ resp = user2Session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ // Submit an approve review on the PR.
+ testSubmitReview(t, user2Session, htmlDoc.GetCSRF(), "user2", "repo1", elem[4], sha, "approve", http.StatusOK)
+
+ // Submit a reject review on the PR.
+ testSubmitReview(t, user2Session, htmlDoc.GetCSRF(), "user2", "repo1", elem[4], sha, "reject", http.StatusOK)
+ })
+ })
+}
+
+func testSubmitReview(t *testing.T, session *TestSession, csrf, owner, repo, pullNumber, commitID, reviewType string, expectedSubmitStatus int) *httptest.ResponseRecorder {
+ options := map[string]string{
+ "_csrf": csrf,
+ "commit_id": commitID,
+ "content": "test",
+ "type": reviewType,
+ }
+
+ submitURL := path.Join(owner, repo, "pulls", pullNumber, "files", "reviews", "submit")
+ req := NewRequestWithValues(t, "POST", submitURL, options)
+ return session.MakeRequest(t, req, expectedSubmitStatus)
+}
+
+func testIssueClose(t *testing.T, session *TestSession, owner, repo, issueNumber string) *httptest.ResponseRecorder {
+ req := NewRequest(t, "GET", path.Join(owner, repo, "pulls", issueNumber))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ closeURL := path.Join(owner, repo, "issues", issueNumber, "comments")
+
+ options := map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "status": "close",
+ }
+
+ req = NewRequestWithValues(t, "POST", closeURL, options)
+ return session.MakeRequest(t, req, http.StatusOK)
+}
diff --git a/tests/integration/pull_status_test.go b/tests/integration/pull_status_test.go
new file mode 100644
index 0000000..80eea34
--- /dev/null
+++ b/tests/integration/pull_status_test.go
@@ -0,0 +1,167 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ api "code.gitea.io/gitea/modules/structs"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPullCreate_CommitStatus(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFileToNewBranch(t, session, "user1", "repo1", "master", "status1", "README.md", "status1")
+
+ url := path.Join("user1", "repo1", "compare", "master...status1")
+ req := NewRequestWithValues(t, "POST", url,
+ map[string]string{
+ "_csrf": GetCSRF(t, session, url),
+ "title": "pull request from status1",
+ },
+ )
+ session.MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", "/user1/repo1/pulls")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ NewHTMLParser(t, resp.Body)
+
+ // Request repository commits page
+ req = NewRequest(t, "GET", "/user1/repo1/pulls/1/commits")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ // Get first commit URL
+ commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Last().Attr("href")
+ assert.True(t, exists)
+ assert.NotEmpty(t, commitURL)
+
+ commitID := path.Base(commitURL)
+
+ statusList := []api.CommitStatusState{
+ api.CommitStatusPending,
+ api.CommitStatusError,
+ api.CommitStatusFailure,
+ api.CommitStatusSuccess,
+ api.CommitStatusWarning,
+ }
+
+ statesIcons := map[api.CommitStatusState]string{
+ api.CommitStatusPending: "octicon-dot-fill",
+ api.CommitStatusSuccess: "octicon-check",
+ api.CommitStatusError: "gitea-exclamation",
+ api.CommitStatusFailure: "octicon-x",
+ api.CommitStatusWarning: "gitea-exclamation",
+ }
+
+ testCtx := NewAPITestContext(t, "user1", "repo1", auth_model.AccessTokenScopeWriteRepository)
+
+ // Update commit status, and check if icon is updated as well
+ for _, status := range statusList {
+ // Call API to add status for commit
+ t.Run("CreateStatus", doAPICreateCommitStatus(testCtx, commitID, api.CreateStatusOption{
+ State: status,
+ TargetURL: "http://test.ci/",
+ Description: "",
+ Context: "testci",
+ }))
+
+ req = NewRequest(t, "GET", "/user1/repo1/pulls/1/commits")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ doc = NewHTMLParser(t, resp.Body)
+
+ commitURL, exists = doc.doc.Find("#commits-table tbody tr td.sha a").Last().Attr("href")
+ assert.True(t, exists)
+ assert.NotEmpty(t, commitURL)
+ assert.EqualValues(t, commitID, path.Base(commitURL))
+
+ cls, ok := doc.doc.Find("#commits-table tbody tr td.message .commit-status").Last().Attr("class")
+ assert.True(t, ok)
+ assert.Contains(t, cls, statesIcons[status])
+ }
+
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user1", Name: "repo1"})
+ css := unittest.AssertExistsAndLoadBean(t, &git_model.CommitStatusSummary{RepoID: repo1.ID, SHA: commitID})
+ assert.EqualValues(t, api.CommitStatusWarning, css.State)
+ })
+}
+
+func doAPICreateCommitStatus(ctx APITestContext, commitID string, data api.CreateStatusOption) func(*testing.T) {
+ return func(t *testing.T) {
+ req := NewRequestWithJSON(
+ t,
+ http.MethodPost,
+ fmt.Sprintf("/api/v1/repos/%s/%s/statuses/%s", ctx.Username, ctx.Reponame, commitID),
+ data,
+ ).AddTokenAuth(ctx.Token)
+ if ctx.ExpectedCode != 0 {
+ ctx.Session.MakeRequest(t, req, ctx.ExpectedCode)
+ return
+ }
+ ctx.Session.MakeRequest(t, req, http.StatusCreated)
+ }
+}
+
+func TestPullCreate_EmptyChangesWithDifferentCommits(t *testing.T) {
+ // Merge must continue if commits SHA are different, even if content is same
+ // Reason: gitflow and merging master back into develop, where is high possibility, there are no changes
+ // but just commit saying "Merge branch". And this meta commit can be also tagged,
+ // so we need to have this meta commit also in develop branch.
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFileToNewBranch(t, session, "user1", "repo1", "master", "status1", "README.md", "status1")
+ testEditFileToNewBranch(t, session, "user1", "repo1", "status1", "status1", "README.md", "# repo1\n\nDescription for repo1")
+
+ url := path.Join("user1", "repo1", "compare", "master...status1")
+ req := NewRequestWithValues(t, "POST", url,
+ map[string]string{
+ "_csrf": GetCSRF(t, session, url),
+ "title": "pull request from status1",
+ },
+ )
+ session.MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", "/user1/repo1/pulls/1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ text := strings.TrimSpace(doc.doc.Find(".merge-section").Text())
+ assert.Contains(t, text, "This pull request can be merged automatically.")
+ })
+}
+
+func TestPullCreate_EmptyChangesWithSameCommits(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user1")
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testCreateBranch(t, session, "user1", "repo1", "branch/master", "status1", http.StatusSeeOther)
+ url := path.Join("user1", "repo1", "compare", "master...status1")
+ req := NewRequestWithValues(t, "POST", url,
+ map[string]string{
+ "_csrf": GetCSRF(t, session, url),
+ "title": "pull request from status1",
+ },
+ )
+ session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", "/user1/repo1/pulls/1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ text := strings.TrimSpace(doc.doc.Find(".merge-section").Text())
+ assert.Contains(t, text, "This branch is already included in the target branch. There is nothing to merge.")
+ })
+}
diff --git a/tests/integration/pull_summary_test.go b/tests/integration/pull_summary_test.go
new file mode 100644
index 0000000..75c1272
--- /dev/null
+++ b/tests/integration/pull_summary_test.go
@@ -0,0 +1,65 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPullSummaryCommits(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ testUser := "user2"
+ testRepo := "repo1"
+ branchOld := "master"
+ branchNew := "new-branch"
+ session := loginUser(t, testUser)
+
+ // Create a branch with commit, open a PR and see if the summary is displayed correctly for 1 commit
+ testEditFileToNewBranch(t, session, testUser, testRepo, branchOld, branchNew, "README.md", "test of pull summary")
+ url := path.Join(testUser, testRepo, "compare", branchOld+"..."+branchNew)
+ req := NewRequestWithValues(t, "POST", url,
+ map[string]string{
+ "_csrf": GetCSRF(t, session, url),
+ "title": "1st pull request to test summary",
+ },
+ )
+ session.MakeRequest(t, req, http.StatusOK)
+ testPullSummaryCommits(t, session, testUser, testRepo, "6", "wants to merge 1 commit")
+
+ // Merge the PR and see if the summary is displayed correctly for 1 commit
+ testPullMerge(t, session, testUser, testRepo, "6", "merge", true)
+ testPullSummaryCommits(t, session, testUser, testRepo, "6", "merged 1 commit")
+
+ // Create a branch with 2 commits, open a PR and see if the summary is displayed correctly for 2 commits
+ testEditFileToNewBranch(t, session, testUser, testRepo, branchOld, branchNew, "README.md", "test of pull summary (the 2nd)")
+ testEditFile(t, session, testUser, testRepo, branchNew, "README.md", "test of pull summary (the 3rd)")
+ req = NewRequestWithValues(t, "POST", url,
+ map[string]string{
+ "_csrf": GetCSRF(t, session, url),
+ "title": "2nd pull request to test summary",
+ },
+ )
+ session.MakeRequest(t, req, http.StatusOK)
+ testPullSummaryCommits(t, session, testUser, testRepo, "7", "wants to merge 2 commits")
+
+ // Merge the PR and see if the summary is displayed correctly for 2 commits
+ testPullMerge(t, session, testUser, testRepo, "7", "merge", true)
+ testPullSummaryCommits(t, session, testUser, testRepo, "7", "merged 2 commits")
+ })
+}
+
+func testPullSummaryCommits(t *testing.T, session *TestSession, user, repo, pullNum, expectedSummary string) {
+ t.Helper()
+ req := NewRequest(t, "GET", path.Join(user, repo, "pulls", pullNum))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ text := strings.TrimSpace(doc.doc.Find(".pull-desc").Text())
+ assert.Contains(t, text, expectedSummary)
+}
diff --git a/tests/integration/pull_test.go b/tests/integration/pull_test.go
new file mode 100644
index 0000000..10dbad2
--- /dev/null
+++ b/tests/integration/pull_test.go
@@ -0,0 +1,67 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestViewPulls(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/pulls")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ search := htmlDoc.doc.Find(".list-header-search > .search > .input > input")
+ placeholder, _ := search.Attr("placeholder")
+ assert.Equal(t, "Search pulls...", placeholder)
+}
+
+func TestPullManuallyMergeWarning(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user2.Name)
+
+ warningMessage := `Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.`
+ t.Run("Autodetect disabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/pulls/3")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ mergeInstructions := htmlDoc.Find("#merge-instructions").Text()
+ assert.Contains(t, mergeInstructions, warningMessage)
+ })
+
+ pullRequestUnit := unittest.AssertExistsAndLoadBean(t, &repo_model.RepoUnit{RepoID: 1, Type: unit.TypePullRequests})
+ config := pullRequestUnit.PullRequestsConfig()
+ config.AutodetectManualMerge = true
+ _, err := db.GetEngine(db.DefaultContext).ID(pullRequestUnit.ID).Cols("config").Update(pullRequestUnit)
+ require.NoError(t, err)
+
+ t.Run("Autodetect enabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/pulls/3")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ mergeInstructions := htmlDoc.Find("#merge-instructions").Text()
+ assert.NotContains(t, mergeInstructions, warningMessage)
+ })
+}
diff --git a/tests/integration/pull_update_test.go b/tests/integration/pull_update_test.go
new file mode 100644
index 0000000..08041f0
--- /dev/null
+++ b/tests/integration/pull_update_test.go
@@ -0,0 +1,175 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ pull_service "code.gitea.io/gitea/services/pull"
+ repo_service "code.gitea.io/gitea/services/repository"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIPullUpdate(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ // Create PR to test
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ org26 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26})
+ pr := createOutdatedPR(t, user, org26)
+
+ // Test GetDiverging
+ diffCount, err := pull_service.GetDiverging(git.DefaultContext, pr)
+ require.NoError(t, err)
+ assert.EqualValues(t, 1, diffCount.Behind)
+ assert.EqualValues(t, 1, diffCount.Ahead)
+ require.NoError(t, pr.LoadBaseRepo(db.DefaultContext))
+ require.NoError(t, pr.LoadIssue(db.DefaultContext))
+
+ session := loginUser(t, "user2")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update", pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Issue.Index).
+ AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // Test GetDiverging after update
+ diffCount, err = pull_service.GetDiverging(git.DefaultContext, pr)
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, diffCount.Behind)
+ assert.EqualValues(t, 2, diffCount.Ahead)
+ })
+}
+
+func TestAPIPullUpdateByRebase(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ // Create PR to test
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ org26 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 26})
+ pr := createOutdatedPR(t, user, org26)
+
+ // Test GetDiverging
+ diffCount, err := pull_service.GetDiverging(git.DefaultContext, pr)
+ require.NoError(t, err)
+ assert.EqualValues(t, 1, diffCount.Behind)
+ assert.EqualValues(t, 1, diffCount.Ahead)
+ require.NoError(t, pr.LoadBaseRepo(db.DefaultContext))
+ require.NoError(t, pr.LoadIssue(db.DefaultContext))
+
+ session := loginUser(t, "user2")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ req := NewRequestf(t, "POST", "/api/v1/repos/%s/%s/pulls/%d/update?style=rebase", pr.BaseRepo.OwnerName, pr.BaseRepo.Name, pr.Issue.Index).
+ AddTokenAuth(token)
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // Test GetDiverging after update
+ diffCount, err = pull_service.GetDiverging(git.DefaultContext, pr)
+ require.NoError(t, err)
+ assert.EqualValues(t, 0, diffCount.Behind)
+ assert.EqualValues(t, 1, diffCount.Ahead)
+ })
+}
+
+func createOutdatedPR(t *testing.T, actor, forkOrg *user_model.User) *issues_model.PullRequest {
+ baseRepo, _, _ := tests.CreateDeclarativeRepo(t, actor, "repo-pr-update", nil, nil, nil)
+
+ headRepo, err := repo_service.ForkRepositoryAndUpdates(git.DefaultContext, actor, forkOrg, repo_service.ForkRepoOptions{
+ BaseRepo: baseRepo,
+ Name: "repo-pr-update",
+ Description: "desc",
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, headRepo)
+
+ // create a commit on base Repo
+ _, err = files_service.ChangeRepoFiles(git.DefaultContext, baseRepo, actor, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "File_A",
+ ContentReader: strings.NewReader("File A"),
+ },
+ },
+ Message: "Add File A",
+ OldBranch: "main",
+ NewBranch: "main",
+ Author: &files_service.IdentityOptions{
+ Name: actor.Name,
+ Email: actor.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: actor.Name,
+ Email: actor.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+
+ // create a commit on head Repo
+ _, err = files_service.ChangeRepoFiles(git.DefaultContext, headRepo, actor, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "File_B",
+ ContentReader: strings.NewReader("File B"),
+ },
+ },
+ Message: "Add File on PR branch",
+ OldBranch: "main",
+ NewBranch: "newBranch",
+ Author: &files_service.IdentityOptions{
+ Name: actor.Name,
+ Email: actor.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: actor.Name,
+ Email: actor.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+
+ // create Pull
+ pullIssue := &issues_model.Issue{
+ RepoID: baseRepo.ID,
+ Title: "Test Pull -to-update-",
+ PosterID: actor.ID,
+ Poster: actor,
+ IsPull: true,
+ }
+ pullRequest := &issues_model.PullRequest{
+ HeadRepoID: headRepo.ID,
+ BaseRepoID: baseRepo.ID,
+ HeadBranch: "newBranch",
+ BaseBranch: "main",
+ HeadRepo: headRepo,
+ BaseRepo: baseRepo,
+ Type: issues_model.PullRequestGitea,
+ }
+ err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil)
+ require.NoError(t, err)
+
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "Test Pull -to-update-"})
+ require.NoError(t, issue.LoadPullRequest(db.DefaultContext))
+
+ return issue.PullRequest
+}
diff --git a/tests/integration/pull_wip_convert_test.go b/tests/integration/pull_wip_convert_test.go
new file mode 100644
index 0000000..935636b
--- /dev/null
+++ b/tests/integration/pull_wip_convert_test.go
@@ -0,0 +1,59 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestPullWIPConvertSidebar(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ testRepo := "repo1"
+ branchOld := "master"
+ branchNew := "wip"
+ userOwner := "user2"
+ userUnrelated := "user4"
+ sessionOwner := loginUser(t, userOwner) // Owner of the repo. Expected to see the offers.
+ sessionUnrelated := loginUser(t, userUnrelated) // Unrelated user. Not expected to see the offers.
+
+ // Create a branch with commit, open a PR and check who is seeing the Add WIP offering
+ testEditFileToNewBranch(t, sessionOwner, userOwner, testRepo, branchOld, branchNew, "README.md", "test of wip offering")
+ url := path.Join(userOwner, testRepo, "compare", branchOld+"..."+branchNew)
+ req := NewRequestWithValues(t, "POST", url,
+ map[string]string{
+ "_csrf": GetCSRF(t, sessionOwner, url),
+ "title": "pull used for testing wip offering",
+ },
+ )
+ sessionOwner.MakeRequest(t, req, http.StatusOK)
+ testPullWIPConvertSidebar(t, sessionOwner, userOwner, testRepo, "6", "Still in progress? Add WIP: prefix")
+ testPullWIPConvertSidebar(t, sessionUnrelated, userOwner, testRepo, "6", "")
+
+ // Add WIP: prefix and check who is seeing the Remove WIP offering
+ req = NewRequestWithValues(t, "POST", path.Join(userOwner, testRepo, "pulls/6/title"),
+ map[string]string{
+ "_csrf": GetCSRF(t, sessionOwner, path.Join(userOwner, testRepo, "pulls/6")),
+ "title": "WIP: pull used for testing wip offering",
+ },
+ )
+ sessionOwner.MakeRequest(t, req, http.StatusOK)
+ testPullWIPConvertSidebar(t, sessionOwner, userOwner, testRepo, "6", "Ready for review? Remove WIP: prefix")
+ testPullWIPConvertSidebar(t, sessionUnrelated, userOwner, testRepo, "6", "")
+ })
+}
+
+func testPullWIPConvertSidebar(t *testing.T, session *TestSession, user, repo, pullNum, expected string) {
+ t.Helper()
+ req := NewRequest(t, "GET", path.Join(user, repo, "pulls", pullNum))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ text := strings.TrimSpace(doc.doc.Find(".toggle-wip a").Text())
+ assert.Equal(t, expected, text)
+}
diff --git a/tests/integration/quota_use_test.go b/tests/integration/quota_use_test.go
new file mode 100644
index 0000000..39c5c1a
--- /dev/null
+++ b/tests/integration/quota_use_test.go
@@ -0,0 +1,1147 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ org_model "code.gitea.io/gitea/models/organization"
+ quota_model "code.gitea.io/gitea/models/quota"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+ forgejo_context "code.gitea.io/gitea/services/context"
+ repo_service "code.gitea.io/gitea/services/repository"
+ "code.gitea.io/gitea/tests"
+
+ gouuid "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWebQuotaEnforcementRepoMigrate(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := createQuotaWebEnv(t)
+ defer env.Cleanup()
+
+ env.RunVisitAndPostToPageTests(t, "/repo/migrate", &Payload{
+ "repo_name": "migration-test",
+ "clone_addr": env.Users.Limited.Repo.Link() + ".git",
+ "service": fmt.Sprintf("%d", api.ForgejoService),
+ }, http.StatusOK)
+ })
+}
+
+func TestWebQuotaEnforcementRepoCreate(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := createQuotaWebEnv(t)
+ defer env.Cleanup()
+
+ env.RunVisitAndPostToPageTests(t, "/repo/create", nil, http.StatusOK)
+ })
+}
+
+func TestWebQuotaEnforcementRepoFork(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := createQuotaWebEnv(t)
+ defer env.Cleanup()
+
+ page := fmt.Sprintf("%s/fork", env.Users.Limited.Repo.Link())
+ env.RunVisitAndPostToPageTests(t, page, &Payload{
+ "repo_name": "fork-test",
+ }, http.StatusSeeOther)
+ })
+}
+
+func TestWebQuotaEnforcementIssueAttachment(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := createQuotaWebEnv(t)
+ defer env.Cleanup()
+
+ // Uploading to our repo => 413
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Users.Limited.Repo}).
+ CreateIssueAttachment("test.txt").
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ // Uploading to the limited org repo => 413
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Orgs.Limited.Repo}).
+ CreateIssueAttachment("test.txt").
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ // Uploading to the unlimited org repo => 200
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Orgs.Unlimited.Repo}).
+ CreateIssueAttachment("test.txt").
+ ExpectStatus(http.StatusOK)
+ })
+}
+
+func TestWebQuotaEnforcementMirrorSync(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := createQuotaWebEnv(t)
+ defer env.Cleanup()
+
+ var mirrorRepo *repo_model.Repository
+
+ env.As(t, env.Users.Limited).
+ WithoutQuota(func(ctx *quotaWebEnvAsContext) {
+ mirrorRepo = ctx.CreateMirror()
+ }).
+ With(Context{
+ Repo: mirrorRepo,
+ Payload: &Payload{"action": "mirror-sync"},
+ }).
+ PostToPage(mirrorRepo.Link() + "/settings").
+ ExpectStatus(http.StatusOK).
+ ExpectFlashMessage("Quota exceeded, not pulling changes.")
+ })
+}
+
+func TestWebQuotaEnforcementRepoContentEditing(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := createQuotaWebEnv(t)
+ defer env.Cleanup()
+
+ // We're only going to test the GET requests here, because the entire combo
+ // is covered by a route check.
+
+ // Lets create a helper!
+ runCheck := func(t *testing.T, path string, successStatus int) {
+ t.Run("#"+path, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Uploading to a limited user's repo => 413
+ env.As(t, env.Users.Limited).
+ VisitPage(env.Users.Limited.Repo.Link() + path).
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ // Limited org => 413
+ env.As(t, env.Users.Limited).
+ VisitPage(env.Orgs.Limited.Repo.Link() + path).
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ // Unlimited org => 200
+ env.As(t, env.Users.Limited).
+ VisitPage(env.Orgs.Unlimited.Repo.Link() + path).
+ ExpectStatus(successStatus)
+ })
+ }
+
+ paths := []string{
+ "/_new/main",
+ "/_edit/main/README.md",
+ "/_delete/main",
+ "/_upload/main",
+ "/_diffpatch/main",
+ }
+
+ for _, path := range paths {
+ runCheck(t, path, http.StatusOK)
+ }
+
+ // Run another check for `_cherrypick`. It's cumbersome to dig out a valid
+ // commit id, so we'll use a fake, and treat 404 as a success: it's not 413,
+ // and that's all we care about for this test.
+ runCheck(t, "/_cherrypick/92cfceb39d57d914ed8b14d0e37643de0797ae56/main", http.StatusNotFound)
+ })
+}
+
+func TestWebQuotaEnforcementRepoBranches(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := createQuotaWebEnv(t)
+ defer env.Cleanup()
+
+ t.Run("create", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ runTest := func(t *testing.T, path string) {
+ t.Run("#"+path, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ env.As(t, env.Users.Limited).
+ With(Context{Payload: &Payload{"new_branch_name": "quota"}}).
+ PostToRepoPage("/branches/_new" + path).
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ env.As(t, env.Users.Limited).
+ With(Context{
+ Payload: &Payload{"new_branch_name": "quota"},
+ Repo: env.Orgs.Limited.Repo,
+ }).
+ PostToRepoPage("/branches/_new" + path).
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ env.As(t, env.Users.Limited).
+ With(Context{
+ Payload: &Payload{"new_branch_name": "quota"},
+ Repo: env.Orgs.Unlimited.Repo,
+ }).
+ PostToRepoPage("/branches/_new" + path).
+ ExpectStatus(http.StatusNotFound)
+ })
+ }
+
+ // We're testing the first two against things that don't exist, so that
+ // all three consistently return 404 if no quota enforcement happens.
+ runTest(t, "/branch/no-such-branch")
+ runTest(t, "/tag/no-such-tag")
+ runTest(t, "/commit/92cfceb39d57d914ed8b14d0e37643de0797ae56")
+ })
+
+ t.Run("delete & restore", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ env.As(t, env.Users.Limited).
+ WithoutQuota(func(ctx *quotaWebEnvAsContext) {
+ ctx.With(Context{Payload: &Payload{"new_branch_name": "to-delete"}}).
+ PostToRepoPage("/branches/_new/branch/main").
+ ExpectStatus(http.StatusSeeOther)
+ })
+
+ env.As(t, env.Users.Limited).
+ PostToRepoPage("/branches/delete?name=to-delete").
+ ExpectStatus(http.StatusOK)
+
+ env.As(t, env.Users.Limited).
+ PostToRepoPage("/branches/restore?name=to-delete").
+ ExpectStatus(http.StatusOK)
+ })
+ })
+}
+
+func TestWebQuotaEnforcementRepoReleases(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := createQuotaWebEnv(t)
+ defer env.Cleanup()
+
+ env.RunVisitAndPostToRepoPageTests(t, "/releases/new", &Payload{
+ "tag_name": "quota",
+ "tag_target": "main",
+ "title": "test release",
+ }, http.StatusSeeOther)
+
+ t.Run("attachments", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Uploading to our repo => 413
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Users.Limited.Repo}).
+ CreateReleaseAttachment("test.txt").
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ // Uploading to the limited org repo => 413
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Orgs.Limited.Repo}).
+ CreateReleaseAttachment("test.txt").
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ // Uploading to the unlimited org repo => 200
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Orgs.Unlimited.Repo}).
+ CreateReleaseAttachment("test.txt").
+ ExpectStatus(http.StatusOK)
+ })
+ })
+}
+
+func TestWebQuotaEnforcementRepoPulls(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := createQuotaWebEnv(t)
+ defer env.Cleanup()
+
+ // To create a pull request, we first fork the two limited repos into the
+ // unlimited org.
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Users.Limited.Repo}).
+ ForkRepoInto(env.Orgs.Unlimited)
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Orgs.Limited.Repo}).
+ ForkRepoInto(env.Orgs.Unlimited)
+
+ // Then, create pull requests from the forks, back to the main repos
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Users.Limited.Repo}).
+ CreatePullFrom(env.Orgs.Unlimited)
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Orgs.Limited.Repo}).
+ CreatePullFrom(env.Orgs.Unlimited)
+
+ // Trying to merge the pull request will fail for both, though, due to being
+ // over quota.
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Users.Limited.Repo}).
+ With(Context{Payload: &Payload{"do": "merge"}}).
+ PostToRepoPage("/pulls/1/merge").
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Orgs.Limited.Repo}).
+ With(Context{Payload: &Payload{"do": "merge"}}).
+ PostToRepoPage("/pulls/1/merge").
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+ })
+}
+
+func TestWebQuotaEnforcementRepoTransfer(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := createQuotaWebEnv(t)
+ defer env.Cleanup()
+
+ t.Run("direct transfer", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Trying to transfer the repository to a limited organization fails.
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Users.Limited.Repo}).
+ With(Context{Payload: &Payload{
+ "action": "transfer",
+ "repo_name": env.Users.Limited.Repo.FullName(),
+ "new_owner_name": env.Orgs.Limited.Org.Name,
+ }}).
+ PostToRepoPage("/settings").
+ ExpectStatus(http.StatusOK).
+ ExpectFlashMessageContains("over quota", "The repository has not been transferred")
+
+ // Trying to transfer to a different, also limited user, also fails.
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Users.Limited.Repo}).
+ With(Context{Payload: &Payload{
+ "action": "transfer",
+ "repo_name": env.Users.Limited.Repo.FullName(),
+ "new_owner_name": env.Users.Contributor.User.Name,
+ }}).
+ PostToRepoPage("/settings").
+ ExpectStatus(http.StatusOK).
+ ExpectFlashMessageContains("over quota", "The repository has not been transferred")
+ })
+
+ t.Run("accept & reject", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Trying to transfer to a different user, with quota lifted, starts the transfer
+ env.As(t, env.Users.Contributor).
+ WithoutQuota(func(ctx *quotaWebEnvAsContext) {
+ env.As(ctx.t, env.Users.Limited).
+ With(Context{Repo: env.Users.Limited.Repo}).
+ With(Context{Payload: &Payload{
+ "action": "transfer",
+ "repo_name": env.Users.Limited.Repo.FullName(),
+ "new_owner_name": env.Users.Contributor.User.Name,
+ }}).
+ PostToRepoPage("/settings").
+ ExpectStatus(http.StatusSeeOther).
+ ExpectFlashCookieContains("This repository has been marked for transfer and awaits confirmation")
+ })
+
+ // Trying to accept the transfer, with quota in effect, fails
+ env.As(t, env.Users.Contributor).
+ With(Context{Repo: env.Users.Limited.Repo}).
+ PostToRepoPage("/action/accept_transfer").
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ // Rejecting the transfer, however, succeeds.
+ env.As(t, env.Users.Contributor).
+ With(Context{Repo: env.Users.Limited.Repo}).
+ PostToRepoPage("/action/reject_transfer").
+ ExpectStatus(http.StatusSeeOther)
+ })
+ })
+}
+
+func TestGitQuotaEnforcement(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := createQuotaWebEnv(t)
+ defer env.Cleanup()
+
+ // Lets create a little helper that runs a task for three of our repos: the
+ // user's repo, the limited org repo, and the unlimited org's.
+ //
+ // We expect the last one to always work, and the expected status of the
+ // other two is decided by the caller.
+ runTestForAllRepos := func(t *testing.T, task func(t *testing.T, repo *repo_model.Repository) error, expectSuccess bool) {
+ t.Helper()
+
+ err := task(t, env.Users.Limited.Repo)
+ if expectSuccess {
+ require.NoError(t, err)
+ } else {
+ require.Error(t, err)
+ }
+
+ err = task(t, env.Orgs.Limited.Repo)
+ if expectSuccess {
+ require.NoError(t, err)
+ } else {
+ require.Error(t, err)
+ }
+
+ err = task(t, env.Orgs.Unlimited.Repo)
+ require.NoError(t, err)
+ }
+
+ // Run tests with quotas disabled
+ runTestForAllReposWithQuotaDisabled := func(t *testing.T, task func(t *testing.T, repo *repo_model.Repository) error) {
+ t.Helper()
+
+ t.Run("with quota disabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.Quota.Enabled, false)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ runTestForAllRepos(t, task, true)
+ })
+ }
+
+ t.Run("push branch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Pushing a new branch is denied if the user is over quota.
+ runTestForAllRepos(t, func(t *testing.T, repo *repo_model.Repository) error {
+ return env.As(t, env.Users.Limited).
+ With(Context{Repo: repo}).
+ LocalClone(u).
+ Push("HEAD:new-branch")
+ }, false)
+
+ // Pushing a new branch is always allowed if quota is disabled
+ runTestForAllReposWithQuotaDisabled(t, func(t *testing.T, repo *repo_model.Repository) error {
+ return env.As(t, env.Users.Limited).
+ With(Context{Repo: repo}).
+ LocalClone(u).
+ Push("HEAD:new-branch-wo-quota")
+ })
+ })
+
+ t.Run("push tag", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Pushing a tag is denied if the user is over quota.
+ runTestForAllRepos(t, func(t *testing.T, repo *repo_model.Repository) error {
+ return env.As(t, env.Users.Limited).
+ With(Context{Repo: repo}).
+ LocalClone(u).
+ Tag("new-tag").
+ Push("new-tag")
+ }, false)
+
+ // ...but succeeds if the quota feature is disabled
+ runTestForAllReposWithQuotaDisabled(t, func(t *testing.T, repo *repo_model.Repository) error {
+ return env.As(t, env.Users.Limited).
+ With(Context{Repo: repo}).
+ LocalClone(u).
+ Tag("new-tag-wo-quota").
+ Push("new-tag-wo-quota")
+ })
+ })
+
+ t.Run("Agit PR", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Opening an Agit PR is *always* accepted. At least for now.
+ runTestForAllRepos(t, func(t *testing.T, repo *repo_model.Repository) error {
+ return env.As(t, env.Users.Limited).
+ With(Context{Repo: repo}).
+ LocalClone(u).
+ Push("HEAD:refs/for/main/agit-pr-branch")
+ }, true)
+ })
+
+ t.Run("delete branch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Deleting a branch is respected, and allowed.
+ err := env.As(t, env.Users.Limited).
+ WithoutQuota(func(ctx *quotaWebEnvAsContext) {
+ err := ctx.
+ LocalClone(u).
+ Push("HEAD:branch-to-delete")
+ require.NoError(ctx.t, err)
+ }).
+ Push(":branch-to-delete")
+ require.NoError(t, err)
+ })
+
+ t.Run("delete tag", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Deleting a tag is always allowed.
+ err := env.As(t, env.Users.Limited).
+ WithoutQuota(func(ctx *quotaWebEnvAsContext) {
+ err := ctx.
+ LocalClone(u).
+ Tag("tag-to-delete").
+ Push("tag-to-delete")
+ require.NoError(ctx.t, err)
+ }).
+ Push(":tag-to-delete")
+ require.NoError(t, err)
+ })
+
+ t.Run("mixed push", func(t *testing.T) {
+ t.Run("all deletes", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Pushing multiple deletes is allowed.
+ err := env.As(t, env.Users.Limited).
+ WithoutQuota(func(ctx *quotaWebEnvAsContext) {
+ err := ctx.
+ LocalClone(u).
+ Tag("mixed-push-tag").
+ Push("mixed-push-tag", "HEAD:mixed-push-branch")
+ require.NoError(ctx.t, err)
+ }).
+ Push(":mixed-push-tag", ":mixed-push-branch")
+ require.NoError(t, err)
+ })
+
+ t.Run("new & delete", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Pushing a mix of deletions & a new branch is rejected together.
+ err := env.As(t, env.Users.Limited).
+ WithoutQuota(func(ctx *quotaWebEnvAsContext) {
+ err := ctx.
+ LocalClone(u).
+ Tag("mixed-push-tag").
+ Push("mixed-push-tag", "HEAD:mixed-push-branch")
+ require.NoError(ctx.t, err)
+ }).
+ Push(":mixed-push-tag", ":mixed-push-branch", "HEAD:mixed-push-branch-new")
+ require.Error(t, err)
+
+ // ...unless quota is disabled
+ t.Run("with quota disabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.Quota.Enabled, false)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ err := env.As(t, env.Users.Limited).
+ WithoutQuota(func(ctx *quotaWebEnvAsContext) {
+ err := ctx.
+ LocalClone(u).
+ Tag("mixed-push-tag-2").
+ Push("mixed-push-tag-2", "HEAD:mixed-push-branch-2")
+ require.NoError(ctx.t, err)
+ }).
+ Push(":mixed-push-tag-2", ":mixed-push-branch-2", "HEAD:mixed-push-branch-new-2")
+ require.NoError(t, err)
+ })
+ })
+ })
+ })
+}
+
+func TestQuotaConfigDefault(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ env := createQuotaWebEnv(t)
+ defer env.Cleanup()
+
+ t.Run("with config-based default", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.Quota.Default.Total, 0)()
+
+ env.As(t, env.Users.Ungrouped).
+ With(Context{
+ Payload: &Payload{
+ "uid": env.Users.Ungrouped.ID().AsString(),
+ "repo_name": "quota-config-default",
+ },
+ }).
+ PostToPage("/repo/create").
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+ })
+
+ t.Run("without config-based default", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ env.As(t, env.Users.Ungrouped).
+ With(Context{
+ Payload: &Payload{
+ "uid": env.Users.Ungrouped.ID().AsString(),
+ "repo_name": "quota-config-default",
+ },
+ }).
+ PostToPage("/repo/create").
+ ExpectStatus(http.StatusSeeOther)
+ })
+ })
+}
+
+/**********************
+ * Here be dragons! *
+ * *
+ * . *
+ * .> )\;`a__ *
+ * ( _ _)/ /-." ~~ *
+ * `( )_ )/ *
+ * <_ <_ sb/dwb *
+ **********************/
+
+type quotaWebEnv struct {
+ Users quotaWebEnvUsers
+ Orgs quotaWebEnvOrgs
+
+ cleaners []func()
+}
+
+type quotaWebEnvUsers struct {
+ Limited quotaWebEnvUser
+ Contributor quotaWebEnvUser
+ Ungrouped quotaWebEnvUser
+}
+
+type quotaWebEnvOrgs struct {
+ Limited quotaWebEnvOrg
+ Unlimited quotaWebEnvOrg
+}
+
+type quotaWebEnvOrg struct {
+ Org *org_model.Organization
+
+ Repo *repo_model.Repository
+
+ QuotaGroup *quota_model.Group
+ QuotaRule *quota_model.Rule
+}
+
+type quotaWebEnvUser struct {
+ User *user_model.User
+ Session *TestSession
+ Repo *repo_model.Repository
+
+ QuotaGroup *quota_model.Group
+ QuotaRule *quota_model.Rule
+}
+
+type Payload map[string]string
+
+type quotaWebEnvAsContext struct {
+ t *testing.T
+
+ Doer *quotaWebEnvUser
+ Repo *repo_model.Repository
+
+ Payload Payload
+
+ CSRFPath *string
+
+ gitPath string
+
+ request *RequestWrapper
+ response *httptest.ResponseRecorder
+}
+
+type Context struct {
+ Repo *repo_model.Repository
+ Payload *Payload
+ CSRFPath *string
+}
+
+func (ctx *quotaWebEnvAsContext) With(opts Context) *quotaWebEnvAsContext {
+ if opts.Repo != nil {
+ ctx.Repo = opts.Repo
+ }
+ if opts.Payload != nil {
+ for key, value := range *opts.Payload {
+ ctx.Payload[key] = value
+ }
+ }
+ if opts.CSRFPath != nil {
+ ctx.CSRFPath = opts.CSRFPath
+ }
+ return ctx
+}
+
+func (ctx *quotaWebEnvAsContext) VisitPage(page string) *quotaWebEnvAsContext {
+ ctx.t.Helper()
+
+ ctx.request = NewRequest(ctx.t, "GET", page)
+
+ return ctx
+}
+
+func (ctx *quotaWebEnvAsContext) VisitRepoPage(page string) *quotaWebEnvAsContext {
+ ctx.t.Helper()
+
+ return ctx.VisitPage(ctx.Repo.Link() + page)
+}
+
+func (ctx *quotaWebEnvAsContext) ExpectStatus(status int) *quotaWebEnvAsContext {
+ ctx.t.Helper()
+
+ ctx.response = ctx.Doer.Session.MakeRequest(ctx.t, ctx.request, status)
+
+ return ctx
+}
+
+func (ctx *quotaWebEnvAsContext) ExpectFlashMessage(value string) {
+ ctx.t.Helper()
+
+ htmlDoc := NewHTMLParser(ctx.t, ctx.response.Body)
+ flashMessage := strings.TrimSpace(htmlDoc.Find(`.flash-message`).Text())
+
+ assert.EqualValues(ctx.t, value, flashMessage)
+}
+
+func (ctx *quotaWebEnvAsContext) ExpectFlashMessageContains(parts ...string) {
+ ctx.t.Helper()
+
+ htmlDoc := NewHTMLParser(ctx.t, ctx.response.Body)
+ flashMessage := strings.TrimSpace(htmlDoc.Find(`.flash-message`).Text())
+
+ for _, part := range parts {
+ assert.Contains(ctx.t, flashMessage, part)
+ }
+}
+
+func (ctx *quotaWebEnvAsContext) ExpectFlashCookieContains(parts ...string) {
+ ctx.t.Helper()
+
+ flashCookie := ctx.Doer.Session.GetCookie(forgejo_context.CookieNameFlash)
+ assert.NotNil(ctx.t, flashCookie)
+
+ // Need to decode the cookie twice
+ flashValue, err := url.QueryUnescape(flashCookie.Value)
+ require.NoError(ctx.t, err)
+ flashValue, err = url.QueryUnescape(flashValue)
+ require.NoError(ctx.t, err)
+
+ for _, part := range parts {
+ assert.Contains(ctx.t, flashValue, part)
+ }
+}
+
+func (ctx *quotaWebEnvAsContext) ForkRepoInto(org quotaWebEnvOrg) {
+ ctx.t.Helper()
+
+ ctx.
+ With(Context{Payload: &Payload{
+ "uid": org.ID().AsString(),
+ "repo_name": ctx.Repo.Name + "-fork",
+ }}).
+ PostToRepoPage("/fork").
+ ExpectStatus(http.StatusSeeOther)
+}
+
+func (ctx *quotaWebEnvAsContext) CreatePullFrom(org quotaWebEnvOrg) {
+ ctx.t.Helper()
+
+ url := fmt.Sprintf("/compare/main...%s:main", org.Org.Name)
+ ctx.
+ With(Context{Payload: &Payload{
+ "title": "PR test",
+ }}).
+ PostToRepoPage(url).
+ ExpectStatus(http.StatusOK)
+}
+
+func (ctx *quotaWebEnvAsContext) PostToPage(page string) *quotaWebEnvAsContext {
+ ctx.t.Helper()
+
+ payload := ctx.Payload
+ csrfPath := page
+ if ctx.CSRFPath != nil {
+ csrfPath = *ctx.CSRFPath
+ }
+
+ payload["_csrf"] = GetCSRF(ctx.t, ctx.Doer.Session, csrfPath)
+
+ ctx.request = NewRequestWithValues(ctx.t, "POST", page, payload)
+
+ return ctx
+}
+
+func (ctx *quotaWebEnvAsContext) PostToRepoPage(page string) *quotaWebEnvAsContext {
+ ctx.t.Helper()
+
+ csrfPath := ctx.Repo.Link()
+ return ctx.With(Context{CSRFPath: &csrfPath}).PostToPage(ctx.Repo.Link() + page)
+}
+
+func (ctx *quotaWebEnvAsContext) CreateAttachment(filename, attachmentType string) *quotaWebEnvAsContext {
+ ctx.t.Helper()
+
+ body := &bytes.Buffer{}
+ image := generateImg()
+
+ // Setup multi-part
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("file", filename)
+ require.NoError(ctx.t, err)
+ _, err = io.Copy(part, &image)
+ require.NoError(ctx.t, err)
+ err = writer.Close()
+ require.NoError(ctx.t, err)
+
+ csrf := GetCSRF(ctx.t, ctx.Doer.Session, ctx.Repo.Link())
+
+ ctx.request = NewRequestWithBody(ctx.t, "POST", fmt.Sprintf("%s/%s/attachments", ctx.Repo.Link(), attachmentType), body)
+ ctx.request.Header.Add("X-Csrf-Token", csrf)
+ ctx.request.Header.Add("Content-Type", writer.FormDataContentType())
+
+ return ctx
+}
+
+func (ctx *quotaWebEnvAsContext) CreateIssueAttachment(filename string) *quotaWebEnvAsContext {
+ ctx.t.Helper()
+
+ return ctx.CreateAttachment(filename, "issues")
+}
+
+func (ctx *quotaWebEnvAsContext) CreateReleaseAttachment(filename string) *quotaWebEnvAsContext {
+ ctx.t.Helper()
+
+ return ctx.CreateAttachment(filename, "releases")
+}
+
+func (ctx *quotaWebEnvAsContext) WithoutQuota(task func(ctx *quotaWebEnvAsContext)) *quotaWebEnvAsContext {
+ ctx.t.Helper()
+
+ defer ctx.Doer.SetQuota(-1)()
+ task(ctx)
+
+ return ctx
+}
+
+func (ctx *quotaWebEnvAsContext) CreateMirror() *repo_model.Repository {
+ ctx.t.Helper()
+
+ doer := ctx.Doer.User
+
+ repo, err := repo_service.CreateRepositoryDirectly(db.DefaultContext, doer, doer, repo_service.CreateRepoOptions{
+ Name: "test-mirror",
+ IsMirror: true,
+ Status: repo_model.RepositoryBeingMigrated,
+ })
+ require.NoError(ctx.t, err)
+
+ return repo
+}
+
+func (ctx *quotaWebEnvAsContext) LocalClone(u *url.URL) *quotaWebEnvAsContext {
+ ctx.t.Helper()
+
+ gitPath := ctx.t.TempDir()
+
+ doGitInitTestRepository(gitPath, git.Sha1ObjectFormat)(ctx.t)
+
+ oldPath := u.Path
+ oldUser := u.User
+ defer func() {
+ u.Path = oldPath
+ u.User = oldUser
+ }()
+ u.Path = ctx.Repo.FullName() + ".git"
+ u.User = url.UserPassword(ctx.Doer.User.LowerName, userPassword)
+
+ doGitAddRemote(gitPath, "origin", u)(ctx.t)
+
+ ctx.gitPath = gitPath
+
+ return ctx
+}
+
+func (ctx *quotaWebEnvAsContext) Push(params ...string) error {
+ ctx.t.Helper()
+
+ gitRepo, err := git.OpenRepository(git.DefaultContext, ctx.gitPath)
+ require.NoError(ctx.t, err)
+ defer gitRepo.Close()
+
+ _, _, err = git.NewCommand(git.DefaultContext, "push", "origin").
+ AddArguments(git.ToTrustedCmdArgs(params)...).
+ RunStdString(&git.RunOpts{Dir: ctx.gitPath})
+
+ return err
+}
+
+func (ctx *quotaWebEnvAsContext) Tag(tagName string) *quotaWebEnvAsContext {
+ ctx.t.Helper()
+
+ gitRepo, err := git.OpenRepository(git.DefaultContext, ctx.gitPath)
+ require.NoError(ctx.t, err)
+ defer gitRepo.Close()
+
+ _, _, err = git.NewCommand(git.DefaultContext, "tag").
+ AddArguments(git.ToTrustedCmdArgs([]string{tagName})...).
+ RunStdString(&git.RunOpts{Dir: ctx.gitPath})
+ require.NoError(ctx.t, err)
+
+ return ctx
+}
+
+func (user *quotaWebEnvUser) SetQuota(limit int64) func() {
+ previousLimit := user.QuotaRule.Limit
+
+ user.QuotaRule.Limit = limit
+ user.QuotaRule.Edit(db.DefaultContext, &limit, nil)
+
+ return func() {
+ user.QuotaRule.Limit = previousLimit
+ user.QuotaRule.Edit(db.DefaultContext, &previousLimit, nil)
+ }
+}
+
+func (user *quotaWebEnvUser) ID() convertAs {
+ return convertAs{
+ asString: fmt.Sprintf("%d", user.User.ID),
+ }
+}
+
+func (org *quotaWebEnvOrg) ID() convertAs {
+ return convertAs{
+ asString: fmt.Sprintf("%d", org.Org.ID),
+ }
+}
+
+type convertAs struct {
+ asString string
+}
+
+func (cas convertAs) AsString() string {
+ return cas.asString
+}
+
+func (env *quotaWebEnv) Cleanup() {
+ for i := len(env.cleaners) - 1; i >= 0; i-- {
+ env.cleaners[i]()
+ }
+}
+
+func (env *quotaWebEnv) As(t *testing.T, user quotaWebEnvUser) *quotaWebEnvAsContext {
+ t.Helper()
+
+ ctx := quotaWebEnvAsContext{
+ t: t,
+ Doer: &user,
+ Repo: user.Repo,
+
+ Payload: Payload{},
+ }
+ return &ctx
+}
+
+func (env *quotaWebEnv) RunVisitAndPostToRepoPageTests(t *testing.T, page string, payload *Payload, successStatus int) {
+ t.Helper()
+
+ // Visiting the user's repo page fails due to being over quota.
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Users.Limited.Repo}).
+ VisitRepoPage(page).
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ // Posting as the limited user, to the limited repo, fails due to being over
+ // quota.
+ csrfPath := env.Users.Limited.Repo.Link()
+ env.As(t, env.Users.Limited).
+ With(Context{
+ Payload: payload,
+ CSRFPath: &csrfPath,
+ Repo: env.Users.Limited.Repo,
+ }).
+ PostToRepoPage(page).
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ // Visiting the limited org's repo page fails due to being over quota.
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Orgs.Limited.Repo}).
+ VisitRepoPage(page).
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ // Posting as the limited user, to a limited org's repo, fails for the same
+ // reason.
+ csrfPath = env.Orgs.Limited.Repo.Link()
+ env.As(t, env.Users.Limited).
+ With(Context{
+ Payload: payload,
+ CSRFPath: &csrfPath,
+ Repo: env.Orgs.Limited.Repo,
+ }).
+ PostToRepoPage(page).
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ // Visiting the repo page for the unlimited org succeeds.
+ env.As(t, env.Users.Limited).
+ With(Context{Repo: env.Orgs.Unlimited.Repo}).
+ VisitRepoPage(page).
+ ExpectStatus(http.StatusOK)
+
+ // Posting as the limited user, to an unlimited org's repo, succeeds.
+ csrfPath = env.Orgs.Unlimited.Repo.Link()
+ env.As(t, env.Users.Limited).
+ With(Context{
+ Payload: payload,
+ CSRFPath: &csrfPath,
+ Repo: env.Orgs.Unlimited.Repo,
+ }).
+ PostToRepoPage(page).
+ ExpectStatus(successStatus)
+}
+
+func (env *quotaWebEnv) RunVisitAndPostToPageTests(t *testing.T, page string, payload *Payload, successStatus int) {
+ t.Helper()
+
+ // Visiting the page is always fine.
+ env.As(t, env.Users.Limited).
+ VisitPage(page).
+ ExpectStatus(http.StatusOK)
+
+ // Posting as the Limited user fails, because it is over quota.
+ env.As(t, env.Users.Limited).
+ With(Context{Payload: payload}).
+ With(Context{
+ Payload: &Payload{
+ "uid": env.Users.Limited.ID().AsString(),
+ },
+ }).
+ PostToPage(page).
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ // Posting to a limited org also fails, for the same reason.
+ env.As(t, env.Users.Limited).
+ With(Context{Payload: payload}).
+ With(Context{
+ Payload: &Payload{
+ "uid": env.Orgs.Limited.ID().AsString(),
+ },
+ }).
+ PostToPage(page).
+ ExpectStatus(http.StatusRequestEntityTooLarge)
+
+ // Posting to an unlimited repo works, however.
+ env.As(t, env.Users.Limited).
+ With(Context{Payload: payload}).
+ With(Context{
+ Payload: &Payload{
+ "uid": env.Orgs.Unlimited.ID().AsString(),
+ },
+ }).
+ PostToPage(page).
+ ExpectStatus(successStatus)
+}
+
+func createQuotaWebEnv(t *testing.T) *quotaWebEnv {
+ t.Helper()
+
+ // *** helpers ***
+
+ makeUngroupedUser := func(t *testing.T) quotaWebEnvUser {
+ t.Helper()
+
+ user := quotaWebEnvUser{}
+
+ // Create the user
+ userName := gouuid.NewString()
+ apiCreateUser(t, userName)
+ user.User = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: userName})
+ user.Session = loginUser(t, userName)
+
+ // Create a repository for the user
+ repo, _, _ := tests.CreateDeclarativeRepoWithOptions(t, user.User, tests.DeclarativeRepoOptions{})
+ user.Repo = repo
+
+ return user
+ }
+
+ // Create a user, its quota group & rule
+ makeUser := func(t *testing.T, limit int64) quotaWebEnvUser {
+ t.Helper()
+
+ user := makeUngroupedUser(t)
+ userName := user.User.Name
+
+ // Create a quota group for them
+ group, err := quota_model.CreateGroup(db.DefaultContext, userName)
+ require.NoError(t, err)
+ user.QuotaGroup = group
+
+ // Create a rule
+ rule, err := quota_model.CreateRule(db.DefaultContext, userName, limit, quota_model.LimitSubjects{quota_model.LimitSubjectSizeAll})
+ require.NoError(t, err)
+ user.QuotaRule = rule
+
+ // Add the rule to the group
+ err = group.AddRuleByName(db.DefaultContext, rule.Name)
+ require.NoError(t, err)
+
+ // Add the user to the group
+ err = group.AddUserByID(db.DefaultContext, user.User.ID)
+ require.NoError(t, err)
+
+ return user
+ }
+
+ // Create a user, its quota group & rule
+ makeOrg := func(t *testing.T, owner *user_model.User, limit int64) quotaWebEnvOrg {
+ t.Helper()
+
+ org := quotaWebEnvOrg{}
+
+ // Create the org
+ userName := gouuid.NewString()
+ org.Org = &org_model.Organization{
+ Name: userName,
+ }
+ err := org_model.CreateOrganization(db.DefaultContext, org.Org, owner)
+ require.NoError(t, err)
+
+ // Create a repository for the org
+ orgUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: org.Org.ID})
+ repo, _, _ := tests.CreateDeclarativeRepoWithOptions(t, orgUser, tests.DeclarativeRepoOptions{})
+ org.Repo = repo
+
+ // Create a quota group for them
+ group, err := quota_model.CreateGroup(db.DefaultContext, userName)
+ require.NoError(t, err)
+ org.QuotaGroup = group
+
+ // Create a rule
+ rule, err := quota_model.CreateRule(db.DefaultContext, userName, limit, quota_model.LimitSubjects{quota_model.LimitSubjectSizeAll})
+ require.NoError(t, err)
+ org.QuotaRule = rule
+
+ // Add the rule to the group
+ err = group.AddRuleByName(db.DefaultContext, rule.Name)
+ require.NoError(t, err)
+
+ // Add the org to the group
+ err = group.AddUserByID(db.DefaultContext, org.Org.ID)
+ require.NoError(t, err)
+
+ return org
+ }
+
+ env := quotaWebEnv{}
+ env.cleaners = []func(){
+ test.MockVariableValue(&setting.Quota.Enabled, true),
+ test.MockVariableValue(&testWebRoutes, routers.NormalRoutes()),
+ }
+
+ // Create the limited user and the various orgs, and a contributor who's not
+ // in any of the orgs.
+ env.Users.Limited = makeUser(t, int64(0))
+ env.Users.Contributor = makeUser(t, int64(0))
+ env.Orgs.Limited = makeOrg(t, env.Users.Limited.User, int64(0))
+ env.Orgs.Unlimited = makeOrg(t, env.Users.Limited.User, int64(-1))
+
+ env.Users.Ungrouped = makeUngroupedUser(t)
+
+ return &env
+}
diff --git a/tests/integration/release_test.go b/tests/integration/release_test.go
new file mode 100644
index 0000000..48c2b37
--- /dev/null
+++ b/tests/integration/release_test.go
@@ -0,0 +1,353 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func createNewRelease(t *testing.T, session *TestSession, repoURL, tag, title string, preRelease, draft bool) {
+ createNewReleaseTarget(t, session, repoURL, tag, title, "master", preRelease, draft)
+}
+
+func createNewReleaseTarget(t *testing.T, session *TestSession, repoURL, tag, title, target string, preRelease, draft bool) {
+ req := NewRequest(t, "GET", repoURL+"/releases/new")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action")
+ assert.True(t, exists, "The template has changed")
+
+ postData := map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "tag_name": tag,
+ "tag_target": target,
+ "title": title,
+ "content": "",
+ }
+ if preRelease {
+ postData["prerelease"] = "on"
+ }
+ if draft {
+ postData["draft"] = "Save Draft"
+ }
+ req = NewRequestWithValues(t, "POST", link, postData)
+
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+
+ test.RedirectURL(resp) // check that redirect URL exists
+}
+
+func checkLatestReleaseAndCount(t *testing.T, session *TestSession, repoURL, version, label string, count int) {
+ req := NewRequest(t, "GET", repoURL+"/releases")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ labelText := htmlDoc.doc.Find("#release-list > li .detail .label").First().Text()
+ assert.EqualValues(t, label, labelText)
+ titleText := htmlDoc.doc.Find("#release-list > li .detail h4 a").First().Text()
+ assert.EqualValues(t, version, titleText)
+
+ // Check release count in the counter on the Release/Tag switch, as well as that the tab is highlighted
+ if count < 10 { // Only check values less than 10, should be enough attempts before this test cracks
+ // 10 is the pagination limit, but the counter can have more than that
+ releaseTab := htmlDoc.doc.Find(".repository.releases .ui.compact.menu a.active.item[href$='/releases']")
+ assert.Contains(t, releaseTab.Text(), strconv.Itoa(count)+" release") // Could be "1 release" or "4 releases"
+ }
+
+ releaseList := htmlDoc.doc.Find("#release-list > li")
+ assert.EqualValues(t, count, releaseList.Length())
+}
+
+func TestViewReleases(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+ req := NewRequest(t, "GET", "/user2/repo1/releases")
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // if CI is too slow this test fail, so lets wait a bit
+ time.Sleep(time.Millisecond * 100)
+}
+
+func TestViewReleasesNoLogin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/releases")
+ MakeRequest(t, req, http.StatusOK)
+}
+
+func TestCreateRelease(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+ createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", false, false)
+
+ checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").TrString("repo.release.stable"), 4)
+}
+
+func TestDeleteRelease(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 57, OwnerName: "user2", LowerName: "repo-release"})
+ release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{TagName: "v2.0"})
+ assert.False(t, release.IsTag)
+
+ // Using the ID of a comment that does not belong to the repository must fail
+ session5 := loginUser(t, "user5")
+ otherRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerName: "user5", LowerName: "repo4"})
+
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("%s/releases/delete?id=%d", otherRepo.Link(), release.ID), map[string]string{
+ "_csrf": GetCSRF(t, session5, otherRepo.Link()),
+ })
+ session5.MakeRequest(t, req, http.StatusNotFound)
+
+ session := loginUser(t, "user2")
+ req = NewRequestWithValues(t, "POST", fmt.Sprintf("%s/releases/delete?id=%d", repo.Link(), release.ID), map[string]string{
+ "_csrf": GetCSRF(t, session, repo.Link()),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+ release = unittest.AssertExistsAndLoadBean(t, &repo_model.Release{ID: release.ID})
+
+ if assert.True(t, release.IsTag) {
+ req = NewRequestWithValues(t, "POST", fmt.Sprintf("%s/tags/delete?id=%d", otherRepo.Link(), release.ID), map[string]string{
+ "_csrf": GetCSRF(t, session5, otherRepo.Link()),
+ })
+ session5.MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequestWithValues(t, "POST", fmt.Sprintf("%s/tags/delete?id=%d", repo.Link(), release.ID), map[string]string{
+ "_csrf": GetCSRF(t, session, repo.Link()),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ unittest.AssertNotExistsBean(t, &repo_model.Release{ID: release.ID})
+ }
+}
+
+func TestCreateReleasePreRelease(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+ createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", true, false)
+
+ checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").TrString("repo.release.prerelease"), 4)
+}
+
+func TestCreateReleaseDraft(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+ createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", false, true)
+
+ checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").TrString("repo.release.draft"), 4)
+}
+
+func TestCreateReleasePaging(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ oldAPIDefaultNum := setting.API.DefaultPagingNum
+ defer func() {
+ setting.API.DefaultPagingNum = oldAPIDefaultNum
+ }()
+ setting.API.DefaultPagingNum = 10
+
+ session := loginUser(t, "user2")
+ // Create enough releases to have paging
+ for i := 0; i < 12; i++ {
+ version := fmt.Sprintf("v0.0.%d", i)
+ createNewRelease(t, session, "/user2/repo1", version, version, false, false)
+ }
+ createNewRelease(t, session, "/user2/repo1", "v0.0.12", "v0.0.12", false, true)
+
+ checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.12", translation.NewLocale("en-US").TrString("repo.release.draft"), 10)
+
+ // Check that user4 does not see draft and still see 10 latest releases
+ session2 := loginUser(t, "user4")
+ checkLatestReleaseAndCount(t, session2, "/user2/repo1", "v0.0.11", translation.NewLocale("en-US").TrString("repo.release.stable"), 10)
+}
+
+func TestViewReleaseListNoLogin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 57, OwnerName: "user2", LowerName: "repo-release"})
+
+ link := repo.Link() + "/releases"
+
+ req := NewRequest(t, "GET", link)
+ rsp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, rsp.Body)
+ releases := htmlDoc.Find("#release-list li.ui.grid")
+ assert.Equal(t, 5, releases.Length())
+
+ links := make([]string, 0, 5)
+ commitsToMain := make([]string, 0, 5)
+ releases.Each(func(i int, s *goquery.Selection) {
+ link, exist := s.Find(".release-list-title a").Attr("href")
+ if !exist {
+ return
+ }
+ links = append(links, link)
+
+ commitsToMain = append(commitsToMain, s.Find(".ahead > a").Text())
+ })
+
+ assert.EqualValues(t, []string{
+ "/user2/repo-release/releases/tag/empty-target-branch",
+ "/user2/repo-release/releases/tag/non-existing-target-branch",
+ "/user2/repo-release/releases/tag/v2.0",
+ "/user2/repo-release/releases/tag/v1.1",
+ "/user2/repo-release/releases/tag/v1.0",
+ }, links)
+ assert.EqualValues(t, []string{
+ "1 commits", // like v1.1
+ "1 commits", // like v1.1
+ "0 commits",
+ "1 commits", // should be 3 commits ahead and 2 commits behind, but not implemented yet
+ "3 commits",
+ }, commitsToMain)
+}
+
+func TestViewSingleReleaseNoLogin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo-release/releases/tag/v1.0")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ // check the "number of commits to main since this release"
+ releaseList := htmlDoc.doc.Find("#release-list .ahead > a")
+ assert.EqualValues(t, 1, releaseList.Length())
+ assert.EqualValues(t, "3 commits", releaseList.First().Text())
+}
+
+func TestViewReleaseListLogin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ link := repo.Link() + "/releases"
+
+ session := loginUser(t, "user1")
+ req := NewRequest(t, "GET", link)
+ rsp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, rsp.Body)
+ releases := htmlDoc.Find("#release-list li.ui.grid")
+ assert.Equal(t, 3, releases.Length())
+
+ links := make([]string, 0, 5)
+ releases.Each(func(i int, s *goquery.Selection) {
+ link, exist := s.Find(".release-list-title a").Attr("href")
+ if !exist {
+ return
+ }
+ links = append(links, link)
+ })
+
+ assert.EqualValues(t, []string{
+ "/user2/repo1/releases/tag/draft-release",
+ "/user2/repo1/releases/tag/v1.0",
+ "/user2/repo1/releases/tag/v1.1",
+ }, links)
+}
+
+func TestReleaseOnCommit(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+ createNewReleaseTarget(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", "65f1bf27bc3bf70f64657658635e66094edbcb4d", false, false)
+
+ checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", translation.NewLocale("en-US").TrString("repo.release.stable"), 4)
+}
+
+func TestViewTagsList(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ link := repo.Link() + "/tags"
+
+ session := loginUser(t, "user1")
+ req := NewRequest(t, "GET", link)
+ rsp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, rsp.Body)
+ tags := htmlDoc.Find(".tag-list tr")
+ assert.Equal(t, 3, tags.Length())
+
+ tagNames := make([]string, 0, 5)
+ tags.Each(func(i int, s *goquery.Selection) {
+ tagNames = append(tagNames, s.Find(".tag a.tw-flex.tw-items-center").Text())
+ })
+
+ assert.EqualValues(t, []string{"v1.0", "delete-tag", "v1.1"}, tagNames)
+}
+
+func TestDownloadReleaseAttachment(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ tests.PrepareAttachmentsStorage(t)
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+
+ url := repo.Link() + "/releases/download/v1.1/README.md"
+
+ req := NewRequest(t, "GET", url)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", url)
+ session := loginUser(t, "user2")
+ session.MakeRequest(t, req, http.StatusOK)
+}
+
+func TestReleaseHideArchiveLinksUI(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ release := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{TagName: "v2.0"})
+
+ require.NoError(t, release.LoadAttributes(db.DefaultContext))
+
+ session := loginUser(t, release.Repo.OwnerName)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ zipURL := fmt.Sprintf("%s/archive/%s.zip", release.Repo.Link(), release.TagName)
+ tarGzURL := fmt.Sprintf("%s/archive/%s.tar.gz", release.Repo.Link(), release.TagName)
+
+ resp := session.MakeRequest(t, NewRequest(t, "GET", release.HTMLURL()), http.StatusOK)
+ body := resp.Body.String()
+ assert.Contains(t, body, zipURL)
+ assert.Contains(t, body, tarGzURL)
+
+ hideArchiveLinks := true
+
+ req := NewRequestWithJSON(t, "PATCH", release.APIURL(), &api.EditReleaseOption{
+ HideArchiveLinks: &hideArchiveLinks,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ resp = session.MakeRequest(t, NewRequest(t, "GET", release.HTMLURL()), http.StatusOK)
+ body = resp.Body.String()
+ assert.NotContains(t, body, zipURL)
+ assert.NotContains(t, body, tarGzURL)
+}
diff --git a/tests/integration/remote_test.go b/tests/integration/remote_test.go
new file mode 100644
index 0000000..c59b4c7
--- /dev/null
+++ b/tests/integration/remote_test.go
@@ -0,0 +1,206 @@
+// Copyright Earl Warren <contact@earl-warren.org>
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/test"
+ remote_service "code.gitea.io/gitea/services/remote"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/markbates/goth"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRemote_MaybePromoteUserSuccess(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ //
+ // OAuth2 authentication source GitLab
+ //
+ gitlabName := "gitlab"
+ _ = addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
+ //
+ // Remote authentication source matching the GitLab authentication source
+ //
+ remoteName := "remote"
+ remote := createRemoteAuthSource(t, remoteName, "http://mygitlab.eu", gitlabName)
+
+ //
+ // Create a user as if it had previously been created by the remote
+ // authentication source.
+ //
+ gitlabUserID := "5678"
+ gitlabEmail := "gitlabuser@example.com"
+ userBeforeSignIn := &user_model.User{
+ Name: "gitlabuser",
+ Type: user_model.UserTypeRemoteUser,
+ LoginType: auth_model.Remote,
+ LoginSource: remote.ID,
+ LoginName: gitlabUserID,
+ }
+ defer createUser(context.Background(), t, userBeforeSignIn)()
+
+ //
+ // A request for user information sent to Goth will return a
+ // goth.User exactly matching the user created above.
+ //
+ defer mockCompleteUserAuth(func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
+ return goth.User{
+ Provider: gitlabName,
+ UserID: gitlabUserID,
+ Email: gitlabEmail,
+ }, nil
+ })()
+ req := NewRequest(t, "GET", fmt.Sprintf("/user/oauth2/%s/callback?code=XYZ&state=XYZ", gitlabName))
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, "/", test.RedirectURL(resp))
+ userAfterSignIn := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userBeforeSignIn.ID})
+
+ // both are about the same user
+ assert.Equal(t, userBeforeSignIn.ID, userAfterSignIn.ID)
+ // the login time was updated, proof the login succeeded
+ assert.Greater(t, userAfterSignIn.LastLoginUnix, userBeforeSignIn.LastLoginUnix)
+ // the login type was promoted from Remote to OAuth2
+ assert.Equal(t, auth_model.Remote, userBeforeSignIn.LoginType)
+ assert.Equal(t, auth_model.OAuth2, userAfterSignIn.LoginType)
+ // the OAuth2 email was used to set the missing user email
+ assert.Equal(t, "", userBeforeSignIn.Email)
+ assert.Equal(t, gitlabEmail, userAfterSignIn.Email)
+}
+
+func TestRemote_MaybePromoteUserFail(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ ctx := context.Background()
+ //
+ // OAuth2 authentication source GitLab
+ //
+ gitlabName := "gitlab"
+ gitlabSource := addAuthSource(t, authSourcePayloadGitLabCustom(gitlabName))
+ //
+ // Remote authentication source matching the GitLab authentication source
+ //
+ remoteName := "remote"
+ remoteSource := createRemoteAuthSource(t, remoteName, "http://mygitlab.eu", gitlabName)
+
+ {
+ promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, &auth_model.Source{}, "", "")
+ require.NoError(t, err)
+ assert.False(t, promoted)
+ assert.Equal(t, remote_service.ReasonNotAuth2, reason)
+ }
+
+ {
+ remoteSource.Type = auth_model.OAuth2
+ promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, remoteSource, "", "")
+ require.NoError(t, err)
+ assert.False(t, promoted)
+ assert.Equal(t, remote_service.ReasonBadAuth2, reason)
+ remoteSource.Type = auth_model.Remote
+ }
+
+ {
+ promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, "unknownloginname", "")
+ require.NoError(t, err)
+ assert.False(t, promoted)
+ assert.Equal(t, remote_service.ReasonLoginNameNotExists, reason)
+ }
+
+ {
+ remoteUserID := "844"
+ remoteUser := &user_model.User{
+ Name: "withmailuser",
+ Type: user_model.UserTypeRemoteUser,
+ LoginType: auth_model.Remote,
+ LoginSource: remoteSource.ID,
+ LoginName: remoteUserID,
+ Email: "some@example.com",
+ }
+ defer createUser(context.Background(), t, remoteUser)()
+ promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, "")
+ require.NoError(t, err)
+ assert.False(t, promoted)
+ assert.Equal(t, remote_service.ReasonEmailIsSet, reason)
+ }
+
+ {
+ remoteUserID := "7464"
+ nonexistentloginsource := int64(4344)
+ remoteUser := &user_model.User{
+ Name: "badsourceuser",
+ Type: user_model.UserTypeRemoteUser,
+ LoginType: auth_model.Remote,
+ LoginSource: nonexistentloginsource,
+ LoginName: remoteUserID,
+ }
+ defer createUser(context.Background(), t, remoteUser)()
+ promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, "")
+ require.NoError(t, err)
+ assert.False(t, promoted)
+ assert.Equal(t, remote_service.ReasonNoSource, reason)
+ }
+
+ {
+ remoteUserID := "33335678"
+ remoteUser := &user_model.User{
+ Name: "badremoteuser",
+ Type: user_model.UserTypeRemoteUser,
+ LoginType: auth_model.Remote,
+ LoginSource: gitlabSource.ID,
+ LoginName: remoteUserID,
+ }
+ defer createUser(context.Background(), t, remoteUser)()
+ promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, "")
+ require.NoError(t, err)
+ assert.False(t, promoted)
+ assert.Equal(t, remote_service.ReasonSourceWrongType, reason)
+ }
+
+ {
+ unrelatedName := "unrelated"
+ unrelatedSource := addAuthSource(t, authSourcePayloadGitHubCustom(unrelatedName))
+ assert.NotNil(t, unrelatedSource)
+
+ remoteUserID := "488484"
+ remoteEmail := "4848484@example.com"
+ remoteUser := &user_model.User{
+ Name: "unrelateduser",
+ Type: user_model.UserTypeRemoteUser,
+ LoginType: auth_model.Remote,
+ LoginSource: remoteSource.ID,
+ LoginName: remoteUserID,
+ }
+ defer createUser(context.Background(), t, remoteUser)()
+ promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, unrelatedSource, remoteUserID, remoteEmail)
+ require.NoError(t, err)
+ assert.False(t, promoted)
+ assert.Equal(t, remote_service.ReasonNoMatch, reason)
+ }
+
+ {
+ remoteUserID := "5678"
+ remoteEmail := "gitlabuser@example.com"
+ remoteUser := &user_model.User{
+ Name: "remoteuser",
+ Type: user_model.UserTypeRemoteUser,
+ LoginType: auth_model.Remote,
+ LoginSource: remoteSource.ID,
+ LoginName: remoteUserID,
+ }
+ defer createUser(context.Background(), t, remoteUser)()
+ promoted, reason, err := remote_service.MaybePromoteRemoteUser(ctx, gitlabSource, remoteUserID, remoteEmail)
+ require.NoError(t, err)
+ assert.True(t, promoted)
+ assert.Equal(t, remote_service.ReasonPromoted, reason)
+ }
+}
diff --git a/tests/integration/rename_branch_test.go b/tests/integration/rename_branch_test.go
new file mode 100644
index 0000000..a96cbf5
--- /dev/null
+++ b/tests/integration/rename_branch_test.go
@@ -0,0 +1,176 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ gitea_context "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRenameBranch(t *testing.T) {
+ onGiteaRun(t, testRenameBranch)
+}
+
+func testRenameBranch(t *testing.T, u *url.URL) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo.ID, Name: "master"})
+
+ // get branch setting page
+ session := loginUser(t, "user2")
+ t.Run("Normal", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/rename_branch", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings/branches"),
+ "from": "master",
+ "to": "main",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // check new branch link
+ req = NewRequest(t, "GET", "/user2/repo1/src/branch/main/README.md")
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // check old branch link
+ req = NewRequest(t, "GET", "/user2/repo1/src/branch/master/README.md")
+ resp := session.MakeRequest(t, req, http.StatusSeeOther)
+ location := resp.Header().Get("Location")
+ assert.Equal(t, "/user2/repo1/src/branch/main/README.md", location)
+
+ // check db
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ assert.Equal(t, "main", repo1.DefaultBranch)
+ })
+
+ t.Run("Database synchronization", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/rename_branch", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings/branches"),
+ "from": "master",
+ "to": "main",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // check new branch link
+ req = NewRequestWithValues(t, "GET", "/user2/repo1/src/branch/main/README.md", nil)
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // check old branch link
+ req = NewRequestWithValues(t, "GET", "/user2/repo1/src/branch/master/README.md", nil)
+ resp := session.MakeRequest(t, req, http.StatusSeeOther)
+ location := resp.Header().Get("Location")
+ assert.Equal(t, "/user2/repo1/src/branch/main/README.md", location)
+
+ // check db
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ assert.Equal(t, "main", repo1.DefaultBranch)
+
+ // create branch1
+ csrf := GetCSRF(t, session, "/user2/repo1/src/branch/main")
+
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/branches/_new/branch/main", map[string]string{
+ "_csrf": csrf,
+ "new_branch_name": "branch1",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ branch1 := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch1"})
+ assert.Equal(t, "branch1", branch1.Name)
+
+ // create branch2
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/branches/_new/branch/main", map[string]string{
+ "_csrf": csrf,
+ "new_branch_name": "branch2",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ branch2 := unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch2"})
+ assert.Equal(t, "branch2", branch2.Name)
+
+ // rename branch2 to branch1
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/rename_branch", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings/branches"),
+ "from": "branch2",
+ "to": "branch1",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.Contains(t, flashCookie.Value, "error")
+
+ branch2 = unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch2"})
+ assert.Equal(t, "branch2", branch2.Name)
+ branch1 = unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch1"})
+ assert.Equal(t, "branch1", branch1.Name)
+
+ // delete branch1
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/branches/delete", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings/branches"),
+ "name": "branch1",
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+ branch2 = unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch2"})
+ assert.Equal(t, "branch2", branch2.Name)
+ branch1 = unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch1"})
+ assert.True(t, branch1.IsDeleted) // virtual deletion
+
+ // rename branch2 to branch1 again
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/rename_branch", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings/branches"),
+ "from": "branch2",
+ "to": "branch1",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie = session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.Contains(t, flashCookie.Value, "success")
+
+ unittest.AssertNotExistsBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch2"})
+ branch1 = unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: repo1.ID, Name: "branch1"})
+ assert.Equal(t, "branch1", branch1.Name)
+ })
+
+ t.Run("Protected branch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Add protected branch
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings/branches/edit"),
+ "rule_name": "*",
+ "enable_push": "true",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // Verify it was added.
+ unittest.AssertExistsIf(t, true, &git_model.ProtectedBranch{RuleName: "*", RepoID: repo.ID})
+
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/rename_branch", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1/settings/branches"),
+ "from": "main",
+ "to": "main2",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.EqualValues(t, "error%3DCannot%2Brename%2Bbranch%2Bmain2%2Bbecause%2Bit%2Bis%2Ba%2Bprotected%2Bbranch.", flashCookie.Value)
+
+ // Verify it didn't change.
+ repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ assert.Equal(t, "main", repo1.DefaultBranch)
+ })
+}
diff --git a/tests/integration/repo_activity_test.go b/tests/integration/repo_activity_test.go
new file mode 100644
index 0000000..c1177fa
--- /dev/null
+++ b/tests/integration/repo_activity_test.go
@@ -0,0 +1,216 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/test"
+ repo_service "code.gitea.io/gitea/services/repository"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepoActivity(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ session := loginUser(t, "user1")
+
+ // Create PRs (1 merged & 2 proposed)
+ testRepoFork(t, session, "user2", "repo1", "user1", "repo1")
+ testEditFile(t, session, "user1", "repo1", "master", "README.md", "Hello, World (Edited)\n")
+ resp := testPullCreate(t, session, "user1", "repo1", false, "master", "master", "This is a pull title")
+ elem := strings.Split(test.RedirectURL(resp), "/")
+ assert.EqualValues(t, "pulls", elem[3])
+ testPullMerge(t, session, elem[1], elem[2], elem[4], repo_model.MergeStyleMerge, false)
+
+ testEditFileToNewBranch(t, session, "user1", "repo1", "master", "feat/better_readme", "README.md", "Hello, World (Edited Again)\n")
+ testPullCreate(t, session, "user1", "repo1", false, "master", "feat/better_readme", "This is a pull title")
+
+ testEditFileToNewBranch(t, session, "user1", "repo1", "master", "feat/much_better_readme", "README.md", "Hello, World (Edited More)\n")
+ testPullCreate(t, session, "user1", "repo1", false, "master", "feat/much_better_readme", "This is a pull title")
+
+ // Create issues (3 new issues)
+ testNewIssue(t, session, "user2", "repo1", "Issue 1", "Description 1")
+ testNewIssue(t, session, "user2", "repo1", "Issue 2", "Description 2")
+ testNewIssue(t, session, "user2", "repo1", "Issue 3", "Description 3")
+
+ // Create releases (1 release, 1 pre-release, 1 release-draft, 1 tag)
+ createNewRelease(t, session, "/user2/repo1", "v1.0.0", "v1 Release", false, false)
+ createNewRelease(t, session, "/user2/repo1", "v0.1.0", "v0.1 Pre-release", true, false)
+ createNewRelease(t, session, "/user2/repo1", "v2.0.0", "v2 Release-Draft", false, true)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ createNewTagUsingAPI(t, token, "user2", "repo1", "v3.0.0", "master", "Tag message")
+
+ // Open Activity page and check stats
+ req := NewRequest(t, "GET", "/user2/repo1/activity")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ // Should be 3 published releases
+ list := htmlDoc.doc.Find("#published-releases").Next().Find("p.desc")
+ assert.Len(t, list.Nodes, 3)
+ var labels []string
+ var titles []string
+ list.Each(func(i int, s *goquery.Selection) {
+ labels = append(labels, s.Find(".label").Text())
+ titles = append(titles, s.Find(".title").Text())
+ })
+ sort.Strings(labels)
+ sort.Strings(titles)
+ assert.Equal(t, []string{"Pre-release", "Release", "Tag"}, labels)
+ assert.Equal(t, []string{"", "v0.1 Pre-release", "v1 Release"}, titles)
+
+ // Should be 1 merged pull request
+ list = htmlDoc.doc.Find("#merged-pull-requests").Next().Find("p.desc")
+ assert.Len(t, list.Nodes, 1)
+ assert.Equal(t, "Merged", list.Find(".label").Text())
+
+ // Should be 2 proposed pull requests
+ list = htmlDoc.doc.Find("#proposed-pull-requests").Next().Find("p.desc")
+ assert.Len(t, list.Nodes, 2)
+ assert.Equal(t, "Proposed", list.Find(".label").First().Text())
+
+ // Should be 0 closed issues
+ list = htmlDoc.doc.Find("#closed-issues").Next().Find("p.desc")
+ assert.Empty(t, list.Nodes)
+
+ // Should be 3 new issues
+ list = htmlDoc.doc.Find("#new-issues").Next().Find("p.desc")
+ assert.Len(t, list.Nodes, 3)
+ assert.Equal(t, "Opened", list.Find(".label").First().Text())
+ })
+}
+
+func TestRepoActivityAllUnitsDisabled(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
+ session := loginUser(t, user.Name)
+
+ unit_model.LoadUnitConfig()
+
+ // Create a repo, with no unit enabled.
+ repo, err := repo_service.CreateRepository(db.DefaultContext, user, user, repo_service.CreateRepoOptions{
+ Name: "empty-repo",
+ AutoInit: false,
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, repo)
+
+ enabledUnits := make([]repo_model.RepoUnit, 0)
+ disabledUnits := []unit_model.Type{unit_model.TypeCode, unit_model.TypeIssues, unit_model.TypePullRequests, unit_model.TypeReleases}
+ err = repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, enabledUnits, disabledUnits)
+ require.NoError(t, err)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/activity", repo.Link()))
+ session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/contributors", repo.Link()))
+ session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/code-frequency", repo.Link()))
+ session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/recent-commits", repo.Link()))
+ session.MakeRequest(t, req, http.StatusNotFound)
+}
+
+func TestRepoActivityOnlyCodeUnitWithEmptyRepo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
+ session := loginUser(t, user.Name)
+
+ unit_model.LoadUnitConfig()
+
+ // Create a empty repo, with only code unit enabled.
+ repo, err := repo_service.CreateRepository(db.DefaultContext, user, user, repo_service.CreateRepoOptions{
+ Name: "empty-repo",
+ AutoInit: false,
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, repo)
+
+ enabledUnits := make([]repo_model.RepoUnit, 1)
+ enabledUnits[0] = repo_model.RepoUnit{RepoID: repo.ID, Type: unit_model.TypeCode}
+ disabledUnits := []unit_model.Type{unit_model.TypeIssues, unit_model.TypePullRequests, unit_model.TypeReleases}
+ err = repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, enabledUnits, disabledUnits)
+ require.NoError(t, err)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/activity", repo.Link()))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // Git repo empty so no activity for contributors etc
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/contributors", repo.Link()))
+ session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/code-frequency", repo.Link()))
+ session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/recent-commits", repo.Link()))
+ session.MakeRequest(t, req, http.StatusNotFound)
+}
+
+func TestRepoActivityOnlyCodeUnitWithNonEmptyRepo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
+ session := loginUser(t, user.Name)
+
+ unit_model.LoadUnitConfig()
+
+ // Create a repo, with only code unit enabled.
+ repo, _, f := tests.CreateDeclarativeRepo(t, user, "", []unit_model.Type{unit_model.TypeCode}, nil, nil)
+ defer f()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/activity", repo.Link()))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // Git repo not empty so activity for contributors etc
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/contributors", repo.Link()))
+ session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/code-frequency", repo.Link()))
+ session.MakeRequest(t, req, http.StatusOK)
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/recent-commits", repo.Link()))
+ session.MakeRequest(t, req, http.StatusOK)
+}
+
+func TestRepoActivityOnlyIssuesUnit(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
+ session := loginUser(t, user.Name)
+
+ unit_model.LoadUnitConfig()
+
+ // Create a empty repo, with only code unit enabled.
+ repo, err := repo_service.CreateRepository(db.DefaultContext, user, user, repo_service.CreateRepoOptions{
+ Name: "empty-repo",
+ AutoInit: false,
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, repo)
+
+ enabledUnits := make([]repo_model.RepoUnit, 1)
+ enabledUnits[0] = repo_model.RepoUnit{RepoID: repo.ID, Type: unit_model.TypeIssues}
+ disabledUnits := []unit_model.Type{unit_model.TypeCode, unit_model.TypePullRequests, unit_model.TypeReleases}
+ err = repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, enabledUnits, disabledUnits)
+ require.NoError(t, err)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/activity", repo.Link()))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // Git repo empty so no activity for contributors etc
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/contributors", repo.Link()))
+ session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/code-frequency", repo.Link()))
+ session.MakeRequest(t, req, http.StatusNotFound)
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/activity/recent-commits", repo.Link()))
+ session.MakeRequest(t, req, http.StatusNotFound)
+}
diff --git a/tests/integration/repo_archive_test.go b/tests/integration/repo_archive_test.go
new file mode 100644
index 0000000..75fe78e
--- /dev/null
+++ b/tests/integration/repo_archive_test.go
@@ -0,0 +1,34 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "io"
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/routers/web"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepoDownloadArchive(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.EnableGzip, true)()
+ defer test.MockVariableValue(&web.GzipMinSize, 10)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ req := NewRequest(t, "GET", "/user2/repo1/archive/master.zip")
+ req.Header.Set("Accept-Encoding", "gzip")
+ resp := MakeRequest(t, req, http.StatusOK)
+ bs, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ assert.Empty(t, resp.Header().Get("Content-Encoding"))
+ assert.Len(t, bs, 320)
+}
diff --git a/tests/integration/repo_archive_text_test.go b/tests/integration/repo_archive_text_test.go
new file mode 100644
index 0000000..e759246
--- /dev/null
+++ b/tests/integration/repo_archive_text_test.go
@@ -0,0 +1,78 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestArchiveText(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ testUser := "user2"
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: testUser})
+ session := loginUser(t, testUser)
+ testRepoName := "archived_repo"
+ tr := translation.NewLocale("en-US")
+ link := path.Join(testUser, testRepoName, "settings")
+
+ // Create test repo
+ _, _, f := tests.CreateDeclarativeRepo(t, user2, testRepoName, nil, nil, nil)
+ defer f()
+
+ // Test settings page
+ req := NewRequest(t, "GET", link)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ archivation := NewHTMLParser(t, resp.Body)
+ testRepoArchiveElements(t, tr, archivation, "archive")
+
+ // Archive repo
+ req = NewRequestWithValues(t, "POST", link, map[string]string{
+ "action": "archive",
+ "_csrf": GetCSRF(t, session, link),
+ })
+ _ = session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // Test settings page again
+ req = NewRequest(t, "GET", link)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ unarchivation := NewHTMLParser(t, resp.Body)
+ testRepoArchiveElements(t, tr, unarchivation, "unarchive")
+ })
+}
+
+func testRepoArchiveElements(t *testing.T, tr translation.Locale, doc *HTMLDoc, opType string) {
+ t.Helper()
+
+ // Test danger section
+ section := doc.Find(".danger.segment .flex-list .flex-item:has(.button[data-modal='#archive-repo-modal'])")
+ testRepoArchiveElement(t, tr, section, ".flex-item-title", opType+".header")
+ testRepoArchiveElement(t, tr, section, ".flex-item-body", opType+".text")
+ testRepoArchiveElement(t, tr, section, ".button", opType+".button")
+
+ // Test modal
+ modal := doc.Find("#archive-repo-modal")
+ testRepoArchiveElement(t, tr, modal, ".header", opType+".header")
+ testRepoArchiveElement(t, tr, modal, ".message", opType+".text")
+ testRepoArchiveElement(t, tr, modal, ".button.red", opType+".button")
+}
+
+func testRepoArchiveElement(t *testing.T, tr translation.Locale, doc *goquery.Selection, selector, op string) {
+ t.Helper()
+
+ element := doc.Find(selector).Text()
+ element = strings.TrimSpace(element)
+ assert.Equal(t, tr.TrString("repo.settings."+op), element)
+}
diff --git a/tests/integration/repo_badges_test.go b/tests/integration/repo_badges_test.go
new file mode 100644
index 0000000..a74d397
--- /dev/null
+++ b/tests/integration/repo_badges_test.go
@@ -0,0 +1,252 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ actions_model "code.gitea.io/gitea/models/actions"
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/services/release"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBadges(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ prep := func(t *testing.T) (*repo_model.Repository, func()) {
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ repo, _, f := tests.CreateDeclarativeRepo(t, owner, "",
+ []unit_model.Type{unit_model.TypeActions},
+ []unit_model.Type{unit_model.TypeIssues, unit_model.TypePullRequests, unit_model.TypeReleases},
+ []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: ".gitea/workflows/pr.yml",
+ ContentReader: strings.NewReader("name: pr\non:\n push:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
+ },
+ {
+ Operation: "create",
+ TreePath: ".gitea/workflows/self-test.yaml",
+ ContentReader: strings.NewReader("name: self-test\non:\n push:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
+ },
+ {
+ Operation: "create",
+ TreePath: ".gitea/workflows/tag-test.yaml",
+ ContentReader: strings.NewReader("name: tags\non:\n push:\n tags: '*'\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo helloworld\n"),
+ },
+ },
+ )
+ assert.Equal(t, 2, unittest.GetCount(t, &actions_model.ActionRun{RepoID: repo.ID}))
+
+ return repo, f
+ }
+
+ assertBadge := func(t *testing.T, resp *httptest.ResponseRecorder, badge string) {
+ t.Helper()
+
+ assert.Equal(t, fmt.Sprintf("https://img.shields.io/badge/%s", badge), test.RedirectURL(resp))
+ }
+
+ t.Run("Workflows", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ repo, f := prep(t)
+ defer f()
+
+ // Actions disabled
+ req := NewRequest(t, "GET", "/user2/repo1/badges/workflows/test.yaml/badge.svg")
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "test.yaml-Not%20found-crimson")
+
+ req = NewRequest(t, "GET", "/user2/repo1/badges/workflows/test.yaml/badge.svg?branch=no-such-branch")
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "test.yaml-Not%20found-crimson")
+
+ // Actions enabled
+ req = NewRequestf(t, "GET", "/user2/%s/badges/workflows/pr.yml/badge.svg", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "pr.yml-waiting-lightgrey")
+
+ req = NewRequestf(t, "GET", "/user2/%s/badges/workflows/pr.yml/badge.svg?branch=main", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "pr.yml-waiting-lightgrey")
+
+ req = NewRequestf(t, "GET", "/user2/%s/badges/workflows/pr.yml/badge.svg?branch=no-such-branch", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "pr.yml-Not%20found-crimson")
+
+ req = NewRequestf(t, "GET", "/user2/%s/badges/workflows/pr.yml/badge.svg?event=cron", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "pr.yml-Not%20found-crimson")
+
+ // Workflow with a dash in its name
+ req = NewRequestf(t, "GET", "/user2/%s/badges/workflows/self-test.yaml/badge.svg", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "self--test.yaml-waiting-lightgrey")
+
+ // GitHub compatibility
+ req = NewRequestf(t, "GET", "/user2/%s/actions/workflows/pr.yml/badge.svg", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "pr.yml-waiting-lightgrey")
+
+ req = NewRequestf(t, "GET", "/user2/%s/actions/workflows/pr.yml/badge.svg?branch=main", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "pr.yml-waiting-lightgrey")
+
+ req = NewRequestf(t, "GET", "/user2/%s/actions/workflows/pr.yml/badge.svg?branch=no-such-branch", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "pr.yml-Not%20found-crimson")
+
+ req = NewRequestf(t, "GET", "/user2/%s/actions/workflows/pr.yml/badge.svg?event=cron", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "pr.yml-Not%20found-crimson")
+
+ t.Run("tagged", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // With no tags, the workflow has no runs, and isn't found
+ req := NewRequestf(t, "GET", "/user2/%s/actions/workflows/tag-test.yaml/badge.svg", repo.Name)
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "tag--test.yaml-Not%20found-crimson")
+
+ // Lets create a tag!
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ err := release.CreateNewTag(git.DefaultContext, owner, repo, "main", "v1", "message")
+ require.NoError(t, err)
+
+ // Now the workflow is waiting
+ req = NewRequestf(t, "GET", "/user2/%s/actions/workflows/tag-test.yaml/badge.svg", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "tag--test.yaml-waiting-lightgrey")
+ })
+ })
+
+ t.Run("Stars", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/badges/stars.svg")
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+
+ assertBadge(t, resp, "stars-0-blue")
+
+ t.Run("disabled stars", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.Repository.DisableStars, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+
+ t.Run("Issues", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ repo, f := prep(t)
+ defer f()
+
+ // Issues enabled
+ req := NewRequest(t, "GET", "/user2/repo1/badges/issues.svg")
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "issues-2-blue")
+
+ req = NewRequest(t, "GET", "/user2/repo1/badges/issues/open.svg")
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "issues-1%20open-blue")
+
+ req = NewRequest(t, "GET", "/user2/repo1/badges/issues/closed.svg")
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "issues-1%20closed-blue")
+
+ // Issues disabled
+ req = NewRequestf(t, "GET", "/user2/%s/badges/issues.svg", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "issues-Not%20found-crimson")
+
+ req = NewRequestf(t, "GET", "/user2/%s/badges/issues/open.svg", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "issues-Not%20found-crimson")
+
+ req = NewRequestf(t, "GET", "/user2/%s/badges/issues/closed.svg", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "issues-Not%20found-crimson")
+ })
+
+ t.Run("Pulls", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ repo, f := prep(t)
+ defer f()
+
+ // Pull requests enabled
+ req := NewRequest(t, "GET", "/user2/repo1/badges/pulls.svg")
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "pulls-3-blue")
+
+ req = NewRequest(t, "GET", "/user2/repo1/badges/pulls/open.svg")
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "pulls-3%20open-blue")
+
+ req = NewRequest(t, "GET", "/user2/repo1/badges/pulls/closed.svg")
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "pulls-0%20closed-blue")
+
+ // Pull requests disabled
+ req = NewRequestf(t, "GET", "/user2/%s/badges/pulls.svg", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "pulls-Not%20found-crimson")
+
+ req = NewRequestf(t, "GET", "/user2/%s/badges/pulls/open.svg", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "pulls-Not%20found-crimson")
+
+ req = NewRequestf(t, "GET", "/user2/%s/badges/pulls/closed.svg", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "pulls-Not%20found-crimson")
+ })
+
+ t.Run("Release", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ repo, f := prep(t)
+ defer f()
+
+ req := NewRequest(t, "GET", "/user2/repo1/badges/release.svg")
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "release-v1.1-blue")
+
+ req = NewRequestf(t, "GET", "/user2/%s/badges/release.svg", repo.Name)
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "release-Not%20found-crimson")
+
+ t.Run("Dashes in the name", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, repo.Owner.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ err := release.CreateNewTag(git.DefaultContext, repo.Owner, repo, "main", "repo-name-2.0", "dash in the tag name")
+ require.NoError(t, err)
+ createNewReleaseUsingAPI(t, token, repo.Owner, repo, "repo-name-2.0", "main", "dashed release", "dashed release")
+
+ req := NewRequestf(t, "GET", "/user2/%s/badges/release.svg", repo.Name)
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ assertBadge(t, resp, "release-repo--name--2.0-blue")
+ })
+ })
+ })
+}
diff --git a/tests/integration/repo_branch_test.go b/tests/integration/repo_branch_test.go
new file mode 100644
index 0000000..df9ea9a
--- /dev/null
+++ b/tests/integration/repo_branch_test.go
@@ -0,0 +1,206 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "path"
+ "strconv"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/modules/translation"
+ repo_service "code.gitea.io/gitea/services/repository"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func testCreateBranch(t testing.TB, session *TestSession, user, repo, oldRefSubURL, newBranchName string, expectedStatus int) string {
+ var csrf string
+ if expectedStatus == http.StatusNotFound {
+ csrf = GetCSRF(t, session, path.Join(user, repo, "src/branch/master"))
+ } else {
+ csrf = GetCSRF(t, session, path.Join(user, repo, "src", oldRefSubURL))
+ }
+ req := NewRequestWithValues(t, "POST", path.Join(user, repo, "branches/_new", oldRefSubURL), map[string]string{
+ "_csrf": csrf,
+ "new_branch_name": newBranchName,
+ })
+ resp := session.MakeRequest(t, req, expectedStatus)
+ if expectedStatus != http.StatusSeeOther {
+ return ""
+ }
+ return test.RedirectURL(resp)
+}
+
+func TestCreateBranch(t *testing.T) {
+ onGiteaRun(t, testCreateBranches)
+}
+
+func testCreateBranches(t *testing.T, giteaURL *url.URL) {
+ tests := []struct {
+ OldRefSubURL string
+ NewBranch string
+ CreateRelease string
+ FlashMessage string
+ ExpectedStatus int
+ CheckBranch bool
+ }{
+ {
+ OldRefSubURL: "branch/master",
+ NewBranch: "feature/test1",
+ ExpectedStatus: http.StatusSeeOther,
+ FlashMessage: translation.NewLocale("en-US").TrString("repo.branch.create_success", "feature/test1"),
+ CheckBranch: true,
+ },
+ {
+ OldRefSubURL: "branch/master",
+ NewBranch: "",
+ ExpectedStatus: http.StatusSeeOther,
+ FlashMessage: translation.NewLocale("en-US").TrString("form.NewBranchName") + translation.NewLocale("en-US").TrString("form.require_error"),
+ },
+ {
+ OldRefSubURL: "branch/master",
+ NewBranch: "feature=test1",
+ ExpectedStatus: http.StatusSeeOther,
+ FlashMessage: translation.NewLocale("en-US").TrString("repo.branch.create_success", "feature=test1"),
+ CheckBranch: true,
+ },
+ {
+ OldRefSubURL: "branch/master",
+ NewBranch: strings.Repeat("b", 101),
+ ExpectedStatus: http.StatusSeeOther,
+ FlashMessage: translation.NewLocale("en-US").TrString("form.NewBranchName") + translation.NewLocale("en-US").TrString("form.max_size_error", "100"),
+ },
+ {
+ OldRefSubURL: "branch/master",
+ NewBranch: "master",
+ ExpectedStatus: http.StatusSeeOther,
+ FlashMessage: translation.NewLocale("en-US").TrString("repo.branch.branch_already_exists", "master"),
+ },
+ {
+ OldRefSubURL: "branch/master",
+ NewBranch: "master/test",
+ ExpectedStatus: http.StatusSeeOther,
+ FlashMessage: translation.NewLocale("en-US").TrString("repo.branch.branch_name_conflict", "master/test", "master"),
+ },
+ {
+ OldRefSubURL: "commit/acd1d892867872cb47f3993468605b8aa59aa2e0",
+ NewBranch: "feature/test2",
+ ExpectedStatus: http.StatusNotFound,
+ },
+ {
+ OldRefSubURL: "commit/65f1bf27bc3bf70f64657658635e66094edbcb4d",
+ NewBranch: "feature/test3",
+ ExpectedStatus: http.StatusSeeOther,
+ FlashMessage: translation.NewLocale("en-US").TrString("repo.branch.create_success", "feature/test3"),
+ CheckBranch: true,
+ },
+ {
+ OldRefSubURL: "branch/master",
+ NewBranch: "v1.0.0",
+ CreateRelease: "v1.0.0",
+ ExpectedStatus: http.StatusSeeOther,
+ FlashMessage: translation.NewLocale("en-US").TrString("repo.branch.tag_collision", "v1.0.0"),
+ },
+ {
+ OldRefSubURL: "tag/v1.0.0",
+ NewBranch: "feature/test4",
+ CreateRelease: "v1.0.1",
+ ExpectedStatus: http.StatusSeeOther,
+ FlashMessage: translation.NewLocale("en-US").TrString("repo.branch.create_success", "feature/test4"),
+ CheckBranch: true,
+ },
+ }
+
+ session := loginUser(t, "user2")
+ for _, test := range tests {
+ if test.CheckBranch {
+ unittest.AssertNotExistsBean(t, &git_model.Branch{RepoID: 1, Name: test.NewBranch})
+ }
+ if test.CreateRelease != "" {
+ createNewRelease(t, session, "/user2/repo1", test.CreateRelease, test.CreateRelease, false, false)
+ }
+ redirectURL := testCreateBranch(t, session, "user2", "repo1", test.OldRefSubURL, test.NewBranch, test.ExpectedStatus)
+ if test.ExpectedStatus == http.StatusSeeOther {
+ req := NewRequest(t, "GET", redirectURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Contains(t,
+ strings.TrimSpace(htmlDoc.doc.Find(".ui.message").Text()),
+ test.FlashMessage,
+ )
+ }
+ if test.CheckBranch {
+ unittest.AssertExistsAndLoadBean(t, &git_model.Branch{RepoID: 1, Name: test.NewBranch})
+ }
+ }
+}
+
+func TestCreateBranchInvalidCSRF(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+ req := NewRequestWithValues(t, "POST", "user2/repo1/branches/_new/branch/master", map[string]string{
+ "_csrf": "fake_csrf",
+ "new_branch_name": "test",
+ })
+ resp := session.MakeRequest(t, req, http.StatusBadRequest)
+ assert.Contains(t, resp.Body.String(), "Invalid CSRF token")
+}
+
+func TestDatabaseMissingABranch(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, URL *url.URL) {
+ session := loginUser(t, "user2")
+
+ // Create two branches
+ testCreateBranch(t, session, "user2", "repo1", "branch/master", "will-be-present", http.StatusSeeOther)
+ testCreateBranch(t, session, "user2", "repo1", "branch/master", "will-be-missing", http.StatusSeeOther)
+
+ // Run the repo branch sync, to ensure the db and git agree.
+ err2 := repo_service.AddAllRepoBranchesToSyncQueue(graceful.GetManager().ShutdownContext())
+ require.NoError(t, err2)
+
+ // Delete one branch from git only, leaving it in the database
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ cmd := git.NewCommand(db.DefaultContext, "branch", "-D").AddDynamicArguments("will-be-missing")
+ _, _, err := cmd.RunStdString(&git.RunOpts{Dir: repo.RepoPath()})
+ require.NoError(t, err)
+
+ // Verify that loading the repo's branches page works still, and that it
+ // reports at least three branches (master, will-be-present, and
+ // will-be-missing).
+ req := NewRequest(t, "GET", "/user2/repo1/branches")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ firstBranchCount, _ := strconv.Atoi(doc.Find(".repository-menu a[href*='/branches'] b").Text())
+ assert.GreaterOrEqual(t, firstBranchCount, 3)
+
+ // Run the repo branch sync again
+ err2 = repo_service.AddAllRepoBranchesToSyncQueue(graceful.GetManager().ShutdownContext())
+ require.NoError(t, err2)
+
+ // Verify that loading the repo's branches page works still, and that it
+ // reports one branch less than the first time.
+ //
+ // NOTE: This assumes that the branch counter on the web UI is out of
+ // date before the sync. If that problem gets resolved, we'll have to
+ // find another way to test that the syncing works.
+ req = NewRequest(t, "GET", "/user2/repo1/branches")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ doc = NewHTMLParser(t, resp.Body)
+ secondBranchCount, _ := strconv.Atoi(doc.Find(".repository-menu a[href*='/branches'] b").Text())
+ assert.Equal(t, firstBranchCount-1, secondBranchCount)
+ })
+}
diff --git a/tests/integration/repo_citation_test.go b/tests/integration/repo_citation_test.go
new file mode 100644
index 0000000..4f7e24e
--- /dev/null
+++ b/tests/integration/repo_citation_test.go
@@ -0,0 +1,81 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCitation(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ session := loginUser(t, user.LoginName)
+
+ t.Run("No citation", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo, _, f := tests.CreateDeclarativeRepo(t, user, "citation-no-citation", []unit_model.Type{unit_model.TypeCode}, nil, nil)
+ defer f()
+
+ testCitationButtonExists(t, session, repo, "", false)
+ })
+
+ t.Run("cff citation", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo, f := createRepoWithEmptyFile(t, user, "citation-cff", "CITATION.cff")
+ defer f()
+
+ testCitationButtonExists(t, session, repo, "CITATION.cff", true)
+ })
+
+ t.Run("bib citation", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo, f := createRepoWithEmptyFile(t, user, "citation-bib", "CITATION.bib")
+ defer f()
+
+ testCitationButtonExists(t, session, repo, "CITATION.bib", true)
+ })
+ })
+}
+
+func testCitationButtonExists(t *testing.T, session *TestSession, repo *repo_model.Repository, file string, exists bool) {
+ req := NewRequest(t, "GET", repo.HTMLURL())
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ doc.AssertElement(t, "#cite-repo-button", exists)
+
+ if exists {
+ href, exists := doc.doc.Find("#goto-citation-btn").Attr("href")
+ assert.True(t, exists)
+
+ assert.True(t, strings.HasSuffix(href, file))
+ }
+}
+
+func createRepoWithEmptyFile(t *testing.T, user *user_model.User, repoName, fileName string) (*repo_model.Repository, func()) {
+ repo, _, f := tests.CreateDeclarativeRepo(t, user, repoName, []unit_model.Type{unit_model.TypeCode}, nil, []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: fileName,
+ },
+ })
+
+ return repo, f
+}
diff --git a/tests/integration/repo_collaborator_test.go b/tests/integration/repo_collaborator_test.go
new file mode 100644
index 0000000..beeb950
--- /dev/null
+++ b/tests/integration/repo_collaborator_test.go
@@ -0,0 +1,37 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TestRepoCollaborators is a test for contents of Collaborators tab in the repo settings
+// It only covers a few elements and can be extended as needed
+func TestRepoCollaborators(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user2")
+
+ // Visit Collaborators tab of repo settings
+ response := session.MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/settings/collaboration"), http.StatusOK)
+ page := NewHTMLParser(t, response.Body).Find(".repo-setting-content")
+
+ // Veirfy header
+ assert.EqualValues(t, "Collaborators", strings.TrimSpace(page.Find("h4").Text()))
+
+ // Veirfy button text
+ page = page.Find("#repo-collab-form")
+ assert.EqualValues(t, "Add collaborator", strings.TrimSpace(page.Find("button.primary").Text()))
+
+ // Veirfy placeholder
+ placeholder, exists := page.Find("#search-user-box input").Attr("placeholder")
+ assert.True(t, exists)
+ assert.EqualValues(t, "Search users...", placeholder)
+ })
+}
diff --git a/tests/integration/repo_commits_search_test.go b/tests/integration/repo_commits_search_test.go
new file mode 100644
index 0000000..74ac25c
--- /dev/null
+++ b/tests/integration/repo_commits_search_test.go
@@ -0,0 +1,44 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func testRepoCommitsSearch(t *testing.T, query, commit string) {
+ session := loginUser(t, "user2")
+
+ // Request repository commits page
+ req := NewRequestf(t, "GET", "/user2/commits_search_test/commits/branch/master/search?q=%s", url.QueryEscape(query))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ sel := doc.doc.Find("#commits-table tbody tr td.sha a")
+ assert.EqualValues(t, commit, strings.TrimSpace(sel.Text()))
+}
+
+func TestRepoCommitsSearch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ testRepoCommitsSearch(t, "e8eabd", "")
+ testRepoCommitsSearch(t, "38a9cb", "")
+ testRepoCommitsSearch(t, "6e8e", "6e8eabd9a7")
+ testRepoCommitsSearch(t, "58e97", "58e97d1a24")
+ testRepoCommitsSearch(t, "[build]", "")
+ testRepoCommitsSearch(t, "author:alice", "6e8eabd9a7")
+ testRepoCommitsSearch(t, "author:alice 6e8ea", "6e8eabd9a7")
+ testRepoCommitsSearch(t, "committer:Tom", "58e97d1a24")
+ testRepoCommitsSearch(t, "author:bob commit-4", "58e97d1a24")
+ testRepoCommitsSearch(t, "author:bob commit after:2019-03-03", "58e97d1a24")
+ testRepoCommitsSearch(t, "committer:alice 6e8e before:2019-03-02", "6e8eabd9a7")
+ testRepoCommitsSearch(t, "committer:alice commit before:2019-03-02", "6e8eabd9a7")
+ testRepoCommitsSearch(t, "committer:alice author:tom commit before:2019-03-04 after:2019-03-02", "0a8499a22a")
+}
diff --git a/tests/integration/repo_commits_test.go b/tests/integration/repo_commits_test.go
new file mode 100644
index 0000000..e399898
--- /dev/null
+++ b/tests/integration/repo_commits_test.go
@@ -0,0 +1,206 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "path"
+ "sync"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/json"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepoCommits(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ // Request repository commits page
+ req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Attr("href")
+ assert.True(t, exists)
+ assert.NotEmpty(t, commitURL)
+}
+
+func doTestRepoCommitWithStatus(t *testing.T, state string, classes ...string) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ // Request repository commits page
+ req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ // Get first commit URL
+ commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Attr("href")
+ assert.True(t, exists)
+ assert.NotEmpty(t, commitURL)
+
+ // Call API to add status for commit
+ ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository)
+ t.Run("CreateStatus", doAPICreateCommitStatus(ctx, path.Base(commitURL), api.CreateStatusOption{
+ State: api.CommitStatusState(state),
+ TargetURL: "http://test.ci/",
+ Description: "",
+ Context: "testci",
+ }))
+
+ req = NewRequest(t, "GET", "/user2/repo1/commits/branch/master")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ doc = NewHTMLParser(t, resp.Body)
+ // Check if commit status is displayed in message column (.tippy-target to ignore the tippy trigger)
+ sel := doc.doc.Find("#commits-table tbody tr td.message .tippy-target .commit-status")
+ assert.Equal(t, 1, sel.Length())
+ for _, class := range classes {
+ assert.True(t, sel.HasClass(class))
+ }
+
+ // By SHA
+ req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/"+path.Base(commitURL)+"/statuses")
+ reqOne := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/"+path.Base(commitURL)+"/status")
+ testRepoCommitsWithStatus(t, session.MakeRequest(t, req, http.StatusOK), session.MakeRequest(t, reqOne, http.StatusOK), state)
+
+ // By short SHA
+ req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/"+path.Base(commitURL)[:10]+"/statuses")
+ reqOne = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/"+path.Base(commitURL)[:10]+"/status")
+ testRepoCommitsWithStatus(t, session.MakeRequest(t, req, http.StatusOK), session.MakeRequest(t, reqOne, http.StatusOK), state)
+
+ // By Ref
+ req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/master/statuses")
+ reqOne = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/master/status")
+ testRepoCommitsWithStatus(t, session.MakeRequest(t, req, http.StatusOK), session.MakeRequest(t, reqOne, http.StatusOK), state)
+ req = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/v1.1/statuses")
+ reqOne = NewRequest(t, "GET", "/api/v1/repos/user2/repo1/commits/v1.1/status")
+ testRepoCommitsWithStatus(t, session.MakeRequest(t, req, http.StatusOK), session.MakeRequest(t, reqOne, http.StatusOK), state)
+}
+
+func testRepoCommitsWithStatus(t *testing.T, resp, respOne *httptest.ResponseRecorder, state string) {
+ var statuses []*api.CommitStatus
+ require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &statuses))
+ var status api.CombinedStatus
+ require.NoError(t, json.Unmarshal(respOne.Body.Bytes(), &status))
+ assert.NotNil(t, status)
+
+ if assert.Len(t, statuses, 1) {
+ assert.Equal(t, api.CommitStatusState(state), statuses[0].State)
+ assert.Equal(t, setting.AppURL+"api/v1/repos/user2/repo1/statuses/65f1bf27bc3bf70f64657658635e66094edbcb4d", statuses[0].URL)
+ assert.Equal(t, "http://test.ci/", statuses[0].TargetURL)
+ assert.Equal(t, "", statuses[0].Description)
+ assert.Equal(t, "testci", statuses[0].Context)
+
+ assert.Len(t, status.Statuses, 1)
+ assert.Equal(t, statuses[0], status.Statuses[0])
+ assert.Equal(t, "65f1bf27bc3bf70f64657658635e66094edbcb4d", status.SHA)
+ }
+}
+
+func TestRepoCommitsWithStatusPending(t *testing.T) {
+ doTestRepoCommitWithStatus(t, "pending", "octicon-dot-fill", "yellow")
+}
+
+func TestRepoCommitsWithStatusSuccess(t *testing.T) {
+ doTestRepoCommitWithStatus(t, "success", "octicon-check", "green")
+}
+
+func TestRepoCommitsWithStatusError(t *testing.T) {
+ doTestRepoCommitWithStatus(t, "error", "gitea-exclamation", "red")
+}
+
+func TestRepoCommitsWithStatusFailure(t *testing.T) {
+ doTestRepoCommitWithStatus(t, "failure", "octicon-x", "red")
+}
+
+func TestRepoCommitsWithStatusWarning(t *testing.T) {
+ doTestRepoCommitWithStatus(t, "warning", "gitea-exclamation", "yellow")
+}
+
+func TestRepoCommitsStatusParallel(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ // Request repository commits page
+ req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ // Get first commit URL
+ commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Attr("href")
+ assert.True(t, exists)
+ assert.NotEmpty(t, commitURL)
+
+ var wg sync.WaitGroup
+ for i := 0; i < 10; i++ {
+ wg.Add(1)
+ go func(parentT *testing.T, i int) {
+ parentT.Run(fmt.Sprintf("ParallelCreateStatus_%d", i), func(t *testing.T) {
+ ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository)
+ runBody := doAPICreateCommitStatus(ctx, path.Base(commitURL), api.CreateStatusOption{
+ State: api.CommitStatusPending,
+ TargetURL: "http://test.ci/",
+ Description: "",
+ Context: "testci",
+ })
+ runBody(t)
+ wg.Done()
+ })
+ }(t, i)
+ }
+ wg.Wait()
+}
+
+func TestRepoCommitsStatusMultiple(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ // Request repository commits page
+ req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ // Get first commit URL
+ commitURL, exists := doc.doc.Find("#commits-table tbody tr td.sha a").Attr("href")
+ assert.True(t, exists)
+ assert.NotEmpty(t, commitURL)
+
+ // Call API to add status for commit
+ ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteRepository)
+ t.Run("CreateStatus", doAPICreateCommitStatus(ctx, path.Base(commitURL), api.CreateStatusOption{
+ State: api.CommitStatusSuccess,
+ TargetURL: "http://test.ci/",
+ Description: "",
+ Context: "testci",
+ }))
+
+ t.Run("CreateStatus", doAPICreateCommitStatus(ctx, path.Base(commitURL), api.CreateStatusOption{
+ State: api.CommitStatusSuccess,
+ TargetURL: "http://test.ci/",
+ Description: "",
+ Context: "other_context",
+ }))
+
+ req = NewRequest(t, "GET", "/user2/repo1/commits/branch/master")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ doc = NewHTMLParser(t, resp.Body)
+ // Check that the data-tippy="commit-statuses" (for trigger) and commit-status (svg) are present
+ sel := doc.doc.Find("#commits-table tbody tr td.message [data-tippy=\"commit-statuses\"] .commit-status")
+ assert.Equal(t, 1, sel.Length())
+}
diff --git a/tests/integration/repo_delete_test.go b/tests/integration/repo_delete_test.go
new file mode 100644
index 0000000..44ef26f
--- /dev/null
+++ b/tests/integration/repo_delete_test.go
@@ -0,0 +1,74 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ repo_service "code.gitea.io/gitea/services/repository"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTeam_HasRepository(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ test := func(teamID, repoID int64, expected bool) {
+ team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID})
+ assert.Equal(t, expected, repo_service.HasRepository(db.DefaultContext, team, repoID))
+ }
+ test(1, 1, false)
+ test(1, 3, true)
+ test(1, 5, true)
+ test(1, unittest.NonexistentID, false)
+
+ test(2, 3, true)
+ test(2, 5, false)
+}
+
+func TestTeam_RemoveRepository(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ testSuccess := func(teamID, repoID int64) {
+ team := unittest.AssertExistsAndLoadBean(t, &organization.Team{ID: teamID})
+ require.NoError(t, repo_service.RemoveRepositoryFromTeam(db.DefaultContext, team, repoID))
+ unittest.AssertNotExistsBean(t, &organization.TeamRepo{TeamID: teamID, RepoID: repoID})
+ unittest.CheckConsistencyFor(t, &organization.Team{ID: teamID}, &repo_model.Repository{ID: repoID})
+ }
+ testSuccess(2, 3)
+ testSuccess(2, 5)
+ testSuccess(1, unittest.NonexistentID)
+}
+
+func TestDeleteOwnerRepositoriesDirectly(t *testing.T) {
+ unittest.PrepareTestEnv(t)
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ deletedHookID := unittest.AssertExistsAndLoadBean(t, &webhook_model.Webhook{RepoID: 1}).ID
+ unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{
+ HookID: deletedHookID,
+ })
+
+ preservedHookID := unittest.AssertExistsAndLoadBean(t, &webhook_model.Webhook{RepoID: 3}).ID
+ unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{
+ HookID: preservedHookID,
+ })
+
+ require.NoError(t, repo_service.DeleteOwnerRepositoriesDirectly(db.DefaultContext, user))
+
+ unittest.AssertNotExistsBean(t, &webhook_model.HookTask{
+ HookID: deletedHookID,
+ })
+ unittest.AssertExistsAndLoadBean(t, &webhook_model.HookTask{
+ HookID: preservedHookID,
+ })
+}
diff --git a/tests/integration/repo_flags_test.go b/tests/integration/repo_flags_test.go
new file mode 100644
index 0000000..8b64776
--- /dev/null
+++ b/tests/integration/repo_flags_test.go
@@ -0,0 +1,391 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "slices"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRepositoryFlagsUIDisabled(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Repository.EnableFlags, false)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ admin := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+ session := loginUser(t, admin.Name)
+
+ // With the repo flags feature disabled, the /flags route is 404
+ req := NewRequest(t, "GET", "/user2/repo1/flags")
+ session.MakeRequest(t, req, http.StatusNotFound)
+
+ // With the repo flags feature disabled, the "Modify flags" tab does not
+ // appear for instance admins
+ req = NewRequest(t, "GET", "/user2/repo1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ flagsLinkCount := doc.Find(fmt.Sprintf(`a[href="%s/flags"]`, "/user2/repo1")).Length()
+ assert.Equal(t, 0, flagsLinkCount)
+}
+
+func TestRepositoryFlagsAPI(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Repository.EnableFlags, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ // *************
+ // ** Helpers **
+ // *************
+
+ adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}).Name
+ normalUserBean := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ assert.False(t, normalUserBean.IsAdmin)
+ normalUser := normalUserBean.Name
+
+ assertAccess := func(t *testing.T, user, method, uri string, expectedStatus int) {
+ session := loginUser(t, user)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeReadAdmin)
+
+ req := NewRequestf(t, method, "/api/v1/repos/user2/repo1/flags%s", uri).AddTokenAuth(token)
+ MakeRequest(t, req, expectedStatus)
+ }
+
+ // ***********
+ // ** Tests **
+ // ***********
+
+ t.Run("API access", func(t *testing.T) {
+ t.Run("as admin", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertAccess(t, adminUser, "GET", "", http.StatusOK)
+ })
+
+ t.Run("as normal user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertAccess(t, normalUser, "GET", "", http.StatusForbidden)
+ })
+ })
+
+ t.Run("token scopes", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Trying to access the API with a token that lacks permissions, will
+ // fail, even if the token owner is an instance admin.
+ session := loginUser(t, adminUser)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ req := NewRequest(t, "GET", "/api/v1/repos/user2/repo1/flags").AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+
+ t.Run("setting.Repository.EnableFlags is respected", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.Repository.EnableFlags, false)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ t.Run("as admin", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertAccess(t, adminUser, "GET", "", http.StatusNotFound)
+ })
+
+ t.Run("as normal user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertAccess(t, normalUser, "GET", "", http.StatusNotFound)
+ })
+ })
+
+ t.Run("API functionality", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
+ defer func() {
+ repo.ReplaceAllFlags(db.DefaultContext, []string{})
+ }()
+
+ baseURLFmtStr := "/api/v1/repos/user5/repo4/flags%s"
+
+ session := loginUser(t, adminUser)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteAdmin)
+
+ // Listing flags
+ req := NewRequestf(t, "GET", baseURLFmtStr, "").AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+ var flags []string
+ DecodeJSON(t, resp, &flags)
+ assert.Empty(t, flags)
+
+ // Replacing all tags works, twice in a row
+ for i := 0; i < 2; i++ {
+ req = NewRequestWithJSON(t, "PUT", fmt.Sprintf(baseURLFmtStr, ""), &api.ReplaceFlagsOption{
+ Flags: []string{"flag-1", "flag-2", "flag-3"},
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ }
+
+ // The list now includes all three flags
+ req = NewRequestf(t, "GET", baseURLFmtStr, "").AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &flags)
+ assert.Len(t, flags, 3)
+ for _, flag := range []string{"flag-1", "flag-2", "flag-3"} {
+ assert.True(t, slices.Contains(flags, flag))
+ }
+
+ // Check a flag that is on the repo
+ req = NewRequestf(t, "GET", baseURLFmtStr, "/flag-1").AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // Check a flag that isn't on the repo
+ req = NewRequestf(t, "GET", baseURLFmtStr, "/no-such-flag").AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNotFound)
+
+ // We can add the same flag twice
+ for i := 0; i < 2; i++ {
+ req = NewRequestf(t, "PUT", baseURLFmtStr, "/brand-new-flag").AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ }
+
+ // The new flag is there
+ req = NewRequestf(t, "GET", baseURLFmtStr, "/brand-new-flag").AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // We can delete a flag, twice
+ for i := 0; i < 2; i++ {
+ req = NewRequestf(t, "DELETE", baseURLFmtStr, "/flag-3").AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+ }
+
+ // We can delete a flag that wasn't there
+ req = NewRequestf(t, "DELETE", baseURLFmtStr, "/no-such-flag").AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // We can delete all of the flags in one go, too
+ req = NewRequestf(t, "DELETE", baseURLFmtStr, "").AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusNoContent)
+
+ // ..once all flags are deleted, none are listed, either
+ req = NewRequestf(t, "GET", baseURLFmtStr, "").AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &flags)
+ assert.Empty(t, flags)
+ })
+}
+
+func TestRepositoryFlagsUI(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Repository.EnableFlags, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ // *******************
+ // ** Preparations **
+ // *******************
+ flaggedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ unflaggedRepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 4})
+
+ // **************
+ // ** Helpers **
+ // **************
+
+ adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true}).Name
+ flaggedOwner := "user2"
+ flaggedRepoURLStr := "/user2/repo1"
+ unflaggedOwner := "user5"
+ unflaggedRepoURLStr := "/user5/repo4"
+ otherUser := "user4"
+
+ ensureFlags := func(repo *repo_model.Repository, flags []string) func() {
+ repo.ReplaceAllFlags(db.DefaultContext, flags)
+
+ return func() {
+ repo.ReplaceAllFlags(db.DefaultContext, flags)
+ }
+ }
+
+ // Tests:
+ // - Presence of the link
+ // - Number of flags listed in the admin-only message box
+ // - Whether there's a link to /user/repo/flags
+ // - Whether /user/repo/flags is OK or Forbidden
+ assertFlagAccessAndCount := func(t *testing.T, user, repoURL string, hasAccess bool, expectedFlagCount int) {
+ t.Helper()
+
+ var expectedLinkCount int
+ var expectedStatus int
+ if hasAccess {
+ expectedLinkCount = 1
+ expectedStatus = http.StatusOK
+ } else {
+ expectedLinkCount = 0
+ if user != "" {
+ expectedStatus = http.StatusForbidden
+ } else {
+ expectedStatus = http.StatusSeeOther
+ }
+ }
+
+ var resp *httptest.ResponseRecorder
+ var session *TestSession
+ req := NewRequest(t, "GET", repoURL)
+ if user != "" {
+ session = loginUser(t, user)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ } else {
+ resp = MakeRequest(t, req, http.StatusOK)
+ }
+ doc := NewHTMLParser(t, resp.Body)
+
+ flagsLinkCount := doc.Find(fmt.Sprintf(`a[href="%s/flags"]`, repoURL)).Length()
+ assert.Equal(t, expectedLinkCount, flagsLinkCount)
+
+ flagCount := doc.Find(".ui.info.message .ui.label").Length()
+ assert.Equal(t, expectedFlagCount, flagCount)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("%s/flags", repoURL))
+ if user != "" {
+ session.MakeRequest(t, req, expectedStatus)
+ } else {
+ MakeRequest(t, req, expectedStatus)
+ }
+ }
+
+ // Ensures that given a repo owner and a repo:
+ // - An instance admin has access to flags, and sees the list on the repo home
+ // - A repo admin does not have access to either, and does not see the list
+ // - A passer by has no access to either, and does not see the list
+ runTests := func(t *testing.T, ownerUser, repoURL string, expectedFlagCount int) {
+ t.Run("as instance admin", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertFlagAccessAndCount(t, adminUser, repoURL, true, expectedFlagCount)
+ })
+ t.Run("as owner", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertFlagAccessAndCount(t, ownerUser, repoURL, false, 0)
+ })
+ t.Run("as other user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertFlagAccessAndCount(t, otherUser, repoURL, false, 0)
+ })
+ t.Run("as non-logged in user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertFlagAccessAndCount(t, "", repoURL, false, 0)
+ })
+ }
+
+ // **************************
+ // ** The tests themselves **
+ // **************************
+ t.Run("unflagged repo", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer ensureFlags(unflaggedRepo, []string{})()
+
+ runTests(t, unflaggedOwner, unflaggedRepoURLStr, 0)
+ })
+
+ t.Run("flagged repo", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer ensureFlags(flaggedRepo, []string{"test-flag"})()
+
+ runTests(t, flaggedOwner, flaggedRepoURLStr, 1)
+ })
+
+ t.Run("modifying flags", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, adminUser)
+ flaggedRepoManageURL := fmt.Sprintf("%s/flags", flaggedRepoURLStr)
+ unflaggedRepoManageURL := fmt.Sprintf("%s/flags", unflaggedRepoURLStr)
+
+ assertUIFlagStates := func(t *testing.T, url string, flagStates map[string]bool) {
+ t.Helper()
+
+ req := NewRequest(t, "GET", url)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ flagBoxes := doc.Find(`input[name="flags"]`)
+ assert.Equal(t, len(flagStates), flagBoxes.Length())
+
+ for name, state := range flagStates {
+ _, checked := doc.Find(fmt.Sprintf(`input[value="%s"]`, name)).Attr("checked")
+ assert.Equal(t, state, checked)
+ }
+ }
+
+ t.Run("flag presence on the UI", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer ensureFlags(flaggedRepo, []string{"test-flag"})()
+
+ assertUIFlagStates(t, flaggedRepoManageURL, map[string]bool{"test-flag": true})
+ })
+
+ t.Run("setting.Repository.SettableFlags is respected", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.Repository.SettableFlags, []string{"featured", "no-license"})()
+ defer ensureFlags(flaggedRepo, []string{"test-flag"})()
+
+ assertUIFlagStates(t, flaggedRepoManageURL, map[string]bool{
+ "test-flag": true,
+ "featured": false,
+ "no-license": false,
+ })
+ })
+
+ t.Run("removing flags", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer ensureFlags(flaggedRepo, []string{"test-flag"})()
+
+ flagged := flaggedRepo.IsFlagged(db.DefaultContext)
+ assert.True(t, flagged)
+
+ req := NewRequestWithValues(t, "POST", flaggedRepoManageURL, map[string]string{
+ "_csrf": GetCSRF(t, session, flaggedRepoManageURL),
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flagged = flaggedRepo.IsFlagged(db.DefaultContext)
+ assert.False(t, flagged)
+
+ assertUIFlagStates(t, flaggedRepoManageURL, map[string]bool{})
+ })
+
+ t.Run("adding flags", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer ensureFlags(unflaggedRepo, []string{})()
+
+ flagged := unflaggedRepo.IsFlagged(db.DefaultContext)
+ assert.False(t, flagged)
+
+ req := NewRequestWithValues(t, "POST", unflaggedRepoManageURL, map[string]string{
+ "_csrf": GetCSRF(t, session, unflaggedRepoManageURL),
+ "flags": "test-flag",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ assertUIFlagStates(t, unflaggedRepoManageURL, map[string]bool{"test-flag": true})
+ })
+ })
+}
diff --git a/tests/integration/repo_fork_test.go b/tests/integration/repo_fork_test.go
new file mode 100644
index 0000000..2627749
--- /dev/null
+++ b/tests/integration/repo_fork_test.go
@@ -0,0 +1,240 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+ repo_service "code.gitea.io/gitea/services/repository"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func testRepoFork(t *testing.T, session *TestSession, ownerName, repoName, forkOwnerName, forkRepoName string) *httptest.ResponseRecorder {
+ t.Helper()
+
+ forkOwner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: forkOwnerName})
+
+ // Step0: check the existence of the to-fork repo
+ req := NewRequestf(t, "GET", "/%s/%s", forkOwnerName, forkRepoName)
+ session.MakeRequest(t, req, http.StatusNotFound)
+
+ // Step1: visit the /fork page
+ forkURL := fmt.Sprintf("/%s/%s/fork", ownerName, repoName)
+ req = NewRequest(t, "GET", forkURL)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ // Step2: fill the form of the forking
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ link, exists := htmlDoc.doc.Find(fmt.Sprintf("form.ui.form[action=\"%s\"]", forkURL)).Attr("action")
+ assert.True(t, exists, "The template has changed")
+ _, exists = htmlDoc.doc.Find(fmt.Sprintf(".owner.dropdown .item[data-value=\"%d\"]", forkOwner.ID)).Attr("data-value")
+ assert.True(t, exists, "Fork owner %q is not present in select box", forkOwnerName)
+ req = NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "uid": fmt.Sprintf("%d", forkOwner.ID),
+ "repo_name": forkRepoName,
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // Step3: check the existence of the forked repo
+ req = NewRequestf(t, "GET", "/%s/%s", forkOwnerName, forkRepoName)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ return resp
+}
+
+func testRepoForkLegacyRedirect(t *testing.T, session *TestSession, ownerName, repoName string) {
+ t.Helper()
+
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: ownerName})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: owner.ID, Name: repoName})
+
+ // Visit the /repo/fork/:id url
+ req := NewRequestf(t, "GET", "/repo/fork/%d", repo.ID)
+ resp := session.MakeRequest(t, req, http.StatusMovedPermanently)
+
+ assert.Equal(t, repo.Link()+"/fork", resp.Header().Get("Location"))
+}
+
+func TestRepoFork(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user5"})
+ session := loginUser(t, user5.Name)
+
+ t.Run("by name", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer func() {
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user5.ID, Name: "repo1"})
+ repo_service.DeleteRepository(db.DefaultContext, user5, repo, false)
+ }()
+ testRepoFork(t, session, "user2", "repo1", "user5", "repo1")
+ })
+
+ t.Run("legacy redirect", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ testRepoForkLegacyRedirect(t, session, "user2", "repo1")
+
+ t.Run("private 404", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Make sure the repo we try to fork is private
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 31, IsPrivate: true})
+
+ // user5 does not have access to user2/repo20
+ req := NewRequestf(t, "GET", "/repo/fork/%d", repo.ID) // user2/repo20
+ session.MakeRequest(t, req, http.StatusNotFound)
+ })
+ t.Run("authenticated private redirect", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Make sure the repo we try to fork is private
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 31, IsPrivate: true})
+
+ // user1 has access to user2/repo20
+ session := loginUser(t, "user1")
+ req := NewRequestf(t, "GET", "/repo/fork/%d", repo.ID) // user2/repo20
+ session.MakeRequest(t, req, http.StatusMovedPermanently)
+ })
+ t.Run("no code unit", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Make sure the repo we try to fork is private.
+ // We're also choosing user15/big_test_private_2, because it has the Code unit disabled.
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 20, IsPrivate: true})
+
+ // user1, even though an admin, can't fork a repo without a code unit.
+ session := loginUser(t, "user1")
+ req := NewRequestf(t, "GET", "/repo/fork/%d", repo.ID) // user15/big_test_private_2
+ session.MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+
+ t.Run("fork button", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ forkButton := htmlDoc.Find("a[href*='/forks']")
+ assert.EqualValues(t, 1, forkButton.Length())
+
+ href, _ := forkButton.Attr("href")
+ assert.Equal(t, "/user2/repo1/forks", href)
+ assert.Equal(t, "0", strings.TrimSpace(forkButton.Text()))
+
+ t.Run("no fork button on empty repo", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Create an empty repository
+ repo, err := repo_service.CreateRepository(db.DefaultContext, user5, user5, repo_service.CreateRepoOptions{
+ Name: "empty-repo",
+ AutoInit: false,
+ })
+ defer func() {
+ repo_service.DeleteRepository(db.DefaultContext, user5, repo, false)
+ }()
+ require.NoError(t, err)
+ assert.NotEmpty(t, repo)
+
+ // Load the repository home view
+ req := NewRequest(t, "GET", repo.HTMLURL())
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ // On an empty repo, the fork button is not present
+ htmlDoc.AssertElement(t, ".basic.button[href*='/fork']", false)
+ })
+ })
+
+ t.Run("DISABLE_FORKS", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.Repository.DisableForks, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ t.Run("fork button not present", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // The "Fork" button should not appear on the repo home
+ req := NewRequest(t, "GET", "/user2/repo1")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, "[href=/user2/repo1/fork]", false)
+ })
+
+ t.Run("forking by URL", func(t *testing.T) {
+ t.Run("by name", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Forking by URL should be Not Found
+ req := NewRequest(t, "GET", "/user2/repo1/fork")
+ session.MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("by legacy URL", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Forking by legacy URL should be Not Found
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) // user2/repo1
+ req := NewRequestf(t, "GET", "/repo/fork/%d", repo.ID)
+ session.MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+
+ t.Run("fork listing", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Listing the forks should be Not Found, too
+ req := NewRequest(t, "GET", "/user2/repo1/forks")
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+ })
+ })
+}
+
+func TestRepoForkToOrg(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ session := loginUser(t, "user2")
+ org3 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"})
+
+ t.Run("by name", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer func() {
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: org3.ID, Name: "repo1"})
+ repo_service.DeleteRepository(db.DefaultContext, org3, repo, false)
+ }()
+
+ testRepoFork(t, session, "user2", "repo1", "org3", "repo1")
+
+ // Check that no more forking is allowed as user2 owns repository
+ // and org3 organization that owner user2 is also now has forked this repository
+ req := NewRequest(t, "GET", "/user2/repo1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ _, exists := htmlDoc.doc.Find("a.ui.button[href^=\"/fork\"]").Attr("href")
+ assert.False(t, exists, "Forking should not be allowed anymore")
+ })
+
+ t.Run("legacy redirect", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ testRepoForkLegacyRedirect(t, session, "user2", "repo1")
+ })
+ })
+}
diff --git a/tests/integration/repo_generate_test.go b/tests/integration/repo_generate_test.go
new file mode 100644
index 0000000..c475c92
--- /dev/null
+++ b/tests/integration/repo_generate_test.go
@@ -0,0 +1,137 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func assertRepoCreateForm(t *testing.T, htmlDoc *HTMLDoc, owner *user_model.User, templateID string) {
+ _, exists := htmlDoc.doc.Find("form.ui.form[action^='/repo/create']").Attr("action")
+ assert.True(t, exists, "Expected the repo creation form")
+ locale := translation.NewLocale("en-US")
+
+ // Verify page title
+ title := htmlDoc.doc.Find("title").Text()
+ assert.Contains(t, title, locale.TrString("new_repo.title"))
+
+ // Verify form header
+ header := strings.TrimSpace(htmlDoc.doc.Find(".form[action='/repo/create'] .header").Text())
+ assert.EqualValues(t, locale.TrString("new_repo.title"), header)
+
+ htmlDoc.AssertDropdownHasSelectedOption(t, "uid", strconv.FormatInt(owner.ID, 10))
+
+ // the template menu is loaded client-side, so don't assert the option exists
+ assert.Equal(t, templateID, htmlDoc.GetInputValueByName("repo_template"), "Unexpected repo_template selection")
+
+ for _, name := range []string{"issue_labels", "gitignores", "license", "readme", "object_format_name"} {
+ htmlDoc.AssertDropdownHasOptions(t, name)
+ }
+}
+
+func testRepoGenerate(t *testing.T, session *TestSession, templateID, templateOwnerName, templateRepoName string, user, generateOwner *user_model.User, generateRepoName string) {
+ // Step0: check the existence of the generated repo
+ req := NewRequestf(t, "GET", "/%s/%s", generateOwner.Name, generateRepoName)
+ session.MakeRequest(t, req, http.StatusNotFound)
+
+ // Step1: go to the main page of template repo
+ req = NewRequestf(t, "GET", "/%s/%s", templateOwnerName, templateRepoName)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ // Step2: click the "Use this template" button
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ link, exists := htmlDoc.doc.Find("a.ui.button[href^=\"/repo/create\"]").Attr("href")
+ assert.True(t, exists, "The template has changed")
+ req = NewRequest(t, "GET", link)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ // Step3: test and submit form
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ assertRepoCreateForm(t, htmlDoc, user, templateID)
+ req = NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "uid": fmt.Sprintf("%d", generateOwner.ID),
+ "repo_name": generateRepoName,
+ "repo_template": templateID,
+ "git_content": "true",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // Step4: check the existence of the generated repo
+ req = NewRequestf(t, "GET", "/%s/%s", generateOwner.Name, generateRepoName)
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // Step5: check substituted values in Readme
+ req = NewRequestf(t, "GET", "/%s/%s/raw/branch/master/README.md", generateOwner.Name, generateRepoName)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ body := fmt.Sprintf(`# %s Readme
+Owner: %s
+Link: /%s/%s
+Clone URL: %s%s/%s.git`,
+ generateRepoName,
+ strings.ToUpper(generateOwner.Name),
+ generateOwner.Name,
+ generateRepoName,
+ setting.AppURL,
+ generateOwner.Name,
+ generateRepoName)
+ assert.Equal(t, body, resp.Body.String())
+
+ // Step6: check substituted values in substituted file path ${REPO_NAME}
+ req = NewRequestf(t, "GET", "/%s/%s/raw/branch/master/%s.log", generateOwner.Name, generateRepoName, generateRepoName)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, generateRepoName, resp.Body.String())
+}
+
+// test form elements before and after POST error response
+func TestRepoCreateForm(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ userName := "user1"
+ session := loginUser(t, userName)
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: userName})
+
+ req := NewRequest(t, "GET", "/repo/create")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assertRepoCreateForm(t, htmlDoc, user, "")
+
+ req = NewRequestWithValues(t, "POST", "/repo/create", map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ })
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ assertRepoCreateForm(t, htmlDoc, user, "")
+}
+
+func TestRepoGenerate(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ userName := "user1"
+ session := loginUser(t, userName)
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: userName})
+
+ testRepoGenerate(t, session, "44", "user27", "template1", user, user, "generated1")
+}
+
+func TestRepoGenerateToOrg(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ userName := "user2"
+ session := loginUser(t, userName)
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: userName})
+ org := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "org3"})
+
+ testRepoGenerate(t, session, "44", "user27", "template1", user, org, "generated2")
+}
diff --git a/tests/integration/repo_issue_title_test.go b/tests/integration/repo_issue_title_test.go
new file mode 100644
index 0000000..5199be9
--- /dev/null
+++ b/tests/integration/repo_issue_title_test.go
@@ -0,0 +1,162 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ issue_service "code.gitea.io/gitea/services/issue"
+ pull_service "code.gitea.io/gitea/services/pull"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestIssueTitles(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+ repo, _, f := tests.CreateDeclarativeRepo(t, user, "issue-titles", nil, nil, nil)
+ defer f()
+
+ session := loginUser(t, user.LoginName)
+
+ title := "Title :+1: `code`"
+ issue1 := createIssue(t, user, repo, title, "Test issue")
+ issue2 := createIssue(t, user, repo, title, "Ref #1")
+
+ titleHTML := []string{
+ "Title",
+ `<span class="emoji" aria-label="thumbs up">ðŸ‘</span>`,
+ `<code class="inline-code-block">code</code>`,
+ }
+
+ t.Run("Main issue title", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ html := extractHTML(t, session, issue1, "div.issue-title-header > * > h1")
+ assertContainsAll(t, titleHTML, html)
+ })
+
+ t.Run("Referenced issue comment", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ html := extractHTML(t, session, issue1, "div.timeline > div.timeline-item:nth-child(3) > div.detail > * > a")
+ assertContainsAll(t, titleHTML, html)
+ })
+
+ t.Run("Dependent issue comment", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ err := issues_model.CreateIssueDependency(db.DefaultContext, user, issue1, issue2)
+ require.NoError(t, err)
+
+ html := extractHTML(t, session, issue1, "div.timeline > div:nth-child(3) > div.detail > * > a")
+ assertContainsAll(t, titleHTML, html)
+ })
+
+ t.Run("Dependent issue sidebar", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ html := extractHTML(t, session, issue1, "div.item.dependency > * > a.title")
+ assertContainsAll(t, titleHTML, html)
+ })
+
+ t.Run("Referenced pull comment", func(t *testing.T) {
+ _, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user, &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "update",
+ TreePath: "README.md",
+ ContentReader: strings.NewReader("Update README"),
+ },
+ },
+ Message: "Update README",
+ OldBranch: "main",
+ NewBranch: "branch",
+ Author: &files_service.IdentityOptions{
+ Name: user.Name,
+ Email: user.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: user.Name,
+ Email: user.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+
+ require.NoError(t, err)
+
+ pullIssue := &issues_model.Issue{
+ RepoID: repo.ID,
+ Title: title,
+ Content: "Closes #1",
+ PosterID: user.ID,
+ Poster: user,
+ IsPull: true,
+ }
+
+ pullRequest := &issues_model.PullRequest{
+ HeadRepoID: repo.ID,
+ BaseRepoID: repo.ID,
+ HeadBranch: "branch",
+ BaseBranch: "main",
+ HeadRepo: repo,
+ BaseRepo: repo,
+ Type: issues_model.PullRequestGitea,
+ }
+
+ err = pull_service.NewPullRequest(git.DefaultContext, repo, pullIssue, nil, nil, pullRequest, nil)
+ require.NoError(t, err)
+
+ html := extractHTML(t, session, issue1, "div.timeline > div:nth-child(4) > div.detail > * > a")
+ assertContainsAll(t, titleHTML, html)
+ })
+ })
+}
+
+func createIssue(t *testing.T, user *user_model.User, repo *repo_model.Repository, title, content string) *issues_model.Issue {
+ issue := &issues_model.Issue{
+ RepoID: repo.ID,
+ Title: title,
+ Content: content,
+ PosterID: user.ID,
+ Poster: user,
+ }
+
+ err := issue_service.NewIssue(db.DefaultContext, repo, issue, nil, nil, nil)
+ require.NoError(t, err)
+
+ return issue
+}
+
+func extractHTML(t *testing.T, session *TestSession, issue *issues_model.Issue, query string) string {
+ req := NewRequest(t, "GET", issue.HTMLURL())
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ res, err := doc.doc.Find(query).Html()
+ require.NoError(t, err)
+
+ return res
+}
+
+func assertContainsAll(t *testing.T, expected []string, actual string) {
+ for i := range expected {
+ assert.Contains(t, actual, expected[i])
+ }
+}
diff --git a/tests/integration/repo_mergecommit_revert_test.go b/tests/integration/repo_mergecommit_revert_test.go
new file mode 100644
index 0000000..eb75d45
--- /dev/null
+++ b/tests/integration/repo_mergecommit_revert_test.go
@@ -0,0 +1,38 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRepoMergeCommitRevert(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+
+ req := NewRequest(t, "GET", "/user2/test_commit_revert/_cherrypick/deebcbc752e540bab4ce3ee713d3fc8fdc35b2f7/main?ref=main&refType=branch&cherry-pick-type=revert")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ req = NewRequestWithValues(t, "POST", "/user2/test_commit_revert/_cherrypick/deebcbc752e540bab4ce3ee713d3fc8fdc35b2f7/main", map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "last_commit": "deebcbc752e540bab4ce3ee713d3fc8fdc35b2f7",
+ "page_has_posted": "true",
+ "revert": "true",
+ "commit_summary": "reverting test commit",
+ "commit_message": "test message",
+ "commit_choice": "direct",
+ "new_branch_name": "test-revert-branch-1",
+ "commit_mail_id": "-1",
+ })
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // A successful revert redirects to the main branch
+ assert.EqualValues(t, "/user2/test_commit_revert/src/branch/main", resp.Header().Get("Location"))
+}
diff --git a/tests/integration/repo_migrate_test.go b/tests/integration/repo_migrate_test.go
new file mode 100644
index 0000000..9fb7a73
--- /dev/null
+++ b/tests/integration/repo_migrate_test.go
@@ -0,0 +1,57 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func testRepoMigrate(t testing.TB, session *TestSession, cloneAddr, repoName string, service structs.GitServiceType) *httptest.ResponseRecorder {
+ req := NewRequest(t, "GET", fmt.Sprintf("/repo/migrate?service_type=%d", service)) // render plain git migration page
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ link, exists := htmlDoc.doc.Find("form.ui.form").Attr("action")
+ assert.True(t, exists, "The template has changed")
+
+ uid, exists := htmlDoc.doc.Find("#uid").Attr("value")
+ assert.True(t, exists, "The template has changed")
+
+ req = NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "clone_addr": cloneAddr,
+ "uid": uid,
+ "repo_name": repoName,
+ "service": fmt.Sprintf("%d", service),
+ })
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+
+ return resp
+}
+
+func TestRepoMigrate(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+ for _, s := range []struct {
+ testName string
+ cloneAddr string
+ repoName string
+ service structs.GitServiceType
+ }{
+ {"TestMigrateGithub", "https://github.com/go-gitea/test_repo.git", "git", structs.PlainGitService},
+ {"TestMigrateGithub", "https://github.com/go-gitea/test_repo.git", "github", structs.GithubService},
+ } {
+ t.Run(s.testName, func(t *testing.T) {
+ testRepoMigrate(t, session, s.cloneAddr, s.repoName, s.service)
+ })
+ }
+}
diff --git a/tests/integration/repo_migration_ui_test.go b/tests/integration/repo_migration_ui_test.go
new file mode 100644
index 0000000..40688d4
--- /dev/null
+++ b/tests/integration/repo_migration_ui_test.go
@@ -0,0 +1,116 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRepoMigrationUI(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ sessionUser1 := loginUser(t, "user1")
+ // Nothing is tested in plain Git migration form right now
+ testRepoMigrationFormGitHub(t, sessionUser1)
+ testRepoMigrationFormGitea(t, sessionUser1)
+ testRepoMigrationFormGitLab(t, sessionUser1)
+ testRepoMigrationFormGogs(t, sessionUser1)
+ testRepoMigrationFormOneDev(t, sessionUser1)
+ testRepoMigrationFormGitBucket(t, sessionUser1)
+ testRepoMigrationFormCodebase(t, sessionUser1)
+ testRepoMigrationFormForgejo(t, sessionUser1)
+ })
+}
+
+func testRepoMigrationFormGitHub(t *testing.T, session *TestSession) {
+ response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=2"), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+
+ items := page.Find("#migrate_items .field .checkbox input")
+ expectedItems := []string{"issues", "pull_requests", "labels", "milestones", "releases"}
+ testRepoMigrationFormItems(t, items, expectedItems)
+}
+
+func testRepoMigrationFormGitea(t *testing.T, session *TestSession) {
+ response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=3"), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+
+ items := page.Find("#migrate_items .field .checkbox input")
+ expectedItems := []string{"issues", "pull_requests", "labels", "milestones", "releases"}
+ testRepoMigrationFormItems(t, items, expectedItems)
+}
+
+func testRepoMigrationFormGitLab(t *testing.T, session *TestSession) {
+ response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=4"), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+
+ items := page.Find("#migrate_items .field .checkbox input")
+ // Note: the checkbox "Merge requests" has name "pull_requests"
+ expectedItems := []string{"issues", "pull_requests", "labels", "milestones", "releases"}
+ testRepoMigrationFormItems(t, items, expectedItems)
+}
+
+func testRepoMigrationFormGogs(t *testing.T, session *TestSession) {
+ response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=5"), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+
+ items := page.Find("#migrate_items .field .checkbox input")
+ expectedItems := []string{"issues", "labels", "milestones"}
+ testRepoMigrationFormItems(t, items, expectedItems)
+}
+
+func testRepoMigrationFormOneDev(t *testing.T, session *TestSession) {
+ response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=6"), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+
+ items := page.Find("#migrate_items .field .checkbox input")
+ expectedItems := []string{"issues", "pull_requests", "labels", "milestones"}
+ testRepoMigrationFormItems(t, items, expectedItems)
+}
+
+func testRepoMigrationFormGitBucket(t *testing.T, session *TestSession) {
+ response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=7"), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+
+ items := page.Find("#migrate_items .field .checkbox input")
+ expectedItems := []string{"issues", "pull_requests", "labels", "milestones", "releases"}
+ testRepoMigrationFormItems(t, items, expectedItems)
+}
+
+func testRepoMigrationFormCodebase(t *testing.T, session *TestSession) {
+ response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=8"), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+
+ items := page.Find("#migrate_items .field .checkbox input")
+ // Note: the checkbox "Merge requests" has name "pull_requests"
+ expectedItems := []string{"issues", "pull_requests", "labels", "milestones"}
+ testRepoMigrationFormItems(t, items, expectedItems)
+}
+
+func testRepoMigrationFormForgejo(t *testing.T, session *TestSession) {
+ response := session.MakeRequest(t, NewRequest(t, "GET", "/repo/migrate?service_type=9"), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+
+ items := page.Find("#migrate_items .field .checkbox input")
+ expectedItems := []string{"issues", "pull_requests", "labels", "milestones", "releases"}
+ testRepoMigrationFormItems(t, items, expectedItems)
+}
+
+func testRepoMigrationFormItems(t *testing.T, items *goquery.Selection, expectedItems []string) {
+ t.Helper()
+
+ // Compare lengths of item lists
+ assert.EqualValues(t, len(expectedItems), items.Length())
+
+ // Compare contents of item lists
+ for index, expectedName := range expectedItems {
+ name, exists := items.Eq(index).Attr("name")
+ assert.True(t, exists)
+ assert.EqualValues(t, expectedName, name)
+ }
+}
diff --git a/tests/integration/repo_pagination_test.go b/tests/integration/repo_pagination_test.go
new file mode 100644
index 0000000..1c1f2ac
--- /dev/null
+++ b/tests/integration/repo_pagination_test.go
@@ -0,0 +1,84 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "path"
+ "testing"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRepoPaginations(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("Fork", func(t *testing.T) {
+ // Make forks of user2/repo1
+ session := loginUser(t, "user2")
+ testRepoFork(t, session, "user2", "repo1", "org3", "repo1")
+ session = loginUser(t, "user5")
+ testRepoFork(t, session, "user2", "repo1", "org6", "repo1")
+
+ unittest.AssertCount(t, &repo_model.Repository{ForkID: 1}, 2)
+
+ testRepoPagination(t, session, "user2/repo1", "forks", &setting.MaxForksPerPage)
+ })
+ t.Run("Stars", func(t *testing.T) {
+ // Add stars to user2/repo1.
+ session := loginUser(t, "user2")
+ req := NewRequestWithValues(t, "POST", "/user2/repo1/action/star", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1"),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ session = loginUser(t, "user1")
+ req = NewRequestWithValues(t, "POST", "/user2/repo1/action/star", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1"),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ testRepoPagination(t, session, "user2/repo1", "stars", &setting.MaxUserCardsPerPage)
+ })
+ t.Run("Watcher", func(t *testing.T) {
+ // user2/repo2 is watched by its creator user2. Watch it by user1 to make it watched by 2 users.
+ session := loginUser(t, "user1")
+ req := NewRequestWithValues(t, "POST", "/user2/repo2/action/watch", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo2"),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ testRepoPagination(t, session, "user2/repo2", "watchers", &setting.MaxUserCardsPerPage)
+ })
+}
+
+func testRepoPagination(t *testing.T, session *TestSession, repo, kind string, mockableVar *int) {
+ t.Run("Should paginate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(mockableVar, 1)()
+ req := NewRequest(t, "GET", "/"+path.Join(repo, kind))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ paginationButton := htmlDoc.Find(".item.navigation[href='/" + path.Join(repo, kind) + "?page=2']")
+ // Next and Last button.
+ assert.Equal(t, 2, paginationButton.Length())
+ })
+
+ t.Run("Shouldn't paginate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(mockableVar, 2)()
+ req := NewRequest(t, "GET", "/"+path.Join(repo, kind))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, ".item.navigation[href='/"+path.Join(repo, kind)+"?page=2']", false)
+ })
+}
diff --git a/tests/integration/repo_search_test.go b/tests/integration/repo_search_test.go
new file mode 100644
index 0000000..c7a31f4
--- /dev/null
+++ b/tests/integration/repo_search_test.go
@@ -0,0 +1,135 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ code_indexer "code.gitea.io/gitea/modules/indexer/code"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func resultFilenames(t testing.TB, doc *HTMLDoc) []string {
+ resultSelections := doc.
+ Find(".repository.search").
+ Find("details.repo-search-result")
+
+ result := make([]string, resultSelections.Length())
+ resultSelections.Each(func(i int, selection *goquery.Selection) {
+ assert.Positive(t, selection.Find("div ol li").Length(), 0)
+ assert.Positive(t, selection.Find(".code-inner").Find(".search-highlight").Length(), 0)
+ result[i] = selection.
+ Find(".header").
+ Find("span.file a.file-link").
+ First().
+ Text()
+ })
+
+ return result
+}
+
+func TestSearchRepoIndexer(t *testing.T) {
+ testSearchRepo(t, true)
+}
+
+func TestSearchRepoNoIndexer(t *testing.T) {
+ testSearchRepo(t, false)
+}
+
+func testSearchRepo(t *testing.T, indexer bool) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Indexer.RepoIndexerEnabled, indexer)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "repo1")
+ require.NoError(t, err)
+
+ if indexer {
+ code_indexer.UpdateRepoIndexer(repo)
+ }
+
+ testSearch(t, "/user2/repo1/search?q=Description&page=1", []string{"README.md"}, indexer)
+
+ req := NewRequest(t, "HEAD", "/user2/repo1/search/branch/"+repo.DefaultBranch)
+ if indexer {
+ MakeRequest(t, req, http.StatusNotFound)
+ } else {
+ MakeRequest(t, req, http.StatusOK)
+ }
+
+ defer test.MockVariableValue(&setting.Indexer.IncludePatterns, setting.IndexerGlobFromString("**.txt"))()
+ defer test.MockVariableValue(&setting.Indexer.ExcludePatterns, setting.IndexerGlobFromString("**/y/**"))()
+
+ repo, err = repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "glob")
+ require.NoError(t, err)
+
+ if indexer {
+ code_indexer.UpdateRepoIndexer(repo)
+ }
+
+ testSearch(t, "/user2/glob/search?q=loren&page=1", []string{"a.txt"}, indexer)
+ testSearch(t, "/user2/glob/search?q=loren&page=1&fuzzy=false", []string{"a.txt"}, indexer)
+
+ if indexer {
+ // fuzzy search: matches both file3 (x/b.txt) and file1 (a.txt)
+ // when indexer is enabled
+ testSearch(t, "/user2/glob/search?q=file3&page=1", []string{"x/b.txt", "a.txt"}, indexer)
+ testSearch(t, "/user2/glob/search?q=file4&page=1", []string{"x/b.txt", "a.txt"}, indexer)
+ testSearch(t, "/user2/glob/search?q=file5&page=1", []string{"x/b.txt", "a.txt"}, indexer)
+ } else {
+ // fuzzy search: Union/OR of all the keywords
+ // when indexer is disabled
+ testSearch(t, "/user2/glob/search?q=file3+file1&page=1", []string{"a.txt", "x/b.txt"}, indexer)
+ testSearch(t, "/user2/glob/search?q=file4&page=1", []string{}, indexer)
+ testSearch(t, "/user2/glob/search?q=file5&page=1", []string{}, indexer)
+ }
+
+ testSearch(t, "/user2/glob/search?q=file3&page=1&fuzzy=false", []string{"x/b.txt"}, indexer)
+ testSearch(t, "/user2/glob/search?q=file4&page=1&fuzzy=false", []string{}, indexer)
+ testSearch(t, "/user2/glob/search?q=file5&page=1&fuzzy=false", []string{}, indexer)
+}
+
+func testSearch(t *testing.T, url string, expected []string, indexer bool) {
+ req := NewRequest(t, "GET", url)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ container := doc.Find(".repository").Find(".ui.container")
+
+ grepMsg := container.Find(".ui.message[data-test-tag=grep]")
+ assert.EqualValues(t, indexer, len(grepMsg.Nodes) == 0)
+
+ branchDropdown := container.Find(".js-branch-tag-selector")
+ assert.EqualValues(t, indexer, len(branchDropdown.Nodes) == 0)
+
+ // if indexer is disabled "fuzzy" should be displayed as "union"
+ expectedFuzzy := "Fuzzy"
+ if !indexer {
+ expectedFuzzy = "Union"
+ }
+
+ fuzzyDropdown := container.Find(".ui.dropdown[data-test-tag=fuzzy-dropdown]")
+ actualFuzzyText := fuzzyDropdown.Find(".menu .item[data-value=true]").First().Text()
+ assert.EqualValues(t, expectedFuzzy, actualFuzzyText)
+
+ if fuzzyDropdown.
+ Find("input[name=fuzzy][value=true]").
+ Length() != 0 {
+ actualFuzzyText = fuzzyDropdown.Find("div.text").First().Text()
+ assert.EqualValues(t, expectedFuzzy, actualFuzzyText)
+ }
+
+ filenames := resultFilenames(t, doc)
+ assert.EqualValues(t, expected, filenames)
+}
diff --git a/tests/integration/repo_settings_hook_test.go b/tests/integration/repo_settings_hook_test.go
new file mode 100644
index 0000000..0a3dd57
--- /dev/null
+++ b/tests/integration/repo_settings_hook_test.go
@@ -0,0 +1,63 @@
+// Copyright 2022 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepoSettingsHookHistory(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ // Request repository hook page with history
+ req := NewRequest(t, "GET", "/user2/repo1/settings/hooks/1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+
+ t.Run("1/delivered", func(t *testing.T) {
+ html, err := doc.doc.Find(".webhook div[data-tab='request-1']").Html()
+ require.NoError(t, err)
+ assert.Contains(t, html, "<strong>Request URL:</strong> /matrix-delivered\n")
+ assert.Contains(t, html, "<strong>Request method:</strong> PUT")
+ assert.Contains(t, html, "<strong>X-Head:</strong> 42")
+ assert.Contains(t, html, `<code class="json">{}</code>`)
+
+ val, ok := doc.doc.Find(".webhook div.item:has(div#info-1) svg").Attr("class")
+ assert.True(t, ok)
+ assert.Equal(t, "svg octicon-alert", val)
+ })
+
+ t.Run("2/undelivered", func(t *testing.T) {
+ html, err := doc.doc.Find(".webhook div[data-tab='request-2']").Html()
+ require.NoError(t, err)
+ assert.Equal(t, "-", strings.TrimSpace(html))
+
+ val, ok := doc.doc.Find(".webhook div.item:has(div#info-2) svg").Attr("class")
+ assert.True(t, ok)
+ assert.Equal(t, "svg octicon-stopwatch", val)
+ })
+
+ t.Run("3/success", func(t *testing.T) {
+ html, err := doc.doc.Find(".webhook div[data-tab='request-3']").Html()
+ require.NoError(t, err)
+ assert.Contains(t, html, "<strong>Request URL:</strong> /matrix-success\n")
+ assert.Contains(t, html, "<strong>Request method:</strong> PUT")
+ assert.Contains(t, html, "<strong>X-Head:</strong> 42")
+ assert.Contains(t, html, `<code class="json">{&#34;key&#34;:&#34;value&#34;}</code>`)
+
+ val, ok := doc.doc.Find(".webhook div.item:has(div#info-3) svg").Attr("class")
+ assert.True(t, ok)
+ assert.Equal(t, "svg octicon-check", val)
+ })
+}
diff --git a/tests/integration/repo_settings_test.go b/tests/integration/repo_settings_test.go
new file mode 100644
index 0000000..ff13853
--- /dev/null
+++ b/tests/integration/repo_settings_test.go
@@ -0,0 +1,370 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/forgefed"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ fm "code.gitea.io/gitea/modules/forgefed"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/validation"
+ gitea_context "code.gitea.io/gitea/services/context"
+ repo_service "code.gitea.io/gitea/services/repository"
+ user_service "code.gitea.io/gitea/services/user"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepoSettingsUnits(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{OwnerID: user.ID, Name: "repo1"})
+ session := loginUser(t, user.Name)
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/settings/units", repo.Link()))
+ session.MakeRequest(t, req, http.StatusOK)
+}
+
+func TestRepoAddMoreUnitsHighlighting(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+ session := loginUser(t, user.Name)
+
+ // Make sure there are no disabled repos in the settings!
+ setting.Repository.DisabledRepoUnits = []string{}
+ unit_model.LoadUnitConfig()
+
+ // Create a known-good repo, with some units disabled.
+ repo, _, f := tests.CreateDeclarativeRepo(t, user, "", []unit_model.Type{
+ unit_model.TypeCode,
+ unit_model.TypePullRequests,
+ unit_model.TypeProjects,
+ unit_model.TypeActions,
+ unit_model.TypeIssues,
+ unit_model.TypeWiki,
+ }, []unit_model.Type{unit_model.TypePackages}, nil)
+ defer f()
+
+ setUserHints := func(t *testing.T, hints bool) func() {
+ saved := user.EnableRepoUnitHints
+
+ require.NoError(t, user_service.UpdateUser(db.DefaultContext, user, &user_service.UpdateOptions{
+ EnableRepoUnitHints: optional.Some(hints),
+ }))
+
+ return func() {
+ require.NoError(t, user_service.UpdateUser(db.DefaultContext, user, &user_service.UpdateOptions{
+ EnableRepoUnitHints: optional.Some(saved),
+ }))
+ }
+ }
+
+ assertHighlight := func(t *testing.T, page, uri string, highlighted bool) {
+ t.Helper()
+
+ req := NewRequest(t, "GET", fmt.Sprintf("%s/settings%s", repo.Link(), page))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, fmt.Sprintf(".overflow-menu-items a[href='%s'].active", fmt.Sprintf("%s/settings%s", repo.Link(), uri)), highlighted)
+ }
+
+ t.Run("hints enabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer setUserHints(t, true)()
+
+ t.Run("settings", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Visiting the /settings page, "Settings" is highlighted
+ assertHighlight(t, "", "", true)
+ // ...but "Add more" isn't.
+ assertHighlight(t, "", "/units", false)
+ })
+
+ t.Run("units", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Visiting the /settings/units page, "Add more" is highlighted
+ assertHighlight(t, "/units", "/units", true)
+ // ...but "Settings" isn't.
+ assertHighlight(t, "/units", "", false)
+ })
+ })
+
+ t.Run("hints disabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer setUserHints(t, false)()
+
+ t.Run("settings", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Visiting the /settings page, "Settings" is highlighted
+ assertHighlight(t, "", "", true)
+ // ...but "Add more" isn't (it doesn't exist).
+ assertHighlight(t, "", "/units", false)
+ })
+
+ t.Run("units", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Visiting the /settings/units page, "Settings" is highlighted
+ assertHighlight(t, "/units", "", true)
+ // ...but "Add more" isn't (it doesn't exist)
+ assertHighlight(t, "/units", "/units", false)
+ })
+ })
+}
+
+func TestRepoAddMoreUnits(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+ session := loginUser(t, user.Name)
+
+ // Make sure there are no disabled repos in the settings!
+ setting.Repository.DisabledRepoUnits = []string{}
+ unit_model.LoadUnitConfig()
+
+ // Create a known-good repo, with all units enabled.
+ repo, _, f := tests.CreateDeclarativeRepo(t, user, "", []unit_model.Type{
+ unit_model.TypeCode,
+ unit_model.TypePullRequests,
+ unit_model.TypeProjects,
+ unit_model.TypePackages,
+ unit_model.TypeActions,
+ unit_model.TypeIssues,
+ unit_model.TypeWiki,
+ }, nil, nil)
+ defer f()
+
+ assertAddMore := func(t *testing.T, present bool) {
+ t.Helper()
+
+ req := NewRequest(t, "GET", repo.Link())
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, fmt.Sprintf("a[href='%s/settings/units']", repo.Link()), present)
+ }
+
+ t.Run("no add more with all units enabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertAddMore(t, false)
+ })
+
+ t.Run("add more if units can be enabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer func() {
+ repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{
+ RepoID: repo.ID,
+ Type: unit_model.TypePackages,
+ }}, nil)
+ }()
+
+ // Disable the Packages unit
+ err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, nil, []unit_model.Type{unit_model.TypePackages})
+ require.NoError(t, err)
+
+ assertAddMore(t, true)
+ })
+
+ t.Run("no add more if unit is globally disabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer func() {
+ repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{
+ RepoID: repo.ID,
+ Type: unit_model.TypePackages,
+ }}, nil)
+ setting.Repository.DisabledRepoUnits = []string{}
+ unit_model.LoadUnitConfig()
+ }()
+
+ // Disable the Packages unit globally
+ setting.Repository.DisabledRepoUnits = []string{"repo.packages"}
+ unit_model.LoadUnitConfig()
+
+ // Disable the Packages unit
+ err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, nil, []unit_model.Type{unit_model.TypePackages})
+ require.NoError(t, err)
+
+ // The "Add more" link appears no more
+ assertAddMore(t, false)
+ })
+
+ t.Run("issues & ext tracker globally disabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer func() {
+ repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{
+ RepoID: repo.ID,
+ Type: unit_model.TypeIssues,
+ }}, nil)
+ setting.Repository.DisabledRepoUnits = []string{}
+ unit_model.LoadUnitConfig()
+ }()
+
+ // Disable both Issues and ExternalTracker units globally
+ setting.Repository.DisabledRepoUnits = []string{"repo.issues", "repo.ext_issues"}
+ unit_model.LoadUnitConfig()
+
+ // Disable the Issues unit
+ err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, nil, []unit_model.Type{unit_model.TypeIssues})
+ require.NoError(t, err)
+
+ // The "Add more" link appears no more
+ assertAddMore(t, false)
+ })
+}
+
+func TestProtectedBranch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user.ID})
+ session := loginUser(t, user.Name)
+
+ t.Run("Add", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ link := fmt.Sprintf("/%s/settings/branches/edit", repo.FullName())
+
+ req := NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": GetCSRF(t, session, link),
+ "rule_name": "master",
+ "enable_push": "true",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // Verify it was added.
+ unittest.AssertExistsIf(t, true, &git_model.ProtectedBranch{RuleName: "master", RepoID: repo.ID})
+ })
+
+ t.Run("Add duplicate", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ link := fmt.Sprintf("/%s/settings/branches/edit", repo.FullName())
+
+ req := NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": GetCSRF(t, session, link),
+ "rule_name": "master",
+ "require_signed_": "true",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.EqualValues(t, "error%3DThere%2Bis%2Balready%2Ba%2Brule%2Bfor%2Bthis%2Bset%2Bof%2Bbranches", flashCookie.Value)
+
+ // Verify it wasn't added.
+ unittest.AssertCount(t, &git_model.ProtectedBranch{RuleName: "master", RepoID: repo.ID}, 1)
+ })
+}
+
+func TestRepoFollowing(t *testing.T) {
+ setting.Federation.Enabled = true
+ defer tests.PrepareTestEnv(t)()
+ defer func() {
+ setting.Federation.Enabled = false
+ }()
+
+ federatedRoutes := http.NewServeMux()
+ federatedRoutes.HandleFunc("/.well-known/nodeinfo",
+ func(res http.ResponseWriter, req *http.Request) {
+ // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/.well-known/nodeinfo
+ responseBody := fmt.Sprintf(`{"links":[{"href":"http://%s/api/v1/nodeinfo","rel":"http://nodeinfo.diaspora.software/ns/schema/2.1"}]}`, req.Host)
+ t.Logf("response: %s", responseBody)
+ // TODO: as soon as content-type will become important: content-type: application/json;charset=utf-8
+ fmt.Fprint(res, responseBody)
+ })
+ federatedRoutes.HandleFunc("/api/v1/nodeinfo",
+ func(res http.ResponseWriter, req *http.Request) {
+ // curl -H "Accept: application/json" https://federated-repo.prod.meissa.de/api/v1/nodeinfo
+ responseBody := fmt.Sprintf(`{"version":"2.1","software":{"name":"forgejo","version":"1.20.0+dev-3183-g976d79044",` +
+ `"repository":"https://codeberg.org/forgejo/forgejo.git","homepage":"https://forgejo.org/"},` +
+ `"protocols":["activitypub"],"services":{"inbound":[],"outbound":["rss2.0"]},` +
+ `"openRegistrations":true,"usage":{"users":{"total":14,"activeHalfyear":2}},"metadata":{}}`)
+ fmt.Fprint(res, responseBody)
+ })
+ repo1InboxReceivedLike := false
+ federatedRoutes.HandleFunc("/api/v1/activitypub/repository-id/1/inbox/",
+ func(res http.ResponseWriter, req *http.Request) {
+ if req.Method != "POST" {
+ t.Errorf("Unhandled request: %q", req.URL.EscapedPath())
+ }
+ buf := new(strings.Builder)
+ _, err := io.Copy(buf, req.Body)
+ if err != nil {
+ t.Errorf("Error reading body: %q", err)
+ }
+ like := fm.ForgeLike{}
+ err = like.UnmarshalJSON([]byte(buf.String()))
+ if err != nil {
+ t.Errorf("Error unmarshalling ForgeLike: %q", err)
+ }
+ if isValid, err := validation.IsValid(like); !isValid {
+ t.Errorf("ForgeLike is not valid: %q", err)
+ }
+
+ activityType := like.Type
+ object := like.Object.GetLink().String()
+ isLikeType := activityType == "Like"
+ isCorrectObject := strings.HasSuffix(object, "/api/v1/activitypub/repository-id/1")
+ if !isLikeType || !isCorrectObject {
+ t.Errorf("Activity is not a like for this repo")
+ }
+
+ repo1InboxReceivedLike = true
+ })
+ federatedRoutes.HandleFunc("/",
+ func(res http.ResponseWriter, req *http.Request) {
+ t.Errorf("Unhandled request: %q", req.URL.EscapedPath())
+ })
+ federatedSrv := httptest.NewServer(federatedRoutes)
+ defer federatedSrv.Close()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1, OwnerID: user.ID})
+ session := loginUser(t, user.Name)
+
+ t.Run("Add a following repo", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ link := fmt.Sprintf("/%s/settings", repo.FullName())
+
+ req := NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": GetCSRF(t, session, link),
+ "action": "federation",
+ "following_repos": fmt.Sprintf("%s/api/v1/activitypub/repository-id/1", federatedSrv.URL),
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // Verify it was added.
+ federationHost := unittest.AssertExistsAndLoadBean(t, &forgefed.FederationHost{HostFqdn: "127.0.0.1"})
+ unittest.AssertExistsAndLoadBean(t, &repo_model.FollowingRepo{
+ ExternalID: "1",
+ FederationHostID: federationHost.ID,
+ })
+ })
+
+ t.Run("Star a repo having a following repo", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ repoLink := fmt.Sprintf("/%s", repo.FullName())
+ link := fmt.Sprintf("%s/action/star", repoLink)
+ req := NewRequestWithValues(t, "POST", link, map[string]string{
+ "_csrf": GetCSRF(t, session, repoLink),
+ })
+ assert.False(t, repo1InboxReceivedLike)
+ session.MakeRequest(t, req, http.StatusOK)
+ assert.True(t, repo1InboxReceivedLike)
+ })
+}
diff --git a/tests/integration/repo_signed_tag_test.go b/tests/integration/repo_signed_tag_test.go
new file mode 100644
index 0000000..41e663c
--- /dev/null
+++ b/tests/integration/repo_signed_tag_test.go
@@ -0,0 +1,107 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "os/exec"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/graceful"
+ repo_module "code.gitea.io/gitea/modules/repository"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestRepoSSHSignedTags(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // Preparations
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo, _, f := tests.CreateDeclarativeRepo(t, user, "", nil, nil, nil)
+ defer f()
+
+ // Set up an SSH key for the tagger
+ tmpDir := t.TempDir()
+ err := os.Chmod(tmpDir, 0o700)
+ require.NoError(t, err)
+
+ signingKey := fmt.Sprintf("%s/ssh_key", tmpDir)
+
+ cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-N", "", "-f", signingKey)
+ err = cmd.Run()
+ require.NoError(t, err)
+
+ // Set up git config for the tagger
+ _ = git.NewCommand(git.DefaultContext, "config", "user.name").AddDynamicArguments(user.Name).Run(&git.RunOpts{Dir: repo.RepoPath()})
+ _ = git.NewCommand(git.DefaultContext, "config", "user.email").AddDynamicArguments(user.Email).Run(&git.RunOpts{Dir: repo.RepoPath()})
+ _ = git.NewCommand(git.DefaultContext, "config", "gpg.format", "ssh").Run(&git.RunOpts{Dir: repo.RepoPath()})
+ _ = git.NewCommand(git.DefaultContext, "config", "user.signingkey").AddDynamicArguments(signingKey).Run(&git.RunOpts{Dir: repo.RepoPath()})
+
+ // Open the git repo
+ gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo)
+ defer gitRepo.Close()
+
+ // Create a signed tag
+ err = git.NewCommand(git.DefaultContext, "tag", "-s", "-m", "this is a signed tag", "ssh-signed-tag").Run(&git.RunOpts{Dir: repo.RepoPath()})
+ require.NoError(t, err)
+
+ // Sync the tag to the DB
+ repo_module.SyncRepoTags(graceful.GetManager().ShutdownContext(), repo.ID)
+
+ // Helper functions
+ assertTagSignedStatus := func(t *testing.T, isSigned bool) {
+ t.Helper()
+
+ req := NewRequestf(t, "GET", "%s/releases/tag/ssh-signed-tag", repo.HTMLURL())
+ resp := MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ doc.AssertElement(t, ".tag-signature-row .gitea-unlock", !isSigned)
+ doc.AssertElement(t, ".tag-signature-row .gitea-lock", isSigned)
+ }
+
+ t.Run("unverified", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertTagSignedStatus(t, false)
+ })
+
+ t.Run("verified", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Upload the signing key
+ keyData, err := os.ReadFile(fmt.Sprintf("%s.pub", signingKey))
+ require.NoError(t, err)
+ key := string(keyData)
+
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+
+ req := NewRequestWithJSON(t, "POST", "/api/v1/user/keys", &api.CreateKeyOption{
+ Key: key,
+ Title: "test key",
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusCreated)
+
+ var pubkey *api.PublicKey
+ DecodeJSON(t, resp, &pubkey)
+
+ // Mark the key as verified
+ db.GetEngine(db.DefaultContext).Exec("UPDATE `public_key` SET verified = true WHERE id = ?", pubkey.ID)
+
+ // Check the tag page
+ assertTagSignedStatus(t, true)
+ })
+}
diff --git a/tests/integration/repo_starwatch_test.go b/tests/integration/repo_starwatch_test.go
new file mode 100644
index 0000000..a8bad30
--- /dev/null
+++ b/tests/integration/repo_starwatch_test.go
@@ -0,0 +1,108 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func testRepoStarringOrWatching(t *testing.T, action, listURI string) {
+ t.Helper()
+
+ defer tests.PrepareTestEnv(t)()
+
+ oppositeAction := "un" + action
+ session := loginUser(t, "user5")
+
+ // Star/Watch the repo as user5
+ req := NewRequestWithValues(t, "POST", fmt.Sprintf("/user2/repo1/action/%s", action), map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1"),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // Load the repo home as user5
+ req = NewRequest(t, "GET", "/user2/repo1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ // Verify that the star/watch button is now the opposite
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ actionButton := htmlDoc.Find(fmt.Sprintf("form[action='/user2/repo1/action/%s']", oppositeAction))
+ assert.Equal(t, 1, actionButton.Length())
+ text := strings.ToLower(actionButton.Find("button span.text").Text())
+ assert.Equal(t, oppositeAction, text)
+
+ // Load stargazers/watchers as user5
+ req = NewRequestf(t, "GET", "/user2/repo1/%s", listURI)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ // Verify that "user5" is among the stargazers/watchers
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, ".user-cards .list .card > a[href='/user5']", true)
+
+ // Unstar/unwatch the repo as user5
+ req = NewRequestWithValues(t, "POST", fmt.Sprintf("/user2/repo1/action/%s", oppositeAction), map[string]string{
+ "_csrf": GetCSRF(t, session, "/user2/repo1"),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // Load the repo home as user5
+ req = NewRequest(t, "GET", "/user2/repo1")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ // Verify that the star/watch button is now back to its default
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ actionButton = htmlDoc.Find(fmt.Sprintf("form[action='/user2/repo1/action/%s']", action))
+ assert.Equal(t, 1, actionButton.Length())
+ text = strings.ToLower(actionButton.Find("button span.text").Text())
+ assert.Equal(t, action, text)
+
+ // Load stargazers/watchers as user5
+ req = NewRequestf(t, "GET", "/user2/repo1/%s", listURI)
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ // Verify that "user5" is not among the stargazers/watchers
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, ".user-cards .list .item.ui.segment > a[href='/user5']", false)
+}
+
+func TestRepoStarUnstarUI(t *testing.T) {
+ testRepoStarringOrWatching(t, "star", "stars")
+}
+
+func TestRepoWatchUnwatchUI(t *testing.T) {
+ testRepoStarringOrWatching(t, "watch", "watchers")
+}
+
+func TestDisabledStars(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Repository.DisableStars, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ t.Run("repo star, unstar", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "POST", "/user2/repo1/action/star")
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "POST", "/user2/repo1/action/unstar")
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("repo stargazers", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/stars")
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+}
diff --git a/tests/integration/repo_tag_test.go b/tests/integration/repo_tag_test.go
new file mode 100644
index 0000000..d5539cb
--- /dev/null
+++ b/tests/integration/repo_tag_test.go
@@ -0,0 +1,165 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/models/db"
+ git_model "code.gitea.io/gitea/models/git"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/services/release"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTagViewWithoutRelease(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ defer func() {
+ releases, err := db.Find[repo_model.Release](db.DefaultContext, repo_model.FindReleasesOptions{
+ IncludeTags: true,
+ TagNames: []string{"no-release"},
+ RepoID: repo.ID,
+ })
+ require.NoError(t, err)
+
+ for _, release := range releases {
+ _, err = db.DeleteByID[repo_model.Release](db.DefaultContext, release.ID)
+ require.NoError(t, err)
+ }
+ }()
+
+ err := release.CreateNewTag(git.DefaultContext, owner, repo, "master", "no-release", "release-less tag")
+ require.NoError(t, err)
+
+ // Test that the page loads
+ req := NewRequestf(t, "GET", "/%s/releases/tag/no-release", repo.FullName())
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ // Test that the tags sub-menu is active and has a counter
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ tagsTab := htmlDoc.Find(".small-menu-items .active.item[href$='/tags']")
+ assert.Contains(t, tagsTab.Text(), "4 tags")
+
+ // Test that the release sub-menu isn't active
+ releaseLink := htmlDoc.Find(".small-menu-items .item[href$='/releases']")
+ assert.False(t, releaseLink.HasClass("active"))
+
+ // Test that the title is displayed
+ releaseTitle := strings.TrimSpace(htmlDoc.Find("h4.release-list-title > a").Text())
+ assert.Equal(t, "no-release", releaseTitle)
+
+ // Test that there is no "Stable" link
+ htmlDoc.AssertElement(t, "h4.release-list-title > span.ui.green.label", false)
+}
+
+func TestCreateNewTagProtected(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ t.Run("Code", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ err := release.CreateNewTag(git.DefaultContext, owner, repo, "master", "t-first", "first tag")
+ require.NoError(t, err)
+
+ err = release.CreateNewTag(git.DefaultContext, owner, repo, "master", "v-2", "second tag")
+ require.Error(t, err)
+ assert.True(t, models.IsErrProtectedTagName(err))
+
+ err = release.CreateNewTag(git.DefaultContext, owner, repo, "master", "v-1.1", "third tag")
+ require.NoError(t, err)
+ })
+
+ t.Run("Git", func(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ httpContext := NewAPITestContext(t, owner.Name, repo.Name)
+
+ dstPath := t.TempDir()
+
+ u.Path = httpContext.GitPath()
+ u.User = url.UserPassword(owner.Name, userPassword)
+
+ doGitClone(dstPath, u)(t)
+
+ _, _, err := git.NewCommand(git.DefaultContext, "tag", "v-2").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+
+ _, _, err = git.NewCommand(git.DefaultContext, "push", "--tags").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "Tag v-2 is protected")
+ })
+ })
+
+ t.Run("GitTagForce", func(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ httpContext := NewAPITestContext(t, owner.Name, repo.Name)
+
+ dstPath := t.TempDir()
+
+ u.Path = httpContext.GitPath()
+ u.User = url.UserPassword(owner.Name, userPassword)
+
+ doGitClone(dstPath, u)(t)
+
+ _, _, err := git.NewCommand(git.DefaultContext, "tag", "v-1.1", "-m", "force update", "--force").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+
+ _, _, err = git.NewCommand(git.DefaultContext, "push", "--tags").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+
+ _, _, err = git.NewCommand(git.DefaultContext, "tag", "v-1.1", "-m", "force update v2", "--force").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+
+ _, _, err = git.NewCommand(git.DefaultContext, "push", "--tags").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "the tag already exists in the remote")
+
+ _, _, err = git.NewCommand(git.DefaultContext, "push", "--tags", "--force").RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+ req := NewRequestf(t, "GET", "/%s/releases/tag/v-1.1", repo.FullName())
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ tagsTab := htmlDoc.Find(".release-list-title")
+ assert.Contains(t, tagsTab.Text(), "force update v2")
+ })
+ })
+
+ // Cleanup
+ releases, err := db.Find[repo_model.Release](db.DefaultContext, repo_model.FindReleasesOptions{
+ IncludeTags: true,
+ TagNames: []string{"v-1", "v-1.1"},
+ RepoID: repo.ID,
+ })
+ require.NoError(t, err)
+
+ for _, release := range releases {
+ _, err = db.DeleteByID[repo_model.Release](db.DefaultContext, release.ID)
+ require.NoError(t, err)
+ }
+
+ protectedTags, err := git_model.GetProtectedTags(db.DefaultContext, repo.ID)
+ require.NoError(t, err)
+
+ for _, protectedTag := range protectedTags {
+ err = git_model.DeleteProtectedTag(db.DefaultContext, protectedTag)
+ require.NoError(t, err)
+ }
+}
diff --git a/tests/integration/repo_test.go b/tests/integration/repo_test.go
new file mode 100644
index 0000000..2d12df7
--- /dev/null
+++ b/tests/integration/repo_test.go
@@ -0,0 +1,1379 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/modules/translation"
+ repo_service "code.gitea.io/gitea/services/repository"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestViewRepo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ req := NewRequest(t, "GET", "/user2/repo1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ noDescription := htmlDoc.doc.Find("#repo-desc").Children()
+ repoTopics := htmlDoc.doc.Find("#repo-topics").Children()
+ repoSummary := htmlDoc.doc.Find(".repository-summary").Children()
+
+ assert.True(t, noDescription.HasClass("no-description"))
+ assert.True(t, repoTopics.HasClass("repo-topic"))
+ assert.True(t, repoSummary.HasClass("repository-menu"))
+
+ req = NewRequest(t, "GET", "/org3/repo3")
+ MakeRequest(t, req, http.StatusNotFound)
+
+ session = loginUser(t, "user1")
+ session.MakeRequest(t, req, http.StatusNotFound)
+}
+
+func testViewRepo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/org3/repo3")
+ session := loginUser(t, "user2")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ files := htmlDoc.doc.Find("#repo-files-table > TBODY > TR")
+
+ type file struct {
+ fileName string
+ commitID string
+ commitMsg string
+ commitTime string
+ }
+
+ var items []file
+
+ files.Each(func(i int, s *goquery.Selection) {
+ tds := s.Find("td")
+ var f file
+ tds.Each(func(i int, s *goquery.Selection) {
+ if i == 0 {
+ f.fileName = strings.TrimSpace(s.Text())
+ } else if i == 1 {
+ a := s.Find("a")
+ f.commitMsg = strings.TrimSpace(a.Text())
+ l, _ := a.Attr("href")
+ f.commitID = path.Base(l)
+ }
+ })
+
+ // convert "2017-06-14 21:54:21 +0800" to "Wed, 14 Jun 2017 13:54:21 UTC"
+ htmlTimeString, _ := s.Find("relative-time").Attr("datetime")
+ htmlTime, _ := time.Parse(time.RFC3339, htmlTimeString)
+ f.commitTime = htmlTime.In(time.Local).Format(time.RFC1123)
+ items = append(items, f)
+ })
+
+ commitT := time.Date(2017, time.June, 14, 13, 54, 21, 0, time.UTC).In(time.Local).Format(time.RFC1123)
+ assert.EqualValues(t, []file{
+ {
+ fileName: "doc",
+ commitID: "2a47ca4b614a9f5a43abbd5ad851a54a616ffee6",
+ commitMsg: "init project",
+ commitTime: commitT,
+ },
+ {
+ fileName: "README.md",
+ commitID: "2a47ca4b614a9f5a43abbd5ad851a54a616ffee6",
+ commitMsg: "init project",
+ commitTime: commitT,
+ },
+ }, items)
+}
+
+func TestViewRepo2(t *testing.T) {
+ // no last commit cache
+ testViewRepo(t)
+
+ // enable last commit cache for all repositories
+ oldCommitsCount := setting.CacheService.LastCommit.CommitsCount
+ setting.CacheService.LastCommit.CommitsCount = 0
+ // first view will not hit the cache
+ testViewRepo(t)
+ // second view will hit the cache
+ testViewRepo(t)
+ setting.CacheService.LastCommit.CommitsCount = oldCommitsCount
+}
+
+func TestViewRepo3(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/org3/repo3")
+ session := loginUser(t, "user4")
+ session.MakeRequest(t, req, http.StatusOK)
+}
+
+func TestViewRepo1CloneLinkAnonymous(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ link, exists := htmlDoc.doc.Find("#repo-clone-https").Attr("data-link")
+ assert.True(t, exists, "The template has changed")
+ assert.Equal(t, setting.AppURL+"user2/repo1.git", link)
+ _, exists = htmlDoc.doc.Find("#repo-clone-ssh").Attr("data-link")
+ assert.False(t, exists)
+}
+
+func TestViewRepo1CloneLinkAuthorized(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ req := NewRequest(t, "GET", "/user2/repo1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ link, exists := htmlDoc.doc.Find("#repo-clone-https").Attr("data-link")
+ assert.True(t, exists, "The template has changed")
+ assert.Equal(t, setting.AppURL+"user2/repo1.git", link)
+ link, exists = htmlDoc.doc.Find("#repo-clone-ssh").Attr("data-link")
+ assert.True(t, exists, "The template has changed")
+ sshURL := fmt.Sprintf("ssh://%s@%s:%d/user2/repo1.git", setting.SSH.User, setting.SSH.Domain, setting.SSH.Port)
+ assert.Equal(t, sshURL, link)
+}
+
+func TestViewRepoWithSymlinks(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ req := NewRequest(t, "GET", "/user2/repo20.git")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ files := htmlDoc.doc.Find("#repo-files-table > TBODY > TR > TD.name > SPAN.truncate")
+ items := files.Map(func(i int, s *goquery.Selection) string {
+ cls, _ := s.Find("SVG").Attr("class")
+ file := strings.Trim(s.Find("A").Text(), " \t\n")
+ return fmt.Sprintf("%s: %s", file, cls)
+ })
+ assert.Len(t, items, 5)
+ assert.Equal(t, "a: tw-mr-2 svg octicon-file-directory-fill", items[0])
+ assert.Equal(t, "link_b: tw-mr-2 svg octicon-file-directory-symlink", items[1])
+ assert.Equal(t, "link_d: tw-mr-2 svg octicon-file-symlink-file", items[2])
+ assert.Equal(t, "link_hi: tw-mr-2 svg octicon-file-symlink-file", items[3])
+ assert.Equal(t, "link_link: tw-mr-2 svg octicon-file-symlink-file", items[4])
+}
+
+// TestViewAsRepoAdmin tests PR #2167
+func TestViewAsRepoAdmin(t *testing.T) {
+ for _, user := range []string{"user2", "user4"} {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, user)
+
+ req := NewRequest(t, "GET", "/user2/repo1.git")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ noDescription := htmlDoc.doc.Find("#repo-desc").Children()
+ repoTopics := htmlDoc.doc.Find("#repo-topics").Children()
+ repoSummary := htmlDoc.doc.Find(".repository-summary").Children()
+
+ assert.True(t, noDescription.HasClass("no-description"))
+ assert.True(t, repoTopics.HasClass("repo-topic"))
+ assert.True(t, repoSummary.HasClass("repository-menu"))
+ }
+}
+
+func TestRepoHTMLTitle(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("Repository homepage", func(t *testing.T) {
+ t.Run("Without description", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1")
+ assert.EqualValues(t, "user2/repo1 - Forgejo: Beyond coding. We Forge.", htmlTitle)
+ })
+ t.Run("With description", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ htmlTitle := GetHTMLTitle(t, nil, "/user27/repo49")
+ assert.EqualValues(t, "user27/repo49: A wonderful repository with more than just a README.md - Forgejo: Beyond coding. We Forge.", htmlTitle)
+ })
+ })
+
+ t.Run("Code view", func(t *testing.T) {
+ t.Run("Directory", func(t *testing.T) {
+ t.Run("Default branch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/branch/master/deep/nesting")
+ assert.EqualValues(t, "repo59/deep/nesting at master - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle)
+ })
+ t.Run("Non-default branch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/branch/cake-recipe/deep/nesting")
+ assert.EqualValues(t, "repo59/deep/nesting at cake-recipe - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle)
+ })
+ t.Run("Commit", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/commit/d8f53dfb33f6ccf4169c34970b5e747511c18beb/deep/nesting/")
+ assert.EqualValues(t, "repo59/deep/nesting at d8f53dfb33f6ccf4169c34970b5e747511c18beb - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle)
+ })
+ t.Run("Tag", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/tag/v1.0/deep/nesting/")
+ assert.EqualValues(t, "repo59/deep/nesting at v1.0 - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle)
+ })
+ })
+ t.Run("File", func(t *testing.T) {
+ t.Run("Default branch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/branch/master/deep/nesting/folder/secret_sauce_recipe.txt")
+ assert.EqualValues(t, "repo59/deep/nesting/folder/secret_sauce_recipe.txt at master - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle)
+ })
+ t.Run("Non-default branch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/branch/cake-recipe/deep/nesting/folder/secret_sauce_recipe.txt")
+ assert.EqualValues(t, "repo59/deep/nesting/folder/secret_sauce_recipe.txt at cake-recipe - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle)
+ })
+ t.Run("Commit", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/commit/d8f53dfb33f6ccf4169c34970b5e747511c18beb/deep/nesting/folder/secret_sauce_recipe.txt")
+ assert.EqualValues(t, "repo59/deep/nesting/folder/secret_sauce_recipe.txt at d8f53dfb33f6ccf4169c34970b5e747511c18beb - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle)
+ })
+ t.Run("Tag", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ htmlTitle := GetHTMLTitle(t, nil, "/user2/repo59/src/tag/v1.0/deep/nesting/folder/secret_sauce_recipe.txt")
+ assert.EqualValues(t, "repo59/deep/nesting/folder/secret_sauce_recipe.txt at v1.0 - user2/repo59 - Forgejo: Beyond coding. We Forge.", htmlTitle)
+ })
+ })
+ })
+
+ t.Run("Issues view", func(t *testing.T) {
+ t.Run("Overview page", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1/issues")
+ assert.EqualValues(t, "Issues - user2/repo1 - Forgejo: Beyond coding. We Forge.", htmlTitle)
+ })
+ t.Run("View issue page", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1/issues/1")
+ assert.EqualValues(t, "#1 - issue1 - user2/repo1 - Forgejo: Beyond coding. We Forge.", htmlTitle)
+ })
+ })
+
+ t.Run("Pull requests view", func(t *testing.T) {
+ t.Run("Overview page", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1/pulls")
+ assert.EqualValues(t, "Pull requests - user2/repo1 - Forgejo: Beyond coding. We Forge.", htmlTitle)
+ })
+ t.Run("View pull request", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ htmlTitle := GetHTMLTitle(t, nil, "/user2/repo1/pulls/2")
+ assert.EqualValues(t, "#2 - issue2 - user2/repo1 - Forgejo: Beyond coding. We Forge.", htmlTitle)
+ })
+ })
+}
+
+// TestViewFileInRepo repo description, topics and summary should not be displayed when viewing a file
+func TestViewFileInRepo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ req := NewRequest(t, "GET", "/user2/repo1/src/branch/master/README.md")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ description := htmlDoc.doc.Find("#repo-desc")
+ repoTopics := htmlDoc.doc.Find("#repo-topics")
+ repoSummary := htmlDoc.doc.Find(".repository-summary")
+
+ assert.EqualValues(t, 0, description.Length())
+ assert.EqualValues(t, 0, repoTopics.Length())
+ assert.EqualValues(t, 0, repoSummary.Length())
+}
+
+func TestViewFileInRepoRSSFeed(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ hasFileRSSFeed := func(t *testing.T, ref string) bool {
+ t.Helper()
+
+ req := NewRequestf(t, "GET", "/user2/repo1/src/%s/README.md", ref)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ fileFeed := htmlDoc.doc.Find(`a[href*="/user2/repo1/rss/"]`)
+
+ return fileFeed.Length() != 0
+ }
+
+ t.Run("branch", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assert.True(t, hasFileRSSFeed(t, "branch/master"))
+ })
+
+ t.Run("tag", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assert.False(t, hasFileRSSFeed(t, "tag/v1.1"))
+ })
+
+ t.Run("commit", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assert.False(t, hasFileRSSFeed(t, "commit/65f1bf27bc3bf70f64657658635e66094edbcb4d"))
+ })
+}
+
+// TestBlameFileInRepo repo description, topics and summary should not be displayed when running blame on a file
+func TestBlameFileInRepo(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ t.Run("Assert", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/blame/branch/master/README.md")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ description := htmlDoc.doc.Find("#repo-desc")
+ repoTopics := htmlDoc.doc.Find("#repo-topics")
+ repoSummary := htmlDoc.doc.Find(".repository-summary")
+
+ assert.EqualValues(t, 0, description.Length())
+ assert.EqualValues(t, 0, repoTopics.Length())
+ assert.EqualValues(t, 0, repoSummary.Length())
+ })
+
+ t.Run("File size", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ gitRepo, err := git.OpenRepository(git.DefaultContext, repo.RepoPath())
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ commit, err := gitRepo.GetCommit("HEAD")
+ require.NoError(t, err)
+
+ blob, err := commit.GetBlobByPath("README.md")
+ require.NoError(t, err)
+
+ fileSize := blob.Size()
+ require.NotZero(t, fileSize)
+
+ t.Run("Above maximum", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.UI.MaxDisplayFileSize, fileSize)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/blame/branch/master/README.md")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Contains(t, htmlDoc.Find(".code-view").Text(), translation.NewLocale("en-US").Tr("repo.file_too_large"))
+ })
+
+ t.Run("Under maximum", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.UI.MaxDisplayFileSize, fileSize+1)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/blame/branch/master/README.md")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.NotContains(t, htmlDoc.Find(".code-view").Text(), translation.NewLocale("en-US").Tr("repo.file_too_large"))
+ })
+ })
+}
+
+// TestViewRepoDirectory repo description, topics and summary should not be displayed when within a directory
+func TestViewRepoDirectory(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ req := NewRequest(t, "GET", "/user2/repo20/src/branch/master/a")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ description := htmlDoc.doc.Find("#repo-desc")
+ repoTopics := htmlDoc.doc.Find("#repo-topics")
+ repoSummary := htmlDoc.doc.Find(".repository-summary")
+
+ repoFilesTable := htmlDoc.doc.Find("#repo-files-table")
+ assert.NotEmpty(t, repoFilesTable.Nodes)
+
+ assert.Zero(t, description.Length())
+ assert.Zero(t, repoTopics.Length())
+ assert.Zero(t, repoSummary.Length())
+}
+
+// ensure that the all the different ways to find and render a README work
+func TestViewRepoDirectoryReadme(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // there are many combinations:
+ // - READMEs can be .md, .txt, or have no extension
+ // - READMEs can be tagged with a language and even a country code
+ // - READMEs can be stored in docs/, .gitea/, or .github/
+ // - READMEs can be symlinks to other files
+ // - READMEs can be broken symlinks which should not render
+ //
+ // this doesn't cover all possible cases, just the major branches of the code
+
+ session := loginUser(t, "user2")
+
+ check := func(name, url, expectedFilename, expectedReadmeType, expectedContent string) {
+ t.Run(name, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", url)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ readmeName := htmlDoc.doc.Find("h4.file-header")
+ readmeContent := htmlDoc.doc.Find(".file-view") // TODO: add a id="readme" to the output to make this test more precise
+ readmeType, _ := readmeContent.Attr("class")
+
+ assert.Equal(t, expectedFilename, strings.TrimSpace(readmeName.Text()))
+ assert.Contains(t, readmeType, expectedReadmeType)
+ assert.Contains(t, readmeContent.Text(), expectedContent)
+ })
+ }
+
+ // viewing the top level
+ check("Home", "/user2/readme-test/", "README.md", "markdown", "The cake is a lie.")
+
+ // viewing different file extensions
+ check("md", "/user2/readme-test/src/branch/master/", "README.md", "markdown", "The cake is a lie.")
+ check("txt", "/user2/readme-test/src/branch/txt/", "README.txt", "plain-text", "My spoon is too big.")
+ check("plain", "/user2/readme-test/src/branch/plain/", "README", "plain-text", "Birken my stocks gee howdy")
+ check("i18n", "/user2/readme-test/src/branch/i18n/", "README.zh.md", "markdown", "蛋糕是一个谎言")
+
+ // using HEAD ref
+ check("branch-HEAD", "/user2/readme-test/src/branch/HEAD/", "README.md", "markdown", "The cake is a lie.")
+ check("commit-HEAD", "/user2/readme-test/src/commit/HEAD/", "README.md", "markdown", "The cake is a lie.")
+
+ // viewing different subdirectories
+ check("subdir", "/user2/readme-test/src/branch/subdir/libcake", "README.md", "markdown", "Four pints of sugar.")
+ check("docs-direct", "/user2/readme-test/src/branch/special-subdir-docs/docs/", "README.md", "markdown", "This is in docs/")
+ check("docs", "/user2/readme-test/src/branch/special-subdir-docs/", "docs/README.md", "markdown", "This is in docs/")
+ check(".gitea", "/user2/readme-test/src/branch/special-subdir-.gitea/", ".gitea/README.md", "markdown", "This is in .gitea/")
+ check(".github", "/user2/readme-test/src/branch/special-subdir-.github/", ".github/README.md", "markdown", "This is in .github/")
+
+ // symlinks
+ // symlinks are subtle:
+ // - they should be able to handle going a reasonable number of times up and down in the tree
+ // - they shouldn't get stuck on link cycles
+ // - they should determine the filetype based on the name of the link, not the target
+ check("symlink", "/user2/readme-test/src/branch/symlink/", "README.md", "markdown", "This is in some/other/path")
+ check("symlink-multiple", "/user2/readme-test/src/branch/symlink/some/", "README.txt", "plain-text", "This is in some/other/path")
+ check("symlink-up-and-down", "/user2/readme-test/src/branch/symlink/up/back/down/down", "README.md", "markdown", "It's a me, mario")
+
+ // testing fallback rules
+ // READMEs are searched in this order:
+ // - [README.zh-cn.md, README.zh_cn.md, README.zh.md, README_zh.md, README.md, README.txt, README,
+ // docs/README.zh-cn.md, docs/README.zh_cn.md, docs/README.zh.md, docs/README_zh.md, docs/README.md, docs/README.txt, docs/README,
+ // .gitea/README.zh-cn.md, .gitea/README.zh_cn.md, .gitea/README.zh.md, .gitea/README_zh.md, .gitea/README.md, .gitea/README.txt, .gitea/README,
+
+ // .github/README.zh-cn.md, .github/README.zh_cn.md, .github/README.zh.md, .github/README_zh.md, .github/README.md, .github/README.txt, .github/README]
+ // and a broken/looped symlink counts as not existing at all and should be skipped.
+ // again, this doesn't cover all cases, but it covers a few
+ check("fallback/top", "/user2/readme-test/src/branch/fallbacks/", "README.en.md", "markdown", "This is README.en.md")
+ check("fallback/2", "/user2/readme-test/src/branch/fallbacks2/", "README.md", "markdown", "This is README.md")
+ check("fallback/3", "/user2/readme-test/src/branch/fallbacks3/", "README", "plain-text", "This is README")
+ check("fallback/4", "/user2/readme-test/src/branch/fallbacks4/", "docs/README.en.md", "markdown", "This is docs/README.en.md")
+ check("fallback/5", "/user2/readme-test/src/branch/fallbacks5/", "docs/README.md", "markdown", "This is docs/README.md")
+ check("fallback/6", "/user2/readme-test/src/branch/fallbacks6/", "docs/README", "plain-text", "This is docs/README")
+ check("fallback/7", "/user2/readme-test/src/branch/fallbacks7/", ".gitea/README.en.md", "markdown", "This is .gitea/README.en.md")
+ check("fallback/8", "/user2/readme-test/src/branch/fallbacks8/", ".gitea/README.md", "markdown", "This is .gitea/README.md")
+ check("fallback/9", "/user2/readme-test/src/branch/fallbacks9/", ".gitea/README", "plain-text", "This is .gitea/README")
+
+ // this case tests that broken symlinks count as missing files, instead of rendering their contents
+ check("fallbacks-broken-symlinks", "/user2/readme-test/src/branch/fallbacks-broken-symlinks/", "docs/README", "plain-text", "This is docs/README")
+
+ // some cases that should NOT render a README
+ // - /readme
+ // - /.github/docs/README.md
+ // - a symlink loop
+
+ missing := func(name, url string) {
+ t.Run("missing/"+name, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", url)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ _, exists := htmlDoc.doc.Find(".file-view").Attr("class")
+
+ assert.False(t, exists, "README should not have rendered")
+ })
+ }
+ missing("sp-ace", "/user2/readme-test/src/branch/sp-ace/")
+ missing("nested-special", "/user2/readme-test/src/branch/special-subdir-nested/subproject") // the special subdirs should only trigger on the repo root
+ missing("special-subdir-nested", "/user2/readme-test/src/branch/special-subdir-nested/")
+ missing("symlink-loop", "/user2/readme-test/src/branch/symlink-loop/")
+}
+
+func TestRenamedFileHistory(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("Renamed file", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo59/commits/branch/master/license")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ renameNotice := htmlDoc.doc.Find(".ui.bottom.attached.header")
+ assert.Equal(t, 1, renameNotice.Length())
+ assert.Contains(t, renameNotice.Text(), "Renamed from licnse (Browse further)")
+
+ oldFileHistoryLink, ok := renameNotice.Find("a").Attr("href")
+ assert.True(t, ok)
+ assert.Equal(t, "/user2/repo59/commits/commit/80b83c5c8220c3aa3906e081f202a2a7563ec879/licnse", oldFileHistoryLink)
+ })
+
+ t.Run("Non renamed file", func(t *testing.T) {
+ req := NewRequest(t, "GET", "/user2/repo59/commits/branch/master/README.md")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, ".ui.bottom.attached.header", false)
+ })
+}
+
+func TestMarkDownReadmeImage(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ req := NewRequest(t, "GET", "/user2/repo1/src/branch/home-md-img-check")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ src, exists := htmlDoc.doc.Find(`.markdown img`).Attr("src")
+ assert.True(t, exists, "Image not found in README")
+ assert.Equal(t, "/user2/repo1/media/branch/home-md-img-check/test-fake-img.jpg", src)
+
+ req = NewRequest(t, "GET", "/user2/repo1/src/branch/home-md-img-check/README.md")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ src, exists = htmlDoc.doc.Find(`.markdown img`).Attr("src")
+ assert.True(t, exists, "Image not found in markdown file")
+ assert.Equal(t, "/user2/repo1/media/branch/home-md-img-check/test-fake-img.jpg", src)
+}
+
+func TestMarkDownReadmeImageSubfolder(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ // this branch has the README in the special docs/README.md location
+ req := NewRequest(t, "GET", "/user2/repo1/src/branch/sub-home-md-img-check")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ src, exists := htmlDoc.doc.Find(`.markdown img`).Attr("src")
+ assert.True(t, exists, "Image not found in README")
+ assert.Equal(t, "/user2/repo1/media/branch/sub-home-md-img-check/docs/test-fake-img.jpg", src)
+
+ req = NewRequest(t, "GET", "/user2/repo1/src/branch/sub-home-md-img-check/docs/README.md")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ src, exists = htmlDoc.doc.Find(`.markdown img`).Attr("src")
+ assert.True(t, exists, "Image not found in markdown file")
+ assert.Equal(t, "/user2/repo1/media/branch/sub-home-md-img-check/docs/test-fake-img.jpg", src)
+}
+
+func TestGeneratedSourceLink(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("Rendered file", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ req := NewRequest(t, "GET", "/user2/repo1/src/branch/master/README.md?display=source")
+ resp := MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ dataURL, exists := doc.doc.Find(".copy-line-permalink").Attr("data-url")
+ assert.True(t, exists)
+ assert.Equal(t, "/user2/repo1/src/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d/README.md?display=source", dataURL)
+
+ dataURL, exists = doc.doc.Find(".ref-in-new-issue").Attr("data-url-param-body-link")
+ assert.True(t, exists)
+ assert.Equal(t, "/user2/repo1/src/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d/README.md?display=source", dataURL)
+ })
+
+ t.Run("Non-Rendered file", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ session := loginUser(t, "user27")
+ req := NewRequest(t, "GET", "/user27/repo49/src/branch/master/test/test.txt")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+
+ dataURL, exists := doc.doc.Find(".copy-line-permalink").Attr("data-url")
+ assert.True(t, exists)
+ assert.Equal(t, "/user27/repo49/src/commit/aacbdfe9e1c4b47f60abe81849045fa4e96f1d75/test/test.txt", dataURL)
+
+ dataURL, exists = doc.doc.Find(".ref-in-new-issue").Attr("data-url-param-body-link")
+ assert.True(t, exists)
+ assert.Equal(t, "/user27/repo49/src/commit/aacbdfe9e1c4b47f60abe81849045fa4e96f1d75/test/test.txt", dataURL)
+ })
+}
+
+func TestViewCommit(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/commit/0123456789012345678901234567890123456789")
+ req.Header.Add("Accept", "text/html")
+ resp := MakeRequest(t, req, http.StatusNotFound)
+ assert.True(t, test.IsNormalPageCompleted(resp.Body.String()), "non-existing commit should render 404 page")
+}
+
+func TestCommitView(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("Non-existent commit", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/commit/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+ req.SetHeader("Accept", "text/html")
+ resp := MakeRequest(t, req, http.StatusNotFound)
+
+ // Really ensure that 404 is being sent back.
+ doc := NewHTMLParser(t, resp.Body)
+ doc.AssertElement(t, `[aria-label="Page Not Found"]`, true)
+ })
+
+ t.Run("Too short commit ID", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/commit/65f")
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+
+ t.Run("Short commit ID", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/commit/65f1")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ commitTitle := doc.Find(".commit-summary").Text()
+ assert.Contains(t, commitTitle, "Initial commit")
+
+ req = NewRequest(t, "GET", "/user2/repo1/src/commit/65f1")
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ doc = NewHTMLParser(t, resp.Body)
+ commitTitle = doc.Find(".shortsha").Text()
+ assert.Contains(t, commitTitle, "65f1bf27bc")
+ })
+
+ t.Run("Full commit ID", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/commit/65f1bf27bc3bf70f64657658635e66094edbcb4d")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ commitTitle := doc.Find(".commit-summary").Text()
+ assert.Contains(t, commitTitle, "Initial commit")
+ })
+}
+
+func TestRepoHomeViewRedirect(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("Code", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ l := doc.Find("#repo-desc").Length()
+ assert.Equal(t, 1, l)
+ })
+
+ t.Run("No Code redirects to Issues", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Disable the Code unit
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, nil, []unit_model.Type{
+ unit_model.TypeCode,
+ })
+ require.NoError(t, err)
+
+ // The repo home should redirect to the built-in issue tracker
+ req := NewRequest(t, "GET", "/user2/repo1")
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ redir := resp.Header().Get("Location")
+
+ assert.Equal(t, "/user2/repo1/issues", redir)
+ })
+
+ t.Run("No Code and ExternalTracker redirects to Pulls", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Replace the internal tracker with an external one
+ // Disable Code, Projects, Packages, and Actions
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{
+ RepoID: repo.ID,
+ Type: unit_model.TypeExternalTracker,
+ Config: &repo_model.ExternalTrackerConfig{
+ ExternalTrackerURL: "https://example.com",
+ },
+ }}, []unit_model.Type{
+ unit_model.TypeCode,
+ unit_model.TypeIssues,
+ unit_model.TypeProjects,
+ unit_model.TypePackages,
+ unit_model.TypeActions,
+ })
+ require.NoError(t, err)
+
+ // The repo home should redirect to pull requests
+ req := NewRequest(t, "GET", "/user2/repo1")
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ redir := resp.Header().Get("Location")
+
+ assert.Equal(t, "/user2/repo1/pulls", redir)
+ })
+
+ t.Run("Only external wiki results in 404", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Replace the internal wiki with an external, and disable everything
+ // else.
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, []repo_model.RepoUnit{{
+ RepoID: repo.ID,
+ Type: unit_model.TypeExternalWiki,
+ Config: &repo_model.ExternalWikiConfig{
+ ExternalWikiURL: "https://example.com",
+ },
+ }}, []unit_model.Type{
+ unit_model.TypeCode,
+ unit_model.TypeIssues,
+ unit_model.TypeExternalTracker,
+ unit_model.TypeProjects,
+ unit_model.TypePackages,
+ unit_model.TypeActions,
+ unit_model.TypePullRequests,
+ unit_model.TypeReleases,
+ unit_model.TypeWiki,
+ })
+ require.NoError(t, err)
+
+ // The repo home ends up being 404
+ req := NewRequest(t, "GET", "/user2/repo1")
+ req.Header.Set("Accept", "text/html")
+ resp := MakeRequest(t, req, http.StatusNotFound)
+
+ // The external wiki is linked to from the 404 page
+ doc := NewHTMLParser(t, resp.Body)
+ txt := strings.TrimSpace(doc.Find(`a[href="https://example.com"]`).Text())
+ assert.Equal(t, "Wiki", txt)
+ })
+}
+
+func TestRepoFilesList(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ // create the repo
+ repo, _, f := tests.CreateDeclarativeRepo(t, user2, "",
+ []unit_model.Type{unit_model.TypeCode}, nil,
+ []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "zEta",
+ ContentReader: strings.NewReader("zeta"),
+ },
+ {
+ Operation: "create",
+ TreePath: "licensa",
+ ContentReader: strings.NewReader("licensa"),
+ },
+ {
+ Operation: "create",
+ TreePath: "licensz",
+ ContentReader: strings.NewReader("licensz"),
+ },
+ {
+ Operation: "create",
+ TreePath: "delta",
+ ContentReader: strings.NewReader("delta"),
+ },
+ {
+ Operation: "create",
+ TreePath: "Charlie/aa.txt",
+ ContentReader: strings.NewReader("charlie"),
+ },
+ {
+ Operation: "create",
+ TreePath: "Beta",
+ ContentReader: strings.NewReader("beta"),
+ },
+ {
+ Operation: "create",
+ TreePath: "alpha",
+ ContentReader: strings.NewReader("alpha"),
+ },
+ },
+ )
+ defer f()
+
+ req := NewRequest(t, "GET", "/"+repo.FullName())
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ filesList := htmlDoc.Find("#repo-files-table tbody tr").Map(func(_ int, s *goquery.Selection) string {
+ return s.AttrOr("data-entryname", "")
+ })
+
+ assert.EqualValues(t, []string{"Charlie", "alpha", "Beta", "delta", "licensa", "LICENSE", "licensz", "README.md", "zEta"}, filesList)
+ })
+}
+
+func TestRepoFollowSymlink(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+
+ assertCase := func(t *testing.T, url, expectedSymlinkURL string, shouldExist bool) {
+ t.Helper()
+
+ req := NewRequest(t, "GET", url)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ symlinkURL, ok := htmlDoc.Find(".file-actions .button[data-kind='follow-symlink']").Attr("href")
+ if shouldExist {
+ assert.True(t, ok)
+ assert.EqualValues(t, expectedSymlinkURL, symlinkURL)
+ } else {
+ assert.False(t, ok)
+ }
+ }
+
+ t.Run("Normal", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ assertCase(t, "/user2/readme-test/src/branch/symlink/README.md?display=source", "/user2/readme-test/src/branch/symlink/some/other/path/awefulcake.txt", true)
+ })
+
+ t.Run("Normal", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ assertCase(t, "/user2/readme-test/src/branch/symlink/some/README.txt", "/user2/readme-test/src/branch/symlink/some/other/path/awefulcake.txt", true)
+ })
+
+ t.Run("Normal", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ assertCase(t, "/user2/readme-test/src/branch/symlink/up/back/down/down/README.md", "/user2/readme-test/src/branch/symlink/down/side/../left/right/../reelmein", true)
+ })
+
+ t.Run("Broken symlink", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ assertCase(t, "/user2/readme-test/src/branch/fallbacks-broken-symlinks/docs/README", "", false)
+ })
+
+ t.Run("Loop symlink", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ assertCase(t, "/user2/readme-test/src/branch/symlink-loop/README.md", "", false)
+ })
+
+ t.Run("Not a symlink", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ assertCase(t, "/user2/readme-test/src/branch/master/README.md", "", false)
+ })
+}
+
+func TestViewRepoOpenWith(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ getOpenWith := func() []string {
+ req := NewRequest(t, "GET", "/user2/repo1")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ openWithHTML := htmlDoc.doc.Find(".js-clone-url-editor")
+
+ var methods []string
+ openWithHTML.Each(func(i int, s *goquery.Selection) {
+ a, _ := s.Attr("data-href-template")
+ methods = append(methods, a)
+ })
+
+ return methods
+ }
+
+ testOpenWith := func(expected []string) {
+ methods := getOpenWith()
+
+ assert.Len(t, methods, len(expected))
+ for i, expectedMethod := range expected {
+ assert.True(t, strings.HasPrefix(methods[i], expectedMethod))
+ }
+ }
+
+ t.Run("Defaults", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ testOpenWith([]string{"vscode://", "vscodium://", "jetbrains://"})
+ })
+
+ t.Run("Customised", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Change the methods via the admin settings
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+ session := loginUser(t, user.Name)
+
+ setEditorApps := func(t *testing.T, apps string) {
+ t.Helper()
+
+ req := NewRequestWithValues(t, "POST", "/admin/config?key=repository.open-with.editor-apps", map[string]string{
+ "value": apps,
+ "_csrf": GetCSRF(t, session, "/admin/config/settings"),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+ }
+
+ defer func() {
+ setEditorApps(t, "")
+ }()
+
+ setEditorApps(t, "test = test://?url={url}")
+
+ testOpenWith([]string{"test://"})
+ })
+}
+
+func TestRepoCodeSearchForm(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ testSearchForm := func(t *testing.T, indexer bool) {
+ defer test.MockVariableValue(&setting.Indexer.RepoIndexerEnabled, indexer)()
+ req := NewRequest(t, "GET", "/user2/repo1/src/branch/master")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ action, exists := htmlDoc.doc.Find("form[data-test-tag=codesearch]").Attr("action")
+ assert.True(t, exists)
+
+ branchSubURL := "/branch/master"
+
+ if indexer {
+ assert.NotContains(t, action, branchSubURL)
+ } else {
+ assert.Contains(t, action, branchSubURL)
+ }
+ }
+
+ t.Run("indexer disabled", func(t *testing.T) {
+ testSearchForm(t, false)
+ })
+
+ t.Run("indexer enabled", func(t *testing.T) {
+ testSearchForm(t, true)
+ })
+}
+
+func TestFileHistoryPager(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("Normal page number", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master/README.md?page=1")
+ MakeRequest(t, req, http.StatusOK)
+ })
+
+ t.Run("Too high page number", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/commits/branch/master/README.md?page=9999")
+ MakeRequest(t, req, http.StatusNotFound)
+ })
+}
+
+func TestRepoIssueFilterLinks(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("No filters", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ called := false
+ htmlDoc.Find("#issue-filters a[href^='?']").Each(func(_ int, s *goquery.Selection) {
+ called = true
+ href, _ := s.Attr("href")
+ assert.Contains(t, href, "?q=&")
+ assert.Contains(t, href, "&type=")
+ assert.Contains(t, href, "&sort=")
+ assert.Contains(t, href, "&state=")
+ assert.Contains(t, href, "&labels=")
+ assert.Contains(t, href, "&milestone=")
+ assert.Contains(t, href, "&project=")
+ assert.Contains(t, href, "&assignee=")
+ assert.Contains(t, href, "&poster=")
+ assert.Contains(t, href, "&fuzzy=")
+ })
+ assert.True(t, called)
+ })
+
+ t.Run("Keyword", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues?q=search-on-this")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ called := false
+ htmlDoc.Find("#issue-filters a[href^='?']").Each(func(_ int, s *goquery.Selection) {
+ called = true
+ href, _ := s.Attr("href")
+ assert.Contains(t, href, "?q=search-on-this")
+ assert.Contains(t, href, "&type=")
+ assert.Contains(t, href, "&sort=")
+ assert.Contains(t, href, "&state=")
+ assert.Contains(t, href, "&labels=")
+ assert.Contains(t, href, "&milestone=")
+ assert.Contains(t, href, "&project=")
+ assert.Contains(t, href, "&assignee=")
+ assert.Contains(t, href, "&poster=")
+ assert.Contains(t, href, "&fuzzy=")
+ })
+ assert.True(t, called)
+ })
+
+ t.Run("Fuzzy", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues?fuzzy=true")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ called := false
+ htmlDoc.Find("#issue-filters a[href^='?']").Each(func(_ int, s *goquery.Selection) {
+ called = true
+ href, _ := s.Attr("href")
+ assert.Contains(t, href, "?q=&")
+ assert.Contains(t, href, "&type=")
+ assert.Contains(t, href, "&sort=")
+ assert.Contains(t, href, "&state=")
+ assert.Contains(t, href, "&labels=")
+ assert.Contains(t, href, "&milestone=")
+ assert.Contains(t, href, "&project=")
+ assert.Contains(t, href, "&assignee=")
+ assert.Contains(t, href, "&poster=")
+ assert.Contains(t, href, "&fuzzy=true")
+ })
+ assert.True(t, called)
+ })
+
+ t.Run("Sort", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues?sort=oldest")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ called := false
+ htmlDoc.Find("#issue-filters a[href^='?']:not(.list-header-sort a)").Each(func(_ int, s *goquery.Selection) {
+ called = true
+ href, _ := s.Attr("href")
+ assert.Contains(t, href, "?q=&")
+ assert.Contains(t, href, "&type=")
+ assert.Contains(t, href, "&sort=oldest")
+ assert.Contains(t, href, "&state=")
+ assert.Contains(t, href, "&labels=")
+ assert.Contains(t, href, "&milestone=")
+ assert.Contains(t, href, "&project=")
+ assert.Contains(t, href, "&assignee=")
+ assert.Contains(t, href, "&poster=")
+ assert.Contains(t, href, "&fuzzy=")
+ })
+ assert.True(t, called)
+ })
+
+ t.Run("Type", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues?type=assigned")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ called := false
+ htmlDoc.Find("#issue-filters a[href^='?']:not(.list-header-type a)").Each(func(_ int, s *goquery.Selection) {
+ called = true
+ href, _ := s.Attr("href")
+ assert.Contains(t, href, "?q=&")
+ assert.Contains(t, href, "&type=assigned")
+ assert.Contains(t, href, "&sort=")
+ assert.Contains(t, href, "&state=")
+ assert.Contains(t, href, "&labels=")
+ assert.Contains(t, href, "&milestone=")
+ assert.Contains(t, href, "&project=")
+ assert.Contains(t, href, "&assignee=")
+ assert.Contains(t, href, "&poster=")
+ assert.Contains(t, href, "&fuzzy=")
+ })
+ assert.True(t, called)
+ })
+
+ t.Run("State", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues?state=closed")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ called := false
+ htmlDoc.Find("#issue-filters a[href^='?']:not(.issue-list-toolbar-left a)").Each(func(_ int, s *goquery.Selection) {
+ called = true
+ href, _ := s.Attr("href")
+ assert.Contains(t, href, "?q=&")
+ assert.Contains(t, href, "&type=")
+ assert.Contains(t, href, "&sort=")
+ assert.Contains(t, href, "&state=closed")
+ assert.Contains(t, href, "&labels=")
+ assert.Contains(t, href, "&milestone=")
+ assert.Contains(t, href, "&project=")
+ assert.Contains(t, href, "&assignee=")
+ assert.Contains(t, href, "&poster=")
+ assert.Contains(t, href, "&fuzzy=")
+ })
+ assert.True(t, called)
+ })
+
+ t.Run("Miilestone", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues?milestone=1")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ called := false
+ htmlDoc.Find("#issue-filters a[href^='?']:not(.list-header-milestone a)").Each(func(_ int, s *goquery.Selection) {
+ called = true
+ href, _ := s.Attr("href")
+ assert.Contains(t, href, "?q=&")
+ assert.Contains(t, href, "&type=")
+ assert.Contains(t, href, "&sort=")
+ assert.Contains(t, href, "&state=")
+ assert.Contains(t, href, "&labels=")
+ assert.Contains(t, href, "&milestone=1")
+ assert.Contains(t, href, "&project=")
+ assert.Contains(t, href, "&assignee=")
+ assert.Contains(t, href, "&poster=")
+ assert.Contains(t, href, "&fuzzy=")
+ })
+ assert.True(t, called)
+ })
+
+ t.Run("Milestone", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues?milestone=1")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ called := false
+ htmlDoc.Find("#issue-filters a[href^='?']:not(.list-header-milestone a)").Each(func(_ int, s *goquery.Selection) {
+ called = true
+ href, _ := s.Attr("href")
+ assert.Contains(t, href, "?q=&")
+ assert.Contains(t, href, "&type=")
+ assert.Contains(t, href, "&sort=")
+ assert.Contains(t, href, "&state=")
+ assert.Contains(t, href, "&labels=")
+ assert.Contains(t, href, "&milestone=1")
+ assert.Contains(t, href, "&project=")
+ assert.Contains(t, href, "&assignee=")
+ assert.Contains(t, href, "&poster=")
+ assert.Contains(t, href, "&fuzzy=")
+ })
+ assert.True(t, called)
+ })
+
+ t.Run("Project", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues?project=1")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ called := false
+ htmlDoc.Find("#issue-filters a[href^='?']:not(.list-header-project a)").Each(func(_ int, s *goquery.Selection) {
+ called = true
+ href, _ := s.Attr("href")
+ assert.Contains(t, href, "?q=&")
+ assert.Contains(t, href, "&type=")
+ assert.Contains(t, href, "&sort=")
+ assert.Contains(t, href, "&state=")
+ assert.Contains(t, href, "&labels=")
+ assert.Contains(t, href, "&milestone=")
+ assert.Contains(t, href, "&project=1")
+ assert.Contains(t, href, "&assignee=")
+ assert.Contains(t, href, "&poster=")
+ assert.Contains(t, href, "&fuzzy=")
+ })
+ assert.True(t, called)
+ })
+
+ t.Run("Assignee", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues?assignee=1")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ called := false
+ htmlDoc.Find("#issue-filters a[href^='?']:not(.list-header-assignee a)").Each(func(_ int, s *goquery.Selection) {
+ called = true
+ href, _ := s.Attr("href")
+ assert.Contains(t, href, "?q=&")
+ assert.Contains(t, href, "&type=")
+ assert.Contains(t, href, "&sort=")
+ assert.Contains(t, href, "&state=")
+ assert.Contains(t, href, "&labels=")
+ assert.Contains(t, href, "&milestone=")
+ assert.Contains(t, href, "&project=")
+ assert.Contains(t, href, "&assignee=1")
+ assert.Contains(t, href, "&poster=")
+ assert.Contains(t, href, "&fuzzy=")
+ })
+ assert.True(t, called)
+ })
+
+ t.Run("Poster", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues?poster=1")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ called := false
+ htmlDoc.Find("#issue-filters a[href^='?']:not(.list-header-poster a)").Each(func(_ int, s *goquery.Selection) {
+ called = true
+ href, _ := s.Attr("href")
+ assert.Contains(t, href, "?q=&")
+ assert.Contains(t, href, "&type=")
+ assert.Contains(t, href, "&sort=")
+ assert.Contains(t, href, "&state=")
+ assert.Contains(t, href, "&labels=")
+ assert.Contains(t, href, "&milestone=")
+ assert.Contains(t, href, "&project=")
+ assert.Contains(t, href, "&assignee=")
+ assert.Contains(t, href, "&poster=1")
+ assert.Contains(t, href, "&fuzzy=")
+ })
+ assert.True(t, called)
+ })
+
+ t.Run("Labels", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues?labels=1")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ called := false
+ htmlDoc.Find("#issue-filters a[href^='?']:not(.label-filter a)").Each(func(_ int, s *goquery.Selection) {
+ called = true
+ href, _ := s.Attr("href")
+ assert.Contains(t, href, "?q=&")
+ assert.Contains(t, href, "&type=")
+ assert.Contains(t, href, "&sort=")
+ assert.Contains(t, href, "&state=")
+ assert.Contains(t, href, "&labels=1")
+ assert.Contains(t, href, "&milestone=")
+ assert.Contains(t, href, "&project=")
+ assert.Contains(t, href, "&assignee=")
+ assert.Contains(t, href, "&poster=")
+ assert.Contains(t, href, "&fuzzy=")
+ })
+ assert.True(t, called)
+ })
+
+ t.Run("Archived labels", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/issues?archived=true")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ called := false
+ htmlDoc.Find("#issue-filters a[href^='?']").Each(func(_ int, s *goquery.Selection) {
+ called = true
+ href, _ := s.Attr("href")
+ assert.Contains(t, href, "?q=&")
+ assert.Contains(t, href, "&type=")
+ assert.Contains(t, href, "&sort=")
+ assert.Contains(t, href, "&state=")
+ assert.Contains(t, href, "&labels=")
+ assert.Contains(t, href, "&milestone=")
+ assert.Contains(t, href, "&project=")
+ assert.Contains(t, href, "&assignee=")
+ assert.Contains(t, href, "&poster=")
+ assert.Contains(t, href, "&fuzzy=")
+ assert.Contains(t, href, "&archived=true")
+ })
+ assert.True(t, called)
+ })
+}
diff --git a/tests/integration/repo_topic_test.go b/tests/integration/repo_topic_test.go
new file mode 100644
index 0000000..f5778a2
--- /dev/null
+++ b/tests/integration/repo_topic_test.go
@@ -0,0 +1,81 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestTopicSearch(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ searchURL, _ := url.Parse("/explore/topics/search")
+ var topics struct {
+ TopicNames []*api.TopicResponse `json:"topics"`
+ }
+
+ query := url.Values{"page": []string{"1"}, "limit": []string{"4"}}
+
+ searchURL.RawQuery = query.Encode()
+ res := MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ assert.Len(t, topics.TopicNames, 4)
+ assert.EqualValues(t, "6", res.Header().Get("x-total-count"))
+
+ query.Add("q", "topic")
+ searchURL.RawQuery = query.Encode()
+ res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ assert.Len(t, topics.TopicNames, 2)
+
+ query.Set("q", "database")
+ searchURL.RawQuery = query.Encode()
+ res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ if assert.Len(t, topics.TopicNames, 1) {
+ assert.EqualValues(t, 2, topics.TopicNames[0].ID)
+ assert.EqualValues(t, "database", topics.TopicNames[0].Name)
+ assert.EqualValues(t, 1, topics.TopicNames[0].RepoCount)
+ }
+}
+
+func TestTopicSearchPaging(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ var topics struct {
+ TopicNames []*api.TopicResponse `json:"topics"`
+ }
+
+ // Add 20 unique topics to user2/repo2, and 20 unique ones to user2/repo3
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ token2 := getUserToken(t, user2.Name, auth_model.AccessTokenScopeWriteRepository)
+ repo2 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ repo3 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 2})
+ for i := 0; i < 20; i++ {
+ req := NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/paging-topic-%d", user2.Name, repo2.Name, i).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNoContent)
+ req = NewRequestf(t, "PUT", "/api/v1/repos/%s/%s/topics/paging-topic-%d", user2.Name, repo3.Name, i+30).
+ AddTokenAuth(token2)
+ MakeRequest(t, req, http.StatusNoContent)
+ }
+
+ res := MakeRequest(t, NewRequest(t, "GET", "/explore/topics/search"), http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ assert.Len(t, topics.TopicNames, 30)
+
+ res = MakeRequest(t, NewRequest(t, "GET", "/explore/topics/search?page=2"), http.StatusOK)
+ DecodeJSON(t, res, &topics)
+ assert.NotEmpty(t, topics.TopicNames)
+}
diff --git a/tests/integration/repo_view_test.go b/tests/integration/repo_view_test.go
new file mode 100644
index 0000000..7c280e2
--- /dev/null
+++ b/tests/integration/repo_view_test.go
@@ -0,0 +1,230 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/routers/web/repo"
+ "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/contexttest"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func createRepoAndGetContext(t *testing.T, files []string, deleteMdReadme bool) (*context.Context, func()) {
+ t.Helper()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user1"})
+
+ size := len(files)
+ if deleteMdReadme {
+ size++
+ }
+ changeFiles := make([]*files_service.ChangeRepoFile, size)
+ for i, e := range files {
+ changeFiles[i] = &files_service.ChangeRepoFile{
+ Operation: "create",
+ TreePath: e,
+ ContentReader: strings.NewReader("test"),
+ }
+ }
+ if deleteMdReadme {
+ changeFiles[len(files)] = &files_service.ChangeRepoFile{
+ Operation: "delete",
+ TreePath: "README.md",
+ }
+ }
+
+ // README.md is already added by auto init
+ repo, _, f := tests.CreateDeclarativeRepo(t, user, "readmetest", []unit_model.Type{unit_model.TypeCode}, nil, changeFiles)
+
+ ctx, _ := contexttest.MockContext(t, "user1/readmetest")
+ ctx.SetParams(":id", fmt.Sprint(repo.ID))
+ contexttest.LoadRepo(t, ctx, repo.ID)
+ contexttest.LoadGitRepo(t, ctx)
+ contexttest.LoadRepoCommit(t, ctx)
+
+ return ctx, func() {
+ f()
+ ctx.Repo.GitRepo.Close()
+ }
+}
+
+func TestRepoView_FindReadme(t *testing.T) {
+ t.Run("PrioOneLocalizedMdReadme", func(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ ctx, f := createRepoAndGetContext(t, []string{"README.en.md", "README.en.org", "README.org", "README.txt", "README.tex"}, false)
+ defer f()
+
+ tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath)
+ entries, _ := tree.ListEntries()
+ _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false)
+
+ assert.Equal(t, "README.en.md", file.Name())
+ })
+ })
+ t.Run("PrioTwoMdReadme", func(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ ctx, f := createRepoAndGetContext(t, []string{"README.en.org", "README.org", "README.txt", "README.tex"}, false)
+ defer f()
+
+ tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath)
+ entries, _ := tree.ListEntries()
+ _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false)
+
+ assert.Equal(t, "README.md", file.Name())
+ })
+ })
+ t.Run("PrioThreeLocalizedOrgReadme", func(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ ctx, f := createRepoAndGetContext(t, []string{"README.en.org", "README.org", "README.txt", "README.tex"}, true)
+ defer f()
+
+ tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath)
+ entries, _ := tree.ListEntries()
+ _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false)
+
+ assert.Equal(t, "README.en.org", file.Name())
+ })
+ })
+ t.Run("PrioFourOrgReadme", func(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ ctx, f := createRepoAndGetContext(t, []string{"README.org", "README.txt", "README.tex"}, true)
+ defer f()
+
+ tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath)
+ entries, _ := tree.ListEntries()
+ _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false)
+
+ assert.Equal(t, "README.org", file.Name())
+ })
+ })
+ t.Run("PrioFiveTxtReadme", func(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ ctx, f := createRepoAndGetContext(t, []string{"README.txt", "README", "README.tex"}, true)
+ defer f()
+
+ tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath)
+ entries, _ := tree.ListEntries()
+ _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false)
+
+ assert.Equal(t, "README.txt", file.Name())
+ })
+ })
+ t.Run("PrioSixWithoutExtensionReadme", func(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ ctx, f := createRepoAndGetContext(t, []string{"README", "README.tex"}, true)
+ defer f()
+
+ tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath)
+ entries, _ := tree.ListEntries()
+ _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false)
+
+ assert.Equal(t, "README", file.Name())
+ })
+ })
+ t.Run("PrioSevenAnyReadme", func(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ ctx, f := createRepoAndGetContext(t, []string{"README.tex"}, true)
+ defer f()
+
+ tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath)
+ entries, _ := tree.ListEntries()
+ _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false)
+
+ assert.Equal(t, "README.tex", file.Name())
+ })
+ })
+ t.Run("DoNotPickReadmeIfNonPresent", func(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ ctx, f := createRepoAndGetContext(t, []string{}, true)
+ defer f()
+
+ tree, _ := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath)
+ entries, _ := tree.ListEntries()
+ _, file, _ := repo.FindReadmeFileInEntries(ctx, entries, false)
+
+ assert.Nil(t, file)
+ })
+ })
+}
+
+func TestRepoViewFileLines(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, _ *url.URL) {
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo, _, f := tests.CreateDeclarativeRepo(t, user, "file-lines", []unit_model.Type{unit_model.TypeCode}, nil, []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "test-1",
+ ContentReader: strings.NewReader("No newline"),
+ },
+ {
+ Operation: "create",
+ TreePath: "test-2",
+ ContentReader: strings.NewReader("No newline\n"),
+ },
+ {
+ Operation: "create",
+ TreePath: "test-3",
+ ContentReader: strings.NewReader("Two\nlines"),
+ },
+ {
+ Operation: "create",
+ TreePath: "test-4",
+ ContentReader: strings.NewReader("Really two\nlines\n"),
+ },
+ {
+ Operation: "create",
+ TreePath: "empty",
+ ContentReader: strings.NewReader(""),
+ },
+ {
+ Operation: "create",
+ TreePath: "seemingly-empty",
+ ContentReader: strings.NewReader("\n"),
+ },
+ })
+ defer f()
+
+ testEOL := func(t *testing.T, filename string, hasEOL bool) {
+ t.Helper()
+ req := NewRequestf(t, "GET", "%s/src/branch/main/%s", repo.Link(), filename)
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ fileInfo := htmlDoc.Find(".file-info").Text()
+ if hasEOL {
+ assert.NotContains(t, fileInfo, "No EOL")
+ } else {
+ assert.Contains(t, fileInfo, "No EOL")
+ }
+ }
+
+ t.Run("No EOL", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ testEOL(t, "test-1", false)
+ testEOL(t, "test-3", false)
+ })
+
+ t.Run("With EOL", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ testEOL(t, "test-2", true)
+ testEOL(t, "test-4", true)
+ testEOL(t, "empty", true)
+ testEOL(t, "seemingly-empty", true)
+ })
+ })
+}
diff --git a/tests/integration/repo_watch_test.go b/tests/integration/repo_watch_test.go
new file mode 100644
index 0000000..ef3028f
--- /dev/null
+++ b/tests/integration/repo_watch_test.go
@@ -0,0 +1,24 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/url"
+ "testing"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/setting"
+)
+
+func TestRepoWatch(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ // Test round-trip auto-watch
+ setting.Service.AutoWatchOnChanges = true
+ session := loginUser(t, "user2")
+ unittest.AssertNotExistsBean(t, &repo_model.Watch{UserID: 2, RepoID: 3})
+ testEditFile(t, session, "org3", "repo3", "master", "README.md", "Hello, World (Edited for watch)\n")
+ unittest.AssertExistsAndLoadBean(t, &repo_model.Watch{UserID: 2, RepoID: 3, Mode: repo_model.WatchModeAuto})
+ })
+}
diff --git a/tests/integration/repo_webhook_test.go b/tests/integration/repo_webhook_test.go
new file mode 100644
index 0000000..8f65b5c
--- /dev/null
+++ b/tests/integration/repo_webhook_test.go
@@ -0,0 +1,473 @@
+// Copyright 2024 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+
+ gitea_context "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/webhook"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewWebHookLink(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+
+ webhooksLen := len(webhook.List())
+ baseurl := "/user2/repo1/settings/hooks"
+ tests := []string{
+ // webhook list page
+ baseurl,
+ // new webhook page
+ baseurl + "/gitea/new",
+ // edit webhook page
+ baseurl + "/1",
+ }
+
+ var csrfToken string
+ for _, url := range tests {
+ resp := session.MakeRequest(t, NewRequest(t, "GET", url), http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Equal(t,
+ webhooksLen,
+ htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(),
+ "not all webhooks are listed in the 'new' dropdown")
+
+ csrfToken = htmlDoc.GetCSRF()
+ }
+
+ // ensure that the "failure" pages has the full dropdown as well
+ resp := session.MakeRequest(t, NewRequestWithValues(t, "POST", baseurl+"/gitea/new", map[string]string{"_csrf": csrfToken}), http.StatusUnprocessableEntity)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Equal(t,
+ webhooksLen,
+ htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(),
+ "not all webhooks are listed in the 'new' dropdown on failure")
+
+ resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", baseurl+"/1", map[string]string{"_csrf": csrfToken}), http.StatusUnprocessableEntity)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ assert.Equal(t,
+ webhooksLen,
+ htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(),
+ "not all webhooks are listed in the 'new' dropdown on failure")
+
+ adminSession := loginUser(t, "user1")
+ t.Run("org3", func(t *testing.T) {
+ baseurl := "/org/org3/settings/hooks"
+ resp := adminSession.MakeRequest(t, NewRequest(t, "GET", baseurl), http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Equal(t,
+ webhooksLen,
+ htmlDoc.Find(`a[href^="`+baseurl+`/"][href$="/new"]`).Length(),
+ "not all webhooks are listed in the 'new' dropdown")
+ })
+ t.Run("admin", func(t *testing.T) {
+ baseurl := "/admin/hooks"
+ resp := adminSession.MakeRequest(t, NewRequest(t, "GET", baseurl), http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Equal(t,
+ webhooksLen,
+ htmlDoc.Find(`a[href^="/admin/default-hooks/"][href$="/new"]`).Length(),
+ "not all webhooks are listed in the 'new' dropdown for default-hooks")
+ assert.Equal(t,
+ webhooksLen,
+ htmlDoc.Find(`a[href^="/admin/system-hooks/"][href$="/new"]`).Length(),
+ "not all webhooks are listed in the 'new' dropdown for system-hooks")
+ })
+}
+
+func TestWebhookForms(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user1")
+
+ t.Run("forgejo/required", testWebhookForms("forgejo", session, map[string]string{
+ "payload_url": "https://forgejo.example.com",
+ "http_method": "POST",
+ "content_type": "1", // json
+ }, map[string]string{
+ "payload_url": "",
+ }, map[string]string{
+ "http_method": "",
+ }, map[string]string{
+ "content_type": "",
+ }, map[string]string{
+ "payload_url": "invalid_url",
+ }, map[string]string{
+ "http_method": "INVALID",
+ }))
+ t.Run("forgejo/optional", testWebhookForms("forgejo", session, map[string]string{
+ "payload_url": "https://forgejo.example.com",
+ "http_method": "POST",
+ "content_type": "1", // json
+ "secret": "s3cr3t",
+
+ "branch_filter": "forgejo/*",
+ "authorization_header": "Bearer 123456",
+ }))
+
+ t.Run("gitea/required", testWebhookForms("gitea", session, map[string]string{
+ "payload_url": "https://gitea.example.com",
+ "http_method": "POST",
+ "content_type": "1", // json
+ }, map[string]string{
+ "payload_url": "",
+ }, map[string]string{
+ "http_method": "",
+ }, map[string]string{
+ "content_type": "",
+ }, map[string]string{
+ "payload_url": "invalid_url",
+ }, map[string]string{
+ "http_method": "INVALID",
+ }))
+ t.Run("gitea/optional", testWebhookForms("gitea", session, map[string]string{
+ "payload_url": "https://gitea.example.com",
+ "http_method": "POST",
+ "content_type": "1", // json
+ "secret": "s3cr3t",
+
+ "branch_filter": "gitea/*",
+ "authorization_header": "Bearer 123456",
+ }))
+
+ t.Run("gogs/required", testWebhookForms("gogs", session, map[string]string{
+ "payload_url": "https://gogs.example.com",
+ "content_type": "1", // json
+ }))
+ t.Run("gogs/optional", testWebhookForms("gogs", session, map[string]string{
+ "payload_url": "https://gogs.example.com",
+ "content_type": "1", // json
+ "secret": "s3cr3t",
+
+ "branch_filter": "gogs/*",
+ "authorization_header": "Bearer 123456",
+ }))
+
+ t.Run("slack/required", testWebhookForms("slack", session, map[string]string{
+ "payload_url": "https://slack.example.com",
+ "channel": "general",
+ }, map[string]string{
+ "channel": "",
+ }, map[string]string{
+ "channel": "invalid channel name",
+ }))
+ t.Run("slack/optional", testWebhookForms("slack", session, map[string]string{
+ "payload_url": "https://slack.example.com",
+ "channel": "#general",
+ "username": "john",
+ "icon_url": "https://slack.example.com/icon.png",
+ "color": "#dd4b39",
+
+ "branch_filter": "slack/*",
+ "authorization_header": "Bearer 123456",
+ }))
+
+ t.Run("discord/required", testWebhookForms("discord", session, map[string]string{
+ "username": "john",
+ "payload_url": "https://discord.example.com",
+ }))
+ t.Run("discord/optional", testWebhookForms("discord", session, map[string]string{
+ "payload_url": "https://discord.example.com",
+ "username": "john",
+ "icon_url": "https://discord.example.com/icon.png",
+
+ "branch_filter": "discord/*",
+ "authorization_header": "Bearer 123456",
+ }))
+
+ t.Run("dingtalk/required", testWebhookForms("dingtalk", session, map[string]string{
+ "payload_url": "https://dingtalk.example.com",
+ }))
+ t.Run("dingtalk/optional", testWebhookForms("dingtalk", session, map[string]string{
+ "payload_url": "https://dingtalk.example.com",
+
+ "branch_filter": "discord/*",
+ "authorization_header": "Bearer 123456",
+ }))
+
+ t.Run("telegram/required", testWebhookForms("telegram", session, map[string]string{
+ "bot_token": "123456",
+ "chat_id": "789",
+ }))
+ t.Run("telegram/optional", testWebhookForms("telegram", session, map[string]string{
+ "bot_token": "123456",
+ "chat_id": "789",
+ "thread_id": "abc",
+
+ "branch_filter": "telegram/*",
+ "authorization_header": "Bearer 123456",
+ }))
+
+ t.Run("msteams/required", testWebhookForms("msteams", session, map[string]string{
+ "payload_url": "https://msteams.example.com",
+ }))
+ t.Run("msteams/optional", testWebhookForms("msteams", session, map[string]string{
+ "payload_url": "https://msteams.example.com",
+
+ "branch_filter": "msteams/*",
+ "authorization_header": "Bearer 123456",
+ }))
+
+ t.Run("feishu/required", testWebhookForms("feishu", session, map[string]string{
+ "payload_url": "https://feishu.example.com",
+ }))
+ t.Run("feishu/optional", testWebhookForms("feishu", session, map[string]string{
+ "payload_url": "https://feishu.example.com",
+
+ "branch_filter": "feishu/*",
+ "authorization_header": "Bearer 123456",
+ }))
+
+ t.Run("matrix/required", testWebhookForms("matrix", session, map[string]string{
+ "homeserver_url": "https://matrix.example.com",
+ "access_token": "123456",
+ "room_id": "123",
+ }, map[string]string{
+ "access_token": "",
+ }))
+ t.Run("matrix/optional", testWebhookForms("matrix", session, map[string]string{
+ "homeserver_url": "https://matrix.example.com",
+ "access_token": "123456",
+ "room_id": "123",
+ "message_type": "1", // m.text
+
+ "branch_filter": "matrix/*",
+ }))
+
+ t.Run("wechatwork/required", testWebhookForms("wechatwork", session, map[string]string{
+ "payload_url": "https://wechatwork.example.com",
+ }))
+ t.Run("wechatwork/optional", testWebhookForms("wechatwork", session, map[string]string{
+ "payload_url": "https://wechatwork.example.com",
+
+ "branch_filter": "wechatwork/*",
+ "authorization_header": "Bearer 123456",
+ }))
+
+ t.Run("packagist/required", testWebhookForms("packagist", session, map[string]string{
+ "username": "john",
+ "api_token": "secret",
+ "package_url": "https://packagist.org/packages/example/framework",
+ }))
+ t.Run("packagist/optional", testWebhookForms("packagist", session, map[string]string{
+ "username": "john",
+ "api_token": "secret",
+ "package_url": "https://packagist.org/packages/example/framework",
+
+ "branch_filter": "packagist/*",
+ "authorization_header": "Bearer 123456",
+ }))
+
+ t.Run("sourcehut_builds/required", testWebhookForms("sourcehut_builds", session, map[string]string{
+ "payload_url": "https://sourcehut_builds.example.com",
+ "manifest_path": ".build.yml",
+ "visibility": "PRIVATE",
+ "access_token": "123456",
+ }, map[string]string{
+ "access_token": "",
+ }, map[string]string{
+ "manifest_path": "",
+ }, map[string]string{
+ "manifest_path": "/absolute",
+ }, map[string]string{
+ "visibility": "",
+ }, map[string]string{
+ "visibility": "INVALID",
+ }))
+ t.Run("sourcehut_builds/optional", testWebhookForms("sourcehut_builds", session, map[string]string{
+ "payload_url": "https://sourcehut_builds.example.com",
+ "manifest_path": ".build.yml",
+ "visibility": "PRIVATE",
+ "secrets": "on",
+ "access_token": "123456",
+
+ "branch_filter": "srht/*",
+ }))
+}
+
+func assertInput(t testing.TB, form *goquery.Selection, name string) string {
+ t.Helper()
+ input := form.Find(`input[name="` + name + `"]`)
+ if input.Length() != 1 {
+ form.Find("input").Each(func(i int, s *goquery.Selection) {
+ t.Logf("found <input name=%q />", s.AttrOr("name", ""))
+ })
+ t.Errorf("field <input name=%q /> found %d times, expected once", name, input.Length())
+ }
+ switch input.AttrOr("type", "") {
+ case "checkbox":
+ if _, checked := input.Attr("checked"); checked {
+ return "on"
+ }
+ return ""
+ default:
+ return input.AttrOr("value", "")
+ }
+}
+
+func testWebhookForms(name string, session *TestSession, validFields map[string]string, invalidPatches ...map[string]string) func(t *testing.T) {
+ return func(t *testing.T) {
+ t.Run("repo1", func(t *testing.T) {
+ testWebhookFormsShared(t, "/user2/repo1/settings/hooks", name, session, validFields, invalidPatches...)
+ })
+ t.Run("org3", func(t *testing.T) {
+ testWebhookFormsShared(t, "/org/org3/settings/hooks", name, session, validFields, invalidPatches...)
+ })
+ t.Run("system", func(t *testing.T) {
+ testWebhookFormsShared(t, "/admin/system-hooks", name, session, validFields, invalidPatches...)
+ })
+ t.Run("default", func(t *testing.T) {
+ testWebhookFormsShared(t, "/admin/default-hooks", name, session, validFields, invalidPatches...)
+ })
+ }
+}
+
+func testWebhookFormsShared(t *testing.T, endpoint, name string, session *TestSession, validFields map[string]string, invalidPatches ...map[string]string) {
+ // new webhook form
+ resp := session.MakeRequest(t, NewRequest(t, "GET", endpoint+"/"+name+"/new"), http.StatusOK)
+ htmlForm := NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`)
+
+ // fill the form
+ payload := map[string]string{
+ "_csrf": htmlForm.Find(`input[name="_csrf"]`).AttrOr("value", ""),
+ "events": "send_everything",
+ }
+ for k, v := range validFields {
+ assertInput(t, htmlForm, k)
+ payload[k] = v
+ }
+ if t.Failed() {
+ t.FailNow() // prevent further execution if the form could not be filled properly
+ }
+
+ // create the webhook (this redirects back to the hook list)
+ resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", endpoint+"/"+name+"/new", payload), http.StatusSeeOther)
+ assertHasFlashMessages(t, resp, "success")
+ listEndpoint := resp.Header().Get("Location")
+ updateEndpoint := endpoint + "/"
+ if endpoint == "/admin/system-hooks" || endpoint == "/admin/default-hooks" {
+ updateEndpoint = "/admin/hooks/"
+ }
+
+ // find last created hook in the hook list
+ // (a bit hacky, but the list should be sorted)
+ resp = session.MakeRequest(t, NewRequest(t, "GET", listEndpoint), http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ selector := `a[href^="` + updateEndpoint + `"]`
+ if endpoint == "/admin/system-hooks" {
+ // system-hooks and default-hooks are listed on the same page
+ // add a specifier to select the latest system-hooks
+ // (the default-hooks are at the end, so no further specifier needed)
+ selector = `.admin-setting-content > div:first-of-type ` + selector
+ }
+ editFormURL := htmlDoc.Find(selector).Last().AttrOr("href", "")
+ assert.NotEmpty(t, editFormURL)
+
+ // edit webhook form
+ resp = session.MakeRequest(t, NewRequest(t, "GET", editFormURL), http.StatusOK)
+ htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + updateEndpoint + `"]`)
+ editPostURL := htmlForm.AttrOr("action", "")
+ assert.NotEmpty(t, editPostURL)
+
+ // fill the form
+ payload = map[string]string{
+ "_csrf": htmlForm.Find(`input[name="_csrf"]`).AttrOr("value", ""),
+ "events": "push_only",
+ }
+ for k, v := range validFields {
+ assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v)
+ payload[k] = v
+ }
+
+ // update the webhook
+ resp = session.MakeRequest(t, NewRequestWithValues(t, "POST", editPostURL, payload), http.StatusSeeOther)
+ assertHasFlashMessages(t, resp, "success")
+
+ // check the updated webhook
+ resp = session.MakeRequest(t, NewRequest(t, "GET", editFormURL), http.StatusOK)
+ htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + updateEndpoint + `"]`)
+ for k, v := range validFields {
+ assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v)
+ }
+
+ if len(invalidPatches) > 0 {
+ // check that invalid fields are rejected
+ resp := session.MakeRequest(t, NewRequest(t, "GET", endpoint+"/"+name+"/new"), http.StatusOK)
+ htmlForm := NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`)
+
+ for _, invalidPatch := range invalidPatches {
+ t.Run("invalid", func(t *testing.T) {
+ // fill the form
+ payload := map[string]string{
+ "_csrf": htmlForm.Find(`input[name="_csrf"]`).AttrOr("value", ""),
+ "events": "send_everything",
+ }
+ for k, v := range validFields {
+ payload[k] = v
+ }
+ for k, v := range invalidPatch {
+ if v == "" {
+ delete(payload, k)
+ } else {
+ payload[k] = v
+ }
+ }
+
+ resp := session.MakeRequest(t, NewRequestWithValues(t, "POST", endpoint+"/"+name+"/new", payload), http.StatusUnprocessableEntity)
+ // check that the invalid form is pre-filled
+ htmlForm = NewHTMLParser(t, resp.Body).Find(`form[action^="` + endpoint + `/"]`)
+ for k, v := range payload {
+ if k == "_csrf" || k == "events" || v == "" {
+ // the 'events' is a radio input, which is buggy below
+ continue
+ }
+ assert.Equal(t, v, assertInput(t, htmlForm, k), "input %q did not contain value %q", k, v)
+ }
+ if t.Failed() {
+ t.Log(invalidPatch)
+ }
+ })
+ }
+ }
+}
+
+func assertHasFlashMessages(t *testing.T, resp *httptest.ResponseRecorder, expectedKeys ...string) {
+ seenKeys := make(map[string][]string, len(expectedKeys))
+
+ for _, cookie := range resp.Result().Cookies() {
+ if cookie.Name != gitea_context.CookieNameFlash {
+ continue
+ }
+ flash, _ := url.ParseQuery(cookie.Value)
+ for key, value := range flash {
+ // the key is itself url-encoded
+ if flash, err := url.ParseQuery(key); err == nil {
+ for key, value := range flash {
+ seenKeys[key] = value
+ }
+ } else {
+ seenKeys[key] = value
+ }
+ }
+ }
+
+ for _, k := range expectedKeys {
+ if len(seenKeys[k]) == 0 {
+ t.Errorf("missing expected flash message %q", k)
+ }
+ delete(seenKeys, k)
+ }
+
+ for k, v := range seenKeys {
+ t.Errorf("unexpected flash message %q: %q", k, v)
+ }
+}
diff --git a/tests/integration/repo_wiki_test.go b/tests/integration/repo_wiki_test.go
new file mode 100644
index 0000000..316c045
--- /dev/null
+++ b/tests/integration/repo_wiki_test.go
@@ -0,0 +1,91 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestWikiSearchContent(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/user2/repo1/wiki/search?q=This")
+ resp := MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ res := doc.Find(".item > b").Map(func(_ int, el *goquery.Selection) string {
+ return el.Text()
+ })
+ assert.Equal(t, []string{
+ "Home.md",
+ "Page-With-Spaced-Name.md",
+ "Unescaped File.md",
+ }, res)
+}
+
+func TestWikiBranchNormalize(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ username := "user2"
+ session := loginUser(t, username)
+ settingsURLStr := "/user2/repo1/settings"
+
+ assertNormalizeButton := func(present bool) string {
+ req := NewRequest(t, "GET", settingsURLStr) //.AddTokenAuth(token)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, "button[data-modal='#rename-wiki-branch-modal']", present)
+
+ return htmlDoc.GetCSRF()
+ }
+
+ // By default the repo wiki branch is empty
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ assert.Empty(t, repo.WikiBranch)
+
+ // This means we default to setting.Repository.DefaultBranch
+ assert.Equal(t, setting.Repository.DefaultBranch, repo.GetWikiBranchName())
+
+ // Which further means that the "Normalize wiki branch" parts do not appear on settings
+ assertNormalizeButton(false)
+
+ // Lets rename the branch!
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+ repoURLStr := fmt.Sprintf("/api/v1/repos/%s/%s", username, repo.Name)
+ wikiBranch := "wiki"
+ req := NewRequestWithJSON(t, "PATCH", repoURLStr, &api.EditRepoOption{
+ WikiBranch: &wikiBranch,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ // The wiki branch should now be changed
+ repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ assert.Equal(t, wikiBranch, repo.GetWikiBranchName())
+
+ // And as such, the button appears!
+ csrf := assertNormalizeButton(true)
+
+ // Invoking the normalization renames the wiki branch back to the default
+ req = NewRequestWithValues(t, "POST", settingsURLStr, map[string]string{
+ "_csrf": csrf,
+ "action": "rename-wiki-branch",
+ "repo_name": repo.FullName(),
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ repo = unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ assert.Equal(t, setting.Repository.DefaultBranch, repo.GetWikiBranchName())
+ assertNormalizeButton(false)
+}
diff --git a/tests/integration/repofiles_change_test.go b/tests/integration/repofiles_change_test.go
new file mode 100644
index 0000000..9790b36
--- /dev/null
+++ b/tests/integration/repofiles_change_test.go
@@ -0,0 +1,495 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/url"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ files_service "code.gitea.io/gitea/services/repository/files"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func getCreateRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions {
+ return &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "new/file.txt",
+ ContentReader: strings.NewReader("This is a NEW file"),
+ },
+ },
+ OldBranch: repo.DefaultBranch,
+ NewBranch: repo.DefaultBranch,
+ Message: "Creates new/file.txt",
+ Author: nil,
+ Committer: nil,
+ }
+}
+
+func getUpdateRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions {
+ return &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "update",
+ TreePath: "README.md",
+ SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
+ ContentReader: strings.NewReader("This is UPDATED content for the README file"),
+ },
+ },
+ OldBranch: repo.DefaultBranch,
+ NewBranch: repo.DefaultBranch,
+ Message: "Updates README.md",
+ Author: nil,
+ Committer: nil,
+ }
+}
+
+func getDeleteRepoFilesOptions(repo *repo_model.Repository) *files_service.ChangeRepoFilesOptions {
+ return &files_service.ChangeRepoFilesOptions{
+ Files: []*files_service.ChangeRepoFile{
+ {
+ Operation: "delete",
+ TreePath: "README.md",
+ SHA: "4b4851ad51df6a7d9f25c979345979eaeb5b349f",
+ },
+ },
+ LastCommitID: "",
+ OldBranch: repo.DefaultBranch,
+ NewBranch: repo.DefaultBranch,
+ Message: "Deletes README.md",
+ Author: &files_service.IdentityOptions{
+ Name: "Bob Smith",
+ Email: "bob@smith.com",
+ },
+ Committer: nil,
+ }
+}
+
+func getExpectedFileResponseForRepofilesDelete() *api.FileResponse {
+ // Just returns fields that don't change, i.e. fields with commit SHAs and dates can't be determined
+ return &api.FileResponse{
+ Content: nil,
+ Commit: &api.FileCommitResponse{
+ Author: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "Bob Smith",
+ Email: "bob@smith.com",
+ },
+ },
+ Committer: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "Bob Smith",
+ Email: "bob@smith.com",
+ },
+ },
+ Message: "Deletes README.md\n",
+ },
+ Verification: &api.PayloadCommitVerification{
+ Verified: false,
+ Reason: "gpg.error.not_signed_commit",
+ Signature: "",
+ Payload: "",
+ },
+ }
+}
+
+func getExpectedFileResponseForRepofilesCreate(commitID, lastCommitSHA string) *api.FileResponse {
+ treePath := "new/file.txt"
+ encoding := "base64"
+ content := "VGhpcyBpcyBhIE5FVyBmaWxl"
+ selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + treePath + "?ref=master"
+ htmlURL := setting.AppURL + "user2/repo1/src/branch/master/" + treePath
+ gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/103ff9234cefeee5ec5361d22b49fbb04d385885"
+ downloadURL := setting.AppURL + "user2/repo1/raw/branch/master/" + treePath
+ return &api.FileResponse{
+ Content: &api.ContentsResponse{
+ Name: filepath.Base(treePath),
+ Path: treePath,
+ SHA: "103ff9234cefeee5ec5361d22b49fbb04d385885",
+ LastCommitSHA: lastCommitSHA,
+ Type: "file",
+ Size: 18,
+ Encoding: &encoding,
+ Content: &content,
+ URL: &selfURL,
+ HTMLURL: &htmlURL,
+ GitURL: &gitURL,
+ DownloadURL: &downloadURL,
+ Links: &api.FileLinksResponse{
+ Self: &selfURL,
+ GitURL: &gitURL,
+ HTMLURL: &htmlURL,
+ },
+ },
+ Commit: &api.FileCommitResponse{
+ CommitMeta: api.CommitMeta{
+ URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/" + commitID,
+ SHA: commitID,
+ },
+ HTMLURL: setting.AppURL + "user2/repo1/commit/" + commitID,
+ Author: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "User Two",
+ Email: "user2@noreply.example.org",
+ },
+ Date: time.Now().UTC().Format(time.RFC3339),
+ },
+ Committer: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "User Two",
+ Email: "user2@noreply.example.org",
+ },
+ Date: time.Now().UTC().Format(time.RFC3339),
+ },
+ Parents: []*api.CommitMeta{
+ {
+ URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d",
+ SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
+ },
+ },
+ Message: "Updates README.md\n",
+ Tree: &api.CommitMeta{
+ URL: setting.AppURL + "api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc",
+ SHA: "f93e3a1a1525fb5b91020git dda86e44810c87a2d7bc",
+ },
+ },
+ Verification: &api.PayloadCommitVerification{
+ Verified: false,
+ Reason: "gpg.error.not_signed_commit",
+ Signature: "",
+ Payload: "",
+ },
+ }
+}
+
+func getExpectedFileResponseForRepofilesUpdate(commitID, filename, lastCommitSHA string) *api.FileResponse {
+ encoding := "base64"
+ content := "VGhpcyBpcyBVUERBVEVEIGNvbnRlbnQgZm9yIHRoZSBSRUFETUUgZmlsZQ=="
+ selfURL := setting.AppURL + "api/v1/repos/user2/repo1/contents/" + filename + "?ref=master"
+ htmlURL := setting.AppURL + "user2/repo1/src/branch/master/" + filename
+ gitURL := setting.AppURL + "api/v1/repos/user2/repo1/git/blobs/dbf8d00e022e05b7e5cf7e535de857de57925647"
+ downloadURL := setting.AppURL + "user2/repo1/raw/branch/master/" + filename
+ return &api.FileResponse{
+ Content: &api.ContentsResponse{
+ Name: filename,
+ Path: filename,
+ SHA: "dbf8d00e022e05b7e5cf7e535de857de57925647",
+ LastCommitSHA: lastCommitSHA,
+ Type: "file",
+ Size: 43,
+ Encoding: &encoding,
+ Content: &content,
+ URL: &selfURL,
+ HTMLURL: &htmlURL,
+ GitURL: &gitURL,
+ DownloadURL: &downloadURL,
+ Links: &api.FileLinksResponse{
+ Self: &selfURL,
+ GitURL: &gitURL,
+ HTMLURL: &htmlURL,
+ },
+ },
+ Commit: &api.FileCommitResponse{
+ CommitMeta: api.CommitMeta{
+ URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/" + commitID,
+ SHA: commitID,
+ },
+ HTMLURL: setting.AppURL + "user2/repo1/commit/" + commitID,
+ Author: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "User Two",
+ Email: "user2@noreply.example.org",
+ },
+ Date: time.Now().UTC().Format(time.RFC3339),
+ },
+ Committer: &api.CommitUser{
+ Identity: api.Identity{
+ Name: "User Two",
+ Email: "user2@noreply.example.org",
+ },
+ Date: time.Now().UTC().Format(time.RFC3339),
+ },
+ Parents: []*api.CommitMeta{
+ {
+ URL: setting.AppURL + "api/v1/repos/user2/repo1/git/commits/65f1bf27bc3bf70f64657658635e66094edbcb4d",
+ SHA: "65f1bf27bc3bf70f64657658635e66094edbcb4d",
+ },
+ },
+ Message: "Updates README.md\n",
+ Tree: &api.CommitMeta{
+ URL: setting.AppURL + "api/v1/repos/user2/repo1/git/trees/f93e3a1a1525fb5b91020da86e44810c87a2d7bc",
+ SHA: "f93e3a1a1525fb5b91020da86e44810c87a2d7bc",
+ },
+ },
+ Verification: &api.PayloadCommitVerification{
+ Verified: false,
+ Reason: "gpg.error.not_signed_commit",
+ Signature: "",
+ Payload: "",
+ },
+ }
+}
+
+func TestChangeRepoFilesForCreate(t *testing.T) {
+ // setup
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ opts := getCreateRepoFilesOptions(repo)
+
+ // test
+ filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+
+ // asserts
+ require.NoError(t, err)
+ gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo)
+ defer gitRepo.Close()
+
+ commitID, _ := gitRepo.GetBranchCommitID(opts.NewBranch)
+ lastCommit, _ := gitRepo.GetCommitByPath("new/file.txt")
+ expectedFileResponse := getExpectedFileResponseForRepofilesCreate(commitID, lastCommit.ID.String())
+ assert.NotNil(t, expectedFileResponse)
+ if expectedFileResponse != nil {
+ assert.EqualValues(t, expectedFileResponse.Content, filesResponse.Files[0])
+ assert.EqualValues(t, expectedFileResponse.Commit.SHA, filesResponse.Commit.SHA)
+ assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL)
+ assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, filesResponse.Commit.Author.Email)
+ assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name)
+ }
+ })
+}
+
+func TestChangeRepoFilesForUpdate(t *testing.T) {
+ // setup
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ opts := getUpdateRepoFilesOptions(repo)
+
+ // test
+ filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+
+ // asserts
+ require.NoError(t, err)
+ gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo)
+ defer gitRepo.Close()
+
+ commit, _ := gitRepo.GetBranchCommit(opts.NewBranch)
+ lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath)
+ expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.Files[0].TreePath, lastCommit.ID.String())
+ assert.EqualValues(t, expectedFileResponse.Content, filesResponse.Files[0])
+ assert.EqualValues(t, expectedFileResponse.Commit.SHA, filesResponse.Commit.SHA)
+ assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL)
+ assert.EqualValues(t, expectedFileResponse.Commit.Author.Email, filesResponse.Commit.Author.Email)
+ assert.EqualValues(t, expectedFileResponse.Commit.Author.Name, filesResponse.Commit.Author.Name)
+ })
+}
+
+func TestChangeRepoFilesForUpdateWithFileMove(t *testing.T) {
+ // setup
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ opts := getUpdateRepoFilesOptions(repo)
+ opts.Files[0].FromTreePath = "README.md"
+ opts.Files[0].TreePath = "README_new.md" // new file name, README_new.md
+
+ // test
+ filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+
+ // asserts
+ require.NoError(t, err)
+ gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo)
+ defer gitRepo.Close()
+
+ commit, _ := gitRepo.GetBranchCommit(opts.NewBranch)
+ lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath)
+ expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.Files[0].TreePath, lastCommit.ID.String())
+ // assert that the old file no longer exists in the last commit of the branch
+ fromEntry, err := commit.GetTreeEntryByPath(opts.Files[0].FromTreePath)
+ switch err.(type) {
+ case git.ErrNotExist:
+ // correct, continue
+ default:
+ t.Fatalf("expected git.ErrNotExist, got:%v", err)
+ }
+ toEntry, err := commit.GetTreeEntryByPath(opts.Files[0].TreePath)
+ require.NoError(t, err)
+ assert.Nil(t, fromEntry) // Should no longer exist here
+ assert.NotNil(t, toEntry) // Should exist here
+ // assert SHA has remained the same but paths use the new file name
+ assert.EqualValues(t, expectedFileResponse.Content.SHA, filesResponse.Files[0].SHA)
+ assert.EqualValues(t, expectedFileResponse.Content.Name, filesResponse.Files[0].Name)
+ assert.EqualValues(t, expectedFileResponse.Content.Path, filesResponse.Files[0].Path)
+ assert.EqualValues(t, expectedFileResponse.Content.URL, filesResponse.Files[0].URL)
+ assert.EqualValues(t, expectedFileResponse.Commit.SHA, filesResponse.Commit.SHA)
+ assert.EqualValues(t, expectedFileResponse.Commit.HTMLURL, filesResponse.Commit.HTMLURL)
+ })
+}
+
+// Test opts with branch names removed, should get same results as above test
+func TestChangeRepoFilesWithoutBranchNames(t *testing.T) {
+ // setup
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ opts := getUpdateRepoFilesOptions(repo)
+ opts.OldBranch = ""
+ opts.NewBranch = ""
+
+ // test
+ filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+
+ // asserts
+ require.NoError(t, err)
+ gitRepo, _ := gitrepo.OpenRepository(git.DefaultContext, repo)
+ defer gitRepo.Close()
+
+ commit, _ := gitRepo.GetBranchCommit(repo.DefaultBranch)
+ lastCommit, _ := commit.GetCommitByPath(opts.Files[0].TreePath)
+ expectedFileResponse := getExpectedFileResponseForRepofilesUpdate(commit.ID.String(), opts.Files[0].TreePath, lastCommit.ID.String())
+ assert.EqualValues(t, expectedFileResponse.Content, filesResponse.Files[0])
+ })
+}
+
+func TestChangeRepoFilesForDelete(t *testing.T) {
+ onGiteaRun(t, testDeleteRepoFiles)
+}
+
+func testDeleteRepoFiles(t *testing.T, u *url.URL) {
+ // setup
+ unittest.PrepareTestEnv(t)
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ opts := getDeleteRepoFilesOptions(repo)
+
+ t.Run("Delete README.md file", func(t *testing.T) {
+ filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+ require.NoError(t, err)
+ expectedFileResponse := getExpectedFileResponseForRepofilesDelete()
+ assert.NotNil(t, filesResponse)
+ assert.Nil(t, filesResponse.Files[0])
+ assert.EqualValues(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message)
+ assert.EqualValues(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity)
+ assert.EqualValues(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity)
+ assert.EqualValues(t, expectedFileResponse.Verification, filesResponse.Verification)
+ })
+
+ t.Run("Verify README.md has been deleted", func(t *testing.T) {
+ filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+ assert.Nil(t, filesResponse)
+ expectedError := "repository file does not exist [path: " + opts.Files[0].TreePath + "]"
+ assert.EqualError(t, err, expectedError)
+ })
+}
+
+// Test opts with branch names removed, same results
+func TestChangeRepoFilesForDeleteWithoutBranchNames(t *testing.T) {
+ onGiteaRun(t, testDeleteRepoFilesWithoutBranchNames)
+}
+
+func testDeleteRepoFilesWithoutBranchNames(t *testing.T, u *url.URL) {
+ // setup
+ unittest.PrepareTestEnv(t)
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ opts := getDeleteRepoFilesOptions(repo)
+ opts.OldBranch = ""
+ opts.NewBranch = ""
+
+ t.Run("Delete README.md without Branch Name", func(t *testing.T) {
+ filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+ require.NoError(t, err)
+ expectedFileResponse := getExpectedFileResponseForRepofilesDelete()
+ assert.NotNil(t, filesResponse)
+ assert.Nil(t, filesResponse.Files[0])
+ assert.EqualValues(t, expectedFileResponse.Commit.Message, filesResponse.Commit.Message)
+ assert.EqualValues(t, expectedFileResponse.Commit.Author.Identity, filesResponse.Commit.Author.Identity)
+ assert.EqualValues(t, expectedFileResponse.Commit.Committer.Identity, filesResponse.Commit.Committer.Identity)
+ assert.EqualValues(t, expectedFileResponse.Verification, filesResponse.Verification)
+ })
+}
+
+func TestChangeRepoFilesErrors(t *testing.T) {
+ // setup
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ doer := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+
+ t.Run("bad branch", func(t *testing.T) {
+ opts := getUpdateRepoFilesOptions(repo)
+ opts.OldBranch = "bad_branch"
+ filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+ require.Error(t, err)
+ assert.Nil(t, filesResponse)
+ expectedError := "branch does not exist [name: " + opts.OldBranch + "]"
+ assert.EqualError(t, err, expectedError)
+ })
+
+ t.Run("bad SHA", func(t *testing.T) {
+ opts := getUpdateRepoFilesOptions(repo)
+ origSHA := opts.Files[0].SHA
+ opts.Files[0].SHA = "bad_sha"
+ filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+ assert.Nil(t, filesResponse)
+ require.Error(t, err)
+ expectedError := "sha does not match [given: " + opts.Files[0].SHA + ", expected: " + origSHA + "]"
+ assert.EqualError(t, err, expectedError)
+ })
+
+ t.Run("new branch already exists", func(t *testing.T) {
+ opts := getUpdateRepoFilesOptions(repo)
+ opts.NewBranch = "develop"
+ filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+ assert.Nil(t, filesResponse)
+ require.Error(t, err)
+ expectedError := "branch already exists [name: " + opts.NewBranch + "]"
+ assert.EqualError(t, err, expectedError)
+ })
+
+ t.Run("treePath is empty:", func(t *testing.T) {
+ opts := getUpdateRepoFilesOptions(repo)
+ opts.Files[0].TreePath = ""
+ filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+ assert.Nil(t, filesResponse)
+ require.Error(t, err)
+ expectedError := "path contains a malformed path component [path: ]"
+ assert.EqualError(t, err, expectedError)
+ })
+
+ t.Run("treePath is a git directory:", func(t *testing.T) {
+ opts := getUpdateRepoFilesOptions(repo)
+ opts.Files[0].TreePath = ".git"
+ filesResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+ assert.Nil(t, filesResponse)
+ require.Error(t, err)
+ expectedError := "path contains a malformed path component [path: " + opts.Files[0].TreePath + "]"
+ assert.EqualError(t, err, expectedError)
+ })
+
+ t.Run("create file that already exists", func(t *testing.T) {
+ opts := getCreateRepoFilesOptions(repo)
+ opts.Files[0].TreePath = "README.md" // already exists
+ fileResponse, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, doer, opts)
+ assert.Nil(t, fileResponse)
+ require.Error(t, err)
+ expectedError := "repository file already exists [path: " + opts.Files[0].TreePath + "]"
+ assert.EqualError(t, err, expectedError)
+ })
+ })
+}
diff --git a/tests/integration/schemas/nodeinfo_2.1.json b/tests/integration/schemas/nodeinfo_2.1.json
new file mode 100644
index 0000000..561e644
--- /dev/null
+++ b/tests/integration/schemas/nodeinfo_2.1.json
@@ -0,0 +1,188 @@
+{
+ "$schema": "http://json-schema.org/draft-04/schema#",
+ "id": "http://nodeinfo.diaspora.software/ns/schema/2.1#",
+ "description": "NodeInfo schema version 2.1.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "version",
+ "software",
+ "protocols",
+ "services",
+ "openRegistrations",
+ "usage",
+ "metadata"
+ ],
+ "properties": {
+ "version": {
+ "description": "The schema version, must be 2.1.",
+ "enum": [
+ "2.1"
+ ]
+ },
+ "software": {
+ "description": "Metadata about server software in use.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "name",
+ "version"
+ ],
+ "properties": {
+ "name": {
+ "description": "The canonical name of this server software.",
+ "type": "string",
+ "pattern": "^[a-z0-9-]+$"
+ },
+ "version": {
+ "description": "The version of this server software.",
+ "type": "string"
+ },
+ "repository": {
+ "description": "The url of the source code repository of this server software.",
+ "type": "string"
+ },
+ "homepage": {
+ "description": "The url of the homepage of this server software.",
+ "type": "string"
+ }
+ }
+ },
+ "protocols": {
+ "description": "The protocols supported on this server.",
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "enum": [
+ "activitypub",
+ "buddycloud",
+ "dfrn",
+ "diaspora",
+ "libertree",
+ "ostatus",
+ "pumpio",
+ "tent",
+ "xmpp",
+ "zot"
+ ]
+ }
+ },
+ "services": {
+ "description": "The third party sites this server can connect to via their application API.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "inbound",
+ "outbound"
+ ],
+ "properties": {
+ "inbound": {
+ "description": "The third party sites this server can retrieve messages from for combined display with regular traffic.",
+ "type": "array",
+ "minItems": 0,
+ "items": {
+ "enum": [
+ "atom1.0",
+ "gnusocial",
+ "imap",
+ "pnut",
+ "pop3",
+ "pumpio",
+ "rss2.0",
+ "twitter"
+ ]
+ }
+ },
+ "outbound": {
+ "description": "The third party sites this server can publish messages to on the behalf of a user.",
+ "type": "array",
+ "minItems": 0,
+ "items": {
+ "enum": [
+ "atom1.0",
+ "blogger",
+ "buddycloud",
+ "diaspora",
+ "dreamwidth",
+ "drupal",
+ "facebook",
+ "friendica",
+ "gnusocial",
+ "google",
+ "insanejournal",
+ "libertree",
+ "linkedin",
+ "livejournal",
+ "mediagoblin",
+ "myspace",
+ "pinterest",
+ "pnut",
+ "posterous",
+ "pumpio",
+ "redmatrix",
+ "rss2.0",
+ "smtp",
+ "tent",
+ "tumblr",
+ "twitter",
+ "wordpress",
+ "xmpp"
+ ]
+ }
+ }
+ }
+ },
+ "openRegistrations": {
+ "description": "Whether this server allows open self-registration.",
+ "type": "boolean"
+ },
+ "usage": {
+ "description": "Usage statistics for this server.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "users"
+ ],
+ "properties": {
+ "users": {
+ "description": "statistics about the users of this server.",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "total": {
+ "description": "The total amount of on this server registered users.",
+ "type": "integer",
+ "minimum": 0
+ },
+ "activeHalfyear": {
+ "description": "The amount of users that signed in at least once in the last 180 days.",
+ "type": "integer",
+ "minimum": 0
+ },
+ "activeMonth": {
+ "description": "The amount of users that signed in at least once in the last 30 days.",
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ },
+ "localPosts": {
+ "description": "The amount of posts that were made by users that are registered on this server.",
+ "type": "integer",
+ "minimum": 0
+ },
+ "localComments": {
+ "description": "The amount of comments that were made by users that are registered on this server.",
+ "type": "integer",
+ "minimum": 0
+ }
+ }
+ },
+ "metadata": {
+ "description": "Free form key value pairs for software specific values. Clients should not rely on any specific key present.",
+ "type": "object",
+ "minProperties": 0,
+ "additionalProperties": true
+ }
+ }
+}
diff --git a/tests/integration/session_test.go b/tests/integration/session_test.go
new file mode 100644
index 0000000..a5bcab2
--- /dev/null
+++ b/tests/integration/session_test.go
@@ -0,0 +1,38 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_RegenerateSession(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ key := "new_key890123456" // it must be 16 characters long
+ key2 := "new_key890123457" // it must be 16 characters
+ exist, err := auth.ExistSession(db.DefaultContext, key)
+ require.NoError(t, err)
+ assert.False(t, exist)
+
+ sess, err := auth.RegenerateSession(db.DefaultContext, "", key)
+ require.NoError(t, err)
+ assert.EqualValues(t, key, sess.Key)
+ assert.Empty(t, sess.Data, 0)
+
+ sess, err = auth.ReadSession(db.DefaultContext, key2)
+ require.NoError(t, err)
+ assert.EqualValues(t, key2, sess.Key)
+ assert.Empty(t, sess.Data, 0)
+}
diff --git a/tests/integration/setting_test.go b/tests/integration/setting_test.go
new file mode 100644
index 0000000..29615b3
--- /dev/null
+++ b/tests/integration/setting_test.go
@@ -0,0 +1,158 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSettingShowUserEmailExplore(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ showUserEmail := setting.UI.ShowUserEmail
+ setting.UI.ShowUserEmail = true
+
+ session := loginUser(t, "user2")
+ req := NewRequest(t, "GET", "/explore/users?sort=alphabetically")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Contains(t,
+ htmlDoc.doc.Find(".explore.users").Text(),
+ "user34@example.com",
+ )
+
+ setting.UI.ShowUserEmail = false
+
+ req = NewRequest(t, "GET", "/explore/users?sort=alphabetically")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ assert.NotContains(t,
+ htmlDoc.doc.Find(".explore.users").Text(),
+ "user34@example.com",
+ )
+
+ setting.UI.ShowUserEmail = showUserEmail
+}
+
+func TestSettingShowUserEmailProfile(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ showUserEmail := setting.UI.ShowUserEmail
+
+ // user1: keep_email_private = false, user2: keep_email_private = true
+
+ setting.UI.ShowUserEmail = true
+
+ // user1 can see own visible email
+ session := loginUser(t, "user1")
+ req := NewRequest(t, "GET", "/user1")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Contains(t, htmlDoc.doc.Find(".user.profile").Text(), "user1@example.com")
+
+ // user1 can not see user2's hidden email
+ req = NewRequest(t, "GET", "/user2")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ // Should only contain if the user visits their own profile page
+ assert.NotContains(t, htmlDoc.doc.Find(".user.profile").Text(), "user2@example.com")
+
+ // user2 can see user1's visible email
+ session = loginUser(t, "user2")
+ req = NewRequest(t, "GET", "/user1")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ assert.Contains(t, htmlDoc.doc.Find(".user.profile").Text(), "user1@example.com")
+
+ // user2 cannot see own hidden email
+ session = loginUser(t, "user2")
+ req = NewRequest(t, "GET", "/user2")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ assert.NotContains(t, htmlDoc.doc.Find(".user.profile").Text(), "user2@example.com")
+
+ setting.UI.ShowUserEmail = false
+
+ // user1 cannot see own (now hidden) email
+ session = loginUser(t, "user1")
+ req = NewRequest(t, "GET", "/user1")
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+ assert.NotContains(t, htmlDoc.doc.Find(".user.profile").Text(), "user1@example.com")
+
+ setting.UI.ShowUserEmail = showUserEmail
+}
+
+func TestSettingLandingPage(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ landingPage := setting.LandingPageURL
+
+ setting.LandingPageURL = setting.LandingPageHome
+ req := NewRequest(t, "GET", "/")
+ MakeRequest(t, req, http.StatusOK)
+
+ setting.LandingPageURL = setting.LandingPageExplore
+ req = NewRequest(t, "GET", "/")
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, "/explore", resp.Header().Get("Location"))
+
+ setting.LandingPageURL = setting.LandingPageOrganizations
+ req = NewRequest(t, "GET", "/")
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, "/explore/organizations", resp.Header().Get("Location"))
+
+ setting.LandingPageURL = setting.LandingPageLogin
+ req = NewRequest(t, "GET", "/")
+ resp = MakeRequest(t, req, http.StatusSeeOther)
+ assert.Equal(t, "/user/login", resp.Header().Get("Location"))
+
+ setting.LandingPageURL = landingPage
+}
+
+func TestSettingSecurityAuthSource(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
+
+ active := addAuthSource(t, authSourcePayloadGitLabCustom("gitlab-active"))
+ activeExternalLoginUser := &user_model.ExternalLoginUser{
+ ExternalID: "12345",
+ UserID: user.ID,
+ LoginSourceID: active.ID,
+ }
+ err := user_model.LinkExternalToUser(db.DefaultContext, user, activeExternalLoginUser)
+ require.NoError(t, err)
+
+ inactive := addAuthSource(t, authSourcePayloadGitLabCustom("gitlab-inactive"))
+ inactiveExternalLoginUser := &user_model.ExternalLoginUser{
+ ExternalID: "5678",
+ UserID: user.ID,
+ LoginSourceID: inactive.ID,
+ }
+ err = user_model.LinkExternalToUser(db.DefaultContext, user, inactiveExternalLoginUser)
+ require.NoError(t, err)
+
+ // mark the authSource as inactive
+ inactive.IsActive = false
+ err = auth_model.UpdateSource(db.DefaultContext, inactive)
+ require.NoError(t, err)
+
+ session := loginUser(t, "user1")
+ req := NewRequest(t, "GET", "user/settings/security")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ assert.Contains(t, resp.Body.String(), `gitlab-active`)
+ assert.Contains(t, resp.Body.String(), `gitlab-inactive`)
+}
diff --git a/tests/integration/signin_test.go b/tests/integration/signin_test.go
new file mode 100644
index 0000000..77e19bb
--- /dev/null
+++ b/tests/integration/signin_test.go
@@ -0,0 +1,95 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func testLoginFailed(t *testing.T, username, password, message string) {
+ session := emptyTestSession(t)
+ req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/login"),
+ "user_name": username,
+ "password": password,
+ })
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ resultMsg := htmlDoc.doc.Find(".ui.message>p").Text()
+
+ assert.EqualValues(t, message, resultMsg)
+}
+
+func TestSignin(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ // add new user with user2's email
+ user.Name = "testuser"
+ user.LowerName = strings.ToLower(user.Name)
+ user.ID = 0
+ unittest.AssertSuccessfulInsert(t, user)
+
+ samples := []struct {
+ username string
+ password string
+ message string
+ }{
+ {username: "wrongUsername", password: "wrongPassword", message: translation.NewLocale("en-US").TrString("form.username_password_incorrect")},
+ {username: "wrongUsername", password: "password", message: translation.NewLocale("en-US").TrString("form.username_password_incorrect")},
+ {username: "user15", password: "wrongPassword", message: translation.NewLocale("en-US").TrString("form.username_password_incorrect")},
+ {username: "user1@example.com", password: "wrongPassword", message: translation.NewLocale("en-US").TrString("form.username_password_incorrect")},
+ }
+
+ for _, s := range samples {
+ testLoginFailed(t, s.username, s.password, s.message)
+ }
+}
+
+func TestSigninWithRememberMe(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ baseURL, _ := url.Parse(setting.AppURL)
+
+ session := emptyTestSession(t)
+ req := NewRequestWithValues(t, "POST", "/user/login", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/login"),
+ "user_name": user.Name,
+ "password": userPassword,
+ "remember": "on",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ c := session.GetCookie(setting.CookieRememberName)
+ assert.NotNil(t, c)
+
+ session = emptyTestSession(t)
+
+ // Without session the settings page should not be reachable
+ req = NewRequest(t, "GET", "/user/settings")
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ req = NewRequest(t, "GET", "/user/login")
+ // Set the remember me cookie for the login GET request
+ session.jar.SetCookies(baseURL, []*http.Cookie{c})
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // With session the settings page should be reachable
+ req = NewRequest(t, "GET", "/user/settings")
+ session.MakeRequest(t, req, http.StatusOK)
+}
diff --git a/tests/integration/signout_test.go b/tests/integration/signout_test.go
new file mode 100644
index 0000000..7fd0b5c
--- /dev/null
+++ b/tests/integration/signout_test.go
@@ -0,0 +1,24 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "code.gitea.io/gitea/tests"
+)
+
+func TestSignOut(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ req := NewRequest(t, "POST", "/user/logout")
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // try to view a private repo, should fail
+ req = NewRequest(t, "GET", "/user2/repo2")
+ session.MakeRequest(t, req, http.StatusNotFound)
+}
diff --git a/tests/integration/signup_test.go b/tests/integration/signup_test.go
new file mode 100644
index 0000000..d5df41f
--- /dev/null
+++ b/tests/integration/signup_test.go
@@ -0,0 +1,209 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/cache"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/modules/translation"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSignup(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.Service.EnableCaptcha = false
+
+ req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
+ "user_name": "exampleUser",
+ "email": "exampleUser@example.com",
+ "password": "examplePassword!1",
+ "retype": "examplePassword!1",
+ })
+ MakeRequest(t, req, http.StatusSeeOther)
+
+ // should be able to view new user's page
+ req = NewRequest(t, "GET", "/exampleUser")
+ MakeRequest(t, req, http.StatusOK)
+}
+
+func TestSignupAsRestricted(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.Service.EnableCaptcha = false
+ setting.Service.DefaultUserIsRestricted = true
+
+ req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
+ "user_name": "restrictedUser",
+ "email": "restrictedUser@example.com",
+ "password": "examplePassword!1",
+ "retype": "examplePassword!1",
+ })
+ MakeRequest(t, req, http.StatusSeeOther)
+
+ // should be able to view new user's page
+ req = NewRequest(t, "GET", "/restrictedUser")
+ MakeRequest(t, req, http.StatusOK)
+
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "restrictedUser"})
+ assert.True(t, user2.IsRestricted)
+}
+
+func TestSignupEmail(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.Service.EnableCaptcha = false
+
+ tests := []struct {
+ email string
+ wantStatus int
+ wantMsg string
+ }{
+ {"exampleUser@example.com\r\n", http.StatusOK, translation.NewLocale("en-US").TrString("form.email_invalid")},
+ {"exampleUser@example.com\r", http.StatusOK, translation.NewLocale("en-US").TrString("form.email_invalid")},
+ {"exampleUser@example.com\n", http.StatusOK, translation.NewLocale("en-US").TrString("form.email_invalid")},
+ {"exampleUser@example.com", http.StatusSeeOther, ""},
+ }
+
+ for i, test := range tests {
+ req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
+ "user_name": fmt.Sprintf("exampleUser%d", i),
+ "email": test.email,
+ "password": "examplePassword!1",
+ "retype": "examplePassword!1",
+ })
+ resp := MakeRequest(t, req, test.wantStatus)
+ if test.wantMsg != "" {
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Equal(t,
+ test.wantMsg,
+ strings.TrimSpace(htmlDoc.doc.Find(".ui.message").Text()),
+ )
+ }
+ }
+}
+
+func TestSignupEmailChangeForInactiveUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // Disable the captcha & enable email confirmation for registrations
+ defer test.MockVariableValue(&setting.Service.EnableCaptcha, false)()
+ defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, true)()
+
+ // Create user
+ req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
+ "user_name": "exampleUserX",
+ "email": "wrong-email@example.com",
+ "password": "examplePassword!1",
+ "retype": "examplePassword!1",
+ })
+ MakeRequest(t, req, http.StatusOK)
+
+ session := loginUserWithPassword(t, "exampleUserX", "examplePassword!1")
+
+ // Verify that the initial e-mail is the wrong one.
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserX"})
+ assert.Equal(t, "wrong-email@example.com", user.Email)
+
+ // Change the email address
+ req = NewRequestWithValues(t, "POST", "/user/activate", map[string]string{
+ "email": "fine-email@example.com",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ // Verify that the email was updated
+ user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserX"})
+ assert.Equal(t, "fine-email@example.com", user.Email)
+
+ // Try to change the email again
+ req = NewRequestWithValues(t, "POST", "/user/activate", map[string]string{
+ "email": "wrong-again@example.com",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ // Verify that the email was NOT updated
+ user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserX"})
+ assert.Equal(t, "fine-email@example.com", user.Email)
+}
+
+func TestSignupEmailChangeForActiveUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // Disable the captcha & enable email confirmation for registrations
+ defer test.MockVariableValue(&setting.Service.EnableCaptcha, false)()
+ defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, false)()
+
+ // Create user
+ req := NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
+ "user_name": "exampleUserY",
+ "email": "wrong-email-2@example.com",
+ "password": "examplePassword!1",
+ "retype": "examplePassword!1",
+ })
+ MakeRequest(t, req, http.StatusSeeOther)
+
+ session := loginUserWithPassword(t, "exampleUserY", "examplePassword!1")
+
+ // Verify that the initial e-mail is the wrong one.
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserY"})
+ assert.Equal(t, "wrong-email-2@example.com", user.Email)
+
+ // Changing the email for a validated address is not available
+ req = NewRequestWithValues(t, "POST", "/user/activate", map[string]string{
+ "email": "fine-email-2@example.com",
+ })
+ session.MakeRequest(t, req, http.StatusNotFound)
+
+ // Verify that the email remained unchanged
+ user = unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "exampleUserY"})
+ assert.Equal(t, "wrong-email-2@example.com", user.Email)
+}
+
+func TestSignupImageCaptcha(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ defer test.MockVariableValue(&setting.Service.RegisterEmailConfirm, false)()
+ defer test.MockVariableValue(&setting.Service.EnableCaptcha, true)()
+ defer test.MockVariableValue(&setting.Service.CaptchaType, "image")()
+ c := cache.GetCache()
+
+ req := NewRequest(t, "GET", "/user/sign_up")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ idCaptcha, ok := htmlDoc.Find("input[name='img-captcha-id']").Attr("value")
+ assert.True(t, ok)
+
+ digits, ok := c.Get("captcha:" + idCaptcha).(string)
+ assert.True(t, ok)
+ assert.Len(t, digits, 6)
+
+ digitStr := ""
+ // Convert digits to ASCII digits.
+ for _, digit := range digits {
+ digitStr += string(digit + '0')
+ }
+
+ req = NewRequestWithValues(t, "POST", "/user/sign_up", map[string]string{
+ "user_name": "captcha-test",
+ "email": "captcha-test@example.com",
+ "password": "examplePassword!1",
+ "retype": "examplePassword!1",
+ "img-captcha-id": idCaptcha,
+ "img-captcha-response": digitStr,
+ })
+ MakeRequest(t, req, http.StatusSeeOther)
+
+ loginUserWithPassword(t, "captcha-test", "examplePassword!1")
+
+ unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "captcha-test", IsActive: true})
+}
diff --git a/tests/integration/size_translations_test.go b/tests/integration/size_translations_test.go
new file mode 100644
index 0000000..a0b8829
--- /dev/null
+++ b/tests/integration/size_translations_test.go
@@ -0,0 +1,116 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "path"
+ "regexp"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestDataSizeTranslation(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ testUser := "user2"
+ testRepoName := "data_size_test"
+ noDigits := regexp.MustCompile("[0-9]+")
+ longString100 := `testRepoMigrate(t, session, "https://code.forgejo.org/forgejo/test_repo.git", testRepoName, struct)` + "\n"
+
+ // Login user
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: testUser})
+ session := loginUser(t, testUser)
+
+ // Create test repo
+ testRepo, _, f := tests.CreateDeclarativeRepo(t, user2, testRepoName, nil, nil,
+ []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "137byteFile.txt",
+ ContentReader: strings.NewReader(longString100 + strings.Repeat("1", 36) + "\n"),
+ },
+ {
+ Operation: "create",
+ TreePath: "1.5kibFile.txt",
+ ContentReader: strings.NewReader(strings.Repeat(longString100, 15) + strings.Repeat("1", 35) + "\n"),
+ },
+ {
+ Operation: "create",
+ TreePath: "1.25mibFile.txt",
+ ContentReader: strings.NewReader(strings.Repeat(longString100, 13107) + strings.Repeat("1", 19) + "\n"),
+ },
+ })
+ defer f()
+
+ // Change language from English to catch regressions that make translated sizes fall back to
+ // not translated, like to raw output of FileSize() or humanize.IBytes()
+ lang := session.GetCookie("lang")
+ lang.Value = "ru-RU"
+ session.SetCookie(lang)
+
+ // Go to /user/settings/repos
+ req := NewRequest(t, "GET", "user/settings/repos")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ // Check if repo size is translated
+ repos := NewHTMLParser(t, resp.Body).Find(".user-setting-content .list .item .content")
+ assert.Positive(t, repos.Length())
+ repos.Each(func(i int, repo *goquery.Selection) {
+ repoName := repo.Find("a.name").Text()
+ if repoName == path.Join(testUser, testRepo.Name) {
+ repoSize := repo.Find("span").Text()
+ repoSize = noDigits.ReplaceAllString(repoSize, "")
+ assert.Equal(t, " КиБ", repoSize)
+ }
+ })
+
+ // Go to /user2/repo1
+ req = NewRequest(t, "GET", path.Join(testUser, testRepoName))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+
+ // Check if repo size in repo summary is translated
+ repo := NewHTMLParser(t, resp.Body).Find(".repository-summary span")
+ repoSize := strings.TrimSpace(repo.Text())
+ repoSize = noDigits.ReplaceAllString(repoSize, "")
+ assert.Equal(t, " КиБ", repoSize)
+
+ // Check if repo sizes in the tooltip are translated
+ fullSize, exists := repo.Attr("data-tooltip-content")
+ assert.True(t, exists)
+ fullSize = noDigits.ReplaceAllString(fullSize, "")
+ assert.Equal(t, "git: КиБ; lfs: Б", fullSize)
+
+ // Check if file sizes are correctly translated
+ testFileSizeTranslated(t, session, path.Join(testUser, testRepoName, "src/branch/main/137byteFile.txt"), "137 Б")
+ testFileSizeTranslated(t, session, path.Join(testUser, testRepoName, "src/branch/main/1.5kibFile.txt"), "1,5 КиБ")
+ testFileSizeTranslated(t, session, path.Join(testUser, testRepoName, "src/branch/main/1.25mibFile.txt"), "1,3 МиБ")
+ })
+}
+
+func testFileSizeTranslated(t *testing.T, session *TestSession, filePath, correctSize string) {
+ // Go to specified file page
+ req := NewRequest(t, "GET", filePath)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ // Check if file size is translated
+ sizeCorrent := false
+ fileInfo := NewHTMLParser(t, resp.Body).Find(".file-info .file-info-entry")
+ fileInfo.Each(func(i int, info *goquery.Selection) {
+ infoText := strings.TrimSpace(info.Text())
+ if infoText == correctSize {
+ sizeCorrent = true
+ }
+ })
+
+ assert.True(t, sizeCorrent)
+}
diff --git a/tests/integration/ssh_key_test.go b/tests/integration/ssh_key_test.go
new file mode 100644
index 0000000..ebf0d26
--- /dev/null
+++ b/tests/integration/ssh_key_test.go
@@ -0,0 +1,208 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/git"
+ api "code.gitea.io/gitea/modules/structs"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func doCheckRepositoryEmptyStatus(ctx APITestContext, isEmpty bool) func(*testing.T) {
+ return doAPIGetRepository(ctx, func(t *testing.T, repository api.Repository) {
+ assert.Equal(t, isEmpty, repository.Empty)
+ })
+}
+
+func doAddChangesToCheckout(dstPath, filename string) func(*testing.T) {
+ return func(t *testing.T) {
+ require.NoError(t, os.WriteFile(filepath.Join(dstPath, filename), []byte(fmt.Sprintf("# Testing Repository\n\nOriginally created in: %s at time: %v", dstPath, time.Now())), 0o644))
+ require.NoError(t, git.AddChanges(dstPath, true))
+ signature := git.Signature{
+ Email: "test@example.com",
+ Name: "test",
+ When: time.Now(),
+ }
+ require.NoError(t, git.CommitChanges(dstPath, git.CommitChangesOptions{
+ Committer: &signature,
+ Author: &signature,
+ Message: "Initial Commit",
+ }))
+ }
+}
+
+func TestPushDeployKeyOnEmptyRepo(t *testing.T) {
+ onGiteaRun(t, testPushDeployKeyOnEmptyRepo)
+}
+
+func testPushDeployKeyOnEmptyRepo(t *testing.T, u *url.URL) {
+ forEachObjectFormat(t, func(t *testing.T, objectFormat git.ObjectFormat) {
+ // OK login
+ ctx := NewAPITestContext(t, "user2", "deploy-key-empty-repo-"+objectFormat.Name(), auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+ keyname := fmt.Sprintf("%s-push", ctx.Reponame)
+ u.Path = ctx.GitPath()
+
+ t.Run("CreateEmptyRepository", doAPICreateRepository(ctx, true, objectFormat))
+
+ t.Run("CheckIsEmpty", doCheckRepositoryEmptyStatus(ctx, true))
+
+ withKeyFile(t, keyname, func(keyFile string) {
+ t.Run("CreatePushDeployKey", doAPICreateDeployKey(ctx, keyname, keyFile, false))
+
+ // Setup the testing repository
+ dstPath := t.TempDir()
+
+ t.Run("InitTestRepository", doGitInitTestRepository(dstPath, objectFormat))
+
+ // Setup remote link
+ sshURL := createSSHUrl(ctx.GitPath(), u)
+
+ t.Run("AddRemote", doGitAddRemote(dstPath, "origin", sshURL))
+
+ t.Run("SSHPushTestRepository", doGitPushTestRepository(dstPath, "origin", "master"))
+
+ t.Run("CheckIsNotEmpty", doCheckRepositoryEmptyStatus(ctx, false))
+
+ t.Run("DeleteRepository", doAPIDeleteRepository(ctx))
+ })
+ })
+}
+
+func TestKeyOnlyOneType(t *testing.T) {
+ onGiteaRun(t, testKeyOnlyOneType)
+}
+
+func testKeyOnlyOneType(t *testing.T, u *url.URL) {
+ // Once a key is a user key we cannot use it as a deploy key
+ // If we delete it from the user we should be able to use it as a deploy key
+ reponame := "ssh-key-test-repo"
+ username := "user2"
+ u.Path = fmt.Sprintf("%s/%s.git", username, reponame)
+ keyname := fmt.Sprintf("%s-push", reponame)
+
+ // OK login
+ ctx := NewAPITestContext(t, username, reponame, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+ ctxWithDeleteRepo := NewAPITestContext(t, username, reponame, auth_model.AccessTokenScopeWriteRepository, auth_model.AccessTokenScopeWriteUser)
+
+ otherCtx := ctx
+ otherCtx.Reponame = "ssh-key-test-repo-2"
+ otherCtxWithDeleteRepo := ctxWithDeleteRepo
+ otherCtxWithDeleteRepo.Reponame = otherCtx.Reponame
+
+ failCtx := ctx
+ failCtx.ExpectedCode = http.StatusUnprocessableEntity
+
+ t.Run("CreateRepository", doAPICreateRepository(ctx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat
+ t.Run("CreateOtherRepository", doAPICreateRepository(otherCtx, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat
+
+ withKeyFile(t, keyname, func(keyFile string) {
+ var userKeyPublicKeyID int64
+ t.Run("KeyCanOnlyBeUser", func(t *testing.T) {
+ dstPath := t.TempDir()
+
+ sshURL := createSSHUrl(ctx.GitPath(), u)
+
+ t.Run("FailToClone", doGitCloneFail(sshURL))
+
+ t.Run("CreateUserKey", doAPICreateUserKey(ctx, keyname, keyFile, func(t *testing.T, publicKey api.PublicKey) {
+ userKeyPublicKeyID = publicKey.ID
+ }))
+
+ t.Run("FailToAddReadOnlyDeployKey", doAPICreateDeployKey(failCtx, keyname, keyFile, true))
+
+ t.Run("FailToAddDeployKey", doAPICreateDeployKey(failCtx, keyname, keyFile, false))
+
+ t.Run("Clone", doGitClone(dstPath, sshURL))
+
+ t.Run("AddChanges", doAddChangesToCheckout(dstPath, "CHANGES1.md"))
+
+ t.Run("Push", doGitPushTestRepository(dstPath, "origin", "master"))
+
+ t.Run("DeleteUserKey", doAPIDeleteUserKey(ctx, userKeyPublicKeyID))
+ })
+
+ t.Run("KeyCanBeAnyDeployButNotUserAswell", func(t *testing.T) {
+ dstPath := t.TempDir()
+
+ sshURL := createSSHUrl(ctx.GitPath(), u)
+
+ t.Run("FailToClone", doGitCloneFail(sshURL))
+
+ // Should now be able to add...
+ t.Run("AddReadOnlyDeployKey", doAPICreateDeployKey(ctx, keyname, keyFile, true))
+
+ t.Run("Clone", doGitClone(dstPath, sshURL))
+
+ t.Run("AddChanges", doAddChangesToCheckout(dstPath, "CHANGES2.md"))
+
+ t.Run("FailToPush", doGitPushTestRepositoryFail(dstPath, "origin", "master"))
+
+ otherSSHURL := createSSHUrl(otherCtx.GitPath(), u)
+ dstOtherPath := t.TempDir()
+
+ t.Run("AddWriterDeployKeyToOther", doAPICreateDeployKey(otherCtx, keyname, keyFile, false))
+
+ t.Run("CloneOther", doGitClone(dstOtherPath, otherSSHURL))
+
+ t.Run("AddChangesToOther", doAddChangesToCheckout(dstOtherPath, "CHANGES3.md"))
+
+ t.Run("PushToOther", doGitPushTestRepository(dstOtherPath, "origin", "master"))
+
+ t.Run("FailToCreateUserKey", doAPICreateUserKey(failCtx, keyname, keyFile))
+ })
+
+ t.Run("DeleteRepositoryShouldReleaseKey", func(t *testing.T) {
+ otherSSHURL := createSSHUrl(otherCtx.GitPath(), u)
+ dstOtherPath := t.TempDir()
+
+ t.Run("DeleteRepository", doAPIDeleteRepository(ctxWithDeleteRepo))
+
+ t.Run("FailToCreateUserKeyAsStillDeploy", doAPICreateUserKey(failCtx, keyname, keyFile))
+
+ t.Run("MakeSureCloneOtherStillWorks", doGitClone(dstOtherPath, otherSSHURL))
+
+ t.Run("AddChangesToOther", doAddChangesToCheckout(dstOtherPath, "CHANGES3.md"))
+
+ t.Run("PushToOther", doGitPushTestRepository(dstOtherPath, "origin", "master"))
+
+ t.Run("DeleteOtherRepository", doAPIDeleteRepository(otherCtxWithDeleteRepo))
+
+ t.Run("RecreateRepository", doAPICreateRepository(ctxWithDeleteRepo, false, git.Sha1ObjectFormat)) // FIXME: use forEachObjectFormat
+
+ t.Run("CreateUserKey", doAPICreateUserKey(ctx, keyname, keyFile, func(t *testing.T, publicKey api.PublicKey) {
+ userKeyPublicKeyID = publicKey.ID
+ }))
+
+ dstPath := t.TempDir()
+
+ sshURL := createSSHUrl(ctx.GitPath(), u)
+
+ t.Run("Clone", doGitClone(dstPath, sshURL))
+
+ t.Run("AddChanges", doAddChangesToCheckout(dstPath, "CHANGES1.md"))
+
+ t.Run("Push", doGitPushTestRepository(dstPath, "origin", "master"))
+ })
+
+ t.Run("DeleteUserKeyShouldRemoveAbilityToClone", func(t *testing.T) {
+ sshURL := createSSHUrl(ctx.GitPath(), u)
+
+ t.Run("DeleteUserKey", doAPIDeleteUserKey(ctx, userKeyPublicKeyID))
+
+ t.Run("FailToClone", doGitCloneFail(sshURL))
+ })
+ })
+}
diff --git a/tests/integration/timetracking_test.go b/tests/integration/timetracking_test.go
new file mode 100644
index 0000000..10e539c
--- /dev/null
+++ b/tests/integration/timetracking_test.go
@@ -0,0 +1,81 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "path"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestViewTimetrackingControls(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+ testViewTimetrackingControls(t, session, "user2", "repo1", "1", true)
+ // user2/repo1
+}
+
+func TestNotViewTimetrackingControls(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user5")
+ testViewTimetrackingControls(t, session, "user2", "repo1", "1", false)
+ // user2/repo1
+}
+
+func TestViewTimetrackingControlsDisabled(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ session := loginUser(t, "user2")
+ testViewTimetrackingControls(t, session, "org3", "repo3", "1", false)
+}
+
+func testViewTimetrackingControls(t *testing.T, session *TestSession, user, repo, issue string, canTrackTime bool) {
+ req := NewRequest(t, "GET", path.Join(user, repo, "issues", issue))
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, ".timetrack .issue-start-time", canTrackTime)
+ htmlDoc.AssertElement(t, ".timetrack .issue-add-time", canTrackTime)
+
+ req = NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", issue, "times", "stopwatch", "toggle"), map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ })
+ if canTrackTime {
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+
+ events := htmlDoc.doc.Find(".event > span.text")
+ assert.Contains(t, events.Last().Text(), "started working")
+
+ htmlDoc.AssertElement(t, ".timetrack .issue-stop-time", true)
+ htmlDoc.AssertElement(t, ".timetrack .issue-cancel-time", true)
+
+ // Sleep for 1 second to not get wrong order for stopping timer
+ time.Sleep(time.Second)
+
+ req = NewRequestWithValues(t, "POST", path.Join(user, repo, "issues", issue, "times", "stopwatch", "toggle"), map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ })
+ resp = session.MakeRequest(t, req, http.StatusSeeOther)
+
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc = NewHTMLParser(t, resp.Body)
+
+ events = htmlDoc.doc.Find(".event > span.text")
+ assert.Contains(t, events.Last().Text(), "stopped working")
+ htmlDoc.AssertElement(t, ".event .detail .octicon-clock", true)
+ } else {
+ session.MakeRequest(t, req, http.StatusNotFound)
+ }
+}
diff --git a/tests/integration/user_avatar_test.go b/tests/integration/user_avatar_test.go
new file mode 100644
index 0000000..a5805d0
--- /dev/null
+++ b/tests/integration/user_avatar_test.go
@@ -0,0 +1,94 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "bytes"
+ "fmt"
+ "image/png"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/url"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/avatar"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUserAvatar(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo3, is an org
+
+ seed := user2.Email
+ if len(seed) == 0 {
+ seed = user2.Name
+ }
+
+ img, err := avatar.RandomImage([]byte(seed))
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ session := loginUser(t, "user2")
+ csrf := GetCSRF(t, session, "/user/settings")
+
+ imgData := &bytes.Buffer{}
+
+ body := &bytes.Buffer{}
+
+ // Setup multi-part
+ writer := multipart.NewWriter(body)
+ writer.WriteField("source", "local")
+ part, err := writer.CreateFormFile("avatar", "avatar-for-testuseravatar.png")
+ if err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ if err := png.Encode(imgData, img); err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ if _, err := io.Copy(part, imgData); err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ if err := writer.Close(); err != nil {
+ require.NoError(t, err)
+ return
+ }
+
+ req := NewRequestWithBody(t, "POST", "/user/settings/avatar", body)
+ req.Header.Add("X-Csrf-Token", csrf)
+ req.Header.Add("Content-Type", writer.FormDataContentType())
+
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ user2 = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) // owner of the repo3, is an org
+
+ req = NewRequest(t, "GET", user2.AvatarLinkWithSize(db.DefaultContext, 0))
+ _ = session.MakeRequest(t, req, http.StatusOK)
+
+ testGetAvatarRedirect(t, user2)
+
+ // Can't test if the response matches because the image is re-generated on upload but checking that this at least doesn't give a 404 should be enough.
+ })
+}
+
+func testGetAvatarRedirect(t *testing.T, user *user_model.User) {
+ t.Run(fmt.Sprintf("getAvatarRedirect_%s", user.Name), func(t *testing.T) {
+ req := NewRequestf(t, "GET", "/%s.png", user.Name)
+ resp := MakeRequest(t, req, http.StatusSeeOther)
+ assert.EqualValues(t, fmt.Sprintf("/avatars/%s", user.Avatar), resp.Header().Get("location"))
+ })
+}
diff --git a/tests/integration/user_count_test.go b/tests/integration/user_count_test.go
new file mode 100644
index 0000000..e76c30c
--- /dev/null
+++ b/tests/integration/user_count_test.go
@@ -0,0 +1,175 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ "code.gitea.io/gitea/models/organization"
+ packages_model "code.gitea.io/gitea/models/packages"
+ project_model "code.gitea.io/gitea/models/project"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/PuerkitoBio/goquery"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type userCountTest struct {
+ doer *user_model.User
+ user *user_model.User
+ session *TestSession
+ repoCount int64
+ projectCount int64
+ packageCount int64
+ memberCount int64
+ teamCount int64
+}
+
+func (countTest *userCountTest) Init(t *testing.T, doerID, userID int64) {
+ countTest.doer = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: doerID})
+ countTest.user = unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: userID})
+ countTest.session = loginUser(t, countTest.doer.Name)
+
+ var err error
+
+ countTest.repoCount, err = repo_model.CountRepository(db.DefaultContext, &repo_model.SearchRepoOptions{
+ Actor: countTest.doer,
+ OwnerID: countTest.user.ID,
+ Private: true,
+ Collaborate: optional.Some(false),
+ })
+ require.NoError(t, err)
+
+ var projectType project_model.Type
+ if countTest.user.IsOrganization() {
+ projectType = project_model.TypeOrganization
+ } else {
+ projectType = project_model.TypeIndividual
+ }
+ countTest.projectCount, err = db.Count[project_model.Project](db.DefaultContext, &project_model.SearchOptions{
+ OwnerID: countTest.user.ID,
+ IsClosed: optional.Some(false),
+ Type: projectType,
+ })
+ require.NoError(t, err)
+ countTest.packageCount, err = packages_model.CountOwnerPackages(db.DefaultContext, countTest.user.ID)
+ require.NoError(t, err)
+
+ if !countTest.user.IsOrganization() {
+ return
+ }
+
+ org := (*organization.Organization)(countTest.user)
+
+ isMember, err := org.IsOrgMember(db.DefaultContext, countTest.doer.ID)
+ require.NoError(t, err)
+
+ countTest.memberCount, err = organization.CountOrgMembers(db.DefaultContext, &organization.FindOrgMembersOpts{
+ OrgID: org.ID,
+ PublicOnly: !isMember,
+ })
+ require.NoError(t, err)
+
+ teams, err := org.LoadTeams(db.DefaultContext)
+ require.NoError(t, err)
+
+ countTest.teamCount = int64(len(teams))
+}
+
+func (countTest *userCountTest) getCount(doc *goquery.Document, name string) (int64, error) {
+ selection := doc.Find(fmt.Sprintf("[test-name=\"%s\"]", name))
+
+ if selection.Length() != 1 {
+ return 0, fmt.Errorf("%s was not found", name)
+ }
+
+ return strconv.ParseInt(selection.Text(), 10, 64)
+}
+
+func (countTest *userCountTest) TestPage(t *testing.T, page string, orgLink bool) {
+ t.Run(page, func(t *testing.T) {
+ var userLink string
+
+ if orgLink {
+ userLink = countTest.user.OrganisationLink()
+ } else {
+ userLink = countTest.user.HomeLink()
+ }
+
+ req := NewRequestf(t, "GET", "%s/%s", userLink, page)
+ resp := countTest.session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ repoCount, err := countTest.getCount(htmlDoc.doc, "repository-count")
+ require.NoError(t, err)
+ assert.Equal(t, countTest.repoCount, repoCount)
+
+ projectCount, err := countTest.getCount(htmlDoc.doc, "project-count")
+ require.NoError(t, err)
+ assert.Equal(t, countTest.projectCount, projectCount)
+
+ packageCount, err := countTest.getCount(htmlDoc.doc, "package-count")
+ require.NoError(t, err)
+ assert.Equal(t, countTest.packageCount, packageCount)
+
+ if !countTest.user.IsOrganization() {
+ return
+ }
+
+ memberCount, err := countTest.getCount(htmlDoc.doc, "member-count")
+ require.NoError(t, err)
+ assert.Equal(t, countTest.memberCount, memberCount)
+
+ teamCount, err := countTest.getCount(htmlDoc.doc, "team-count")
+ require.NoError(t, err)
+ assert.Equal(t, countTest.teamCount, teamCount)
+ })
+}
+
+func TestFrontendHeaderCountUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ countTest := new(userCountTest)
+ countTest.Init(t, 2, 2)
+
+ countTest.TestPage(t, "", false)
+ countTest.TestPage(t, "?tab=repositories", false)
+ countTest.TestPage(t, "-/projects", false)
+ countTest.TestPage(t, "-/packages", false)
+ countTest.TestPage(t, "?tab=activity", false)
+ countTest.TestPage(t, "?tab=stars", false)
+}
+
+func TestFrontendHeaderCountOrg(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ countTest := new(userCountTest)
+ countTest.Init(t, 15, 17)
+
+ countTest.TestPage(t, "", false)
+ countTest.TestPage(t, "-/projects", false)
+ countTest.TestPage(t, "-/packages", false)
+ countTest.TestPage(t, "members", true)
+ countTest.TestPage(t, "teams", true)
+
+ countTest.TestPage(t, "settings", true)
+ countTest.TestPage(t, "settings/hooks", true)
+ countTest.TestPage(t, "settings/labels", true)
+ countTest.TestPage(t, "settings/applications", true)
+ countTest.TestPage(t, "settings/packages", true)
+ countTest.TestPage(t, "settings/actions/runners", true)
+ countTest.TestPage(t, "settings/actions/secrets", true)
+ countTest.TestPage(t, "settings/actions/variables", true)
+ countTest.TestPage(t, "settings/blocked_users", true)
+ countTest.TestPage(t, "settings/delete", true)
+}
diff --git a/tests/integration/user_dashboard_test.go b/tests/integration/user_dashboard_test.go
new file mode 100644
index 0000000..abc3e06
--- /dev/null
+++ b/tests/integration/user_dashboard_test.go
@@ -0,0 +1,30 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ "code.gitea.io/gitea/modules/translation"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUserDashboardActionLinks(t *testing.T) {
+ require.NoError(t, unittest.PrepareTestDatabase())
+
+ session := loginUser(t, "user1")
+ locale := translation.NewLocale("en-US")
+
+ response := session.MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+ links := page.Find("#navbar .dropdown[data-tooltip-content='Create…'] .menu")
+ assert.EqualValues(t, locale.TrString("new_repo.link"), strings.TrimSpace(links.Find("a[href='/repo/create']").Text()))
+ assert.EqualValues(t, locale.TrString("new_migrate.link"), strings.TrimSpace(links.Find("a[href='/repo/migrate']").Text()))
+ assert.EqualValues(t, locale.TrString("new_org.link"), strings.TrimSpace(links.Find("a[href='/org/create']").Text()))
+}
diff --git a/tests/integration/user_profile_activity_test.go b/tests/integration/user_profile_activity_test.go
new file mode 100644
index 0000000..0592523
--- /dev/null
+++ b/tests/integration/user_profile_activity_test.go
@@ -0,0 +1,112 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TestUserProfileActivity ensures visibility and correctness of elements related to activity of a user:
+// - RSS feed button (doesn't test `other.ENABLE_FEED:false`)
+// - Public activity tab
+// - Banner/hint in the tab
+// - "Configure" link in the hint
+func TestUserProfileActivity(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ // This test needs multiple users with different access statuses to check for all possible states
+ userAdmin := loginUser(t, "user1")
+ userRegular := loginUser(t, "user2")
+ // Activity availability should be the same for guest and another non-admin user, so this is not tested separately
+ userGuest := emptyTestSession(t)
+
+ // = Public =
+
+ // Set activity visibility of user2 to public. This is the default, but won't hurt to set it before testing.
+ testChangeUserActivityVisibility(t, userRegular, "off")
+
+ // Verify availability of RSS button and activity tab
+ testUser2ActivityButtonsAvailability(t, userAdmin, true)
+ testUser2ActivityButtonsAvailability(t, userRegular, true)
+ testUser2ActivityButtonsAvailability(t, userGuest, true)
+
+ // Verify the hint for all types of users: admin, self, guest
+ testUser2ActivityVisibility(t, userAdmin, "This activity is visible to everyone, but as an administrator you can also see interactions in private spaces.", true)
+ hintLink := testUser2ActivityVisibility(t, userRegular, "Your activity is visible to everyone, except for interactions in private spaces. Configure.", true)
+ testUser2ActivityVisibility(t, userGuest, "", true)
+
+ // When viewing own profile, the user is offered to configure activity visibility. Verify that the link is correct and works, also check that it links back to the activity tab.
+ linkCorrect := assert.EqualValues(t, "/user/settings#keep-activity-private", hintLink)
+ if linkCorrect {
+ page := NewHTMLParser(t, userRegular.MakeRequest(t, NewRequest(t, "GET", hintLink), http.StatusOK).Body)
+ activityLink, exists := page.Find(".field:has(.checkbox#keep-activity-private) .help a").Attr("href")
+ assert.True(t, exists)
+ assert.EqualValues(t, "/user2?tab=activity", activityLink)
+ }
+
+ // = Private =
+
+ // Set activity visibility of user2 to private
+ testChangeUserActivityVisibility(t, userRegular, "on")
+
+ // Verify availability of RSS button and activity tab
+ testUser2ActivityButtonsAvailability(t, userAdmin, true)
+ testUser2ActivityButtonsAvailability(t, userRegular, true)
+ testUser2ActivityButtonsAvailability(t, userGuest, false)
+
+ // Verify the hint for all types of users: admin, self, guest
+ testUser2ActivityVisibility(t, userAdmin, "This activity is visible to you because you're an administrator, but the user wants it to remain private.", true)
+ hintLink = testUser2ActivityVisibility(t, userRegular, "Your activity is only visible to you and the instance administrators. Configure.", true)
+ testUser2ActivityVisibility(t, userGuest, "This user has disabled the public visibility of the activity.", false)
+
+ // Verify that Configure link is correct
+ assert.EqualValues(t, "/user/settings#keep-activity-private", hintLink)
+ })
+}
+
+// testChangeUserActivityVisibility allows to easily change visibility of public activity for a user
+func testChangeUserActivityVisibility(t *testing.T, session *TestSession, newState string) {
+ t.Helper()
+ session.MakeRequest(t, NewRequestWithValues(t, "POST", "/user/settings",
+ map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings"),
+ "keep_activity_private": newState,
+ }), http.StatusSeeOther)
+}
+
+// testUser2ActivityVisibility checks visibility of UI elements on /<user>?tab=activity
+// It also returns the account visibility link if it is present on the page.
+func testUser2ActivityVisibility(t *testing.T, session *TestSession, hint string, availability bool) string {
+ t.Helper()
+ response := session.MakeRequest(t, NewRequest(t, "GET", "/user2?tab=activity"), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+ // Check hint visibility and correctness
+ testSelectorEquals(t, page, "#visibility-hint", hint)
+ hintLink, hintLinkExists := page.Find("#visibility-hint a").Attr("href")
+
+ // Check that the hint aligns with the actual feed availability
+ assert.EqualValues(t, availability, page.Find("#activity-feed").Length() > 0)
+
+ // Check availability of RSS feed button too
+ assert.EqualValues(t, availability, page.Find("#profile-avatar-card a[href='/user2.rss']").Length() > 0)
+
+ // Check that the current tab is displayed and is active regardless of it's actual availability
+ // For example, on /<user> it wouldn't be available to guest, but it should be still present on /<user>?tab=activity
+ assert.Positive(t, page.Find("overflow-menu .active.item[href='/user2?tab=activity']").Length())
+ if hintLinkExists {
+ return hintLink
+ }
+ return ""
+}
+
+// testUser2ActivityButtonsAvailability checks visibility of Public activity tab on main profile page
+func testUser2ActivityButtonsAvailability(t *testing.T, session *TestSession, buttons bool) {
+ t.Helper()
+ response := session.MakeRequest(t, NewRequest(t, "GET", "/user2"), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+ assert.EqualValues(t, buttons, page.Find("overflow-menu .item[href='/user2?tab=activity']").Length() > 0)
+}
diff --git a/tests/integration/user_profile_follows_test.go b/tests/integration/user_profile_follows_test.go
new file mode 100644
index 0000000..bad0944
--- /dev/null
+++ b/tests/integration/user_profile_follows_test.go
@@ -0,0 +1,132 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+// TestUserProfileFollows is a test for user following counters, pages and titles.
+// It tests that:
+// - Followers and Following tabs always have titles present and always use correct plurals
+// - Followers and Following lists have correct amounts of items
+// - %d followers and %following counters are always present and always have correct numbers and use correct plurals
+func TestUserProfileFollows(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ // This test needs 3 users to check for all possible states
+ // The accounts of user3 and user4 are not functioning
+ user1 := loginUser(t, "user1")
+ user2 := loginUser(t, "user2")
+ user5 := loginUser(t, "user5")
+
+ followersLink := "#profile-avatar-card a[href='/user1?tab=followers']"
+ followingLink := "#profile-avatar-card a[href='/user1?tab=following']"
+ listHeader := ".user-cards h2"
+ listItems := ".user-cards .list"
+
+ // = No follows =
+
+ var followCount int
+
+ // Request the profile of user1, the Followers tab
+ response := user1.MakeRequest(t, NewRequest(t, "GET", "/user1?tab=followers"), http.StatusOK)
+ page := NewHTMLParser(t, response.Body)
+
+ // Verify that user1 has no followers
+ testSelectorEquals(t, page, followersLink, "0 followers")
+ testSelectorEquals(t, page, listHeader, "Followers")
+ testListCount(t, page, listItems, followCount)
+
+ // Request the profile of user1, the Following tab
+ response = user1.MakeRequest(t, NewRequest(t, "GET", "/user1?tab=following"), http.StatusOK)
+ page = NewHTMLParser(t, response.Body)
+
+ // Verify that user1 does not follow anyone
+ testSelectorEquals(t, page, followingLink, "0 following")
+ testSelectorEquals(t, page, listHeader, "Following")
+ testListCount(t, page, listItems, followCount)
+
+ // Make user1 and user2 follow each other
+ testUserFollowUser(t, user1, "user2")
+ testUserFollowUser(t, user2, "user1")
+
+ // = 1 follow each =
+
+ followCount++
+
+ // Request the profile of user1, the Followers tab
+ response = user1.MakeRequest(t, NewRequest(t, "GET", "/user1?tab=followers"), http.StatusOK)
+ page = NewHTMLParser(t, response.Body)
+
+ // Verify it is now followed by 1 user
+ testSelectorEquals(t, page, followersLink, "1 follower")
+ testSelectorEquals(t, page, listHeader, "Follower")
+ testListCount(t, page, listItems, followCount)
+
+ // Request the profile of user1, the Following tab
+ response = user1.MakeRequest(t, NewRequest(t, "GET", "/user1?tab=following"), http.StatusOK)
+ page = NewHTMLParser(t, response.Body)
+
+ // Verify it now follows follows 1 user
+ testSelectorEquals(t, page, followingLink, "1 following")
+ testSelectorEquals(t, page, listHeader, "Following")
+ testListCount(t, page, listItems, followCount)
+
+ // Make user1 and user3 follow each other
+ testUserFollowUser(t, user1, "user5")
+ testUserFollowUser(t, user5, "user1")
+
+ // = 2 follows =
+
+ followCount++
+
+ // Request the profile of user1, the Followers tab
+ response = user1.MakeRequest(t, NewRequest(t, "GET", "/user1?tab=followers"), http.StatusOK)
+ page = NewHTMLParser(t, response.Body)
+
+ // Verify it is now followed by 2 users
+ testSelectorEquals(t, page, followersLink, "2 followers")
+ testSelectorEquals(t, page, listHeader, "Followers")
+ testListCount(t, page, listItems, followCount)
+
+ // Request the profile of user1, the Following tab
+ response = user1.MakeRequest(t, NewRequest(t, "GET", "/user1?tab=following"), http.StatusOK)
+ page = NewHTMLParser(t, response.Body)
+
+ // Verify it now follows follows 2 users
+ testSelectorEquals(t, page, followingLink, "2 following")
+ testSelectorEquals(t, page, listHeader, "Following")
+ testListCount(t, page, listItems, followCount)
+ })
+}
+
+// testUserFollowUser simply follows a user `following` by session of user `follower`
+func testUserFollowUser(t *testing.T, follower *TestSession, following string) {
+ t.Helper()
+ follower.MakeRequest(t, NewRequestWithValues(t, "POST", fmt.Sprintf("/%s?action=follow", following),
+ map[string]string{
+ "_csrf": GetCSRF(t, follower, fmt.Sprintf("/%s", following)),
+ }), http.StatusOK)
+}
+
+// testSelectorEquals prevents duplication of a lot of code for tests with many checks
+func testSelectorEquals(t *testing.T, page *HTMLDoc, selector, expectedContent string) {
+ t.Helper()
+ element := page.Find(selector)
+ content := strings.TrimSpace(element.Text())
+ assert.Equal(t, expectedContent, content)
+}
+
+// testListCount checks that the list on the page has the right amount of items
+func testListCount(t *testing.T, page *HTMLDoc, selector string, expectedCount int) {
+ t.Helper()
+ itemCount := page.Find(selector).Children().Length()
+ assert.Equal(t, expectedCount, itemCount)
+}
diff --git a/tests/integration/user_profile_test.go b/tests/integration/user_profile_test.go
new file mode 100644
index 0000000..5532403
--- /dev/null
+++ b/tests/integration/user_profile_test.go
@@ -0,0 +1,67 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestUserProfile(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ checkReadme := func(t *testing.T, title, readmeFilename string, expectedCount int) {
+ t.Run(title, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Prepare the test repository
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ var ops []*files_service.ChangeRepoFile
+ op := "create"
+ if readmeFilename != "README.md" {
+ ops = append(ops, &files_service.ChangeRepoFile{
+ Operation: "delete",
+ TreePath: "README.md",
+ })
+ } else {
+ op = "update"
+ }
+ if readmeFilename != "" {
+ ops = append(ops, &files_service.ChangeRepoFile{
+ Operation: op,
+ TreePath: readmeFilename,
+ ContentReader: strings.NewReader("# Hi!\n"),
+ })
+ }
+
+ _, _, f := tests.CreateDeclarativeRepo(t, user2, ".profile", nil, nil, ops)
+ defer f()
+
+ // Perform the test
+ req := NewRequest(t, "GET", "/user2")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ readmeCount := doc.Find("#readme_profile").Length()
+
+ assert.Equal(t, expectedCount, readmeCount)
+ })
+ }
+
+ checkReadme(t, "No readme", "", 0)
+ checkReadme(t, "README.md", "README.md", 1)
+ checkReadme(t, "readme.md", "readme.md", 1)
+ checkReadme(t, "ReadMe.mD", "ReadMe.mD", 1)
+ checkReadme(t, "readme.org does not render", "README.org", 0)
+ })
+}
diff --git a/tests/integration/user_test.go b/tests/integration/user_test.go
new file mode 100644
index 0000000..73976b9
--- /dev/null
+++ b/tests/integration/user_test.go
@@ -0,0 +1,821 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ issues_model "code.gitea.io/gitea/models/issues"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ api "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/modules/translation"
+ gitea_context "code.gitea.io/gitea/services/context"
+ "code.gitea.io/gitea/services/mailer"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/pquerna/otp/totp"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestViewUser(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ req := NewRequest(t, "GET", "/user2")
+ MakeRequest(t, req, http.StatusOK)
+}
+
+func TestRenameUsername(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+ req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings"),
+ "name": "newUsername",
+ "email": "user2@example.com",
+ "language": "en-US",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "newUsername"})
+ unittest.AssertNotExistsBean(t, &user_model.User{Name: "user2"})
+}
+
+func TestRenameInvalidUsername(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ invalidUsernames := []string{
+ "%2f*",
+ "%2f.",
+ "%2f..",
+ "%00",
+ "thisHas ASpace",
+ "p<A>tho>lo<gical",
+ ".",
+ "..",
+ ".well-known",
+ ".abc",
+ "abc.",
+ "a..bc",
+ "a...bc",
+ "a.-bc",
+ "a._bc",
+ "a_-bc",
+ "a/bc",
+ "â˜ï¸",
+ "-",
+ "--diff",
+ "-im-here",
+ "a space",
+ }
+
+ session := loginUser(t, "user2")
+ for _, invalidUsername := range invalidUsernames {
+ t.Logf("Testing username %s", invalidUsername)
+
+ req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings"),
+ "name": invalidUsername,
+ "email": "user2@example.com",
+ })
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Contains(t,
+ htmlDoc.doc.Find(".ui.negative.message").Text(),
+ translation.NewLocale("en-US").TrString("form.username_error"),
+ )
+
+ unittest.AssertNotExistsBean(t, &user_model.User{Name: invalidUsername})
+ }
+}
+
+func TestRenameReservedUsername(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ reservedUsernames := []string{
+ // ".", "..", ".well-known", // The names are not only reserved but also invalid
+ "admin",
+ "api",
+ "assets",
+ "attachments",
+ "avatar",
+ "avatars",
+ "captcha",
+ "commits",
+ "debug",
+ "devtest",
+ "error",
+ "explore",
+ "favicon.ico",
+ "ghost",
+ "issues",
+ "login",
+ "manifest.json",
+ "metrics",
+ "milestones",
+ "new",
+ "notifications",
+ "org",
+ "pulls",
+ "raw",
+ "repo",
+ "repo-avatars",
+ "robots.txt",
+ "search",
+ "serviceworker.js",
+ "ssh_info",
+ "swagger.v1.json",
+ "user",
+ "v2",
+ }
+
+ session := loginUser(t, "user2")
+ for _, reservedUsername := range reservedUsernames {
+ t.Logf("Testing username %s", reservedUsername)
+ req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings"),
+ "name": reservedUsername,
+ "email": "user2@example.com",
+ "language": "en-US",
+ })
+ resp := session.MakeRequest(t, req, http.StatusSeeOther)
+
+ req = NewRequest(t, "GET", test.RedirectURL(resp))
+ resp = session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Contains(t,
+ htmlDoc.doc.Find(".ui.negative.message").Text(),
+ translation.NewLocale("en-US").TrString("user.form.name_reserved", reservedUsername),
+ )
+
+ unittest.AssertNotExistsBean(t, &user_model.User{Name: reservedUsername})
+ }
+}
+
+func TestExportUserGPGKeys(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ // Export empty key list
+ testExportUserGPGKeys(t, "user1", `-----BEGIN PGP PUBLIC KEY BLOCK-----
+Note: This user hasn't uploaded any GPG keys.
+
+
+=twTO
+-----END PGP PUBLIC KEY BLOCK-----`)
+ // Import key
+ // User1 <user1@example.com>
+ session := loginUser(t, "user1")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+ testCreateGPGKey(t, session.MakeRequest, token, http.StatusCreated, `-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQENBFyy/VUBCADJ7zbM20Z1RWmFoVgp5WkQfI2rU1Vj9cQHes9i42wVLLtcbPeo
+QzubgzvMPITDy7nfWxgSf83E23DoHQ1ACFbQh/6eFSRrjsusp3YQ/08NSfPPbcu8
+0M5G+VGwSfzS5uEcwBVQmHyKdcOZIERTNMtYZx1C3bjLD1XVJHvWz9D72Uq4qeO3
+8SR+lzp5n6ppUakcmRnxt3nGRBj1+hEGkdgzyPo93iy+WioegY2lwCA9xMEo5dah
+BmYxWx51zyiXYlReTaxlyb3/nuSUt8IcW3Q8zjdtJj4Nu8U1SpV8EdaA1I9IPbHW
+510OSLmD3XhqHH5m6mIxL1YoWxk3V7gpDROtABEBAAG0GVVzZXIxIDx1c2VyMUBl
+eGFtcGxlLmNvbT6JAU4EEwEIADgWIQTQEbrYxmXsp1z3j7z9+v0I6RSEHwUCXLL9
+VQIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRD9+v0I6RSEH22YCACFqL5+
+6M0m18AMC/pumcpnnmvAS1GrrKTF8nOROA1augZwp1WCNuKw2R6uOJIHANrYECSn
+u7+j6GBP2gbIW8mSAzS6HWCs7GGiPpVtT4wcu8wljUI6BxjpyZtoEkriyBjt6HfK
+rkegbkuySoJvjq4IcO5D1LB1JWgsUjMYQJj/ZpBIzVtjG9QtFSOiT1Hct4PoZHdC
+nsdSgyCkwRZXG+u3kT/wP9F663ba4o16vYlz3dCGo66lF2tyoG3qcyZ1OUzUrnuv
+96ytAzT6XIhrE0nVoBprMxFF5zExotJD3bHjcGBFNLf944bhjKee3U6t9+OsfJVC
+l7N5xxIawCuTQdbfuQENBFyy/VUBCADe61yGEoTwKfsOKIhxLaNoRmD883O0tiWt
+soO/HPj9dPQLTOiwXgSgSCd8C+LNxGKct87wgFozpah4tDLC6c0nALuHJ0SLbkfz
+55aRhLeOOcrAydatDp72GroXzqpZ0xZBk5wjIWdgEol2GmVRM8QGbeuakU/HVz5y
+lPzxUUocgdbSi3GE3zbzijQzVJdyL/kw/KP7pKT/PPKKJ2C5NQDLy0XGKEHddXGR
+EWKkVlRalxq/TjfaMR0bi3MpezBsQmp99ATPO/d7trayZUxQHRtXzGFiOXfDHATr
+qN730sODjqvU+mpc/SHCRwh9qWDjZRHSuKU5YDBjb5jIQJivZsQ/ABEBAAGJATYE
+GAEIACAWIQTQEbrYxmXsp1z3j7z9+v0I6RSEHwUCXLL9VQIbDAAKCRD9+v0I6RSE
+H7WoB/4tXl+97rQ6owPCGSVp1Xbwt2521V7COgsOFRVTRTryEWxRW8mm0S7wQvax
+C0TLXKur6NVYQMn01iyL+FZzRpEWNuYF3f9QeeLJ/+l2DafESNhNTy17+RPmacK6
+21dccpqchByVw/UMDeHSyjQLiG2lxzt8Gfx2gHmSbrq3aWovTGyz6JTffZvfy/n2
+0Hm437OBPazO0gZyXhdV2PE5RSUfvAgm44235tcV5EV0d32TJDfv61+Vr2GUbah6
+7XhJ1v6JYuh8kaYaEz8OpZDeh7f6Ho6PzJrsy/TKTKhGgZNINj1iaPFyOkQgKR5M
+GrE0MHOxUbc9tbtyk0F1SuzREUBH
+=DDXw
+-----END PGP PUBLIC KEY BLOCK-----`)
+ // Export new key
+ testExportUserGPGKeys(t, "user1", `-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+xsBNBFyy/VUBCADJ7zbM20Z1RWmFoVgp5WkQfI2rU1Vj9cQHes9i42wVLLtcbPeo
+QzubgzvMPITDy7nfWxgSf83E23DoHQ1ACFbQh/6eFSRrjsusp3YQ/08NSfPPbcu8
+0M5G+VGwSfzS5uEcwBVQmHyKdcOZIERTNMtYZx1C3bjLD1XVJHvWz9D72Uq4qeO3
+8SR+lzp5n6ppUakcmRnxt3nGRBj1+hEGkdgzyPo93iy+WioegY2lwCA9xMEo5dah
+BmYxWx51zyiXYlReTaxlyb3/nuSUt8IcW3Q8zjdtJj4Nu8U1SpV8EdaA1I9IPbHW
+510OSLmD3XhqHH5m6mIxL1YoWxk3V7gpDROtABEBAAHNGVVzZXIxIDx1c2VyMUBl
+eGFtcGxlLmNvbT7CwI4EEwEIADgWIQTQEbrYxmXsp1z3j7z9+v0I6RSEHwUCXLL9
+VQIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRD9+v0I6RSEH22YCACFqL5+
+6M0m18AMC/pumcpnnmvAS1GrrKTF8nOROA1augZwp1WCNuKw2R6uOJIHANrYECSn
+u7+j6GBP2gbIW8mSAzS6HWCs7GGiPpVtT4wcu8wljUI6BxjpyZtoEkriyBjt6HfK
+rkegbkuySoJvjq4IcO5D1LB1JWgsUjMYQJj/ZpBIzVtjG9QtFSOiT1Hct4PoZHdC
+nsdSgyCkwRZXG+u3kT/wP9F663ba4o16vYlz3dCGo66lF2tyoG3qcyZ1OUzUrnuv
+96ytAzT6XIhrE0nVoBprMxFF5zExotJD3bHjcGBFNLf944bhjKee3U6t9+OsfJVC
+l7N5xxIawCuTQdbfzsBNBFyy/VUBCADe61yGEoTwKfsOKIhxLaNoRmD883O0tiWt
+soO/HPj9dPQLTOiwXgSgSCd8C+LNxGKct87wgFozpah4tDLC6c0nALuHJ0SLbkfz
+55aRhLeOOcrAydatDp72GroXzqpZ0xZBk5wjIWdgEol2GmVRM8QGbeuakU/HVz5y
+lPzxUUocgdbSi3GE3zbzijQzVJdyL/kw/KP7pKT/PPKKJ2C5NQDLy0XGKEHddXGR
+EWKkVlRalxq/TjfaMR0bi3MpezBsQmp99ATPO/d7trayZUxQHRtXzGFiOXfDHATr
+qN730sODjqvU+mpc/SHCRwh9qWDjZRHSuKU5YDBjb5jIQJivZsQ/ABEBAAHCwHYE
+GAEIACAWIQTQEbrYxmXsp1z3j7z9+v0I6RSEHwUCXLL9VQIbDAAKCRD9+v0I6RSE
+H7WoB/4tXl+97rQ6owPCGSVp1Xbwt2521V7COgsOFRVTRTryEWxRW8mm0S7wQvax
+C0TLXKur6NVYQMn01iyL+FZzRpEWNuYF3f9QeeLJ/+l2DafESNhNTy17+RPmacK6
+21dccpqchByVw/UMDeHSyjQLiG2lxzt8Gfx2gHmSbrq3aWovTGyz6JTffZvfy/n2
+0Hm437OBPazO0gZyXhdV2PE5RSUfvAgm44235tcV5EV0d32TJDfv61+Vr2GUbah6
+7XhJ1v6JYuh8kaYaEz8OpZDeh7f6Ho6PzJrsy/TKTKhGgZNINj1iaPFyOkQgKR5M
+GrE0MHOxUbc9tbtyk0F1SuzREUBH
+=WFf5
+-----END PGP PUBLIC KEY BLOCK-----`)
+}
+
+func testExportUserGPGKeys(t *testing.T, user, expected string) {
+ session := loginUser(t, user)
+ t.Logf("Testing username %s export gpg keys", user)
+ req := NewRequest(t, "GET", "/"+user+".gpg")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ // t.Log(resp.Body.String())
+ assert.Equal(t, expected, resp.Body.String())
+}
+
+func TestGetUserRss(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("Normal", func(t *testing.T) {
+ user34 := "the_34-user.with.all.allowedChars"
+ req := NewRequestf(t, "GET", "/%s.rss", user34)
+ resp := MakeRequest(t, req, http.StatusOK)
+ if assert.EqualValues(t, "application/rss+xml;charset=utf-8", resp.Header().Get("Content-Type")) {
+ rssDoc := NewHTMLParser(t, resp.Body).Find("channel")
+ title, _ := rssDoc.ChildrenFiltered("title").Html()
+ assert.EqualValues(t, "Feed of &#34;the_1-user.with.all.allowedChars&#34;", title)
+ description, _ := rssDoc.ChildrenFiltered("description").Html()
+ assert.EqualValues(t, "&lt;p dir=&#34;auto&#34;&gt;some &lt;a href=&#34;https://commonmark.org/&#34; rel=&#34;nofollow&#34;&gt;commonmark&lt;/a&gt;!&lt;/p&gt;\n", description)
+ }
+ })
+ t.Run("Non-existent user", func(t *testing.T) {
+ session := loginUser(t, "user2")
+ req := NewRequestf(t, "GET", "/non-existent-user.rss")
+ session.MakeRequest(t, req, http.StatusNotFound)
+ })
+}
+
+func TestListStopWatches(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
+
+ session := loginUser(t, owner.Name)
+ req := NewRequest(t, "GET", "/user/stopwatches")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ var apiWatches []*api.StopWatch
+ DecodeJSON(t, resp, &apiWatches)
+ stopwatch := unittest.AssertExistsAndLoadBean(t, &issues_model.Stopwatch{UserID: owner.ID})
+ issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: stopwatch.IssueID})
+ if assert.Len(t, apiWatches, 1) {
+ assert.EqualValues(t, stopwatch.CreatedUnix.AsTime().Unix(), apiWatches[0].Created.Unix())
+ assert.EqualValues(t, issue.Index, apiWatches[0].IssueIndex)
+ assert.EqualValues(t, issue.Title, apiWatches[0].IssueTitle)
+ assert.EqualValues(t, repo.Name, apiWatches[0].RepoName)
+ assert.EqualValues(t, repo.OwnerName, apiWatches[0].RepoOwnerName)
+ assert.Positive(t, apiWatches[0].Seconds)
+ }
+}
+
+func TestUserLocationMapLink(t *testing.T) {
+ setting.Service.UserLocationMapURL = "https://example/foo/"
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+ req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings"),
+ "name": "user2",
+ "email": "user@example.com",
+ "language": "en-US",
+ "location": "A/b",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ req = NewRequest(t, "GET", "/user2/")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, `a[href="https://example/foo/A%2Fb"]`, true)
+}
+
+func TestUserHints(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+ session := loginUser(t, user.Name)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+
+ // Create a known-good repo, with only one unit enabled
+ repo, _, f := tests.CreateDeclarativeRepo(t, user, "", []unit_model.Type{
+ unit_model.TypeCode,
+ }, []unit_model.Type{
+ unit_model.TypePullRequests,
+ unit_model.TypeProjects,
+ unit_model.TypePackages,
+ unit_model.TypeActions,
+ unit_model.TypeIssues,
+ unit_model.TypeWiki,
+ }, nil)
+ defer f()
+
+ ensureRepoUnitHints := func(t *testing.T, hints bool) {
+ t.Helper()
+
+ req := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{
+ EnableRepoUnitHints: &hints,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var userSettings api.UserSettings
+ DecodeJSON(t, resp, &userSettings)
+ assert.Equal(t, hints, userSettings.EnableRepoUnitHints)
+ }
+
+ t.Run("API", func(t *testing.T) {
+ t.Run("setting hints on and off", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ ensureRepoUnitHints(t, true)
+ ensureRepoUnitHints(t, false)
+ })
+
+ t.Run("retrieving settings", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ for _, v := range []bool{true, false} {
+ ensureRepoUnitHints(t, v)
+
+ req := NewRequest(t, "GET", "/api/v1/user/settings").AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var userSettings api.UserSettings
+ DecodeJSON(t, resp, &userSettings)
+ assert.Equal(t, v, userSettings.EnableRepoUnitHints)
+ }
+ })
+ })
+
+ t.Run("user settings", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Set a known-good state, that isn't the default
+ ensureRepoUnitHints(t, false)
+
+ assertHintState := func(t *testing.T, enabled bool) {
+ t.Helper()
+
+ req := NewRequest(t, "GET", "/user/settings/appearance")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ _, hintChecked := htmlDoc.Find(`input[name="enable_repo_unit_hints"]`).Attr("checked")
+ assert.Equal(t, enabled, hintChecked)
+
+ link, _ := htmlDoc.Find("form[action='/user/settings/appearance/language'] a").Attr("href")
+ assert.EqualValues(t, "https://forgejo.org/docs/next/contributor/localization/", link)
+ }
+
+ t.Run("view", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertHintState(t, false)
+ })
+
+ t.Run("change", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequestWithValues(t, "POST", "/user/settings/appearance/hints", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings/appearance"),
+ "enable_repo_unit_hints": "true",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ assertHintState(t, true)
+ })
+ })
+
+ t.Run("repo view", func(t *testing.T) {
+ assertAddMore := func(t *testing.T, present bool) {
+ t.Helper()
+
+ req := NewRequest(t, "GET", repo.Link())
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, fmt.Sprintf("a[href='%s/settings/units']", repo.Link()), present)
+ }
+
+ t.Run("hints enabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ ensureRepoUnitHints(t, true)
+ assertAddMore(t, true)
+ })
+
+ t.Run("hints disabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ ensureRepoUnitHints(t, false)
+ assertAddMore(t, false)
+ })
+ })
+}
+
+func TestUserPronouns(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser)
+
+ adminUser := unittest.AssertExistsAndLoadBean(t, &user_model.User{IsAdmin: true})
+ adminSession := loginUser(t, adminUser.Name)
+ adminToken := getTokenForLoggedInUser(t, adminSession, auth_model.AccessTokenScopeWriteAdmin)
+
+ t.Run("API", func(t *testing.T) {
+ t.Run("user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ // We check the raw JSON, because we want to test the response, not
+ // what it decodes into. Contents doesn't matter, we're testing the
+ // presence only.
+ assert.Contains(t, resp.Body.String(), `"pronouns":`)
+ })
+
+ t.Run("users/{username}", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/api/v1/users/user2")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ // We check the raw JSON, because we want to test the response, not
+ // what it decodes into. Contents doesn't matter, we're testing the
+ // presence only.
+ assert.Contains(t, resp.Body.String(), `"pronouns":`)
+ })
+
+ t.Run("user/settings", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Set pronouns first
+ pronouns := "they/them"
+ req := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{
+ Pronouns: &pronouns,
+ }).AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ // Verify the response
+ var user *api.UserSettings
+ DecodeJSON(t, resp, &user)
+ assert.Equal(t, pronouns, user.Pronouns)
+
+ // Verify retrieving the settings again
+ req = NewRequest(t, "GET", "/api/v1/user/settings").AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+
+ DecodeJSON(t, resp, &user)
+ assert.Equal(t, pronouns, user.Pronouns)
+ })
+
+ t.Run("admin/users/{username}", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Set the pronouns for user2
+ pronouns := "she/her"
+ req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/user2", &api.EditUserOption{
+ Pronouns: &pronouns,
+ }).AddTokenAuth(adminToken)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ // Verify the API response
+ var user *api.User
+ DecodeJSON(t, resp, &user)
+ assert.Equal(t, pronouns, user.Pronouns)
+
+ // Verify via user2 too
+ req = NewRequest(t, "GET", "/api/v1/user").AddTokenAuth(token)
+ resp = MakeRequest(t, req, http.StatusOK)
+ DecodeJSON(t, resp, &user)
+ assert.Equal(t, pronouns, user.Pronouns)
+ })
+ })
+
+ t.Run("UI", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Set the pronouns to a known state via the API
+ pronouns := "she/her"
+ req := NewRequestWithJSON(t, "PATCH", "/api/v1/user/settings", &api.UserSettingsOptions{
+ Pronouns: &pronouns,
+ }).AddTokenAuth(token)
+ MakeRequest(t, req, http.StatusOK)
+
+ t.Run("profile view", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user2")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ userNameAndPronouns := strings.TrimSpace(htmlDoc.Find(".profile-avatar-name .username").Text())
+ assert.Contains(t, userNameAndPronouns, pronouns)
+ })
+
+ t.Run("settings", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", "/user/settings")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ // Check that the field is present
+ pronounField, has := htmlDoc.Find(`input[name="pronouns"]`).Attr("value")
+ assert.True(t, has)
+ assert.Equal(t, pronouns, pronounField)
+
+ // Check that updating the field works
+ newPronouns := "they/them"
+ req = NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings"),
+ "pronouns": newPronouns,
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+ assert.Equal(t, newPronouns, user2.Pronouns)
+ })
+
+ t.Run("admin settings", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+
+ req := NewRequestf(t, "GET", "/admin/users/%d/edit", user2.ID)
+ resp := adminSession.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ // Check that the pronouns field is present
+ pronounField, has := htmlDoc.Find(`input[name="pronouns"]`).Attr("value")
+ assert.True(t, has)
+ assert.NotEmpty(t, pronounField)
+
+ // Check that updating the field works
+ newPronouns := "it/its"
+ editURI := fmt.Sprintf("/admin/users/%d/edit", user2.ID)
+ req = NewRequestWithValues(t, "POST", editURI, map[string]string{
+ "_csrf": GetCSRF(t, adminSession, editURI),
+ "login_type": "0-0",
+ "login_name": user2.LoginName,
+ "email": user2.Email,
+ "pronouns": newPronouns,
+ })
+ adminSession.MakeRequest(t, req, http.StatusSeeOther)
+
+ user2New := unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: "user2"})
+ assert.Equal(t, newPronouns, user2New.Pronouns)
+ })
+ })
+
+ t.Run("unspecified", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Set the pronouns to Unspecified (an empty string) via the API
+ pronouns := ""
+ req := NewRequestWithJSON(t, "PATCH", "/api/v1/admin/users/user2", &api.EditUserOption{
+ Pronouns: &pronouns,
+ }).AddTokenAuth(adminToken)
+ MakeRequest(t, req, http.StatusOK)
+
+ // Verify that the profile page does not display any pronouns, nor the separator
+ req = NewRequest(t, "GET", "/user2")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ userName := strings.TrimSpace(htmlDoc.Find(".profile-avatar-name .username").Text())
+ assert.EqualValues(t, "user2", userName)
+ })
+}
+
+func TestUserTOTPMail(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user.Name)
+
+ t.Run("No security keys", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ called := false
+ defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
+ assert.Len(t, msgs, 1)
+ assert.Equal(t, user.EmailTo(), msgs[0].To)
+ assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.totp_disabled.subject"), msgs[0].Subject)
+ assert.Contains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.totp_disabled.no_2fa"))
+ called = true
+ })()
+
+ unittest.AssertSuccessfulInsert(t, &auth_model.TwoFactor{UID: user.ID})
+ req := NewRequestWithValues(t, "POST", "/user/settings/security/two_factor/disable", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings/security"),
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ assert.True(t, called)
+ unittest.AssertExistsIf(t, false, &auth_model.TwoFactor{UID: user.ID})
+ })
+
+ t.Run("with security keys", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ called := false
+ defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
+ assert.Len(t, msgs, 1)
+ assert.Equal(t, user.EmailTo(), msgs[0].To)
+ assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.totp_disabled.subject"), msgs[0].Subject)
+ assert.NotContains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.totp_disabled.no_2fa"))
+ called = true
+ })()
+
+ unittest.AssertSuccessfulInsert(t, &auth_model.TwoFactor{UID: user.ID})
+ unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID})
+ req := NewRequestWithValues(t, "POST", "/user/settings/security/two_factor/disable", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings/security"),
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ assert.True(t, called)
+ unittest.AssertExistsIf(t, false, &auth_model.TwoFactor{UID: user.ID})
+ })
+}
+
+func TestUserSecurityKeyMail(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user.Name)
+
+ t.Run("Normal", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ called := false
+ defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
+ assert.Len(t, msgs, 1)
+ assert.Equal(t, user.EmailTo(), msgs[0].To)
+ assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.removed_security_key.subject"), msgs[0].Subject)
+ assert.Contains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.removed_security_key.no_2fa"))
+ assert.Contains(t, msgs[0].Body, "Little Bobby Tables&#39;s primary key")
+ called = true
+ })()
+
+ unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"})
+ id := unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{UserID: user.ID}).ID
+ req := NewRequestWithValues(t, "POST", "/user/settings/security/webauthn/delete", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings/security"),
+ "id": strconv.FormatInt(id, 10),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ assert.True(t, called)
+ unittest.AssertExistsIf(t, false, &auth_model.WebAuthnCredential{UserID: user.ID})
+ })
+
+ t.Run("With TOTP", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ called := false
+ defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
+ assert.Len(t, msgs, 1)
+ assert.Equal(t, user.EmailTo(), msgs[0].To)
+ assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.removed_security_key.subject"), msgs[0].Subject)
+ assert.NotContains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.removed_security_key.no_2fa"))
+ assert.Contains(t, msgs[0].Body, "Little Bobby Tables&#39;s primary key")
+ called = true
+ })()
+
+ unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"})
+ id := unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{UserID: user.ID}).ID
+ unittest.AssertSuccessfulInsert(t, &auth_model.TwoFactor{UID: user.ID})
+ req := NewRequestWithValues(t, "POST", "/user/settings/security/webauthn/delete", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings/security"),
+ "id": strconv.FormatInt(id, 10),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ assert.True(t, called)
+ unittest.AssertExistsIf(t, false, &auth_model.WebAuthnCredential{UserID: user.ID})
+ })
+
+ t.Run("Two security keys", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ called := false
+ defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
+ assert.Len(t, msgs, 1)
+ assert.Equal(t, user.EmailTo(), msgs[0].To)
+ assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.removed_security_key.subject"), msgs[0].Subject)
+ assert.NotContains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.removed_security_key.no_2fa"))
+ assert.Contains(t, msgs[0].Body, "Little Bobby Tables&#39;s primary key")
+ called = true
+ })()
+
+ unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"})
+ id := unittest.AssertExistsAndLoadBean(t, &auth_model.WebAuthnCredential{UserID: user.ID}).ID
+ unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's evil key"})
+ req := NewRequestWithValues(t, "POST", "/user/settings/security/webauthn/delete", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings/security"),
+ "id": strconv.FormatInt(id, 10),
+ })
+ session.MakeRequest(t, req, http.StatusOK)
+
+ assert.True(t, called)
+ unittest.AssertExistsIf(t, false, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's primary key"})
+ unittest.AssertExistsIf(t, true, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Little Bobby Tables's evil key"})
+ })
+}
+
+func TestUserTOTPEnrolled(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user.Name)
+
+ enrollTOTP := func(t *testing.T) {
+ t.Helper()
+
+ req := NewRequest(t, "GET", "/user/settings/security/two_factor/enroll")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ totpSecretKey, has := htmlDoc.Find(".twofa img[src^='data:image/png;base64']").Attr("alt")
+ assert.True(t, has)
+
+ currentTOTP, err := totp.GenerateCode(totpSecretKey, time.Now())
+ require.NoError(t, err)
+
+ req = NewRequestWithValues(t, "POST", "/user/settings/security/two_factor/enroll", map[string]string{
+ "_csrf": htmlDoc.GetCSRF(),
+ "passcode": currentTOTP,
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ flashCookie := session.GetCookie(gitea_context.CookieNameFlash)
+ assert.NotNil(t, flashCookie)
+ assert.Contains(t, flashCookie.Value, "success%3DYour%2Baccount%2Bhas%2Bbeen%2Bsuccessfully%2Benrolled.")
+
+ unittest.AssertSuccessfulDelete(t, &auth_model.TwoFactor{UID: user.ID})
+ }
+
+ t.Run("No WebAuthn enabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ called := false
+ defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
+ assert.Len(t, msgs, 1)
+ assert.Equal(t, user.EmailTo(), msgs[0].To)
+ assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.totp_enrolled.subject"), msgs[0].Subject)
+ assert.Contains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.totp_enrolled.text_1.no_webauthn"))
+ called = true
+ })()
+
+ enrollTOTP(t)
+
+ assert.True(t, called)
+ })
+
+ t.Run("With WebAuthn enabled", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ called := false
+ defer test.MockVariableValue(&mailer.SendAsync, func(msgs ...*mailer.Message) {
+ assert.Len(t, msgs, 1)
+ assert.Equal(t, user.EmailTo(), msgs[0].To)
+ assert.EqualValues(t, translation.NewLocale("en-US").Tr("mail.totp_enrolled.subject"), msgs[0].Subject)
+ assert.Contains(t, msgs[0].Body, translation.NewLocale("en-US").Tr("mail.totp_enrolled.text_1.has_webauthn"))
+ called = true
+ })()
+
+ unittest.AssertSuccessfulInsert(t, &auth_model.WebAuthnCredential{UserID: user.ID, Name: "Cueball's primary key"})
+ enrollTOTP(t)
+
+ assert.True(t, called)
+ })
+}
diff --git a/tests/integration/version_test.go b/tests/integration/version_test.go
new file mode 100644
index 0000000..144471a
--- /dev/null
+++ b/tests/integration/version_test.go
@@ -0,0 +1,62 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ auth_model "code.gitea.io/gitea/models/auth"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/structs"
+ "code.gitea.io/gitea/modules/test"
+ "code.gitea.io/gitea/routers"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestVersion(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ t.Run("Version", func(t *testing.T) {
+ setting.AppVer = "test-version-1"
+ req := NewRequest(t, "GET", "/api/v1/version")
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var version structs.ServerVersion
+ DecodeJSON(t, resp, &version)
+ assert.Equal(t, setting.AppVer, version.Version)
+ })
+
+ t.Run("Versions with REQUIRE_SIGNIN_VIEW enabled", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.Service.RequireSignInView, true)()
+ defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
+
+ setting.AppVer = "test-version-1"
+
+ t.Run("Get version without auth", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // GET api without auth
+ req := NewRequest(t, "GET", "/api/v1/version")
+ MakeRequest(t, req, http.StatusForbidden)
+ })
+
+ t.Run("Get version without auth", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ username := "user1"
+ session := loginUser(t, username)
+ token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository)
+
+ // GET api with auth
+ req := NewRequest(t, "GET", "/api/v1/version").AddTokenAuth(token)
+ resp := MakeRequest(t, req, http.StatusOK)
+
+ var version structs.ServerVersion
+ DecodeJSON(t, resp, &version)
+ assert.Equal(t, setting.AppVer, version.Version)
+ })
+ })
+}
diff --git a/tests/integration/view_test.go b/tests/integration/view_test.go
new file mode 100644
index 0000000..ff2f2bd
--- /dev/null
+++ b/tests/integration/view_test.go
@@ -0,0 +1,212 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/test"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRenderFileSVGIsInImgTag(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ session := loginUser(t, "user2")
+
+ req := NewRequest(t, "GET", "/user2/repo2/src/branch/master/line.svg")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ doc := NewHTMLParser(t, resp.Body)
+ src, exists := doc.doc.Find(".file-view img").Attr("src")
+ assert.True(t, exists, "The SVG image should be in an <img> tag so that scripts in the SVG are not run")
+ assert.Equal(t, "/user2/repo2/raw/branch/master/line.svg", src)
+}
+
+func TestAmbiguousCharacterDetection(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user2.Name)
+
+ // Prepare the environments. File view, commit view (diff), wiki page.
+ repo, commitID, f := tests.CreateDeclarativeRepo(t, user2, "",
+ []unit_model.Type{unit_model.TypeCode, unit_model.TypeWiki}, nil,
+ []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "test.sh",
+ ContentReader: strings.NewReader("Hello there!\nline western"),
+ },
+ },
+ )
+ defer f()
+
+ req := NewRequestWithValues(t, "POST", repo.Link()+"/wiki?action=new", map[string]string{
+ "_csrf": GetCSRF(t, session, repo.Link()+"/wiki?action=new"),
+ "title": "Normal",
+ "content": "Hello – Hello",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ assertCase := func(t *testing.T, fileContext, commitContext, wikiContext bool) {
+ t.Helper()
+
+ t.Run("File context", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", repo.Link()+"/src/branch/main/test.sh")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, ".unicode-escape-prompt", fileContext)
+ })
+ t.Run("Commit context", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", repo.Link()+"/commit/"+commitID)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, ".lines-escape .toggle-escape-button", commitContext)
+ })
+ t.Run("Wiki context", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", repo.Link()+"/wiki/Normal")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ htmlDoc.AssertElement(t, ".unicode-escape-prompt", wikiContext)
+ })
+ }
+
+ t.Run("Enabled all context", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.UI.SkipEscapeContexts, []string{})()
+
+ assertCase(t, true, true, true)
+ })
+
+ t.Run("Enabled file context", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.UI.SkipEscapeContexts, []string{"diff", "wiki"})()
+
+ assertCase(t, true, false, false)
+ })
+
+ t.Run("Enabled commit context", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.UI.SkipEscapeContexts, []string{"file-view", "wiki"})()
+
+ assertCase(t, false, true, false)
+ })
+
+ t.Run("Enabled wiki context", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.UI.SkipEscapeContexts, []string{"file-view", "diff"})()
+
+ assertCase(t, false, false, true)
+ })
+
+ t.Run("No context", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.UI.SkipEscapeContexts, []string{"file-view", "wiki", "diff"})()
+
+ assertCase(t, false, false, false)
+ })
+
+ t.Run("Disabled detection", func(t *testing.T) {
+ defer test.MockVariableValue(&setting.UI.SkipEscapeContexts, []string{})()
+ defer test.MockVariableValue(&setting.UI.AmbiguousUnicodeDetection, false)()
+
+ assertCase(t, false, false, false)
+ })
+ })
+}
+
+func TestInHistoryButton(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ session := loginUser(t, user2.Name)
+ repo, commitID, f := tests.CreateDeclarativeRepo(t, user2, "",
+ []unit_model.Type{unit_model.TypeCode, unit_model.TypeWiki}, nil,
+ []*files_service.ChangeRepoFile{
+ {
+ Operation: "create",
+ TreePath: "test.sh",
+ ContentReader: strings.NewReader("Hello there!"),
+ },
+ },
+ )
+ defer f()
+
+ req := NewRequestWithValues(t, "POST", repo.Link()+"/wiki?action=new", map[string]string{
+ "_csrf": GetCSRF(t, session, repo.Link()+"/wiki?action=new"),
+ "title": "Normal",
+ "content": "Hello world!",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ t.Run("Wiki revision", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", repo.Link()+"/wiki/Normal?action=_revision")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, fmt.Sprintf(".commit-list a[href^='/%s/src/commit/']", repo.FullName()), false)
+ })
+
+ t.Run("Commit list", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", repo.Link()+"/commits/branch/main")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, fmt.Sprintf(".commit-list a[href='/%s/src/commit/%s']", repo.FullName(), commitID), true)
+ })
+
+ t.Run("File history", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, "GET", repo.Link()+"/commits/branch/main/test.sh")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, fmt.Sprintf(".commit-list a[href='/%s/src/commit/%s/test.sh']", repo.FullName(), commitID), true)
+ })
+ })
+}
+
+func TestTitleDisplayName(t *testing.T) {
+ session := emptyTestSession(t)
+ title := GetHTMLTitle(t, session, "/")
+ assert.Equal(t, "Forgejo: Beyond coding. We Forge.", title)
+}
+
+func TestHomeDisplayName(t *testing.T) {
+ session := emptyTestSession(t)
+ req := NewRequest(t, "GET", "/")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.Equal(t, "Forgejo: Beyond coding. We Forge.", strings.TrimSpace(htmlDoc.Find("h1.title").Text()))
+}
+
+func TestOpenGraphDisplayName(t *testing.T) {
+ session := emptyTestSession(t)
+ req := NewRequest(t, "GET", "/")
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ ogTitle, _ := htmlDoc.Find("meta[property='og:title']").Attr("content")
+ assert.Equal(t, "Forgejo: Beyond coding. We Forge.", ogTitle)
+ ogSiteName, _ := htmlDoc.Find("meta[property='og:site_name']").Attr("content")
+ assert.Equal(t, "Forgejo: Beyond coding. We Forge.", ogSiteName)
+}
diff --git a/tests/integration/webfinger_test.go b/tests/integration/webfinger_test.go
new file mode 100644
index 0000000..18f509a
--- /dev/null
+++ b/tests/integration/webfinger_test.go
@@ -0,0 +1,84 @@
+// Copyright 2022 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "testing"
+
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestWebfinger(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ setting.Federation.Enabled = true
+ defer func() {
+ setting.Federation.Enabled = false
+ }()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+
+ appURL, _ := url.Parse(setting.AppURL)
+
+ type webfingerLink struct {
+ Rel string `json:"rel,omitempty"`
+ Type string `json:"type,omitempty"`
+ Href string `json:"href,omitempty"`
+ Titles map[string]string `json:"titles,omitempty"`
+ Properties map[string]any `json:"properties,omitempty"`
+ }
+
+ type webfingerJRD struct {
+ Subject string `json:"subject,omitempty"`
+ Aliases []string `json:"aliases,omitempty"`
+ Properties map[string]any `json:"properties,omitempty"`
+ Links []*webfingerLink `json:"links,omitempty"`
+ }
+
+ session := loginUser(t, "user1")
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", user.LowerName, appURL.Host))
+ resp := MakeRequest(t, req, http.StatusOK)
+ assert.Equal(t, "application/jrd+json", resp.Header().Get("Content-Type"))
+
+ var jrd webfingerJRD
+ DecodeJSON(t, resp, &jrd)
+ assert.Equal(t, "acct:user2@"+appURL.Host, jrd.Subject)
+ assert.ElementsMatch(t, []string{user.HTMLURL(), appURL.String() + "api/v1/activitypub/user-id/" + fmt.Sprint(user.ID)}, jrd.Aliases)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", user.LowerName, "unknown.host"))
+ MakeRequest(t, req, http.StatusBadRequest)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", "user31", appURL.Host))
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=acct:%s@%s", "user31", appURL.Host))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=mailto:%s", user.Email))
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=https://%s/%s/", appURL.Host, user.Name))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=https://%s/%s", appURL.Host, user.Name))
+ session.MakeRequest(t, req, http.StatusOK)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=http://%s/%s/foo", appURL.Host, user.Name))
+ session.MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=http://%s", appURL.Host))
+ MakeRequest(t, req, http.StatusNotFound)
+
+ req = NewRequest(t, "GET", fmt.Sprintf("/.well-known/webfinger?resource=http://%s/%s/foo", "example.com", user.Name))
+ MakeRequest(t, req, http.StatusBadRequest)
+}
diff --git a/tests/integration/webhook_test.go b/tests/integration/webhook_test.go
new file mode 100644
index 0000000..60d4d48
--- /dev/null
+++ b/tests/integration/webhook_test.go
@@ -0,0 +1,189 @@
+// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ "code.gitea.io/gitea/models/db"
+ repo_model "code.gitea.io/gitea/models/repo"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ webhook_model "code.gitea.io/gitea/models/webhook"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/gitrepo"
+ "code.gitea.io/gitea/modules/json"
+ webhook_module "code.gitea.io/gitea/modules/webhook"
+ "code.gitea.io/gitea/services/release"
+ "code.gitea.io/gitea/tests"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWebhookPayloadRef(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
+ w := unittest.AssertExistsAndLoadBean(t, &webhook_model.Webhook{ID: 1})
+ w.HookEvent = &webhook_module.HookEvent{
+ SendEverything: true,
+ }
+ require.NoError(t, w.UpdateEvent())
+ require.NoError(t, webhook_model.UpdateWebhook(db.DefaultContext, w))
+
+ hookTasks := retrieveHookTasks(t, w.ID, true)
+ hookTasksLenBefore := len(hookTasks)
+
+ session := loginUser(t, "user2")
+ // create new branch
+ csrf := GetCSRF(t, session, "user2/repo1")
+ req := NewRequestWithValues(t, "POST", "user2/repo1/branches/_new/branch/master",
+ map[string]string{
+ "_csrf": csrf,
+ "new_branch_name": "arbre",
+ "create_tag": "false",
+ },
+ )
+ session.MakeRequest(t, req, http.StatusSeeOther)
+ // delete the created branch
+ req = NewRequestWithValues(t, "POST", "user2/repo1/branches/delete?name=arbre",
+ map[string]string{
+ "_csrf": csrf,
+ },
+ )
+ session.MakeRequest(t, req, http.StatusOK)
+
+ // check the newly created hooktasks
+ hookTasks = retrieveHookTasks(t, w.ID, false)
+ expected := map[webhook_module.HookEventType]bool{
+ webhook_module.HookEventCreate: true,
+ webhook_module.HookEventPush: true, // the branch creation also creates a push event
+ webhook_module.HookEventDelete: true,
+ }
+ for _, hookTask := range hookTasks[:len(hookTasks)-hookTasksLenBefore] {
+ if !expected[hookTask.EventType] {
+ t.Errorf("unexpected (or duplicated) event %q", hookTask.EventType)
+ }
+
+ var payload struct {
+ Ref string `json:"ref"`
+ }
+ require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payload))
+ assert.Equal(t, "refs/heads/arbre", payload.Ref, "unexpected ref for %q event", hookTask.EventType)
+ delete(expected, hookTask.EventType)
+ }
+ assert.Empty(t, expected)
+ })
+}
+
+func TestWebhookReleaseEvents(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ w := unittest.AssertExistsAndLoadBean(t, &webhook_model.Webhook{
+ ID: 1,
+ RepoID: repo.ID,
+ })
+ w.HookEvent = &webhook_module.HookEvent{
+ SendEverything: true,
+ }
+ require.NoError(t, w.UpdateEvent())
+ require.NoError(t, webhook_model.UpdateWebhook(db.DefaultContext, w))
+
+ hookTasks := retrieveHookTasks(t, w.ID, true)
+
+ gitRepo, err := gitrepo.OpenRepository(git.DefaultContext, repo)
+ require.NoError(t, err)
+ defer gitRepo.Close()
+
+ t.Run("CreateRelease", func(t *testing.T) {
+ require.NoError(t, release.CreateRelease(gitRepo, &repo_model.Release{
+ RepoID: repo.ID,
+ Repo: repo,
+ PublisherID: user.ID,
+ Publisher: user,
+ TagName: "v1.1.1",
+ Target: "master",
+ Title: "v1.1.1 is released",
+ Note: "v1.1.1 is released",
+ IsDraft: false,
+ IsPrerelease: false,
+ IsTag: false,
+ }, "", nil))
+
+ // check the newly created hooktasks
+ hookTasksLenBefore := len(hookTasks)
+ hookTasks = retrieveHookTasks(t, w.ID, false)
+
+ checkHookTasks(t, map[webhook_module.HookEventType]string{
+ webhook_module.HookEventRelease: "published",
+ webhook_module.HookEventCreate: "", // a tag was created as well
+ webhook_module.HookEventPush: "", // the tag creation also means a push event
+ }, hookTasks[:len(hookTasks)-hookTasksLenBefore])
+
+ t.Run("UpdateRelease", func(t *testing.T) {
+ rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{RepoID: repo.ID, TagName: "v1.1.1"})
+ require.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, false, nil))
+
+ // check the newly created hooktasks
+ hookTasksLenBefore := len(hookTasks)
+ hookTasks = retrieveHookTasks(t, w.ID, false)
+
+ checkHookTasks(t, map[webhook_module.HookEventType]string{
+ webhook_module.HookEventRelease: "updated",
+ }, hookTasks[:len(hookTasks)-hookTasksLenBefore])
+ })
+ })
+
+ t.Run("CreateNewTag", func(t *testing.T) {
+ require.NoError(t, release.CreateNewTag(db.DefaultContext,
+ user,
+ repo,
+ "master",
+ "v1.1.2",
+ "v1.1.2 is tagged",
+ ))
+
+ // check the newly created hooktasks
+ hookTasksLenBefore := len(hookTasks)
+ hookTasks = retrieveHookTasks(t, w.ID, false)
+
+ checkHookTasks(t, map[webhook_module.HookEventType]string{
+ webhook_module.HookEventCreate: "", // tag was created as well
+ webhook_module.HookEventPush: "", // the tag creation also means a push event
+ }, hookTasks[:len(hookTasks)-hookTasksLenBefore])
+
+ t.Run("UpdateRelease", func(t *testing.T) {
+ rel := unittest.AssertExistsAndLoadBean(t, &repo_model.Release{RepoID: repo.ID, TagName: "v1.1.2"})
+ require.NoError(t, release.UpdateRelease(db.DefaultContext, user, gitRepo, rel, true, nil))
+
+ // check the newly created hooktasks
+ hookTasksLenBefore := len(hookTasks)
+ hookTasks = retrieveHookTasks(t, w.ID, false)
+
+ checkHookTasks(t, map[webhook_module.HookEventType]string{
+ webhook_module.HookEventRelease: "published",
+ }, hookTasks[:len(hookTasks)-hookTasksLenBefore])
+ })
+ })
+}
+
+func checkHookTasks(t *testing.T, expectedActions map[webhook_module.HookEventType]string, hookTasks []*webhook_model.HookTask) {
+ t.Helper()
+ for _, hookTask := range hookTasks {
+ expectedAction, ok := expectedActions[hookTask.EventType]
+ if !ok {
+ t.Errorf("unexpected (or duplicated) event %q", hookTask.EventType)
+ }
+ var payload struct {
+ Action string `json:"action"`
+ }
+ require.NoError(t, json.Unmarshal([]byte(hookTask.PayloadContent), &payload))
+ assert.Equal(t, expectedAction, payload.Action, "unexpected action for %q event", hookTask.EventType)
+ delete(expectedActions, hookTask.EventType)
+ }
+ assert.Empty(t, expectedActions)
+}
diff --git a/tests/integration/xss_test.go b/tests/integration/xss_test.go
new file mode 100644
index 0000000..70038cf
--- /dev/null
+++ b/tests/integration/xss_test.go
@@ -0,0 +1,129 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package integration
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ issues_model "code.gitea.io/gitea/models/issues"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/tests"
+
+ gogit "github.com/go-git/go-git/v5"
+ "github.com/go-git/go-git/v5/plumbing/object"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestXSSUserFullName(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+ user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
+ const fullName = `name & <script class="evil">alert('Oh no!');</script>`
+
+ session := loginUser(t, user.Name)
+ req := NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
+ "_csrf": GetCSRF(t, session, "/user/settings"),
+ "name": user.Name,
+ "full_name": fullName,
+ "email": user.Email,
+ "language": "en-US",
+ })
+ session.MakeRequest(t, req, http.StatusSeeOther)
+
+ req = NewRequestf(t, "GET", "/%s", user.Name)
+ resp := session.MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+ assert.EqualValues(t, 0, htmlDoc.doc.Find("script.evil").Length())
+ assert.EqualValues(t, fullName,
+ htmlDoc.doc.Find("div.content").Find(".header.text.center").Text(),
+ )
+}
+
+func TestXSSWikiLastCommitInfo(t *testing.T) {
+ onGiteaRun(t, func(t *testing.T, u *url.URL) {
+ // Prepare the environment.
+ dstPath := t.TempDir()
+ r := fmt.Sprintf("%suser2/repo1.wiki.git", u.String())
+ u, err := url.Parse(r)
+ require.NoError(t, err)
+ u.User = url.UserPassword("user2", userPassword)
+ require.NoError(t, git.CloneWithArgs(context.Background(), git.AllowLFSFiltersArgs(), u.String(), dstPath, git.CloneRepoOptions{}))
+
+ // Use go-git here, because using git wouldn't work, it has code to remove
+ // `<`, `>` and `\n` in user names. Even though this is permitted and
+ // wouldn't result in a error by a Git server.
+ gitRepo, err := gogit.PlainOpen(dstPath)
+ require.NoError(t, err)
+
+ w, err := gitRepo.Worktree()
+ require.NoError(t, err)
+
+ filename := filepath.Join(dstPath, "Home.md")
+ err = os.WriteFile(filename, []byte("Oh, a XSS attack?"), 0o644)
+ require.NoError(t, err)
+
+ _, err = w.Add("Home.md")
+ require.NoError(t, err)
+
+ _, err = w.Commit("Yay XSS", &gogit.CommitOptions{
+ Author: &object.Signature{
+ Name: `Gusted<script class="evil">alert('Oh no!');</script>`,
+ Email: "valid@example.org",
+ When: time.Date(2024, time.January, 31, 0, 0, 0, 0, time.UTC),
+ },
+ })
+ require.NoError(t, err)
+
+ // Push.
+ _, _, err = git.NewCommand(git.DefaultContext, "push").AddArguments(git.ToTrustedCmdArgs([]string{"origin", "master"})...).RunStdString(&git.RunOpts{Dir: dstPath})
+ require.NoError(t, err)
+
+ // Check on page view.
+ t.Run("Page view", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, http.MethodGet, "/user2/repo1/wiki/Home")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, "script.evil", false)
+ assert.Contains(t, htmlDoc.Find(".ui.sub.header").Text(), `Gusted<script class="evil">alert('Oh no!');</script> edited this page 2024-01-31`)
+ })
+
+ // Check on revisions page.
+ t.Run("Revision page", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ req := NewRequest(t, http.MethodGet, "/user2/repo1/wiki/Home?action=_revision")
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, "script.evil", false)
+ assert.Contains(t, htmlDoc.Find(".ui.sub.header").Text(), `Gusted<script class="evil">alert('Oh no!');</script> edited this page 2024-01-31`)
+ })
+ })
+}
+
+func TestXSSReviewDismissed(t *testing.T) {
+ defer tests.AddFixtures("tests/integration/fixtures/TestXSSReviewDismissed/")()
+ defer tests.PrepareTestEnv(t)()
+
+ review := unittest.AssertExistsAndLoadBean(t, &issues_model.Review{ID: 1000})
+
+ req := NewRequest(t, http.MethodGet, fmt.Sprintf("/user2/repo1/pulls/%d", +review.IssueID))
+ resp := MakeRequest(t, req, http.StatusOK)
+ htmlDoc := NewHTMLParser(t, resp.Body)
+
+ htmlDoc.AssertElement(t, "script.evil", false)
+ assert.Contains(t, htmlDoc.Find("#issuecomment-1000 .dismissed-message").Text(), `dismissed Otto <script class='evil'>alert('Oh no!')</script>'s review`)
+}
diff --git a/tests/mysql.ini.tmpl b/tests/mysql.ini.tmpl
new file mode 100644
index 0000000..f24590a
--- /dev/null
+++ b/tests/mysql.ini.tmpl
@@ -0,0 +1,115 @@
+APP_NAME = Forgejo
+APP_SLOGAN = Beyond coding. We Forge.
+RUN_MODE = prod
+
+[database]
+DB_TYPE = mysql
+HOST = {{TEST_MYSQL_HOST}}
+NAME = {{TEST_MYSQL_DBNAME}}
+USER = {{TEST_MYSQL_USERNAME}}
+PASSWD = {{TEST_MYSQL_PASSWORD}}
+SSL_MODE = disable
+
+[indexer]
+REPO_INDEXER_ENABLED = true
+REPO_INDEXER_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mysql/indexers/repos.bleve
+
+[queue.issue_indexer]
+TYPE = level
+DATADIR = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mysql/indexers/issues.queue
+
+[queue]
+TYPE = immediate
+
+[repository]
+ROOT = {{REPO_TEST_DIR}}tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mysql/gitea-repositories
+
+[repository.local]
+LOCAL_COPY_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mysql/tmp/local-repo
+
+[repository.upload]
+TEMP_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mysql/tmp/uploads
+
+[repository.signing]
+SIGNING_KEY = none
+
+[server]
+SSH_DOMAIN = localhost
+HTTP_PORT = 3001
+ROOT_URL = http://localhost:3001/
+DISABLE_SSH = false
+SSH_LISTEN_HOST = localhost
+SSH_PORT = 2201
+APP_DATA_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mysql/data
+BUILTIN_SSH_SERVER_USER = git
+START_SSH_SERVER = true
+OFFLINE_MODE = false
+
+LFS_START_SERVER = true
+LFS_JWT_SECRET = Tv_MjmZuHqpIY6GFl12ebgkRAMt4RlWt0v4EHKSXO0w
+SSH_TRUSTED_USER_CA_KEYS = ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCb4DC1dMFnJ6pXWo7GMxTchtzmJHYzfN6sZ9FAPFR4ijMLfGki+olvOMO5Fql1/yGnGfbELQa1S6y4shSvj/5K+zUFScmEXYf3Gcr87RqilLkyk16RS+cHNB1u87xTHbETaa3nyCJeGQRpd4IQ4NKob745mwDZ7jQBH8AZEng50Oh8y8fi8skBBBzaYp1ilgvzG740L7uex6fHV62myq0SXeCa+oJUjq326FU8y+Vsa32H8A3e7tOgXZPdt2TVNltx2S9H2WO8RMi7LfaSwARNfy1zu+bfR50r6ef8Yx5YKCMz4wWb1SHU1GS800mjOjlInLQORYRNMlSwR1+vLlVDciOqFapDSbj+YOVOawR0R1aqlSKpZkt33DuOBPx9qe6CVnIi7Z+Px/KqM+OLCzlLY/RS+LbxQpDWcfTVRiP+S5qRTcE3M3UioN/e0BE/1+MpX90IGpvVkA63ILYbKEa4bM3ASL7ChTCr6xN5XT+GpVJveFKK1cfNx9ExHI4rzYE=
+
+[mailer]
+ENABLED = true
+PROTOCOL = dummy
+FROM = mysql-{{TEST_TYPE}}-test@gitea.io
+
+[service]
+REGISTER_EMAIL_CONFIRM = false
+REGISTER_MANUAL_CONFIRM = false
+DISABLE_REGISTRATION = false
+ENABLE_CAPTCHA = false
+REQUIRE_SIGNIN_VIEW = false
+DEFAULT_KEEP_EMAIL_PRIVATE = false
+DEFAULT_ALLOW_CREATE_ORGANIZATION = true
+NO_REPLY_ADDRESS = noreply.example.org
+ENABLE_NOTIFY_MAIL = true
+
+[picture]
+DISABLE_GRAVATAR = false
+ENABLE_FEDERATED_AVATAR = false
+
+[session]
+PROVIDER = file
+PROVIDER_CONFIG = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mysql/data/sessions
+
+[log]
+MODE = {{TEST_LOGGER}}
+ROOT_PATH = {{REPO_TEST_DIR}}mysql-log
+ENABLE_SSH_LOG = true
+logger.xorm.MODE = file
+
+[log.test]
+LEVEL = Info
+COLORIZE = true
+
+[log.file]
+LEVEL = Debug
+
+[security]
+PASSWORD_HASH_ALGO = argon2
+DISABLE_GIT_HOOKS = false
+INSTALL_LOCK = true
+SECRET_KEY = 9pCviYTWSb
+INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0OTU1NTE2MTh9.hhSVGOANkaKk3vfCd2jDOIww4pUk0xtg9JRde5UogyQ
+DISABLE_QUERY_AUTH_TOKEN = true
+
+[lfs]
+PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-mysql/data/lfs
+
+[packages]
+ENABLED = true
+
+[email.incoming]
+; temporarily disabled because the incoming mail tests are flaky due to the IMAP server (during integration tests) couldn't be not ready in time sometimes.
+ENABLED = false
+HOST = smtpimap
+PORT = 993
+USERNAME = debug@localdomain.test
+PASSWORD = debug
+USE_TLS = true
+SKIP_TLS_VERIFY = true
+REPLY_TO_ADDRESS = incoming+%{token}@localhost
+
+[actions]
+ENABLED = true
diff --git a/tests/pgsql.ini.tmpl b/tests/pgsql.ini.tmpl
new file mode 100644
index 0000000..b58d753
--- /dev/null
+++ b/tests/pgsql.ini.tmpl
@@ -0,0 +1,129 @@
+APP_NAME = Forgejo
+APP_SLOGAN = Beyond coding. We Forge.
+RUN_MODE = prod
+
+[database]
+DB_TYPE = postgres
+HOST = {{TEST_PGSQL_HOST}}
+NAME = {{TEST_PGSQL_DBNAME}}
+USER = {{TEST_PGSQL_USERNAME}}
+PASSWD = {{TEST_PGSQL_PASSWORD}}
+SCHEMA = {{TEST_PGSQL_SCHEMA}}
+SSL_MODE = disable
+
+[indexer]
+REPO_INDEXER_ENABLED = true
+REPO_INDEXER_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-pgsql/indexers/repos.bleve
+
+[queue.issue_indexer]
+TYPE = level
+DATADIR = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-pgsql/indexers/issues.queue
+
+[queue]
+TYPE = immediate
+
+[repository]
+ROOT = {{REPO_TEST_DIR}}tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-pgsql/gitea-repositories
+
+[repository.local]
+LOCAL_COPY_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-pgsql/tmp/local-repo
+
+[repository.upload]
+TEMP_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-pgsql/tmp/uploads
+
+[repository.signing]
+SIGNING_KEY = none
+
+[server]
+SSH_DOMAIN = localhost
+HTTP_PORT = 3002
+ROOT_URL = http://localhost:3002/
+DISABLE_SSH = false
+SSH_LISTEN_HOST = localhost
+SSH_PORT = 2202
+START_SSH_SERVER = true
+LFS_START_SERVER = true
+OFFLINE_MODE = false
+LFS_JWT_SECRET = Tv_MjmZuHqpIY6GFl12ebgkRAMt4RlWt0v4EHKSXO0w
+APP_DATA_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-pgsql/data
+BUILTIN_SSH_SERVER_USER = git
+SSH_TRUSTED_USER_CA_KEYS = ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCb4DC1dMFnJ6pXWo7GMxTchtzmJHYzfN6sZ9FAPFR4ijMLfGki+olvOMO5Fql1/yGnGfbELQa1S6y4shSvj/5K+zUFScmEXYf3Gcr87RqilLkyk16RS+cHNB1u87xTHbETaa3nyCJeGQRpd4IQ4NKob745mwDZ7jQBH8AZEng50Oh8y8fi8skBBBzaYp1ilgvzG740L7uex6fHV62myq0SXeCa+oJUjq326FU8y+Vsa32H8A3e7tOgXZPdt2TVNltx2S9H2WO8RMi7LfaSwARNfy1zu+bfR50r6ef8Yx5YKCMz4wWb1SHU1GS800mjOjlInLQORYRNMlSwR1+vLlVDciOqFapDSbj+YOVOawR0R1aqlSKpZkt33DuOBPx9qe6CVnIi7Z+Px/KqM+OLCzlLY/RS+LbxQpDWcfTVRiP+S5qRTcE3M3UioN/e0BE/1+MpX90IGpvVkA63ILYbKEa4bM3ASL7ChTCr6xN5XT+GpVJveFKK1cfNx9ExHI4rzYE=
+
+[attachment]
+PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-pgsql/data/attachments
+
+[mailer]
+ENABLED = true
+PROTOCOL = dummy
+FROM = pgsql-{{TEST_TYPE}}-test@gitea.io
+
+[service]
+REGISTER_EMAIL_CONFIRM = false
+REGISTER_MANUAL_CONFIRM = false
+DISABLE_REGISTRATION = false
+ENABLE_CAPTCHA = false
+REQUIRE_SIGNIN_VIEW = false
+DEFAULT_KEEP_EMAIL_PRIVATE = false
+DEFAULT_ALLOW_CREATE_ORGANIZATION = true
+NO_REPLY_ADDRESS = noreply.example.org
+ENABLE_NOTIFY_MAIL = true
+
+[picture]
+DISABLE_GRAVATAR = false
+ENABLE_FEDERATED_AVATAR = false
+AVATAR_UPLOAD_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-pgsql/data/avatars
+REPOSITORY_AVATAR_UPLOAD_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-pgsql/data/repo-avatars
+
+[session]
+PROVIDER = file
+PROVIDER_CONFIG = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-pgsql/data/sessions
+
+[log]
+MODE = {{TEST_LOGGER}}
+ROOT_PATH = {{REPO_TEST_DIR}}pgsql-log
+ENABLE_SSH_LOG = true
+logger.xorm.MODE = file
+
+[log.test]
+LEVEL = Info
+COLORIZE = true
+
+[log.file]
+LEVEL = Debug
+
+[security]
+PASSWORD_HASH_ALGO = argon2
+DISABLE_GIT_HOOKS = false
+INSTALL_LOCK = true
+SECRET_KEY = 9pCviYTWSb
+INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0OTU1NTE2MTh9.hhSVGOANkaKk3vfCd2jDOIww4pUk0xtg9JRde5UogyQ
+DISABLE_QUERY_AUTH_TOKEN = true
+
+[lfs]
+MINIO_BASE_PATH = lfs/
+
+[attachment]
+MINIO_BASE_PATH = attachments/
+
+[avatars]
+MINIO_BASE_PATH = avatars/
+
+[repo-avatars]
+MINIO_BASE_PATH = repo-avatars/
+
+[storage]
+STORAGE_TYPE = minio
+SERVE_DIRECT = false
+MINIO_ENDPOINT = minio:9000
+MINIO_ACCESS_KEY_ID = 123456
+MINIO_SECRET_ACCESS_KEY = 12345678
+MINIO_BUCKET = gitea
+MINIO_LOCATION = us-east-1
+MINIO_USE_SSL = false
+MINIO_CHECKSUM_ALGORITHM = md5
+
+[packages]
+ENABLED = true
+
+[actions]
+ENABLED = true
diff --git a/tests/sqlite.ini.tmpl b/tests/sqlite.ini.tmpl
new file mode 100644
index 0000000..51234e3
--- /dev/null
+++ b/tests/sqlite.ini.tmpl
@@ -0,0 +1,115 @@
+APP_NAME = Forgejo
+APP_SLOGAN = Beyond coding. We Forge.
+RUN_MODE = prod
+
+[database]
+DB_TYPE = sqlite3
+PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/gitea.db
+
+[indexer]
+REPO_INDEXER_ENABLED = true
+REPO_INDEXER_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/indexers/repos.bleve
+
+[queue.issue_indexer]
+TYPE = level
+DATADIR = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/indexers/issues.queue
+
+[queue]
+TYPE = immediate
+
+[repository]
+ROOT = {{REPO_TEST_DIR}}tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/gitea-repositories
+
+[repository.local]
+LOCAL_COPY_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/tmp/local-repo
+
+[repository.upload]
+TEMP_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/tmp/uploads
+
+[repository.signing]
+SIGNING_KEY = none
+
+[server]
+SSH_DOMAIN = localhost
+HTTP_PORT = 3003
+ROOT_URL = http://localhost:3003/
+DISABLE_SSH = false
+SSH_LISTEN_HOST = localhost
+SSH_PORT = 2203
+START_SSH_SERVER = true
+LFS_START_SERVER = true
+OFFLINE_MODE = false
+LFS_JWT_SECRET = Tv_MjmZuHqpIY6GFl12ebgkRAMt4RlWt0v4EHKSXO0w
+APP_DATA_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/data
+ENABLE_GZIP = true
+BUILTIN_SSH_SERVER_USER = git
+SSH_TRUSTED_USER_CA_KEYS = ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCb4DC1dMFnJ6pXWo7GMxTchtzmJHYzfN6sZ9FAPFR4ijMLfGki+olvOMO5Fql1/yGnGfbELQa1S6y4shSvj/5K+zUFScmEXYf3Gcr87RqilLkyk16RS+cHNB1u87xTHbETaa3nyCJeGQRpd4IQ4NKob745mwDZ7jQBH8AZEng50Oh8y8fi8skBBBzaYp1ilgvzG740L7uex6fHV62myq0SXeCa+oJUjq326FU8y+Vsa32H8A3e7tOgXZPdt2TVNltx2S9H2WO8RMi7LfaSwARNfy1zu+bfR50r6ef8Yx5YKCMz4wWb1SHU1GS800mjOjlInLQORYRNMlSwR1+vLlVDciOqFapDSbj+YOVOawR0R1aqlSKpZkt33DuOBPx9qe6CVnIi7Z+Px/KqM+OLCzlLY/RS+LbxQpDWcfTVRiP+S5qRTcE3M3UioN/e0BE/1+MpX90IGpvVkA63ILYbKEa4bM3ASL7ChTCr6xN5XT+GpVJveFKK1cfNx9ExHI4rzYE=
+
+[attachment]
+PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/data/attachments
+
+[mailer]
+ENABLED = true
+PROTOCOL = dummy
+FROM = sqlite-{{TEST_TYPE}}-test@gitea.io
+
+[service]
+REGISTER_EMAIL_CONFIRM = false
+REGISTER_MANUAL_CONFIRM = false
+ENABLE_NOTIFY_MAIL = true
+DISABLE_REGISTRATION = false
+ENABLE_CAPTCHA = false
+REQUIRE_SIGNIN_VIEW = false
+DEFAULT_KEEP_EMAIL_PRIVATE = false
+DEFAULT_ALLOW_CREATE_ORGANIZATION = true
+NO_REPLY_ADDRESS = noreply.example.org
+
+[picture]
+DISABLE_GRAVATAR = false
+ENABLE_FEDERATED_AVATAR = false
+AVATAR_UPLOAD_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/data/avatars
+REPOSITORY_AVATAR_UPLOAD_PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/data/repo-avatars
+
+[session]
+PROVIDER = file
+PROVIDER_CONFIG = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/data/sessions
+
+[log]
+MODE = {{TEST_LOGGER}}
+ROOT_PATH = {{REPO_TEST_DIR}}sqlite-log
+ENABLE_SSH_LOG = true
+logger.xorm.MODE = file
+
+[log.test]
+LEVEL = Info
+COLORIZE = true
+
+[log.file]
+LEVEL = Trace
+
+[security]
+PASSWORD_HASH_ALGO = argon2
+DISABLE_GIT_HOOKS = false
+INSTALL_LOCK = true
+SECRET_KEY = 9pCviYTWSb
+INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0OTI3OTU5ODN9.OQkH5UmzID2XBdwQ9TAI6Jj2t1X-wElVTjbE7aoN4I8
+DISABLE_QUERY_AUTH_TOKEN = true
+
+[oauth2]
+JWT_SECRET = KZb_QLUd4fYVyxetjxC4eZkrBgWM2SndOOWDNtgUUko
+
+[lfs]
+PATH = tests/{{TEST_TYPE}}/gitea-{{TEST_TYPE}}-sqlite/data/lfs
+
+[packages]
+ENABLED = true
+
+[markup.html]
+ENABLED = true
+FILE_EXTENSIONS = .html
+RENDER_COMMAND = `go run build/test-echo.go`
+IS_INPUT_FILE = false
+RENDER_CONTENT_MODE=sanitized
+
+[actions]
+ENABLED = true
diff --git a/tests/test_utils.go b/tests/test_utils.go
new file mode 100644
index 0000000..ee10689
--- /dev/null
+++ b/tests/test_utils.go
@@ -0,0 +1,452 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+//nolint:forbidigo
+package tests
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "path"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "code.gitea.io/gitea/models/db"
+ packages_model "code.gitea.io/gitea/models/packages"
+ repo_model "code.gitea.io/gitea/models/repo"
+ unit_model "code.gitea.io/gitea/models/unit"
+ "code.gitea.io/gitea/models/unittest"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/base"
+ "code.gitea.io/gitea/modules/git"
+ "code.gitea.io/gitea/modules/graceful"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/optional"
+ "code.gitea.io/gitea/modules/process"
+ repo_module "code.gitea.io/gitea/modules/repository"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/modules/storage"
+ "code.gitea.io/gitea/modules/testlogger"
+ "code.gitea.io/gitea/modules/util"
+ "code.gitea.io/gitea/routers"
+ repo_service "code.gitea.io/gitea/services/repository"
+ files_service "code.gitea.io/gitea/services/repository/files"
+ wiki_service "code.gitea.io/gitea/services/wiki"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func exitf(format string, args ...any) {
+ fmt.Printf(format+"\n", args...)
+ os.Exit(1)
+}
+
+func InitTest(requireGitea bool) {
+ log.RegisterEventWriter("test", testlogger.NewTestLoggerWriter)
+
+ giteaRoot := base.SetupGiteaRoot()
+ if giteaRoot == "" {
+ exitf("Environment variable $GITEA_ROOT not set")
+ }
+
+ // TODO: Speedup tests that rely on the event source ticker, confirm whether there is any bug or failure.
+ // setting.UI.Notification.EventSourceUpdateTime = time.Second
+
+ setting.IsInTesting = true
+ setting.AppWorkPath = giteaRoot
+ setting.CustomPath = filepath.Join(setting.AppWorkPath, "custom")
+ if requireGitea {
+ giteaBinary := "gitea"
+ if setting.IsWindows {
+ giteaBinary += ".exe"
+ }
+ setting.AppPath = path.Join(giteaRoot, giteaBinary)
+ if _, err := os.Stat(setting.AppPath); err != nil {
+ exitf("Could not find gitea binary at %s", setting.AppPath)
+ }
+ }
+ giteaConf := os.Getenv("GITEA_CONF")
+ if giteaConf == "" {
+ // By default, use sqlite.ini for testing, then IDE like GoLand can start the test process with debugger.
+ // It's easier for developers to debug bugs step by step with a debugger.
+ // Notice: when doing "ssh push", Gitea executes sub processes, debugger won't work for the sub processes.
+ giteaConf = "tests/sqlite.ini"
+ _ = os.Setenv("GITEA_CONF", giteaConf)
+ fmt.Printf("Environment variable $GITEA_CONF not set, use default: %s\n", giteaConf)
+ if !setting.EnableSQLite3 {
+ exitf(`sqlite3 requires: import _ "github.com/mattn/go-sqlite3" or -tags sqlite,sqlite_unlock_notify`)
+ }
+ }
+ if !path.IsAbs(giteaConf) {
+ setting.CustomConf = filepath.Join(giteaRoot, giteaConf)
+ } else {
+ setting.CustomConf = giteaConf
+ }
+
+ unittest.InitSettings()
+ setting.Repository.DefaultBranch = "master" // many test code still assume that default branch is called "master"
+ _ = util.RemoveAll(repo_module.LocalCopyPath())
+
+ if err := git.InitFull(context.Background()); err != nil {
+ log.Fatal("git.InitOnceWithSync: %v", err)
+ }
+
+ setting.LoadDBSetting()
+ if err := storage.Init(); err != nil {
+ exitf("Init storage failed: %v", err)
+ }
+
+ switch {
+ case setting.Database.Type.IsMySQL():
+ connType := "tcp"
+ if len(setting.Database.Host) > 0 && setting.Database.Host[0] == '/' { // looks like a unix socket
+ connType = "unix"
+ }
+
+ db, err := sql.Open("mysql", fmt.Sprintf("%s:%s@%s(%s)/",
+ setting.Database.User, setting.Database.Passwd, connType, setting.Database.Host))
+ defer db.Close()
+ if err != nil {
+ log.Fatal("sql.Open: %v", err)
+ }
+ if _, err = db.Exec(fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", strings.SplitN(setting.Database.Name, "?", 2)[0])); err != nil {
+ log.Fatal("db.Exec: %v", err)
+ }
+ case setting.Database.Type.IsPostgreSQL():
+ var db *sql.DB
+ var err error
+ if setting.Database.Host[0] == '/' {
+ db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/%s?sslmode=%s&host=%s",
+ setting.Database.User, setting.Database.Passwd, setting.Database.Name, setting.Database.SSLMode, setting.Database.Host))
+ } else {
+ db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s",
+ setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode))
+ }
+
+ defer db.Close()
+ if err != nil {
+ log.Fatal("sql.Open: %v", err)
+ }
+ dbrows, err := db.Query(fmt.Sprintf("SELECT 1 FROM pg_database WHERE datname = '%s'", setting.Database.Name))
+ if err != nil {
+ log.Fatal("db.Query: %v", err)
+ }
+ defer dbrows.Close()
+
+ if !dbrows.Next() {
+ if _, err = db.Exec(fmt.Sprintf("CREATE DATABASE %s", setting.Database.Name)); err != nil {
+ log.Fatal("db.Exec: CREATE DATABASE: %v", err)
+ }
+ }
+ // Check if we need to setup a specific schema
+ if len(setting.Database.Schema) == 0 {
+ break
+ }
+ db.Close()
+
+ if setting.Database.Host[0] == '/' {
+ db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@/%s?sslmode=%s&host=%s",
+ setting.Database.User, setting.Database.Passwd, setting.Database.Name, setting.Database.SSLMode, setting.Database.Host))
+ } else {
+ db, err = sql.Open("postgres", fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=%s",
+ setting.Database.User, setting.Database.Passwd, setting.Database.Host, setting.Database.Name, setting.Database.SSLMode))
+ }
+ // This is a different db object; requires a different Close()
+ defer db.Close()
+ if err != nil {
+ log.Fatal("sql.Open: %v", err)
+ }
+ schrows, err := db.Query(fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name = '%s'", setting.Database.Schema))
+ if err != nil {
+ log.Fatal("db.Query: %v", err)
+ }
+ defer schrows.Close()
+
+ if !schrows.Next() {
+ // Create and setup a DB schema
+ if _, err = db.Exec(fmt.Sprintf("CREATE SCHEMA %s", setting.Database.Schema)); err != nil {
+ log.Fatal("db.Exec: CREATE SCHEMA: %v", err)
+ }
+ }
+ }
+
+ routers.InitWebInstalled(graceful.GetManager().HammerContext())
+}
+
+func PrepareAttachmentsStorage(t testing.TB) {
+ // prepare attachments directory and files
+ require.NoError(t, storage.Clean(storage.Attachments))
+
+ s, err := storage.NewStorage(setting.LocalStorageType, &setting.Storage{
+ Path: filepath.Join(filepath.Dir(setting.AppPath), "tests", "testdata", "data", "attachments"),
+ })
+ require.NoError(t, err)
+ require.NoError(t, s.IterateObjects("", func(p string, obj storage.Object) error {
+ _, err = storage.Copy(storage.Attachments, p, s, p)
+ return err
+ }))
+}
+
+// cancelProcesses cancels all processes of the [process.Manager].
+// Returns immediately if delay is 0, otherwise wait until all processes are done
+// and fails the test if it takes longer that the given delay.
+func cancelProcesses(t testing.TB, delay time.Duration) {
+ processManager := process.GetManager()
+ processes, _ := processManager.Processes(true, true)
+ for _, p := range processes {
+ processManager.Cancel(p.PID)
+ t.Logf("PrepareTestEnv:Process %q cancelled", p.Description)
+ }
+ if delay == 0 || len(processes) == 0 {
+ return
+ }
+
+ start := time.Now()
+ processes, _ = processManager.Processes(true, true)
+ for len(processes) > 0 {
+ if time.Since(start) > delay {
+ t.Errorf("ERROR PrepareTestEnv: could not cancel all processes within %s", delay)
+ for _, p := range processes {
+ t.Logf("PrepareTestEnv:Remaining Process: %q", p.Description)
+ }
+ return
+ }
+ runtime.Gosched() // let the context cancellation propagate
+ processes, _ = processManager.Processes(true, true)
+ }
+ t.Logf("PrepareTestEnv: all processes cancelled within %s", time.Since(start))
+}
+
+func PrepareTestEnv(t testing.TB, skip ...int) func() {
+ t.Helper()
+ ourSkip := 1
+ if len(skip) > 0 {
+ ourSkip += skip[0]
+ }
+ deferFn := PrintCurrentTest(t, ourSkip)
+
+ // kill all background processes to prevent them from interfering with the fixture loading
+ // see https://codeberg.org/forgejo/forgejo/issues/2962
+ cancelProcesses(t, 30*time.Second)
+ t.Cleanup(func() { cancelProcesses(t, 0) }) // cancel remaining processes in a non-blocking way
+
+ // load database fixtures
+ require.NoError(t, unittest.LoadFixtures())
+
+ // load git repo fixtures
+ require.NoError(t, util.RemoveAll(setting.RepoRootPath))
+ require.NoError(t, unittest.CopyDir(path.Join(filepath.Dir(setting.AppPath), "tests/gitea-repositories-meta"), setting.RepoRootPath))
+ ownerDirs, err := os.ReadDir(setting.RepoRootPath)
+ if err != nil {
+ require.NoError(t, err, "unable to read the new repo root: %v\n", err)
+ }
+ for _, ownerDir := range ownerDirs {
+ if !ownerDir.Type().IsDir() {
+ continue
+ }
+ repoDirs, err := os.ReadDir(filepath.Join(setting.RepoRootPath, ownerDir.Name()))
+ if err != nil {
+ require.NoError(t, err, "unable to read the new repo root: %v\n", err)
+ }
+ for _, repoDir := range repoDirs {
+ _ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "pack"), 0o755)
+ _ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "objects", "info"), 0o755)
+ _ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "heads"), 0o755)
+ _ = os.MkdirAll(filepath.Join(setting.RepoRootPath, ownerDir.Name(), repoDir.Name(), "refs", "tag"), 0o755)
+ }
+ }
+
+ // load LFS object fixtures
+ // (LFS storage can be on any of several backends, including remote servers, so we init it with the storage API)
+ lfsFixtures, err := storage.NewStorage(setting.LocalStorageType, &setting.Storage{
+ Path: filepath.Join(filepath.Dir(setting.AppPath), "tests/gitea-lfs-meta"),
+ })
+ require.NoError(t, err)
+ require.NoError(t, storage.Clean(storage.LFS))
+ require.NoError(t, lfsFixtures.IterateObjects("", func(path string, _ storage.Object) error {
+ _, err := storage.Copy(storage.LFS, path, lfsFixtures, path)
+ return err
+ }))
+
+ // clear all package data
+ require.NoError(t, db.TruncateBeans(db.DefaultContext,
+ &packages_model.Package{},
+ &packages_model.PackageVersion{},
+ &packages_model.PackageFile{},
+ &packages_model.PackageBlob{},
+ &packages_model.PackageProperty{},
+ &packages_model.PackageBlobUpload{},
+ &packages_model.PackageCleanupRule{},
+ ))
+ require.NoError(t, storage.Clean(storage.Packages))
+
+ return deferFn
+}
+
+func PrintCurrentTest(t testing.TB, skip ...int) func() {
+ t.Helper()
+ actualSkip := 1
+ if len(skip) > 0 {
+ actualSkip = skip[0] + 1
+ }
+ return testlogger.PrintCurrentTest(t, actualSkip)
+}
+
+// Printf takes a format and args and prints the string to os.Stdout
+func Printf(format string, args ...any) {
+ testlogger.Printf(format, args...)
+}
+
+func AddFixtures(dirs ...string) func() {
+ return unittest.OverrideFixtures(
+ unittest.FixturesOptions{
+ Dir: filepath.Join(setting.AppWorkPath, "models/fixtures/"),
+ Base: setting.AppWorkPath,
+ Dirs: dirs,
+ },
+ )
+}
+
+type DeclarativeRepoOptions struct {
+ Name optional.Option[string]
+ EnabledUnits optional.Option[[]unit_model.Type]
+ DisabledUnits optional.Option[[]unit_model.Type]
+ Files optional.Option[[]*files_service.ChangeRepoFile]
+ WikiBranch optional.Option[string]
+ AutoInit optional.Option[bool]
+ IsTemplate optional.Option[bool]
+}
+
+func CreateDeclarativeRepoWithOptions(t *testing.T, owner *user_model.User, opts DeclarativeRepoOptions) (*repo_model.Repository, string, func()) {
+ t.Helper()
+
+ // Not using opts.Name.ValueOrDefault() here to avoid unnecessarily
+ // generating an UUID when a name is specified.
+ var repoName string
+ if opts.Name.Has() {
+ repoName = opts.Name.Value()
+ } else {
+ repoName = uuid.NewString()
+ }
+
+ var autoInit bool
+ if opts.AutoInit.Has() {
+ autoInit = opts.AutoInit.Value()
+ } else {
+ autoInit = true
+ }
+
+ // Create the repository
+ repo, err := repo_service.CreateRepository(db.DefaultContext, owner, owner, repo_service.CreateRepoOptions{
+ Name: repoName,
+ Description: "Temporary Repo",
+ AutoInit: autoInit,
+ Gitignores: "",
+ License: "WTFPL",
+ Readme: "Default",
+ DefaultBranch: "main",
+ IsTemplate: opts.IsTemplate.Value(),
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, repo)
+
+ // Populate `enabledUnits` if we have any enabled.
+ var enabledUnits []repo_model.RepoUnit
+ if opts.EnabledUnits.Has() {
+ units := opts.EnabledUnits.Value()
+ enabledUnits = make([]repo_model.RepoUnit, len(units))
+
+ for i, unitType := range units {
+ enabledUnits[i] = repo_model.RepoUnit{
+ RepoID: repo.ID,
+ Type: unitType,
+ }
+ }
+ }
+
+ // Adjust the repo units according to our parameters.
+ if opts.EnabledUnits.Has() || opts.DisabledUnits.Has() {
+ err := repo_service.UpdateRepositoryUnits(db.DefaultContext, repo, enabledUnits, opts.DisabledUnits.ValueOrDefault(nil))
+ require.NoError(t, err)
+ }
+
+ // Add files, if any.
+ var sha string
+ if opts.Files.Has() {
+ assert.True(t, autoInit, "Files cannot be specified if AutoInit is disabled")
+ files := opts.Files.Value()
+
+ resp, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, owner, &files_service.ChangeRepoFilesOptions{
+ Files: files,
+ Message: "add files",
+ OldBranch: "main",
+ NewBranch: "main",
+ Author: &files_service.IdentityOptions{
+ Name: owner.Name,
+ Email: owner.Email,
+ },
+ Committer: &files_service.IdentityOptions{
+ Name: owner.Name,
+ Email: owner.Email,
+ },
+ Dates: &files_service.CommitDateOptions{
+ Author: time.Now(),
+ Committer: time.Now(),
+ },
+ })
+ require.NoError(t, err)
+ assert.NotEmpty(t, resp)
+
+ sha = resp.Commit.SHA
+ }
+
+ // If there's a Wiki branch specified, create a wiki, and a default wiki page.
+ if opts.WikiBranch.Has() {
+ // Set the wiki branch in the database first
+ repo.WikiBranch = opts.WikiBranch.Value()
+ err := repo_model.UpdateRepositoryCols(db.DefaultContext, repo, "wiki_branch")
+ require.NoError(t, err)
+
+ // Initialize the wiki
+ err = wiki_service.InitWiki(db.DefaultContext, repo)
+ require.NoError(t, err)
+
+ // Add a new wiki page
+ err = wiki_service.AddWikiPage(db.DefaultContext, owner, repo, "Home", "Welcome to the wiki!", "Add a Home page")
+ require.NoError(t, err)
+ }
+
+ // Return the repo, the top commit, and a defer-able function to delete the
+ // repo.
+ return repo, sha, func() {
+ _ = repo_service.DeleteRepository(db.DefaultContext, owner, repo, false)
+ }
+}
+
+func CreateDeclarativeRepo(t *testing.T, owner *user_model.User, name string, enabledUnits, disabledUnits []unit_model.Type, files []*files_service.ChangeRepoFile) (*repo_model.Repository, string, func()) {
+ t.Helper()
+
+ var opts DeclarativeRepoOptions
+
+ if name != "" {
+ opts.Name = optional.Some(name)
+ }
+ if enabledUnits != nil {
+ opts.EnabledUnits = optional.Some(enabledUnits)
+ }
+ if disabledUnits != nil {
+ opts.DisabledUnits = optional.Some(disabledUnits)
+ }
+ if files != nil {
+ opts.Files = optional.Some(files)
+ }
+
+ return CreateDeclarativeRepoWithOptions(t, owner, opts)
+}
diff --git a/tests/testdata/data/attachments/a/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22 b/tests/testdata/data/attachments/a/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22
new file mode 100644
index 0000000..96fc988
--- /dev/null
+++ b/tests/testdata/data/attachments/a/0/a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22
@@ -0,0 +1 @@
+# This is a release README