Files
coder/coderd/coderdtest/users.go
Asher 70d46943db fix: match on ID instead of username (#24797)
The username suffix could put the name past the 32 character limit,
causing the test to flake. Instead of using a suffix, match on the
expected ID instead.
2026-04-29 12:24:52 -08:00

623 lines
19 KiB
Go

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")
}
type UsersFilterOptions struct {
CreateServiceAccounts bool
}
// 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,
options *UsersFilterOptions,
setup func(users []codersdk.User),
fetch func(ctx context.Context, req codersdk.UsersRequest) []codersdk.ReducedUser,
) {
t.Helper()
if options == nil {
options = &UsersFilterOptions{}
}
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]
githubIDs := make(map[int]uuid.UUID)
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.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)
githubIDs[i] = userData.ID
}
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)
}
// Add some service accounts.
if options.CreateServiceAccounts {
for range 3 {
_, user := CreateAnotherUserMutators(t, client, orgID, nil, func(r *codersdk.CreateUserRequestWithOrgs) {
r.ServiceAccount = true
})
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 u.ID == githubIDs[7]
},
},
{
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
},
},
{
Name: "IsServiceAccount",
Filter: codersdk.UsersRequest{
Search: "service_account:true",
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return u.IsServiceAccount
},
},
{
Name: "IsNotServiceAccount",
Filter: codersdk.UsersRequest{
Search: "service_account:false",
},
FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool {
return !u.IsServiceAccount
},
},
}
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")
})
}
}