package api import ( "context" "encoding/json" "fmt" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "syscall" "testing" "time" "git.misaka.ren/m1saka/dsh-web/internal/store" "golang.org/x/crypto/bcrypt" ) func TestUpgradeWorkspaceCompatibility(t *testing.T) { for _, tc := range []struct { name, recipe, target string shared, reject bool }{ {"legacy-to-seven", "7", "/data/dsh-home", true, true}, {"single-to-seven", "7", "/workspace", false, false}, {"migrated-to-seven", "7", "/workspace", true, true}, {"legacy-to-six", "6", "/data/dsh-home", true, true}, {"legacy-to-unknown", "", "/data/dsh-home", true, true}, {"single-to-six", "6", "/workspace", false, true}, {"single-to-unknown", "", "/workspace", false, true}, } { t.Run(tc.name, func(t *testing.T) { mounts := []map[string]any{{"Type": "bind", "Source": "/instance/home", "Target": tc.target, "ReadOnly": false}} if tc.shared { target := "/workspace" if tc.target == "/workspace" { target = "/shared" } mounts = append(mounts, map[string]any{"Type": "bind", "Source": "/user/files", "Target": target, "ReadOnly": true}) } raw, _ := json.Marshal(mounts) host := map[string]json.RawMessage{"Mounts": raw} config := map[string]json.RawMessage{"Env": json.RawMessage(`["CUSTOM=yes","DSH_HOME=/data/dsh-home","HOME=/data/dsh-home","XDG_DATA_HOME=/old/data","XDG_CONFIG_HOME=/old/config"]`)} before := string(config["Env"]) err := upgradeWorkspace(config, host, tc.recipe, "/instance/home") if (err != nil) != tc.reject { t.Fatalf("error=%v", err) } if tc.reject { if string(host["Mounts"]) != string(raw) || string(config["Env"]) != before { t.Fatal("legacy or rejected config changed") } return } if !strings.Contains(string(config["Env"]), "CUSTOM=yes") || strings.Contains(string(config["Env"]), "/data/dsh-home") { t.Fatal(string(config["Env"])) } var got []map[string]any if err := json.Unmarshal(host["Mounts"], &got); err != nil { t.Fatal(err) } if got[0]["Source"] != "/instance/home" || got[0]["Target"] != "/workspace" || len(got) != 1 { t.Fatal(got) } first, _ := json.Marshal([]any{config, host}) if err := upgradeWorkspace(config, host, tc.recipe, "/instance/home"); err != nil { t.Fatal(err) } second, _ := json.Marshal([]any{config, host}) if string(first) != string(second) { t.Fatal("conversion is not idempotent") } }) } } func TestUpgradeWorkspaceRejectsAmbiguousMounts(t *testing.T) { for _, extra := range []string{ `{"Type":"bind","Source":"/other","Target":"/shared"}`, `{"Type":"bind","Source":"/other","Target":"/workspace"}`, `{"Type":"volume","Source":"/user/files","Target":"/workspace"}`, `{"Type":"bind","Source":"/instance/home","Target":"/workspace"}`, } { config := map[string]json.RawMessage{"Env": json.RawMessage(`["CUSTOM=yes"]`)} host := map[string]json.RawMessage{"Mounts": json.RawMessage(`[{"Type":"bind","Source":"/instance/home","Target":"/workspace"},` + extra + `]`)} before, _ := json.Marshal([]any{config, host}) if err := upgradeWorkspace(config, host, "7", "/instance/home"); err == nil { t.Fatal("accepted ambiguous mounts", extra) } after, _ := json.Marshal([]any{config, host}) if string(before) != string(after) { t.Fatal("rejected config mutated") } } } func TestUpgradeLifecycle(t *testing.T) { if os.Geteuid() != 0 { t.Skip("requires chown") } for _, mode := range []string{"snapshot-fifo", "snapshot-dangling-link", "success", "legacy-layout", "shared-layout", "start-failure", "readiness-failure", "cleanup-failure", "running", "running-failure", "restart-failure", "delete-failure", "invalid-image", "unverified-image", "failed-build", "version-mismatch", "image-id-mismatch", "db-failure"} { t.Run(mode, func(t *testing.T) { image := "sha256:" + strings.Repeat("b", 64) var a *API var home, shared string var calls []string var body map[string]json.RawMessage running := mode == "running" || mode == "running-failure" || mode == "restart-failure" stopped := false upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Host != "panel.test" { t.Error("wrong trusted host") } if r.URL.Query().Get("token") == "must-not-leak" { http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "ready"}) w.WriteHeader(303) return } if r.Header.Get("Cookie") != "dsh_auth=ready" { t.Error("missing authenticated readiness") } w.WriteHeader(200) })) defer upstream.Close() transport := runtimeTransport runtimeTransport = transport.Clone() runtimeTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, network, strings.TrimPrefix(upstream.URL, "http://")) } defer func() { runtimeTransport.CloseIdleConnections(); runtimeTransport = transport }() handler := func(w http.ResponseWriter, r *http.Request) { p := strings.TrimPrefix(r.URL.Path, "/v1.47") calls = append(calls, r.Method+" "+p) switch { case p == "/containers/new/exec": writeJSON(w, map[string]string{"Id": "reconcile"}) case p == "/exec/reconcile/json": writeJSON(w, map[string]any{"Running": false, "ExitCode": 0}) case p == "/containers/old/json": state := "exited" if running { state = "running" } mounts := []map[string]string{{"Type": "bind", "Source": home, "Target": "/workspace"}} if mode == "legacy-layout" { mounts[0]["Target"] = "/data/dsh-home" mounts = append(mounts, map[string]string{"Type": "bind", "Source": shared, "Target": "/workspace"}) } if mode == "shared-layout" { mounts = append(mounts, map[string]string{"Type": "bind", "Source": shared, "Target": "/shared"}) } writeJSON(w, map[string]any{"Config": map[string]any{"Hostname": "old", "Cmd": []string{"obsolete-flag"}, "Image": "old-image", "Env": []string{"CUSTOM=yes"}, "User": "1000:1000"}, "HostConfig": map[string]any{"NetworkMode": "dsh-inst-one", "Memory": 9007199254740993, "Mounts": mounts}, "State": map[string]any{"Status": state, "Running": running && !stopped}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]any{"IPAddress": "172.18.0.3", "Aliases": []string{"old"}}}}}) case strings.HasPrefix(p, "/images/"): role := "runtime" if mode == "invalid-image" { role = "builder" } writeJSON(w, map[string]any{"Id": image, "Os": "linux", "Architecture": "amd64", "Config": map[string]any{"Labels": map[string]string{"dsh-mgr.role": role, "dsh-mgr.version": "v2", "dsh-mgr.recipe": imageRecipeRev}}}) case p == "/containers/create": if !strings.HasPrefix(r.URL.Query().Get("name"), "dsh-inst-one-upgrade-") { t.Error("missing unique name") } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Error(err) } before, _ := a.s.GetInstance("one") if before.ContainerID != "old" || before.Status != "error" { t.Errorf("premature switch: %+v", before) } writeJSON(w, map[string]string{"Id": "new"}) case p == "/containers/old/stop": stopped = true w.WriteHeader(204) case p == "/containers/old/start": restored, _ := os.ReadFile(filepath.Join(home, ".config", "state")) if string(restored) != "original" { t.Error("old runtime restarted before home restore") } if mode == "restart-failure" { http.Error(w, "failed", 500) return } w.WriteHeader(204) case p == "/containers/old" && r.Method == "DELETE": current, _ := a.s.GetInstance("one") if current.ContainerID != "new" { t.Error("old removed before commit") } if mode == "delete-failure" { http.Error(w, "failed", 500) return } w.WriteHeader(204) case p == "/containers/new/start": if err := os.WriteFile(filepath.Join(home, ".config", "state"), []byte("migrated"), 0600); err != nil { t.Error(err) } if err := os.WriteFile(filepath.Join(shared, "file"), []byte("shared-new"), 0600); err != nil { t.Error(err) } if mode == "start-failure" || mode == "cleanup-failure" || mode == "running-failure" || mode == "restart-failure" { http.Error(w, "token=must-not-leak", 500) return } w.WriteHeader(204) case p == "/containers/new/json": writeJSON(w, map[string]any{"State": map[string]any{"Running": mode != "readiness-failure"}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]string{"IPAddress": "127.0.0.1"}}}}) case p == "/containers/new/logs": if mode == "db-failure" { _, err := a.s.DB.Exec("CREATE TRIGGER reject_switch BEFORE UPDATE OF container_id ON instances BEGIN SELECT RAISE(FAIL, 'blocked'); END") if err != nil { t.Error(err) } } fmt.Fprint(w, "http://runtime/?token=must-not-leak\n") case p == "/containers/new" && r.Method == "DELETE": if mode == "cleanup-failure" { http.Error(w, "token=must-not-leak", 500) return } w.WriteHeader(204) default: w.WriteHeader(204) } } var sid string a, sid = runtimeTestAPI(t, handler) hash, _ := bcrypt.GenerateFromPassword([]byte("upgrade-password"), bcrypt.MinCost) a.s.DB.Exec("UPDATE users SET pass_hash=?,quota=10737418240 WHERE id='owner'", string(hash)) if _, err := a.verifiedImages(context.Background()); err != nil { t.Fatal(err) } if mode != "unverified-image" { status, version, builtImage := "success", "v2", image switch mode { case "failed-build": status = "failed" case "version-mismatch": version = "v3" case "image-id-mismatch": builtImage = "sha256:" + strings.Repeat("c", 64) } if _, err := a.s.DB.Exec("INSERT INTO image_builds(id,version,status,image_id,created_at,recipe_rev,platform) VALUES(?,?,?,?,?,?,?)", store.ID(), version, status, builtImage, time.Now().UTC().Format(time.RFC3339Nano), imageRecipeRev, imagePlatform); err != nil { t.Fatal(err) } } if _, err := a.s.DB.Exec("INSERT INTO image_admissions(image_id,version,recipe_rev,platform,state,evidence,admitted_by,admitted_at) VALUES(?,'v2',?,?,'compatible','test acceptance','test','now')", image, imageRecipeRev, imagePlatform); err != nil { t.Fatal(err) } base := filepath.Join(a.root, "workspaces", "owner", "instances", "one") home = filepath.Join(base, "home") shared = filepath.Join(a.root, "workspaces", "owner", "files") for _, p := range []string{filepath.Join(home, ".config"), shared} { if err := os.MkdirAll(p, 0750); err != nil { t.Fatal(err) } } if err := os.WriteFile(filepath.Join(home, ".config", "state"), []byte("original"), 0600); err != nil { t.Fatal(err) } if mode == "snapshot-dangling-link" { if err := os.Symlink("missing", filepath.Join(home, "dangling")); err != nil { t.Fatal(err) } } if mode == "snapshot-fifo" { if err := syscall.Mkfifo(filepath.Join(home, "pipe"), 0600); err != nil { t.Fatal(err) } } i := store.Instance{ID: "one", UID: "owner", Name: "one", ContainerID: "old", ImageID: "old-image", Version: "v1", Status: "exited", Created: time.Now()} if err := a.s.AddInstance(i); err != nil { t.Fatal(err) } w := httptest.NewRecorder() stale := i stale.ContainerID = "stale" a.upgrade(w, runtimeRequest("POST", "/api/instances/one/upgrade", fmt.Sprintf(`{"image_id":%q,"password":"upgrade-password"}`, image), sid), stale) if strings.Contains(w.Body.String(), "must-not-leak") { t.Fatal("leaked token") } got, err := a.s.GetInstance(i.ID) if err != nil { t.Fatal(err) } if mode == "legacy-layout" || mode == "shared-layout" || mode == "invalid-image" || mode == "unverified-image" || mode == "failed-build" || mode == "version-mismatch" || mode == "image-id-mismatch" { want := 400 if mode == "legacy-layout" || mode == "shared-layout" { want = 409 } if w.Code != want { t.Fatalf("%d %s", w.Code, w.Body.String()) } if got.ContainerID != i.ContainerID || got.ImageID != i.ImageID || got.Version != i.Version || got.Status != i.Status { t.Fatalf("rejected upgrade changed instance: %+v", got) } for _, call := range calls { if !strings.HasPrefix(call, "GET ") { t.Errorf("rejected upgrade mutated runtime: %s", call) } } return } var phase, snapshot, failed string if err := a.s.DB.QueryRow("SELECT phase,snapshot,failed_home FROM upgrade_journal WHERE instance_id='one'").Scan(&phase, &snapshot, &failed); err != nil { t.Fatal(err) } if mode == "snapshot-fifo" { if w.Code != 500 || !strings.Contains(w.Body.String(), "home/pipe") || phase != "rolled_back" || got.ContainerID != "old" { t.Fatalf("%d %s phase=%s", w.Code, w.Body.String(), phase) } entries, err := os.ReadDir(base) if err != nil || len(entries) != 1 || entries[0].Name() != "home" { t.Fatalf("partial snapshot leaked: %v %v", entries, err) } listed := configCall(a, got, "GET", "snapshots", "") if listed.Code != 200 || strings.TrimSpace(listed.Body.String()) != "[]" { t.Fatal(listed.Code, listed.Body.String()) } return } if mode == "snapshot-dangling-link" { if w.Code != 200 || phase != "complete" || got.ContainerID != "new" { t.Fatalf("unexpected result: %d %s phase=%s instance=%+v", w.Code, w.Body.String(), phase, got) } saved, err := os.ReadFile(filepath.Join(base, snapshot, ".config", "state")) if err != nil || string(saved) != "original" { t.Fatalf("copied data missing: %q %v", saved, err) } link, err := os.Readlink(filepath.Join(base, snapshot, "dangling")) if err != nil || link != "missing" { t.Fatalf("copied link missing: %q %v", link, err) } listed := configCall(a, got, "GET", "snapshots", "") if listed.Code != 200 || !strings.Contains(listed.Body.String(), snapshot) { t.Fatalf("snapshot not listed: %d %s", listed.Code, listed.Body.String()) } return } data, err := os.ReadFile(filepath.Join(home, ".config", "state")) if err != nil { t.Fatal(err) } if mode == "success" || mode == "running" { if w.Code != 200 || got.ContainerID != "new" || got.Version != "v2" || phase != "complete" || string(data) != "migrated" { t.Fatalf("%d %s %+v phase=%s home=%s", w.Code, w.Body.String(), got, phase, data) } saved, err := os.ReadFile(filepath.Join(base, snapshot, ".config", "state")) if err != nil || string(saved) != "original" { t.Fatalf("snapshot %q %v", saved, err) } st, _ := os.Stat(filepath.Join(base, snapshot, ".config", "state")) if st.Sys().(*syscall.Stat_t).Uid != 1000 { t.Error("snapshot ownership") } } else if mode == "delete-failure" { if w.Code != 502 || got.ContainerID != "new" || got.Status != "error" || phase != "committed" || string(data) != "migrated" { t.Fatalf("unsafe delete failure %+v %s", got, phase) } } else if mode == "restart-failure" { if w.Code < 400 || got.ContainerID != "old" || got.Status != "error" || phase != "restoring_runtime" || string(data) != "original" { t.Fatalf("unsafe restart failure %+v %s", got, phase) } } else if mode == "cleanup-failure" { if got.Status != "error" || phase != "rolling_back" || string(data) != "migrated" || !a.upgradeRecoveryRequired(got) { t.Fatalf("unsafe cleanup %+v %s %s", got, phase, data) } } else { if w.Code < 400 || got.ContainerID != "old" || got.ImageID != "old-image" || got.Status != map[bool]string{true: "running", false: "exited"}[running] || phase != "rolled_back" || string(data) != "original" { t.Fatalf("%d %+v phase=%s home=%s", w.Code, got, phase, data) } failedData, err := os.ReadFile(filepath.Join(base, failed, ".config", "state")) if err != nil || string(failedData) != "migrated" { t.Fatalf("failed home %s %v", failedData, err) } } sharedData, _ := os.ReadFile(filepath.Join(shared, "file")) if string(sharedData) != "shared-new" { t.Error("shared workspace rolled back") } if _, ok := body["Hostname"]; ok { t.Error("hostname copied") } if string(body["Env"]) != `["CUSTOM=yes","DSH_HOME=/workspace","HOME=/workspace","XDG_DATA_HOME=/workspace/.local/share","XDG_CONFIG_HOME=/workspace/.config"]` || !strings.Contains(string(body["HostConfig"]), "9007199254740993") { t.Error("raw config not preserved") } if strings.Contains(string(body["HostConfig"]), `"Target":"/shared"`) || strings.Contains(string(body["HostConfig"]), "/data/dsh-home") { t.Error("workspace mounts not migrated") } if strings.Contains(string(body["NetworkingConfig"]), "172.18") || strings.Contains(string(body["NetworkingConfig"]), "Aliases") { t.Error("dynamic endpoint copied") } if string(body["Cmd"]) != `["web","--no-open","--port","3080","--trusted-host","panel.test"]` { t.Errorf("command not recalculated: %s", body["Cmd"]) } has := func(want string) bool { for _, call := range calls { if call == want { return true } } return false } if has("DELETE /containers/old") != (mode == "success" || mode == "running" || mode == "delete-failure") { t.Error("old deletion lifecycle") } if has("POST /containers/old/start") != (mode == "running-failure" || mode == "restart-failure") { t.Error("old restart lifecycle") } if has("POST /containers/old/stop") != running { t.Error("old stop lifecycle") } }) } } func TestUpgradeSnapshotRejectsUnsafeFiles(t *testing.T) { for _, kind := range []string{"fifo", "oversize"} { t.Run(kind, func(t *testing.T) { base := t.TempDir() if err := os.Mkdir(filepath.Join(base, "home"), 0700); err != nil { t.Fatal(err) } p := filepath.Join(base, "home", "bad") var err error switch kind { case "fifo": err = syscall.Mkfifo(p, 0600) case "oversize": err = os.WriteFile(p, []byte("12345"), 0600) } if err != nil { t.Fatal(err) } root, err := os.OpenRoot(base) if err != nil { t.Fatal(err) } defer root.Close() if upgradeCopy(context.Background(), root, "home", "snapshot", 4) == nil { t.Fatal("unsafe file accepted") } }) } } func TestUpgradeSnapshotRelativeLinks(t *testing.T) { if os.Geteuid() != 0 { t.Skip("requires chown") } for _, mode := range []string{"pnpm", "absolute", "escape", "chain-escape", "loop", "dangling"} { t.Run(mode, func(t *testing.T) { base := t.TempDir() home := filepath.Join(base, "home") pkg := filepath.Join(home, "node_modules/.pnpm/pkg/node_modules/pkg") if err := os.MkdirAll(pkg, 0750); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(pkg, "index.js"), []byte("original"), 0640); err != nil { t.Fatal(err) } link := ".pnpm/pkg/node_modules/pkg" switch mode { case "absolute": link = "/workspace/example" case "escape": link = "../../outside" case "chain-escape": if err := os.Symlink(".", filepath.Join(home, "alias")); err != nil { t.Fatal(err) } link = "../alias/../outside" case "loop": link = "pkg" case "dangling": link = "missing" } if err := os.Symlink(link, filepath.Join(home, "node_modules/pkg")); err != nil { t.Fatal(err) } root, err := os.OpenRoot(base) if err != nil { t.Fatal(err) } defer root.Close() err = upgradeCopy(context.Background(), root, "home", "snapshot", 1024) if err != nil { t.Fatal(err) } got, err := root.Readlink("snapshot/node_modules/pkg") if err != nil || got != link { t.Fatalf("link not preserved: %q %v", got, err) } if err := root.Rename("home", "failed-home"); err != nil { t.Fatal(err) } if err := root.Rename("snapshot", "home"); err != nil { t.Fatal(err) } if mode != "pnpm" { got, err := root.Readlink("home/node_modules/pkg") if err != nil || got != link { t.Fatalf("restored link changed: %q %v", got, err) } return } data, err := os.ReadFile(filepath.Join(home, "node_modules/pkg/index.js")) if err != nil || string(data) != "original" { t.Fatalf("rollback link broken: %q %v", data, err) } }) } } func TestUpgradeReadinessRequiresToken(t *testing.T) { a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/json") { writeJSON(w, map[string]any{"State": map[string]bool{"Running": true}}) } else { fmt.Fprint(w, "starting without token") } }) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) defer cancel() if a.upgradeReady(ctx, "new", "one", "panel.test") == nil { t.Fatal("accepted without token") } } func TestUpgradeReadinessRequiresAuthenticatedHTTP(t *testing.T) { for _, mode := range []string{"success", "token-rejected", "missing-cookie", "unauthorized", "redirect", "unavailable"} { t.Run(mode, func(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("token") != "" { if mode == "token-rejected" { w.WriteHeader(401) return } if mode != "missing-cookie" { http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "ready"}) } w.WriteHeader(303) return } if r.Header.Get("Cookie") != "dsh_auth=ready" { t.Error("cookie not sent") } switch mode { case "unauthorized": w.WriteHeader(401) case "redirect": w.WriteHeader(302) case "unavailable": w.WriteHeader(503) default: w.WriteHeader(200) } })) defer upstream.Close() transport := runtimeTransport runtimeTransport = transport.Clone() runtimeTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, network, strings.TrimPrefix(upstream.URL, "http://")) } defer func() { runtimeTransport.CloseIdleConnections(); runtimeTransport = transport }() a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/json") { writeJSON(w, map[string]any{"State": map[string]bool{"Running": true}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]string{"IPAddress": "127.0.0.1"}}}}) } else { fmt.Fprint(w, "http://runtime/?token=ready\n") } }) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() err := a.upgradeReady(ctx, "new", "one", "panel.test") if (err == nil) != (mode == "success") { t.Fatalf("readiness=%v", err) } }) } }