diff --git a/internal/client/auth.go b/internal/client/auth.go index c5793c5..1182a07 100644 --- a/internal/client/auth.go +++ b/internal/client/auth.go @@ -85,6 +85,20 @@ func (c *Client) ListOrgProjects(ctx context.Context, orgID string) ([]serverapi return resp.Items, nil } +func selectProjectName(projects []serverapi.Project) (string, bool) { + for _, p := range projects { + if p.Name != "" && p.DefaultProject != nil && *p.DefaultProject { + return p.Name, true + } + } + for _, p := range projects { + if p.Name != "" { + return p.Name, true + } + } + return "", false +} + // ResolveDefaultProject picks the project the CLI operates in. Projects are not // exposed in the CLI UX, so we resolve one silently: the default project if the // API marks one, otherwise the first. @@ -94,23 +108,20 @@ func (c *Client) ResolveDefaultProject(ctx context.Context, orgID string) (strin return "", err } - // Server bug: GET /users/current/projects answers {"items":[],"total":0} on - // deployed servers even when the user has a default project, which made - // login fail outright. GET /organizations/{org_id}/projects is correct. - // Drop this fallback once the per-user endpoint is fixed. - if len(projects) == 0 && orgID != "" { + if name, ok := selectProjectName(projects); ok { + return name, nil + } + + // GET /users/current/projects has returned empty and malformed project lists + // on deployed servers. GET /organizations/{org_id}/projects is the fallback. + if orgID != "" { if projects, err = c.ListOrgProjects(ctx, orgID); err != nil { return "", err } - } - - for _, p := range projects { - if p.DefaultProject != nil && *p.DefaultProject { - return p.Name, nil + if name, ok := selectProjectName(projects); ok { + return name, nil } } - if len(projects) > 0 { - return projects[0].Name, nil - } + return "", clierrors.New("No projects found for your account.") } diff --git a/internal/client/auth_test.go b/internal/client/auth_test.go index 9d087d3..11dcfc9 100644 --- a/internal/client/auth_test.go +++ b/internal/client/auth_test.go @@ -43,6 +43,70 @@ func TestResolveDefaultProjectFallsBackToOrgProjects(t *testing.T) { } } +func TestResolveDefaultProjectFallsBackWhenCurrentUserProjectsHaveNoName(t *testing.T) { + var paths []string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/users/current/projects": + fmt.Fprint(w, `{"items":[{"project":{"name":"default","default_project":true}}],"total":1}`) + case "/api/v1/organizations/org-1/projects": + fmt.Fprint(w, `{"items":[{"name":"other"},{"name":"the-default","default_project":true}],"total":2}`) + default: + t.Errorf("unexpected request to %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := New(ts.URL, WithTokens("access", "")) + + name, err := c.ResolveDefaultProject(context.Background(), "org-1") + if err != nil { + t.Fatalf("ResolveDefaultProject: %v", err) + } + if name != "the-default" { + t.Errorf("project = %q, want the-default", name) + } + if len(paths) != 2 || paths[1] != "/api/v1/organizations/org-1/projects" { + t.Errorf("requests = %v, want the org projects endpoint consulted second", paths) + } +} + +func TestResolveDefaultProjectErrorsWhenNoProjectHasAName(t *testing.T) { + var paths []string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/users/current/projects": + fmt.Fprint(w, `{"items":[{"project":{"id":"project-1"}}],"total":1}`) + case "/api/v1/organizations/org-1/projects": + fmt.Fprint(w, `{"items":[{"id":"project-1"}],"total":1}`) + default: + t.Errorf("unexpected request to %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + c := New(ts.URL, WithTokens("access", "")) + + name, err := c.ResolveDefaultProject(context.Background(), "org-1") + if err == nil { + t.Fatal("ResolveDefaultProject returned a nil error for nameless projects") + } + if name != "" { + t.Errorf("project = %q, want empty on error", name) + } + if len(paths) != 2 || paths[1] != "/api/v1/organizations/org-1/projects" { + t.Errorf("requests = %v, want the org projects endpoint consulted second", paths) + } +} + // The per-user endpoint is still the primary source: when it answers, the org // endpoint must not be touched. func TestResolveDefaultProjectPrefersCurrentUserProjects(t *testing.T) {