Files
pocket-id/backend/internal/service/user_service_test.go
T

381 lines
14 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"uuid"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func newTestUserService(t *testing.T) (*UserService, *UserGroupService) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
fileStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
userService := NewUserService(
db,
nil,
NewCustomClaimService(db),
NewAppImagesService(map[string]string{}, fileStorage),
nil,
nil,
fileStorage,
nil,
)
groupService := NewUserGroupService(db, nil, nil)
return userService, groupService
}
func TestUserAndGroupLookupsReturnSpecificNotFoundErrors(t *testing.T) {
userService, groupService := newTestUserService(t)
_, err := userService.GetUser(t.Context(), "missing-user")
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
_, err = groupService.Get(t.Context(), "missing-group")
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
var appErr *apperror.Error
require.ErrorAs(t, err, &appErr)
require.Equal(t, "User group", appErr.Details()["resource"])
}
func TestUpdateProfilePictureRejectsInvalidImageData(t *testing.T) {
userService, _ := newTestUserService(t)
config := &appconfig.AppConfigModel{RequireUserEmail: "false"}
user, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{
ID: uuid.NewV4().String(),
Username: "image-test",
})
require.NoError(t, err)
err = userService.UpdateProfilePicture(
t.Context(),
user.ID,
strings.NewReader("not an image"),
)
require.True(t, apperror.IsCode(err, apperror.CodeInvalidImage))
}
func TestProfilePictureUpdatesRejectMissingUser(t *testing.T) {
userService, _ := newTestUserService(t)
missingUserID := uuid.NewV4().String()
err := userService.UpdateProfilePicture(t.Context(), missingUserID, strings.NewReader("not an image"))
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
err = userService.ResetProfilePicture(t.Context(), missingUserID)
require.True(t, apperror.IsCode(err, apperror.CodeUserNotFound))
}
func TestCreateUserBumpsGroupUpdatedAt(t *testing.T) {
config := &appconfig.AppConfigModel{RequireUserEmail: "false"}
userService, groupService := newTestUserService(t)
group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{
Name: "members",
FriendlyName: "Members",
})
require.NoError(t, err)
require.Nil(t, group.UpdatedAt, "a freshly created group has no UpdatedAt yet")
// Create a user that is a member of the group
// This mirrors signing up via an invite link that adds the user to a group
email := "member@example.com"
_, err = userService.CreateUser(t.Context(), config, dto.UserCreateDto{
Username: "member",
Email: &email,
FirstName: "Group",
LastName: "Member",
UserGroupIds: []string{group.ID},
})
require.NoError(t, err)
// The group's UpdatedAt must now be set
updated, err := groupService.Get(t.Context(), group.ID)
require.NoError(t, err)
require.NotNil(t, updated.UpdatedAt, "creating a group member must bump the group's UpdatedAt")
require.False(t, updated.LastModified().Before(updated.CreatedAt.ToTime()), "group LastModified must not predate its CreatedAt after a membership change")
require.Len(t, updated.Users, 1, "the user should be a member of the group")
}
func TestCreateUserBumpsDefaultGroupUpdatedAt(t *testing.T) {
config := &appconfig.AppConfigModel{RequireUserEmail: "false"}
userService, groupService := newTestUserService(t)
group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{
Name: "default",
FriendlyName: "Default",
})
require.NoError(t, err)
require.Nil(t, group.UpdatedAt)
// Configure the group as a default signup group
defaultGroups, err := json.Marshal([]string{group.ID})
require.NoError(t, err)
config.SignupDefaultUserGroupIDs = appconfig.AppConfigValue(defaultGroups)
// Create a user without explicit group IDs, so the default groups apply
email := "default@example.com"
_, err = userService.CreateUser(t.Context(), config, dto.UserCreateDto{
Username: "defaultmember",
Email: &email,
FirstName: "Default",
LastName: "Member",
})
require.NoError(t, err)
updated, err := groupService.Get(t.Context(), group.ID)
require.NoError(t, err)
require.NotNil(t, updated.UpdatedAt, "adding a default group member must bump the group's UpdatedAt")
require.Len(t, updated.Users, 1)
}
func TestUpdateUserGroupsBumpsRemovedGroupUpdatedAt(t *testing.T) {
config := &appconfig.AppConfigModel{RequireUserEmail: "false"}
userService, groupService := newTestUserService(t)
oldGroup, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{Name: "old", FriendlyName: "Old"})
require.NoError(t, err)
newGroup, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{Name: "new", FriendlyName: "New"})
require.NoError(t, err)
user, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{
Username: "mover",
FirstName: "Group",
LastName: "Mover",
UserGroupIds: []string{oldGroup.ID},
})
require.NoError(t, err)
// Backdate both groups so a bump is visible regardless of the timestamp precision
past := time.Now().Add(-time.Hour)
require.NoError(t, userService.db.Model(&model.UserGroup{}).Where("id IN ?", []string{oldGroup.ID, newGroup.ID}).Update("updated_at", datatype.DateTime(past)).Error)
// Move the user from the old group to the new one, as the user's group selection does
_, err = userService.UpdateUserGroups(t.Context(), user.ID, []string{newGroup.ID})
require.NoError(t, err)
for _, groupID := range []string{oldGroup.ID, newGroup.ID} {
updated, err := groupService.Get(t.Context(), groupID)
require.NoError(t, err)
require.True(t, updated.LastModified().After(past), "group %s changed membership, so its UpdatedAt must be bumped", updated.Name)
}
}
func TestDeleteUserBumpsGroupUpdatedAt(t *testing.T) {
config := &appconfig.AppConfigModel{RequireUserEmail: "false"}
userService, groupService := newTestUserService(t)
group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{Name: "members", FriendlyName: "Members"})
require.NoError(t, err)
user, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{
Username: "leaver",
FirstName: "Group",
LastName: "Leaver",
UserGroupIds: []string{group.ID},
})
require.NoError(t, err)
// Backdate the group so a bump is visible regardless of the timestamp precision
past := time.Now().Add(-time.Hour)
require.NoError(t, userService.db.Model(&model.UserGroup{}).Where("id = ?", group.ID).Update("updated_at", datatype.DateTime(past)).Error)
err = userService.DeleteUser(t.Context(), config, user.ID, false)
require.NoError(t, err)
updated, err := groupService.Get(t.Context(), group.ID)
require.NoError(t, err)
require.Empty(t, updated.Users)
require.True(t, updated.LastModified().After(past), "deleting a member must bump the group's UpdatedAt")
}
type recordingEmailVerificationSender struct {
sent []string
requested []string
requestErr error
}
func (r *recordingEmailVerificationSender) Send(_ context.Context, _ *appconfig.AppConfigModel, userID string) error {
r.sent = append(r.sent, userID)
return nil
}
func (r *recordingEmailVerificationSender) RequestEmailChange(_ context.Context, _ *appconfig.AppConfigModel, _, newEmail string) error {
r.requested = append(r.requested, newEmail)
return r.requestErr
}
func TestUpdateOwnEmailWithVerification(t *testing.T) {
// setup creates a user with the given verification state and an update that changes only their email
setup := func(t *testing.T, mode string, emailVerified bool) (*UserService, *recordingEmailVerificationSender, *appconfig.AppConfigModel, model.User, dto.UserCreateDto) {
t.Helper()
config := appconfig.NewTestConfig(nil)
config.EmailVerification = appconfig.AppConfigValue(mode)
userService, _ := newTestUserService(t)
sender := &recordingEmailVerificationSender{}
userService.emailVerification = sender
user, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{
Username: "changer",
Email: new("old@example.com"),
EmailVerified: emailVerified,
FirstName: "Email",
LastName: "Changer",
})
require.NoError(t, err)
update := dto.UserCreateDto{
Username: user.Username,
Email: new("new@example.com"),
FirstName: user.FirstName,
LastName: user.LastName,
}
return userService, sender, config, user, update
}
t.Run("a verified email stays in effect until the new address is confirmed", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "old@example.com", *updated.Email)
require.True(t, updated.EmailVerified)
require.Equal(t, []string{"new@example.com"}, sender.requested)
require.Empty(t, sender.sent)
})
t.Run("a deferred change to an address another user has is rejected right away", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
_, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{Username: "other", Email: new("new@example.com"), FirstName: "Other"})
require.NoError(t, err)
_, err = userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.True(t, apperror.IsCode(err, apperror.CodeAlreadyInUse))
require.Empty(t, sender.requested)
})
t.Run("an unverified email is replaced right away and the new one has to be verified", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, false)
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "new@example.com", *updated.Email)
require.False(t, updated.EmailVerified)
require.Equal(t, []string{user.ID}, sender.sent)
require.Empty(t, sender.requested)
})
t.Run("the other fields are saved while the email change waits for confirmation", func(t *testing.T) {
userService, _, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
update.FirstName = "Renamed"
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "Renamed", updated.FirstName)
require.Equal(t, "old@example.com", *updated.Email)
})
t.Run("a change link that can't be sent is reported apart from the saved fields", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
sender.requestErr = errors.New("smtp unavailable")
update.FirstName = "Renamed"
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.True(t, apperror.IsCode(err, apperror.CodeEmailChangeNotStarted))
require.Equal(t, "Renamed", updated.FirstName)
stored, err := userService.GetUser(t.Context(), user.ID)
require.NoError(t, err)
require.Equal(t, "Renamed", stored.FirstName)
require.Equal(t, "old@example.com", *stored.Email)
})
t.Run("no change link is sent when the user may not edit their own account", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
config.AllowOwnAccountEdit = "false"
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "old@example.com", *updated.Email)
require.Empty(t, sender.requested)
require.Empty(t, sender.sent)
})
t.Run("an admin changing another user's email replaces it right away", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
update.EmailVerified = true
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, false, false)
require.NoError(t, err)
require.Equal(t, "new@example.com", *updated.Email)
require.Empty(t, sender.requested)
require.Empty(t, sender.sent)
})
t.Run("the email is replaced right away when verification is optional", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationOptional, true)
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "new@example.com", *updated.Email)
require.False(t, updated.EmailVerified)
require.Empty(t, sender.sent)
require.Empty(t, sender.requested)
})
}
func TestMarkingEmailVerifiedClearsSignupDeadline(t *testing.T) {
config := appconfig.NewTestConfig(nil)
userService, _ := newTestUserService(t)
user, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{
Username: "pending",
Email: new("pending@example.com"),
FirstName: "Pending",
LastName: "User",
})
require.NoError(t, err)
deadline := datatype.DateTime(time.Now().Add(time.Hour))
require.NoError(t, userService.db.Model(&model.User{}).Where("id = ?", user.ID).Update("email_verification_deadline", deadline).Error)
updated, err := userService.UpdateUser(t.Context(), config, user.ID, dto.UserCreateDto{
Username: user.Username,
Email: user.Email,
EmailVerified: true,
FirstName: user.FirstName,
LastName: user.LastName,
}, false, false)
require.NoError(t, err)
require.True(t, updated.EmailVerified)
stored, err := userService.GetUser(t.Context(), user.ID)
require.NoError(t, err)
require.Nil(t, stored.EmailVerificationDeadline)
}