From 7fc51991e405ea8d44fd6b4b4de13ad65da63ae7 Mon Sep 17 00:00:00 2001 From: Gergely Nagy Date: Fri, 5 Jan 2024 13:45:10 +0100 Subject: [PATCH] [FEAT] API support for repository flags Expose the repository flags feature over the API, so the flags can be managed by a site administrator without using the web API. Signed-off-by: Gergely Nagy (cherry picked from commit bac9f0225d47e159afa90e5bbea9562cbc860dae) (cherry picked from commit e7f5c1ba141ac7f8c7834b5048d0ffd3ce50900b) (cherry picked from commit 95d9fe19cf3ed5787855ac2a442d29104498aa36) --- modules/structs/repo_flags.go | 9 + routers/api/v1/api.go | 12 ++ routers/api/v1/repo/flags.go | 245 ++++++++++++++++++++++++ routers/api/v1/swagger/options.go | 3 + templates/swagger/v1_json.tmpl | 268 +++++++++++++++++++++++++++ tests/integration/repo_flags_test.go | 149 +++++++++++++++ 6 files changed, 686 insertions(+) create mode 100644 modules/structs/repo_flags.go create mode 100644 routers/api/v1/repo/flags.go diff --git a/modules/structs/repo_flags.go b/modules/structs/repo_flags.go new file mode 100644 index 0000000000..5db714545c --- /dev/null +++ b/modules/structs/repo_flags.go @@ -0,0 +1,9 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs + +// ReplaceFlagsOption options when replacing the flags of a repository +type ReplaceFlagsOption struct { + Flags []string `json:"flags"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index e718924a81..610c292fba 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1096,6 +1096,18 @@ func Routes() *web.Route { m.Get("/permission", repo.GetRepoPermissions) }) }, reqToken()) + if setting.Repository.EnableFlags { + m.Group("/flags", func() { + m.Combo("").Get(repo.ListFlags). + Put(bind(api.ReplaceFlagsOption{}), repo.ReplaceAllFlags). + Delete(repo.DeleteAllFlags) + m.Group("/{flag}", func() { + m.Combo("").Get(repo.HasFlag). + Put(repo.AddFlag). + Delete(repo.DeleteFlag) + }) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryAdmin), reqToken(), reqSiteAdmin()) + } m.Get("/assignees", reqToken(), reqAnyRepoReader(), repo.GetAssignees) m.Get("/reviewers", reqToken(), reqAnyRepoReader(), repo.GetReviewers) m.Group("/teams", func() { diff --git a/routers/api/v1/repo/flags.go b/routers/api/v1/repo/flags.go new file mode 100644 index 0000000000..cbb2c95914 --- /dev/null +++ b/routers/api/v1/repo/flags.go @@ -0,0 +1,245 @@ +// Copyright 2024 The Forgejo Authors c/o Codeberg e.V.. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/modules/context" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" +) + +func ListFlags(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/flags repository repoListFlags + // --- + // summary: List a repository's flags + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "200": + // "$ref": "#/responses/StringSlice" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + repoFlags, err := ctx.Repo.Repository.ListFlags(ctx) + if err != nil { + ctx.InternalServerError(err) + return + } + + flags := make([]string, len(repoFlags)) + for i := range repoFlags { + flags[i] = repoFlags[i].Name + } + + ctx.SetTotalCountHeader(int64(len(repoFlags))) + ctx.JSON(http.StatusOK, flags) +} + +func ReplaceAllFlags(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/flags repository repoReplaceAllFlags + // --- + // summary: Replace all flags of a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/ReplaceFlagsOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + flagsForm := web.GetForm(ctx).(*api.ReplaceFlagsOption) + + if err := ctx.Repo.Repository.ReplaceAllFlags(ctx, flagsForm.Flags); err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func DeleteAllFlags(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/flags repository repoDeleteAllFlags + // --- + // summary: Remove all flags from a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + if err := ctx.Repo.Repository.ReplaceAllFlags(ctx, nil); err != nil { + ctx.InternalServerError(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func HasFlag(ctx *context.APIContext) { + // swagger:operation GET /repos/{owner}/{repo}/flags/{flag} repository repoCheckFlag + // --- + // summary: Check if a repository has a given flag + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: flag + // in: path + // description: name of the flag + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + hasFlag := ctx.Repo.Repository.HasFlag(ctx, ctx.Params(":flag")) + if hasFlag { + ctx.Status(http.StatusNoContent) + } else { + ctx.NotFound() + } +} + +func AddFlag(ctx *context.APIContext) { + // swagger:operation PUT /repos/{owner}/{repo}/flags/{flag} repository repoAddFlag + // --- + // summary: Add a flag to a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: flag + // in: path + // description: name of the flag + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + flag := ctx.Params(":flag") + + if ctx.Repo.Repository.HasFlag(ctx, flag) { + ctx.Status(http.StatusNoContent) + return + } + + if err := ctx.Repo.Repository.AddFlag(ctx, flag); err != nil { + ctx.InternalServerError(err) + return + } + ctx.Status(http.StatusNoContent) +} + +func DeleteFlag(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/flags/{flag} repository repoDeleteFlag + // --- + // summary: Remove a flag from a repository + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: flag + // in: path + // description: name of the flag + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + // "403": + // "$ref": "#/responses/forbidden" + // "404": + // "$ref": "#/responses/notFound" + + flag := ctx.Params(":flag") + + if _, err := ctx.Repo.Repository.DeleteFlag(ctx, flag); err != nil { + ctx.InternalServerError(err) + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index b5efbe916d..cca6d2d572 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -17,6 +17,9 @@ type swaggerParameterBodies struct { // in:body AddCollaboratorOption api.AddCollaboratorOption + // in:body + ReplaceFlagsOption api.ReplaceFlagsOption + // in:body CreateEmailOption api.CreateEmailOption // in:body diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 52dface646..b4f82ab93f 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -4992,6 +4992,260 @@ } } }, + "/repos/{owner}/{repo}/flags": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "List a repository's flags", + "operationId": "repoListFlags", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "$ref": "#/responses/StringSlice" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Replace all flags of a repository", + "operationId": "repoReplaceAllFlags", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/ReplaceFlagsOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Remove all flags from a repository", + "operationId": "repoDeleteAllFlags", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, + "/repos/{owner}/{repo}/flags/{flag}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Check if a repository has a given flag", + "operationId": "repoCheckFlag", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the flag", + "name": "flag", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "put": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Add a flag to a repository", + "operationId": "repoAddFlag", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the flag", + "name": "flag", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Remove a flag from a repository", + "operationId": "repoDeleteFlag", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the flag", + "name": "flag", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + }, + "403": { + "$ref": "#/responses/forbidden" + }, + "404": { + "$ref": "#/responses/notFound" + } + } + } + }, "/repos/{owner}/{repo}/forks": { "get": { "produces": [ @@ -22008,6 +22262,20 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "ReplaceFlagsOption": { + "description": "ReplaceFlagsOption options when replacing the flags of a repository", + "type": "object", + "properties": { + "flags": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Flags" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "RepoCollaboratorPermission": { "description": "RepoCollaboratorPermission to get repository permission for a collaborator", "type": "object", diff --git a/tests/integration/repo_flags_test.go b/tests/integration/repo_flags_test.go index a335ca9adf..8b64776a5a 100644 --- a/tests/integration/repo_flags_test.go +++ b/tests/integration/repo_flags_test.go @@ -7,13 +7,16 @@ 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" @@ -42,6 +45,152 @@ func TestRepositoryFlagsUIDisabled(t *testing.T) { 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)()