🏡 index : quine-global/quintodrome.git

package db_test

import (
	"context"
	"database/sql"
	"math/rand"
	"os"
	"path/filepath"
	"time"

	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/conf/configtest"
	. "github.com/navidrome/navidrome/db"
	"github.com/navidrome/navidrome/tests"
	"github.com/navidrome/navidrome/utils/singleton"
	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"
)

func shortTime(year int, month time.Month, day, hour, minute int) time.Time {
	return time.Date(year, month, day, hour, minute, 0, 0, time.UTC)
}

const (
	gooseTableDDL      = "CREATE TABLE goose_db_version(version_id, is_applied)"
	navidromeTablesDDL = "CREATE TABLE user(id); CREATE TABLE media_file(id)"
)

func sqliteFile(statements string) func(path string) {
	return func(path string) {
		GinkgoHelper()
		other, err := sql.Open(Driver, path)
		Expect(err).ToNot(HaveOccurred())
		defer other.Close()
		_, err = other.Exec(statements)
		Expect(err).ToNot(HaveOccurred())
	}
}

var _ = Describe("database backups", func() {
	When("there are a few backup files", func() {
		var ctx context.Context
		var timesShuffled []time.Time

		timesDecreasingChronologically := []time.Time{
			shortTime(2024, 11, 6, 5, 11),
			shortTime(2024, 11, 6, 5, 8),
			shortTime(2024, 11, 6, 4, 32),
			shortTime(2024, 11, 6, 2, 4),
			shortTime(2024, 11, 6, 1, 52),

			shortTime(2024, 11, 5, 23, 0),
			shortTime(2024, 11, 5, 6, 4),
			shortTime(2024, 11, 4, 2, 4),
			shortTime(2024, 11, 3, 8, 5),
			shortTime(2024, 11, 2, 5, 24),
			shortTime(2024, 11, 1, 5, 24),

			shortTime(2024, 10, 31, 5, 9),
			shortTime(2024, 10, 30, 5, 9),
			shortTime(2024, 10, 23, 14, 3),
			shortTime(2024, 10, 22, 3, 6),
			shortTime(2024, 10, 11, 14, 3),

			shortTime(2024, 9, 21, 19, 5),
			shortTime(2024, 9, 3, 8, 5),

			shortTime(2024, 7, 5, 1, 1),

			shortTime(2023, 8, 2, 19, 5),

			shortTime(2021, 8, 2, 19, 5),
			shortTime(2020, 8, 2, 19, 5),
		}

		BeforeEach(func() {
			DeferCleanup(configtest.SetupConfig())

			tempFolder, err := os.MkdirTemp("", "navidrome_backup")
			Expect(err).ToNot(HaveOccurred())
			conf.Server.Backup.Path = conf.NewDir(tempFolder)

			DeferCleanup(func() {
				_ = os.RemoveAll(tempFolder)
			})

			timesShuffled = make([]time.Time, len(timesDecreasingChronologically))
			copy(timesShuffled, timesDecreasingChronologically)
			//nolint:gosec // shuffle order is not a security decision
			rand.Shuffle(len(timesShuffled), func(i, j int) {
				timesShuffled[i], timesShuffled[j] = timesShuffled[j], timesShuffled[i]
			})

			for _, time := range timesShuffled {
				path := BackupPath(time)
				file, err := os.Create(path)
				Expect(err).ToNot(HaveOccurred())
				_ = file.Close()
			}

			ctx = context.Background()
		})

		DescribeTable("prune", func(count, expected int) {
			conf.Server.Backup.Count = count
			pruneCount, err := Prune(ctx)
			Expect(err).ToNot(HaveOccurred())
			for idx, time := range timesDecreasingChronologically {
				_, err := os.Stat(BackupPath(time))
				shouldExist := idx < conf.Server.Backup.Count
				if shouldExist {
					Expect(err).ToNot(HaveOccurred())
				} else {
					Expect(err).To(MatchError(os.ErrNotExist))
				}
			}

			Expect(len(timesDecreasingChronologically) - pruneCount).To(Equal(expected))
		},
			Entry("preserve latest 5 backups", 5, 5),
			Entry("delete all files", 0, 0),
			Entry("preserve all files when at length", len(timesDecreasingChronologically), len(timesDecreasingChronologically)),
			Entry("preserve all files when less than count", 10000, len(timesDecreasingChronologically)))

		It("ignores SQLite sidecar files when counting backups", func() {
			for _, suffix := range []string{"-shm", "-wal"} {
				file, err := os.Create(BackupPath(timesDecreasingChronologically[0]) + suffix)
				Expect(err).ToNot(HaveOccurred())
				_ = file.Close()
			}

			conf.Server.Backup.Count = len(timesDecreasingChronologically)
			pruneCount, err := Prune(ctx)
			Expect(err).ToNot(HaveOccurred())
			Expect(pruneCount).To(BeZero())
		})
	})

	Describe("backup and restore", Ordered, func() {
		var ctx context.Context

		BeforeAll(func() {
			ctx = context.Background()
			DeferCleanup(configtest.SetupConfig())

			conf.Server.DbPath = "file::memory:?cache=shared&_foreign_keys=on"
			DeferCleanup(Init(ctx))
		})

		BeforeEach(func() {
			tempFolder, err := os.MkdirTemp("", "navidrome_backup")
			Expect(err).ToNot(HaveOccurred())
			conf.Server.Backup.Path = conf.NewDir(tempFolder)

			DeferCleanup(func() {
				_ = os.RemoveAll(tempFolder)
			})
		})

		It("successfully backups the database", func() {
			path, err := Backup(ctx)
			Expect(err).ToNot(HaveOccurred())

			backup, err := sql.Open(Driver, path)
			Expect(err).ToNot(HaveOccurred())
			Expect(IsSchemaEmpty(ctx, backup)).To(BeFalse())
		})

		It("successfully restores the database", func() {
			path, err := Backup(ctx)
			Expect(err).ToNot(HaveOccurred())

			err = tests.ClearDB()
			Expect(err).ToNot(HaveOccurred())
			Expect(IsSchemaEmpty(ctx, Db())).To(BeTrue())

			err = Restore(ctx, path, false)
			Expect(err).ToNot(HaveOccurred())
			Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse())
		})
	})

	Describe("backup and restore with a file-based database", Ordered, func() {
		var ctx context.Context
		var tempFolder string
		var dbFilePath string

		BeforeAll(func() {
			ctx = context.Background()
			DeferCleanup(configtest.SetupConfig())

			var err error
			tempFolder, err = os.MkdirTemp("", "navidrome_restore")
			Expect(err).ToNot(HaveOccurred())
			conf.Server.Backup.Path = conf.NewDir(tempFolder)
			DeferCleanup(func() {
				Close(ctx)
				_ = os.RemoveAll(tempFolder)
			})

			// Mimic the production DSN (consts.DefaultDbPath): a file database in WAL mode.
			dbFilePath = filepath.Join(tempFolder, "navidrome.db")
			conf.Server.DbPath = dbFilePath + "?_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
			// The previous container's cleanup closed the shared *sql.DB without
			// dropping the singleton; force a fresh connection for this container.
			singleton.DeleteInstance[*sql.DB]()
			DeferCleanup(Init(ctx))
		})

		It("restores data into a database whose stale WAL sidecar files were left behind", func() {
			By("seeding user data in the current database")
			_, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at)
				VALUES ('u-restore-1', 'drilladmin', 'drilladmin', 'drilladmin@example.com', 'x', 1, datetime('now'), datetime('now'))`)
			Expect(err).ToNot(HaveOccurred())

			By("creating a backup containing the user row")
			path, err := Backup(ctx)
			Expect(err).ToNot(HaveOccurred())

			By("simulating the CLI exiting without closing the pool: sidecar files stay behind")
			_, err = Db().ExecContext(ctx, "CREATE TABLE IF NOT EXISTS _restore_probe(x)")
			Expect(err).ToNot(HaveOccurred())
			singleton.DeleteInstance[*sql.DB]()

			err = tests.ClearDB()
			Expect(err).ToNot(HaveOccurred())

			By("restoring the backup")
			Expect(Restore(ctx, path, false)).To(Succeed())

			By("verifying the restored data is readable through a fresh connection")
			singleton.DeleteInstance[*sql.DB]()
			var userName string
			Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-1'").Scan(&userName)).To(Succeed())
			Expect(userName).To(Equal("drilladmin"))
		})

		expectRestoreRefused := func(path, expectedErr string) {
			GinkgoHelper()
			By("seeding user data in the current database")
			_, err := Db().ExecContext(ctx, `INSERT OR REPLACE INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at)
				VALUES ('u-restore-2', 'keepme', 'keepme', 'keepme@example.com', 'x', 1, datetime('now'), datetime('now'))`)
			Expect(err).ToNot(HaveOccurred())

			By("attempting the restore")
			Expect(Restore(ctx, path, false)).To(MatchError(ContainSubstring(expectedErr)))

			By("verifying the database was not wiped")
			var userName string
			Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed())
			Expect(userName).To(Equal("keepme"))
		}

		It("fails to restore from a backup file that does not exist, leaving the database intact", func() {
			missingPath := filepath.Join(tempFolder, "does_not_exist.db")
			expectRestoreRefused(missingPath, "backup file not available")
			_, statErr := os.Stat(missingPath)
			Expect(statErr).To(MatchError(os.ErrNotExist))
		})

		DescribeTable("fails to restore from a file that is not a Navidrome database, leaving the database intact",
			func(createFile func(path string), expectedErr string) {
				invalidPath := filepath.Join(GinkgoT().TempDir(), "invalid_backup.db")
				createFile(invalidPath)
				expectRestoreRefused(invalidPath, expectedErr)
			},
			Entry("empty file", func(path string) {
				Expect(os.WriteFile(path, nil, 0600)).To(Succeed())
			}, "is not a Navidrome database"),
			Entry("file that is not a SQLite database", func(path string) {
				Expect(os.WriteFile(path, []byte("this is not a database, just some text"), 0600)).To(Succeed())
			}, "reading backup file"),
			Entry("SQLite database without the Navidrome schema",
				sqliteFile("CREATE TABLE something_else(x)"), "is not a Navidrome database"),
			Entry("another project's goose database",
				sqliteFile(gooseTableDDL+"; INSERT INTO goose_db_version VALUES (1, 1)"), "is not a Navidrome database"),
			Entry("goose database with only one of the Navidrome tables",
				sqliteFile(gooseTableDDL+"; INSERT INTO goose_db_version VALUES (1, 1); CREATE TABLE media_file(id)"), "is not a Navidrome database"),
			Entry("SQLite database with an empty goose_db_version table",
				sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL), "has no applied migrations"),
			Entry("SQLite database with only goose's initial version row",
				sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL+"; INSERT INTO goose_db_version VALUES (0, 1)"), "has no applied migrations"),
			Entry("SQLite database whose migrations were all rolled back",
				sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL+"; INSERT INTO goose_db_version VALUES (1, 0)"), "has no applied migrations"),
			Entry("backup with garbage over its last pages", func(path string) {
				// Filler rows go to the end of the file, so the damage below stays clear of
				// the schema and of goose_db_version, which still read fine.
				_, err := Db().ExecContext(ctx, "CREATE TABLE _restore_filler AS SELECT zeroblob(1000000) AS x")
				Expect(err).ToNot(HaveOccurred())
				backupFile, err := Backup(ctx)
				Expect(err).ToNot(HaveOccurred())
				data, err := os.ReadFile(backupFile)
				Expect(err).ToNot(HaveOccurred())

				for i := len(data) * 4 / 5; i < len(data); i++ {
					data[i] = 0xFF
				}
				Expect(os.WriteFile(path, data, 0600)).To(Succeed())
			}, "is damaged"),
		)

		It("restores a good backup with the integrity check", func() {
			path, err := Backup(ctx)
			Expect(err).ToNot(HaveOccurred())
			Expect(Restore(ctx, path, true)).To(Succeed())
		})

		It("refuses an index that doesn't match its table only with the integrity check", func() {
			path, err := Backup(ctx)
			Expect(err).ToNot(HaveOccurred())
			writeMismatchedIndex(ctx, path, 1)
			restored := func() int {
				var count int
				Expect(Db().QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE name = 't'").Scan(&count)).To(Succeed())
				return count
			}

			Expect(Restore(ctx, path, true)).To(MatchError(ContainSubstring("is damaged")))
			Expect(restored()).To(BeZero())

			Expect(Restore(ctx, path, false)).To(Succeed())
			Expect(restored()).To(Equal(1))
		})
	})
})