mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
Generated
+146
@@ -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": [
|
||||
|
||||
Generated
+138
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Generated
+119
-3
@@ -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
|
||||
|
||||
Generated
+31
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Generated
+11
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user