mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(model): add app password support
Adds the model, persistence, and database migration for named per-user app passwords. Secret values are AES-GCM encrypted at rest using a key generated on first run and stored in the data directory. This is a standalone primitive useful on its own for Subsonic clients and CLI/script auth and lands ahead of the OIDC work that will rely on it for IdP-managed users.
This commit is contained in:
parent
efe9291db0
commit
14ba9c7ed3
32
db/migrations/20260510120100_create_app_password.go
Normal file
32
db/migrations/20260510120100_create_app_password.go
Normal file
@ -0,0 +1,32 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upCreateAppPassword, downCreateAppPassword)
|
||||
}
|
||||
|
||||
func upCreateAppPassword(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS app_password (
|
||||
id VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
user_id VARCHAR(255) NOT NULL REFERENCES user(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
secret_encrypted TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME,
|
||||
expires_at DATETIME
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS app_password_user_id ON app_password(user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS app_password_user_name ON app_password(user_id, name);`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downCreateAppPassword(ctx context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
76
model/app_password.go
Normal file
76
model/app_password.go
Normal file
@ -0,0 +1,76 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppPassword represents a long-lived secondary credential a user creates so
|
||||
// that Subsonic clients (which cannot perform an OIDC flow) can authenticate
|
||||
// without exposing the user's primary password. Each app password is bound to
|
||||
// a single user, has a human-readable name, and is stored AES-GCM-encrypted so
|
||||
// that the Subsonic md5(password+salt) verification path can read the
|
||||
// plaintext at request time.
|
||||
type AppPassword struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
UserID string `structs:"user_id" json:"userId"`
|
||||
Name string `structs:"name" json:"name"`
|
||||
SecretEncrypted string `structs:"secret_encrypted" json:"-"`
|
||||
CreatedAt time.Time `structs:"created_at" json:"createdAt"`
|
||||
LastUsedAt *time.Time `structs:"last_used_at" json:"lastUsedAt,omitempty"`
|
||||
ExpiresAt *time.Time `structs:"expires_at" json:"expiresAt,omitempty"`
|
||||
|
||||
// Secret is the plaintext app password. It is populated in two situations
|
||||
// only: (a) on the response to Create, where the caller must surface it to
|
||||
// the user immediately because it is never recoverable afterwards; and (b)
|
||||
// internally inside GetActiveForUser so the Subsonic auth fallback can
|
||||
// compute md5(secret+salt). Never persisted, never serialized on read APIs.
|
||||
Secret string `structs:"-" json:"secret,omitempty"`
|
||||
}
|
||||
|
||||
type AppPasswords []AppPassword
|
||||
|
||||
// AppPasswordPublic is the read-only projection returned by listing endpoints.
|
||||
// It deliberately omits SecretEncrypted and Secret so neither the ciphertext
|
||||
// nor the plaintext can leak through generic JSON serialization.
|
||||
type AppPasswordPublic struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
type AppPasswordRepository interface {
|
||||
// Create generates a cryptographically random secret, encrypts it with the
|
||||
// shared password-encryption key, persists the record, and returns the
|
||||
// plaintext secret along with the new record. The plaintext is returned
|
||||
// exactly once; subsequent reads only ever expose the ciphertext.
|
||||
//
|
||||
// Parameters:
|
||||
// userID - owner user ID; foreign key into the user table.
|
||||
// name - human-readable label, unique per user.
|
||||
// expiresAt - optional expiry; nil means the password never expires.
|
||||
Create(ctx context.Context, userID, name string, expiresAt *time.Time) (plaintextSecret string, ap *AppPassword, err error)
|
||||
|
||||
// Delete removes the app password identified by id, but only if it is owned
|
||||
// by ownerUserID. This double-check prevents users from deleting other
|
||||
// users' app passwords by guessing IDs.
|
||||
Delete(ctx context.Context, id, ownerUserID string) error
|
||||
|
||||
// GetActiveForUser returns all non-expired app passwords for the given
|
||||
// user, with the Secret field populated (decrypted) so the Subsonic
|
||||
// authentication fallback can perform md5(secret+salt) checks. Returns an
|
||||
// empty slice if the user has no active app passwords.
|
||||
GetActiveForUser(ctx context.Context, userID string) (AppPasswords, error)
|
||||
|
||||
// UpdateLastUsedAt sets last_used_at on the record to the current time.
|
||||
// Called fire-and-forget after a successful Subsonic authentication, so
|
||||
// errors are non-fatal but should be logged.
|
||||
UpdateLastUsedAt(ctx context.Context, id string) error
|
||||
|
||||
// ListForUser returns the metadata-only public projection for the given
|
||||
// user, suitable for the management UI list view.
|
||||
ListForUser(ctx context.Context, userID string) ([]AppPasswordPublic, error)
|
||||
}
|
||||
@ -37,6 +37,7 @@ type DataStore interface {
|
||||
Property(ctx context.Context) PropertyRepository
|
||||
User(ctx context.Context) UserRepository
|
||||
UserProps(ctx context.Context) UserPropsRepository
|
||||
AppPassword(ctx context.Context) AppPasswordRepository
|
||||
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
||||
Scrobble(ctx context.Context) ScrobbleRepository
|
||||
Plugin(ctx context.Context) PluginRepository
|
||||
|
||||
176
persistence/app_password_repository.go
Normal file
176
persistence/app_password_repository.go
Normal file
@ -0,0 +1,176 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
. "github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
// appPasswordRepository persists named long-lived app passwords. Each row stores
|
||||
// an AES-GCM-encrypted secret so that the Subsonic md5(secret+salt) verification
|
||||
// path can recover the plaintext on each authentication attempt.
|
||||
type appPasswordRepository struct {
|
||||
sqlRepository
|
||||
}
|
||||
|
||||
// appPasswordSecretBytes is the size, in raw bytes, of a generated app-password
|
||||
// secret before base64 encoding. 24 bytes → 32-character URL-safe string, which
|
||||
// is well above the practical brute-force threshold for the Subsonic md5+salt
|
||||
// verification flow.
|
||||
const appPasswordSecretBytes = 24
|
||||
|
||||
// NewAppPasswordRepository constructs a repository over the app_password table.
|
||||
//
|
||||
// Side effect: it calls NewUserRepository to guarantee that the shared password
|
||||
// encryption key (encKey) has been derived and any one-time password
|
||||
// re-encryption migration has run before this repository is used. Without this
|
||||
// piggyback, a request that happens to hit /rest before any /api or /auth
|
||||
// endpoint could observe a nil encKey.
|
||||
func NewAppPasswordRepository(ctx context.Context, db dbx.Builder) model.AppPasswordRepository {
|
||||
_ = NewUserRepository(ctx, db) // ensures encKey is initialised
|
||||
r := &appPasswordRepository{}
|
||||
r.ctx = ctx
|
||||
r.db = db
|
||||
r.tableName = "app_password"
|
||||
r.registerModel(&model.AppPassword{}, nil)
|
||||
return r
|
||||
}
|
||||
|
||||
// Create generates a fresh secret, encrypts it, and inserts a new row.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx : request context used for cancellation and logging.
|
||||
// - userID : the owning user's ID; must already exist in the user table.
|
||||
// - name : a human-readable label, unique per user.
|
||||
// - expiresAt : optional expiry timestamp; nil means the password never expires.
|
||||
//
|
||||
// Returns the plaintext secret (which the caller must surface to the user
|
||||
// exactly once — it is not retrievable afterwards) plus the persisted record.
|
||||
func (r *appPasswordRepository) Create(ctx context.Context, userID, name string, expiresAt *time.Time) (string, *model.AppPassword, error) {
|
||||
if userID == "" {
|
||||
return "", nil, errors.New("appPassword: userID is required")
|
||||
}
|
||||
if name == "" {
|
||||
return "", nil, errors.New("appPassword: name is required")
|
||||
}
|
||||
|
||||
plaintext, err := generateAppPasswordSecret()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
encrypted, err := utils.Encrypt(ctx, encryptionKey(), plaintext)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
ap := &model.AppPassword{
|
||||
ID: id.NewRandom(),
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
SecretEncrypted: encrypted,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
insert := Insert(r.tableName).SetMap(map[string]any{
|
||||
"id": ap.ID,
|
||||
"user_id": ap.UserID,
|
||||
"name": ap.Name,
|
||||
"secret_encrypted": ap.SecretEncrypted,
|
||||
"created_at": ap.CreatedAt,
|
||||
"expires_at": ap.ExpiresAt,
|
||||
})
|
||||
if _, err := r.executeSQL(insert); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
ap.Secret = plaintext
|
||||
return plaintext, ap, nil
|
||||
}
|
||||
|
||||
// Delete removes the row identified by id, but only if owned by ownerUserID.
|
||||
// The compound WHERE clause prevents users from deleting other users' app
|
||||
// passwords by guessing IDs.
|
||||
func (r *appPasswordRepository) Delete(ctx context.Context, id, ownerUserID string) error {
|
||||
del := Delete(r.tableName).Where(And{Eq{"id": id}, Eq{"user_id": ownerUserID}})
|
||||
count, err := r.executeSQL(del)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActiveForUser returns every non-expired app password for userID, with the
|
||||
// Secret field populated by decrypting SecretEncrypted. Used by the Subsonic
|
||||
// authentication fallback.
|
||||
func (r *appPasswordRepository) GetActiveForUser(ctx context.Context, userID string) (model.AppPasswords, error) {
|
||||
now := time.Now()
|
||||
sel := r.newSelect().Columns("id", "user_id", "name", "secret_encrypted", "created_at", "last_used_at", "expires_at").
|
||||
Where(Eq{"user_id": userID}).
|
||||
Where(Or{Eq{"expires_at": nil}, Gt{"expires_at": now}})
|
||||
var rows model.AppPasswords
|
||||
if err := r.queryAll(sel, &rows); err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.AppPasswords{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for i := range rows {
|
||||
plain, err := utils.Decrypt(ctx, encryptionKey(), rows[i].SecretEncrypted)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Skipping app password whose secret could not be decrypted", "id", rows[i].ID, err)
|
||||
continue
|
||||
}
|
||||
rows[i].Secret = plain
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// UpdateLastUsedAt sets last_used_at on the row to the current wall clock time.
|
||||
// Intended to be called fire-and-forget from the Subsonic auth path.
|
||||
func (r *appPasswordRepository) UpdateLastUsedAt(ctx context.Context, id string) error {
|
||||
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_used_at", time.Now())
|
||||
_, err := r.executeSQL(upd)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListForUser returns the metadata-only public projection for the user's
|
||||
// app passwords, sorted by creation time descending so the most recent appears
|
||||
// first in the management UI.
|
||||
func (r *appPasswordRepository) ListForUser(ctx context.Context, userID string) ([]model.AppPasswordPublic, error) {
|
||||
sel := r.newSelect().
|
||||
Columns("id", "user_id", "name", "created_at", "last_used_at", "expires_at").
|
||||
Where(Eq{"user_id": userID}).
|
||||
OrderBy("created_at DESC")
|
||||
var rows []model.AppPasswordPublic
|
||||
if err := r.queryAll(sel, &rows); err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return []model.AppPasswordPublic{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// generateAppPasswordSecret returns a URL-safe base64-encoded random string
|
||||
// suitable for use as a secondary password.
|
||||
func generateAppPasswordSecret() (string, error) {
|
||||
buf := make([]byte, appPasswordSecretBytes)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
var _ model.AppPasswordRepository = (*appPasswordRepository)(nil)
|
||||
122
persistence/app_password_repository_test.go
Normal file
122
persistence/app_password_repository_test.go
Normal file
@ -0,0 +1,122 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/id"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("AppPasswordRepository", func() {
|
||||
var (
|
||||
repo model.AppPasswordRepository
|
||||
userID string
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(GinkgoT().Context())
|
||||
// The user repo constructor seeds the encryption key; the app password
|
||||
// repo piggybacks on that via NewUserRepository in its constructor.
|
||||
userRepo := NewUserRepository(ctx, GetDBXBuilder())
|
||||
userID = id.NewRandom()
|
||||
Expect(userRepo.Put(&model.User{
|
||||
ID: userID,
|
||||
UserName: "ap-user-" + userID,
|
||||
Name: "AP User",
|
||||
NewPassword: "irrelevant",
|
||||
})).To(Succeed())
|
||||
|
||||
repo = NewAppPasswordRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
|
||||
Describe("Create", func() {
|
||||
It("returns plaintext and persists encrypted secret", func() {
|
||||
plain, ap, err := repo.Create(GinkgoT().Context(), userID, "phone", nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(plain).ToNot(BeEmpty())
|
||||
Expect(ap.SecretEncrypted).ToNot(BeEmpty())
|
||||
Expect(ap.SecretEncrypted).ToNot(Equal(plain))
|
||||
})
|
||||
|
||||
It("rejects empty user ID", func() {
|
||||
_, _, err := repo.Create(GinkgoT().Context(), "", "x", nil)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetActiveForUser", func() {
|
||||
It("decrypts and returns active passwords", func() {
|
||||
plain, _, err := repo.Create(GinkgoT().Context(), userID, "active", nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rows, err := repo.GetActiveForUser(GinkgoT().Context(), userID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(rows).To(HaveLen(1))
|
||||
Expect(rows[0].Secret).To(Equal(plain))
|
||||
})
|
||||
|
||||
It("excludes expired passwords", func() {
|
||||
past := time.Now().Add(-time.Hour)
|
||||
_, _, err := repo.Create(GinkgoT().Context(), userID, "expired", &past)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rows, err := repo.GetActiveForUser(GinkgoT().Context(), userID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(rows).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Delete", func() {
|
||||
It("removes a password owned by the user", func() {
|
||||
_, ap, err := repo.Create(GinkgoT().Context(), userID, "to-delete", nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(repo.Delete(GinkgoT().Context(), ap.ID, userID)).To(Succeed())
|
||||
|
||||
rows, err := repo.GetActiveForUser(GinkgoT().Context(), userID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(rows).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("refuses to delete a password owned by a different user", func() {
|
||||
_, ap, err := repo.Create(GinkgoT().Context(), userID, "other-owned", nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = repo.Delete(GinkgoT().Context(), ap.ID, "someone-else")
|
||||
Expect(err).To(MatchError(model.ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("UpdateLastUsedAt", func() {
|
||||
It("sets last_used_at on the row", func() {
|
||||
_, ap, err := repo.Create(GinkgoT().Context(), userID, "lu", nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(repo.UpdateLastUsedAt(GinkgoT().Context(), ap.ID)).To(Succeed())
|
||||
|
||||
pubs, err := repo.ListForUser(GinkgoT().Context(), userID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pubs).ToNot(BeEmpty())
|
||||
Expect(pubs[0].LastUsedAt).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ListForUser", func() {
|
||||
It("returns metadata only, ordered newest first", func() {
|
||||
_, _, err := repo.Create(GinkgoT().Context(), userID, "first", nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
_, _, err = repo.Create(GinkgoT().Context(), userID, "second", nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
pubs, err := repo.ListForUser(GinkgoT().Context(), userID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pubs).To(HaveLen(2))
|
||||
Expect(pubs[0].Name).To(Equal("second"))
|
||||
Expect(pubs[1].Name).To(Equal("first"))
|
||||
})
|
||||
})
|
||||
})
|
||||
15
persistence/enc_key.go
Normal file
15
persistence/enc_key.go
Normal file
@ -0,0 +1,15 @@
|
||||
package persistence
|
||||
|
||||
// encryptionKey returns the 32-byte AES-GCM key used to encrypt user passwords
|
||||
// and app-password secrets.
|
||||
//
|
||||
// The key is initialised by NewUserRepository's sync.Once on first call to any
|
||||
// user repository constructor; that path also performs the one-time
|
||||
// re-encryption migration when conf.Server.PasswordEncryptionKey is rotated.
|
||||
// Repositories that depend on this key (e.g. app_password_repository) must
|
||||
// therefore ensure a user repository has been constructed at least once before
|
||||
// they invoke this accessor — see appPasswordRepository's constructor for the
|
||||
// canonical pattern.
|
||||
func encryptionKey() []byte {
|
||||
return encKey
|
||||
}
|
||||
@ -77,6 +77,10 @@ func (s *SQLStore) User(ctx context.Context) model.UserRepository {
|
||||
return NewUserRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) AppPassword(ctx context.Context) model.AppPasswordRepository {
|
||||
return NewAppPasswordRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository {
|
||||
return NewTranscodingRepository(ctx, s.getDBXBuilder())
|
||||
}
|
||||
|
||||
60
tests/mock_app_password_repo.go
Normal file
60
tests/mock_app_password_repo.go
Normal file
@ -0,0 +1,60 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// MockAppPasswordRepo is a minimal in-memory stand-in for
|
||||
// model.AppPasswordRepository. By default GetActiveForUser returns an empty
|
||||
// slice, so the Subsonic auth fallback short-circuits cleanly without
|
||||
// panicking on nil-method invocations of an unmocked interface.
|
||||
//
|
||||
// Tests that need richer behaviour can populate Active or override fields
|
||||
// directly.
|
||||
type MockAppPasswordRepo struct {
|
||||
model.AppPasswordRepository
|
||||
Active model.AppPasswords
|
||||
GetActiveErr error
|
||||
UpdateLastUsedAtFn func(id string)
|
||||
}
|
||||
|
||||
func CreateMockAppPasswordRepo() *MockAppPasswordRepo {
|
||||
return &MockAppPasswordRepo{}
|
||||
}
|
||||
|
||||
func (m *MockAppPasswordRepo) GetActiveForUser(ctx context.Context, userID string) (model.AppPasswords, error) {
|
||||
if m.GetActiveErr != nil {
|
||||
return nil, m.GetActiveErr
|
||||
}
|
||||
out := make(model.AppPasswords, 0, len(m.Active))
|
||||
for _, ap := range m.Active {
|
||||
if ap.UserID == userID {
|
||||
out = append(out, ap)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *MockAppPasswordRepo) UpdateLastUsedAt(ctx context.Context, id string) error {
|
||||
if m.UpdateLastUsedAtFn != nil {
|
||||
m.UpdateLastUsedAtFn(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockAppPasswordRepo) ListForUser(ctx context.Context, userID string) ([]model.AppPasswordPublic, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockAppPasswordRepo) Create(ctx context.Context, userID, name string, expiresAt *time.Time) (string, *model.AppPassword, error) {
|
||||
ap := &model.AppPassword{ID: "mock", UserID: userID, Name: name, CreatedAt: time.Now(), ExpiresAt: expiresAt}
|
||||
m.Active = append(m.Active, *ap)
|
||||
return "secret", ap, nil
|
||||
}
|
||||
|
||||
func (m *MockAppPasswordRepo) Delete(ctx context.Context, id, ownerUserID string) error {
|
||||
return nil
|
||||
}
|
||||
@ -28,6 +28,7 @@ type MockDataStore struct {
|
||||
MockedScrobble model.ScrobbleRepository
|
||||
MockedRadio model.RadioRepository
|
||||
MockedPlugin model.PluginRepository
|
||||
MockedAppPassword model.AppPasswordRepository
|
||||
scrobbleBufferMu sync.Mutex
|
||||
repoMu sync.Mutex
|
||||
|
||||
@ -247,6 +248,17 @@ func (db *MockDataStore) Plugin(ctx context.Context) model.PluginRepository {
|
||||
return db.MockedPlugin
|
||||
}
|
||||
|
||||
func (db *MockDataStore) AppPassword(ctx context.Context) model.AppPasswordRepository {
|
||||
if db.MockedAppPassword != nil {
|
||||
return db.MockedAppPassword
|
||||
}
|
||||
if db.RealDS != nil {
|
||||
return db.RealDS.AppPassword(ctx)
|
||||
}
|
||||
db.MockedAppPassword = CreateMockAppPasswordRepo()
|
||||
return db.MockedAppPassword
|
||||
}
|
||||
|
||||
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
|
||||
return block(db)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user