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
+267 -30

No files matched your search

+6
View File
@@ -254,6 +254,10 @@ Renamed flags that appear on many commands:
| `--inactive` (webhook) | `--active=false` | | `--inactive` (webhook) | `--active=false` |
| `--expires` / `-x` (milestones create) | `--deadline` | | `--expires` / `-x` (milestones create) | `--deadline` |
| `--kind issue\|pull` (milestones issues) | `--kind issues\|pulls\|all` | | `--kind issue\|pull` (milestones issues) | `--kind issues\|pulls\|all` |
| `--no-admin`, `--no-restricted`, `--inactive`, `--no-allow-git-hook`, `--no-allow-import-local`, `--no-allow-create-organization` (admin users edit) | `--admin=false`, `--restricted=false`, `--active=false`, `--allow-git-hook=false`, and so on |
| `--allow-login` (admin users edit) | `--prohibit-login=false` |
| `--password-stdin`, `--no-must-change-password` (admin users) | `--password-file -`, `--must-change-password=false` |
| `-u`, `-p`, `-e` (admin users create) | the username argument or `--username`, `--password`, `--email` |
| `--out` (man) | `--output-file` / `-o` | | `--out` (man) | `--output-file` / `-o` |
Other long names changed too, mostly on `admin user`, `repo fork`, `repo edit` and Other long names changed too, mostly on `admin user`, `repo fork`, `repo edit` and
@@ -400,6 +404,8 @@ archive, or run `make docs`.
on every call, because Gitea replaces it: pass `--authorization-header`, on every call, because Gitea replaces it: pass `--authorization-header`,
`--authorization-header-file` or `--clear-authorization`. The signing secret `--authorization-header-file` or `--clear-authorization`. The signing secret
cannot be edited; recreate the hook to rotate it. cannot be edited; recreate the hook to rotate it.
- `admin user edit --password` no longer forces a password change at the next sign-in
(`admin user create` still does); add `--must-change-password` to require one.
- `notification mark-read`, `mark-unread`, `pin` and `unpin` act on the given IDs; - `notification mark-read`, `mark-unread`, `pin` and `unpin` act on the given IDs;
pass `--all` to apply them to every notification matching the filters. pass `--all` to apply them to every notification matching the filters.
- `--no-input` disables prompts, editors and browsers. With `--json`, tea never - `--no-input` disables prompts, editors and browsers. With `--json`, tea never
+9 -9
View File
@@ -185,16 +185,16 @@ $ tea admin user create ci-bot --email ci@example.com --password-file pw.txt --m
if verb == "edit" { if verb == "edit" {
stringsToBind = append(stringsToBind, stringBinding{"description", "Profile description", &description, &opts.Description}, stringBinding{"website", "Website URL", &website, &opts.Website}, stringBinding{"location", "Location", &location, &opts.Location}) stringsToBind = append(stringsToBind, stringBinding{"description", "Profile description", &description, &opts.Description}, stringBinding{"website", "Website URL", &website, &opts.Website}, stringBinding{"location", "Location", &location, &opts.Location})
boolsToBind = append(boolsToBind, boolBinding{"active", "Activate account", &active, &opts.Active}, boolBinding{"allow-git-hook", "Allow Git hooks", &allowGitHook, &opts.AllowGitHook}, boolBinding{"allow-import-local", "Allow local repository imports", &allowImportLocal, &opts.AllowImportLocal}, boolBinding{"allow-create-organization", "Allow creating organizations", &allowCreateOrganization, &opts.AllowCreateOrganization}) boolsToBind = append(boolsToBind, boolBinding{"active", "Activate account", &active, &opts.Active}, boolBinding{"allow-git-hook", "Allow Git hooks", &allowGitHook, &opts.AllowGitHook}, boolBinding{"allow-import-local", "Allow local repository imports", &allowImportLocal, &opts.AllowImportLocal}, boolBinding{"allow-create-organization", "Allow creating organizations", &allowCreateOrganization, &opts.AllowCreateOrganization})
command.Flags().IntVar(&maxRepos, "max-repo-creation", 0, "Repository creation limit (-1 for unlimited)") command.Flags().IntVar(&maxRepos, "max-repo-creation", 0, "Repository creation limit (-1 uses the instance default)")
} }
for _, binding := range stringsToBind { for _, binding := range stringsToBind {
command.Flags().StringVar(binding.value, binding.name, "", binding.description) command.Flags().StringVar(binding.value, binding.name, "", binding.description)
} }
initialVisibility := "" visibilityUsage := "Profile visibility"
if verb == "create" { if verb == "create" {
initialVisibility = "public" visibilityUsage += ", by default the instance's default"
} }
enumFlag(command, &visibility, "visibility", "", initialVisibility, "Profile visibility", visibilities...) enumFlag(command, &visibility, "visibility", "", "", visibilityUsage, visibilities...)
for _, binding := range boolsToBind { for _, binding := range boolsToBind {
command.Flags().BoolVar(binding.value, binding.name, false, binding.description+" (use =false to revoke)") command.Flags().BoolVar(binding.value, binding.name, false, binding.description+" (use =false to revoke)")
} }
@@ -215,7 +215,7 @@ $ tea admin user create ci-bot --email ci@example.com --password-file pw.txt --m
if _, err := a.requireInput(cmd, &email, "email", "Email address", validEmail); err != nil { if _, err := a.requireInput(cmd, &email, "email", "Email address", validEmail); err != nil {
return err return err
} }
opts.Email, opts.FullName, opts.Visibility = &email, &fullName, &visibility opts.Email, opts.FullName = &email, &fullName
} else { } else {
username = args[0] username = args[0]
} }
@@ -329,7 +329,7 @@ func (a *application) adminUserDeleteCommand() *cobra.Command {
Long: `Delete an account. Gitea refuses while the user owns repositories or organizations, unless Long: `Delete an account. Gitea refuses while the user owns repositories or organizations, unless
--purge deletes the account with everything it owns.`, --purge deletes the account with everything it owns.`,
Example: `$ tea admin user delete spammer Example: `$ tea admin user delete spammer
# also delete the user's repositories, packages and organization memberships # also delete the user's repositories, packages and organization memberships (and organizations they solely own)
$ tea admin user delete spammer --purge`, $ tea admin user delete spammer --purge`,
Annotations: map[string]string{"tea.text": "account deletion does not return a resource", "tea.confirm": "true"}, Annotations: map[string]string{"tea.text": "account deletion does not return a resource", "tea.confirm": "true"},
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -348,7 +348,7 @@ $ tea admin user delete spammer --purge`,
confirmation := terminal.Confirmation{Prompt: "Delete user " + user.Username + "?"} confirmation := terminal.Confirmation{Prompt: "Delete user " + user.Username + "?"}
if purge { if purge {
confirmation = terminal.Confirmation{ confirmation = terminal.Confirmation{
Prompt: fmt.Sprintf("Delete user %s with their repositories, packages and organization memberships? This cannot be undone.", user.Username), Prompt: fmt.Sprintf("Delete user %s with their repositories, packages and organization memberships, and the organizations they are the last owner of with those organizations' repositories? This cannot be undone.", user.Username),
Typed: user.Username, NonTTY: "purge user " + user.Username, Typed: user.Username, NonTTY: "purge user " + user.Username,
} }
} }
@@ -356,7 +356,7 @@ $ tea admin user delete spammer --purge`,
return err return err
} }
err = service.Delete(cmd.Context(), user.Username, purge) err = service.Delete(cmd.Context(), user.Username, purge)
if conflict, ok := errors.AsType[*services.ConflictError](err); ok && conflict.Reason == services.ReasonOwnsRepos { if conflict, ok := errors.AsType[*services.ConflictError](err); ok && (conflict.Reason == services.ReasonOwnsRepos || conflict.Reason == services.ReasonOwnsOther) {
conflict.Hint = "transfer or delete them, or pass --purge to delete them with the user" conflict.Hint = "transfer or delete them, or pass --purge to delete them with the user"
} }
if err != nil { if err != nil {
@@ -370,6 +370,6 @@ $ tea admin user delete spammer --purge`,
return nil return nil
}, },
} }
cmd.Flags().BoolVar(&purge, "purge", false, "Also delete the user's repositories, packages and organization memberships") cmd.Flags().BoolVar(&purge, "purge", false, "Also delete the user's repositories, packages, organization memberships and the organizations they solely own")
return cmd return cmd
} }
+41 -4
View File
@@ -47,7 +47,7 @@ func TestAdminUserEditSetsMustChangePasswordOnlyWhenPassed(t *testing.T) {
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
edits = append(edits, payload) edits = append(edits, payload)
} }
_, _ = w.Write([]byte(`{"id":2,"login":"bob"}`)) _, _ = w.Write([]byte(`[{"id":2,"login":"bob"}]`))
})) }))
defer server.Close() defer server.Close()
deps, _, _ := serverDeps(t, server.URL) deps, _, _ := serverDeps(t, server.URL)
@@ -85,8 +85,8 @@ func TestAdminUserDeleteOffersPurgeForOwners(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
require.Equal(t, "/api/v1/users/bob", r.URL.Path) require.Equal(t, "/api/v1/admin/users", r.URL.Path)
_, _ = w.Write([]byte(`{"id":2,"login":"bob"}`)) _, _ = w.Write([]byte(`[{"id":2,"login":"bob"}]`))
case http.MethodDelete: case http.MethodDelete:
deletes = append(deletes, r.URL.RequestURI()) deletes = append(deletes, r.URL.RequestURI())
if r.URL.Query().Get("purge") != "true" { if r.URL.Query().Get("purge") != "true" {
@@ -103,10 +103,47 @@ func TestAdminUserDeleteOffersPurgeForOwners(t *testing.T) {
require.EqualError(t, err, `user "bob" still owns repositories; transfer or delete them, or pass --purge to delete them with the user`) require.EqualError(t, err, `user "bob" still owns repositories; transfer or delete them, or pass --purge to delete them with the user`)
deps.Streams.InTTY, deps.Streams.ErrTTY = true, true deps.Streams.InTTY, deps.Streams.ErrTTY = true, true
const typed = "Delete user bob with their repositories, packages and organization memberships? This cannot be undone. Type bob to confirm:" const typed = "Delete user bob with their repositories, packages and organization memberships, and the organizations they are the last owner of with those organizations' repositories? This cannot be undone. Type bob to confirm:"
prompt := &scriptedPrompter{inputs: map[string]string{typed: "bob"}} prompt := &scriptedPrompter{inputs: map[string]string{typed: "bob"}}
deps.Prompter = prompt deps.Prompter = prompt
require.NoError(t, Execute(context.Background(), deps, []string{"admin", "user", "delete", "bob", "--purge"})) require.NoError(t, Execute(context.Background(), deps, []string{"admin", "user", "delete", "bob", "--purge"}))
require.Equal(t, []string{typed}, prompt.asked) require.Equal(t, []string{typed}, prompt.asked)
require.Equal(t, []string{"/api/v1/admin/users/bob", "/api/v1/admin/users/bob?purge=true"}, deletes) require.Equal(t, []string{"/api/v1/admin/users/bob", "/api/v1/admin/users/bob?purge=true"}, deletes)
} }
func TestAdminUserDeleteOffersPurgeForMembersAndPackageOwners(t *testing.T) {
message := "user still has membership of organizations [uid: 2]"
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`[{"id":2,"login":"bob"}]`))
return
}
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message":"` + message + `"}`))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
err := Execute(context.Background(), deps, []string{"admin", "user", "delete", "bob", "--yes"})
require.EqualError(t, err, `user "bob" is still a member of organizations or owns packages; transfer or delete them, or pass --purge to delete them with the user`)
message = "user still has ownership of packages [uid: 2]"
err = Execute(context.Background(), deps, []string{"admin", "user", "delete", "bob", "--yes"})
require.ErrorContains(t, err, "pass --purge")
}
func TestAdminUserCreateLeavesVisibilityToTheInstanceUnlessPassed(t *testing.T) {
var payloads []map[string]any
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
payloads = append(payloads, payload)
_, _ = w.Write([]byte(`{"id":1,"login":"account"}`))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
args := []string{"admin", "user", "create", "account", "--email", "account@example.test", "--password", "secret", "--no-input"}
require.NoError(t, Execute(context.Background(), deps, args))
require.NoError(t, Execute(context.Background(), deps, append(args, "--visibility", "limited")))
require.Len(t, payloads, 2)
require.Nil(t, payloads[0]["visibility"], "an omitted visibility lets DEFAULT_USER_VISIBILITY apply")
require.Equal(t, "limited", payloads[1]["visibility"])
}
+37 -1
View File
@@ -6,6 +6,7 @@ package app
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -120,11 +121,42 @@ func publicKey(data []byte, source string) (ssh.PublicKey, string, error) {
} }
key, comment, _, rest, err := ssh.ParseAuthorizedKey(data) key, comment, _, rest, err := ssh.ParseAuthorizedKey(data)
if err != nil || len(bytes.TrimSpace(rest)) != 0 { if err != nil || len(bytes.TrimSpace(rest)) != 0 {
if key, comment, ok := parseSSH2PublicKey(data); ok {
return key, comment, nil
}
return nil, "", clierr.Wrap(clierr.Usage, fmt.Errorf("%s must hold exactly one valid SSH public key", sshKeySource(source))) return nil, "", clierr.Wrap(clierr.Usage, fmt.Errorf("%s must hold exactly one valid SSH public key", sshKeySource(source)))
} }
return key, comment, nil return key, comment, nil
} }
// parseSSH2PublicKey reads the RFC 4716 format of PuTTYgen and ssh-keygen -e, which Gitea accepts
// as well. It returns the key and the Comment header without its quotes.
func parseSSH2PublicKey(data []byte) (ssh.PublicKey, string, bool) {
lines := strings.Split(strings.TrimSpace(strings.ReplaceAll(string(data), "\r", "")), "\n")
if len(lines) < 3 || lines[0] != "---- BEGIN SSH2 PUBLIC KEY ----" || lines[len(lines)-1] != "---- END SSH2 PUBLIC KEY ----" {
return nil, "", false
}
var body strings.Builder
var comment string
continued := false
for _, line := range lines[1 : len(lines)-1] {
header := continued || strings.Contains(line, ":")
continued = header && strings.HasSuffix(line, "\\")
switch {
case !header:
body.WriteString(strings.TrimSpace(line))
case strings.HasPrefix(line, "Comment:"):
comment = strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "Comment:")), `"`)
}
}
blob, err := base64.StdEncoding.DecodeString(body.String())
if err != nil {
return nil, "", false
}
key, err := ssh.ParsePublicKey(blob)
return key, comment, err == nil
}
func sshKeySource(source string) string { func sshKeySource(source string) string {
if source == "-" { if source == "-" {
return "stdin" return "stdin"
@@ -147,7 +179,7 @@ $ cat key.pub | tea ssh-key add`,
if len(args) > 0 { if len(args) > 0 {
source = args[0] source = args[0]
} }
if source == "-" && a.deps.Streams.InTTY { if len(args) == 0 && a.deps.Streams.InTTY {
return clierr.Wrap(clierr.Usage, errors.New("supply a public key file, such as ~/.ssh/id_ed25519.pub, or pipe a key to stdin")) return clierr.Wrap(clierr.Usage, errors.New("supply a public key file, such as ~/.ssh/id_ed25519.pub, or pipe a key to stdin"))
} }
data, err := readFileOrStdin(a.deps.Streams.In, source) data, err := readFileOrStdin(a.deps.Streams.In, source)
@@ -174,6 +206,10 @@ $ cat key.pub | tea ssh-key add`,
service := services.SSHKeys{Client: target.client} service := services.SSHKeys{Client: target.client}
created, err := service.Create(cmd.Context(), title, strings.TrimSpace(string(data))) created, err := service.Create(cmd.Context(), title, strings.TrimSpace(string(data)))
if err != nil { if err != nil {
if conflict, ok := errors.AsType[*services.ConflictError](err); ok && conflict.Reason == services.ReasonExists {
conflict.Hint = fmt.Sprintf("another SSH key is titled %q; pass a different --title", title)
return conflict
}
return a.registeredKey(cmd.Context(), service, key, err) return a.registeredKey(cmd.Context(), service, key, err)
} }
a.done(cmd, "Added %s", sshKeyLabel(created)) a.done(cmd, "Added %s", sshKeyLabel(created))
+39
View File
@@ -8,6 +8,7 @@ import (
"context" "context"
"crypto/ed25519" "crypto/ed25519"
"crypto/rand" "crypto/rand"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -96,3 +97,41 @@ func TestSSHKeyDeleteNamesTheKey(t *testing.T) {
require.Equal(t, []string{`Delete SSH key "uxkey" (SHA256:aR5L)?`}, prompt.confirmations) require.Equal(t, []string{`Delete SSH key "uxkey" (SHA256:aR5L)?`}, prompt.confirmations)
require.False(t, deleted) require.False(t, deleted)
} }
func TestSSHKeyAddAcceptsRFC4716KeysAndExplicitStdinOnATerminal(t *testing.T) {
line, fingerprint := testPublicKey(t, "")
key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
require.NoError(t, err)
blob := base64.StdEncoding.EncodeToString(key.Marshal())
rfc4716 := "---- BEGIN SSH2 PUBLIC KEY ----\nComment: \"putty key\"\n" + blob[:30] + "\n" + blob[30:] + "\n---- END SSH2 PUBLIC KEY ----\n"
var titles []string
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
titles = append(titles, body["title"].(string))
w.WriteHeader(http.StatusCreated)
_, _ = fmt.Fprintf(w, `{"id":3,"title":%q,"fingerprint":%q}`, body["title"], fingerprint)
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
deps.Streams.InTTY = true
deps.Streams.In = bytes.NewBufferString(rfc4716)
require.NoError(t, Execute(context.Background(), deps, []string{"ssh-key", "create", "-"}))
require.Equal(t, []string{"putty key"}, titles)
err = Execute(context.Background(), deps, []string{"ssh-key", "create"})
require.ErrorContains(t, err, "supply a public key file")
}
func TestSSHKeyAddExplainsATakenTitle(t *testing.T) {
key, _ := testPublicKey(t, "alice@laptop")
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message":"Key title has been used"}`))
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
deps.Streams.In = bytes.NewBufferString(key)
err := Execute(context.Background(), deps, []string{"ssh-key", "add"})
require.EqualError(t, err, `SSH key already exists; another SSH key is titled "alice@laptop"; pass a different --title`)
}
+2 -1
View File
@@ -261,7 +261,8 @@ Events: ` + strings.Join(services.WebhookEvents, ", ") + `; issues and pull_requ
include every issue or pull request event. include every issue or pull request event.
Types: ` + strings.Join(services.WebhookTypes, ", ") + `. Slack webhooks need Types: ` + strings.Join(services.WebhookTypes, ", ") + `. Slack webhooks need
--config channel=#name; other types take their settings through --config too.` --config channel=#name (username, icon_url and color are optional). Gitea reads no other
settings from --config, so set up discord, matrix and packagist webhooks in the web UI.`
command.Example = `$ tea webhook create https://ci.example.com/hooks/gitea --events push,pull_request command.Example = `$ tea webhook create https://ci.example.com/hooks/gitea --events push,pull_request
# sign deliveries with a secret read from a file # sign deliveries with a secret read from a file
$ tea webhook create https://ci.example.com/hooks/gitea --secret-file secret.txt $ tea webhook create https://ci.example.com/hooks/gitea --secret-file secret.txt
+19 -3
View File
@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport" "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 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) { 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) { for page := 1; ; page++ {
return c.Users.GetUserInfo(ctx, username) 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})
}) })
return user, err 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. // 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) { client := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet { if r.Method == http.MethodGet {
reads++ 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 { 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 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 return
} }
mutations++ mutations++
@@ -65,7 +66,7 @@ func TestAdminCreateAppliesRestrictionsAndReloads(t *testing.T) {
require.Equal(t, true, payload["restricted"]) require.Equal(t, true, payload["restricted"])
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
default: default:
_, _ = w.Write([]byte(`{"id":4,"login":"new-account","restricted":true}`)) _, _ = w.Write([]byte(`[{"id":4,"login":"new-account","restricted":true}]`))
} }
})) }))
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}) 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.NoError(t, err)
require.True(t, item.Restricted) 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) { func TestAdminCreateReportsFollowupFailureWithoutRetrying(t *testing.T) {
@@ -108,3 +109,12 @@ func TestAdminUserFromMapsServerFields(t *testing.T) {
Visibility: "limited", CreatedAt: "2026-10-01T12:00:00+02:00", Visibility: "limited", CreatedAt: "2026-10-01T12:00:00+02:00",
}, adminUserFrom(user)) }, 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 ReasonNotDone = "not done" // the run has not finished yet
ReasonKeyInUse = "key in use" // the SSH key is registered already ReasonKeyInUse = "key in use" // the SSH key is registered already
ReasonOwnsRepos = "owns repositories" // the user still owns repositories 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 // 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" text = "this SSH key is already registered"
case ReasonOwnsRepos: case ReasonOwnsRepos:
text = cmp.Or(subject, "the user") + " still owns repositories" 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: default:
text = e.Err.Error() text = e.Err.Error()
} }
@@ -153,7 +156,8 @@ var refusals = []struct {
{ReasonNotDone, []int{http.StatusBadRequest}, []string{"not done"}}, {ReasonNotDone, []int{http.StatusBadRequest}, []string{"not done"}},
{ReasonKeyInUse, nil, []string{"key content has been used"}}, {ReasonKeyInUse, nil, []string{"key content has been used"}},
{ReasonOwnsRepos, nil, []string{"ownership of repositories"}}, {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 ( var (
+13 -1
View File
@@ -5,7 +5,9 @@ package services
import ( import (
"context" "context"
"fmt"
"strconv" "strconv"
"strings"
gitea "gitea.dev/sdk" gitea "gitea.dev/sdk"
"gitea.dev/tea/internal/transport" "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 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) { 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) { key, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.PublicKey, *gitea.Response, error) {
return c.Users.GetPublicKey(ctx, id) return c.Users.GetPublicKey(ctx, id)
@@ -44,6 +47,15 @@ func (s SSHKeys) View(ctx context.Context, id int64) (SSHKey, error) {
if err != nil { if err != nil {
return SSHKey{}, Classify(err, "SSH key", strconv.FormatInt(id, 10), "") 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 return sshKeyFrom(key), nil
} }
+17 -1
View File
@@ -48,6 +48,10 @@ func TestSSHKeyViewCreateAndDelete(t *testing.T) {
case http.MethodDelete: case http.MethodDelete:
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
default: default:
if r.URL.Path == "/api/v1/user" {
_, _ = w.Write([]byte(`{"login":"alice"}`))
return
}
_, _ = w.Write([]byte(sshKeyJSON)) _, _ = w.Write([]byte(sshKeyJSON))
} }
})) }))
@@ -59,5 +63,17 @@ func TestSSHKeyViewCreateAndDelete(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.EqualValues(t, 3, created.ID) require.EqualValues(t, 3, created.ID)
require.NoError(t, service.Delete(context.Background(), 3)) 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 package services
import ( import (
"cmp"
"context" "context"
"maps" "maps"
"net/url" "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. // WebhookScope selects one repository, organization, account or system scope.
type WebhookScope struct { type WebhookScope struct {
Repository, Organization string 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 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. // 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 { if err != nil {
return Webhook{}, err 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 { if opts.Events != nil {
edit.Events = *opts.Events edit.Events = *opts.Events
} }
+30 -1
View File
@@ -34,7 +34,7 @@ func TestWebhookEditPreservesSettingsAndRedactsCredentials(t *testing.T) {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
return 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, "main", payload["branch_filter"])
assert.Equal(t, "", payload["authorization_header"]) assert.Equal(t, "", payload["authorization_header"])
assert.Equal(t, false, payload["active"]) 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)
})
}
}