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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user