package backup import ( "archive/zip" "context" "errors" "io" "log/slog" "os" "path/filepath" "strings" "testing" "time" "github.com/cdnutter/playlink/internal/store" ) var ctx = context.Background() func setup(t *testing.T) (*store.Store, *Manager, string) { t.Helper() dir := t.TempDir() st, err := store.Open(dir) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.DB.Close() }) return st, New(st, dir, "test", slog.New(slog.NewTextHandler(io.Discard, nil))), dir } func TestCreateListDownloadPath(t *testing.T) { st, m, _ := setup(t) st.SetSetting(ctx, "marker", "before") b, err := m.Create(ctx, TypeManual, "manual") if err != nil { t.Fatal(err) } list, _ := m.List(ctx) if len(list) != 1 || list[0].Name != b.Name || list[0].Type != TypeManual || list[0].Size == 0 { t.Fatalf("list: %+v", list) } p, err := m.Path(ctx, b.Name) if err != nil { t.Fatal(err) } zr, err := zip.OpenReader(p) if err != nil { t.Fatal(err) } names := []string{} for _, f := range zr.File { names = append(names, f.Name) } zr.Close() if strings.Join(names, ",") != "playlink.db,backup.json" { t.Fatalf("zip contents: %v", names) } for _, bad := range []string{"../playlink.db", "evil.zip", "playlink_backup_manual_x.zip", "playlink_backup_manual_2026.01.01_00.00.00.zip/../../x"} { if _, err := m.Path(ctx, bad); !errors.Is(err, ErrBadName) { t.Errorf("%q must be refused, got %v", bad, err) } } } func TestRestoreRoundTrip(t *testing.T) { st, m, dir := setup(t) st.SetSetting(ctx, "marker", "before") b, _ := m.Create(ctx, TypeManual, "manual") st.SetSetting(ctx, "marker", "after") p, _ := m.Path(ctx, b.Name) if err := m.Stage(ctx, p, store.SchemaVersion()); err != nil { t.Fatal(err) } st.DB.Close() if err := ApplyStaged(dir, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { t.Fatal(err) } st2, err := store.Open(dir) if err != nil { t.Fatal(err) } defer st2.DB.Close() if got := st2.Setting(ctx, "marker", ""); got != "before" { t.Fatalf("restore should bring back the old value, got %q", got) } kept, _ := filepath.Glob(filepath.Join(dir, "playlink.db.pre-restore-*")) if len(kept) == 0 { t.Fatal("the replaced database must be kept") } } func TestStageRejectsBadBackups(t *testing.T) { _, m, dir := setup(t) notZip := filepath.Join(dir, "x.zip") os.WriteFile(notZip, []byte("nope"), 0o644) if err := m.Stage(ctx, notZip, store.SchemaVersion()); err == nil { t.Error("a non-zip must be rejected") } mk := func(name string, content []byte) string { p := filepath.Join(dir, name) f, _ := os.Create(p) zw := zip.NewWriter(f) w, _ := zw.Create("playlink.db") w.Write(content) zw.Close() f.Close() return p } if err := m.Stage(ctx, mk("junk.zip", []byte("not a database")), store.SchemaVersion()); err == nil { t.Error("a damaged database must be rejected") } b, _ := m.Create(ctx, TypeManual, "manual") p, _ := m.Path(ctx, b.Name) if err := m.Stage(ctx, p, 1); err == nil || !strings.Contains(err.Error(), "newer version") { t.Errorf("a backup from a newer schema must be rejected, got %v", err) } if _, err := os.Stat(filepath.Join(dir, stagedName)); err == nil { t.Error("nothing may be staged after a rejected backup") } } func TestRetentionScheduleAndBeforeAction(t *testing.T) { _, m, _ := setup(t) cfg := m.Config(ctx) if cfg.IntervalDays != 7 || cfg.RetentionDays != 28 || cfg.BeforeActions { t.Fatalf("defaults: %+v", cfg) } // Off by default: no backup before actions. if err := m.BeforeAction(ctx, "link"); err != nil { t.Fatal(err) } if l, _ := m.List(ctx); len(l) != 0 { t.Fatal("no action backup while the option is off") } cfg.BeforeActions = true if err := m.SetConfig(ctx, cfg); err != nil { t.Fatal(err) } for i := 0; i < 12; i++ { if err := m.BeforeAction(ctx, "link"); err != nil { t.Fatal(err) } } l, _ := m.List(ctx) if len(l) != keepActions { t.Fatalf("only the newest %d action backups are kept, got %d", keepActions, len(l)) } // An old scheduled backup is removed by retention; Due makes a new one. old := filepath.Join(cfg.Folder, "playlink_backup_scheduled_"+time.Now().AddDate(0, 0, -60).Format("2006.01.02_15.04.05")+".zip") os.WriteFile(old, []byte("x"), 0o644) m.Due(ctx) if _, err := os.Stat(old); !errors.Is(err, os.ErrNotExist) { t.Error("scheduled backups past retention must be removed") } sched := 0 l, _ = m.List(ctx) for _, b := range l { if b.Type == TypeScheduled { sched++ } } m.Due(ctx) // not due again yet l2, _ := m.List(ctx) if sched != 1 || len(l2) != len(l) { t.Fatalf("want exactly one scheduled backup, got %d (then %d total vs %d)", sched, len(l2), len(l)) } if err := m.SetConfig(ctx, Config{Folder: "relative/path", IntervalDays: 1, RetentionDays: 1}); err == nil { t.Error("a relative backup folder must be rejected") } }