mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
fix: address verified review findings (admin-webhooks-keys-1)
Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
13 files changed
+268
-31
No files matched your search
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
"gitea.dev/tea/internal/transport"
|
||||
@@ -88,11 +89,26 @@ func (s AdminUsers) Search(ctx context.Context, query string, limit int) ([]Admi
|
||||
return convertAll(users, adminUserFrom), err
|
||||
}
|
||||
|
||||
// get finds an account by its exact username through the administrator listing. Unlike
|
||||
// /users/{name}, that answers only administrators, only with complete role and status fields,
|
||||
// and only for individual accounts, never for organizations.
|
||||
func (s AdminUsers) get(ctx context.Context, username string) (*gitea.User, error) {
|
||||
user, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.User, *gitea.Response, error) {
|
||||
return c.Users.GetUserInfo(ctx, username)
|
||||
})
|
||||
return user, err
|
||||
for page := 1; ; page++ {
|
||||
users, err := callPage(ctx, s.Client, 50, page, func(ctx context.Context, c *gitea.Client, list gitea.ListOptions) ([]*gitea.User, *gitea.Response, error) {
|
||||
return c.Admin.ListUsers(ctx, gitea.AdminListUsersOptions{ListOptions: list, Query: username})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, user := range users.Items {
|
||||
if strings.EqualFold(user.UserName, username) {
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
if !users.More {
|
||||
return nil, NotFound("user", strconv.Quote(username), "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// View retrieves one account.
|
||||
|
||||
@@ -17,12 +17,13 @@ func TestAdminEditPreservesAuthenticationAndExplicitFalse(t *testing.T) {
|
||||
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
reads++
|
||||
require.Equal(t, "/api/v1/users/account", r.URL.Path)
|
||||
require.Equal(t, "/api/v1/admin/users", r.URL.Path)
|
||||
require.Equal(t, "account", r.URL.Query().Get("q"))
|
||||
if mutations == 0 {
|
||||
_, _ = w.Write([]byte(`{"id":1,"login":"account","login_name":"external-identity","source_id":7,"is_admin":true}`))
|
||||
_, _ = w.Write([]byte(`[{"id":2,"login":"account-2"},{"id":1,"login":"account","login_name":"external-identity","source_id":7,"is_admin":true}]`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":1,"login":"account","is_admin":false,"created":"2026-01-02T03:04:05Z"}`))
|
||||
_, _ = w.Write([]byte(`[{"id":1,"login":"account","is_admin":false,"created":"2026-01-02T03:04:05Z"}]`))
|
||||
return
|
||||
}
|
||||
mutations++
|
||||
@@ -65,7 +66,7 @@ func TestAdminCreateAppliesRestrictionsAndReloads(t *testing.T) {
|
||||
require.Equal(t, true, payload["restricted"])
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
_, _ = w.Write([]byte(`{"id":4,"login":"new-account","restricted":true}`))
|
||||
_, _ = w.Write([]byte(`[{"id":4,"login":"new-account","restricted":true}]`))
|
||||
}
|
||||
}))
|
||||
restricted := true
|
||||
@@ -73,7 +74,7 @@ func TestAdminCreateAppliesRestrictionsAndReloads(t *testing.T) {
|
||||
item, err := (AdminUsers{Client: client}).Create(context.Background(), "new-account", AdminUserOptions{Email: &email, Password: "local-password", Visibility: &visibility, Restricted: &restricted})
|
||||
require.NoError(t, err)
|
||||
require.True(t, item.Restricted)
|
||||
require.Equal(t, []string{"POST /api/v1/admin/users", "PATCH /api/v1/admin/users/new-account", "GET /api/v1/users/new-account"}, requests)
|
||||
require.Equal(t, []string{"POST /api/v1/admin/users", "PATCH /api/v1/admin/users/new-account", "GET /api/v1/admin/users"}, requests)
|
||||
}
|
||||
|
||||
func TestAdminCreateReportsFollowupFailureWithoutRetrying(t *testing.T) {
|
||||
@@ -108,3 +109,12 @@ func TestAdminUserFromMapsServerFields(t *testing.T) {
|
||||
Visibility: "limited", CreatedAt: "2026-10-01T12:00:00+02:00",
|
||||
}, adminUserFrom(user))
|
||||
}
|
||||
|
||||
func TestAdminViewFindsOnlyIndividualAccountsByExactName(t *testing.T) {
|
||||
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/api/v1/admin/users", r.URL.Path, "the public /users/{name} endpoint also answers for organizations and hides roles from other accounts")
|
||||
_, _ = w.Write([]byte(`[{"id":2,"login":"acme-bot"}]`))
|
||||
}))
|
||||
_, err := (AdminUsers{Client: client}).View(context.Background(), "acme")
|
||||
require.EqualError(t, err, `user "acme" not found`)
|
||||
}
|
||||
@@ -61,6 +61,7 @@ const (
|
||||
ReasonNotDone = "not done" // the run has not finished yet
|
||||
ReasonKeyInUse = "key in use" // the SSH key is registered already
|
||||
ReasonOwnsRepos = "owns repositories" // the user still owns repositories
|
||||
ReasonOwnsOther = "owns other" // the user is still in organizations or owns packages
|
||||
)
|
||||
|
||||
// ConflictError reports a request the current state of a resource refuses, such as closing a
|
||||
@@ -105,6 +106,8 @@ func (e *ConflictError) Error() string {
|
||||
text = "this SSH key is already registered"
|
||||
case ReasonOwnsRepos:
|
||||
text = cmp.Or(subject, "the user") + " still owns repositories"
|
||||
case ReasonOwnsOther:
|
||||
text = cmp.Or(subject, "the user") + " is still a member of organizations or owns packages"
|
||||
default:
|
||||
text = e.Err.Error()
|
||||
}
|
||||
@@ -153,7 +156,8 @@ var refusals = []struct {
|
||||
{ReasonNotDone, []int{http.StatusBadRequest}, []string{"not done"}},
|
||||
{ReasonKeyInUse, nil, []string{"key content has been used"}},
|
||||
{ReasonOwnsRepos, nil, []string{"ownership of repositories"}},
|
||||
{ReasonExists, []int{http.StatusBadRequest, http.StatusConflict, http.StatusUnprocessableEntity}, []string{"already exist", "release is has no tag"}},
|
||||
{ReasonOwnsOther, nil, []string{"membership of organizations", "ownership of packages"}},
|
||||
{ReasonExists, []int{http.StatusBadRequest, http.StatusConflict, http.StatusUnprocessableEntity}, []string{"already exist", "release is has no tag", "key title has been used"}},
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -5,7 +5,9 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
gitea "gitea.dev/sdk"
|
||||
"gitea.dev/tea/internal/transport"
|
||||
@@ -36,7 +38,8 @@ func (s SSHKeys) List(ctx context.Context, limit, page int) (Page[SSHKey], error
|
||||
return convertPage(keys, sshKeyFrom), err
|
||||
}
|
||||
|
||||
// View retrieves a public key by ID.
|
||||
// View retrieves a public key of the account by ID. Gitea also answers for other accounts' keys
|
||||
// and lets administrators delete them, so a key that belongs to someone else is refused.
|
||||
func (s SSHKeys) View(ctx context.Context, id int64) (SSHKey, error) {
|
||||
key, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.PublicKey, *gitea.Response, error) {
|
||||
return c.Users.GetPublicKey(ctx, id)
|
||||
@@ -44,6 +47,15 @@ func (s SSHKeys) View(ctx context.Context, id int64) (SSHKey, error) {
|
||||
if err != nil {
|
||||
return SSHKey{}, Classify(err, "SSH key", strconv.FormatInt(id, 10), "")
|
||||
}
|
||||
if key.Owner != nil {
|
||||
account, err := CurrentAccount(ctx, s.Client)
|
||||
if err != nil {
|
||||
return SSHKey{}, err
|
||||
}
|
||||
if owner := login(key.Owner); !strings.EqualFold(owner, account) {
|
||||
return SSHKey{}, fmt.Errorf("SSH key %d belongs to %s, not to %s", id, owner, account)
|
||||
}
|
||||
}
|
||||
return sshKeyFrom(key), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@ func TestSSHKeyViewCreateAndDelete(t *testing.T) {
|
||||
case http.MethodDelete:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
if r.URL.Path == "/api/v1/user" {
|
||||
_, _ = w.Write([]byte(`{"login":"alice"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(sshKeyJSON))
|
||||
}
|
||||
}))
|
||||
@@ -59,5 +63,17 @@ func TestSSHKeyViewCreateAndDelete(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 3, created.ID)
|
||||
require.NoError(t, service.Delete(context.Background(), 3))
|
||||
require.Equal(t, []string{"GET /api/v1/user/keys/3", "POST /api/v1/user/keys", "DELETE /api/v1/user/keys/3"}, requests)
|
||||
require.Equal(t, []string{"GET /api/v1/user/keys/3", "GET /api/v1/user", "POST /api/v1/user/keys", "DELETE /api/v1/user/keys/3"}, requests)
|
||||
}
|
||||
|
||||
func TestSSHKeyViewRefusesKeysOfOtherAccounts(t *testing.T) {
|
||||
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1/user" {
|
||||
_, _ = w.Write([]byte(`{"login":"admin"}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(sshKeyJSON))
|
||||
}))
|
||||
_, err := (SSHKeys{Client: client}).View(context.Background(), 3)
|
||||
require.EqualError(t, err, "SSH key 3 belongs to alice, not to admin")
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"maps"
|
||||
"net/url"
|
||||
@@ -78,6 +77,27 @@ func webhookFrom(hook *gitea.Hook, reveal bool) Webhook {
|
||||
}
|
||||
}
|
||||
|
||||
// eventGroupMembers are the events that sending issues or pull_request enables besides that event.
|
||||
var eventGroupMembers = map[string][]string{
|
||||
"issues": {"issue_assign", "issue_comment", "issue_label", "issue_milestone"},
|
||||
"pull_request": {"pull_request_assign", "pull_request_comment", "pull_request_label", "pull_request_milestone", "pull_request_review", "pull_request_review_request", "pull_request_sync"},
|
||||
}
|
||||
|
||||
// narrowedEvents turns the events Gitea reports into events that select the same set when sent
|
||||
// back. Gitea reports a hook of the issue or pull request events alone as issues or pull_request,
|
||||
// but taking those names back enables their whole group, so they become issues_only and
|
||||
// pull_request_only unless every member of the group is there too.
|
||||
func narrowedEvents(events []string) []string {
|
||||
narrowed := slices.Clone(events)
|
||||
for head, members := range eventGroupMembers {
|
||||
index := slices.Index(narrowed, head)
|
||||
if index >= 0 && slices.ContainsFunc(members, func(member string) bool { return !slices.Contains(events, member) }) {
|
||||
narrowed[index] = head + "_only"
|
||||
}
|
||||
}
|
||||
return narrowed
|
||||
}
|
||||
|
||||
// WebhookScope selects one repository, organization, account or system scope.
|
||||
type WebhookScope struct {
|
||||
Repository, Organization string
|
||||
@@ -143,7 +163,18 @@ func (s Webhooks) get(ctx context.Context, id int64) (*gitea.Hook, error) {
|
||||
}
|
||||
return c.Hooks.GetRepoHook(ctx, owner, name, id)
|
||||
})
|
||||
return hook, Classify(err, "webhook", strconv.FormatInt(id, 10), cmp.Or(s.Scope.Repository, s.Scope.Organization))
|
||||
return hook, Classify(err, "webhook", strconv.FormatInt(id, 10), s.errorScope())
|
||||
}
|
||||
|
||||
// errorScope names the repository or organization the selected scope looks in, for error messages.
|
||||
func (s Webhooks) errorScope() string {
|
||||
switch {
|
||||
case s.Scope.Global, s.Scope.Account:
|
||||
return ""
|
||||
case s.Scope.Organization != "":
|
||||
return s.Scope.Organization
|
||||
}
|
||||
return s.Scope.Repository
|
||||
}
|
||||
|
||||
// View retrieves hook metadata without exposing its delivery configuration.
|
||||
@@ -199,7 +230,7 @@ func (s Webhooks) Edit(ctx context.Context, id int64, opts WebhookOptions) (Webh
|
||||
if err != nil {
|
||||
return Webhook{}, err
|
||||
}
|
||||
edit := gitea.EditHookOption{Events: current.Events, BranchFilter: current.BranchFilter, Active: opts.Active, AuthorizationHeader: *opts.Authorization}
|
||||
edit := gitea.EditHookOption{Events: narrowedEvents(current.Events), BranchFilter: current.BranchFilter, Active: opts.Active, AuthorizationHeader: *opts.Authorization}
|
||||
if opts.Events != nil {
|
||||
edit.Events = *opts.Events
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestWebhookEditPreservesSettingsAndRedactsCredentials(t *testing.T) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, []any{"issues", "release"}, payload["events"])
|
||||
assert.Equal(t, []any{"issues_only", "release"}, payload["events"])
|
||||
assert.Equal(t, "main", payload["branch_filter"])
|
||||
assert.Equal(t, "", payload["authorization_header"])
|
||||
assert.Equal(t, false, payload["active"])
|
||||
@@ -171,3 +171,32 @@ func TestWebhookFromRedactsDeliveryURLs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookEditKeepsEventGroupsNarrow(t *testing.T) {
|
||||
issues := []string{"issue_assign", "issue_comment", "issue_label", "issue_milestone", "issues"}
|
||||
pulls := []string{"pull_request", "pull_request_assign", "pull_request_comment", "pull_request_label", "pull_request_milestone", "pull_request_review", "pull_request_review_request", "pull_request_sync"}
|
||||
require.Equal(t, []string{"push", "issues_only", "pull_request_only", "pull_request_review"}, narrowedEvents([]string{"push", "issues", "pull_request", "pull_request_review"}))
|
||||
require.Equal(t, []string{"issues_only", "issue_label"}, narrowedEvents([]string{"issues", "issue_label"}))
|
||||
require.Equal(t, issues, narrowedEvents(issues))
|
||||
require.Equal(t, pulls, narrowedEvents(pulls))
|
||||
}
|
||||
|
||||
func TestWebhookNotFoundNamesTheSelectedScope(t *testing.T) {
|
||||
client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
for name, tc := range map[string]struct {
|
||||
scope WebhookScope
|
||||
want string
|
||||
}{
|
||||
"organization": {WebhookScope{Repository: "owner/repo", Organization: "acme"}, "webhook 9 not found in acme"},
|
||||
"account": {WebhookScope{Repository: "owner/repo", Account: true}, "webhook 9 not found"},
|
||||
"global": {WebhookScope{Repository: "owner/repo", Global: true}, "webhook 9 not found"},
|
||||
"repository": {WebhookScope{Repository: "owner/repo"}, "webhook 9 not found in owner/repo"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := (Webhooks{Client: client, Scope: tc.scope}).View(context.Background(), 9)
|
||||
require.EqualError(t, err, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user