fix: address verified review findings (admin-webhooks-keys-1)

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-18 17:42:51 +02:00
1 parent 0910cf307a
commit b69019fd74
13 files changed
+268 -31

No files matched your search

+20 -4
View File
@@ -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.
+15 -5
View File
@@ -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`)
}
+5 -1
View File
@@ -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 (
+13 -1
View File
@@ -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
}
+17 -1
View File
@@ -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")
}
+34 -3
View File
@@ -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
}
+30 -1
View File
@@ -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)
})
}
}