package api import ( "encoding/json" "fmt" "git.misaka.ren/m1saka/dsh-web/internal/docker" "git.misaka.ren/m1saka/dsh-web/internal/store" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) func snapshotRestoreFixture(t *testing.T, running bool, failure string) (*API, store.Instance, string, string) { t.Helper() a, i, home := configTestAPI(t, running, failure) i.ImageID = "sha256:" + strings.Repeat("a", 64) i.Version = "1.0" if _, err := a.s.DB.Exec("UPDATE instances SET image_id=?,version=? WHERE id=?", i.ImageID, i.Version, i.ID); err != nil { t.Fatal(err) } original := a.docker proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/images/") { writeJSON(w, map[string]any{"Id": i.ImageID, "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.recipe": "7", "dsh-mgr.version": i.Version}}}) return } if strings.HasSuffix(r.URL.Path, "/json") { var data map[string]any if err := original.Do(r.Context(), "GET", r.URL.RequestURI(), nil, &data); err != nil { t.Error(err) w.WriteHeader(500) return } data["Image"] = i.ImageID data["Config"] = map[string]any{"Env": []string{"DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config"}} data["Mounts"] = []map[string]string{{"Type": "bind", "Source": home, "Destination": "/workspace"}} data["HostConfig"] = map[string]any{"Mounts": []map[string]string{{"Type": "bind", "Source": home, "Target": "/workspace"}}} data["HostConfig"].(map[string]any)["Tmpfs"] = map[string]string{"/tmp": "rw,noexec,nosuid,size=64m"} writeJSON(w, data) return } resp, err := original.Stream(r.Context(), r.Method, r.URL.RequestURI(), nil, "") if err != nil { t.Error(err) w.WriteHeader(500) return } defer resp.Body.Close() w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) })) t.Cleanup(proxy.Close) a.docker = docker.New(proxy.URL) id := "upgrade-" + strings.Repeat("b", 32) + "-snapshot" if err := os.Mkdir(filepath.Join(filepath.Dir(home), id), 0700); err != nil { t.Fatal(err) } configWrite(t, filepath.Join(filepath.Dir(home), id, "data"), "snapshot") configWrite(t, filepath.Join(home, "data"), "current") return a, i, home, id } func snapshotRestoreBody(id string, unknown bool) string { return fmt.Sprintf(`{"id":%q,"password":"original-password","confirm":true,"allow_unknown_version":%t}`, id, unknown) } func TestSnapshotRuntimeLayout(t *testing.T) { for _, test := range []string{"valid", "env", "tmpfs", "volume", "effective"} { t.Run(test, func(t *testing.T) { env := []string{"DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config"} if test == "env" { env = append(env, "DSH_HOME=/old-shared") } raw, _ := json.Marshal(env) config := map[string]json.RawMessage{"Env": raw} host := map[string]json.RawMessage{"Tmpfs": json.RawMessage(`{"/tmp":"rw"}`)} mounts := []struct{ Type, Source, Destination string }{{"bind", "/home", "/workspace"}} mounts = append(mounts, struct{ Type, Source, Destination string }{"tmpfs", "", "/tmp"}) if test == "tmpfs" { host["Tmpfs"] = json.RawMessage(`{"/workspace/data":"rw"}`) } if test == "volume" { config["Volumes"] = json.RawMessage(`{"/workspace/data":{}}`) } if test == "effective" { mounts = append(mounts, struct{ Type, Source, Destination string }{"volume", "/other", "/workspace/data"}) } if snapshotRuntimeLayout(config, host, mounts, "/home") != (test == "valid") { t.Fatal(test) } }) } } func TestSnapshotListProvenance(t *testing.T) { a, i, _, id := snapshotRestoreFixture(t, false, "") for _, known := range []bool{false, true} { if known { if err := a.recordSnapshot(i, id, "old-version", i.ImageID); err != nil { t.Fatal(err) } } w := configCall(a, i, "GET", "snapshots", "") var list []snapshotInfo if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &list) != nil || len(list) != 1 { t.Fatal(w.Code, w.Body.String()) } if list[0].Restorable == known || list[0].RestoreReason == "" { t.Fatal(list) } if !known && (list[0].SourceVersion != "" || list[0].SourceImage != "") { t.Fatal("inferred provenance", list) } } } func TestSnapshotFullRestore(t *testing.T) { for _, running := range []bool{false, true} { t.Run(fmt.Sprint(running), func(t *testing.T) { a, i, home, id := snapshotRestoreFixture(t, running, "") if err := a.recordSnapshot(i, id, i.Version, i.ImageID); err != nil { t.Fatal(err) } w := configCall(a, i, "POST", "snapshots", snapshotRestoreBody(id, false)) if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } var result struct { SnapshotID string `json:"snapshot_id"` } json.Unmarshal(w.Body.Bytes(), &result) for path, want := range map[string]string{filepath.Join(home, "data"): "snapshot", filepath.Join(filepath.Dir(home), id, "data"): "snapshot", filepath.Join(filepath.Dir(home), result.SnapshotID, "data"): "current"} { got, err := os.ReadFile(path) if err != nil || string(got) != want { t.Fatal(path, string(got), err) } } current, _ := a.s.GetInstance(i.ID) want := "exited" if running { want = "running" } if current.Status != want { t.Fatal(current.Status) } }) } } func TestSnapshotRestoreRollback(t *testing.T) { a, i, home, id := snapshotRestoreFixture(t, false, "reconcile") w := configCall(a, i, "POST", "snapshots", snapshotRestoreBody(id, true)) if w.Code != 400 || !strings.Contains(w.Body.String(), "previous home restored") { t.Fatal(w.Code, w.Body.String()) } for path, want := range map[string]string{filepath.Join(home, "data"): "current", filepath.Join(filepath.Dir(home), id, "data"): "snapshot"} { got, err := os.ReadFile(path) if err != nil || string(got) != want { t.Fatal(path, string(got), err) } } matches, _ := filepath.Glob(filepath.Join(filepath.Dir(home), "failed-restore-*", "data")) if len(matches) != 1 { t.Fatal(matches) } } func TestSnapshotRestoreGuards(t *testing.T) { for _, test := range []string{"unknown", "mismatch", "password", "confirm", "reader", "quota", "error", "unsafe", "foreign", "oversized"} { t.Run(test, func(t *testing.T) { a, i, home, id := snapshotRestoreFixture(t, false, "") body := snapshotRestoreBody(id, true) want := 409 switch test { case "unknown": body = snapshotRestoreBody(id, false) case "mismatch": if err := a.recordSnapshot(i, id, "different", i.ImageID); err != nil { t.Fatal(err) } case "password": body = strings.ReplaceAll(body, "original-password", "wrong") want = 403 case "confirm": body = strings.ReplaceAll(body, `"confirm":true`, `"confirm":false`) want = 400 case "reader": a.workspaceReaders = map[string]int{i.ID: 1} case "quota": a.s.DB.Exec("UPDATE users SET quota=1 WHERE id=?", i.UID) want = 413 case "error": a.s.SetInstance(i.ID, "error") case "unsafe": if err := os.Symlink("/etc/passwd", filepath.Join(filepath.Dir(home), id, "unsafe")); err != nil { t.Fatal(err) } want = 400 case "foreign": i.UID = "someone-else" want = 404 case "oversized": body = strings.Repeat("x", 4097) want = 413 } w := configCall(a, i, "POST", "snapshots", body) if w.Code != want { t.Fatal(w.Code, w.Body.String()) } got, err := os.ReadFile(filepath.Join(home, "data")) if err != nil || string(got) != "current" { t.Fatal(string(got), err) } }) } }