Files
dsh-web/internal/api/download_filename_test.go
m1saka 602917698a 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.
2026-10-17 00:36:21 +08:00

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)
}
})
}