package persistence import ( "context" "errors" "github.com/deluan/rest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" ) const testAPIKey = "nds_0123456789abcdefghijkl" func expectAPIKeyError(err error, msg string) { var verr *rest.ValidationError ExpectWithOffset(1, errors.As(err, &verr)).To(BeTrue()) ExpectWithOffset(1, verr.Errors).To(HaveKeyWithValue("apiKey", msg)) } var _ = Describe("PlayerRepository", func() { var adminRepo *playerRepository var database *dbx.DB var ctx context.Context var ( adminPlayer1 = model.Player{ID: "1", Name: "NavidromeUI [Firefox/Linux]", UserAgent: "Firefox/Linux", UserId: adminUser.ID, Username: adminUser.UserName, Client: "NavidromeUI", IP: "127.0.0.1", ReportRealPath: true, ScrobbleEnabled: true} adminPlayer2 = model.Player{ID: "2", Name: "GenericClient [Chrome/Windows]", IP: "192.168.0.5", UserAgent: "Chrome/Windows", UserId: adminUser.ID, Username: adminUser.UserName, Client: "GenericClient", MaxBitRate: 128} regularPlayer = model.Player{ID: "3", Name: "NavidromeUI [Safari/macOS]", UserAgent: "Safari/macOS", UserId: regularUser.ID, Username: regularUser.UserName, Client: "NavidromeUI", ReportRealPath: true, ScrobbleEnabled: false} players = model.Players{adminPlayer1, adminPlayer2, regularPlayer} ) BeforeEach(func() { ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) database = GetDBXBuilder() adminRepo = NewPlayerRepository(database).(*playerRepository) for idx := range players { err := adminRepo.Put(ctx, &players[idx]) Expect(err).To(BeNil()) } }) AfterEach(func() { players, err := adminRepo.ReadAll(ctx) Expect(err).To(BeNil()) for i := range players { err = adminRepo.Delete(ctx, players[i].ID) Expect(err).To(BeNil()) } }) Describe("FindMatch", func() { It("finds existing match", func() { player, err := adminRepo.FindMatch(ctx, adminUser.ID, "NavidromeUI", "Firefox/Linux") Expect(err).To(BeNil()) Expect(*player).To(Equal(adminPlayer1)) }) It("doesn't find bad match", func() { _, err := adminRepo.FindMatch(ctx, regularUser.ID, "NavidromeUI", "Firefox/Linux") Expect(err).To(Equal(model.ErrNotFound)) }) }) Describe("Get", func() { It("Gets an existing item from user", func() { player, err := adminRepo.Get(ctx, adminPlayer1.ID) Expect(err).To(BeNil()) Expect(*player).To(Equal(adminPlayer1)) }) It("Gets an existing item from another user", func() { player, err := adminRepo.Get(ctx, regularPlayer.ID) Expect(err).To(BeNil()) Expect(*player).To(Equal(regularPlayer)) }) It("does not get nonexistent item", func() { _, err := adminRepo.Get(ctx, "i don't exist") Expect(err).To(Equal(model.ErrNotFound)) }) }) DescribeTableSubtree("per context", func(admin bool, players model.Players, userPlayer model.Player, otherPlayer model.Player) { var repo *playerRepository var repoCtx context.Context BeforeEach(func() { repoCtx = ctx if admin { repo = adminRepo } else { repoCtx = request.WithUser(ctx, regularUser) repo = NewPlayerRepository(database).(*playerRepository) } }) baseCount := int64(len(players)) Describe("Count", func() { It("should return all", func() { count, err := repo.Count(repoCtx) Expect(err).To(BeNil()) Expect(count).To(Equal(baseCount)) }) }) Describe("Delete", func() { It("deletes a player owned by the current user", func() { err := repo.Delete(repoCtx, userPlayer.ID) Expect(err).To(BeNil()) count, err := repo.Count(repoCtx) Expect(err).To(BeNil()) Expect(count).To(Equal(baseCount - 1)) _, err = repo.Get(repoCtx, userPlayer.ID) Expect(err).To(Equal(model.ErrNotFound)) }) It("does not delete another user's player when not admin", func() { err := repo.Delete(repoCtx, otherPlayer.ID) if admin { // Admins may delete any player. Expect(err).To(BeNil()) Expect(repo.Count(repoCtx)).To(Equal(baseCount - 1)) _, err = repo.Get(repoCtx, otherPlayer.ID) Expect(err).To(Equal(model.ErrNotFound)) } else { // The ownership-restricted delete matches no owned row, so it reports // permission-denied and leaves the other user's player untouched. Expect(err).To(Equal(rest.ErrPermissionDenied)) Expect(repo.Count(repoCtx)).To(Equal(baseCount)) item, err := repo.Get(repoCtx, otherPlayer.ID) Expect(err).To(BeNil()) Expect(*item).To(Equal(otherPlayer)) } }) It("returns not-found for a nonexistent player", func() { err := repo.Delete(repoCtx, "i don't exist") Expect(err).To(Equal(rest.ErrNotFound)) Expect(repo.Count(repoCtx)).To(Equal(baseCount)) }) }) Describe("Read", func() { It("can read from current user", func() { player, err := repo.Read(repoCtx, userPlayer.ID) Expect(err).To(BeNil()) Expect(player).To(Equal(&userPlayer)) }) It("can read from other user or fail if not admin", func() { player, err := repo.Read(repoCtx, otherPlayer.ID) if admin { Expect(err).To(BeNil()) Expect(player).To(Equal(&otherPlayer)) } else { Expect(err).To(Equal(model.ErrNotFound)) } }) It("does not get nonexistent item", func() { _, err := repo.Read(repoCtx, "i don't exist") Expect(err).To(Equal(model.ErrNotFound)) }) }) Describe("ReadAll", func() { It("should get all items", func() { data, err := repo.ReadAll(repoCtx) Expect(err).To(BeNil()) Expect(model.Players(data)).To(Equal(players)) }) }) Describe("Save", func() { DescribeTable("item type", func(player model.Player) { clone := player clone.ID = "" clone.IP = "192.168.1.1" clone.APIKey = new(testAPIKey) id, err := repo.Save(repoCtx, &clone) if clone.UserId == "" { Expect(err).To(HaveOccurred()) } else if player.UserId != userPlayer.UserId { Expect(err).To(Equal(rest.ErrPermissionDenied)) clone.UserId = "" } else { Expect(err).To(BeNil()) Expect(id).ToNot(BeEmpty()) } count, err := repo.Count(repoCtx) Expect(err).To(BeNil()) clone.ID = id newItem, err := repo.Get(repoCtx, id) if clone.UserId == "" { Expect(count).To(Equal(baseCount)) Expect(err).To(Equal(model.ErrNotFound)) } else { Expect(count).To(Equal(baseCount + 1)) Expect(err).To(BeNil()) clone.APIKey = nil clone.HasAPIKey = true Expect(*newItem).To(Equal(clone)) } }, Entry("same user", userPlayer), Entry("other item", otherPlayer), ) }) Describe("Update", func() { DescribeTable("item type", func(player model.Player) { clone := player clone.IP = "192.168.1.1" clone.MaxBitRate = 10000 err := repo.Update(repoCtx, clone.ID, clone, "ip") if player.UserId == "" { Expect(err).To(HaveOccurred()) } else if !admin && player.Username == adminPlayer1.Username { // A non-admin cannot target another user's player: the ownership-restricted // update matches no owned row, so it reports permission-denied rather than // touching it. Expect(err).To(Equal(rest.ErrPermissionDenied)) clone.IP = player.IP } else { Expect(err).To(BeNil()) } clone.MaxBitRate = player.MaxBitRate newItem, err := repo.Get(repoCtx, clone.ID) if player.UserId == "" { Expect(err).To(Equal(model.ErrNotFound)) } else if !admin && player.UserId == adminUser.ID { Expect(*newItem).To(Equal(player)) } else { Expect(*newItem).To(Equal(clone)) } }, Entry("same user", userPlayer), Entry("other item", otherPlayer), Entry("fake item", model.Player{}), ) }) }, Entry("admin context", true, players, adminPlayer1, regularPlayer), Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1), ) Describe("API keys", func() { const key = testAPIKey const otherKey = "nds_ABCDEFGHIJKLMNOPQRSTUV" var ownerCtx, otherCtx context.Context BeforeEach(func() { ownerCtx = request.WithUser(log.NewContext(GinkgoT().Context()), regularUser) otherCtx = request.WithUser(log.NewContext(GinkgoT().Context()), thirdUser) }) storedHash := func(id string) string { var row struct { Hash string `db:"api_key_hash"` } Expect(database.NewQuery("select coalesce(api_key_hash, '') as api_key_hash from player where id = {:id}"). Bind(dbx.Params{"id": id}).One(&row)).To(Succeed()) return row.Hash } Describe("SetAPIKey", func() { It("stores only the hash and finds the player by the key", func() { Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) plr, err := adminRepo.FindByAPIKey(ctx, key) Expect(err).ToNot(HaveOccurred()) Expect(plr.ID).To(Equal(regularPlayer.ID)) Expect(plr.HasAPIKey).To(BeTrue()) Expect(plr.APIKey).To(BeNil()) }) It("replaces the previous key", func() { Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, otherKey)).To(Succeed()) _, err := adminRepo.FindByAPIKey(ctx, key) Expect(err).To(MatchError(model.ErrNotFound)) _, err = adminRepo.FindByAPIKey(ctx, otherKey) Expect(err).ToNot(HaveOccurred()) }) DescribeTable("rejects malformed keys", func(bad string) { err := adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, bad) expectAPIKeyError(err, "resources.player.validation.apiKeyFormat") Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) }, Entry("no prefix", "0123456789abcdefghijklmn"), Entry("too short", "nds_short"), Entry("too long", key+"x"), Entry("bad chars", "nds_0123456789abcdefghij-!"), ) It("revokes with an empty key, by the owner or an admin", func() { Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed()) Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, "")).To(Succeed()) Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) }) It("accepts revoking a player that has no key", func() { Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, "")).To(Succeed()) }) It("does not let an admin set a key on another user's player", func() { Expect(adminRepo.SetAPIKey(ctx, regularPlayer.ID, key)).To(MatchError(rest.ErrPermissionDenied)) Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) }) It("does not let another user set or revoke", func() { Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, otherKey)).To(MatchError(rest.ErrPermissionDenied)) Expect(adminRepo.SetAPIKey(otherCtx, regularPlayer.ID, "")).To(MatchError(rest.ErrPermissionDenied)) Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) }) It("returns not found for a missing player", func() { Expect(adminRepo.SetAPIKey(ownerCtx, "missing", key)).To(MatchError(rest.ErrNotFound)) Expect(adminRepo.SetAPIKey(ownerCtx, "missing", "")).To(MatchError(rest.ErrNotFound)) }) It("does not find unknown or empty keys", func() { _, err := adminRepo.FindByAPIKey(ctx, otherKey) Expect(err).To(MatchError(model.ErrNotFound)) _, err = adminRepo.FindByAPIKey(ctx, "") Expect(err).To(MatchError(model.ErrNotFound)) }) It("drops the key with the player", func() { Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) Expect(adminRepo.Delete(ownerCtx, regularPlayer.ID)).To(Succeed()) _, err := adminRepo.FindByAPIKey(ctx, key) Expect(err).To(MatchError(model.ErrNotFound)) }) }) Describe("hasApiKey filter", func() { BeforeEach(func() { Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) }) filtered := func(value string) []string { res, err := adminRepo.ReadAll(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": value}}) Expect(err).ToNot(HaveOccurred()) var ids []string for _, p := range res { ids = append(ids, p.ID) } return ids } It("lists only players with a key", func() { Expect(filtered("true")).To(ConsistOf(regularPlayer.ID)) count, err := adminRepo.Count(ctx, rest.QueryOptions{Filters: map[string]any{"hasApiKey": "true"}}) Expect(err).ToNot(HaveOccurred()) Expect(count).To(Equal(int64(1))) }) It("lists only players without a key", func() { Expect(filtered("false")).To(ConsistOf(adminPlayer1.ID, adminPlayer2.ID)) }) }) Describe("Save (create)", func() { It("creates the player with the key, owned by the logged-in user", func() { id, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Manual player", APIKey: new(key)}) Expect(err).ToNot(HaveOccurred()) plr, err := adminRepo.FindByAPIKey(ctx, key) Expect(err).ToNot(HaveOccurred()) Expect(plr.ID).To(Equal(id)) Expect(plr.UserId).To(Equal(regularUser.ID)) }) It("requires a key", func() { count, _ := adminRepo.CountAll(ctx) _, err := adminRepo.Save(ownerCtx, &model.Player{Name: "No key"}) expectAPIKeyError(err, "ra.validation.required") _, err = adminRepo.Save(ownerCtx, &model.Player{Name: "Empty key", APIKey: new("")}) expectAPIKeyError(err, "ra.validation.required") Expect(adminRepo.CountAll(ctx)).To(Equal(count)) }) It("rejects a malformed key without creating the player", func() { count, _ := adminRepo.CountAll(ctx) _, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Bad", APIKey: new("nds_bad")}) expectAPIKeyError(err, "resources.player.validation.apiKeyFormat") Expect(adminRepo.CountAll(ctx)).To(Equal(count)) }) It("does not let an admin create a keyed player for another user", func() { count, _ := adminRepo.CountAll(ctx) _, err := adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID, APIKey: new(key)}) Expect(err).To(MatchError(rest.ErrPermissionDenied)) _, err = adminRepo.Save(ctx, &model.Player{Name: "For someone", UserId: regularUser.ID}) Expect(err).To(MatchError(rest.ErrPermissionDenied)) Expect(adminRepo.CountAll(ctx)).To(Equal(count)) }) It("rejects a key already used by another player without creating the player", func() { Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed()) count, _ := adminRepo.CountAll(ctx) _, err := adminRepo.Save(ownerCtx, &model.Player{Name: "Duplicate", APIKey: new(key)}) expectAPIKeyError(err, "ra.validation.unique") Expect(adminRepo.CountAll(ctx)).To(Equal(count)) }) }) Describe("Update (edit)", func() { It("rolls back the key change when the rest of the edit fails", func() { _, err := database.NewQuery(`create trigger fail_player_rename before update of name on player when new.name = 'boom' begin select raise(abort, 'boom'); end`).Execute() Expect(err).ToNot(HaveOccurred()) DeferCleanup(func() { _, _ = database.NewQuery("drop trigger if exists fail_player_rename").Execute() }) plr := regularPlayer plr.Name = "boom" plr.APIKey = new(key) Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).ToNot(Succeed()) Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) }) It("keeps the key when apiKey is absent (a normal edit)", func() { Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) plr := regularPlayer plr.Name = "Renamed" Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "hasApiKey")).To(Succeed()) Expect(adminRepo.Update(ownerCtx, plr.ID, plr)).To(Succeed()) found, err := adminRepo.FindByAPIKey(ctx, key) Expect(err).ToNot(HaveOccurred()) Expect(found.Name).To(Equal("Renamed")) }) It("sets a new key when apiKey has a value", func() { plr := regularPlayer plr.APIKey = new(key) Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey")).To(Succeed()) Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) }) It("revokes the key when apiKey is empty", func() { Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) plr := regularPlayer plr.APIKey = new("") Expect(adminRepo.Update(ownerCtx, plr.ID, plr, "apiKey")).To(Succeed()) Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) }) It("lets an admin edit another user's keyed player without touching the key", func() { Expect(adminRepo.SetAPIKey(ownerCtx, regularPlayer.ID, key)).To(Succeed()) plr := regularPlayer plr.MaxBitRate = 192 Expect(adminRepo.Update(ctx, plr.ID, plr, "maxBitRate", "hasApiKey")).To(Succeed()) Expect(storedHash(regularPlayer.ID)).To(Equal(hashAPIKey(key))) }) It("refuses an admin setting a key on another user's player and leaves other columns alone", func() { plr := regularPlayer plr.Name = "Hijacked" plr.APIKey = new(key) Expect(adminRepo.Update(ctx, plr.ID, plr, "name", "apiKey")).To(MatchError(rest.ErrPermissionDenied)) got, err := adminRepo.Get(ctx, regularPlayer.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.Name).To(Equal(regularPlayer.Name)) Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) }) It("refuses a key already used by another player and leaves other columns alone", func() { Expect(adminRepo.SetAPIKey(ctx, adminPlayer1.ID, key)).To(Succeed()) plr := regularPlayer plr.Name = "Renamed" plr.APIKey = new(key) err := adminRepo.Update(ownerCtx, plr.ID, plr, "name", "apiKey") expectAPIKeyError(err, "ra.validation.unique") got, err := adminRepo.Get(ctx, regularPlayer.ID) Expect(err).ToNot(HaveOccurred()) Expect(got.Name).To(Equal(regularPlayer.Name)) Expect(storedHash(regularPlayer.ID)).To(BeEmpty()) Expect(storedHash(adminPlayer1.ID)).To(Equal(hashAPIKey(key))) }) }) }) Describe("Ownership enforcement (cross-tenant write protection)", func() { var regularRepo *playerRepository var regularCtx context.Context BeforeEach(func() { regularCtx = request.WithUser(ctx, regularUser) regularRepo = NewPlayerRepository(database).(*playerRepository) }) It("does not let a regular user hijack another user's player by spoofing userId in the body", func() { // Attacker (regularUser) targets the victim's (adminUser) player by URL id, // but sets userId in the body to their own id to try to pass the permission check. spoofed := model.Player{ ID: adminPlayer1.ID, Name: "HIJACKED", UserId: regularUser.ID, // attacker's own id, spoofed in the body MaxBitRate: 1, } // The ownership-restricted update matches no row owned by the attacker, so the write // targets nothing and reports permission-denied rather than overwriting the victim's row. err := regularRepo.Update(regularCtx, adminPlayer1.ID, spoofed, "name", "user_id", "max_bit_rate") Expect(err).To(Equal(rest.ErrPermissionDenied)) // The victim's player must remain untouched. stored, err := adminRepo.Get(ctx, adminPlayer1.ID) Expect(err).To(BeNil()) Expect(*stored).To(Equal(adminPlayer1)) }) It("does not let a regular user overwrite another user's player via Save with a spoofed id", func() { spoofed := model.Player{ ID: adminPlayer1.ID, Name: "HIJACKED", UserId: regularUser.ID, ReportRealPath: true, APIKey: new(testAPIKey), } id, err := regularRepo.Save(regularCtx, &spoofed) Expect(err).To(BeNil()) Expect(id).ToNot(Equal(adminPlayer1.ID)) stored, err := adminRepo.Get(ctx, adminPlayer1.ID) Expect(err).To(BeNil()) Expect(*stored).To(Equal(adminPlayer1)) created, err := adminRepo.Get(ctx, id) Expect(err).To(BeNil()) Expect(created.UserId).To(Equal(regularUser.ID)) }) It("does not let a regular user reassign their own player to another user", func() { // Owner updates their own player but tries to give it away to the admin. The update // succeeds for the other fields, but user_id is never written, so ownership stays put. reassign := regularPlayer reassign.UserId = adminUser.ID reassign.Name = "given-away" err := regularRepo.Update(regularCtx, regularPlayer.ID, reassign, "name", "user_id") Expect(err).To(BeNil()) // Ownership must not have changed. stored, err := adminRepo.Get(ctx, regularPlayer.ID) Expect(err).To(BeNil()) Expect(stored.UserId).To(Equal(regularUser.ID)) }) It("does not let an admin reassign a player to another user", func() { // Even an admin cannot change a player's owner via update. reassign := regularPlayer reassign.UserId = adminUser.ID reassign.Name = "admin-renamed" err := adminRepo.Update(regularCtx, regularPlayer.ID, reassign, "name", "user_id") Expect(err).To(BeNil()) // The name change applies, but ownership must not have moved. stored, err := adminRepo.Get(ctx, regularPlayer.ID) Expect(err).To(BeNil()) Expect(stored.Name).To(Equal("admin-renamed")) Expect(stored.UserId).To(Equal(regularUser.ID)) }) It("lets the owner update their own player", func() { update := regularPlayer update.Name = "renamed-by-owner" err := regularRepo.Update(regularCtx, regularPlayer.ID, update, "name") Expect(err).To(BeNil()) stored, err := adminRepo.Get(ctx, regularPlayer.ID) Expect(err).To(BeNil()) Expect(stored.Name).To(Equal("renamed-by-owner")) Expect(stored.UserId).To(Equal(regularUser.ID)) }) It("returns not found when updating a nonexistent player", func() { ghost := model.Player{ID: "does-not-exist", Name: "ghost", UserId: regularUser.ID} err := regularRepo.Update(regularCtx, "does-not-exist", ghost, "name") Expect(err).To(Equal(rest.ErrNotFound)) }) }) })