package persistence import ( "context" "errors" "slices" "sync" "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils/slice" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("UserRepository", func() { var repo model.UserRepository var ctx context.Context BeforeEach(func() { ctx = log.NewContext(GinkgoT().Context()) repo = NewUserRepository(GetDBXBuilder()) }) Describe("Put/Get/FindByUsername", func() { usr := model.User{ ID: "123", UserName: "AdMiN", Name: "Admin", Email: "admin@admin.com", NewPassword: "wordpass", IsAdmin: true, } It("saves the user to the DB", func() { Expect(repo.Put(ctx, &usr)).To(BeNil()) }) It("returns the newly created user", func() { actual, err := repo.Get(ctx, "123") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Admin")) }) It("find the user by case-insensitive username", func() { actual, err := repo.FindByUsername(ctx, "aDmIn") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Admin")) }) It("find the user by username and decrypts the password", func() { actual, err := repo.FindByUsernameWithPassword(ctx, "aDmIn") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Admin")) Expect(actual.Password).To(Equal("wordpass")) }) It("updates the name and keep the same password", func() { usr.Name = "Jane Doe" usr.NewPassword = "" Expect(repo.Put(ctx, &usr)).To(BeNil()) actual, err := repo.FindByUsernameWithPassword(ctx, "admin") Expect(err).ToNot(HaveOccurred()) Expect(actual.Name).To(Equal("Jane Doe")) Expect(actual.Password).To(Equal("wordpass")) }) It("updates password if specified", func() { usr.NewPassword = "newpass" Expect(repo.Put(ctx, &usr)).To(BeNil()) actual, err := repo.FindByUsernameWithPassword(ctx, "admin") Expect(err).ToNot(HaveOccurred()) Expect(actual.Password).To(Equal("newpass")) }) It("persists and reads back the scrobble filter", func() { usr := model.User{ID: "u-filter", UserName: "u-filter", Name: "Filter User", ScrobbleFilter: `{"all":[{"contains":{"title":"????"}}]}`} Expect(repo.Put(ctx, &usr)).To(Succeed()) saved, err := repo.Get(ctx, "u-filter") Expect(err).ToNot(HaveOccurred()) Expect(saved.ScrobbleFilter).To(Equal(`{"all":[{"contains":{"title":"????"}}]}`)) }) It("reads back a user row inserted without scrobble_filter", func() { // Guards the column's NOT NULL DEFAULT '': rows predating the migration must stay scannable _, err := GetDBXBuilder().NewQuery( "insert into user (id, user_name, name, email, password, created_at, updated_at) " + "values ('u-rawsql', 'u-rawsql', 'Raw', '', '', datetime('now'), datetime('now'))").Execute() Expect(err).ToNot(HaveOccurred()) saved, err := repo.Get(ctx, "u-rawsql") Expect(err).ToNot(HaveOccurred()) Expect(saved.ScrobbleFilter).To(Equal("")) }) }) Describe("validatePasswordChange", func() { var loggedUser *model.User BeforeEach(func() { loggedUser = &model.User{ID: "1", UserName: "logan"} }) It("does nothing if passwords are not specified", func() { user := &model.User{ID: "2", UserName: "johndoe"} err := validatePasswordChange(user, loggedUser) Expect(err).ToNot(HaveOccurred()) }) Context("Autogenerated password (used with Reverse Proxy Authentication)", func() { var user model.User BeforeEach(func() { loggedUser.IsAdmin = false loggedUser.Password = consts.PasswordAutogenPrefix + id.NewRandom() }) It("does nothing if passwords are not specified", func() { user = *loggedUser err := validatePasswordChange(&user, loggedUser) Expect(err).ToNot(HaveOccurred()) }) It("does not requires currentPassword for regular user", func() { user = *loggedUser user.CurrentPassword = "" user.NewPassword = "new" err := validatePasswordChange(&user, loggedUser) Expect(err).ToNot(HaveOccurred()) }) It("does not requires currentPassword for admin", func() { loggedUser.IsAdmin = true user = *loggedUser user.CurrentPassword = "" user.NewPassword = "new" err := validatePasswordChange(&user, loggedUser) Expect(err).ToNot(HaveOccurred()) }) }) Context("Logged User is admin", func() { BeforeEach(func() { loggedUser.IsAdmin = true }) It("can change other user's passwords without currentPassword", func() { user := &model.User{ID: "2", UserName: "johndoe"} user.NewPassword = "new" err := validatePasswordChange(user, loggedUser) Expect(err).ToNot(HaveOccurred()) }) It("requires currentPassword to change its own", func() { user := *loggedUser user.NewPassword = "new" err := validatePasswordChange(&user, loggedUser) var verr *rest.ValidationError errors.As(err, &verr) Expect(verr.Errors).To(HaveLen(1)) Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.required")) }) It("does not allow to change password to empty string", func() { loggedUser.Password = "abc123" user := *loggedUser user.CurrentPassword = "abc123" err := validatePasswordChange(&user, loggedUser) var verr *rest.ValidationError errors.As(err, &verr) Expect(verr.Errors).To(HaveLen(1)) Expect(verr.Errors).To(HaveKeyWithValue("password", "ra.validation.required")) }) It("fails if currentPassword does not match", func() { loggedUser.Password = "abc123" user := *loggedUser user.CurrentPassword = "current" user.NewPassword = "new" err := validatePasswordChange(&user, loggedUser) var verr *rest.ValidationError errors.As(err, &verr) Expect(verr.Errors).To(HaveLen(1)) Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.passwordDoesNotMatch")) }) It("can change own password if requirements are met", func() { loggedUser.Password = "abc123" user := *loggedUser user.CurrentPassword = "abc123" user.NewPassword = "new" err := validatePasswordChange(&user, loggedUser) Expect(err).ToNot(HaveOccurred()) }) }) Context("Logged User is a regular user", func() { BeforeEach(func() { loggedUser.IsAdmin = false }) It("requires currentPassword", func() { user := *loggedUser user.NewPassword = "new" err := validatePasswordChange(&user, loggedUser) var verr *rest.ValidationError errors.As(err, &verr) Expect(verr.Errors).To(HaveLen(1)) Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.required")) }) It("does not allow to change password to empty string", func() { loggedUser.Password = "abc123" user := *loggedUser user.CurrentPassword = "abc123" err := validatePasswordChange(&user, loggedUser) var verr *rest.ValidationError errors.As(err, &verr) Expect(verr.Errors).To(HaveLen(1)) Expect(verr.Errors).To(HaveKeyWithValue("password", "ra.validation.required")) }) It("fails if currentPassword does not match", func() { loggedUser.Password = "abc123" user := *loggedUser user.CurrentPassword = "current" user.NewPassword = "new" err := validatePasswordChange(&user, loggedUser) var verr *rest.ValidationError errors.As(err, &verr) Expect(verr.Errors).To(HaveLen(1)) Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.passwordDoesNotMatch")) }) It("can change own password if requirements are met", func() { loggedUser.Password = "abc123" user := *loggedUser user.CurrentPassword = "abc123" user.NewPassword = "new" err := validatePasswordChange(&user, loggedUser) Expect(err).ToNot(HaveOccurred()) }) }) }) Describe("Delete", func() { It("returns not found for a missing user", func() { adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) adminRepo := NewUserRepository(GetDBXBuilder()).(*userRepository) Expect(adminRepo.Delete(adminCtx, "does-not-exist")).To(MatchError(model.ErrNotFound)) }) }) Describe("ReadAll name filter", func() { var adminRepo model.UserRepository var adminCtx context.Context BeforeEach(func() { adminCtx = request.WithUser(ctx, model.User{ID: "admin-id", UserName: "admin", IsAdmin: true}) adminRepo = NewUserRepository(GetDBXBuilder()) for _, u := range []model.User{ {ID: "filter-alice", UserName: "alice_filter", Name: "Alice Filter", NewPassword: "x"}, {ID: "filter-bob", UserName: "bob_filter", Name: "Bob Filter", NewPassword: "x"}, } { Expect(adminRepo.Put(adminCtx, &u)).To(Succeed()) } }) AfterEach(func() { _ = adminRepo.Delete(adminCtx, "filter-alice", "filter-bob") }) It("matches users whose name starts with the given prefix", func() { users, err := adminRepo.ReadAll(adminCtx, rest.QueryOptions{Filters: map[string]any{"name": "Alice"}}) Expect(err).ToNot(HaveOccurred()) var names []string for _, u := range users { names = append(names, u.Name) } Expect(names).To(ContainElement("Alice Filter")) Expect(names).ToNot(ContainElement("Bob Filter")) }) It("does not match names by mid-string substring (startsWith, not contains)", func() { users, err := adminRepo.ReadAll(adminCtx, rest.QueryOptions{Filters: map[string]any{"name": "Filter"}}) Expect(err).ToNot(HaveOccurred()) for _, u := range users { Expect(u.ID).ToNot(Or(Equal("filter-alice"), Equal("filter-bob")), "a mid-string substring should not match a startsWith filter") } }) }) Describe("validateUsernameUnique", func() { var repo *tests.MockedUserRepo var existingUser *model.User BeforeEach(func() { existingUser = &model.User{ID: "1", UserName: "johndoe"} repo = tests.CreateMockUserRepo() err := repo.Put(ctx, existingUser) Expect(err).ToNot(HaveOccurred()) }) It("allows unique usernames", func() { var newUser = &model.User{ID: "2", UserName: "unique_username"} err := validateUsernameUnique(ctx, repo, newUser) Expect(err).ToNot(HaveOccurred()) }) It("returns ValidationError if username already exists", func() { var newUser = &model.User{ID: "2", UserName: "johndoe"} err := validateUsernameUnique(ctx, repo, newUser) var verr *rest.ValidationError isValidationError := errors.As(err, &verr) Expect(isValidationError).To(BeTrue()) Expect(verr.Errors).To(HaveKeyWithValue("userName", "ra.validation.unique")) }) It("returns generic error if repository call fails", func() { repo.Error = errors.New("fake error") var newUser = &model.User{ID: "2", UserName: "newuser"} err := validateUsernameUnique(ctx, repo, newUser) Expect(err).To(MatchError("fake error")) }) }) Describe("Library Association Methods", func() { var userID string var library1, library2 model.Library BeforeEach(func() { // Create a test user first to satisfy foreign key constraints testUser := model.User{ ID: "test-user-id", UserName: "testuser", Name: "Test User", Email: "test@example.com", NewPassword: "password", IsAdmin: false, } Expect(repo.Put(ctx, &testUser)).To(BeNil()) userID = testUser.ID library1 = model.Library{ID: 0, Name: "Library 500", Path: "/path/500"} library2 = model.Library{ID: 0, Name: "Library 501", Path: "/path/501"} // Create test libraries libRepo := NewLibraryRepository(GetDBXBuilder()) Expect(libRepo.Put(ctx, &library1)).To(BeNil()) Expect(libRepo.Put(ctx, &library2)).To(BeNil()) }) AfterEach(func() { // Clean up user-library associations to ensure test isolation _ = repo.SetUserLibraries(ctx, userID, []int{}) // Clean up test libraries to ensure isolation between test groups libRepo := NewLibraryRepository(GetDBXBuilder()) _ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}}) }) Describe("GetUserLibraries", func() { It("returns empty list when user has no library associations", func() { libraries, err := repo.GetUserLibraries(ctx, "non-existent-user") Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(0)) }) It("returns user's associated libraries", func() { err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID}) Expect(err).ToNot(HaveOccurred()) libraries, err := repo.GetUserLibraries(ctx, userID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(2)) libIDs := []int{libraries[0].ID, libraries[1].ID} Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) }) }) Describe("SetUserLibraries", func() { It("sets user's library associations", func() { libraryIDs := []int{library1.ID, library2.ID} err := repo.SetUserLibraries(ctx, userID, libraryIDs) Expect(err).ToNot(HaveOccurred()) libraries, err := repo.GetUserLibraries(ctx, userID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(2)) }) It("replaces existing associations", func() { // Set initial associations err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID}) Expect(err).ToNot(HaveOccurred()) // Replace with just one library err = repo.SetUserLibraries(ctx, userID, []int{library1.ID}) Expect(err).ToNot(HaveOccurred()) libraries, err := repo.GetUserLibraries(ctx, userID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(1)) Expect(libraries[0].ID).To(Equal(library1.ID)) }) It("removes all associations when passed empty slice", func() { // Set initial associations err := repo.SetUserLibraries(ctx, userID, []int{library1.ID, library2.ID}) Expect(err).ToNot(HaveOccurred()) // Remove all err = repo.SetUserLibraries(ctx, userID, []int{}) Expect(err).ToNot(HaveOccurred()) libraries, err := repo.GetUserLibraries(ctx, userID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(0)) }) }) }) Describe("Admin User Auto-Assignment", func() { var ( libRepo model.LibraryRepository library1 model.Library library2 model.Library initialLibCount int ) BeforeEach(func() { libRepo = NewLibraryRepository(GetDBXBuilder()) // Count initial libraries existingLibs, err := libRepo.GetAll(ctx) Expect(err).ToNot(HaveOccurred()) initialLibCount = len(existingLibs) library1 = model.Library{ID: 0, Name: "Admin Test Library 1", Path: "/admin/test/path1"} library2 = model.Library{ID: 0, Name: "Admin Test Library 2", Path: "/admin/test/path2"} // Create test libraries Expect(libRepo.Put(ctx, &library1)).To(BeNil()) Expect(libRepo.Put(ctx, &library2)).To(BeNil()) }) AfterEach(func() { // Clean up test libraries and their associations _ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}}) // Clean up user-library associations for these test libraries _, _ = repo.(*userRepository).executeSQL(ctx, squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}})) }) It("automatically assigns all libraries to admin users when created", func() { adminUser := model.User{ ID: "admin-user-id-1", UserName: "adminuser1", Name: "Admin User", Email: "admin1@example.com", NewPassword: "password", IsAdmin: true, } err := repo.Put(ctx, &adminUser) Expect(err).ToNot(HaveOccurred()) // Admin should automatically have access to all libraries (including existing ones) libraries, err := repo.GetUserLibraries(ctx, adminUser.ID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries libIDs := make([]int, len(libraries)) for i, lib := range libraries { libIDs[i] = lib.ID } Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) }) It("automatically assigns all libraries to admin users when updated", func() { // Create regular user first regularUser := model.User{ ID: "regular-user-id-1", UserName: "regularuser1", Name: "Regular User", Email: "regular1@example.com", NewPassword: "password", IsAdmin: false, } err := repo.Put(ctx, ®ularUser) Expect(err).ToNot(HaveOccurred()) // Give them access to just one library err = repo.SetUserLibraries(ctx, regularUser.ID, []int{library1.ID}) Expect(err).ToNot(HaveOccurred()) // Promote to admin regularUser.IsAdmin = true err = repo.Put(ctx, ®ularUser) Expect(err).ToNot(HaveOccurred()) // Should now have access to all libraries (including existing ones) libraries, err := repo.GetUserLibraries(ctx, regularUser.ID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(initialLibCount + 2)) // Initial libraries + our 2 test libraries libIDs := make([]int, len(libraries)) for i, lib := range libraries { libIDs[i] = lib.ID } // Should include our test libraries plus all existing ones Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) }) It("assigns default libraries to regular users", func() { regularUser := model.User{ ID: "regular-user-id-2", UserName: "regularuser2", Name: "Regular User", Email: "regular2@example.com", NewPassword: "password", IsAdmin: false, } err := repo.Put(ctx, ®ularUser) Expect(err).ToNot(HaveOccurred()) // Regular user should be assigned to default libraries (library ID 1 from migration) libraries, err := repo.GetUserLibraries(ctx, regularUser.ID) Expect(err).ToNot(HaveOccurred()) Expect(libraries).To(HaveLen(1)) Expect(libraries[0].ID).To(Equal(1)) Expect(libraries[0].DefaultNewUsers).To(BeTrue()) }) }) Describe("Libraries Field Population", func() { var ( libRepo model.LibraryRepository library1 model.Library library2 model.Library testUser model.User ) BeforeEach(func() { libRepo = NewLibraryRepository(GetDBXBuilder()) library1 = model.Library{ID: 0, Name: "Field Test Library 1", Path: "/field/test/path1"} library2 = model.Library{ID: 0, Name: "Field Test Library 2", Path: "/field/test/path2"} // Create test libraries Expect(libRepo.Put(ctx, &library1)).To(BeNil()) Expect(libRepo.Put(ctx, &library2)).To(BeNil()) // Create test user testUser = model.User{ ID: "field-test-user", UserName: "fieldtestuser", Name: "Field Test User", Email: "fieldtest@example.com", NewPassword: "password", IsAdmin: false, } Expect(repo.Put(ctx, &testUser)).To(BeNil()) // Assign libraries to user Expect(repo.SetUserLibraries(ctx, testUser.ID, []int{library1.ID, library2.ID})).To(BeNil()) }) AfterEach(func() { // Clean up test libraries and their associations _ = libRepo.(*libraryRepository).delete(ctx, squirrel.Eq{"id": []int{library1.ID, library2.ID}}) _ = repo.(*userRepository).delete(ctx, squirrel.Eq{"id": testUser.ID}) // Clean up user-library associations for these test libraries _, _ = repo.(*userRepository).executeSQL(ctx, squirrel.Delete("user_library").Where(squirrel.Eq{"library_id": []int{library1.ID, library2.ID}})) }) It("populates Libraries field when getting a single user", func() { user, err := repo.Get(ctx, testUser.ID) Expect(err).ToNot(HaveOccurred()) Expect(user.Libraries).To(HaveLen(2)) libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID} Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) // Check that library details are properly populated for _, lib := range user.Libraries { switch lib.ID { case library1.ID: Expect(lib.Name).To(Equal("Field Test Library 1")) Expect(lib.Path).To(Equal("/field/test/path1")) case library2.ID: Expect(lib.Name).To(Equal("Field Test Library 2")) Expect(lib.Path).To(Equal("/field/test/path2")) } } }) It("populates Libraries field when getting all users", func() { users, err := repo.(*userRepository).GetAll(ctx) Expect(err).ToNot(HaveOccurred()) // Find our test user in the results found := slices.IndexFunc(users, func(u model.User) bool { return u.ID == testUser.ID }) Expect(found).ToNot(Equal(-1)) foundUser := users[found] Expect(foundUser).ToNot(BeNil()) Expect(foundUser.Libraries).To(HaveLen(2)) libIDs := []int{foundUser.Libraries[0].ID, foundUser.Libraries[1].ID} Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) }) It("populates Libraries field when finding user by username", func() { user, err := repo.FindByUsername(ctx, testUser.UserName) Expect(err).ToNot(HaveOccurred()) Expect(user.Libraries).To(HaveLen(2)) libIDs := []int{user.Libraries[0].ID, user.Libraries[1].ID} Expect(libIDs).To(ContainElements(library1.ID, library2.ID)) }) It("returns default Libraries array for new regular users", func() { // Create a user with no explicit library associations - should get default libraries userWithoutLibs := model.User{ ID: "no-libs-user", UserName: "nolibsuser", Name: "No Libs User", Email: "nolibs@example.com", NewPassword: "password", IsAdmin: false, } Expect(repo.Put(ctx, &userWithoutLibs)).To(BeNil()) defer func() { _ = repo.(*userRepository).delete(ctx, squirrel.Eq{"id": userWithoutLibs.ID}) }() user, err := repo.Get(ctx, userWithoutLibs.ID) Expect(err).ToNot(HaveOccurred()) Expect(user.Libraries).ToNot(BeNil()) // Regular users should be assigned to default libraries (library ID 1 from migration) Expect(user.Libraries).To(HaveLen(1)) Expect(user.Libraries[0].ID).To(Equal(1)) }) }) Describe("validateScrobbleFilter", func() { It("accepts an empty filter", func() { u := &model.User{} Expect(validateScrobbleFilter(u)).To(Succeed()) }) It("trims a whitespace-only filter to empty", func() { u := &model.User{ScrobbleFilter: " "} Expect(validateScrobbleFilter(u)).To(Succeed()) Expect(u.ScrobbleFilter).To(Equal("")) }) It("accepts valid criteria JSON", func() { u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}]}`} Expect(validateScrobbleFilter(u)).To(Succeed()) }) It("rejects malformed JSON", func() { u := &model.User{ScrobbleFilter: `{not json`} var vErr *rest.ValidationError err := validateScrobbleFilter(u) Expect(errors.As(err, &vErr)).To(BeTrue()) Expect(vErr.Errors).To(HaveKey("scrobbleFilter")) }) It("rejects criteria without rules", func() { u := &model.User{ScrobbleFilter: `{"sort":"title"}`} Expect(validateScrobbleFilter(u)).ToNot(Succeed()) }) It("rejects selection options that mean nothing for a single track", func() { for _, f := range []string{ `{"all":[{"lt":{"rating":4}}],"limit":100}`, `{"all":[{"lt":{"rating":4}}],"limitPercent":10}`, `{"all":[{"lt":{"rating":4}}],"offset":5}`, `{"all":[{"lt":{"rating":4}}],"refreshDelay":"1h"}`, } { u := &model.User{ScrobbleFilter: f} Expect(validateScrobbleFilter(u)).ToNot(Succeed(), f) } }) It("accepts a sort, which cannot change a single-track match", func() { u := &model.User{ScrobbleFilter: `{"all":[{"lt":{"rating":4}}],"sort":"title"}`} Expect(validateScrobbleFilter(u)).To(Succeed()) }) It("rejects unknown fields", func() { u := &model.User{ScrobbleFilter: `{"all":[{"is":{"bogusfield":1}}]}`} Expect(validateScrobbleFilter(u)).ToNot(Succeed()) }) }) Describe("filters", func() { It("qualifies id filter with table name", func() { r := repo.(*userRepository) qo := r.parseRestOptions(ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}}) sel := r.selectUserWithLibraries(ctx, qo) query, _, err := r.toSQL(sel) Expect(err).NotTo(HaveOccurred()) Expect(query).To(ContainSubstring("user.id = {:p0}")) }) }) Describe("token epoch", func() { var repo model.UserRepository var usr model.User newUser := func() model.User { uid := id.NewRandom() // user_name is unique; suffix it so each It gets its own row in the shared suite DB. return model.User{ID: uid, UserName: "epoch-user-" + uid, Name: "Epoch", NewPassword: "hunter2"} } BeforeEach(func() { ctx = request.WithUser(ctx, model.User{ID: "userid", IsAdmin: true}) repo = NewUserRepository(GetDBXBuilder()) usr = newUser() Expect(repo.Put(ctx, &usr)).To(Succeed()) }) It("starts at zero for a new user", func() { got, err := repo.Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.TokenEpoch).To(Equal(0)) }) It("increments once per password change", func() { usr.NewPassword = "second" Expect(repo.Put(ctx, &usr)).To(Succeed()) got, err := repo.Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.TokenEpoch).To(Equal(1)) usr.NewPassword = "third" Expect(repo.Put(ctx, &usr)).To(Succeed()) got, err = repo.Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.TokenEpoch).To(Equal(2)) }) It("leaves the epoch alone when the password is untouched", func() { usr.NewPassword = "" usr.Name = "Renamed" Expect(repo.Put(ctx, &usr)).To(Succeed()) got, err := repo.Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.TokenEpoch).To(Equal(0)) Expect(got.Name).To(Equal("Renamed")) }) It("never signals the same epoch to two concurrent password changes", func() { // Each writer's epoch must be the one its own UPDATE produced. const callers = 4 var mu sync.Mutex var signalled []int var wg sync.WaitGroup for range callers { wg.Go(func() { ctx := log.NewContext(context.TODO()) ctx = request.WithUser(ctx, model.User{ID: usr.ID}) ctx = request.WithTokenEpochHolder(ctx) own := NewUserRepository(GetDBXBuilder()) u := usr u.NewPassword = "concurrent" if err := own.Put(ctx, &u); err != nil { return // the shared in-memory test DB can raise SQLITE_LOCKED } epoch, ok := request.TokenEpochFrom(ctx) if !ok { return } mu.Lock() defer mu.Unlock() signalled = append(signalled, epoch) }) } wg.Wait() Expect(signalled).To(HaveLen(len(slice.Unique(signalled))), "an epoch was signalled to more than one writer: %v", signalled) }) }) Describe("Put and the token epoch", func() { newRepo := func(actingUserID string) (context.Context, model.UserRepository) { ctx := log.NewContext(context.TODO()) ctx = request.WithUser(ctx, model.User{ID: actingUserID, IsAdmin: true}) ctx = request.WithTokenEpochHolder(ctx) return ctx, NewUserRepository(GetDBXBuilder()) } It("does not bump when creating a user", func() { ctx, repo := newRepo("admin") usr := model.User{ID: id.NewRandom(), UserName: "fresh", NewPassword: "pw1"} Expect(repo.Put(ctx, &usr)).To(Succeed()) got, err := repo.Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.TokenEpoch).To(Equal(0)) }) It("bumps when the password changes", func() { ctx, repo := newRepo("admin") usr := model.User{ID: id.NewRandom(), UserName: "changer", NewPassword: "pw1"} Expect(repo.Put(ctx, &usr)).To(Succeed()) usr.NewPassword = "pw2" Expect(repo.Put(ctx, &usr)).To(Succeed()) got, err := repo.Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.TokenEpoch).To(Equal(1)) }) It("does not bump on an edit that leaves the password alone", func() { ctx, repo := newRepo("admin") usr := model.User{ID: id.NewRandom(), UserName: "renamer", NewPassword: "pw1"} Expect(repo.Put(ctx, &usr)).To(Succeed()) usr.NewPassword = "" usr.Name = "New Display Name" Expect(repo.Put(ctx, &usr)).To(Succeed()) got, err := repo.Get(ctx, usr.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.TokenEpoch).To(Equal(0)) }) It("signals the new epoch when a user changes their own password", func() { userID := id.NewRandom() ctx, repo := newRepo(userID) usr := model.User{ID: userID, UserName: "self", NewPassword: "pw1"} Expect(repo.Put(ctx, &usr)).To(Succeed()) usr.NewPassword = "pw2" Expect(repo.Put(ctx, &usr)).To(Succeed()) epoch, ok := request.TokenEpochFrom(ctx) Expect(ok).To(BeTrue()) Expect(epoch).To(Equal(1)) }) It("does not signal when an admin changes someone else's password", func() { ctx, repo := newRepo("some-admin") usr := model.User{ID: id.NewRandom(), UserName: "other", NewPassword: "pw1"} Expect(repo.Put(ctx, &usr)).To(Succeed()) usr.NewPassword = "pw2" Expect(repo.Put(ctx, &usr)).To(Succeed()) _, ok := request.TokenEpochFrom(ctx) Expect(ok).To(BeFalse()) }) }) })