feat: add safe backup management and theme preferences

Add snapshot usage and protected cleanup, preserve unsaved profile edits, avoid holding SQL connections during snapshot deletion, and fix download filenames. Include regression tests and API documentation.
This commit is contained in:
2026-10-17 00:36:21 +08:00
parent 09e202796f
commit 602917698a
20 changed files with 1240 additions and 83 deletions
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"archive/zip"
"bytes"
"io"
"mime"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"git.misaka.ren/m1saka/dsh-web/internal/store"
)
func TestWorkspaceDownloadFilename(t *testing.T) {
a, sid := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected Docker call") })
if err := a.s.AddInstance(store.Instance{ID: "one", UID: "owner"}); err != nil {
t.Fatal(err)
}
home := filepath.Join(a.root, "workspaces/owner/instances/one/home")
checkFilename := func(t *testing.T, w *httptest.ResponseRecorder, want string) {
t.Helper()
header := w.Header().Get("Content-Disposition")
mediaType, params, err := mime.ParseMediaType(header)
if err != nil {
t.Fatalf("parse Content-Disposition %q: %v", header, err)
}
if mediaType != "attachment" || params["filename"] != want {
t.Fatalf("Content-Disposition = %q, want attachment with filename %q", header, want)
}
}
const content = "0123456789\nunchanged 下载 content\x00\xff"
for _, name := range []string{"report.txt", "中文文件.txt", "report with spaces.txt", `report";100%.txt`, "README"} {
t.Run(name, func(t *testing.T) {
p := "parent/nested/" + name
file := filepath.Join(home, filepath.FromSlash(p))
if err := os.MkdirAll(filepath.Dir(file), 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(file, []byte(content), 0600); err != nil {
t.Fatal(err)
}
for _, partial := range []bool{false, true} {
r := runtimeRequest(http.MethodGet, "/api/ws/download?instance=one&path="+url.QueryEscape(p), "", sid)
wantStatus, wantBody := http.StatusOK, content
if partial {
r.Header.Set("Range", "bytes=2-6")
wantStatus, wantBody = http.StatusPartialContent, content[2:7]
}
w := httptest.NewRecorder()
a.workspace(w, r)
if w.Code != wantStatus {
t.Fatalf("partial=%v: status = %d, want %d: %s", partial, w.Code, wantStatus, w.Body.String())
}
checkFilename(t, w, name)
if w.Body.String() != wantBody {
t.Errorf("partial=%v: body = %q, want %q", partial, w.Body.String(), wantBody)
}
}
})
}
t.Run("directory zip", func(t *testing.T) {
const name = `资料 ";100%`
p := "parent/" + name
dir := filepath.Join(home, filepath.FromSlash(p))
if err := os.MkdirAll(dir, 0750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "entry.txt"), []byte(content), 0600); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
a.workspace(w, runtimeRequest(http.MethodGet, "/api/ws/download?instance=one&path="+url.QueryEscape(p), "", sid))
if w.Code != http.StatusOK {
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
}
checkFilename(t, w, name+".zip")
archive, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
if err != nil {
t.Fatal(err)
}
if len(archive.File) != 1 || archive.File[0].Name != "entry.txt" {
t.Fatalf("unexpected archive entries: %v", archive.File)
}
r, err := archive.File[0].Open()
if err != nil {
t.Fatal(err)
}
defer r.Close()
got, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if string(got) != content {
t.Errorf("archive body = %q, want %q", got, content)
}
})
}