584 lines
22 KiB
Go
584 lines
22 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|