feat: add new group members endpoint with filtering and pagination (#23067)

Partially addresses #21813 (still need to make changes to the "add user"
button to be complete)

Since there are a lot of user tests already, I moved them into
`coderdtest` to be shared.
This commit is contained in:
Asher
2026-03-20 12:43:03 -08:00
committed by GitHub
parent f135ffdb3a
commit 24ab216dd1
22 changed files with 1742 additions and 724 deletions
+146
View File
@@ -1520,6 +1520,12 @@ const docTemplate = `{
"name": "group",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "Exclude members from the response",
"name": "exclude_members",
"in": "query"
}
],
"responses": {
@@ -1613,6 +1619,65 @@ const docTemplate = `{
]
}
},
"/groups/{group}/members": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get group members by group ID",
"operationId": "get-group-members-by-group-id",
"parameters": [
{
"type": "string",
"description": "Group id",
"name": "group",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Member search query",
"name": "q",
"in": "query"
},
{
"type": "string",
"format": "uuid",
"description": "After ID",
"name": "after_id",
"in": "query"
},
{
"type": "integer",
"description": "Page limit",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Page offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.GroupMembersResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/init-script/{os}/{arch}": {
"get": {
"produces": [
@@ -3388,6 +3453,73 @@ const docTemplate = `{
]
}
},
"/organizations/{organization}/groups/{groupName}/members": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Enterprise"
],
"summary": "Get group members by organization and group name",
"operationId": "get-group-members-by-organization-and-group-name",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Group name",
"name": "groupName",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Member search query",
"name": "q",
"in": "query"
},
{
"type": "string",
"format": "uuid",
"description": "After ID",
"name": "after_id",
"in": "query"
},
{
"type": "integer",
"description": "Page limit",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Page offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.GroupMembersResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/organizations/{organization}/members": {
"get": {
"produces": [
@@ -15749,6 +15881,20 @@ const docTemplate = `{
}
}
},
"codersdk.GroupMembersResponse": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"users": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ReducedUser"
}
}
}
},
"codersdk.GroupSource": {
"type": "string",
"enum": [
+138
View File
@@ -1323,6 +1323,12 @@
"name": "group",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "Exclude members from the response",
"name": "exclude_members",
"in": "query"
}
],
"responses": {
@@ -1406,6 +1412,61 @@
]
}
},
"/groups/{group}/members": {
"get": {
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get group members by group ID",
"operationId": "get-group-members-by-group-id",
"parameters": [
{
"type": "string",
"description": "Group id",
"name": "group",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Member search query",
"name": "q",
"in": "query"
},
{
"type": "string",
"format": "uuid",
"description": "After ID",
"name": "after_id",
"in": "query"
},
{
"type": "integer",
"description": "Page limit",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Page offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.GroupMembersResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/init-script/{os}/{arch}": {
"get": {
"produces": ["text/plain"],
@@ -2975,6 +3036,69 @@
]
}
},
"/organizations/{organization}/groups/{groupName}/members": {
"get": {
"produces": ["application/json"],
"tags": ["Enterprise"],
"summary": "Get group members by organization and group name",
"operationId": "get-group-members-by-organization-and-group-name",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Organization ID",
"name": "organization",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Group name",
"name": "groupName",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Member search query",
"name": "q",
"in": "query"
},
{
"type": "string",
"format": "uuid",
"description": "After ID",
"name": "after_id",
"in": "query"
},
{
"type": "integer",
"description": "Page limit",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"description": "Page offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.GroupMembersResponse"
}
}
},
"security": [
{
"CoderSessionToken": []
}
]
}
},
"/organizations/{organization}/members": {
"get": {
"produces": ["application/json"],
@@ -14257,6 +14381,20 @@
}
}
},
"codersdk.GroupMembersResponse": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"users": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ReducedUser"
}
}
}
},
"codersdk.GroupSource": {
"type": "string",
"enum": ["user", "oidc"],
+5 -4
View File
@@ -900,9 +900,10 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI
require.NoError(t, err)
var sessionToken string
if req.UserLoginType == codersdk.LoginTypeNone {
// Cannot log in with a disabled login user. So make it an api key from
// the client making this user.
switch req.UserLoginType {
case codersdk.LoginTypeNone, codersdk.LoginTypeGithub, codersdk.LoginTypeOIDC:
// Cannot log in with a non-password user. So make it an api key from the
// client making this user.
token, err := client.CreateToken(context.Background(), user.ID.String(), codersdk.CreateTokenRequest{
Lifetime: time.Hour * 24,
Scope: codersdk.APIKeyScopeAll,
@@ -910,7 +911,7 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI
})
require.NoError(t, err)
sessionToken = token.Key
} else {
default:
login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Email: req.Email,
Password: req.Password,
+584
View File
@@ -0,0 +1,584 @@
package coderdtest
import (
"context"
"database/sql"
"fmt"
"slices"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/userpassword"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
// UsersPagination creates a set of users for testing pagination. It can be
// used to test paginating both users and group members.
func UsersPagination(
ctx context.Context,
t *testing.T,
client *codersdk.Client,
setup func(users []codersdk.User),
fetch func(req codersdk.UsersRequest) ([]codersdk.ReducedUser, int),
) {
t.Helper()
firstUser, err := client.User(ctx, codersdk.Me)
require.NoError(t, err, "fetch me")
count := 10
users := make([]codersdk.User, count)
orgID := firstUser.OrganizationIDs[0]
users[0] = firstUser
for i := range count - 1 {
_, user := CreateAnotherUserMutators(t, client, orgID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
if i < 5 {
r.Name = fmt.Sprintf("before%d", i)
} else {
r.Name = fmt.Sprintf("after%d", i)
}
})
users[i+1] = user
}
slices.SortFunc(users, func(a, b codersdk.User) int {
return slice.Ascending(strings.ToLower(a.Username), strings.ToLower(b.Username))
})
if setup != nil {
setup(users)
}
gotUsers, gotCount := fetch(codersdk.UsersRequest{})
require.Len(t, gotUsers, count)
require.Equal(t, gotCount, count)
gotUsers, gotCount = fetch(codersdk.UsersRequest{
Pagination: codersdk.Pagination{
Limit: 1,
},
})
require.Len(t, gotUsers, 1)
require.Equal(t, gotCount, count)
gotUsers, gotCount = fetch(codersdk.UsersRequest{
Pagination: codersdk.Pagination{
Offset: 1,
},
})
require.Len(t, gotUsers, count-1)
require.Equal(t, gotCount, count)
gotUsers, gotCount = fetch(codersdk.UsersRequest{
Pagination: codersdk.Pagination{
Limit: 1,
Offset: 1,
},
})
require.Len(t, gotUsers, 1)
require.Equal(t, gotCount, count)
// If offset is higher than the count postgres returns an empty array
// and not an ErrNoRows error.
gotUsers, gotCount = fetch(codersdk.UsersRequest{
Pagination: codersdk.Pagination{
Offset: count + 1,
},
})
require.Len(t, gotUsers, 0)
require.Equal(t, gotCount, 0)
// Check that AfterID works.
gotUsers, gotCount = fetch(codersdk.UsersRequest{
Pagination: codersdk.Pagination{
AfterID: users[5].ID,
},
})
require.NoError(t, err)
require.Len(t, gotUsers, 4)
require.Equal(t, gotCount, 4)
// Check we can paginate a filtered response.
gotUsers, gotCount = fetch(codersdk.UsersRequest{
SearchQuery: "name:after",
Pagination: codersdk.Pagination{
Limit: 1,
Offset: 1,
},
})
require.NoError(t, err)
require.Len(t, gotUsers, 1)
require.Equal(t, gotCount, 4)
require.Contains(t, gotUsers[0].Name, "after")
}
// UsersFilter creates a set of users to run various filters against for
// testing. It can be used to test filtering both users and group members.
func UsersFilter(
setupCtx context.Context,
t *testing.T,
client *codersdk.Client,
db database.Store,
setup func(users []codersdk.User),
fetch func(ctx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser,
) {
t.Helper()
firstUser, err := client.User(setupCtx, codersdk.Me)
require.NoError(t, err, "fetch me")
// Noon on Jan 18 is the "now" for this test for last_seen timestamps.
// All these values are equal
// 2023-01-18T12:00:00Z (UTC)
// 2023-01-18T07:00:00-05:00 (America/New_York)
// 2023-01-18T13:00:00+01:00 (Europe/Madrid)
// 2023-01-16T00:00:00+12:00 (Asia/Anadyr)
lastSeenNow := time.Date(2023, 1, 18, 12, 0, 0, 0, time.UTC)
users := make([]codersdk.User, 0)
users = append(users, firstUser)
orgID := firstUser.OrganizationIDs[0]
for i := range 15 {
roles := []rbac.RoleIdentifier{}
if i%2 == 0 {
roles = append(roles, rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin())
}
if i%3 == 0 {
roles = append(roles, rbac.RoleAuditor())
}
userClient, userData := CreateAnotherUserMutators(t, client, orgID, roles, func(r *codersdk.CreateUserRequestWithOrgs) {
switch {
case i%7 == 0:
r.Username += fmt.Sprintf("-gh%d", i)
r.UserLoginType = codersdk.LoginTypeGithub
r.Password = ""
case i%6 == 0:
r.UserLoginType = codersdk.LoginTypeOIDC
r.Password = ""
default:
r.UserLoginType = codersdk.LoginTypePassword
}
})
// Set the last seen for each user to a unique day
// nolint:gocritic // Setting up unit test data.
_, err := db.UpdateUserLastSeenAt(dbauthz.AsSystemRestricted(setupCtx), database.UpdateUserLastSeenAtParams{
ID: userData.ID,
LastSeenAt: lastSeenNow.Add(-1 * time.Hour * 24 * time.Duration(i)),
UpdatedAt: time.Now(),
})
require.NoError(t, err, "set a last seen")
// Set a github user ID for github login types.
if i%7 == 0 {
// nolint:gocritic // Setting up unit test data.
err = db.UpdateUserGithubComUserID(dbauthz.AsSystemRestricted(setupCtx), database.UpdateUserGithubComUserIDParams{
ID: userData.ID,
GithubComUserID: sql.NullInt64{
Int64: int64(i),
Valid: true,
},
})
require.NoError(t, err)
}
user, err := userClient.User(setupCtx, codersdk.Me)
require.NoError(t, err, "fetch me")
if i%4 == 0 {
user, err = client.UpdateUserStatus(setupCtx, user.ID.String(), codersdk.UserStatusSuspended)
require.NoError(t, err, "suspend user")
}
if i%5 == 0 {
user, err = client.UpdateUserProfile(setupCtx, user.ID.String(), codersdk.UpdateUserProfileRequest{
Username: strings.ToUpper(user.Username),
})
require.NoError(t, err, "update username to uppercase")
}
users = append(users, user)
}
hashedPassword, err := userpassword.Hash("SomeStrongPassword!")
require.NoError(t, err)
// Add users with different creation dates for testing date filters
for i := range 3 {
// nolint:gocritic // Setting up unit test data.
user1, err := db.InsertUser(dbauthz.AsSystemRestricted(setupCtx), database.InsertUserParams{
ID: uuid.New(),
Email: fmt.Sprintf("before%d@coder.com", i),
Username: fmt.Sprintf("before%d", i),
Name: fmt.Sprintf("Test User %d", i),
HashedPassword: []byte(hashedPassword),
LoginType: database.LoginTypeNone,
Status: string(codersdk.UserStatusActive),
RBACRoles: []string{codersdk.RoleMember},
CreatedAt: dbtime.Time(time.Date(2022, 12, 15+i, 12, 0, 0, 0, time.UTC)),
UpdatedAt: dbtime.Time(time.Date(2022, 12, 15+i, 12, 0, 0, 0, time.UTC)),
IsServiceAccount: false,
})
require.NoError(t, err)
// nolint:gocritic // Setting up unit test data.
_, err = db.InsertOrganizationMember(dbauthz.AsSystemRestricted(setupCtx), database.InsertOrganizationMemberParams{
OrganizationID: orgID,
UserID: user1.ID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
Roles: []string{},
})
require.NoError(t, err)
// The expected timestamps must be parsed from strings to compare equal during `ElementsMatch`
sdkUser1 := db2sdk.User(user1, []uuid.UUID{orgID})
sdkUser1.CreatedAt, err = time.Parse(time.RFC3339, sdkUser1.CreatedAt.Format(time.RFC3339))
require.NoError(t, err)
sdkUser1.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser1.UpdatedAt.Format(time.RFC3339))
require.NoError(t, err)
sdkUser1.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser1.LastSeenAt.Format(time.RFC3339))
require.NoError(t, err)
users = append(users, sdkUser1)
// nolint:gocritic // Setting up unit test data.
user2, err := db.InsertUser(dbauthz.AsSystemRestricted(setupCtx), database.InsertUserParams{
ID: uuid.New(),
Email: fmt.Sprintf("during%d@coder.com", i),
Username: fmt.Sprintf("during%d", i),
Name: "",
HashedPassword: []byte(hashedPassword),
LoginType: database.LoginTypeNone,
Status: string(codersdk.UserStatusActive),
RBACRoles: []string{codersdk.RoleOwner},
CreatedAt: dbtime.Time(time.Date(2023, 1, 15+i, 12, 0, 0, 0, time.UTC)),
UpdatedAt: dbtime.Time(time.Date(2023, 1, 15+i, 12, 0, 0, 0, time.UTC)),
IsServiceAccount: false,
})
require.NoError(t, err)
// nolint:gocritic // Setting up unit test data.
_, err = db.InsertOrganizationMember(dbauthz.AsSystemRestricted(setupCtx), database.InsertOrganizationMemberParams{
OrganizationID: orgID,
UserID: user2.ID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
Roles: []string{},
})
require.NoError(t, err)
sdkUser2 := db2sdk.User(user2, []uuid.UUID{orgID})
sdkUser2.CreatedAt, err = time.Parse(time.RFC3339, sdkUser2.CreatedAt.Format(time.RFC3339))
require.NoError(t, err)
sdkUser2.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser2.UpdatedAt.Format(time.RFC3339))
require.NoError(t, err)
sdkUser2.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser2.LastSeenAt.Format(time.RFC3339))
require.NoError(t, err)
users = append(users, sdkUser2)
// nolint:gocritic // Setting up unit test data.
user3, err := db.InsertUser(dbauthz.AsSystemRestricted(setupCtx), database.InsertUserParams{
ID: uuid.New(),
Email: fmt.Sprintf("after%d@coder.com", i),
Username: fmt.Sprintf("after%d", i),
Name: "",
HashedPassword: []byte(hashedPassword),
LoginType: database.LoginTypeNone,
Status: string(codersdk.UserStatusActive),
RBACRoles: []string{codersdk.RoleOwner},
CreatedAt: dbtime.Time(time.Date(2023, 2, 15+i, 12, 0, 0, 0, time.UTC)),
UpdatedAt: dbtime.Time(time.Date(2023, 2, 15+i, 12, 0, 0, 0, time.UTC)),
IsServiceAccount: false,
})
require.NoError(t, err)
// nolint:gocritic // Setting up unit test data.
_, err = db.InsertOrganizationMember(dbauthz.AsSystemRestricted(setupCtx), database.InsertOrganizationMemberParams{
OrganizationID: orgID,
UserID: user3.ID,
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
Roles: []string{},
})
require.NoError(t, err)
sdkUser3 := db2sdk.User(user3, []uuid.UUID{orgID})
sdkUser3.CreatedAt, err = time.Parse(time.RFC3339, sdkUser3.CreatedAt.Format(time.RFC3339))
require.NoError(t, err)
sdkUser3.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser3.UpdatedAt.Format(time.RFC3339))
require.NoError(t, err)
sdkUser3.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser3.LastSeenAt.Format(time.RFC3339))
require.NoError(t, err)
users = append(users, sdkUser3)
}
if setup != nil {
setup(users)
}
// --- Setup done ---
testCases := []struct {
Name string
Filter codersdk.UsersRequest
// If FilterF is true, we include it in the expected results
FilterF func(f codersdk.UsersRequest, user codersdk.User) bool
}{
{
Name: "All",
Filter: codersdk.UsersRequest{
Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive,
},
FilterF: func(_ codersdk.UsersRequest, _ codersdk.User) bool {
return true
},
},
{
Name: "Active",
Filter: codersdk.UsersRequest{
Status: codersdk.UserStatusActive,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return u.Status == codersdk.UserStatusActive
},
},
{
Name: "GithubComUserID",
Filter: codersdk.UsersRequest{
SearchQuery: "github_com_user_id:7",
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return strings.HasSuffix(u.Username, "-gh7")
},
},
{
Name: "ActiveUppercase",
Filter: codersdk.UsersRequest{
Status: "ACTIVE",
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return u.Status == codersdk.UserStatusActive
},
},
{
Name: "Suspended",
Filter: codersdk.UsersRequest{
Status: codersdk.UserStatusSuspended,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return u.Status == codersdk.UserStatusSuspended
},
},
{
Name: "NameContains",
Filter: codersdk.UsersRequest{
Search: "a",
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return (strings.ContainsAny(u.Username, "aA") || strings.ContainsAny(u.Email, "aA"))
},
},
{
Name: "NameAndSearch",
Filter: codersdk.UsersRequest{
SearchQuery: "name:Test search:before1",
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return u.Username == "before1"
},
},
{
Name: "NameNoMatch",
Filter: codersdk.UsersRequest{
Search: "nonexistent",
},
FilterF: func(_ codersdk.UsersRequest, _ codersdk.User) bool {
return false
},
},
{
Name: "Admins",
Filter: codersdk.UsersRequest{
Role: codersdk.RoleOwner,
Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
for _, r := range u.Roles {
if r.Name == codersdk.RoleOwner {
return true
}
}
return false
},
},
{
Name: "AdminsUppercase",
Filter: codersdk.UsersRequest{
Role: "OWNER",
Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
for _, r := range u.Roles {
if r.Name == codersdk.RoleOwner {
return true
}
}
return false
},
},
{
Name: "Members",
Filter: codersdk.UsersRequest{
Role: codersdk.RoleMember,
Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive,
},
FilterF: func(_ codersdk.UsersRequest, _ codersdk.User) bool {
return true
},
},
{
Name: "SearchQuery",
Filter: codersdk.UsersRequest{
SearchQuery: "i role:owner status:active",
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
for _, r := range u.Roles {
if r.Name == codersdk.RoleOwner {
return (strings.ContainsAny(u.Username, "iI") || strings.ContainsAny(u.Email, "iI")) &&
u.Status == codersdk.UserStatusActive
}
}
return false
},
},
{
Name: "SearchQueryInsensitive",
Filter: codersdk.UsersRequest{
SearchQuery: "i Role:Owner STATUS:Active",
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
for _, r := range u.Roles {
if r.Name == codersdk.RoleOwner {
return (strings.ContainsAny(u.Username, "iI") || strings.ContainsAny(u.Email, "iI")) &&
u.Status == codersdk.UserStatusActive
}
}
return false
},
},
{
Name: "LastSeenBeforeNow",
Filter: codersdk.UsersRequest{
SearchQuery: `last_seen_before:"2023-01-16T00:00:00+12:00"`,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return u.LastSeenAt.Before(lastSeenNow)
},
},
{
Name: "LastSeenLastWeek",
Filter: codersdk.UsersRequest{
SearchQuery: `last_seen_before:"2023-01-14T23:59:59Z" last_seen_after:"2023-01-08T00:00:00Z"`,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
start := time.Date(2023, 1, 8, 0, 0, 0, 0, time.UTC)
end := time.Date(2023, 1, 14, 23, 59, 59, 0, time.UTC)
return u.LastSeenAt.Before(end) && u.LastSeenAt.After(start)
},
},
{
Name: "CreatedAtBefore",
Filter: codersdk.UsersRequest{
SearchQuery: `created_before:"2023-01-31T23:59:59Z"`,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
end := time.Date(2023, 1, 31, 23, 59, 59, 0, time.UTC)
return u.CreatedAt.Before(end)
},
},
{
Name: "CreatedAtAfter",
Filter: codersdk.UsersRequest{
SearchQuery: `created_after:"2023-01-01T00:00:00Z"`,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
return u.CreatedAt.After(start)
},
},
{
Name: "CreatedAtRange",
Filter: codersdk.UsersRequest{
SearchQuery: `created_after:"2023-01-01T00:00:00Z" created_before:"2023-01-31T23:59:59Z"`,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2023, 1, 31, 23, 59, 59, 0, time.UTC)
return u.CreatedAt.After(start) && u.CreatedAt.Before(end)
},
},
{
Name: "LoginTypeNone",
Filter: codersdk.UsersRequest{
LoginType: []codersdk.LoginType{codersdk.LoginTypeNone},
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return u.LoginType == codersdk.LoginTypeNone
},
},
{
Name: "LoginTypeOIDC",
Filter: codersdk.UsersRequest{
LoginType: []codersdk.LoginType{codersdk.LoginTypeOIDC},
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return u.LoginType == codersdk.LoginTypeOIDC
},
},
{
Name: "LoginTypeMultiple",
Filter: codersdk.UsersRequest{
LoginType: []codersdk.LoginType{codersdk.LoginTypeNone, codersdk.LoginTypeGithub},
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return u.LoginType == codersdk.LoginTypeNone || u.LoginType == codersdk.LoginTypeGithub
},
},
{
Name: "DormantUserWithLoginTypeNone",
Filter: codersdk.UsersRequest{
Status: codersdk.UserStatusSuspended,
LoginType: []codersdk.LoginType{codersdk.LoginTypeNone},
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return u.Status == codersdk.UserStatusSuspended && u.LoginType == codersdk.LoginTypeNone
},
},
}
for _, c := range testCases {
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
testCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
got := fetch(testCtx, c.Filter)
exp := make([]codersdk.ReducedUser, 0)
for _, made := range users {
match := c.FilterF(c.Filter, made)
if match {
exp = append(exp, made.ReducedUser)
}
}
require.ElementsMatch(t, exp, got, "expected users returned")
})
}
}
+28
View File
@@ -234,6 +234,34 @@ func ReducedUsersFromGroupMembers(members []database.GroupMember) []codersdk.Red
return slice.List(members, ReducedUserFromGroupMember)
}
func UserFromGroupMemberRow(member database.GetGroupMembersByGroupIDPaginatedRow) database.User {
return database.User{
ID: member.UserID,
Email: member.UserEmail,
Username: member.UserUsername,
HashedPassword: member.UserHashedPassword,
CreatedAt: member.UserCreatedAt,
UpdatedAt: member.UserUpdatedAt,
Status: member.UserStatus,
RBACRoles: member.UserRbacRoles,
LoginType: member.UserLoginType,
AvatarURL: member.UserAvatarUrl,
Deleted: member.UserDeleted,
LastSeenAt: member.UserLastSeenAt,
QuietHoursSchedule: member.UserQuietHoursSchedule,
Name: member.UserName,
GithubComUserID: member.UserGithubComUserID,
}
}
func ReducedUserFromGroupMemberRow(member database.GetGroupMembersByGroupIDPaginatedRow) codersdk.ReducedUser {
return ReducedUser(UserFromGroupMemberRow(member))
}
func ReducedUsersFromGroupMemberRows(members []database.GetGroupMembersByGroupIDPaginatedRow) []codersdk.ReducedUser {
return slice.List(members, ReducedUserFromGroupMemberRow)
}
func ReducedUsers(users []database.User) []codersdk.ReducedUser {
return slice.List(users, ReducedUser)
}
+4
View File
@@ -2892,6 +2892,10 @@ func (q *querier) GetGroupMembersByGroupID(ctx context.Context, arg database.Get
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetGroupMembersByGroupID)(ctx, arg)
}
func (q *querier) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg database.GetGroupMembersByGroupIDPaginatedParams) ([]database.GetGroupMembersByGroupIDPaginatedRow, error) {
return fetchWithPostFilter(q.auth, policy.ActionRead, q.db.GetGroupMembersByGroupIDPaginated)(ctx, arg)
}
func (q *querier) GetGroupMembersCountByGroupID(ctx context.Context, arg database.GetGroupMembersCountByGroupIDParams) (int64, error) {
if _, err := q.GetGroupByID(ctx, arg.GroupID); err != nil { // AuthZ check
return 0, err
+9
View File
@@ -1188,6 +1188,15 @@ func (s *MethodTestSuite) TestGroup() {
check.Args(arg).Asserts(gm, policy.ActionRead)
}))
s.Run("GetGroupMembersByGroupIDPaginated", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
g := testutil.Fake(s.T(), faker, database.Group{})
u := testutil.Fake(s.T(), faker, database.User{})
gm := testutil.Fake(s.T(), faker, database.GetGroupMembersByGroupIDPaginatedRow{GroupID: g.ID, UserID: u.ID})
arg := database.GetGroupMembersByGroupIDPaginatedParams{GroupID: g.ID, IncludeSystem: false}
dbm.EXPECT().GetGroupMembersByGroupIDPaginated(gomock.Any(), arg).Return([]database.GetGroupMembersByGroupIDPaginatedRow{gm}, nil).AnyTimes()
check.Args(arg).Asserts(gm, policy.ActionRead)
}))
s.Run("GetGroupMembersCountByGroupID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
g := testutil.Fake(s.T(), faker, database.Group{})
arg := database.GetGroupMembersCountByGroupIDParams{GroupID: g.ID, IncludeSystem: false}
@@ -1472,6 +1472,14 @@ func (m queryMetricsStore) GetGroupMembersByGroupID(ctx context.Context, arg dat
return r0, r1
}
func (m queryMetricsStore) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg database.GetGroupMembersByGroupIDPaginatedParams) ([]database.GetGroupMembersByGroupIDPaginatedRow, error) {
start := time.Now()
r0, r1 := m.s.GetGroupMembersByGroupIDPaginated(ctx, arg)
m.queryLatencies.WithLabelValues("GetGroupMembersByGroupIDPaginated").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetGroupMembersByGroupIDPaginated").Inc()
return r0, r1
}
func (m queryMetricsStore) GetGroupMembersCountByGroupID(ctx context.Context, arg database.GetGroupMembersCountByGroupIDParams) (int64, error) {
start := time.Now()
r0, r1 := m.s.GetGroupMembersCountByGroupID(ctx, arg)
+15
View File
@@ -2704,6 +2704,21 @@ func (mr *MockStoreMockRecorder) GetGroupMembersByGroupID(ctx, arg any) *gomock.
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupMembersByGroupID", reflect.TypeOf((*MockStore)(nil).GetGroupMembersByGroupID), ctx, arg)
}
// GetGroupMembersByGroupIDPaginated mocks base method.
func (m *MockStore) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg database.GetGroupMembersByGroupIDPaginatedParams) ([]database.GetGroupMembersByGroupIDPaginatedRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetGroupMembersByGroupIDPaginated", ctx, arg)
ret0, _ := ret[0].([]database.GetGroupMembersByGroupIDPaginatedRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetGroupMembersByGroupIDPaginated indicates an expected call of GetGroupMembersByGroupIDPaginated.
func (mr *MockStoreMockRecorder) GetGroupMembersByGroupIDPaginated(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGroupMembersByGroupIDPaginated", reflect.TypeOf((*MockStore)(nil).GetGroupMembersByGroupIDPaginated), ctx, arg)
}
// GetGroupMembersCountByGroupID mocks base method.
func (m *MockStore) GetGroupMembersCountByGroupID(ctx context.Context, arg database.GetGroupMembersCountByGroupIDParams) (int64, error) {
m.ctrl.T.Helper()
+4
View File
@@ -393,6 +393,10 @@ func (gm GroupMember) RBACObject() rbac.Object {
return rbac.ResourceGroupMember.WithID(gm.UserID).InOrg(gm.OrganizationID).WithOwner(gm.UserID.String())
}
func (gm GetGroupMembersByGroupIDPaginatedRow) RBACObject() rbac.Object {
return rbac.ResourceGroupMember.WithID(gm.UserID).InOrg(gm.OrganizationID).WithOwner(gm.UserID.String())
}
// PrebuiltWorkspaceResource defines the interface for types that can be identified as prebuilt workspaces
// and converted to their corresponding prebuilt workspace RBAC object.
type PrebuiltWorkspaceResource interface {
+1
View File
@@ -297,6 +297,7 @@ type sqlcQuerier interface {
GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error)
GetGroupMembers(ctx context.Context, includeSystem bool) ([]GroupMember, error)
GetGroupMembersByGroupID(ctx context.Context, arg GetGroupMembersByGroupIDParams) ([]GroupMember, error)
GetGroupMembersByGroupIDPaginated(ctx context.Context, arg GetGroupMembersByGroupIDPaginatedParams) ([]GetGroupMembersByGroupIDPaginatedRow, error)
// Returns the total count of members in a group. Shows the total
// count even if the caller does not have read access to ResourceGroupMember.
// They only need ResourceGroup read access.
+206
View File
@@ -7404,6 +7404,212 @@ func (q *sqlQuerier) GetGroupMembersByGroupID(ctx context.Context, arg GetGroupM
return items, nil
}
const getGroupMembersByGroupIDPaginated = `-- name: GetGroupMembersByGroupIDPaginated :many
SELECT
user_id, user_email, user_username, user_hashed_password, user_created_at, user_updated_at, user_status, user_rbac_roles, user_login_type, user_avatar_url, user_deleted, user_last_seen_at, user_quiet_hours_schedule, user_name, user_github_com_user_id, user_is_system, organization_id, group_name, group_id, COUNT(*) OVER() AS count
FROM
group_members_expanded
WHERE
group_members_expanded.group_id = $1
AND CASE
-- This allows using the last element on a page as effectively a cursor.
-- This is an important option for scripts that need to paginate without
-- duplicating or missing data.
WHEN $2 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
-- The pagination cursor is the last ID of the previous page.
-- The query is ordered by the username field, so select all
-- rows after the cursor.
(LOWER(user_username)) > (
SELECT
LOWER(user_username)
FROM
group_members_expanded
WHERE
group_id = $1
AND user_id = $2
)
)
ELSE true
END
-- Start filters
-- Filter by email or username
AND CASE
WHEN $3 :: text != '' THEN (
user_email ILIKE concat('%', $3, '%')
OR user_username ILIKE concat('%', $3, '%')
)
ELSE true
END
-- Filter by name (display name)
AND CASE
WHEN $4 :: text != '' THEN
user_name ILIKE concat('%', $4, '%')
ELSE true
END
-- Filter by status
AND CASE
-- @status needs to be a text because it can be empty, If it was
-- user_status enum, it would not.
WHEN cardinality($5 :: user_status[]) > 0 THEN
user_status = ANY($5 :: user_status[])
ELSE true
END
-- Filter by rbac_roles
AND CASE
-- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as
-- everyone is a member.
WHEN cardinality($6 :: text[]) > 0 AND 'member' != ANY($6 :: text[]) THEN
user_rbac_roles && $6 :: text[]
ELSE true
END
-- Filter by last_seen
AND CASE
WHEN $7 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_last_seen_at <= $7
ELSE true
END
AND CASE
WHEN $8 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_last_seen_at >= $8
ELSE true
END
-- Filter by created_at
AND CASE
WHEN $9 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_created_at <= $9
ELSE true
END
AND CASE
WHEN $10 :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_created_at >= $10
ELSE true
END
-- Filter by system type
AND CASE
WHEN $11::bool THEN TRUE
ELSE user_is_system = false
END
AND CASE
WHEN $12 :: bigint != 0 THEN
user_github_com_user_id = $12
ELSE true
END
-- Filter by login_type
AND CASE
WHEN cardinality($13 :: login_type[]) > 0 THEN
user_login_type = ANY($13 :: login_type[])
ELSE true
END
-- End of filters
ORDER BY
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
LOWER(user_username) ASC OFFSET $14
LIMIT
-- A null limit means "no limit", so 0 means return all
NULLIF($15 :: int, 0)
`
type GetGroupMembersByGroupIDPaginatedParams struct {
GroupID uuid.UUID `db:"group_id" json:"group_id"`
AfterID uuid.UUID `db:"after_id" json:"after_id"`
Search string `db:"search" json:"search"`
Name string `db:"name" json:"name"`
Status []UserStatus `db:"status" json:"status"`
RbacRole []string `db:"rbac_role" json:"rbac_role"`
LastSeenBefore time.Time `db:"last_seen_before" json:"last_seen_before"`
LastSeenAfter time.Time `db:"last_seen_after" json:"last_seen_after"`
CreatedBefore time.Time `db:"created_before" json:"created_before"`
CreatedAfter time.Time `db:"created_after" json:"created_after"`
IncludeSystem bool `db:"include_system" json:"include_system"`
GithubComUserID int64 `db:"github_com_user_id" json:"github_com_user_id"`
LoginType []LoginType `db:"login_type" json:"login_type"`
OffsetOpt int32 `db:"offset_opt" json:"offset_opt"`
LimitOpt int32 `db:"limit_opt" json:"limit_opt"`
}
type GetGroupMembersByGroupIDPaginatedRow struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
UserEmail string `db:"user_email" json:"user_email"`
UserUsername string `db:"user_username" json:"user_username"`
UserHashedPassword []byte `db:"user_hashed_password" json:"user_hashed_password"`
UserCreatedAt time.Time `db:"user_created_at" json:"user_created_at"`
UserUpdatedAt time.Time `db:"user_updated_at" json:"user_updated_at"`
UserStatus UserStatus `db:"user_status" json:"user_status"`
UserRbacRoles []string `db:"user_rbac_roles" json:"user_rbac_roles"`
UserLoginType LoginType `db:"user_login_type" json:"user_login_type"`
UserAvatarUrl string `db:"user_avatar_url" json:"user_avatar_url"`
UserDeleted bool `db:"user_deleted" json:"user_deleted"`
UserLastSeenAt time.Time `db:"user_last_seen_at" json:"user_last_seen_at"`
UserQuietHoursSchedule string `db:"user_quiet_hours_schedule" json:"user_quiet_hours_schedule"`
UserName string `db:"user_name" json:"user_name"`
UserGithubComUserID sql.NullInt64 `db:"user_github_com_user_id" json:"user_github_com_user_id"`
UserIsSystem bool `db:"user_is_system" json:"user_is_system"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
GroupName string `db:"group_name" json:"group_name"`
GroupID uuid.UUID `db:"group_id" json:"group_id"`
Count int64 `db:"count" json:"count"`
}
func (q *sqlQuerier) GetGroupMembersByGroupIDPaginated(ctx context.Context, arg GetGroupMembersByGroupIDPaginatedParams) ([]GetGroupMembersByGroupIDPaginatedRow, error) {
rows, err := q.db.QueryContext(ctx, getGroupMembersByGroupIDPaginated,
arg.GroupID,
arg.AfterID,
arg.Search,
arg.Name,
pq.Array(arg.Status),
pq.Array(arg.RbacRole),
arg.LastSeenBefore,
arg.LastSeenAfter,
arg.CreatedBefore,
arg.CreatedAfter,
arg.IncludeSystem,
arg.GithubComUserID,
pq.Array(arg.LoginType),
arg.OffsetOpt,
arg.LimitOpt,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetGroupMembersByGroupIDPaginatedRow
for rows.Next() {
var i GetGroupMembersByGroupIDPaginatedRow
if err := rows.Scan(
&i.UserID,
&i.UserEmail,
&i.UserUsername,
&i.UserHashedPassword,
&i.UserCreatedAt,
&i.UserUpdatedAt,
&i.UserStatus,
pq.Array(&i.UserRbacRoles),
&i.UserLoginType,
&i.UserAvatarUrl,
&i.UserDeleted,
&i.UserLastSeenAt,
&i.UserQuietHoursSchedule,
&i.UserName,
&i.UserGithubComUserID,
&i.UserIsSystem,
&i.OrganizationID,
&i.GroupName,
&i.GroupID,
&i.Count,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getGroupMembersCountByGroupID = `-- name: GetGroupMembersCountByGroupID :one
SELECT COUNT(*)
FROM group_members_expanded
+104
View File
@@ -17,6 +17,110 @@ WHERE group_id = @group_id
user_is_system = false
END;
-- name: GetGroupMembersByGroupIDPaginated :many
SELECT
*, COUNT(*) OVER() AS count
FROM
group_members_expanded
WHERE
group_members_expanded.group_id = @group_id
AND CASE
-- This allows using the last element on a page as effectively a cursor.
-- This is an important option for scripts that need to paginate without
-- duplicating or missing data.
WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
-- The pagination cursor is the last ID of the previous page.
-- The query is ordered by the username field, so select all
-- rows after the cursor.
(LOWER(user_username)) > (
SELECT
LOWER(user_username)
FROM
group_members_expanded
WHERE
group_id = @group_id
AND user_id = @after_id
)
)
ELSE true
END
-- Start filters
-- Filter by email or username
AND CASE
WHEN @search :: text != '' THEN (
user_email ILIKE concat('%', @search, '%')
OR user_username ILIKE concat('%', @search, '%')
)
ELSE true
END
-- Filter by name (display name)
AND CASE
WHEN @name :: text != '' THEN
user_name ILIKE concat('%', @name, '%')
ELSE true
END
-- Filter by status
AND CASE
-- @status needs to be a text because it can be empty, If it was
-- user_status enum, it would not.
WHEN cardinality(@status :: user_status[]) > 0 THEN
user_status = ANY(@status :: user_status[])
ELSE true
END
-- Filter by rbac_roles
AND CASE
-- @rbac_role allows filtering by rbac roles. If 'member' is included, show everyone, as
-- everyone is a member.
WHEN cardinality(@rbac_role :: text[]) > 0 AND 'member' != ANY(@rbac_role :: text[]) THEN
user_rbac_roles && @rbac_role :: text[]
ELSE true
END
-- Filter by last_seen
AND CASE
WHEN @last_seen_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_last_seen_at <= @last_seen_before
ELSE true
END
AND CASE
WHEN @last_seen_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_last_seen_at >= @last_seen_after
ELSE true
END
-- Filter by created_at
AND CASE
WHEN @created_before :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_created_at <= @created_before
ELSE true
END
AND CASE
WHEN @created_after :: timestamp with time zone != '0001-01-01 00:00:00Z' THEN
user_created_at >= @created_after
ELSE true
END
-- Filter by system type
AND CASE
WHEN @include_system::bool THEN TRUE
ELSE user_is_system = false
END
AND CASE
WHEN @github_com_user_id :: bigint != 0 THEN
user_github_com_user_id = @github_com_user_id
ELSE true
END
-- Filter by login_type
AND CASE
WHEN cardinality(@login_type :: login_type[]) > 0 THEN
user_login_type = ANY(@login_type :: login_type[])
ELSE true
END
-- End of filters
ORDER BY
-- Deterministic and consistent ordering of all users. This is to ensure consistent pagination.
LOWER(user_username) ASC OFFSET @offset_opt
LIMIT
-- A null limit means "no limit", so 0 means return all
NULLIF(@limit_opt :: int, 0);
-- name: GetGroupMembersCountByGroupID :one
-- Returns the total count of members in a group. Shows the total
-- count even if the caller does not have read access to ResourceGroupMember.
+26 -674
View File
@@ -2,7 +2,6 @@ package coderd_test
import (
"context"
"database/sql"
"fmt"
"net/http"
"slices"
@@ -22,8 +21,6 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/coderdtest/oidctest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtime"
@@ -1725,693 +1722,48 @@ func TestGetUser(t *testing.T) {
})
}
// TestUsersFilter creates a set of users to run various filters against for testing.
func TestUsersFilter(t *testing.T) {
func TestGetUsersFilter(t *testing.T) {
t.Parallel()
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
first := coderdtest.CreateFirstUser(t, client)
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
OIDCConfig: &coderd.OIDCConfig{
AllowSignups: true,
},
})
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
t.Cleanup(cancel)
setupCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
firstUser, err := client.User(ctx, codersdk.Me)
require.NoError(t, err, "fetch me")
// Noon on Jan 18 is the "now" for this test for last_seen timestamps.
// All these values are equal
// 2023-01-18T12:00:00Z (UTC)
// 2023-01-18T07:00:00-05:00 (America/New_York)
// 2023-01-18T13:00:00+01:00 (Europe/Madrid)
// 2023-01-16T00:00:00+12:00 (Asia/Anadyr)
lastSeenNow := time.Date(2023, 1, 18, 12, 0, 0, 0, time.UTC)
users := make([]codersdk.User, 0)
users = append(users, firstUser)
for i := 0; i < 15; i++ {
roles := []rbac.RoleIdentifier{}
if i%2 == 0 {
roles = append(roles, rbac.RoleTemplateAdmin(), rbac.RoleUserAdmin())
coderdtest.UsersFilter(setupCtx, t, client, api.Database, nil, func(testCtx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser {
res, err := client.Users(testCtx, req)
require.NoError(t, err)
reduced := make([]codersdk.ReducedUser, len(res.Users))
for i, user := range res.Users {
reduced[i] = user.ReducedUser
}
if i%3 == 0 {
roles = append(roles, rbac.RoleAuditor())
}
userClient, userData := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, roles...)
// Set the last seen for each user to a unique day
_, err := api.Database.UpdateUserLastSeenAt(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLastSeenAtParams{
ID: userData.ID,
LastSeenAt: lastSeenNow.Add(-1 * time.Hour * 24 * time.Duration(i)),
UpdatedAt: time.Now(),
})
require.NoError(t, err, "set a last seen")
user, err := userClient.User(ctx, codersdk.Me)
require.NoError(t, err, "fetch me")
if i%4 == 0 {
user, err = client.UpdateUserStatus(ctx, user.ID.String(), codersdk.UserStatusSuspended)
require.NoError(t, err, "suspend user")
}
if i%5 == 0 {
user, err = client.UpdateUserProfile(ctx, user.ID.String(), codersdk.UpdateUserProfileRequest{
Username: strings.ToUpper(user.Username),
})
require.NoError(t, err, "update username to uppercase")
}
users = append(users, user)
}
// Add users with different creation dates for testing date filters
for i := 0; i < 3; i++ {
user1, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{
ID: uuid.New(),
Email: fmt.Sprintf("before%d@coder.com", i),
Username: fmt.Sprintf("before%d", i),
LoginType: database.LoginTypeNone,
Status: string(codersdk.UserStatusActive),
RBACRoles: []string{codersdk.RoleMember},
CreatedAt: dbtime.Time(time.Date(2022, 12, 15+i, 12, 0, 0, 0, time.UTC)),
})
require.NoError(t, err)
// The expected timestamps must be parsed from strings to compare equal during `ElementsMatch`
sdkUser1 := db2sdk.User(user1, nil)
sdkUser1.CreatedAt, err = time.Parse(time.RFC3339, sdkUser1.CreatedAt.Format(time.RFC3339))
require.NoError(t, err)
sdkUser1.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser1.UpdatedAt.Format(time.RFC3339))
require.NoError(t, err)
sdkUser1.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser1.LastSeenAt.Format(time.RFC3339))
require.NoError(t, err)
users = append(users, sdkUser1)
user2, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{
ID: uuid.New(),
Email: fmt.Sprintf("during%d@coder.com", i),
Username: fmt.Sprintf("during%d", i),
LoginType: database.LoginTypeNone,
Status: string(codersdk.UserStatusActive),
RBACRoles: []string{codersdk.RoleOwner},
CreatedAt: dbtime.Time(time.Date(2023, 1, 15+i, 12, 0, 0, 0, time.UTC)),
})
require.NoError(t, err)
sdkUser2 := db2sdk.User(user2, nil)
sdkUser2.CreatedAt, err = time.Parse(time.RFC3339, sdkUser2.CreatedAt.Format(time.RFC3339))
require.NoError(t, err)
sdkUser2.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser2.UpdatedAt.Format(time.RFC3339))
require.NoError(t, err)
sdkUser2.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser2.LastSeenAt.Format(time.RFC3339))
require.NoError(t, err)
users = append(users, sdkUser2)
user3, err := api.Database.InsertUser(dbauthz.AsSystemRestricted(ctx), database.InsertUserParams{
ID: uuid.New(),
Email: fmt.Sprintf("after%d@coder.com", i),
Username: fmt.Sprintf("after%d", i),
LoginType: database.LoginTypeNone,
Status: string(codersdk.UserStatusActive),
RBACRoles: []string{codersdk.RoleOwner},
CreatedAt: dbtime.Time(time.Date(2023, 2, 15+i, 12, 0, 0, 0, time.UTC)),
})
require.NoError(t, err)
sdkUser3 := db2sdk.User(user3, nil)
sdkUser3.CreatedAt, err = time.Parse(time.RFC3339, sdkUser3.CreatedAt.Format(time.RFC3339))
require.NoError(t, err)
sdkUser3.UpdatedAt, err = time.Parse(time.RFC3339, sdkUser3.UpdatedAt.Format(time.RFC3339))
require.NoError(t, err)
sdkUser3.LastSeenAt, err = time.Parse(time.RFC3339, sdkUser3.LastSeenAt.Format(time.RFC3339))
require.NoError(t, err)
users = append(users, sdkUser3)
}
// --- Setup done ---
testCases := []struct {
Name string
Filter codersdk.UsersRequest
// If FilterF is true, we include it in the expected results
FilterF func(f codersdk.UsersRequest, user codersdk.User) bool
}{
{
Name: "All",
Filter: codersdk.UsersRequest{
Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return true
},
},
{
Name: "Active",
Filter: codersdk.UsersRequest{
Status: codersdk.UserStatusActive,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return u.Status == codersdk.UserStatusActive
},
},
{
Name: "ActiveUppercase",
Filter: codersdk.UsersRequest{
Status: "ACTIVE",
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return u.Status == codersdk.UserStatusActive
},
},
{
Name: "Suspended",
Filter: codersdk.UsersRequest{
Status: codersdk.UserStatusSuspended,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return u.Status == codersdk.UserStatusSuspended
},
},
{
Name: "NameContains",
Filter: codersdk.UsersRequest{
Search: "a",
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return (strings.ContainsAny(u.Username, "aA") || strings.ContainsAny(u.Email, "aA"))
},
},
{
Name: "Admins",
Filter: codersdk.UsersRequest{
Role: codersdk.RoleOwner,
Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
for _, r := range u.Roles {
if r.Name == codersdk.RoleOwner {
return true
}
}
return false
},
},
{
Name: "AdminsUppercase",
Filter: codersdk.UsersRequest{
Role: "OWNER",
Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
for _, r := range u.Roles {
if r.Name == codersdk.RoleOwner {
return true
}
}
return false
},
},
{
Name: "Members",
Filter: codersdk.UsersRequest{
Role: codersdk.RoleMember,
Status: codersdk.UserStatusSuspended + "," + codersdk.UserStatusActive,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return true
},
},
{
Name: "SearchQuery",
Filter: codersdk.UsersRequest{
SearchQuery: "i role:owner status:active",
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
for _, r := range u.Roles {
if r.Name == codersdk.RoleOwner {
return (strings.ContainsAny(u.Username, "iI") || strings.ContainsAny(u.Email, "iI")) &&
u.Status == codersdk.UserStatusActive
}
}
return false
},
},
{
Name: "SearchQueryInsensitive",
Filter: codersdk.UsersRequest{
SearchQuery: "i Role:Owner STATUS:Active",
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
for _, r := range u.Roles {
if r.Name == codersdk.RoleOwner {
return (strings.ContainsAny(u.Username, "iI") || strings.ContainsAny(u.Email, "iI")) &&
u.Status == codersdk.UserStatusActive
}
}
return false
},
},
{
Name: "LastSeenBeforeNow",
Filter: codersdk.UsersRequest{
SearchQuery: `last_seen_before:"2023-01-16T00:00:00+12:00"`,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return u.LastSeenAt.Before(lastSeenNow)
},
},
{
Name: "LastSeenLastWeek",
Filter: codersdk.UsersRequest{
SearchQuery: `last_seen_before:"2023-01-14T23:59:59Z" last_seen_after:"2023-01-08T00:00:00Z"`,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
start := time.Date(2023, 1, 8, 0, 0, 0, 0, time.UTC)
end := time.Date(2023, 1, 14, 23, 59, 59, 0, time.UTC)
return u.LastSeenAt.Before(end) && u.LastSeenAt.After(start)
},
},
{
Name: "CreatedAtBefore",
Filter: codersdk.UsersRequest{
SearchQuery: `created_before:"2023-01-31T23:59:59Z"`,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
end := time.Date(2023, 1, 31, 23, 59, 59, 0, time.UTC)
return u.CreatedAt.Before(end)
},
},
{
Name: "CreatedAtAfter",
Filter: codersdk.UsersRequest{
SearchQuery: `created_after:"2023-01-01T00:00:00Z"`,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
return u.CreatedAt.After(start)
},
},
{
Name: "CreatedAtRange",
Filter: codersdk.UsersRequest{
SearchQuery: `created_after:"2023-01-01T00:00:00Z" created_before:"2023-01-31T23:59:59Z"`,
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
start := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2023, 1, 31, 23, 59, 59, 0, time.UTC)
return u.CreatedAt.After(start) && u.CreatedAt.Before(end)
},
},
}
for _, c := range testCases {
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
matched, err := client.Users(ctx, c.Filter)
require.NoError(t, err, "fetch workspaces")
exp := make([]codersdk.User, 0)
for _, made := range users {
match := c.FilterF(c.Filter, made)
if match {
exp = append(exp, made)
}
}
require.ElementsMatch(t, exp, matched.Users, "expected users returned")
})
}
}
func TestGetUsers(t *testing.T) {
t.Parallel()
t.Run("AllUsers", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: "alice@email.com",
Username: "alice",
Password: "MySecurePassword!",
OrganizationIDs: []uuid.UUID{user.OrganizationID},
})
// No params is all users
res, err := client.Users(ctx, codersdk.UsersRequest{})
require.NoError(t, err)
require.Len(t, res.Users, 2)
require.Len(t, res.Users[0].OrganizationIDs, 1)
})
t.Run("ActiveUsers", func(t *testing.T) {
t.Parallel()
active := make([]codersdk.User, 0)
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
firstUser, err := client.User(ctx, first.UserID.String())
require.NoError(t, err, "")
active = append(active, firstUser)
// Alice will be suspended
alice, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: "alice@email.com",
Username: "alice",
Password: "MySecurePassword!",
OrganizationIDs: []uuid.UUID{first.OrganizationID},
})
require.NoError(t, err)
_, err = client.UpdateUserStatus(ctx, alice.Username, codersdk.UserStatusSuspended)
require.NoError(t, err)
// Tom will be active
tom, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: "tom@email.com",
Username: "tom",
Password: "MySecurePassword!",
OrganizationIDs: []uuid.UUID{first.OrganizationID},
})
require.NoError(t, err)
tom, err = client.UpdateUserStatus(ctx, tom.Username, codersdk.UserStatusActive)
require.NoError(t, err)
active = append(active, tom)
res, err := client.Users(ctx, codersdk.UsersRequest{
Status: codersdk.UserStatusActive,
})
require.NoError(t, err)
require.ElementsMatch(t, active, res.Users)
})
t.Run("GithubComUserID", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client, db := coderdtest.NewWithDatabase(t, nil)
first := coderdtest.CreateFirstUser(t, client)
_ = dbgen.User(t, db, database.User{
Email: "test2@coder.com",
Username: "test2",
})
err := db.UpdateUserGithubComUserID(dbauthz.AsSystemRestricted(ctx), database.UpdateUserGithubComUserIDParams{
ID: first.UserID,
GithubComUserID: sql.NullInt64{
Int64: 123,
Valid: true,
},
})
require.NoError(t, err)
res, err := client.Users(ctx, codersdk.UsersRequest{
SearchQuery: "github_com_user_id:123",
})
require.NoError(t, err)
require.Len(t, res.Users, 1)
require.Equal(t, res.Users[0].ID, first.UserID)
})
t.Run("LoginTypeNoneFilter", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: "bob@email.com",
Username: "bob",
OrganizationIDs: []uuid.UUID{first.OrganizationID},
UserLoginType: codersdk.LoginTypeNone,
})
require.NoError(t, err)
res, err := client.Users(ctx, codersdk.UsersRequest{
LoginType: []codersdk.LoginType{codersdk.LoginTypeNone},
})
require.NoError(t, err)
require.Len(t, res.Users, 1)
require.Equal(t, res.Users[0].LoginType, codersdk.LoginTypeNone)
})
t.Run("LoginTypeMultipleFilter", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
filtered := make([]codersdk.User, 0)
bob, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: "bob@email.com",
Username: "bob",
OrganizationIDs: []uuid.UUID{first.OrganizationID},
UserLoginType: codersdk.LoginTypeNone,
})
require.NoError(t, err)
filtered = append(filtered, bob)
charlie, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: "charlie@email.com",
Username: "charlie",
OrganizationIDs: []uuid.UUID{first.OrganizationID},
UserLoginType: codersdk.LoginTypeGithub,
})
require.NoError(t, err)
filtered = append(filtered, charlie)
res, err := client.Users(ctx, codersdk.UsersRequest{
LoginType: []codersdk.LoginType{codersdk.LoginTypeNone, codersdk.LoginTypeGithub},
})
require.NoError(t, err)
require.Len(t, res.Users, 2)
require.ElementsMatch(t, filtered, res.Users)
})
t.Run("DormantUserWithLoginTypeNone", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: "bob@email.com",
Username: "bob",
OrganizationIDs: []uuid.UUID{first.OrganizationID},
UserLoginType: codersdk.LoginTypeNone,
})
require.NoError(t, err)
_, err = client.UpdateUserStatus(ctx, "bob", codersdk.UserStatusSuspended)
require.NoError(t, err)
res, err := client.Users(ctx, codersdk.UsersRequest{
Status: codersdk.UserStatusSuspended,
LoginType: []codersdk.LoginType{codersdk.LoginTypeNone, codersdk.LoginTypeGithub},
})
require.NoError(t, err)
require.Len(t, res.Users, 1)
require.Equal(t, res.Users[0].Username, "bob")
require.Equal(t, res.Users[0].Status, codersdk.UserStatusSuspended)
require.Equal(t, res.Users[0].LoginType, codersdk.LoginTypeNone)
})
t.Run("LoginTypeOidcFromMultipleUser", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
OIDCConfig: &coderd.OIDCConfig{
AllowSignups: true,
},
})
first := coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: "bob@email.com",
Username: "bob",
OrganizationIDs: []uuid.UUID{first.OrganizationID},
UserLoginType: codersdk.LoginTypeOIDC,
})
require.NoError(t, err)
for i := range 5 {
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: fmt.Sprintf("%d@coder.com", i),
Username: fmt.Sprintf("user%d", i),
OrganizationIDs: []uuid.UUID{first.OrganizationID},
UserLoginType: codersdk.LoginTypeNone,
})
require.NoError(t, err)
}
res, err := client.Users(ctx, codersdk.UsersRequest{
LoginType: []codersdk.LoginType{codersdk.LoginTypeOIDC},
})
require.NoError(t, err)
require.Len(t, res.Users, 1)
require.Equal(t, res.Users[0].Username, "bob")
require.Equal(t, res.Users[0].LoginType, codersdk.LoginTypeOIDC)
})
t.Run("NameFilter", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
// Create users with different display names
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: "alice@email.com",
Username: "alice",
Name: "Alice Smith",
OrganizationIDs: []uuid.UUID{first.OrganizationID},
UserLoginType: codersdk.LoginTypeNone,
})
require.NoError(t, err)
_, err = client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: "bob@email.com",
Username: "bob",
Name: "Bob Johnson",
OrganizationIDs: []uuid.UUID{first.OrganizationID},
UserLoginType: codersdk.LoginTypeNone,
})
require.NoError(t, err)
_, err = client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: "charlie@email.com",
Username: "charlie",
Name: "Charlie Smith",
OrganizationIDs: []uuid.UUID{first.OrganizationID},
UserLoginType: codersdk.LoginTypeNone,
})
require.NoError(t, err)
// Filter by name "Smith" should return Alice and Charlie
res, err := client.Users(ctx, codersdk.UsersRequest{
Name: "Smith",
})
require.NoError(t, err)
require.Len(t, res.Users, 2)
usernames := []string{res.Users[0].Username, res.Users[1].Username}
require.ElementsMatch(t, []string{"alice", "charlie"}, usernames)
// Filter by name "Alice" should return only Alice
res, err = client.Users(ctx, codersdk.UsersRequest{
Name: "Alice",
})
require.NoError(t, err)
require.Len(t, res.Users, 1)
require.Equal(t, "alice", res.Users[0].Username)
// Filter by name "Johnson" should return only Bob
res, err = client.Users(ctx, codersdk.UsersRequest{
Name: "Johnson",
})
require.NoError(t, err)
require.Len(t, res.Users, 1)
require.Equal(t, "bob", res.Users[0].Username)
// Filter by name that doesn't exist should return no users
res, err = client.Users(ctx, codersdk.UsersRequest{
Name: "Nonexistent",
})
require.NoError(t, err)
require.Len(t, res.Users, 0)
})
t.Run("NameFilterWithSearchFilter", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
// Create users with different display names and usernames
_, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: "alice@email.com",
Username: "alice",
Name: "Alice Developer",
OrganizationIDs: []uuid.UUID{first.OrganizationID},
UserLoginType: codersdk.LoginTypeNone,
})
require.NoError(t, err)
_, err = client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: "bob@email.com",
Username: "bobdev",
Name: "Bob Developer",
OrganizationIDs: []uuid.UUID{first.OrganizationID},
UserLoginType: codersdk.LoginTypeNone,
})
require.NoError(t, err)
// Filter by name "Developer" and search "alice" should return only Alice
// because name matches both but search matches only alice's username
res, err := client.Users(ctx, codersdk.UsersRequest{
SearchQuery: "name:Developer search:alice",
})
require.NoError(t, err)
require.Len(t, res.Users, 1)
require.Equal(t, "alice", res.Users[0].Username)
return reduced
})
}
func TestGetUsersPagination(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
first := coderdtest.CreateFirstUser(t, client)
_ = coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
_, err := client.User(ctx, first.UserID.String())
require.NoError(t, err, "")
_, err = client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
Email: "alice@email.com",
Username: "alice",
Password: "MySecurePassword!",
OrganizationIDs: []uuid.UUID{first.OrganizationID},
coderdtest.UsersPagination(ctx, t, client, nil, func(req codersdk.UsersRequest) ([]codersdk.ReducedUser, int) {
res, err := client.Users(ctx, req)
require.NoError(t, err)
reduced := make([]codersdk.ReducedUser, len(res.Users))
for i, user := range res.Users {
reduced[i] = user.ReducedUser
}
return reduced, res.Count
})
require.NoError(t, err)
res, err := client.Users(ctx, codersdk.UsersRequest{})
require.NoError(t, err)
require.Len(t, res.Users, 2)
require.Equal(t, res.Count, 2)
res, err = client.Users(ctx, codersdk.UsersRequest{
Pagination: codersdk.Pagination{
Limit: 1,
},
})
require.NoError(t, err)
require.Len(t, res.Users, 1)
require.Equal(t, res.Count, 2)
res, err = client.Users(ctx, codersdk.UsersRequest{
Pagination: codersdk.Pagination{
Offset: 1,
},
})
require.NoError(t, err)
require.Len(t, res.Users, 1)
require.Equal(t, res.Count, 2)
// if offset is higher than the count postgres returns an empty array
// and not an ErrNoRows error.
res, err = client.Users(ctx, codersdk.UsersRequest{
Pagination: codersdk.Pagination{
Offset: 3,
},
})
require.NoError(t, err)
require.Len(t, res.Users, 0)
require.Equal(t, res.Count, 0)
}
func TestPostTokens(t *testing.T) {
+40 -1
View File
@@ -43,6 +43,11 @@ type Group struct {
OrganizationDisplayName string `json:"organization_display_name"`
}
type GroupMembersResponse struct {
Users []ReducedUser `json:"users"`
Count int `json:"count"`
}
func (g Group) IsEveryone() bool {
return g.ID == g.OrganizationID
}
@@ -130,10 +135,25 @@ func (c *Client) GroupByOrgAndName(ctx context.Context, orgID uuid.UUID, name st
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
func (c *Client) Group(ctx context.Context, group uuid.UUID) (Group, error) {
type GroupRequest struct {
ExcludeMembers bool `json:"exclude_members"`
}
func (p GroupRequest) asRequestOption() RequestOption {
return func(r *http.Request) {
q := r.URL.Query()
if p.ExcludeMembers {
q.Set("exclude_members", "true")
}
r.URL.RawQuery = q.Encode()
}
}
func (c *Client) Group(ctx context.Context, group uuid.UUID, req GroupRequest) (Group, error) {
res, err := c.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/groups/%s", group.String()),
nil,
req.asRequestOption(),
)
if err != nil {
return Group{}, xerrors.Errorf("make request: %w", err)
@@ -147,6 +167,25 @@ func (c *Client) Group(ctx context.Context, group uuid.UUID) (Group, error) {
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
func (c *Client) GroupMembers(ctx context.Context, group uuid.UUID, req UsersRequest) (GroupMembersResponse, error) {
res, err := c.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/groups/%s/members", group.String()),
nil,
req.Pagination.asRequestOption(),
req.asRequestOption(),
)
if err != nil {
return GroupMembersResponse{}, xerrors.Errorf("make request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GroupMembersResponse{}, ReadBodyAsError(res)
}
var resp GroupMembersResponse
return resp, json.NewDecoder(res.Body).Decode(&resp)
}
type PatchGroupRequest struct {
AddUsers []string `json:"add_users"`
RemoveUsers []string `json:"remove_users"`
+28 -24
View File
@@ -37,6 +37,33 @@ type UsersRequest struct {
Pagination
}
func (req UsersRequest) asRequestOption() RequestOption {
return func(r *http.Request) {
q := r.URL.Query()
var params []string
if req.Search != "" {
params = append(params, req.Search)
}
if req.Name != "" {
params = append(params, "name:"+req.Name)
}
if req.Status != "" {
params = append(params, "status:"+string(req.Status))
}
if req.Role != "" {
params = append(params, "role:"+req.Role)
}
if req.SearchQuery != "" {
params = append(params, req.SearchQuery)
}
for _, lt := range req.LoginType {
params = append(params, "login_type:"+string(lt))
}
q.Set("q", strings.Join(params, " "))
r.URL.RawQuery = q.Encode()
}
}
// MinimalUser is the minimal information needed to identify a user and show
// them on the UI.
type MinimalUser struct {
@@ -881,30 +908,7 @@ func (c *Client) UpdateUserQuietHoursSchedule(ctx context.Context, userIdent str
func (c *Client) Users(ctx context.Context, req UsersRequest) (GetUsersResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/users", nil,
req.Pagination.asRequestOption(),
func(r *http.Request) {
q := r.URL.Query()
var params []string
if req.Search != "" {
params = append(params, req.Search)
}
if req.Name != "" {
params = append(params, "name:"+req.Name)
}
if req.Status != "" {
params = append(params, "status:"+string(req.Status))
}
if req.Role != "" {
params = append(params, "role:"+req.Role)
}
if req.SearchQuery != "" {
params = append(params, req.SearchQuery)
}
for _, lt := range req.LoginType {
params = append(params, "login_type:"+string(lt))
}
q.Set("q", strings.Join(params, " "))
r.URL.RawQuery = q.Encode()
},
req.asRequestOption(),
)
if err != nil {
return GetUsersResponse{}, err
+119 -3
View File
@@ -486,9 +486,10 @@ curl -X GET http://coder-server:8080/api/v2/groups/{group} \
### Parameters
| Name | In | Type | Required | Description |
|---------|------|--------|----------|-------------|
| `group` | path | string | true | Group id |
| Name | In | Type | Required | Description |
|-------------------|-------|---------|----------|-----------------------------------|
| `group` | path | string | true | Group id |
| `exclude_members` | query | boolean | false | Exclude members from the response |
### Example responses
@@ -676,6 +677,63 @@ curl -X PATCH http://coder-server:8080/api/v2/groups/{group} \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get group members by group ID
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/groups/{group}/members \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /groups/{group}/members`
### Parameters
| Name | In | Type | Required | Description |
|------------|-------|--------------|----------|---------------------|
| `group` | path | string | true | Group id |
| `q` | query | string | false | Member search query |
| `after_id` | query | string(uuid) | false | After ID |
| `limit` | query | integer | false | Page limit |
| `offset` | query | integer | false | Page offset |
### Example responses
> 200 Response
```json
{
"count": 0,
"users": [
{
"avatar_url": "http://example.com",
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
"status": "active",
"theme_preference": "string",
"updated_at": "2019-08-24T14:15:22Z",
"username": "string"
}
]
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.GroupMembersResponse](schemas.md#codersdkgroupmembersresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get licenses
### Code samples
@@ -1953,6 +2011,64 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get group members by organization and group name
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/groups/{groupName}/members \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /organizations/{organization}/groups/{groupName}/members`
### Parameters
| Name | In | Type | Required | Description |
|----------------|-------|--------------|----------|---------------------|
| `organization` | path | string(uuid) | true | Organization ID |
| `groupName` | path | string | true | Group name |
| `q` | query | string | false | Member search query |
| `after_id` | query | string(uuid) | false | After ID |
| `limit` | query | integer | false | Page limit |
| `offset` | query | integer | false | Page offset |
### Example responses
> 200 Response
```json
{
"count": 0,
"users": [
{
"avatar_url": "http://example.com",
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
"status": "active",
"theme_preference": "string",
"updated_at": "2019-08-24T14:15:22Z",
"username": "string"
}
]
}
```
### Responses
| Status | Meaning | Description | Schema |
|--------|---------------------------------------------------------|-------------|--------------------------------------------------------------------------|
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.GroupMembersResponse](schemas.md#codersdkgroupmembersresponse) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get workspace quota by user
### Code samples
+31
View File
@@ -4529,6 +4529,37 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
| `source` | [codersdk.GroupSource](#codersdkgroupsource) | false | | |
| `total_member_count` | integer | false | | How many members are in this group. Shows the total count, even if the user is not authorized to read group member details. May be greater than `len(Group.Members)`. |
## codersdk.GroupMembersResponse
```json
{
"count": 0,
"users": [
{
"avatar_url": "http://example.com",
"created_at": "2019-08-24T14:15:22Z",
"email": "user@example.com",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_service_account": true,
"last_seen_at": "2019-08-24T14:15:22Z",
"login_type": "",
"name": "string",
"status": "active",
"theme_preference": "string",
"updated_at": "2019-08-24T14:15:22Z",
"username": "string"
}
]
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|---------|-------------------------------------------------------|----------|--------------|-------------|
| `count` | integer | false | | |
| `users` | array of [codersdk.ReducedUser](#codersdkreduceduser) | false | | |
## codersdk.GroupSource
```json
+2
View File
@@ -461,6 +461,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
)
r.Get("/", api.groupByOrganization)
r.Get("/members", api.groupMembersByOrganization)
})
})
r.Route("/provisionerkeys", func(r chi.Router) {
@@ -545,6 +546,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
r.Get("/", api.group)
r.Patch("/", api.patchGroup)
r.Delete("/", api.deleteGroup)
r.Get("/members", api.groupMembers)
})
})
r.Route("/workspace-quota", func(r chi.Router) {
+104 -7
View File
@@ -5,15 +5,18 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"github.com/google/uuid"
"golang.org/x/xerrors"
agpl "github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/db2sdk"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/searchquery"
"github.com/coder/coder/v2/codersdk"
)
@@ -393,6 +396,7 @@ func (api *API) groupByOrganization(rw http.ResponseWriter, r *http.Request) {
// @Produce json
// @Tags Enterprise
// @Param group path string true "Group id"
// @Param exclude_members query bool false "Exclude members from the response"
// @Success 200 {object} codersdk.Group
// @Router /groups/{group} [get]
func (api *API) group(rw http.ResponseWriter, r *http.Request) {
@@ -401,18 +405,23 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) {
group = httpmw.GroupParam(r)
)
excludeMembers, _ := strconv.ParseBool(r.URL.Query().Get("exclude_members"))
org, err := api.Database.GetOrganizationByID(ctx, group.OrganizationID)
if err != nil {
httpapi.InternalServerError(rw, err)
}
users, err := api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{
GroupID: group.ID,
IncludeSystem: false,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.InternalServerError(rw, err)
return
users := []database.GroupMember{}
if !excludeMembers {
users, err = api.Database.GetGroupMembersByGroupID(ctx, database.GetGroupMembersByGroupIDParams{
GroupID: group.ID,
IncludeSystem: false,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.InternalServerError(rw, err)
return
}
}
memberCount, err := api.Database.GetGroupMembersCountByGroupID(ctx, database.GetGroupMembersCountByGroupIDParams{
@@ -431,6 +440,94 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) {
}, users, int(memberCount)))
}
// @Summary Get group members by organization and group name
// @ID get-group-members-by-organization-and-group-name
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param organization path string true "Organization ID" format(uuid)
// @Param groupName path string true "Group name"
// @Param q query string false "Member search query"
// @Param after_id query string false "After ID" format(uuid)
// @Param limit query int false "Page limit"
// @Param offset query int false "Page offset"
// @Success 200 {object} codersdk.GroupMembersResponse
// @Router /organizations/{organization}/groups/{groupName}/members [get]
func (api *API) groupMembersByOrganization(rw http.ResponseWriter, r *http.Request) {
api.groupMembers(rw, r)
}
// @Summary Get group members by group ID
// @ID get-group-members-by-group-id
// @Security CoderSessionToken
// @Produce json
// @Tags Enterprise
// @Param group path string true "Group id"
// @Param q query string false "Member search query"
// @Param after_id query string false "After ID" format(uuid)
// @Param limit query int false "Page limit"
// @Param offset query int false "Page offset"
// @Success 200 {object} codersdk.GroupMembersResponse
// @Router /groups/{group}/members [get]
func (api *API) groupMembers(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
group = httpmw.GroupParam(r)
)
filterQuery := r.URL.Query().Get("q")
userFilterParams, filterErrs := searchquery.Users(filterQuery)
if len(filterErrs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid member search query.",
Validations: filterErrs,
})
return
}
paginationParams, ok := agpl.ParsePagination(rw, r)
if !ok {
return
}
members, err := api.Database.GetGroupMembersByGroupIDPaginated(ctx, database.GetGroupMembersByGroupIDPaginatedParams{
AfterID: paginationParams.AfterID,
GroupID: group.ID,
IncludeSystem: false,
Search: userFilterParams.Search,
Name: userFilterParams.Name,
Status: userFilterParams.Status,
RbacRole: userFilterParams.RbacRole,
LastSeenBefore: userFilterParams.LastSeenBefore,
LastSeenAfter: userFilterParams.LastSeenAfter,
CreatedAfter: userFilterParams.CreatedAfter,
CreatedBefore: userFilterParams.CreatedBefore,
GithubComUserID: userFilterParams.GithubComUserID,
LoginType: userFilterParams.LoginType,
// #nosec G115 - Pagination offsets are small and fit in int32
OffsetOpt: int32(paginationParams.Offset),
// #nosec G115 - Pagination limits are small and fit in int32
LimitOpt: int32(paginationParams.Limit),
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.InternalServerError(rw, err)
return
}
if len(members) == 0 {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.GroupMembersResponse{
Users: nil,
Count: 0,
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.GroupMembersResponse{
Users: db2sdk.ReducedUsersFromGroupMemberRows(members),
Count: int(members[0].Count),
})
}
// @Summary Get groups by organization
// @ID get-groups-by-organization
// @Security CoderSessionToken
+129 -11
View File
@@ -1,6 +1,7 @@
package coderd_test
import (
"context"
"net/http"
"sort"
"testing"
@@ -9,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
@@ -584,7 +586,7 @@ func TestPatchGroup(t *testing.T) {
userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleUserAdmin())
ctx := testutil.Context(t, testutil.WaitLong)
group, err := userAdminClient.Group(ctx, user.OrganizationID)
group, err := userAdminClient.Group(ctx, user.OrganizationID, codersdk.GroupRequest{})
require.NoError(t, err)
require.Equal(t, 0, group.QuotaAllowance)
@@ -636,7 +638,7 @@ func TestGroup(t *testing.T) {
})
require.NoError(t, err)
ggroup, err := userAdminClient.Group(ctx, group.ID)
ggroup, err := userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{})
require.NoError(t, err)
require.Equal(t, group, ggroup)
})
@@ -686,7 +688,7 @@ func TestGroup(t *testing.T) {
require.Contains(t, group.Members, user2.ReducedUser)
require.Contains(t, group.Members, user3.ReducedUser)
ggroup, err := userAdminClient.Group(ctx, group.ID)
ggroup, err := userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{})
require.NoError(t, err)
normalizeGroupMembers(&group)
normalizeGroupMembers(&ggroup)
@@ -694,6 +696,38 @@ func TestGroup(t *testing.T) {
require.Equal(t, group, ggroup)
})
t.Run("WithoutMembers", func(t *testing.T) {
t.Parallel()
client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
}})
userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleUserAdmin())
_, user2 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
_, user3 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
group, err := userAdminClient.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{
Name: "hi",
})
require.NoError(t, err)
group, err = userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
AddUsers: []string{user2.ID.String(), user3.ID.String()},
})
require.NoError(t, err)
require.Contains(t, group.Members, user2.ReducedUser)
require.Contains(t, group.Members, user3.ReducedUser)
ggroup, err := userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{
ExcludeMembers: true,
})
require.NoError(t, err)
require.Len(t, ggroup.Members, 0)
})
t.Run("RegularUserReadGroup", func(t *testing.T) {
t.Parallel()
@@ -714,7 +748,7 @@ func TestGroup(t *testing.T) {
})
require.NoError(t, err)
ggroup, err := client1.Group(ctx, group.ID)
ggroup, err := client1.Group(ctx, group.ID, codersdk.GroupRequest{})
require.NoError(t, err, "regular users can read groups unless workspace sharing is disabled")
normalizeGroupMembers(&group)
normalizeGroupMembers(&ggroup)
@@ -760,7 +794,7 @@ func TestGroup(t *testing.T) {
})
require.NoError(t, err)
_, err = client1.Group(ctx, group.ID)
_, err = client1.Group(ctx, group.ID, codersdk.GroupRequest{})
require.Error(t, err, "regular users cannot read groups when workspace sharing is disabled")
cerr, ok := codersdk.AsError(err)
require.True(t, ok)
@@ -797,7 +831,7 @@ func TestGroup(t *testing.T) {
err = userAdminClient.DeleteUser(ctx, user1.ID)
require.NoError(t, err)
group, err = userAdminClient.Group(ctx, group.ID)
group, err = userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{})
require.NoError(t, err)
require.NotContains(t, group.Members, user1.ReducedUser)
})
@@ -832,7 +866,7 @@ func TestGroup(t *testing.T) {
user1, err = userAdminClient.UpdateUserStatus(ctx, user1.ID.String(), codersdk.UserStatusSuspended)
require.NoError(t, err)
group, err = userAdminClient.Group(ctx, group.ID)
group, err = userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{})
require.NoError(t, err)
require.Len(t, group.Members, 2)
require.Contains(t, group.Members, user1.ReducedUser)
@@ -854,7 +888,7 @@ func TestGroup(t *testing.T) {
AddUsers: []string{anotherUser.ID.String()},
})
group, err = userAdminClient.Group(ctx, group.ID)
group, err = userAdminClient.Group(ctx, group.ID, codersdk.GroupRequest{})
require.NoError(t, err)
require.Len(t, group.Members, 3)
require.Contains(t, group.Members, user1.ReducedUser)
@@ -916,7 +950,7 @@ func TestGroup(t *testing.T) {
prebuildsUser, err := client.User(ctx, database.PrebuildsSystemUserID.String())
require.NoError(t, err)
// The 'Everyone' group always has an ID that matches the organization ID.
group, err := userAdminClient.Group(ctx, user.OrganizationID)
group, err := userAdminClient.Group(ctx, user.OrganizationID, codersdk.GroupRequest{})
require.NoError(t, err)
require.Len(t, group.Members, 4)
require.Equal(t, "Everyone", group.Name)
@@ -971,7 +1005,7 @@ func TestGroups(t *testing.T) {
normalizeGroupMembers(&group2)
// Fetch everyone group for comparison
everyoneGroup, err := userAdminClient.Group(ctx, user.OrganizationID)
everyoneGroup, err := userAdminClient.Group(ctx, user.OrganizationID, codersdk.GroupRequest{})
require.NoError(t, err)
normalizeGroupMembers(&everyoneGroup)
@@ -1052,7 +1086,7 @@ func TestDeleteGroup(t *testing.T) {
err = userAdminClient.DeleteGroup(ctx, group1.ID)
require.NoError(t, err)
_, err = userAdminClient.Group(ctx, group1.ID)
_, err = userAdminClient.Group(ctx, group1.ID, codersdk.GroupRequest{})
require.Error(t, err)
cerr, ok := codersdk.AsError(err)
require.True(t, ok)
@@ -1114,3 +1148,87 @@ func TestDeleteGroup(t *testing.T) {
require.Equal(t, http.StatusBadRequest, cerr.StatusCode())
})
}
func TestGetGroupMembersFilter(t *testing.T) {
t.Parallel()
client, db, first := coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
OIDCConfig: &coderd.OIDCConfig{
AllowSignups: true,
},
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleUserAdmin())
setupCtx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
t.Cleanup(cancel)
group, err := userAdminClient.CreateGroup(setupCtx, first.OrganizationID, codersdk.CreateGroupRequest{
Name: "filtered",
})
require.NoError(t, err)
setup := func(users []codersdk.User) {
userIDs := make([]string, len(users))
for i, user := range users {
userIDs[i] = user.ID.String()
}
group, err = userAdminClient.PatchGroup(setupCtx, group.ID, codersdk.PatchGroupRequest{
AddUsers: userIDs,
})
require.NoError(t, err)
}
fetch := func(testCtx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser {
res, err := userAdminClient.GroupMembers(testCtx, group.ID, req)
require.NoError(t, err)
return res.Users
}
coderdtest.UsersFilter(setupCtx, t, client, db, setup, fetch)
}
func TestGetGroupMembersPagination(t *testing.T) {
t.Parallel()
client, first := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
userAdminClient, _ := coderdtest.CreateAnotherUser(t, client, first.OrganizationID, rbac.RoleUserAdmin())
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
t.Cleanup(cancel)
group, err := userAdminClient.CreateGroup(ctx, first.OrganizationID, codersdk.CreateGroupRequest{
Name: "paginated",
})
require.NoError(t, err)
setup := func(users []codersdk.User) {
userIDs := make([]string, len(users))
for i, user := range users {
userIDs[i] = user.ID.String()
}
group, err = userAdminClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
AddUsers: userIDs,
})
require.NoError(t, err)
}
fetch := func(req codersdk.UsersRequest) ([]codersdk.ReducedUser, int) {
group, err := userAdminClient.GroupMembers(ctx, group.ID, req)
require.NoError(t, err)
return group.Users, group.Count
}
coderdtest.UsersPagination(ctx, t, client, setup, fetch)
}
+11
View File
@@ -3351,6 +3351,17 @@ export interface GroupArguments {
readonly GroupIDs: readonly string[];
}
// From codersdk/groups.go
export interface GroupMembersResponse {
readonly users: readonly ReducedUser[];
readonly count: number;
}
// From codersdk/groups.go
export interface GroupRequest {
readonly exclude_members: boolean;
}
// From codersdk/groups.go
export type GroupSource = "oidc" | "user";