package api import ( "context" "encoding/json" "fmt" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "time" ) func TestSnapshotManagement(t *testing.T) { a, i, home := configTestAPI(t, false) a.docker = nil // These operations must never inspect or stop Docker. parent := filepath.Dir(home) suffix := strings.Repeat("a", 32) names := []string{"pre-restore-" + suffix, "failed-restore-" + suffix, "upgrade-" + suffix + "-snapshot", "failed-home-" + suffix} for _, name := range append(append([]string{}, names...), "pre-restore-no", "upgrade-"+suffix, "other") { if err := os.Mkdir(filepath.Join(parent, name), 0700); err != nil { t.Fatal(err) } configWrite(t, filepath.Join(parent, name, "data"), "abc") } outside := t.TempDir() configWrite(t, filepath.Join(outside, "keep"), "outside") if err := os.Symlink(outside, filepath.Join(parent, names[0], "link")); err != nil { t.Fatal(err) } if err := os.Link(filepath.Join(parent, names[0], "data"), filepath.Join(parent, names[0], "hardlink")); err != nil { t.Fatal(err) } w := configCall(a, i, "GET", "snapshots", "") var got []snapshotInfo if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got) != 4 { t.Fatal(w.Code, w.Body.String()) } for _, s := range got { want := int64(3) if s.ID == names[0] { want = 6 } if s.Size != want || s.Protected || s.ProtectionReason != "" || s.SizeError != "" || s.CreatedAt.IsZero() { t.Fatalf("%+v", s) } } for _, name := range names { w = configCall(a, i, "DELETE", "snapshots?id="+name, "") if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } if _, err := os.Stat(filepath.Join(parent, name)); !os.IsNotExist(err) { t.Fatal(err) } } for _, p := range []string{home, filepath.Join(parent, "other"), filepath.Join(outside, "keep")} { if _, err := os.Stat(p); err != nil { t.Fatal(err) } } var count int if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='snapshot.delete.completed' AND uid=? AND target=?", i.UID, i.ID).Scan(&count); err != nil || count != 4 { t.Fatal(count, err) } if w = configCall(a, i, "DELETE", "snapshots?id="+names[0], ""); w.Code != 404 { t.Fatal(w.Code) } } func TestManagementInvalidPaths(t *testing.T) { a, i, home := configTestAPI(t, false) a.docker = nil for _, action := range []string{"snapshots", "backups"} { for _, id := range []string{"", "home", "..", "../home", "/home", strings.Repeat("A", 32), "pre-restore-" + strings.Repeat("a", 32) + "/../home"} { if w := configCall(a, i, "DELETE", action+"?id="+url.QueryEscape(id), ""); w.Code != 400 { t.Fatal(action, id, w.Code) } } } id := "failed-home-" + strings.Repeat("b", 32) outside := t.TempDir() configWrite(t, filepath.Join(outside, "keep"), "safe") if err := os.Symlink(outside, filepath.Join(filepath.Dir(home), id)); err != nil { t.Fatal(err) } if w := configCall(a, i, "DELETE", "snapshots?id="+id, ""); w.Code != 400 { t.Fatal(w.Code, w.Body.String()) } if w := configCall(a, i, "GET", "snapshots", ""); w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" { t.Fatal(w.Code, w.Body.String()) } if err := os.Rename(filepath.Dir(home), filepath.Dir(home)+"-saved"); err != nil { t.Fatal(err) } if err := os.Symlink(outside, filepath.Dir(home)); err != nil { t.Fatal(err) } for _, method := range []string{"GET", "DELETE"} { if w := configCall(a, i, method, "snapshots?id="+id, ""); w.Code != 400 { t.Fatal(w.Code, w.Body.String()) } } if _, err := os.Stat(filepath.Join(outside, "keep")); err != nil { t.Fatal(err) } } func TestManagementProtection(t *testing.T) { for _, state := range []string{"error", "pending", "journal-error", "complete", "rolled_back"} { t.Run(state, func(t *testing.T) { a, i, home := configTestAPI(t, false) id := "pre-restore-" + strings.Repeat("c", 32) if err := os.Mkdir(filepath.Join(filepath.Dir(home), id), 0700); err != nil { t.Fatal(err) } var err error if state == "error" { _, err = a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID) } else if state == "journal-error" { _, err = a.s.DB.Exec("CREATE TABLE upgrade_journal(broken TEXT)") } else { _, err = a.s.DB.Exec("CREATE TABLE upgrade_journal(instance_id TEXT,phase TEXT)") if err == nil { _, err = a.s.DB.Exec("INSERT INTO upgrade_journal VALUES(?,?)", i.ID, state) } } if err != nil { t.Fatal(err) } protected := state != "complete" && state != "rolled_back" w := configCall(a, i, "GET", "snapshots", "") var got []snapshotInfo if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got) != 1 || got[0].Protected != protected || (got[0].ProtectionReason != "") != protected { t.Fatal(w.Code, w.Body.String()) } w = configCall(a, i, "DELETE", "snapshots?id="+id, "") want := 200 if protected { want = 409 } if w.Code != want { t.Fatal(w.Code, w.Body.String()) } if protected { w = configCall(a, i, "DELETE", "backups?id="+strings.Repeat("a", 32), "") if w.Code != 409 { t.Fatal(w.Code, w.Body.String()) } } }) } } func TestSnapshotSizeError(t *testing.T) { a, i, home := configTestAPI(t, false) id := "failed-restore-" + strings.Repeat("d", 32) deep := filepath.Join(filepath.Dir(home), id) for n := 0; n < 130; n++ { deep = filepath.Join(deep, "d") } if err := os.MkdirAll(deep, 0700); err != nil { t.Fatal(err) } w := configCall(a, i, "GET", "snapshots", "") var got []snapshotInfo if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got) != 1 || got[0].Size != -1 || got[0].SizeError == "" { t.Fatal(w.Code, w.Body.String()) } } func TestBackupDelete(t *testing.T) { for _, mode := range []string{"indexed", "legacy", "stale", "missing-parent", "index-error", "symlink"} { t.Run(mode, func(t *testing.T) { a, i, _ := configTestAPI(t, false) a.docker = nil id := strings.Repeat("e", 32) dir := filepath.Join(a.root, "backups", i.UID, i.ID) if err := os.MkdirAll(dir, 0700); err != nil { t.Fatal(err) } file := filepath.Join(dir, id+".dshbak") if mode != "stale" && mode != "symlink" { configWrite(t, file, "encrypted") } if mode == "symlink" { if err := os.Symlink(t.TempDir(), file); err != nil { t.Fatal(err) } } if mode == "indexed" || mode == "stale" || mode == "missing-parent" { if err := a.indexBackup(i, id, "manual", 2, 9); err != nil { t.Fatal(err) } } if mode == "index-error" { if _, err := a.s.DB.Exec("CREATE TABLE backups(broken TEXT)"); err != nil { t.Fatal(err) } } if mode == "missing-parent" { if err := os.RemoveAll(filepath.Join(a.root, "backups")); err != nil { t.Fatal(err) } } w := configCall(a, i, "DELETE", "backups?id="+id, "") want := 200 if mode == "index-error" { want = 500 } if mode == "symlink" { want = 400 } if w.Code != want { t.Fatal(w.Code, w.Body.String()) } if want != 200 { if _, err := os.Lstat(file); err != nil { t.Fatal(err) } return } if _, err := os.Stat(file); !os.IsNotExist(err) { t.Fatal(err) } if mode != "legacy" { var count int if err := a.s.DB.QueryRow("SELECT count(*) FROM backups WHERE id=?", id).Scan(&count); err != nil || count != 0 { t.Fatal(count, err) } } var count int if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='backup.delete' AND target=? AND detail=?", i.ID, id).Scan(&count); err != nil || count != 1 { t.Fatal(count, err) } }) } } func TestManagementOwnershipRecheck(t *testing.T) { a, i, _ := configTestAPI(t, false) if _, err := a.s.DB.Exec("UPDATE instances SET uid='someone-else' WHERE id=?", i.ID); err != nil { t.Fatal(err) } for _, action := range []string{"snapshots", "snapshots?id=pre-restore-" + strings.Repeat("a", 32), "backups?id=" + strings.Repeat("a", 32)} { method := "DELETE" if action == "snapshots" { method = "GET" } if w := configCall(a, i, method, action, ""); w.Code != 404 { t.Fatal(w.Code, w.Body.String()) } } } func TestManagementAuditFailure(t *testing.T) { a, i, home := configTestAPI(t, false) id := strings.Repeat("f", 32) snapshot := filepath.Join(filepath.Dir(home), "pre-restore-"+id) if err := os.Mkdir(snapshot, 0700); err != nil { t.Fatal(err) } dir := filepath.Join(a.root, "backups", i.UID, i.ID) if err := os.MkdirAll(dir, 0700); err != nil { t.Fatal(err) } file := filepath.Join(dir, id+".dshbak") configWrite(t, file, "encrypted") if err := a.indexBackup(i, id, "manual", 2, 9); err != nil { t.Fatal(err) } if _, err := a.s.DB.Exec("DROP TABLE audit"); err != nil { t.Fatal(err) } for _, action := range []string{"snapshots?id=pre-restore-" + id, "backups?id=" + id} { if w := configCall(a, i, "DELETE", action, ""); w.Code != 500 { t.Fatal(w.Code, w.Body.String()) } } for _, p := range []string{snapshot, file} { if _, err := os.Stat(p); err != nil { t.Fatal(err) } } var count int if err := a.s.DB.QueryRow("SELECT count(*) FROM backups WHERE id=?", id).Scan(&count); err != nil || count != 1 { t.Fatal(count, err) } } func TestSnapshotScanCancelled(t *testing.T) { root, err := os.OpenRoot(t.TempDir()) if err != nil { t.Fatal(err) } defer root.Close() ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := snapshotBytes(ctx, root, "."); err != context.Canceled { t.Fatal(err) } } func TestSnapshotDeletionReleasesSQLConnection(t *testing.T) { a, i, _ := configTestAPI(t, false) a.s.DB.SetMaxOpenConns(1) entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) w := httptest.NewRecorder() go func() { defer close(done) a.deleteSnapshot(w, i, "snapshot", func() error { close(entered) <-release return nil }) }() defer func() { close(release); <-done }() select { case <-entered: case <-time.After(5 * time.Second): t.Fatal("removal did not start") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() var count int if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM audit WHERE action='snapshot.delete.started'").Scan(&count); err != nil || count != 1 { t.Fatalf("SQL blocked during removal or initial audit not persisted: count=%d err=%v", count, err) } } func TestSnapshotDeletionAuditOutcomes(t *testing.T) { for _, mode := range []string{"success", "remove-failed", "start-failed", "completion-audit-failed", "failure-audit-failed"} { t.Run(mode, func(t *testing.T) { a, i, _ := configTestAPI(t, false) if mode == "start-failed" { if _, err := a.s.DB.Exec("DROP TABLE audit"); err != nil { t.Fatal(err) } } w := httptest.NewRecorder() called := false a.deleteSnapshot(w, i, "snapshot", func() error { called = true if strings.Contains(mode, "audit-failed") { if _, err := a.s.DB.Exec(`CREATE TRIGGER reject_terminal BEFORE INSERT ON audit WHEN NEW.action != 'snapshot.delete.started' BEGIN SELECT RAISE(FAIL, 'audit unavailable'); END`); err != nil { t.Fatal(err) } } if mode == "remove-failed" || mode == "failure-audit-failed" { return os.ErrPermission } return nil }) want := 500 if mode == "success" { want = 200 } if w.Code != want || called != (mode != "start-failed") { t.Fatal(w.Code, w.Body.String(), called) } if mode == "start-failed" { return } for _, state := range []string{"started", "completed", "failed"} { wantCount := 0 if state == "started" || (state == "completed" && mode == "success") || (state == "failed" && mode == "remove-failed") { wantCount = 1 } var count int if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action=? AND uid=? AND target=? AND detail=?", "snapshot.delete."+state, i.UID, i.ID, "snapshot").Scan(&count); err != nil || count != wantCount { t.Fatal(state, count, err) } } }) } } func TestManagementPathErrorClassification(t *testing.T) { for _, tc := range []struct { name string err error want int }{ {"unsafe", errUnsafeManagementPath, 400}, {"wrapped-unsafe", fmt.Errorf("path: %w", errUnsafeManagementPath), 400}, {"permission", &os.PathError{Op: "lstat", Path: "snapshot", Err: os.ErrPermission}, 500}, {"closed", os.ErrClosed, 500}, } { t.Run(tc.name, func(t *testing.T) { w := httptest.NewRecorder() managementPathError(w, tc.err) if w.Code != tc.want { t.Fatal(w.Code, w.Body.String()) } }) } a, i, _ := configTestAPI(t, false) base := t.TempDir() configWrite(t, filepath.Join(base, "file"), "not a directory") a.root = filepath.Join(base, "file", "child") if w := configCall(a, i, "GET", "snapshots", ""); w.Code != 500 { t.Fatal(w.Code, w.Body.String()) } a.root = filepath.Join(base, "missing") if w := configCall(a, i, "GET", "snapshots", ""); w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" { t.Fatal(w.Code, w.Body.String()) } if w := configCall(a, i, "DELETE", "snapshots?id=pre-restore-"+strings.Repeat("a", 32), ""); w.Code != 404 { t.Fatal(w.Code, w.Body.String()) } }