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) {