navidrome/db/backup_test.go
Huang-404-Q 9e950cb63d
fix(cli): fail restore when the backup file does not exist instead of wiping the database (#6085)
* fix(db): fail restore when the backup file does not exist instead of wiping the database

`navidrome backup restore -b <file>` passed the flag value straight to the
SQLite driver, which opens databases with SQLITE_OPEN_CREATE by default. If
the file was not found (for example a file name relative to the working
directory instead of the backup directory), the driver silently created an
empty database and the backup API copied that emptiness over the live
database, reporting 'Restore complete' with an empty instance afterwards.

Two changes:

- db.Restore now opens the backup file read-only, so a missing file is an
  error and nothing gets created or overwritten.
- A relative --backup-file is resolved against Backup.Path, the same folder
  'backup create' writes to; absolute paths keep working as before.

Fixes #6083

* fix(db): stat the backup file instead of opening it read-only

The read-only DSN added in the previous commit works for the reported case but
breaks on other paths: 'file:' + path is parsed as a URI, so a '#' truncates the
path and a '%' sequence is percent-decoded, and a read-only open of a WAL
database leaves '-shm'/'-wal' sidecars next to the backup. Those sidecars then
matched the unanchored prune regex, so 'backup prune -k 3' right after a restore
deleted real backups and kept one.

Stat the file before opening it and keep passing the plain path to the driver.
Paths containing '?' are rejected, since go-sqlite3 splits the DSN there and
would otherwise open (and create) a different file. The prune regex is anchored
so sidecars are never counted as backups.

Also fixes the restore/backup/prune error logs, which printed BasePath (the web
URL prefix) instead of the backup location.

---------

Co-authored-by: Deluan <deluan@navidrome.org>
2026-09-11 20:50:45 -04:00

240 lines
7.6 KiB
Go

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)
}
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)
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())
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)).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"))
})
It("fails to restore from a backup file that does not exist, leaving the database intact", 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-2', 'keepme', 'keepme', 'keepme@example.com', 'x', 1, datetime('now'), datetime('now'))`)
Expect(err).ToNot(HaveOccurred())
By("attempting a restore from a nonexistent file")
missingPath := filepath.Join(tempFolder, "does_not_exist.db")
err = Restore(ctx, missingPath)
Expect(err).To(HaveOccurred())
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"))
_, statErr := os.Stat(missingPath)
Expect(statErr).To(MatchError(os.ErrNotExist))
})
})
})