diff --git a/cmd/kvlist_test.go b/cmd/kvlist_test.go index 9fa95e1b..dea757f0 100644 --- a/cmd/kvlist_test.go +++ b/cmd/kvlist_test.go @@ -5,6 +5,7 @@ import ( "testing" renderapi "github.com/render-oss/cli/internal/fakes/renderapi" + "github.com/render-oss/cli/internal/testids" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -185,3 +186,34 @@ func TestKVList_UnknownProject_Errors(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "does-not-exist") } + +func TestKVList_FilterByIDShapedProjectAndEnvironmentNames(t *testing.T) { + server := renderapi.NewServer(t) + + // Accepted names that match the prj-/evm- ID shape must still resolve by + // name when no object has that ID. + projectA := server.CreateProject( + renderapi.ProjectAttrs{Id: testids.ProjectID("id shaped a"), Name: "prj-00000000000000093206", OwnerId: kvTestWorkspaceID}, + renderapi.EnvAttrs{ID: testids.EnvironmentID("id shaped a"), Name: "evm-00000000000000093206"}, + renderapi.EnvAttrs{ID: testids.EnvironmentID("id shaped staging"), Name: "staging"}, + ) + projectB := server.CreateProject( + renderapi.ProjectAttrs{Id: testids.ProjectID("id shaped b"), Name: "Project B", OwnerId: kvTestWorkspaceID}, + renderapi.EnvAttrs{ID: testids.EnvironmentID("id shaped b"), Name: "evm-00000000000000093206"}, + ) + + aKV := seedKVInEnv(server, "a-cache", projectA.Env("evm-00000000000000093206").Id) + aStagingKV := seedKVInEnv(server, "a-staging-cache", projectA.Env("staging").Id) + bKV := seedKVInEnv(server, "b-cache", projectB.Env("evm-00000000000000093206").Id) + + result, err := executeKVList(t, server, + "--project", "prj-00000000000000093206", + "--environment", "evm-00000000000000093206", + "--output", "text", + ) + require.NoError(t, err) + + assert.Contains(t, result.Stdout, aKV.Id) + assert.NotContains(t, result.Stdout, aStagingKV.Id) + assert.NotContains(t, result.Stdout, bKV.Id) +} diff --git a/cmd/pglist_test.go b/cmd/pglist_test.go index a7c22640..359298f9 100644 --- a/cmd/pglist_test.go +++ b/cmd/pglist_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" renderapi "github.com/render-oss/cli/internal/fakes/renderapi" + "github.com/render-oss/cli/internal/testids" "github.com/render-oss/cli/internal/testrequire" "github.com/render-oss/cli/pkg/client" ) @@ -178,3 +179,34 @@ func TestPGList_UnknownProject_Errors(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "does-not-exist") } + +func TestPGList_FilterByIDShapedProjectAndEnvironmentNames(t *testing.T) { + harness := newPGListHarness(t) + + // Accepted names that match the prj-/evm- ID shape must still resolve by + // name when no object has that ID. + projectA := harness.server.CreateProject( + renderapi.ProjectAttrs{Id: testids.ProjectID("id shaped a"), Name: "prj-00000000000000093206", OwnerId: pgActiveWorkspaceID}, + renderapi.EnvAttrs{ID: testids.EnvironmentID("id shaped a"), Name: "evm-00000000000000093206"}, + renderapi.EnvAttrs{ID: testids.EnvironmentID("id shaped staging"), Name: "staging"}, + ) + projectB := harness.server.CreateProject( + renderapi.ProjectAttrs{Id: testids.ProjectID("id shaped b"), Name: "Project B", OwnerId: pgActiveWorkspaceID}, + renderapi.EnvAttrs{ID: testids.EnvironmentID("id shaped b"), Name: "evm-00000000000000093206"}, + ) + + aPG := seedPGInEnv(harness.server, "a-db", projectA.Env("evm-00000000000000093206").Id) + aStagingPG := seedPGInEnv(harness.server, "a-staging-db", projectA.Env("staging").Id) + bPG := seedPGInEnv(harness.server, "b-db", projectB.Env("evm-00000000000000093206").Id) + + result, err := harness.execute( + "--project", "prj-00000000000000093206", + "--environment", "evm-00000000000000093206", + "--output", "text", + ) + require.NoError(t, err) + + assert.Contains(t, result.Stdout, aPG.Id) + assert.NotContains(t, result.Stdout, aStagingPG.Id) + assert.NotContains(t, result.Stdout, bPG.Id) +} diff --git a/pkg/environment/repo.go b/pkg/environment/repo.go index 1a22c775..430386a9 100644 --- a/pkg/environment/repo.go +++ b/pkg/environment/repo.go @@ -2,11 +2,31 @@ package environment import ( "context" + "errors" "fmt" + "net/http" "github.com/render-oss/cli/pkg/client" ) +// ErrEnvironmentNotFound is matched (via errors.Is) by the error Repo.GetEnvironment returns +// when the API responds 404 Not Found for the requested ID. +var ErrEnvironmentNotFound = errors.New("environment not found") + +// environmentNotFoundError keeps the existing "unexpected response: " message +// while letting callers distinguish a 404 from other failures. +type environmentNotFoundError struct { + status string +} + +func (e *environmentNotFoundError) Error() string { + return fmt.Sprintf("unexpected response: %v", e.status) +} + +func (e *environmentNotFoundError) Unwrap() error { + return ErrEnvironmentNotFound +} + type Repo struct { client *client.ClientWithResponses } @@ -25,6 +45,10 @@ func (e *Repo) GetEnvironment(ctx context.Context, id string) (*client.Environme return nil, err } + if resp.StatusCode() == http.StatusNotFound { + return nil, &environmentNotFoundError{status: resp.Status()} + } + if resp.JSON200 == nil { return nil, fmt.Errorf("unexpected response: %v", resp.Status()) } diff --git a/pkg/project/repo.go b/pkg/project/repo.go index c5369771..29ac62b7 100644 --- a/pkg/project/repo.go +++ b/pkg/project/repo.go @@ -2,13 +2,33 @@ package project import ( "context" + "errors" "fmt" + "net/http" "github.com/render-oss/cli/pkg/client" "github.com/render-oss/cli/pkg/config" "github.com/render-oss/cli/pkg/pointers" ) +// ErrProjectNotFound is matched (via errors.Is) by the error Repo.GetProject returns +// when the API responds 404 Not Found for the requested ID. +var ErrProjectNotFound = errors.New("project not found") + +// projectNotFoundError keeps the existing "unexpected response: " message +// while letting callers distinguish a 404 from other failures. +type projectNotFoundError struct { + status string +} + +func (e *projectNotFoundError) Error() string { + return fmt.Sprintf("unexpected response: %v", e.status) +} + +func (e *projectNotFoundError) Unwrap() error { + return ErrProjectNotFound +} + func NewRepo(client *client.ClientWithResponses) *Repo { return &Repo{client: client} } @@ -87,6 +107,10 @@ func (p *Repo) GetProject(ctx context.Context, id string) (*client.Project, erro return nil, err } + if resp.StatusCode() == http.StatusNotFound { + return nil, &projectNotFoundError{status: resp.Status()} + } + if resp.JSON200 == nil { return nil, fmt.Errorf("unexpected response: %v", resp.Status()) } diff --git a/pkg/resolve/resolve.go b/pkg/resolve/resolve.go index 6779e316..cc2a0c70 100644 --- a/pkg/resolve/resolve.go +++ b/pkg/resolve/resolve.go @@ -2,6 +2,7 @@ package resolve import ( "context" + "errors" "fmt" "strings" @@ -223,22 +224,29 @@ func validateEnvironmentBelongsToWorkspace(env *client.Environment, project *cli // them; when absent, this helper resolves the missing ancestors from the // environment itself. func (r *Resolver) resolveEnvironmentAndProject(ctx context.Context, envIDOrName string, workspaceID string, resolvedProject *client.Project) (*client.Environment, *client.Project, error) { + // An ID-shaped selector that 404s by ID may still be an environment name, + // so fall through to the scoped name lookup. idErr is reported if no + // environment has that name either. Other errors are returned as-is. + var idErr error if validate.IsEnvironmentID(envIDOrName) { env, err := r.environmentRepo.GetEnvironment(ctx, envIDOrName) - if err != nil { - return nil, nil, fmt.Errorf("environment %q not found: %w", envIDOrName, err) - } - project, err := r.projectRepo.GetProject(ctx, env.ProjectId) - if err != nil { - return nil, nil, err + if err == nil { + project, err := r.projectRepo.GetProject(ctx, env.ProjectId) + if err != nil { + return nil, nil, err + } + if err := validateEnvironmentBelongsToProject(env, resolvedProject); err != nil { + return nil, nil, err + } + if err := validateEnvironmentBelongsToWorkspace(env, project, workspaceID); err != nil { + return nil, nil, err + } + return env, project, nil } - if err := validateEnvironmentBelongsToProject(env, resolvedProject); err != nil { - return nil, nil, err - } - if err := validateEnvironmentBelongsToWorkspace(env, project, workspaceID); err != nil { - return nil, nil, err + idErr = fmt.Errorf("environment %q not found: %w", envIDOrName, err) + if !errors.Is(err, environment.ErrEnvironmentNotFound) { + return nil, nil, idErr } - return env, project, nil } projects, err := r.candidateProjectsForEnvironmentLookup(ctx, resolvedProject, workspaceID) @@ -252,7 +260,7 @@ func (r *Resolver) resolveEnvironmentAndProject(ctx context.Context, envIDOrName projectByID[p.Id] = p } if len(projectIDs) == 0 { - return nil, nil, fmt.Errorf("no environment found with name %q", envIDOrName) + return nil, nil, noEnvironmentMatch(envIDOrName, idErr) } nameFilter := client.NameParam{envIDOrName} @@ -265,7 +273,7 @@ func (r *Resolver) resolveEnvironmentAndProject(ctx context.Context, envIDOrName } if len(matches) == 0 { - return nil, nil, fmt.Errorf("no environment found with name %q", envIDOrName) + return nil, nil, noEnvironmentMatch(envIDOrName, idErr) } if len(matches) > 1 { return nil, nil, fmt.Errorf("multiple environments found with name %q across projects — please use the environment ID instead", envIDOrName) @@ -288,6 +296,15 @@ func (r *Resolver) resolveEnvironmentAndProject(ctx context.Context, envIDOrName return env, project, nil } +// noEnvironmentMatch reports a selector that matched no environment name, +// preferring the by-ID 404 error when the selector was ID-shaped. +func noEnvironmentMatch(envIDOrName string, idErr error) error { + if idErr != nil { + return idErr + } + return fmt.Errorf("no environment found with name %q", envIDOrName) +} + // candidateProjectsForEnvironmentLookup returns the projects whose environments // may match an environment name selector under the current scope constraints. func (r *Resolver) candidateProjectsForEnvironmentLookup(ctx context.Context, resolvedProject *client.Project, workspaceID string) ([]*client.Project, error) { @@ -313,12 +330,19 @@ func (r *Resolver) candidateProjectsForEnvironmentLookup(ctx context.Context, re // search spans every workspace the caller can access. Returns the full // *client.Project so callers can read Owner.Id without re-fetching. func (r *Resolver) ResolveProject(ctx context.Context, idOrName string, workspaceID string) (*client.Project, error) { + // An ID-shaped selector that 404s by ID may still be a project name, so + // fall through to the scoped name lookup. idErr is reported if no project + // has that name either. Other errors are returned as-is. + var idErr error if validate.IsProjectID(idOrName) { proj, err := r.projectRepo.GetProject(ctx, idOrName) - if err != nil { - return nil, fmt.Errorf("project %q not found: %w", idOrName, err) + if err == nil { + return proj, nil + } + idErr = fmt.Errorf("project %q not found: %w", idOrName, err) + if !errors.Is(err, project.ErrProjectNotFound) { + return nil, idErr } - return proj, nil } matches, err := r.projectRepo.ListProjectsByName(ctx, idOrName, workspaceID) @@ -326,6 +350,9 @@ func (r *Resolver) ResolveProject(ctx context.Context, idOrName string, workspac return nil, err } if len(matches) == 0 { + if idErr != nil { + return nil, idErr + } return nil, fmt.Errorf("no project found with name %q", idOrName) } if len(matches) > 1 { diff --git a/pkg/resolve/resolve_test.go b/pkg/resolve/resolve_test.go index a6becf17..f2e36629 100644 --- a/pkg/resolve/resolve_test.go +++ b/pkg/resolve/resolve_test.go @@ -2,11 +2,18 @@ package resolve import ( "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" "testing" renderapi "github.com/render-oss/cli/internal/fakes/renderapi" "github.com/render-oss/cli/internal/testids" "github.com/render-oss/cli/pkg/client" + "github.com/render-oss/cli/pkg/environment" + "github.com/render-oss/cli/pkg/project" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -423,3 +430,301 @@ func TestResolverResolveScopeInActiveWorkspace_NarrowsProjectAndEnvironmentNames require.NotNil(t, scope.Environment) assert.Equal(t, activeEnvironmentID, scope.Environment.Id) } + +// Accepted project and environment names can match the ID shape +// (prefix plus 20 lowercase alphanumerics). These must still resolve by name +// when no object has that ID. +const ( + idShapedProjectName = "prj-00000000000000093206" + idShapedEnvironmentName = "evm-00000000000000093206" +) + +func TestResolverResolveProject_IDShapedNameFallsBackToNameOnNotFound(t *testing.T) { + server := renderapi.NewServer(t) + projectID := testids.ProjectID("id shaped name") + seedOwner(server, "tea-owner") + seedOwner(server, "tea-other") + server.Projects.Add(renderapi.NewProject(renderapi.ProjectAttrs{ + Id: projectID, + Name: idShapedProjectName, + OwnerId: "tea-owner", + })) + server.Projects.Add(renderapi.NewProject(renderapi.ProjectAttrs{ + Id: testids.ProjectID("other workspace"), + Name: idShapedProjectName, + OwnerId: "tea-other", + })) + resolver := newTestResolver(t, server) + + proj, err := resolver.ResolveProject(context.Background(), idShapedProjectName, "tea-owner") + + require.NoError(t, err) + assert.Equal(t, projectID, proj.Id) + assert.True(t, server.HasRequest("GET", "/projects/"+idShapedProjectName)) + assert.True(t, server.HasRequest("GET", "ownerId=tea-owner")) +} + +func TestResolverResolveProject_RealIDTakesPrecedenceOverName(t *testing.T) { + server := renderapi.NewServer(t) + byIDProjectID := testids.ProjectID("by id") + seedOwner(server, "tea-owner") + server.Projects.Add(renderapi.NewProject(renderapi.ProjectAttrs{ + Id: byIDProjectID, + Name: "By ID", + OwnerId: "tea-owner", + })) + server.Projects.Add(renderapi.NewProject(renderapi.ProjectAttrs{ + Id: testids.ProjectID("by name"), + Name: byIDProjectID, + OwnerId: "tea-owner", + })) + resolver := newTestResolver(t, server) + + proj, err := resolver.ResolveProject(context.Background(), byIDProjectID, "") + + require.NoError(t, err) + assert.Equal(t, byIDProjectID, proj.Id) + assert.False(t, server.HasRequest("GET", "name=")) +} + +func TestResolverResolveProject_MissingIDWithoutNameMatchKeepsNotFoundError(t *testing.T) { + server := renderapi.NewServer(t) + seedOwner(server, "tea-owner") + resolver := newTestResolver(t, server) + + _, err := resolver.ResolveProject(context.Background(), idShapedProjectName, "tea-owner") + + require.EqualError(t, err, `project "prj-00000000000000093206" not found: unexpected response: 404 Not Found`) + assert.True(t, errors.Is(err, project.ErrProjectNotFound)) +} + +func TestResolverResolveProject_AmbiguousIDShapedNameErrors(t *testing.T) { + server := renderapi.NewServer(t) + seedOwner(server, "tea-owner") + for _, label := range []string{"first", "second"} { + server.Projects.Add(renderapi.NewProject(renderapi.ProjectAttrs{ + Id: testids.ProjectID(label), + Name: idShapedProjectName, + OwnerId: "tea-owner", + })) + } + resolver := newTestResolver(t, server) + + _, err := resolver.ResolveProject(context.Background(), idShapedProjectName, "tea-owner") + + require.ErrorContains(t, err, "multiple projects found") +} + +func TestResolverResolveScope_IDShapedEnvironmentNameFallsBackToNameOnNotFound(t *testing.T) { + server := renderapi.NewServer(t) + projectID := testids.ProjectID("env id shaped project") + otherProjectID := testids.ProjectID("env id shaped other") + environmentID := testids.EnvironmentID("env id shaped") + seedOwner(server, "tea-owner") + server.Projects.Add(renderapi.NewProject(renderapi.ProjectAttrs{ + Id: projectID, + Name: idShapedProjectName, + OwnerId: "tea-owner", + })) + server.Projects.Add(renderapi.NewProject(renderapi.ProjectAttrs{ + Id: otherProjectID, + Name: "Other Project", + OwnerId: "tea-owner", + })) + server.Environments.Add(renderapi.NewEnvironment(client.Environment{ + Id: environmentID, + Name: idShapedEnvironmentName, + ProjectId: projectID, + })) + server.Environments.Add(renderapi.NewEnvironment(client.Environment{ + Id: testids.EnvironmentID("env id shaped other"), + Name: idShapedEnvironmentName, + ProjectId: otherProjectID, + })) + resolver := newTestResolver(t, server) + + scope, err := resolver.ResolveScope(context.Background(), ScopeInput{ + ProjectIDOrName: stringPtr(idShapedProjectName), + EnvironmentIDOrName: stringPtr(idShapedEnvironmentName), + }) + + require.NoError(t, err) + require.NotNil(t, scope.Project) + assert.Equal(t, projectID, scope.Project.Id) + require.NotNil(t, scope.Environment) + assert.Equal(t, environmentID, scope.Environment.Id) + assert.True(t, server.HasRequest("GET", "/environments/"+idShapedEnvironmentName)) +} + +func TestResolverResolveScope_AmbiguousIDShapedEnvironmentNameErrors(t *testing.T) { + server := renderapi.NewServer(t) + seedOwner(server, "tea-owner") + for _, label := range []string{"first", "second"} { + projectID := testids.ProjectID(label) + server.Projects.Add(renderapi.NewProject(renderapi.ProjectAttrs{ + Id: projectID, + Name: label, + OwnerId: "tea-owner", + })) + server.Environments.Add(renderapi.NewEnvironment(client.Environment{ + Id: testids.EnvironmentID(label), + Name: idShapedEnvironmentName, + ProjectId: projectID, + })) + } + resolver := newTestResolver(t, server) + + _, err := resolver.ResolveScope(context.Background(), ScopeInput{ + WorkspaceIDOrName: "tea-owner", + EnvironmentIDOrName: stringPtr(idShapedEnvironmentName), + }) + + require.ErrorContains(t, err, "multiple environments found") +} + +func TestResolverResolveScope_MissingEnvironmentIDWithoutNameMatchKeepsNotFoundError(t *testing.T) { + server := renderapi.NewServer(t) + projectID := testids.ProjectID("missing env project") + seedOwner(server, "tea-owner") + server.Projects.Add(renderapi.NewProject(renderapi.ProjectAttrs{ + Id: projectID, + Name: "Project", + OwnerId: "tea-owner", + })) + resolver := newTestResolver(t, server) + + _, err := resolver.ResolveScope(context.Background(), ScopeInput{ + ProjectIDOrName: stringPtr(projectID), + EnvironmentIDOrName: stringPtr(idShapedEnvironmentName), + }) + + require.EqualError(t, err, `environment "evm-00000000000000093206" not found: unexpected response: 404 Not Found`) + assert.True(t, errors.Is(err, environment.ErrEnvironmentNotFound)) +} + +func TestResolverResolveScope_IDShapedEnvironmentNameStaysScopedToProject(t *testing.T) { + server := renderapi.NewServer(t) + projectID := testids.ProjectID("scoped project") + otherProjectID := testids.ProjectID("scoped other") + seedOwner(server, "tea-owner") + server.Projects.Add(renderapi.NewProject(renderapi.ProjectAttrs{ + Id: projectID, + Name: "Project", + OwnerId: "tea-owner", + })) + server.Projects.Add(renderapi.NewProject(renderapi.ProjectAttrs{ + Id: otherProjectID, + Name: "Other", + OwnerId: "tea-owner", + })) + server.Environments.Add(renderapi.NewEnvironment(client.Environment{ + Id: testids.EnvironmentID("scoped other"), + Name: idShapedEnvironmentName, + ProjectId: otherProjectID, + })) + resolver := newTestResolver(t, server) + + _, err := resolver.ResolveScope(context.Background(), ScopeInput{ + ProjectIDOrName: stringPtr(projectID), + EnvironmentIDOrName: stringPtr(idShapedEnvironmentName), + }) + + require.ErrorIs(t, err, environment.ErrEnvironmentNotFound) + assert.True(t, server.HasRequest("GET", "projectId="+projectID)) +} + +// statusServer answers every detail GET with a fixed response and records +// whether any list (name lookup) request was made. +type statusServer struct { + mu sync.Mutex + listCalls int + status int + contentType string + body string +} + +func newStatusServer(t *testing.T, status int, contentType, body string) (*statusServer, *Resolver) { + t.Helper() + ss := &statusServer{status: status, contentType: contentType, body: body} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/projects" || r.URL.Path == "/environments" { + ss.mu.Lock() + ss.listCalls++ + ss.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte("[]")) + return + } + if strings.HasPrefix(r.URL.Path, "/projects/") || strings.HasPrefix(r.URL.Path, "/environments/") { + if ss.contentType != "" { + w.Header().Set("Content-Type", ss.contentType) + } + w.WriteHeader(ss.status) + _, _ = w.Write([]byte(ss.body)) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + c, err := client.NewClientWithResponses(srv.URL) + require.NoError(t, err) + return ss, NewFromClient(c) +} + +func TestResolver_IDShapedSelectorsOnlyFallBackToNameOn404(t *testing.T) { + cases := []struct { + name string + status int + contentType string + body string + wantErr string + }{ + {name: "unauthorized", status: http.StatusUnauthorized, contentType: "application/json", body: `{"message":"unauthorized"}`, wantErr: "401 Unauthorized"}, + {name: "forbidden", status: http.StatusForbidden, contentType: "application/json", body: `{"message":"forbidden"}`, wantErr: "403 Forbidden"}, + {name: "rate limited", status: http.StatusTooManyRequests, contentType: "application/json", body: `{"message":"slow down"}`, wantErr: "429 Too Many Requests"}, + {name: "server error", status: http.StatusInternalServerError, contentType: "application/json", body: `{"message":"boom"}`, wantErr: "500 Internal Server Error"}, + {name: "200 without JSON body", status: http.StatusOK, contentType: "text/plain", body: "ok", wantErr: "unexpected response: 200 OK"}, + {name: "200 with malformed JSON", status: http.StatusOK, contentType: "application/json", body: "{", wantErr: "unexpected end of JSON input"}, + } + for _, tc := range cases { + t.Run(tc.name+"/project", func(t *testing.T) { + ss, resolver := newStatusServer(t, tc.status, tc.contentType, tc.body) + + _, err := resolver.ResolveProject(context.Background(), idShapedProjectName, "tea-owner") + + require.ErrorContains(t, err, tc.wantErr) + assert.False(t, errors.Is(err, project.ErrProjectNotFound)) + assert.Zero(t, ss.listCalls, "non-404 project detail error must not fall back to name lookup") + }) + t.Run(tc.name+"/environment", func(t *testing.T) { + ss, resolver := newStatusServer(t, tc.status, tc.contentType, tc.body) + + _, err := resolver.ResolveScope(context.Background(), ScopeInput{ + EnvironmentIDOrName: stringPtr(idShapedEnvironmentName), + }) + + require.ErrorContains(t, err, tc.wantErr) + assert.False(t, errors.Is(err, environment.ErrEnvironmentNotFound)) + assert.Zero(t, ss.listCalls, "non-404 environment detail error must not fall back to name lookup") + }) + } + + t.Run("transport error", func(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + url := srv.URL + srv.Close() + c, err := client.NewClientWithResponses(url) + require.NoError(t, err) + resolver := NewFromClient(c) + + _, err = resolver.ResolveProject(context.Background(), idShapedProjectName, "tea-owner") + require.Error(t, err) + assert.False(t, errors.Is(err, project.ErrProjectNotFound)) + + _, err = resolver.ResolveScope(context.Background(), ScopeInput{ + EnvironmentIDOrName: stringPtr(idShapedEnvironmentName), + }) + require.Error(t, err) + assert.False(t, errors.Is(err, environment.ErrEnvironmentNotFound)) + }) +}