diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 49c13e1365..a96acd0cf9 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -276,6 +276,8 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.HasAITask, arg.HasExternalAgent, arg.Shared, + arg.SharedWithUserID, + arg.SharedWithGroupID, arg.RequesterID, arg.Offset, arg.Limit, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 06342a2ab6..a40bd72467 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -21113,7 +21113,18 @@ WHERE (workspaces.user_acl != '{}'::jsonb OR workspaces.group_acl != '{}'::jsonb) = $21 :: boolean ELSE true END - + -- Filter by shared_with_user_id + AND CASE + WHEN $22 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + workspaces.user_acl ? ($22 :: uuid) :: text + ELSE true + END + -- Filter by shared_with_group_id + AND CASE + WHEN $23 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + workspaces.group_acl ? ($23 :: uuid) :: text + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ), filtered_workspaces_order AS ( @@ -21123,7 +21134,7 @@ WHERE filtered_workspaces fw ORDER BY -- To ensure that 'favorite' workspaces show up first in the list only for their owner. - CASE WHEN owner_id = $22 AND favorite THEN 0 ELSE 1 END ASC, + CASE WHEN owner_id = $24 AND favorite THEN 0 ELSE 1 END ASC, (latest_build_completed_at IS NOT NULL AND latest_build_canceled_at IS NULL AND latest_build_error IS NULL AND @@ -21132,11 +21143,11 @@ WHERE LOWER(name) ASC LIMIT CASE - WHEN $24 :: integer > 0 THEN - $24 + WHEN $26 :: integer > 0 THEN + $26 END OFFSET - $23 + $25 ), filtered_workspaces_order_with_summary AS ( SELECT fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.next_start_at, fwo.group_acl, fwo.user_acl, fwo.owner_avatar_url, fwo.owner_username, fwo.owner_name, fwo.organization_name, fwo.organization_display_name, fwo.organization_icon, fwo.organization_description, fwo.template_name, fwo.template_display_name, fwo.template_icon, fwo.template_description, fwo.template_version_id, fwo.template_version_name, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition, fwo.latest_build_status, fwo.latest_build_has_ai_task, fwo.latest_build_has_external_agent @@ -21186,7 +21197,7 @@ WHERE false, -- latest_build_has_ai_task false -- latest_build_has_external_agent WHERE - $25 :: boolean = true + $27 :: boolean = true ), total_count AS ( SELECT count(*) AS count @@ -21224,6 +21235,8 @@ type GetWorkspacesParams struct { HasAITask sql.NullBool `db:"has_ai_task" json:"has_ai_task"` HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` Shared sql.NullBool `db:"shared" json:"shared"` + SharedWithUserID uuid.UUID `db:"shared_with_user_id" json:"shared_with_user_id"` + SharedWithGroupID uuid.UUID `db:"shared_with_group_id" json:"shared_with_group_id"` RequesterID uuid.UUID `db:"requester_id" json:"requester_id"` Offset int32 `db:"offset_" json:"offset_"` Limit int32 `db:"limit_" json:"limit_"` @@ -21298,6 +21311,8 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.HasAITask, arg.HasExternalAgent, arg.Shared, + arg.SharedWithUserID, + arg.SharedWithGroupID, arg.RequesterID, arg.Offset, arg.Limit, diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 9190c263c5..7ab67e98e4 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -384,7 +384,18 @@ WHERE (workspaces.user_acl != '{}'::jsonb OR workspaces.group_acl != '{}'::jsonb) = sqlc.narg('shared') :: boolean ELSE true END - + -- Filter by shared_with_user_id + AND CASE + WHEN @shared_with_user_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + workspaces.user_acl ? (@shared_with_user_id :: uuid) :: text + ELSE true + END + -- Filter by shared_with_group_id + AND CASE + WHEN @shared_with_group_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + workspaces.group_acl ? (@shared_with_group_id :: uuid) :: text + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ), filtered_workspaces_order AS ( diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 5e7644d99b..c9a418ff7c 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -226,6 +226,8 @@ func Workspaces(ctx context.Context, db database.Store, query string, page coder filter.HasExternalAgent = parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent") filter.OrganizationID = parseOrganization(ctx, db, parser, values, "organization") filter.Shared = parser.NullableBoolean(values, sql.NullBool{}, "shared") + filter.SharedWithUserID = parseUser(ctx, db, parser, values, "shared_with_user") + filter.SharedWithGroupID = parseGroup(ctx, db, parser, values, "shared_with_group") type paramMatch struct { name string @@ -363,6 +365,85 @@ func parseOrganization(ctx context.Context, db database.Store, parser *httpapi.Q }) } +func parseUser(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string) uuid.UUID { + return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) { + if v == "" { + return uuid.Nil, nil + } + userID, err := uuid.Parse(v) + if err == nil { + return userID, nil + } + user, err := db.GetUserByEmailOrUsername(ctx, database.GetUserByEmailOrUsernameParams{ + Username: v, + }) + if err != nil { + return uuid.Nil, xerrors.Errorf("user %q either does not exist, or you are unauthorized to view them", v) + } + return user.ID, nil + }) +} + +// Parse a group filter value into a group UUID. +// Supported formats: +// - +// - / +// - (resolved in the default organization) +func parseGroup(ctx context.Context, db database.Store, parser *httpapi.QueryParamParser, vals url.Values, queryParam string) uuid.UUID { + return httpapi.ParseCustom(parser, vals, uuid.Nil, queryParam, func(v string) (uuid.UUID, error) { + if v == "" { + return uuid.Nil, nil + } + groupID, err := uuid.Parse(v) + if err == nil { + return groupID, nil + } + + var groupName string + var org database.Organization + parts := strings.Split(v, "/") + switch len(parts) { + case 1: + dbOrg, err := db.GetDefaultOrganization(ctx) + if err != nil { + return uuid.Nil, xerrors.New("fetching default organization") + } + org = dbOrg + groupName = parts[0] + case 2: + orgName := parts[0] + if err := codersdk.NameValid(orgName); err != nil { + return uuid.Nil, xerrors.Errorf("invalid organization name %w", err) + } + dbOrg, err := db.GetOrganizationByName(ctx, database.GetOrganizationByNameParams{ + Name: orgName, + }) + if err != nil { + return uuid.Nil, xerrors.Errorf("organization %q either does not exist, or you are unauthorized to view it", orgName) + } + org = dbOrg + + groupName = parts[1] + + default: + return uuid.Nil, xerrors.New("invalid organization or group name, the filter must be in the pattern of /") + } + + if err := codersdk.GroupNameValid(groupName); err != nil { + return uuid.Nil, xerrors.Errorf("invalid group name %w", err) + } + + group, err := db.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{ + OrganizationID: org.ID, + Name: groupName, + }) + if err != nil { + return uuid.Nil, xerrors.Errorf("group %q either does not exist, does not belong to the organization %q, or you are unauthorized to view it", groupName, org.Name) + } + return group.ID, nil + }) +} + // splitQueryParameterByDelimiter takes a query string and splits it into the individual elements // of the query. Each element is separated by a delimiter. All quoted strings are // kept as a single element. diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 60e97c6d63..84d4509d0e 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -312,6 +312,84 @@ func TestSearchWorkspace(t *testing.T) { }, }, }, + { + Name: "SharedWithUser", + Query: `shared_with_user:3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf`, + Setup: func(t *testing.T, db database.Store) { + dbgen.User(t, db, database.User{ + ID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"), + }) + }, + Expected: database.GetWorkspacesParams{ + SharedWithUserID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"), + }, + }, + { + Name: "SharedWithUserByName", + Query: `shared_with_user:wibble`, + Setup: func(t *testing.T, db database.Store) { + dbgen.User(t, db, database.User{ + ID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"), + Username: "wibble", + }) + }, + Expected: database.GetWorkspacesParams{ + SharedWithUserID: uuid.MustParse("3dd8b1b8-dff5-4b22-8ae9-c243ca136ecf"), + }, + }, + { + Name: "SharedWithGroupDefaultOrg", + Query: "shared_with_group:wibble", + Setup: func(t *testing.T, db database.Store) { + org, err := db.GetOrganizationByName(t.Context(), database.GetOrganizationByNameParams{ + Name: "coder", + }) + require.NoError(t, err) + + dbgen.Group(t, db, database.Group{ + ID: uuid.MustParse("590f1006-15e6-4b21-a6e1-92e33af8a5c3"), + Name: "wibble", + OrganizationID: org.ID, + }) + }, + Expected: database.GetWorkspacesParams{ + SharedWithGroupID: uuid.MustParse("590f1006-15e6-4b21-a6e1-92e33af8a5c3"), + }, + }, + { + Name: "SharedWithGroupInOrg", + Query: "shared_with_group:wibble/wobble", + Setup: func(t *testing.T, db database.Store) { + org := dbgen.Organization(t, db, database.Organization{ + ID: uuid.MustParse("dbeb1bd5-dce6-459c-ab7b-b7f8b9b10467"), + Name: "wibble", + }) + dbgen.Group(t, db, database.Group{ + ID: uuid.MustParse("3c831688-0a5a-45a2-a796-f7648874df34"), + Name: "wobble", + OrganizationID: org.ID, + }) + }, + Expected: database.GetWorkspacesParams{ + SharedWithGroupID: uuid.MustParse("3c831688-0a5a-45a2-a796-f7648874df34"), + }, + }, + { + Name: "SharedWithGroupID", + Query: "shared_with_group:a7d1ba00-53c7-4aa6-92ea-83157dd57480", + Setup: func(t *testing.T, db database.Store) { + org := dbgen.Organization(t, db, database.Organization{ + ID: uuid.MustParse("8606620f-fee4-4c4e-83ba-f42db804139a"), + }) + dbgen.Group(t, db, database.Group{ + ID: uuid.MustParse("a7d1ba00-53c7-4aa6-92ea-83157dd57480"), + OrganizationID: org.ID, + }) + }, + Expected: database.GetWorkspacesParams{ + SharedWithGroupID: uuid.MustParse("a7d1ba00-53c7-4aa6-92ea-83157dd57480"), + }, + }, // Failures { @@ -354,6 +432,21 @@ func TestSearchWorkspace(t *testing.T) { Query: "param:foo:value", ExpectedErrorContains: "can only contain 1 ':'", }, + { + Name: "SharedWithGroupTooManySegments", + Query: `shared_with_group:acme/devs/extra`, + ExpectedErrorContains: "the filter must be in the pattern of /", + }, + { + Name: "SharedWithGroupEmptyOrg", + Query: `shared_with_group:/devs`, + ExpectedErrorContains: "invalid organization name", + }, + { + Name: "SharedWithGroupEmptyGroup", + Query: `shared_with_group:acme/`, + ExpectedErrorContains: "organization \"acme\" either does not exist", + }, } for _, c := range testCases { diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 98cfba017a..3a24eafde1 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1813,7 +1813,7 @@ func TestWorkspaceFilter(t *testing.T) { }) } - t.Run("SharedWithUser", func(t *testing.T) { + t.Run("Shared", func(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) @@ -1851,7 +1851,7 @@ func TestWorkspaceFilter(t *testing.T) { require.Equal(t, workspaces.Workspaces[0].ID, sharedWorkspace.ID) }) - t.Run("NotSharedWithUser", func(t *testing.T) { + t.Run("NotShared", func(t *testing.T) { t.Parallel() dv := coderdtest.DeploymentValues(t) @@ -1888,6 +1888,82 @@ func TestWorkspaceFilter(t *testing.T) { require.Equal(t, 1, workspaces.Count, "expected only one workspace") require.Equal(t, workspaces.Workspaces[0].ID, notSharedWorkspace.ID) }) + + t.Run("SharedWithUserByID", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + orgOwner = coderdtest.CreateFirstUser(t, client) + _, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID)) + sharedWorkspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + _ = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + _, toShareWithUser = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID) + ctx = testutil.Context(t, testutil.WaitMedium) + ) + + client.UpdateWorkspaceACL(ctx, sharedWorkspace.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + toShareWithUser.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + + workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + SharedWithUser: toShareWithUser.ID.String(), + }) + require.NoError(t, err, "fetch workspaces") + require.Equal(t, 1, workspaces.Count, "expected only one workspace") + require.Equal(t, workspaces.Workspaces[0].ID, sharedWorkspace.ID) + }) + + t.Run("SharedWithUserByUsername", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + var ( + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + orgOwner = coderdtest.CreateFirstUser(t, client) + _, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID)) + sharedWorkspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + _ = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + _, toShareWithUser = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID) + ctx = testutil.Context(t, testutil.WaitMedium) + ) + + client.UpdateWorkspaceACL(ctx, sharedWorkspace.ID, codersdk.UpdateWorkspaceACL{ + UserRoles: map[string]codersdk.WorkspaceRole{ + toShareWithUser.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + + workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + SharedWithUser: toShareWithUser.Username, + }) + require.NoError(t, err, "fetch workspaces") + require.Equal(t, 1, workspaces.Count, "expected only one workspace") + require.Equal(t, workspaces.Workspaces[0].ID, sharedWorkspace.ID) + }) } // TestWorkspaceFilterManual runs some specific setups with basic checks. diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 32ec26643c..f190d58be6 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -518,6 +518,10 @@ type WorkspaceFilter struct { Limit int `json:"limit,omitempty" typescript:"-"` // Shared is a whether the workspace is shared with any users or groups Shared *bool `json:"shared,omitempty" typescript:"-"` + // SharedWithUser is the username or ID of the user that the workspace is shared with + SharedWithUser string `json:"shared_with_user,omitempty" typescript:"-"` + // SharedWithGroup is the group name, group ID, or / of the group that the workspace is shared with + SharedWithGroup string `json:"shared_with_group,omitempty" typescript:"-"` // FilterQuery supports a raw filter query string FilterQuery string `json:"q,omitempty"` } @@ -544,6 +548,12 @@ func (f WorkspaceFilter) asRequestOption() RequestOption { if f.Shared != nil { params = append(params, fmt.Sprintf("shared:%v", *f.Shared)) } + if f.SharedWithUser != "" { + params = append(params, fmt.Sprintf("shared_with_user:%q", f.SharedWithUser)) + } + if f.SharedWithGroup != "" { + params = append(params, fmt.Sprintf("shared_with_group:%q", f.SharedWithGroup)) + } if f.FilterQuery != "" { // If custom stuff is added, just add it on here. params = append(params, f.FilterQuery) diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 35214c4bf3..25fa1527d8 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -3723,6 +3723,116 @@ func TestWorkspacesFiltering(t *testing.T) { require.Equal(t, 1, workspaces.Count, "expected only one workspace") require.Equal(t, workspaces.Workspaces[0].ID, notSharedWorkspace.ID) }) + + t.Run("SharedWithGroupByID", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + var ( + client, db, orgOwner = coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + _, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID)) + sharedWorkspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + _ = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + ctx = testutil.Context(t, testutil.WaitMedium) + ) + + group, err := client.CreateGroup(ctx, orgOwner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "wibble", + }) + require.NoError(t, err, "create group") + err = client.UpdateWorkspaceACL(ctx, sharedWorkspace.ID, codersdk.UpdateWorkspaceACL{ + GroupRoles: map[string]codersdk.WorkspaceRole{ + group.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + require.NoError(t, err) + + workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + SharedWithGroup: group.ID.String(), + }) + require.NoError(t, err) + require.Equal(t, 1, workspaces.Count) + require.Equal(t, sharedWorkspace.ID, workspaces.Workspaces[0].ID) + }) + + t.Run("SharedWithGroupFilter", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)} + + var ( + client, db, orgOwner = coderdenttest.NewWithDatabase(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureTemplateRBAC: 1, + }, + }, + }) + _, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID)) + sharedWorkspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + _ = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OwnerID: workspaceOwner.ID, + OrganizationID: orgOwner.OrganizationID, + }).Do().Workspace + ctx = testutil.Context(t, testutil.WaitMedium) + ) + + group, err := client.CreateGroup(ctx, orgOwner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "wibble", + }) + require.NoError(t, err, "create group") + err = client.UpdateWorkspaceACL(ctx, sharedWorkspace.ID, codersdk.UpdateWorkspaceACL{ + GroupRoles: map[string]codersdk.WorkspaceRole{ + group.ID.String(): codersdk.WorkspaceRoleUse, + }, + }) + require.NoError(t, err) + + workspacesByID, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + SharedWithGroup: group.ID.String(), + }) + require.NoError(t, err) + require.Equal(t, 1, workspacesByID.Count) + require.Equal(t, sharedWorkspace.ID, workspacesByID.Workspaces[0].ID) + + workspacesByName, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + SharedWithGroup: group.Name, + }) + require.NoError(t, err) + require.Equal(t, 1, workspacesByName.Count) + require.Equal(t, sharedWorkspace.ID, workspacesByName.Workspaces[0].ID) + + workspacesByOrgAndName, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + SharedWithGroup: fmt.Sprintf("coder/%s", group.Name), + }) + require.NoError(t, err) + require.Equal(t, 1, workspacesByOrgAndName.Count) + require.Equal(t, sharedWorkspace.ID, workspacesByOrgAndName.Workspaces[0].ID) + }) } // TestWorkspacesWithoutTemplatePerms creates a workspace for a user, then drops