diff --git a/go.mod b/go.mod index bc2bf41..8a0a413 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/KubeRocketCI/gitfusion -go 1.24.2 +go 1.25.0 require ( github.com/epam/edp-codebase-operator/v2 v2.27.2 @@ -9,12 +9,12 @@ require ( github.com/go-chi/httplog/v2 v2.1.1 github.com/go-resty/resty/v2 v2.17.1 github.com/google/go-github/v72 v72.0.0 - github.com/ktrysmt/go-bitbucket v0.9.85 + github.com/ktrysmt/go-bitbucket v0.9.95 github.com/oapi-codegen/runtime v1.1.1 - github.com/stretchr/testify v1.10.0 + github.com/stretchr/testify v1.11.1 github.com/viccon/sturdyc v1.1.5 gitlab.com/gitlab-org/api/client-go v0.128.0 - golang.org/x/sync v0.16.0 + golang.org/x/sync v0.20.0 k8s.io/api v0.32.1 k8s.io/apimachinery v0.32.1 k8s.io/client-go v0.32.1 @@ -60,11 +60,11 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/net v0.43.0 // indirect - golang.org/x/oauth2 v0.29.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/term v0.34.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.12.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/protobuf v1.35.1 // indirect diff --git a/go.sum b/go.sum index 25861ba..4176de5 100644 --- a/go.sum +++ b/go.sum @@ -94,10 +94,13 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ktrysmt/go-bitbucket v0.9.85 h1:WSKYSmpgasEmtnsr+TEhD2UtiZjCZpeTBF5T4f6/d8k= github.com/ktrysmt/go-bitbucket v0.9.85/go.mod h1:ZgvxUOaC6eHrNaC/DbjFvJUXaKpKeDYvfhh4U592jcs= +github.com/ktrysmt/go-bitbucket v0.9.95 h1:joEljTpnIML5ygjEJMArYeotk+D+YFOkgCS71jhhc2s= +github.com/ktrysmt/go-bitbucket v0.9.95/go.mod h1:r/8tIhy008ze0ODPhq04gPMXESwqdXo4jBIQDmByhko= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= @@ -142,6 +145,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/viccon/sturdyc v1.1.5 h1:GLQDnsyKt3L/tpdWCIARIRefn+5DAyvqu+0irBwt+vk= github.com/viccon/sturdyc v1.1.5/go.mod h1:OCBEgG/i48uugKQ498UQlfMHmf5j8MYY8a4BApfVnMo= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= @@ -167,24 +172,36 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -193,6 +210,7 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/services/bitbucket/bitbucket.go b/internal/services/bitbucket/bitbucket.go index 84c6ecb..20ef2ee 100644 --- a/internal/services/bitbucket/bitbucket.go +++ b/internal/services/bitbucket/bitbucket.go @@ -60,7 +60,10 @@ func (b *BitbucketService) GetRepository( return nil, err } - client := bitbucket.NewBasicAuth(username, password) + client, err := bitbucket.NewBasicAuth(username, password) + if err != nil { + return nil, fmt.Errorf("failed to create bitbucket client: %w", err) + } repoOptions := &bitbucket.RepositoryOptions{ Owner: owner, @@ -90,7 +93,11 @@ func (b *BitbucketService) ListRepositories( return nil, err } - client := bitbucket.NewBasicAuth(username, password) + client, err := bitbucket.NewBasicAuth(username, password) + if err != nil { + return nil, fmt.Errorf("failed to create bitbucket client: %w", err) + } + repoOptions := &bitbucket.RepositoriesOptions{ Owner: account, Keyword: listOptions.Name, @@ -113,9 +120,39 @@ func (b *BitbucketService) ListRepositories( return result, nil } -// ListUserOrganizations returns workspaces for the authenticated user using go-bitbucket client +type bitbucketWorkspacesResponse struct { + Size int `json:"size"` + Page int `json:"page"` + Pagelen int `json:"pagelen"` + Values []bitbucketWorkspaceAccess `json:"values"` +} + +// bitbucketWorkspaceAccess represents an item in the /user/workspaces response. +// The API returns workspace_access objects where the workspace data is nested +// under the "workspace" key. +type bitbucketWorkspaceAccess struct { + Workspace bitbucketWorkspace `json:"workspace"` +} + +type bitbucketWorkspace struct { + UUID string `json:"uuid"` + Slug string `json:"slug"` +} + +// ListUserOrganizations returns workspaces for the authenticated user using a direct HTTP call +// to the Bitbucket REST API. +// +// Why direct HTTP instead of go-bitbucket client.Workspaces.List(): +// - The go-bitbucket library (v0.9.95) calls the deprecated /2.0/workspaces endpoint, which +// Bitbucket removed as announced in: +// https://developer.atlassian.com/cloud/bitbucket/changelog/#CHANGE-2770 +// https://developer.atlassian.com/cloud/bitbucket/changelog/#CHANGE-3022 +// - The correct endpoint is now /2.0/user/workspaces (requires authentication): +// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-workspaces/#api-user-workspaces-get +// - The library has not been updated to support the new endpoint, so we call it directly +// using the same go-resty HTTP client used by ListPullRequests and ListPipelines. func (b *BitbucketService) ListUserOrganizations( - _ context.Context, + ctx context.Context, settings krci.GitServerSettings, ) ([]models.Organization, error) { username, password, err := decodeBitbucketToken(settings.Token) @@ -123,22 +160,36 @@ func (b *BitbucketService) ListUserOrganizations( return nil, err } - client := bitbucket.NewBasicAuth(username, password) + apiURL := defaultBitbucketAPIURL + "/user/workspaces" - workspaces, err := client.Workspaces.List() + var bbResp bitbucketWorkspacesResponse + + resp, err := b.httpClient.R(). + SetContext(ctx). + SetBasicAuth(username, password). + SetResult(&bbResp). + Get(apiURL) if err != nil { return nil, fmt.Errorf("failed to list workspaces: %w", err) } - result := make([]models.Organization, 0, len(workspaces.Workspaces)) + if resp.StatusCode() == http.StatusUnauthorized || resp.StatusCode() == http.StatusForbidden { + return nil, fmt.Errorf("invalid credentials: %w", gferrors.ErrUnauthorized) + } - for _, ws := range workspaces.Workspaces { - org := models.Organization{ - Id: ws.UUID, - Name: ws.Name, - } + if resp.IsError() { + return nil, fmt.Errorf("failed to list workspaces: status %d, body: %s", resp.StatusCode(), resp.String()) + } + + result := make([]models.Organization, 0, len(bbResp.Values)) - result = append(result, org) + for _, wa := range bbResp.Values { + ws := wa.Workspace + + result = append(result, models.Organization{ + Id: ws.UUID, + Name: ws.Slug, + }) } return result, nil @@ -156,7 +207,11 @@ func (b *BitbucketService) ListBranches( return nil, fmt.Errorf("failed to decode bitbucket token: %w", err) } - client := bitbucket.NewBasicAuth(username, password) + client, err := bitbucket.NewBasicAuth(username, password) + if err != nil { + return nil, fmt.Errorf("failed to create bitbucket client: %w", err) + } + branchOptions := &bitbucket.RepositoryBranchOptions{ Owner: owner, RepoSlug: repo, diff --git a/internal/services/bitbucket/bitbucket_organizations_test.go b/internal/services/bitbucket/bitbucket_organizations_test.go new file mode 100644 index 0000000..2dcd63c --- /dev/null +++ b/internal/services/bitbucket/bitbucket_organizations_test.go @@ -0,0 +1,173 @@ +package bitbucket + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + gferrors "github.com/KubeRocketCI/gitfusion/internal/errors" + "github.com/KubeRocketCI/gitfusion/internal/services/krci" +) + +func TestBitbucketServiceListUserOrganizationsSuccess(t *testing.T) { + responseBody := bitbucketWorkspacesResponse{ + Size: 2, + Page: 1, + Pagelen: 10, + Values: []bitbucketWorkspaceAccess{ + {Workspace: bitbucketWorkspace{UUID: "{aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb}", Slug: "my-workspace"}}, + {Workspace: bitbucketWorkspace{UUID: "{cccccccc-4444-5555-6666-dddddddddddd}", Slug: "another-workspace"}}, + }, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/2.0/user/workspaces", r.URL.Path) + assert.NotEmpty(t, r.Header.Get("Authorization")) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + body, _ := json.Marshal(responseBody) + _, _ = w.Write(body) + })) + defer server.Close() + + svc := &BitbucketService{ + httpClient: resty.New().SetTransport(&redirectTransport{ + target: server.URL, + wrapped: http.DefaultTransport, + }), + } + + result, err := svc.ListUserOrganizations(context.Background(), krci.GitServerSettings{ + Token: testBitbucketToken(), + }) + + require.NoError(t, err) + require.Len(t, result, 2) + assert.Equal(t, "{aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb}", result[0].Id) + assert.Equal(t, "my-workspace", result[0].Name) + assert.Equal(t, "{cccccccc-4444-5555-6666-dddddddddddd}", result[1].Id) + assert.Equal(t, "another-workspace", result[1].Name) +} + +func TestBitbucketServiceListUserOrganizationsEmpty(t *testing.T) { + responseBody := bitbucketWorkspacesResponse{ + Size: 0, + Page: 1, + Pagelen: 10, + Values: []bitbucketWorkspaceAccess{}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + body, _ := json.Marshal(responseBody) + _, _ = w.Write(body) + })) + defer server.Close() + + svc := &BitbucketService{ + httpClient: resty.New().SetTransport(&redirectTransport{ + target: server.URL, + wrapped: http.DefaultTransport, + }), + } + + result, err := svc.ListUserOrganizations(context.Background(), krci.GitServerSettings{ + Token: testBitbucketToken(), + }) + + require.NoError(t, err) + assert.NotNil(t, result) + assert.Len(t, result, 0) +} + +func TestBitbucketServiceListUserOrganizationsUnauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + svc := &BitbucketService{ + httpClient: resty.New().SetTransport(&redirectTransport{ + target: server.URL, + wrapped: http.DefaultTransport, + }), + } + + result, err := svc.ListUserOrganizations(context.Background(), krci.GitServerSettings{ + Token: testBitbucketToken(), + }) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, errors.Is(err, gferrors.ErrUnauthorized)) +} + +func TestBitbucketServiceListUserOrganizationsForbidden(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + svc := &BitbucketService{ + httpClient: resty.New().SetTransport(&redirectTransport{ + target: server.URL, + wrapped: http.DefaultTransport, + }), + } + + result, err := svc.ListUserOrganizations(context.Background(), krci.GitServerSettings{ + Token: testBitbucketToken(), + }) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, errors.Is(err, gferrors.ErrUnauthorized)) +} + +func TestBitbucketServiceListUserOrganizationsServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error": "internal server error"}`)) + })) + defer server.Close() + + svc := &BitbucketService{ + httpClient: resty.New().SetTransport(&redirectTransport{ + target: server.URL, + wrapped: http.DefaultTransport, + }), + } + + result, err := svc.ListUserOrganizations(context.Background(), krci.GitServerSettings{ + Token: testBitbucketToken(), + }) + + assert.Nil(t, result) + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestBitbucketServiceListUserOrganizationsInvalidToken(t *testing.T) { + svc := &BitbucketService{ + httpClient: resty.New(), + } + + result, err := svc.ListUserOrganizations(context.Background(), krci.GitServerSettings{ + Token: "not-valid-base64!!!", + }) + + assert.Nil(t, result) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to decode token") +}