374 lines
11 KiB
Go
374 lines
11 KiB
Go
package backups
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/db"
|
|
"github.com/kandev/kandev/internal/persistence"
|
|
"github.com/kandev/kandev/internal/system/jobs"
|
|
)
|
|
|
|
func newTestLogger(t *testing.T) *logger.Logger {
|
|
t.Helper()
|
|
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stderr"})
|
|
if err != nil {
|
|
t.Fatalf("logger: %v", err)
|
|
}
|
|
return log
|
|
}
|
|
|
|
func newTestPool(t *testing.T) (*db.Pool, string) {
|
|
t.Helper()
|
|
dataDir := t.TempDir()
|
|
dbPath := filepath.Join(dataDir, "kandev.db")
|
|
|
|
writer, err := sqlx.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
if _, err := writer.Exec(`CREATE TABLE things (id TEXT PRIMARY KEY, name TEXT)`); err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
if _, err := writer.Exec(`INSERT INTO things VALUES ('1','hello')`); err != nil {
|
|
t.Fatalf("insert: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = writer.Close() })
|
|
pool := db.NewPool(writer, writer)
|
|
return pool, dataDir
|
|
}
|
|
|
|
func newTestService(t *testing.T) (*Service, string) {
|
|
t.Helper()
|
|
pool, dataDir := newTestPool(t)
|
|
tracker := jobs.NewTracker(nil, newTestLogger(t))
|
|
svc := NewService(dataDir, pool, tracker, newTestLogger(t))
|
|
return svc, dataDir
|
|
}
|
|
|
|
// waitForJob polls the tracker for a terminal state. Reused from
|
|
// jobs_test patterns to avoid sleeping forever in CI.
|
|
func waitForJob(t *testing.T, tracker *jobs.Tracker, id string, want jobs.State) *jobs.Job {
|
|
t.Helper()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
j := tracker.Get(id)
|
|
if j != nil && j.State == want {
|
|
return j
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("job %s did not reach %s in 3s; last = %+v", id, want, tracker.Get(id))
|
|
return nil
|
|
}
|
|
|
|
func TestList_EmptyDirReturnsEmptySlice(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
got, err := svc.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("expected non-nil empty slice")
|
|
}
|
|
if len(got) != 0 {
|
|
t.Errorf("expected 0 snapshots, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestList_ClassifiesAutoAndManual(t *testing.T) {
|
|
svc, dataDir := newTestService(t)
|
|
backupsDir := filepath.Join(dataDir, "backups")
|
|
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
seed := []string{
|
|
"kandev-v0.1.0-20260101T000000Z.db",
|
|
"manual-1700000000.db",
|
|
"random.txt",
|
|
}
|
|
for _, n := range seed {
|
|
if err := os.WriteFile(filepath.Join(backupsDir, n), []byte("x"), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", n, err)
|
|
}
|
|
}
|
|
|
|
got, err := svc.List()
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected 2 snapshots (only .db), got %d: %+v", len(got), got)
|
|
}
|
|
|
|
kinds := map[string]string{}
|
|
for _, s := range got {
|
|
kinds[s.Name] = s.Kind
|
|
}
|
|
if kinds["kandev-v0.1.0-20260101T000000Z.db"] != "auto" {
|
|
t.Errorf("kandev-* should be auto, got %q", kinds["kandev-v0.1.0-20260101T000000Z.db"])
|
|
}
|
|
if kinds["manual-1700000000.db"] != "manual" {
|
|
t.Errorf("manual-* should be manual, got %q", kinds["manual-1700000000.db"])
|
|
}
|
|
}
|
|
|
|
func TestCreate_ProducesManualVacuumIntoFile(t *testing.T) {
|
|
svc, dataDir := newTestService(t)
|
|
id := svc.Create(context.Background())
|
|
if id == "" {
|
|
t.Fatal("expected non-empty job id")
|
|
}
|
|
waitForJob(t, svc.jobs, id, jobs.StateSucceeded)
|
|
|
|
backupsDir := filepath.Join(dataDir, "backups")
|
|
entries, err := os.ReadDir(backupsDir)
|
|
if err != nil {
|
|
t.Fatalf("readdir: %v", err)
|
|
}
|
|
var manualFile string
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), "manual-") && strings.HasSuffix(e.Name(), ".db") {
|
|
manualFile = filepath.Join(backupsDir, e.Name())
|
|
}
|
|
}
|
|
if manualFile == "" {
|
|
t.Fatalf("manual-*.db not produced; dir contents: %+v", entries)
|
|
}
|
|
|
|
// Verify the snapshot is a valid SQLite file and contains seeded row.
|
|
snap, err := sqlx.Open("sqlite3", manualFile)
|
|
if err != nil {
|
|
t.Fatalf("open snapshot: %v", err)
|
|
}
|
|
defer func() { _ = snap.Close() }()
|
|
var name string
|
|
if err := snap.QueryRow(`SELECT name FROM things WHERE id='1'`).Scan(&name); err != nil {
|
|
t.Fatalf("read snapshot: %v", err)
|
|
}
|
|
if name != "hello" {
|
|
t.Errorf("snapshot row = %q, want %q", name, "hello")
|
|
}
|
|
}
|
|
|
|
// Core retention property: persistence.PruneBackups(dir, 0) must remove all
|
|
// auto snapshots but must NOT touch manual snapshots. This is the contract
|
|
// that lets manual snapshots survive the existing pre-migration pruning.
|
|
func TestCreate_ManualSurvivesPruneBackups(t *testing.T) {
|
|
svc, dataDir := newTestService(t)
|
|
backupsDir := filepath.Join(dataDir, "backups")
|
|
|
|
// Drop a synthetic auto-snapshot alongside the manual one.
|
|
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
autoFile := filepath.Join(backupsDir, "kandev-v0.1.0-20260101T000000Z.db")
|
|
if err := os.WriteFile(autoFile, []byte("auto"), 0o644); err != nil {
|
|
t.Fatalf("seed auto: %v", err)
|
|
}
|
|
|
|
id := svc.Create(context.Background())
|
|
waitForJob(t, svc.jobs, id, jobs.StateSucceeded)
|
|
|
|
if err := persistence.PruneBackups(backupsDir, 0); err != nil {
|
|
t.Fatalf("PruneBackups: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(autoFile); !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("auto snapshot should have been pruned, err=%v", err)
|
|
}
|
|
|
|
entries, _ := os.ReadDir(backupsDir)
|
|
var manualSurvived bool
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), "manual-") && strings.HasSuffix(e.Name(), ".db") {
|
|
manualSurvived = true
|
|
}
|
|
}
|
|
if !manualSurvived {
|
|
t.Errorf("manual snapshot was incorrectly pruned; remaining: %+v", entries)
|
|
}
|
|
}
|
|
|
|
func TestRestore_WrongConfirmReturnsError(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
|
|
_, err := svc.Restore(context.Background(), "manual-1.db", "WRONG")
|
|
if err == nil {
|
|
t.Fatal("expected error for wrong confirm token")
|
|
}
|
|
}
|
|
|
|
func TestRestore_SuccessStagesFileAndFlagsRestartRequired(t *testing.T) {
|
|
svc, dataDir := newTestService(t)
|
|
backupsDir := filepath.Join(dataDir, "backups")
|
|
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
// Make a valid SQLite snapshot to restore.
|
|
id := svc.Create(context.Background())
|
|
waitForJob(t, svc.jobs, id, jobs.StateSucceeded)
|
|
|
|
entries, _ := os.ReadDir(backupsDir)
|
|
var snapName string
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), "manual-") {
|
|
snapName = e.Name()
|
|
}
|
|
}
|
|
if snapName == "" {
|
|
t.Fatal("no manual snapshot to restore")
|
|
}
|
|
|
|
jobID, err := svc.Restore(context.Background(), snapName, "RESTORE")
|
|
if err != nil {
|
|
t.Fatalf("Restore: %v", err)
|
|
}
|
|
waitForJob(t, svc.jobs, jobID, jobs.StateSucceeded)
|
|
|
|
// The DB must be in place at the canonical path post-restore.
|
|
if _, err := os.Stat(filepath.Join(dataDir, "kandev.db")); err != nil {
|
|
t.Errorf("expected kandev.db at canonical path: %v", err)
|
|
}
|
|
|
|
// The job result must flag restart_required=true so the frontend dialog
|
|
// knows to prompt the user to quit and relaunch.
|
|
job := svc.jobs.Get(jobID)
|
|
if job == nil {
|
|
t.Fatalf("job not found")
|
|
}
|
|
if got, _ := job.Result["restart_required"].(bool); !got {
|
|
t.Errorf("restart_required missing or false in job result: %+v", job.Result)
|
|
}
|
|
}
|
|
|
|
func TestRestore_WriteFailurePreservesOriginal(t *testing.T) {
|
|
svc, dataDir := newTestService(t)
|
|
backupsDir := filepath.Join(dataDir, "backups")
|
|
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
id := svc.Create(context.Background())
|
|
waitForJob(t, svc.jobs, id, jobs.StateSucceeded)
|
|
|
|
entries, _ := os.ReadDir(backupsDir)
|
|
var snapName string
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), "manual-") {
|
|
snapName = e.Name()
|
|
}
|
|
}
|
|
|
|
// Capture the original kandev.db bytes (it exists because newTestPool
|
|
// opened sqlite at that path).
|
|
originalPath := filepath.Join(dataDir, "kandev.db")
|
|
originalBytes, err := os.ReadFile(originalPath)
|
|
if err != nil {
|
|
t.Fatalf("read original: %v", err)
|
|
}
|
|
|
|
// Inject a failure in the staged-write step.
|
|
svc.failWritesForTest = true
|
|
|
|
jobID, err := svc.Restore(context.Background(), snapName, "RESTORE")
|
|
if err != nil {
|
|
t.Fatalf("Restore submission: %v", err)
|
|
}
|
|
waitForJob(t, svc.jobs, jobID, jobs.StateFailed)
|
|
|
|
got, err := os.ReadFile(originalPath)
|
|
if err != nil {
|
|
t.Fatalf("read after fail: %v", err)
|
|
}
|
|
if string(got) != string(originalBytes) {
|
|
t.Error("original kandev.db was modified despite failed restore")
|
|
}
|
|
}
|
|
|
|
func TestDelete_RemovesManualFile(t *testing.T) {
|
|
svc, dataDir := newTestService(t)
|
|
backupsDir := filepath.Join(dataDir, "backups")
|
|
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
target := filepath.Join(backupsDir, "manual-1.db")
|
|
if err := os.WriteFile(target, []byte("x"), 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
if err := svc.Delete("manual-1.db"); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("expected file removed, err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestDelete_RefusesPreResetSnapshot(t *testing.T) {
|
|
svc, dataDir := newTestService(t)
|
|
backupsDir := filepath.Join(dataDir, "backups")
|
|
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
target := filepath.Join(backupsDir, "kandev-pre-reset-20260101T000000Z.db")
|
|
if err := os.WriteFile(target, []byte("x"), 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
err := svc.Delete("kandev-pre-reset-20260101T000000Z.db")
|
|
if err == nil {
|
|
t.Fatal("expected error refusing pre-reset deletion")
|
|
}
|
|
if _, statErr := os.Stat(target); statErr != nil {
|
|
t.Errorf("pre-reset file unexpectedly removed: %v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestOpenForDownload_RejectsTraversal(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
cases := []string{"../etc/passwd", "..", "foo/bar.db", "/abs/path.db"}
|
|
for _, name := range cases {
|
|
f, _, err := svc.OpenForDownload(name)
|
|
if err == nil {
|
|
_ = f.Close()
|
|
t.Errorf("OpenForDownload(%q) succeeded; expected error", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveSnapshotPath_RejectsNonSnapshotFile(t *testing.T) {
|
|
svc, _ := newTestService(t)
|
|
for _, name := range []string{"config.db", "secrets.db", "kandev.db"} {
|
|
if _, err := svc.resolveSnapshotPath(name); err == nil {
|
|
t.Errorf("resolveSnapshotPath(%q) succeeded, want rejection", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenForDownload_ValidName(t *testing.T) {
|
|
svc, dataDir := newTestService(t)
|
|
backupsDir := filepath.Join(dataDir, "backups")
|
|
if err := os.MkdirAll(backupsDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
target := filepath.Join(backupsDir, "manual-42.db")
|
|
if err := os.WriteFile(target, []byte("hello"), 0o644); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
f, size, err := svc.OpenForDownload("manual-42.db")
|
|
if err != nil {
|
|
t.Fatalf("OpenForDownload: %v", err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
if size != int64(len("hello")) {
|
|
t.Errorf("size = %d, want %d", size, len("hello"))
|
|
}
|
|
}
|