feat(users): add access token and user key endpoints
Some checks failed
Security Scan / security (push) Successful in 13s
Test / test (push) Has been cancelled

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 06:02:22 +00:00
parent 779d017ad9
commit 1a8fdf53ef
2 changed files with 276 additions and 0 deletions

View file

@ -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)

View file

@ -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 {