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.
102 lines
3.2 KiB
Go
102 lines
3.2 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|