mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-14 13:27:09 +02:00
- GrantRepository keeps three deletes: DeleteForUser, DeleteStaleEpochs
(replaces DeleteOtherEpochs and DeleteIfEpoch) and DeleteIdle. Delete(id)
is gone; the idle path in ResolveGrant now calls DeleteIdle, so a grant
renewed by another node between the read and the delete survives.
settleEpoch deletes the user's grants below the snapshot's epoch, which
is safe outside the transaction because epochs only move forward.
- The "dead grants on an older epoch are only deleted when presented"
policy note moves from the repository to core ListGrants.
- SQL trace logging no longer prints the args of property writes (signing
keys) or user password writes, both encrypted with a key that may be the
public default. The SQL statement is still logged.
- The spec gate rejects JSON body keys that differ from a declared property
only in case. kin-openapi validates exact names while encoding/json
decodes case-insensitively, so {"scopes":[],"Scopes":null} minted a
token with every scope and a "Client" key skipped maxLength.
It also rejects data after the first JSON value, which the handlers'
decoder ignores and which let a body skip the alias check.
- The liveness cache trims its eviction log on evict, not only on put, so
evict-only traffic stays bounded; the floor still drops stale fills.
- createAccessToken, login and setupFirstAdmin declare Cache-Control:
no-store on their success responses.
380 lines
14 KiB
Go
380 lines
14 KiB
Go
package persistence
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Masterminds/squirrel"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/db"
|
|
"github.com/navidrome/navidrome/log"
|
|
"github.com/navidrome/navidrome/model"
|
|
"github.com/navidrome/navidrome/model/request"
|
|
"github.com/navidrome/navidrome/tests"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"github.com/pocketbase/dbx"
|
|
)
|
|
|
|
func TestPersistence(t *testing.T) {
|
|
tests.Init(t, true)
|
|
|
|
//os.Remove("./test-123.db")
|
|
//conf.Server.DbPath = "./test-123.db"
|
|
conf.Server.DbPath = "file::memory:?cache=shared&_foreign_keys=on"
|
|
defer db.Init(context.Background())()
|
|
log.SetLevel(log.LevelFatal)
|
|
RegisterFailHandler(Fail)
|
|
RunSpecs(t, "Persistence Suite")
|
|
}
|
|
|
|
func mf(mf model.MediaFile) model.MediaFile {
|
|
mf.Tags = model.Tags{}
|
|
mf.LibraryID = 1
|
|
mf.LibraryPath = "music" // Default folder
|
|
mf.LibraryName = "Music Library"
|
|
mf.Participants = model.Participants{
|
|
model.RoleArtist: model.ParticipantList{
|
|
model.Participant{Artist: model.Artist{ID: mf.ArtistID, Name: mf.Artist}},
|
|
},
|
|
}
|
|
if mf.Lyrics == "" {
|
|
mf.Lyrics = "[]"
|
|
}
|
|
return mf
|
|
}
|
|
|
|
func al(al model.Album) model.Album {
|
|
al.LibraryID = 1
|
|
al.LibraryPath = "music"
|
|
al.LibraryName = "Music Library"
|
|
al.Discs = model.Discs{}
|
|
al.Tags = model.Tags{}
|
|
al.Participants = model.Participants{}
|
|
return al
|
|
}
|
|
|
|
func alWithTags(a model.Album, tags model.Tags) model.Album {
|
|
a = al(a)
|
|
a.Tags = tags
|
|
return a
|
|
}
|
|
|
|
var (
|
|
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
|
|
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
|
|
artistCJK = model.Artist{ID: "4", Name: "シートベルツ", SortArtistName: "Seatbelts", OrderArtistName: "seatbelts"}
|
|
artistPunctuation = model.Artist{ID: "5", Name: "The Roots", OrderArtistName: "roots"}
|
|
testArtists = model.Artists{
|
|
artistKraftwerk,
|
|
artistBeatles,
|
|
artistCJK,
|
|
artistPunctuation,
|
|
}
|
|
)
|
|
|
|
var (
|
|
albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
|
|
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
|
|
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("kraft/radio/radio.mp3"), SongCount: 2})
|
|
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("test/multi/disc1/track1.mp3"), SongCount: 4})
|
|
albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1})
|
|
albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019},
|
|
model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}})
|
|
albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("roots/things/track1.mp3"), SongCount: 1})
|
|
testAlbums = model.Albums{
|
|
albumSgtPeppers,
|
|
albumAbbeyRoad,
|
|
albumRadioactivity,
|
|
albumMultiDisc,
|
|
albumCJK,
|
|
albumWithVersion,
|
|
albumPunctuation,
|
|
}
|
|
)
|
|
|
|
var (
|
|
songDayInALife = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("beatles/1/sgt/a day.mp3")})
|
|
songComeTogether = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("beatles/1/come together.mp3")})
|
|
songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("kraft/radio/radio.mp3")})
|
|
songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
|
|
AlbumID: "103",
|
|
Path: p("kraft/radio/antenna.mp3"),
|
|
RGAlbumGain: new(1.0), RGAlbumPeak: new(2.0), RGTrackGain: new(3.0), RGTrackPeak: new(4.0),
|
|
})
|
|
songAntennaWithLyrics = mf(model.MediaFile{
|
|
ID: "1005",
|
|
Title: "Antenna",
|
|
ArtistID: "2",
|
|
Artist: "Kraftwerk",
|
|
AlbumID: "103",
|
|
Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`,
|
|
})
|
|
songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
|
|
// Multi-disc album tracks (intentionally out of order to test sorting)
|
|
songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
|
songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
|
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
|
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
|
|
songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("seatbelts/cowboy-bebop/track1.mp3")})
|
|
songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("beatles/2/come together.mp3")})
|
|
songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("roots/things/track1.mp3")})
|
|
testSongs = model.MediaFiles{
|
|
songDayInALife,
|
|
songComeTogether,
|
|
songRadioactivity,
|
|
songAntenna,
|
|
songAntennaWithLyrics,
|
|
songAntenna2,
|
|
songDisc2Track11,
|
|
songDisc1Track01,
|
|
songDisc2Track01,
|
|
songDisc1Track02,
|
|
songCJK,
|
|
songVersioned,
|
|
songPunctuation,
|
|
}
|
|
)
|
|
|
|
var (
|
|
radioWithoutHomePage = model.Radio{ID: "1235", StreamUrl: "https://example.com:8000/1/stream.mp3", HomePageUrl: "", Name: "No Homepage"}
|
|
radioWithHomePage = model.Radio{ID: "5010", StreamUrl: "https://example.com/stream.mp3", Name: "Example Radio", HomePageUrl: "https://example.com"}
|
|
testRadios = model.Radios{radioWithoutHomePage, radioWithHomePage}
|
|
)
|
|
|
|
var (
|
|
plsBest model.Playlist
|
|
plsCool model.Playlist
|
|
testPlaylists []*model.Playlist
|
|
)
|
|
|
|
var (
|
|
adminUser = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true}
|
|
regularUser = model.User{ID: "2222", UserName: "regular-user", Name: "Regular User", Email: "regular@example.com"}
|
|
thirdUser = model.User{ID: "3333", UserName: "third-user", Name: "Third User", Email: "third@example.com"}
|
|
testUsers = model.Users{adminUser, regularUser, thirdUser}
|
|
)
|
|
|
|
var (
|
|
firstScrobble = model.Scrobble{ID: 1, MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Unix()}
|
|
secondScrobble = model.Scrobble{ID: 2, MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC).Unix()}
|
|
thirdScrobble = model.Scrobble{ID: 3, MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC).Unix()}
|
|
scrobbles = model.Scrobbles{firstScrobble, secondScrobble, thirdScrobble}
|
|
)
|
|
|
|
func p(path string) string {
|
|
return filepath.FromSlash(path)
|
|
}
|
|
|
|
// restrictedFixture creates a second library plus a non-admin user granted library 1 only, so
|
|
// specs can assert that a query filters by library. Cleans itself up after the spec.
|
|
func restrictedFixture(name string) (context.Context, model.Library, model.User) {
|
|
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
|
|
db := GetDBXBuilder()
|
|
|
|
lib := model.Library{Name: name + " Library", Path: "/" + name}
|
|
lr := NewLibraryRepository(db)
|
|
Expect(lr.Put(adminCtx, &lib)).To(Succeed())
|
|
|
|
user := createUserWithLibraries(name+"-restricted", []int{1})
|
|
ur := NewUserRepository(db)
|
|
Expect(ur.Put(adminCtx, &user)).To(Succeed())
|
|
Expect(ur.SetUserLibraries(adminCtx, user.ID, []int{1})).To(Succeed())
|
|
|
|
DeferCleanup(func() {
|
|
_ = NewUserRepository(db).Delete(adminCtx, user.ID)
|
|
_ = NewLibraryRepository(db).(*libraryRepository).delete(adminCtx, squirrel.Eq{"id": lib.ID})
|
|
})
|
|
return adminCtx, lib, user
|
|
}
|
|
|
|
var _ = BeforeSuite(func() {
|
|
conn := GetDBXBuilder()
|
|
ctx := log.NewContext(context.TODO())
|
|
ctx = request.WithUser(ctx, adminUser)
|
|
|
|
ur := NewUserRepository(conn)
|
|
for i := range testUsers {
|
|
err := ur.Put(ctx, &testUsers[i])
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// Associate users with library 1 (default test library)
|
|
for i := range testUsers {
|
|
err := ur.SetUserLibraries(ctx, testUsers[i].ID, []int{1})
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
alr := NewAlbumRepository(conn).(*albumRepository)
|
|
for i := range testAlbums {
|
|
err := alr.Put(ctx, new(testAlbums[i]))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
arr := NewArtistRepository(conn)
|
|
for i := range testArtists {
|
|
err := arr.Put(ctx, new(testArtists[i]))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// Associate artists with library 1 (default test library)
|
|
lr := NewLibraryRepository(conn)
|
|
for i := range testArtists {
|
|
err := lr.AddArtist(ctx, 1, testArtists[i].ID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// Populate album_artists based on the AlbumArtistID relationships in testAlbums
|
|
artistIDs := map[string]bool{}
|
|
for _, a := range testArtists {
|
|
artistIDs[a.ID] = true
|
|
}
|
|
for i := range testAlbums {
|
|
a := testAlbums[i]
|
|
if a.AlbumArtistID == "" || !artistIDs[a.AlbumArtistID] {
|
|
continue
|
|
}
|
|
_, err := alr.executeSQL(ctx, squirrel.Insert("album_artists").SetMap(map[string]any{
|
|
"album_id": a.ID,
|
|
"artist_id": a.AlbumArtistID,
|
|
"role": "artist",
|
|
"sub_role": "",
|
|
}))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
mr := NewMediaFileRepository(conn)
|
|
for i := range testSongs {
|
|
err := mr.Put(ctx, &testSongs[i])
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
rar := NewRadioRepository(conn)
|
|
for i := range testRadios {
|
|
err := rar.Put(ctx, new(testRadios[i]))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
plsBest = model.Playlist{
|
|
Name: "Best",
|
|
Comment: "No Comments",
|
|
OwnerID: "userid",
|
|
OwnerName: "userid",
|
|
Public: true,
|
|
SongCount: 2,
|
|
}
|
|
plsBest.AddMediaFilesByID([]string{"1001", "1003"})
|
|
plsCool = model.Playlist{Name: "Cool", OwnerID: "userid", OwnerName: "userid"}
|
|
plsCool.AddMediaFilesByID([]string{"1004"})
|
|
testPlaylists = []*model.Playlist{&plsBest, &plsCool}
|
|
|
|
pr := NewPlaylistRepository(conn)
|
|
for i := range testPlaylists {
|
|
err := pr.Put(ctx, testPlaylists[i])
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// Prepare annotations
|
|
if err := arr.SetStar(ctx, true, artistBeatles.ID); err != nil {
|
|
panic(err)
|
|
}
|
|
ar, err := arr.Get(ctx, artistBeatles.ID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if ar == nil {
|
|
panic("artist not found after SetStar")
|
|
}
|
|
artistBeatles.Starred = true
|
|
artistBeatles.StarredAt = ar.StarredAt
|
|
testArtists[1] = artistBeatles
|
|
|
|
if err := alr.SetStar(ctx, true, albumRadioactivity.ID); err != nil {
|
|
panic(err)
|
|
}
|
|
al, err := alr.Get(ctx, albumRadioactivity.ID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
if al == nil {
|
|
panic("album not found after SetStar")
|
|
}
|
|
albumRadioactivity.Starred = true
|
|
albumRadioactivity.StarredAt = al.StarredAt
|
|
testAlbums[2] = albumRadioactivity
|
|
|
|
if err := mr.SetStar(ctx, true, songComeTogether.ID); err != nil {
|
|
panic(err)
|
|
}
|
|
mf, err := mr.Get(ctx, songComeTogether.ID)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
songComeTogether.Starred = true
|
|
songComeTogether.StarredAt = mf.StarredAt
|
|
testSongs[1] = songComeTogether
|
|
|
|
scrobbleRepo := NewScrobbleRepository(conn).(*scrobbleRepository)
|
|
for _, s := range scrobbles {
|
|
_, err := scrobbleRepo.executeSQL(ctx, squirrel.Insert("scrobbles").SetMap(map[string]any{
|
|
"media_file_id": s.MediaFileID,
|
|
"user_id": s.UserID,
|
|
"submission_time": s.SubmissionTime,
|
|
}))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
})
|
|
|
|
// captureTraceLogs sends trace logs, SQL included, to a buffer for the rest of the spec.
|
|
func captureTraceLogs() *bytes.Buffer {
|
|
buf := &bytes.Buffer{}
|
|
log.SetOutput(buf)
|
|
log.SetLevel(log.LevelTrace)
|
|
DeferCleanup(func() {
|
|
log.SetOutput(os.Stderr)
|
|
log.SetLevel(log.LevelFatal)
|
|
})
|
|
return buf
|
|
}
|
|
|
|
func GetDBXBuilder() *dbx.DB {
|
|
return dbx.NewFromDB(db.Db(), db.Dialect)
|
|
}
|
|
|
|
// collectCursor takes the cursor's underlying func type so the named cursor types
|
|
// (model.AlbumCursor, ...) infer T.
|
|
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {
|
|
GinkgoHelper()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
var out []T
|
|
for item, err := range cursor {
|
|
Expect(err).ToNot(HaveOccurred())
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|