mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-08 10:27:08 +02:00
feat(api): add api_grant storage for API v1 grants
This commit is contained in:
parent
a62d1629f0
commit
2afe2ffe0a
8 changed files with 433 additions and 0 deletions
23
db/migrations/20260926045200_create_api_grant.sql
Normal file
23
db/migrations/20260926045200_create_api_grant.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
create table api_grant (
|
||||
id varchar not null primary key,
|
||||
user_id varchar not null references user(id) on delete cascade,
|
||||
name varchar not null,
|
||||
client varchar not null,
|
||||
client_version varchar not null default '',
|
||||
scopes varchar not null default '',
|
||||
provider varchar not null,
|
||||
secret_hash varchar not null unique,
|
||||
user_epoch integer not null default 0,
|
||||
created_at datetime not null,
|
||||
last_used_at datetime,
|
||||
last_used_ip varchar not null default ''
|
||||
);
|
||||
create index api_grant_user_id on api_grant(user_id);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
drop table api_grant;
|
||||
-- +goose StatementEnd
|
||||
|
|
@ -37,6 +37,7 @@ type DataStore interface {
|
|||
Plugin() PluginRepository
|
||||
Artwork() ArtworkRepository
|
||||
ArtworkQueue() ArtworkQueueRepository
|
||||
Grant() GrantRepository
|
||||
|
||||
WithTx(block func(tx DataStore) error, scope ...string) error
|
||||
WithTxImmediate(block func(tx DataStore) error, scope ...string) error
|
||||
|
|
|
|||
62
model/grant.go
Normal file
62
model/grant.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Grant struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
UserID string `structs:"user_id" json:"userId"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
Client string `structs:"client" json:"client"`
|
||||
ClientVersion string `structs:"client_version" json:"clientVersion"`
|
||||
Scopes Scopes `structs:"scopes" json:"scopes"`
|
||||
Provider string `structs:"provider" json:"provider"`
|
||||
SecretHash string `structs:"secret_hash" json:"-"`
|
||||
UserEpoch int `structs:"user_epoch" json:"-"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
LastUsedAt *time.Time `structs:"last_used_at" json:"lastUsedAt"`
|
||||
LastUsedIP string `structs:"last_used_ip" json:"lastUsedIp"`
|
||||
}
|
||||
|
||||
type Grants []Grant
|
||||
|
||||
// Scopes is stored as a single space-separated column.
|
||||
type Scopes []string
|
||||
|
||||
func (s Scopes) Value() (driver.Value, error) {
|
||||
return strings.Join(s, " "), nil
|
||||
}
|
||||
|
||||
func (s *Scopes) Scan(src any) error {
|
||||
switch v := src.(type) {
|
||||
case string:
|
||||
*s = strings.Fields(v)
|
||||
case []byte:
|
||||
*s = strings.Fields(string(v))
|
||||
case nil:
|
||||
*s = nil
|
||||
default:
|
||||
return fmt.Errorf("cannot scan %T into Scopes", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type GrantRepository interface {
|
||||
Put(ctx context.Context, g *Grant) error
|
||||
Get(ctx context.Context, id string) (*Grant, error)
|
||||
FindBySecretHash(ctx context.Context, hash string) (*Grant, error)
|
||||
GetAllForUser(ctx context.Context, userID string, idleSince time.Time, offset, limit int) (Grants, error)
|
||||
CountForUser(ctx context.Context, userID string, idleSince time.Time) (int64, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
DeleteForUser(ctx context.Context, userID, id string) error
|
||||
DeleteOtherEpochs(ctx context.Context, userID string, epoch int) error
|
||||
SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error
|
||||
DeleteIfEpoch(ctx context.Context, id string, epoch int) error
|
||||
Touch(ctx context.Context, id, ip string, at, notSince time.Time) error
|
||||
DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error)
|
||||
}
|
||||
123
persistence/grant_repository.go
Normal file
123
persistence/grant_repository.go
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
type grantRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
func NewGrantRepository(db dbx.Builder) model.GrantRepository {
|
||||
r := &grantRepository{}
|
||||
r.db = db
|
||||
r.tableName = "api_grant"
|
||||
return r
|
||||
}
|
||||
|
||||
const grantLastActivity = "COALESCE(last_used_at, created_at)"
|
||||
|
||||
func (r *grantRepository) Put(ctx context.Context, g *model.Grant) error {
|
||||
if g.ID == "" {
|
||||
g.ID = id.NewRandom()
|
||||
}
|
||||
if g.CreatedAt.IsZero() {
|
||||
g.CreatedAt = time.Now()
|
||||
}
|
||||
// Stored as UTC: SQLite compares these timestamps as strings.
|
||||
g.CreatedAt = g.CreatedAt.UTC()
|
||||
if g.LastUsedAt != nil {
|
||||
t := g.LastUsedAt.UTC()
|
||||
g.LastUsedAt = &t
|
||||
}
|
||||
values, err := toSQLArgs(*g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.executeSQL(ctx, Insert(r.tableName).SetMap(values))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *grantRepository) Get(ctx context.Context, id string) (*model.Grant, error) {
|
||||
return r.findOne(ctx, Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r *grantRepository) FindBySecretHash(ctx context.Context, hash string) (*model.Grant, error) {
|
||||
return r.findOne(ctx, Eq{"secret_hash": hash})
|
||||
}
|
||||
|
||||
func (r *grantRepository) findOne(ctx context.Context, cond Sqlizer) (*model.Grant, error) {
|
||||
var g model.Grant
|
||||
if err := r.queryOne(ctx, r.newSelect(ctx).Columns("*").Where(cond), &g); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func (r *grantRepository) activeForUser(userID string, idleSince time.Time) Sqlizer {
|
||||
return And{Eq{"user_id": userID}, Expr(grantLastActivity+" >= ?", idleSince.UTC())}
|
||||
}
|
||||
|
||||
func (r *grantRepository) GetAllForUser(ctx context.Context, userID string, idleSince time.Time, offset, limit int) (model.Grants, error) {
|
||||
sel := r.newSelect(ctx).Columns("*").Where(r.activeForUser(userID, idleSince)).
|
||||
OrderBy("last_used_at IS NULL", "last_used_at desc", "created_at desc", "id").
|
||||
Offset(uint64(offset)).Limit(uint64(limit))
|
||||
var res model.Grants
|
||||
err := r.queryAll(ctx, sel, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r *grantRepository) CountForUser(ctx context.Context, userID string, idleSince time.Time) (int64, error) {
|
||||
return r.count(ctx, Select().Where(r.activeForUser(userID, idleSince)))
|
||||
}
|
||||
|
||||
func (r *grantRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.delete(ctx, Eq{"id": id})
|
||||
}
|
||||
|
||||
func (r *grantRepository) DeleteForUser(ctx context.Context, userID, id string) error {
|
||||
n, err := r.executeSQL(ctx, Delete(r.tableName).Where(Eq{"id": id, "user_id": userID}))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *grantRepository) DeleteOtherEpochs(ctx context.Context, userID string, epoch int) error {
|
||||
_, err := r.executeSQL(ctx, Delete(r.tableName).Where(And{Eq{"user_id": userID}, NotEq{"user_epoch": epoch}}))
|
||||
return err
|
||||
}
|
||||
|
||||
// SetEpoch only moves grants still on fromEpoch, so grants killed by an earlier change never come back.
|
||||
func (r *grantRepository) SetEpoch(ctx context.Context, userID string, fromEpoch, toEpoch int, onlyID string) error {
|
||||
cond := Eq{"user_id": userID, "user_epoch": fromEpoch}
|
||||
if onlyID != "" {
|
||||
cond["id"] = onlyID
|
||||
}
|
||||
_, err := r.executeSQL(ctx, Update(r.tableName).Set("user_epoch", toEpoch).Where(cond))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *grantRepository) DeleteIfEpoch(ctx context.Context, id string, epoch int) error {
|
||||
return r.delete(ctx, Eq{"id": id, "user_epoch": epoch})
|
||||
}
|
||||
|
||||
func (r *grantRepository) Touch(ctx context.Context, id, ip string, at, notSince time.Time) error {
|
||||
upd := Update(r.tableName).Set("last_used_at", at.UTC()).Set("last_used_ip", ip).
|
||||
Where(And{Eq{"id": id}, Or{Eq{"last_used_at": nil}, Lt{"last_used_at": notSince.UTC()}}})
|
||||
_, err := r.executeSQL(ctx, upd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *grantRepository) DeleteIdle(ctx context.Context, idleSince time.Time) (int64, error) {
|
||||
return r.executeSQL(ctx, Delete(r.tableName).Where(Expr(grantLastActivity+" < ?", idleSince.UTC())))
|
||||
}
|
||||
192
persistence/grant_repository_test.go
Normal file
192
persistence/grant_repository_test.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var _ = Describe("GrantRepository", func() {
|
||||
var ctx context.Context
|
||||
var repo model.GrantRepository
|
||||
var now time.Time
|
||||
|
||||
newGrant := func(userID, hash string) *model.Grant {
|
||||
return &model.Grant{UserID: userID, Name: "TV", Client: "TestApp", Scopes: model.Scopes{"all"},
|
||||
Provider: "password", SecretHash: hash, CreatedAt: now}
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = log.NewContext(GinkgoT().Context())
|
||||
repo = NewGrantRepository(GetDBXBuilder())
|
||||
now = time.Now().UTC().Truncate(time.Second)
|
||||
DeferCleanup(func() {
|
||||
_, _ = GetDBXBuilder().NewQuery("delete from api_grant").Execute()
|
||||
})
|
||||
})
|
||||
|
||||
It("stores a grant and finds it by id and by secret hash", func() {
|
||||
g := newGrant(adminUser.ID, "hash-1")
|
||||
g.Scopes = model.Scopes{"read", "password"}
|
||||
Expect(repo.Put(ctx, g)).To(Succeed())
|
||||
Expect(g.ID).ToNot(BeEmpty())
|
||||
|
||||
byID, err := repo.Get(ctx, g.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(byID.Scopes).To(Equal(model.Scopes{"read", "password"}))
|
||||
Expect(byID.LastUsedAt).To(BeNil())
|
||||
Expect(byID.LastUsedIP).To(BeEmpty())
|
||||
|
||||
byHash, err := repo.FindBySecretHash(ctx, "hash-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(byHash.ID).To(Equal(g.ID))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound for unknown ids and hashes", func() {
|
||||
_, err := repo.Get(ctx, "nope")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
_, err = repo.FindBySecretHash(ctx, "nope")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("lists and counts only the user's non-idle grants by lastUsedAt, never-used ones last", func() {
|
||||
old := newGrant(adminUser.ID, "h-old")
|
||||
old.CreatedAt = now.Add(-100 * 24 * time.Hour)
|
||||
usedEarly := newGrant(adminUser.ID, "h-used-early")
|
||||
usedEarly.CreatedAt = now.Add(-10 * time.Hour)
|
||||
earlyUse := now.Add(-5 * time.Hour)
|
||||
usedEarly.LastUsedAt = &earlyUse
|
||||
usedLate := newGrant(adminUser.ID, "h-used-late")
|
||||
usedLate.CreatedAt = now.Add(-10 * time.Hour)
|
||||
lateUse := now.Add(-time.Hour)
|
||||
usedLate.LastUsedAt = &lateUse
|
||||
freshNeverUsed := newGrant(adminUser.ID, "h-fresh") // newer than both uses, but never used
|
||||
other := newGrant(regularUser.ID, "h-other")
|
||||
for _, g := range []*model.Grant{old, usedEarly, usedLate, freshNeverUsed, other} {
|
||||
Expect(repo.Put(ctx, g)).To(Succeed())
|
||||
}
|
||||
idleSince := now.Add(-90 * 24 * time.Hour)
|
||||
|
||||
list, err := repo.GetAllForUser(ctx, adminUser.ID, idleSince, 0, 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect([]string{list[0].ID, list[1].ID, list[2].ID}).To(Equal([]string{usedLate.ID, usedEarly.ID, freshNeverUsed.ID}))
|
||||
|
||||
Expect(repo.CountForUser(ctx, adminUser.ID, idleSince)).To(Equal(int64(3)))
|
||||
|
||||
page, err := repo.GetAllForUser(ctx, adminUser.ID, idleSince, 1, 1)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(page).To(HaveLen(1))
|
||||
Expect(page[0].ID).To(Equal(usedEarly.ID))
|
||||
})
|
||||
|
||||
It("deletes a grant only for its owner", func() {
|
||||
g := newGrant(adminUser.ID, "h-own")
|
||||
Expect(repo.Put(ctx, g)).To(Succeed())
|
||||
Expect(repo.DeleteForUser(ctx, regularUser.ID, g.ID)).To(MatchError(model.ErrNotFound))
|
||||
Expect(repo.DeleteForUser(ctx, adminUser.ID, g.ID)).To(Succeed())
|
||||
_, err := repo.Get(ctx, g.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("moves epochs forward and deletes grants left on other epochs", func() {
|
||||
keep := newGrant(adminUser.ID, "h-keep")
|
||||
drop := newGrant(adminUser.ID, "h-drop")
|
||||
Expect(repo.Put(ctx, keep)).To(Succeed())
|
||||
Expect(repo.Put(ctx, drop)).To(Succeed())
|
||||
|
||||
Expect(repo.SetEpoch(ctx, adminUser.ID, 0, 3, keep.ID)).To(Succeed())
|
||||
Expect(repo.DeleteOtherEpochs(ctx, adminUser.ID, 3)).To(Succeed())
|
||||
|
||||
kept, err := repo.Get(ctx, keep.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(kept.UserEpoch).To(Equal(3))
|
||||
_, err = repo.Get(ctx, drop.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
|
||||
Expect(repo.SetEpoch(ctx, adminUser.ID, 3, 4, "")).To(Succeed())
|
||||
kept, _ = repo.Get(ctx, keep.ID)
|
||||
Expect(kept.UserEpoch).To(Equal(4))
|
||||
})
|
||||
|
||||
It("never moves a grant that is not on fromEpoch", func() {
|
||||
stale := newGrant(adminUser.ID, "h-stale") // left behind by an earlier password change
|
||||
stale.UserEpoch = 1
|
||||
current := newGrant(adminUser.ID, "h-current")
|
||||
current.UserEpoch = 2
|
||||
Expect(repo.Put(ctx, stale)).To(Succeed())
|
||||
Expect(repo.Put(ctx, current)).To(Succeed())
|
||||
|
||||
Expect(repo.SetEpoch(ctx, adminUser.ID, 2, 3, "")).To(Succeed())
|
||||
got, _ := repo.Get(ctx, stale.ID)
|
||||
Expect(got.UserEpoch).To(Equal(1))
|
||||
got, _ = repo.Get(ctx, current.ID)
|
||||
Expect(got.UserEpoch).To(Equal(3))
|
||||
})
|
||||
|
||||
It("deletes by epoch only while the row is still on it", func() {
|
||||
g := newGrant(adminUser.ID, "h-cond")
|
||||
g.UserEpoch = 5
|
||||
Expect(repo.Put(ctx, g)).To(Succeed())
|
||||
Expect(repo.DeleteIfEpoch(ctx, g.ID, 4)).To(Succeed())
|
||||
_, err := repo.Get(ctx, g.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(repo.DeleteIfEpoch(ctx, g.ID, 5)).To(Succeed())
|
||||
_, err = repo.Get(ctx, g.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
|
||||
It("touches a never-used grant, then throttles until notSince passes", func() {
|
||||
g := newGrant(adminUser.ID, "h-touch")
|
||||
Expect(repo.Put(ctx, g)).To(Succeed())
|
||||
|
||||
Expect(repo.Touch(ctx, g.ID, "10.0.0.1", now, now.Add(-5*time.Minute))).To(Succeed())
|
||||
got, _ := repo.Get(ctx, g.ID)
|
||||
Expect(got.LastUsedAt).ToNot(BeNil())
|
||||
Expect(got.LastUsedAt.UTC()).To(BeTemporally("==", now))
|
||||
Expect(got.LastUsedIP).To(Equal("10.0.0.1"))
|
||||
|
||||
later := now.Add(time.Minute)
|
||||
Expect(repo.Touch(ctx, g.ID, "10.0.0.2", later, later.Add(-5*time.Minute))).To(Succeed())
|
||||
got, _ = repo.Get(ctx, g.ID)
|
||||
Expect(got.LastUsedIP).To(Equal("10.0.0.1"))
|
||||
|
||||
muchLater := now.Add(6 * time.Minute)
|
||||
Expect(repo.Touch(ctx, g.ID, "10.0.0.3", muchLater, muchLater.Add(-5*time.Minute))).To(Succeed())
|
||||
got, _ = repo.Get(ctx, g.ID)
|
||||
Expect(got.LastUsedIP).To(Equal("10.0.0.3"))
|
||||
})
|
||||
|
||||
It("deletes idle grants, using created_at for never-used ones", func() {
|
||||
idle := newGrant(adminUser.ID, "h-idle")
|
||||
idle.CreatedAt = now.Add(-100 * 24 * time.Hour)
|
||||
usedRecently := newGrant(adminUser.ID, "h-used-recently")
|
||||
usedRecently.CreatedAt = now.Add(-100 * 24 * time.Hour)
|
||||
recentUse := now.Add(-time.Hour)
|
||||
usedRecently.LastUsedAt = &recentUse
|
||||
Expect(repo.Put(ctx, idle)).To(Succeed())
|
||||
Expect(repo.Put(ctx, usedRecently)).To(Succeed())
|
||||
|
||||
n, err := repo.DeleteIdle(ctx, now.Add(-90*24*time.Hour))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(n).To(Equal(int64(1)))
|
||||
_, err = repo.Get(ctx, usedRecently.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("deletes a user's grants when the user is deleted", func() {
|
||||
users := NewUserRepository(GetDBXBuilder())
|
||||
u := model.User{ID: "grant-owner", UserName: "grant-owner", NewPassword: "pw"}
|
||||
Expect(users.Put(ctx, &u)).To(Succeed())
|
||||
g := newGrant(u.ID, "h-cascade")
|
||||
Expect(repo.Put(ctx, g)).To(Succeed())
|
||||
|
||||
Expect(users.Delete(request.WithUser(ctx, adminUser), u.ID)).To(Succeed())
|
||||
_, err := repo.Get(ctx, g.ID)
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
|
@ -37,6 +37,7 @@ type SQLStore struct {
|
|||
plugin func() model.PluginRepository
|
||||
artwork func() model.ArtworkRepository
|
||||
artworkQueue func() model.ArtworkQueueRepository
|
||||
grant func() model.GrantRepository
|
||||
}
|
||||
|
||||
// Repositories are built on first use, so a transaction store only pays for the ones its block touches.
|
||||
|
|
@ -64,6 +65,7 @@ func newSQLStore(db dbx.Builder) *SQLStore {
|
|||
plugin: sync.OnceValue(func() model.PluginRepository { return NewPluginRepository(db) }),
|
||||
artwork: sync.OnceValue(func() model.ArtworkRepository { return NewArtworkRepository(db) }),
|
||||
artworkQueue: sync.OnceValue(func() model.ArtworkQueueRepository { return NewArtworkQueueRepository(db) }),
|
||||
grant: sync.OnceValue(func() model.GrantRepository { return NewGrantRepository(db) }),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +157,10 @@ func (s *SQLStore) ArtworkQueue() model.ArtworkQueueRepository {
|
|||
return s.artworkQueue()
|
||||
}
|
||||
|
||||
func (s *SQLStore) Grant() model.GrantRepository {
|
||||
return s.grant()
|
||||
}
|
||||
|
||||
func scopeLabel(scope []string) string {
|
||||
if len(scope) > 0 {
|
||||
return scope[0]
|
||||
|
|
@ -271,6 +277,10 @@ func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error {
|
|||
trace(ctx, "clean media file bookmarks", func() error { return s.mediaFile().(*mediaFileRepository).cleanBookmarks(ctx) }),
|
||||
trace(ctx, "purge non used tags", func() error { return s.tag().(*tagRepository).purgeUnused(ctx) }),
|
||||
trace(ctx, "remove orphan playlist tracks", func() error { return s.playlist().(*playlistRepository).removeOrphans(ctx) }),
|
||||
trace(ctx, "purge idle API grants", func() error {
|
||||
_, err := s.grant().DeleteIdle(ctx, time.Now().Add(-90*24*time.Hour))
|
||||
return err
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tidying up database: %w", err)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ type MockDataStore struct {
|
|||
MockedPlugin model.PluginRepository
|
||||
MockedArtwork model.ArtworkRepository
|
||||
MockedArtworkQueue model.ArtworkQueueRepository
|
||||
MockedGrant model.GrantRepository
|
||||
scrobbleBufferMu sync.Mutex
|
||||
repoMu sync.Mutex
|
||||
|
||||
|
|
@ -321,6 +322,19 @@ func (db *MockDataStore) ArtworkQueue() model.ArtworkQueueRepository {
|
|||
return db.MockedArtworkQueue
|
||||
}
|
||||
|
||||
func (db *MockDataStore) Grant() model.GrantRepository {
|
||||
db.repoMu.Lock()
|
||||
defer db.repoMu.Unlock()
|
||||
if db.MockedGrant != nil {
|
||||
return db.MockedGrant
|
||||
}
|
||||
if db.RealDS != nil {
|
||||
return db.RealDS.Grant()
|
||||
}
|
||||
db.MockedGrant = &MockedGrantRepo{}
|
||||
return db.MockedGrant
|
||||
}
|
||||
|
||||
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
|
||||
return block(db)
|
||||
}
|
||||
|
|
|
|||
8
tests/mock_grant_repo.go
Normal file
8
tests/mock_grant_repo.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package tests
|
||||
|
||||
import "github.com/navidrome/navidrome/model"
|
||||
|
||||
// MockedGrantRepo exists so MockDataStore satisfies DataStore; auth tests use a real database.
|
||||
type MockedGrantRepo struct {
|
||||
model.GrantRepository
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue