From 7d2d2a00941c1bfb6f0cf1845b00007f264f34a3 Mon Sep 17 00:00:00 2001 From: Indradhanush Gupta Date: Fri, 3 Mar 2023 15:09:43 +0530 Subject: [PATCH] azuredevops: Add client method ListAuthorizedUserOrganizations (#48572) --- .../internal/batches/sources/mocks_test.go | 129 ++++++++++++++++++ internal/extsvc/azuredevops/client.go | 1 + internal/extsvc/azuredevops/client_test.go | 11 +- .../ListAuthorizedUserOrganizations.json | 22 +++ .../vcr/ListAuthorizedUserOrganizations.yaml | 113 +++++++++++++++ internal/extsvc/azuredevops/types.go | 11 ++ internal/extsvc/azuredevops/users.go | 30 ++++ internal/extsvc/azuredevops/users_test.go | 26 ++++ 8 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 internal/extsvc/azuredevops/testdata/golden/ListAuthorizedUserOrganizations.json create mode 100644 internal/extsvc/azuredevops/testdata/vcr/ListAuthorizedUserOrganizations.yaml diff --git a/enterprise/internal/batches/sources/mocks_test.go b/enterprise/internal/batches/sources/mocks_test.go index 4a41f32a597..b25d891d2e6 100644 --- a/enterprise/internal/batches/sources/mocks_test.go +++ b/enterprise/internal/batches/sources/mocks_test.go @@ -5728,6 +5728,10 @@ type MockAzureDevOpsClient struct { // IsAzureDevOpsServicesFunc is an instance of a mock function object // controlling the behavior of the method IsAzureDevOpsServices. IsAzureDevOpsServicesFunc *AzureDevOpsClientIsAzureDevOpsServicesFunc + // ListAuthorizedUserOrganizationsFunc is an instance of a mock function + // object controlling the behavior of the method + // ListAuthorizedUserOrganizations. + ListAuthorizedUserOrganizationsFunc *AzureDevOpsClientListAuthorizedUserOrganizationsFunc // ListRepositoriesByProjectOrOrgFunc is an instance of a mock function // object controlling the behavior of the method // ListRepositoriesByProjectOrOrg. @@ -5814,6 +5818,11 @@ func NewMockAzureDevOpsClient() *MockAzureDevOpsClient { return }, }, + ListAuthorizedUserOrganizationsFunc: &AzureDevOpsClientListAuthorizedUserOrganizationsFunc{ + defaultHook: func(context.Context, azuredevops.Profile) (r0 []azuredevops.Org, r1 error) { + return + }, + }, ListRepositoriesByProjectOrOrgFunc: &AzureDevOpsClientListRepositoriesByProjectOrOrgFunc{ defaultHook: func(context.Context, azuredevops.ListRepositoriesByProjectOrOrgArgs) (r0 []azuredevops.Repository, r1 error) { return @@ -5906,6 +5915,11 @@ func NewStrictMockAzureDevOpsClient() *MockAzureDevOpsClient { panic("unexpected invocation of MockAzureDevOpsClient.IsAzureDevOpsServices") }, }, + ListAuthorizedUserOrganizationsFunc: &AzureDevOpsClientListAuthorizedUserOrganizationsFunc{ + defaultHook: func(context.Context, azuredevops.Profile) ([]azuredevops.Org, error) { + panic("unexpected invocation of MockAzureDevOpsClient.ListAuthorizedUserOrganizations") + }, + }, ListRepositoriesByProjectOrOrgFunc: &AzureDevOpsClientListRepositoriesByProjectOrOrgFunc{ defaultHook: func(context.Context, azuredevops.ListRepositoriesByProjectOrOrgArgs) ([]azuredevops.Repository, error) { panic("unexpected invocation of MockAzureDevOpsClient.ListRepositoriesByProjectOrOrg") @@ -5971,6 +5985,9 @@ func NewMockAzureDevOpsClientFrom(i azuredevops.Client) *MockAzureDevOpsClient { IsAzureDevOpsServicesFunc: &AzureDevOpsClientIsAzureDevOpsServicesFunc{ defaultHook: i.IsAzureDevOpsServices, }, + ListAuthorizedUserOrganizationsFunc: &AzureDevOpsClientListAuthorizedUserOrganizationsFunc{ + defaultHook: i.ListAuthorizedUserOrganizations, + }, ListRepositoriesByProjectOrOrgFunc: &AzureDevOpsClientListRepositoriesByProjectOrOrgFunc{ defaultHook: i.ListRepositoriesByProjectOrOrg, }, @@ -7522,6 +7539,118 @@ func (c AzureDevOpsClientIsAzureDevOpsServicesFuncCall) Results() []interface{} return []interface{}{c.Result0} } +// AzureDevOpsClientListAuthorizedUserOrganizationsFunc describes the +// behavior when the ListAuthorizedUserOrganizations method of the parent +// MockAzureDevOpsClient instance is invoked. +type AzureDevOpsClientListAuthorizedUserOrganizationsFunc struct { + defaultHook func(context.Context, azuredevops.Profile) ([]azuredevops.Org, error) + hooks []func(context.Context, azuredevops.Profile) ([]azuredevops.Org, error) + history []AzureDevOpsClientListAuthorizedUserOrganizationsFuncCall + mutex sync.Mutex +} + +// ListAuthorizedUserOrganizations delegates to the next hook function in +// the queue and stores the parameter and result values of this invocation. +func (m *MockAzureDevOpsClient) ListAuthorizedUserOrganizations(v0 context.Context, v1 azuredevops.Profile) ([]azuredevops.Org, error) { + r0, r1 := m.ListAuthorizedUserOrganizationsFunc.nextHook()(v0, v1) + m.ListAuthorizedUserOrganizationsFunc.appendCall(AzureDevOpsClientListAuthorizedUserOrganizationsFuncCall{v0, v1, r0, r1}) + return r0, r1 +} + +// SetDefaultHook sets function that is called when the +// ListAuthorizedUserOrganizations method of the parent +// MockAzureDevOpsClient instance is invoked and the hook queue is empty. +func (f *AzureDevOpsClientListAuthorizedUserOrganizationsFunc) SetDefaultHook(hook func(context.Context, azuredevops.Profile) ([]azuredevops.Org, error)) { + f.defaultHook = hook +} + +// PushHook adds a function to the end of hook queue. Each invocation of the +// ListAuthorizedUserOrganizations method of the parent +// MockAzureDevOpsClient instance invokes the hook at the front of the queue +// and discards it. After the queue is empty, the default hook function is +// invoked for any future action. +func (f *AzureDevOpsClientListAuthorizedUserOrganizationsFunc) PushHook(hook func(context.Context, azuredevops.Profile) ([]azuredevops.Org, error)) { + f.mutex.Lock() + f.hooks = append(f.hooks, hook) + f.mutex.Unlock() +} + +// SetDefaultReturn calls SetDefaultHook with a function that returns the +// given values. +func (f *AzureDevOpsClientListAuthorizedUserOrganizationsFunc) SetDefaultReturn(r0 []azuredevops.Org, r1 error) { + f.SetDefaultHook(func(context.Context, azuredevops.Profile) ([]azuredevops.Org, error) { + return r0, r1 + }) +} + +// PushReturn calls PushHook with a function that returns the given values. +func (f *AzureDevOpsClientListAuthorizedUserOrganizationsFunc) PushReturn(r0 []azuredevops.Org, r1 error) { + f.PushHook(func(context.Context, azuredevops.Profile) ([]azuredevops.Org, error) { + return r0, r1 + }) +} + +func (f *AzureDevOpsClientListAuthorizedUserOrganizationsFunc) nextHook() func(context.Context, azuredevops.Profile) ([]azuredevops.Org, error) { + f.mutex.Lock() + defer f.mutex.Unlock() + + if len(f.hooks) == 0 { + return f.defaultHook + } + + hook := f.hooks[0] + f.hooks = f.hooks[1:] + return hook +} + +func (f *AzureDevOpsClientListAuthorizedUserOrganizationsFunc) appendCall(r0 AzureDevOpsClientListAuthorizedUserOrganizationsFuncCall) { + f.mutex.Lock() + f.history = append(f.history, r0) + f.mutex.Unlock() +} + +// History returns a sequence of +// AzureDevOpsClientListAuthorizedUserOrganizationsFuncCall objects +// describing the invocations of this function. +func (f *AzureDevOpsClientListAuthorizedUserOrganizationsFunc) History() []AzureDevOpsClientListAuthorizedUserOrganizationsFuncCall { + f.mutex.Lock() + history := make([]AzureDevOpsClientListAuthorizedUserOrganizationsFuncCall, len(f.history)) + copy(history, f.history) + f.mutex.Unlock() + + return history +} + +// AzureDevOpsClientListAuthorizedUserOrganizationsFuncCall is an object +// that describes an invocation of method ListAuthorizedUserOrganizations on +// an instance of MockAzureDevOpsClient. +type AzureDevOpsClientListAuthorizedUserOrganizationsFuncCall struct { + // Arg0 is the value of the 1st argument passed to this method + // invocation. + Arg0 context.Context + // Arg1 is the value of the 2nd argument passed to this method + // invocation. + Arg1 azuredevops.Profile + // Result0 is the value of the 1st result returned from this method + // invocation. + Result0 []azuredevops.Org + // Result1 is the value of the 2nd result returned from this method + // invocation. + Result1 error +} + +// Args returns an interface slice containing the arguments of this +// invocation. +func (c AzureDevOpsClientListAuthorizedUserOrganizationsFuncCall) Args() []interface{} { + return []interface{}{c.Arg0, c.Arg1} +} + +// Results returns an interface slice containing the results of this +// invocation. +func (c AzureDevOpsClientListAuthorizedUserOrganizationsFuncCall) Results() []interface{} { + return []interface{}{c.Result0, c.Result1} +} + // AzureDevOpsClientListRepositoriesByProjectOrOrgFunc describes the // behavior when the ListRepositoriesByProjectOrOrg method of the parent // MockAzureDevOpsClient instance is invoked. diff --git a/internal/extsvc/azuredevops/client.go b/internal/extsvc/azuredevops/client.go index fadb090fdf3..b8c463301e5 100644 --- a/internal/extsvc/azuredevops/client.go +++ b/internal/extsvc/azuredevops/client.go @@ -46,6 +46,7 @@ type Client interface { GetRepositoryBranch(ctx context.Context, args OrgProjectRepoArgs, branchName string) (Ref, error) GetProject(ctx context.Context, org, project string) (Project, error) GetAuthorizedProfile(ctx context.Context) (Profile, error) + ListAuthorizedUserOrganizations(ctx context.Context, profile Profile) ([]Org, error) } type client struct { diff --git a/internal/extsvc/azuredevops/client_test.go b/internal/extsvc/azuredevops/client_test.go index b489c3fcd6b..d56e00b6935 100644 --- a/internal/extsvc/azuredevops/client_test.go +++ b/internal/extsvc/azuredevops/client_test.go @@ -4,6 +4,7 @@ import ( "flag" "net/http" "net/url" + "os" "path/filepath" "testing" @@ -33,7 +34,15 @@ func NewTestClient(t testing.TB, name string, update bool) (Client, func()) { t.Fatal(err) } - cli, err := NewClient("urn", "https://dev.azure.com", &auth.BasicAuth{Username: "testuser", Password: "testtoken"}, hc) + cli, err := NewClient( + "urn", + AzureDevOpsAPIURL, + &auth.BasicAuth{ + Username: os.Getenv("AZURE_DEV_OPS_USERNAME"), + Password: os.Getenv("AZURE_DEV_OPS_TOKEN"), + }, + hc, + ) if err != nil { t.Fatal(err) } diff --git a/internal/extsvc/azuredevops/testdata/golden/ListAuthorizedUserOrganizations.json b/internal/extsvc/azuredevops/testdata/golden/ListAuthorizedUserOrganizations.json new file mode 100644 index 00000000000..b71d4054c3e --- /dev/null +++ b/internal/extsvc/azuredevops/testdata/golden/ListAuthorizedUserOrganizations.json @@ -0,0 +1,22 @@ +[ + { + "accountId": "3b958f8e-017e-4f06-95ce-1b6cdd242b36", + "accountUri": "https://vssps.dev.azure.com:443/sgtestazure/", + "accountName": "sgtestazure" + }, + { + "accountId": "65fd1acc-c89e-43e5-9069-395214eb0c25", + "accountUri": "https://vssps.dev.azure.com:443/repo-management/", + "accountName": "repo-management" + }, + { + "accountId": "e8899f60-b66c-4106-8176-8c75b1710c97", + "accountUri": "https://vssps.dev.azure.com:443/indra-foobar/", + "accountName": "indra-foobar" + }, + { + "accountId": "0b807719-5c78-49cd-a851-b24b694db41c", + "accountUri": "https://vssps.dev.azure.com:443/sgtestazure2/", + "accountName": "sgtestazure2" + } + ] \ No newline at end of file diff --git a/internal/extsvc/azuredevops/testdata/vcr/ListAuthorizedUserOrganizations.yaml b/internal/extsvc/azuredevops/testdata/vcr/ListAuthorizedUserOrganizations.yaml new file mode 100644 index 00000000000..b5232169cca --- /dev/null +++ b/internal/extsvc/azuredevops/testdata/vcr/ListAuthorizedUserOrganizations.yaml @@ -0,0 +1,113 @@ +--- +version: 1 +interactions: +- request: + body: "" + form: {} + headers: {} + url: https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=7.0 + method: GET + response: + body: '{"displayName":"idan.varsano","publicAlias":"473dec3e-03d7-6147-b106-0b0a7f766a92","emailAddress":"idan.varsano@sourcegraph.com","coreRevision":411938451,"timeStamp":"2023-01-20T19:20:51.6433333+00:00","id":"473dec3e-03d7-6147-b106-0b0a7f766a92","revision":411938451}' + headers: + Access-Control-Expose-Headers: + - Request-Context + Activityid: + - d2cf0ab4-6956-4880-bffa-eebdf9f92550 + Cache-Control: + - no-cache, no-store, must-revalidate + Content-Type: + - application/json; charset=utf-8; api-version=7.0 + Date: + - Thu, 02 Mar 2023 18:36:45 GMT + Etag: + - '"0"' + Expires: + - "-1" + Last-Modified: + - Fri, 20 Jan 2023 19:20:51 GMT + P3p: + - CP="CAO DSP COR ADMa DEV CONo TELo CUR PSA PSD TAI IVDo OUR SAMi BUS DEM NAV + STA UNI COM INT PHY ONL FIN PUR LOC CNT" + Pragma: + - no-cache + Request-Context: + - appId=cid-v1:20b3930f-73dc-453a-b660-e3891d782eef + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Vary: + - Accept-Encoding + X-Cache: + - CONFIG_NOCACHE + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Msedge-Ref: + - 'Ref A: 5C3F7F96F5674231B1EC5F1F62DC3144 Ref B: MAA01EDGE1111 Ref C: 2023-03-02T18:36:45Z' + X-Tfs-Processid: + - 820ab1ae-fa5e-4b54-bcd3-c19228bd810b + X-Tfs-Session: + - d2cf0ab4-6956-4880-bffa-eebdf9f92550 + X-Vss-E2eid: + - d2cf0ab4-6956-4880-bffa-eebdf9f92550 + X-Vss-Senderdeploymentid: + - a5ca35eb-148e-4ccd-bbb3-d31576d75958 + X-Vss-Userdata: + - 473dec3e-03d7-6147-b106-0b0a7f766a92:idan.varsano@sourcegraph.com + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: {} + url: https://app.vssps.visualstudio.com/_apis/accounts?api-version=7.0&memberId=473dec3e-03d7-6147-b106-0b0a7f766a92 + method: GET + response: + body: '{"count":4,"value":[{"accountId":"3b958f8e-017e-4f06-95ce-1b6cdd242b36","accountUri":"https://vssps.dev.azure.com:443/sgtestazure/","accountName":"sgtestazure","properties":{}},{"accountId":"65fd1acc-c89e-43e5-9069-395214eb0c25","accountUri":"https://vssps.dev.azure.com:443/repo-management/","accountName":"repo-management","properties":{}},{"accountId":"e8899f60-b66c-4106-8176-8c75b1710c97","accountUri":"https://vssps.dev.azure.com:443/indra-foobar/","accountName":"indra-foobar","properties":{}},{"accountId":"0b807719-5c78-49cd-a851-b24b694db41c","accountUri":"https://vssps.dev.azure.com:443/sgtestazure2/","accountName":"sgtestazure2","properties":{}}]}' + headers: + Access-Control-Expose-Headers: + - Request-Context + Activityid: + - e2fa6c27-6b16-4f2d-83e4-bfc6a8cbcc87 + Cache-Control: + - no-cache, no-store, must-revalidate + Content-Type: + - application/json; charset=utf-8; api-version=7.0 + Date: + - Thu, 02 Mar 2023 18:36:45 GMT + Expires: + - "-1" + P3p: + - CP="CAO DSP COR ADMa DEV CONo TELo CUR PSA PSD TAI IVDo OUR SAMi BUS DEM NAV + STA UNI COM INT PHY ONL FIN PUR LOC CNT" + Pragma: + - no-cache + Request-Context: + - appId=cid-v1:20b3930f-73dc-453a-b660-e3891d782eef + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + Vary: + - Accept-Encoding + X-Cache: + - CONFIG_NOCACHE + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Msedge-Ref: + - 'Ref A: BB7D52F5D4D94054936DD2D1686AD864 Ref B: MAA01EDGE1111 Ref C: 2023-03-02T18:36:45Z' + X-Tfs-Processid: + - 7079e5b3-fcdc-4e60-bc32-c76d7829ecec + X-Tfs-Session: + - e2fa6c27-6b16-4f2d-83e4-bfc6a8cbcc87 + X-Vss-E2eid: + - e2fa6c27-6b16-4f2d-83e4-bfc6a8cbcc87 + X-Vss-Senderdeploymentid: + - a5ca35eb-148e-4ccd-bbb3-d31576d75958 + X-Vss-Userdata: + - 473dec3e-03d7-6147-b106-0b0a7f766a92:idan.varsano@sourcegraph.com + status: 200 OK + code: 200 + duration: "" diff --git a/internal/extsvc/azuredevops/types.go b/internal/extsvc/azuredevops/types.go index 2fe7e31604f..65e80f90e34 100644 --- a/internal/extsvc/azuredevops/types.go +++ b/internal/extsvc/azuredevops/types.go @@ -28,6 +28,17 @@ var ( PullRequestMergeStrategyNoFastForward PullRequestMergeStrategy = "notFastForward" ) +type Org struct { + ID string `json:"accountId"` + URI string `json:"accountUri"` + Name string `json:"accountName"` +} + +type ListAuthorizedUserOrgsResponse struct { + Count int `json:"count"` + Value []Org `json:"value"` +} + type OrgProjectRepoArgs struct { Org string Project string diff --git a/internal/extsvc/azuredevops/users.go b/internal/extsvc/azuredevops/users.go index dee3c83ef9d..a1a46b25f78 100644 --- a/internal/extsvc/azuredevops/users.go +++ b/internal/extsvc/azuredevops/users.go @@ -9,6 +9,7 @@ import ( "github.com/sourcegraph/sourcegraph/internal/encryption" "github.com/sourcegraph/sourcegraph/internal/extsvc" + "github.com/sourcegraph/sourcegraph/lib/errors" "golang.org/x/oauth2" ) @@ -41,6 +42,35 @@ func (c *client) GetAuthorizedProfile(ctx context.Context) (Profile, error) { return p, nil } +func (c *client) ListAuthorizedUserOrganizations(ctx context.Context, profile Profile) ([]Org, error) { + if MockVisualStudioAppURL == "" && !c.IsAzureDevOpsServices() { + return nil, errors.New("ListAuthorizedUserOrganizations can only be used with Azure DevOps Services") + } + + reqURL := url.URL{Path: "_apis/accounts"} + + req, err := http.NewRequest("GET", reqURL.String(), nil) + if err != nil { + return nil, err + } + + queryParams := req.URL.Query() + queryParams.Set("memberId", profile.PublicAlias) + req.URL.RawQuery = queryParams.Encode() + + apiURL := VisualStudioAppURL + if MockVisualStudioAppURL != "" { + apiURL = MockVisualStudioAppURL + } + + response := ListAuthorizedUserOrgsResponse{} + if _, err := c.do(ctx, req, apiURL, &response); err != nil { + return nil, err + } + + return response.Value, nil +} + // SetExternalAccountData sets the user and token into the external account data blob. func SetExternalAccountData(data *extsvc.AccountData, user *Profile, token *oauth2.Token) error { serializedUser, err := json.Marshal(user) diff --git a/internal/extsvc/azuredevops/users_test.go b/internal/extsvc/azuredevops/users_test.go index 06c088cd50d..398fd9beafe 100644 --- a/internal/extsvc/azuredevops/users_test.go +++ b/internal/extsvc/azuredevops/users_test.go @@ -18,3 +18,29 @@ func TestClient_AzureServicesProfile(t *testing.T) { testutil.AssertGolden(t, "testdata/golden/AzureServicesProfile.json", *update, resp) } + +// To update this test run: +// 1. Set the env AZURE_DEV_OPS_USERNAME and AZURE_DEV_OPS_TOKEN (the secrets can be found in 1Password if you search for Azure test credentials) +// 2. Run the test with the -update flag: +// `go test -run='TestClient_ListAuthorizedUserOrganizations' -update=true` +func TestClient_ListAuthorizedUserOrganizations(t *testing.T) { + cli, save := NewTestClient( + t, + "ListAuthorizedUserOrganizations", + *update, + ) + t.Cleanup(save) + + ctx := context.Background() + profile, err := cli.GetAuthorizedProfile(ctx) + if err != nil { + t.Fatalf("failed to get authorized profile: %v", err) + } + + orgs, err := cli.ListAuthorizedUserOrganizations(ctx, profile) + if err != nil { + t.Fatalf("failed to list authorized user origanizations: %v", err) + } + + testutil.AssertGolden(t, "testdata/golden/ListAuthorizedUserOrganizations.json", *update, orgs) +}