From 1a8fdf53efc18b5b3175bdae5e6ebe5039652f7d Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 06:02:22 +0000 Subject: [PATCH] feat(users): add access token and user key endpoints Co-Authored-By: Virgil --- users.go | 88 +++++++++++++++++++++++ users_test.go | 188 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) diff --git a/users.go b/users.go index 6162185..a9de6ed 100644 --- a/users.go +++ b/users.go @@ -305,6 +305,36 @@ func (s *UserService) DeleteKey(ctx context.Context, id int64) error { return s.client.Delete(ctx, path) } +// ListUserKeys returns all public keys for a user. +func (s *UserService) ListUserKeys(ctx context.Context, username string, filters ...UserKeyListOptions) ([]types.PublicKey, error) { + path := ResolvePath("/api/v1/users/{username}/keys", pathParams("username", username)) + query := make(map[string]string, len(filters)) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + query = nil + } + return ListAll[types.PublicKey](ctx, s.client, path, query) +} + +// IterUserKeys returns an iterator over all public keys for a user. +func (s *UserService) IterUserKeys(ctx context.Context, username string, filters ...UserKeyListOptions) iter.Seq2[types.PublicKey, error] { + path := ResolvePath("/api/v1/users/{username}/keys", pathParams("username", username)) + query := make(map[string]string, len(filters)) + for _, filter := range filters { + for key, value := range filter.queryParams() { + query[key] = value + } + } + if len(query) == 0 { + query = nil + } + return ListIter[types.PublicKey](ctx, s.client, path, query) +} + // ListGPGKeys returns all GPG keys owned by the authenticated user. func (s *UserService) ListGPGKeys(ctx context.Context) ([]types.GPGKey, error) { return ListAll[types.GPGKey](ctx, s.client, "/api/v1/user/gpg_keys", nil) @@ -340,6 +370,64 @@ func (s *UserService) DeleteGPGKey(ctx context.Context, id int64) error { return s.client.Delete(ctx, path) } +// ListUserGPGKeys returns all GPG keys for a user. +func (s *UserService) ListUserGPGKeys(ctx context.Context, username string) ([]types.GPGKey, error) { + path := ResolvePath("/api/v1/users/{username}/gpg_keys", pathParams("username", username)) + return ListAll[types.GPGKey](ctx, s.client, path, nil) +} + +// IterUserGPGKeys returns an iterator over all GPG keys for a user. +func (s *UserService) IterUserGPGKeys(ctx context.Context, username string) iter.Seq2[types.GPGKey, error] { + path := ResolvePath("/api/v1/users/{username}/gpg_keys", pathParams("username", username)) + return ListIter[types.GPGKey](ctx, s.client, path, nil) +} + +// GetGPGKeyVerificationToken returns the token used to verify a GPG key. +func (s *UserService) GetGPGKeyVerificationToken(ctx context.Context) (string, error) { + data, err := s.client.GetRaw(ctx, "/api/v1/user/gpg_key_token") + if err != nil { + return "", err + } + return string(data), nil +} + +// VerifyGPGKey verifies a GPG key for the authenticated user. +func (s *UserService) VerifyGPGKey(ctx context.Context) (*types.GPGKey, error) { + var out types.GPGKey + if err := s.client.Post(ctx, "/api/v1/user/gpg_key_verify", nil, &out); err != nil { + return nil, err + } + return &out, nil +} + +// ListTokens returns all access tokens for a user. +func (s *UserService) ListTokens(ctx context.Context, username string) ([]types.AccessToken, error) { + path := ResolvePath("/api/v1/users/{username}/tokens", pathParams("username", username)) + return ListAll[types.AccessToken](ctx, s.client, path, nil) +} + +// IterTokens returns an iterator over all access tokens for a user. +func (s *UserService) IterTokens(ctx context.Context, username string) iter.Seq2[types.AccessToken, error] { + path := ResolvePath("/api/v1/users/{username}/tokens", pathParams("username", username)) + return ListIter[types.AccessToken](ctx, s.client, path, nil) +} + +// CreateToken creates an access token for a user. +func (s *UserService) CreateToken(ctx context.Context, username string, opts *types.CreateAccessTokenOption) (*types.AccessToken, error) { + path := ResolvePath("/api/v1/users/{username}/tokens", pathParams("username", username)) + var out types.AccessToken + if err := s.client.Post(ctx, path, opts, &out); err != nil { + return nil, err + } + return &out, nil +} + +// DeleteToken deletes an access token for a user. +func (s *UserService) DeleteToken(ctx context.Context, username, token string) error { + path := ResolvePath("/api/v1/users/{username}/tokens/{token}", pathParams("username", username, "token", token)) + return s.client.Delete(ctx, path) +} + // ListOAuth2Applications returns all OAuth2 applications owned by the authenticated user. func (s *UserService) ListOAuth2Applications(ctx context.Context) ([]types.OAuth2Application, error) { return ListAll[types.OAuth2Application](ctx, s.client, "/api/v1/user/applications/oauth2", nil) diff --git a/users_test.go b/users_test.go index a7c1936..0488800 100644 --- a/users_test.go +++ b/users_test.go @@ -1128,6 +1128,194 @@ func TestUserService_DeleteGPGKey_Good(t *testing.T) { } } +func TestUserService_GetGPGKeyVerificationToken_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/gpg_key_token" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Write([]byte("verification-token")) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + token, err := f.Users.GetGPGKeyVerificationToken(context.Background()) + if err != nil { + t.Fatal(err) + } + if token != "verification-token" { + t.Errorf("got token=%q, want %q", token, "verification-token") + } +} + +func TestUserService_VerifyGPGKey_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/user/gpg_key_verify" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.Header.Get("Content-Type"); got != "" { + t.Errorf("unexpected content type: %q", got) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.GPGKey{ + ID: 12, + KeyID: "QRST7890", + Verified: true, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + key, err := f.Users.VerifyGPGKey(context.Background()) + if err != nil { + t.Fatal(err) + } + if key.ID != 12 || key.KeyID != "QRST7890" || !key.Verified { + t.Errorf("unexpected key: %+v", key) + } +} + +func TestUserService_ListTokens_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/tokens" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "2") + json.NewEncoder(w).Encode([]types.AccessToken{ + {ID: 1, Name: "ci", Scopes: []string{"repo"}}, + {ID: 2, Name: "deploy", Scopes: []string{"read:packages"}}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + tokens, err := f.Users.ListTokens(context.Background(), "alice") + if err != nil { + t.Fatal(err) + } + if len(tokens) != 2 || tokens[0].Name != "ci" || tokens[1].Name != "deploy" { + t.Fatalf("unexpected tokens: %+v", tokens) + } +} + +func TestUserService_CreateToken_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/tokens" { + t.Errorf("wrong path: %s", r.URL.Path) + } + var body types.CreateAccessTokenOption + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Name != "ci" || len(body.Scopes) != 1 || body.Scopes[0] != "repo" { + t.Fatalf("unexpected body: %+v", body) + } + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(types.AccessToken{ + ID: 7, + Name: body.Name, + Scopes: body.Scopes, + Token: "abcdef0123456789", + TokenLastEight: "456789", + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + token, err := f.Users.CreateToken(context.Background(), "alice", &types.CreateAccessTokenOption{ + Name: "ci", + Scopes: []string{"repo"}, + }) + if err != nil { + t.Fatal(err) + } + if token.ID != 7 || token.Name != "ci" || token.Token != "abcdef0123456789" { + t.Fatalf("unexpected token: %+v", token) + } +} + +func TestUserService_DeleteToken_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/tokens/ci" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + if err := f.Users.DeleteToken(context.Background(), "alice", "ci"); err != nil { + t.Fatal(err) + } +} + +func TestUserService_ListUserKeys_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/keys" { + t.Errorf("wrong path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("fingerprint"); got != "abc123" { + t.Errorf("wrong fingerprint: %s", got) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.PublicKey{ + {ID: 4, Title: "laptop", Fingerprint: "abc123"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + keys, err := f.Users.ListUserKeys(context.Background(), "alice", UserKeyListOptions{Fingerprint: "abc123"}) + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 || keys[0].Title != "laptop" { + t.Fatalf("unexpected keys: %+v", keys) + } +} + +func TestUserService_ListUserGPGKeys_Good(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/api/v1/users/alice/gpg_keys" { + t.Errorf("wrong path: %s", r.URL.Path) + } + w.Header().Set("X-Total-Count", "1") + json.NewEncoder(w).Encode([]types.GPGKey{ + {ID: 8, KeyID: "ABCD1234"}, + }) + })) + defer srv.Close() + + f := NewForge(srv.URL, "tok") + keys, err := f.Users.ListUserGPGKeys(context.Background(), "alice") + if err != nil { + t.Fatal(err) + } + if len(keys) != 1 || keys[0].KeyID != "ABCD1234" { + t.Fatalf("unexpected gpg keys: %+v", keys) + } +} + func TestUserService_ListOAuth2Applications_Good(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet {