package api import ( "bytes" "context" "encoding/json" "fmt" "git.misaka.ren/m1saka/dsh-web/internal/docker" "git.misaka.ren/m1saka/dsh-web/internal/store" "golang.org/x/crypto/bcrypt" "mime/multipart" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "syscall" "testing" "time" ) func TestConfigStopAlreadyStopped(t *testing.T) { for _, state := range []string{`{}`, `{"Running":true}`, `{"Paused":true}`, `{"Restarting":true}`} { t.Run(state, func(t *testing.T) { inspected := false srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { w.WriteHeader(http.StatusNotModified) return } inspected = true fmt.Fprintf(w, `{"State":%s}`, state) })) defer srv.Close() a := &API{docker: docker.New(srv.URL)} err := a.configStop(context.Background(), "test") if !inspected || (err == nil) != (state == `{}`) { t.Fatalf("inspected=%v error=%v", inspected, err) } }) } } func TestConfigEmptyProfileRoundTrip(t *testing.T) { if os.Geteuid() != 0 { t.Skip("requires chown") } a, i, home := configTestAPI(t, false) w := configCall(a, i, "GET", "profile", "") if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } w = configCall(a, i, "PUT", "profile", w.Body.String()) if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } content, err := os.ReadFile(filepath.Join(home, "profiles/web/cordis.patch.yml")) if err != nil || string(content) != "[]\n" { t.Fatalf("content=%q error=%v", content, err) } } func TestConfigPasswordBackupImport(t *testing.T) { if os.Geteuid() != 0 { t.Skip("requires chown") } a, i, home := configTestAPI(t, false) hash, _ := bcrypt.GenerateFromPassword([]byte("original-password"), bcrypt.MinCost) if _, err := a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id=?", string(hash), i.UID); err != nil { t.Fatal(err) } configWrite(t, filepath.Join(home, ".credentials.yaml"), "secret: original") for _, body := range []string{`{}`, `{"password":"wrong"}`} { if w := configCall(a, i, "POST", "backups", body); w.Code < 400 { t.Fatal("accepted wrong password") } } w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`) if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } var out struct { ID string `json:"id"` } json.Unmarshal(w.Body.Bytes(), &out) if _, err := os.Stat(filepath.Join(a.root, "backup.key")); !os.IsNotExist(err) { t.Fatal("new backup persisted key") } encrypted, err := os.ReadFile(filepath.Join(a.root, "backups", i.UID, i.ID, out.ID+".dshbak")) if err != nil { t.Fatal(err) } if bytes.Contains(encrypted, []byte("secret: original")) { t.Fatal("plaintext archive") } hash, _ = bcrypt.GenerateFromPassword([]byte("changed-password"), bcrypt.MinCost) a.s.DB.Exec("UPDATE users SET pass_hash=? WHERE id=?", string(hash), i.UID) configWrite(t, filepath.Join(home, ".credentials.yaml"), "secret: changed") w = configCall(a, i, "POST", "restore", `{"backup_id":"`+out.ID+`","password":"wrong"}`) if w.Code != 400 { t.Fatal(w.Code, w.Body.String()) } entries, _ := os.ReadDir(filepath.Dir(home)) for _, entry := range entries { if strings.HasPrefix(entry.Name(), "pre-restore-") { t.Fatal("bad password created snapshot") } } var body bytes.Buffer form := multipart.NewWriter(&body) part, _ := form.CreateFormFile("file", "external.dshbak") part.Write(encrypted) form.WriteField("password", "original-password") form.Close() req := httptest.NewRequest("POST", "/api/instances/one/backups", &body) req.Header.Set("Content-Type", form.FormDataContentType()) w = httptest.NewRecorder() a.config(w, req, i, "backups") if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } w = configCall(a, i, "POST", "restore", `{"backup_id":"`+out.ID+`","password":"original-password"}`) if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } restored, _ := os.ReadFile(filepath.Join(home, ".credentials.yaml")) if string(restored) != "secret: original" { t.Fatal(string(restored)) } } func configTestAPI(t *testing.T, running bool, failures ...string) (*API, store.Instance, string) { t.Helper() var mu sync.Mutex httpFailure := false backupRestart := running failure := "" if len(failures) != 0 { failure = failures[0] } upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() if httpFailure { httpFailure = false running = false w.WriteHeader(503) return } if r.URL.Query().Get("token") == "test-token" { http.SetCookie(w, &http.Cookie{Name: "dsh_auth", Value: "ready"}) w.WriteHeader(303) return } w.WriteHeader(200) })) t.Cleanup(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://")) } t.Cleanup(func() { runtimeTransport.CloseIdleConnections(); runtimeTransport = transport }) a, _ := runtimeTestAPI(t, func(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() switch { case strings.HasSuffix(r.URL.Path, "/stop"): running = false w.WriteHeader(204) case strings.HasSuffix(r.URL.Path, "/start"): if backupRestart { backupRestart = false running = true w.WriteHeader(204) return } running = failure != "reconcile" httpFailure = failure == "readiness" failure = "" w.WriteHeader(204) case strings.Contains(r.URL.Path, "/exec"), strings.HasSuffix(r.URL.Path, "/restart"): t.Error("restore must use entrypoint prestart, not exec/restart") w.WriteHeader(500) case strings.HasSuffix(r.URL.Path, "/logs"): fmt.Fprint(w, "http://runtime/?token=test-token") default: writeJSON(w, map[string]any{"State": map[string]any{"Running": running}, "NetworkSettings": map[string]any{"Networks": map[string]any{"dsh-inst-one": map[string]string{"IPAddress": "127.0.0.1"}}}}) } }) hash, _ := bcrypt.GenerateFromPassword([]byte("original-password"), bcrypt.MinCost) a.s.DB.Exec("UPDATE users SET pass_hash=?,quota=10737418240 WHERE id='owner'", string(hash)) i := store.Instance{ID: "one", UID: "owner", Name: "one", ContainerID: "old", Status: "exited", Created: time.Now()} if e := a.s.AddInstance(i); e != nil { t.Fatal(e) } home := filepath.Join(a.root, "workspaces", "owner", "instances", "one", "home") if e := os.MkdirAll(home, 0750); e != nil { t.Fatal(e) } return a, i, home } func configCall(a *API, i store.Instance, method, action, body string) *httptest.ResponseRecorder { w := httptest.NewRecorder() r := httptest.NewRequest(method, "/api/instances/one/"+action, strings.NewReader(body)) a.config(w, r, i, strings.Split(action, "?")[0]) return w } func configWrite(t *testing.T, path, content string) { t.Helper() if e := os.WriteFile(path, []byte(content), 0600); e != nil { t.Fatal(e) } } func TestConfigProfile(t *testing.T) { if os.Geteuid() != 0 { t.Skip("requires chown") } a, i, home := configTestAPI(t, false) for _, tc := range []struct { method, body string code int }{ {"GET", "", 200}, {"PUT", `{"content":"foo: ["}`, 400}, {"PUT", `{"content":"foo: bar\n"}`, 200}, {"GET", "", 200}, } { w := configCall(a, i, tc.method, "profile", tc.body) if w.Code != tc.code { t.Fatalf("%s: %d %s", tc.method, w.Code, w.Body.String()) } } for _, path := range []string{"profiles", "profiles/web", "profiles/web/cordis.patch.yml"} { st, e := os.Stat(filepath.Join(home, path)) if e != nil { t.Fatal(e) } stat := st.Sys().(*syscall.Stat_t) if stat.Uid != 1000 || stat.Gid != 1000 { t.Fatalf("ownership of %s: %+v", path, stat) } } w := configCall(a, i, "GET", "profile", "") if !strings.Contains(w.Body.String(), "foo: bar") { t.Fatal(w.Body.String()) } } func TestConfigProfileSymlinks(t *testing.T) { for _, level := range []string{"profiles", "web", "leaf", "home"} { t.Run(level, func(t *testing.T) { a, i, home := configTestAPI(t, false) secret := "credential-secret-never-return" configWrite(t, filepath.Join(home, ".credentials.yaml"), secret) if e := os.MkdirAll(filepath.Join(home, "profiles", "web"), 0750); e != nil { t.Fatal(e) } var link, target string switch level { case "profiles": link = filepath.Join(home, "profiles") target = "private" if e := os.MkdirAll(filepath.Join(home, "private", "web"), 0750); e != nil { t.Fatal(e) } configWrite(t, filepath.Join(home, "private", "web", "cordis.patch.yml"), secret) case "web": link = filepath.Join(home, "profiles", "web") target = "../private" if e := os.Mkdir(filepath.Join(home, "private"), 0750); e != nil { t.Fatal(e) } configWrite(t, filepath.Join(home, "private", "cordis.patch.yml"), secret) case "leaf": link = filepath.Join(home, "profiles", "web", "cordis.patch.yml") target = "../../.credentials.yaml" case "home": link = home target = home + "-other" if e := os.Rename(home, target); e != nil { t.Fatal(e) } } if level != "home" { if e := os.RemoveAll(link); e != nil { t.Fatal(e) } } if e := os.Symlink(target, link); e != nil { t.Fatal(e) } for _, method := range []string{"GET", "PUT"} { w := configCall(a, i, method, "profile", `{"content":"foo: bar"}`) if w.Code != 400 || strings.Contains(w.Body.String(), secret) { t.Fatalf("%s: %d %s", method, w.Code, w.Body.String()) } } }) } } func TestConfigRunningRejected(t *testing.T) { a, i, _ := configTestAPI(t, true) for _, tc := range []struct{ method, action string }{{"PUT", "profile"}} { w := configCall(a, i, tc.method, tc.action, `{}`) if w.Code != 409 { t.Fatalf("%s: %d", tc.action, w.Code) } } } func TestConfigBackupRestore(t *testing.T) { if os.Geteuid() != 0 { t.Skip("requires chown") } a, i, home := configTestAPI(t, false) const secret = "private-credential-token" configWrite(t, filepath.Join(home, ".credentials.yaml"), secret) if w := configCall(a, i, "PUT", "profile", `{"content":"foo: old"}`); w.Code != 200 { t.Fatal(w.Body.String()) } w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`) var result struct { ID string `json:"id"` } if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || !backupIDPattern.MatchString(result.ID) { t.Fatalf("%d %s", w.Code, w.Body.String()) } w = configCall(a, i, "GET", "backups?download="+result.ID, "") if w.Code != 200 || !bytes.HasPrefix(w.Body.Bytes(), []byte("DSHPASS")) || bytes.Contains(w.Body.Bytes(), []byte(secret)) { t.Fatalf("unsafe download: %d", w.Code) } if e := os.RemoveAll(filepath.Join(home, "profiles")); e != nil { t.Fatal(e) } configWrite(t, filepath.Join(home, ".credentials.yaml"), "changed") body := fmt.Sprintf(`{"backup_id":%q,"password":"original-password"}`, result.ID) for n := 0; n < 3; n++ { w = configCall(a, i, "POST", "restore", body) if w.Code != 200 { t.Fatalf("%d %s", w.Code, w.Body.String()) } } w = configCall(a, i, "POST", "restore", body) if w.Code != 409 { t.Fatalf("snapshot retention: %d", w.Code) } got, e := os.ReadFile(filepath.Join(home, ".credentials.yaml")) if e != nil || string(got) != secret { t.Fatalf("restore: %q %v", got, e) } for _, p := range []string{"profiles", "profiles/web", "profiles/web/cordis.patch.yml"} { st, e := os.Stat(filepath.Join(home, p)) if e != nil { t.Fatal(e) } if st.Sys().(*syscall.Stat_t).Uid != 1000 { t.Fatalf("not owned by runtime: %s", p) } } state, e := a.s.GetInstance(i.ID) if e != nil || state.Status != "exited" { t.Fatalf("state: %+v %v", state, e) } id := store.ID() if e := os.Symlink(filepath.Join(a.root, "backup.key"), filepath.Join(a.root, "backups", i.UID, i.ID, id+".dshbak")); e != nil { t.Fatal(e) } w = configCall(a, i, "GET", "backups?download="+id, "") if w.Code == 200 { t.Fatal("symlink download exposed key") } } func TestConfigRestoreInvalidArchive(t *testing.T) { if os.Geteuid() != 0 { t.Skip("requires chown") } a, i, home := configTestAPI(t, false) configWrite(t, filepath.Join(home, "cordis.patch.yml"), "original") id := store.ID() dir := filepath.Join(a.root, "backups", i.UID, i.ID) if e := os.MkdirAll(dir, 0700); e != nil { t.Fatal(e) } configWrite(t, filepath.Join(dir, id+".dshbak"), "corrupt") w := configCall(a, i, "POST", "restore", fmt.Sprintf(`{"backup_id":%q}`, id)) if w.Code != 400 || !strings.Contains(w.Body.String(), "invalid backup") { t.Fatalf("%d %s", w.Code, w.Body.String()) } got, e := os.ReadFile(filepath.Join(home, "cordis.patch.yml")) if e != nil || string(got) != "original" { t.Fatalf("rollback: %q %v", got, e) } a.s.SetInstance(i.ID, "error") w = configCall(a, i, "POST", "restore", fmt.Sprintf(`{"backup_id":%q}`, id)) if w.Code != 409 { t.Fatalf("recovery guard: %d", w.Code) } } func TestConfigRestoreOrchestration(t *testing.T) { if os.Geteuid() != 0 { t.Skip("requires chown") } for _, running := range []bool{false, true} { for _, failure := range []string{"", "reconcile", "readiness"} { t.Run(fmt.Sprintf("running=%v/%s", running, failure), func(t *testing.T) { a, i, home := configTestAPI(t, running, failure) configWrite(t, filepath.Join(home, ".credentials.yaml"), "archive") if err := os.MkdirAll(filepath.Join(home, "pets", "nested"), 0750); err != nil { t.Fatal(err) } configWrite(t, filepath.Join(home, "pets", "nested", "pet.json"), "{}") w := configCall(a, i, "POST", "backups", `{"password":"original-password"}`) if w.Code != 200 { t.Fatal(w.Code, w.Body.String()) } var out struct{ ID string } json.Unmarshal(w.Body.Bytes(), &out) var kind string var schema int if err := a.s.DB.QueryRow("SELECT kind,schema FROM backups WHERE id=?", out.ID).Scan(&kind, &schema); err != nil || kind != "manual" || schema != 2 { t.Fatalf("index: %s %d %v", kind, schema, err) } configWrite(t, filepath.Join(home, ".credentials.yaml"), "before-restore") w = configCall(a, i, "POST", "restore", fmt.Sprintf(`{"backup_id":%q,"password":"original-password"}`, out.ID)) wantCode, wantContent := 200, "archive" if failure != "" { wantCode, wantContent = 400, "before-restore" } if w.Code != wantCode { t.Fatal(w.Code, w.Body.String()) } b, _ := os.ReadFile(filepath.Join(home, ".credentials.yaml")) if string(b) != wantContent { t.Fatalf("home: %s", b) } c, err := a.docker.Inspect(context.Background(), i.ContainerID) if err != nil || c.State.Running != running { t.Fatalf("runtime state: %+v %v", c, err) } st, err := os.Stat(filepath.Join(home, "pets", "nested", "pet.json")) if err != nil || st.Sys().(*syscall.Stat_t).Uid != 1000 { t.Fatalf("resource ownership: %v", err) } }) } } }