package httpapi_test import ( "archive/zip" "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "os" "path/filepath" "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi" ) // ---------- 字体 ---------- // fontEnv 建好带赛事项目目录的测试环境,并给出字体落盘目录。 func fontEnv(t *testing.T) (*testEnv, string) { t.Helper() projectDir := t.TempDir() env := newTestEnvWith(t, func(o *httpapi.Options) { o.ProjectDir = projectDir }) return env, filepath.Join(projectDir, "uploads", "fonts") } // writeFontFile 直接在字体目录里放一个文件,模拟「已经上传过」。 func writeFontFile(t *testing.T, dir, name string, content []byte) { t.Helper() if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("创建字体目录: %v", err) } if err := os.WriteFile(filepath.Join(dir, name), content, 0o644); err != nil { t.Fatalf("写入字体文件: %v", err) } } // postFont 以 multipart 字段 file 上传到 /api/fonts。 func (e *testEnv) postFont(t *testing.T, client *http.Client, filename string, content []byte) (int, envelope) { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, err := mw.CreateFormFile("file", filename) if err != nil { t.Fatalf("构造 multipart: %v", err) } if _, err := fw.Write(content); err != nil { t.Fatalf("写入上传内容: %v", err) } if err := mw.Close(); err != nil { t.Fatalf("关闭 multipart: %v", err) } req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/fonts", &buf) if err != nil { t.Fatalf("构造请求: %v", err) } req.Header.Set("Content-Type", mw.FormDataContentType()) if token := e.csrfToken(client); token != "" { req.Header.Set("X-CSRF-Token", token) } resp, err := client.Do(req) if err != nil { t.Fatalf("上传字体: %v", err) } defer func() { _ = resp.Body.Close() }() var env envelope raw, _ := io.ReadAll(resp.Body) if len(bytes.TrimSpace(raw)) > 0 { if err := json.Unmarshal(raw, &env); err != nil { t.Fatalf("解析上传响应: %v (%s)", err, raw) } } return resp.StatusCode, env } type fontBody struct { ID string `json:"id"` Label string `json:"label"` Builtin bool `json:"builtin"` URL *string `json:"url"` } type fontListBody struct { Fonts []fontBody `json:"fonts"` } // 内置字体固定排在前面且 URL 为 null,上传字体按文件名排序跟在后面。 func TestListFontsBuiltinThenUploaded(t *testing.T) { env, fontsDir := fontEnv(t) writeFontFile(t, fontsDir, "b.ttf", []byte("bbbb")) writeFontFile(t, fontsDir, "a.ttf", []byte("aaaa")) // 不是字体扩展名的残留文件不能出现在清单里。 writeFontFile(t, fontsDir, "notes.txt", []byte("x")) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") status, got := env.do(client, http.MethodGet, "/api/fonts", nil, false) if status != http.StatusOK { t.Fatalf("读取字体清单应 200,得到 %d %+v", status, got.Error) } list := decodeData[fontListBody](t, got) wantIDs := []string{ "builtin:noto-sans", "builtin:noto-serif", "builtin:misans", "builtin:source-han-sans", "custom:a.ttf", "custom:b.ttf", } if len(list.Fonts) != len(wantIDs) { t.Fatalf("字体数量 = %d,期望 %d:%+v", len(list.Fonts), len(wantIDs), list.Fonts) } for i, want := range wantIDs { if list.Fonts[i].ID != want { t.Fatalf("第 %d 项 = %q,期望 %q", i, list.Fonts[i].ID, want) } } if list.Fonts[0].Label != "Noto Sans SC(思源黑体同源)" { t.Errorf("内置字体应带中文显示名,得到 %q", list.Fonts[0].Label) } for _, f := range list.Fonts[:4] { if !f.Builtin || f.URL != nil { t.Errorf("内置字体 %s 应为 builtin=true 且 url=null:%+v", f.ID, f) } } last := list.Fonts[5] if last.Builtin || last.URL == nil || *last.URL != "/uploads/fonts/b.ttf" || last.Label != "b.ttf" { t.Errorf("上传字体对象不符:%+v", last) } } // 字体清单是系统配置,guest 读不到;admin 与 recorder 都能读。 func TestListFontsRequiresRecorder(t *testing.T) { env, _ := fontEnv(t) env.createUser("admin", "admin-pass", "admin") env.createUser("recorder", "recorder-pass", "recorder") guest, _ := env.client() if status, _ := env.do(guest, http.MethodGet, "/api/fonts", nil, false); status != http.StatusForbidden { t.Fatalf("guest 读字体清单应 403,得到 %d", status) } admin, _ := env.client() env.login(admin, "admin", "admin-pass") if status, _ := env.do(admin, http.MethodGet, "/api/fonts", nil, false); status != http.StatusOK { t.Fatalf("admin 读字体清单应 200,得到 %d", status) } } // 上传成功要真落盘、返回 201 与字体对象,并且匿名 GET 能取到同一份内容。 func TestUploadFontThenServeAnonymously(t *testing.T) { env, fontsDir := fontEnv(t) env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") // 文件名里的空格与斜杠都要被收敛:服务端只允许安全字符落盘。 status, got := env.postFont(t, client, `C:\Fonts\思源 宋体.ttf`, []byte("fake-ttf")) if status != http.StatusCreated { t.Fatalf("上传字体应 201,得到 %d %+v", status, got.Error) } font := decodeData[fontBody](t, got) if font.ID != "custom:思源_宋体.ttf" || font.Builtin || font.URL == nil { t.Fatalf("上传返回的字体对象不符:%+v", font) } if *font.URL != "/uploads/fonts/思源_宋体.ttf" { t.Fatalf("字体 URL = %q", *font.URL) } if body, err := os.ReadFile(filepath.Join(fontsDir, "思源_宋体.ttf")); err != nil || string(body) != "fake-ttf" { t.Fatalf("字体未正确落盘: %v %q", err, body) } // 大屏的 @font-face 不带会话,静态访问必须匿名放行。 anon, _ := env.client() resp, err := anon.Get(env.server.URL + *font.URL) if err != nil { t.Fatalf("匿名取字体: %v", err) } defer func() { _ = resp.Body.Close() }() served, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK || string(served) != "fake-ttf" { t.Fatalf("匿名取字体 status=%d body=%q", resp.StatusCode, served) } if ct := resp.Header.Get("Content-Type"); ct != "font/ttf" { t.Errorf("Content-Type = %q,期望 font/ttf", ct) } // 字体会被同名重传覆盖,不能长缓存。 if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" { t.Errorf("Cache-Control = %q,期望 no-cache", cc) } // 目录里没有的文件、非白名单扩展名一律 404。 for _, path := range []string{"/uploads/fonts/nope.ttf", "/uploads/fonts/notes.txt"} { resp, err := anon.Get(env.server.URL + path) if err != nil { t.Fatalf("取 %s: %v", path, err) } _ = resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Errorf("取 %s 应 404,得到 %d", path, resp.StatusCode) } } } // 类型不符 400、超限 413、重名 409,错误消息必须能直接给操作员看。 func TestUploadFontRejectsBadInput(t *testing.T) { env, fontsDir := fontEnv(t) env.createUser("admin", "admin-pass", "admin") env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "admin", "admin-pass") status, got := env.postFont(t, client, "表格.xlsx", []byte("nope")) if status != http.StatusBadRequest { t.Fatalf("非字体文件应 400,得到 %d", status) } if got.Error == nil || !strings.Contains(got.Error.Message, "字体文件名非法") { t.Fatalf("错误消息应说明文件名非法:%+v", got.Error) } // 只有扩展名、没有文件名的情况同样拒绝。 if status, _ := env.postFont(t, client, ".ttf", []byte("nope")); status != http.StatusBadRequest { t.Fatalf("只有扩展名的文件名应 400,得到 %d", status) } status, got = env.postFont(t, client, "big.ttf", make([]byte, 20<<20+1)) if status != http.StatusRequestEntityTooLarge { t.Fatalf("超限字体应 413,得到 %d %+v", status, got.Error) } if got.Error == nil || !strings.Contains(got.Error.Message, "20 MiB") { t.Fatalf("413 消息应说明上限:%+v", got.Error) } if status, _ := env.postFont(t, client, "dup.ttf", []byte("one")); status != http.StatusCreated { t.Fatalf("首次上传应 201,得到 %d", status) } status, got = env.postFont(t, client, "dup.ttf", []byte("two")) if status != http.StatusConflict { t.Fatalf("重名应 409,得到 %d", status) } if got.Error == nil || !strings.Contains(got.Error.Message, "已存在") { t.Fatalf("重名消息应说明已存在:%+v", got.Error) } if body, err := os.ReadFile(filepath.Join(fontsDir, "dup.ttf")); err != nil || string(body) != "one" { t.Fatalf("重名被拒后原文件不能被改写: %v %q", err, body) } // 上传是系统级资源管理,只有 admin 能做。 recorder, _ := env.client() env.login(recorder, "recorder", "recorder-pass") if status, _ := env.postFont(t, recorder, "r.ttf", []byte("x")); status != http.StatusForbidden { t.Fatalf("recorder 上传字体应 403,得到 %d", status) } guest, _ := env.client() if status, _ := env.postFont(t, guest, "g.ttf", []byte("x")); status != http.StatusUnauthorized { t.Fatalf("guest 上传字体应 401,得到 %d", status) } } // 写入 settings 时必须挡住不存在的字体 id:否则大屏只会静默回落成默认字体。 func TestSettingsFontValidation(t *testing.T) { env, fontsDir := fontEnv(t) writeFontFile(t, fontsDir, "my-font.ttf", []byte("fake")) env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") seq := 0 put := func(title *string, body *string) (int, envelope) { seq++ display := map[string]any{} if title != nil { display["fontTitle"] = *title } if body != nil { display["fontBody"] = *body } return env.do(client, http.MethodPut, "/api/settings", map[string]any{"display": display, "requestId": fmt.Sprintf("req-font-%d", seq)}, true) } for _, bad := range []string{"builtin:comic-sans", "custom:nope.ttf", "custom:../evil.ttf", "随便写的"} { status, got := put(&bad, nil) if status != http.StatusBadRequest { t.Fatalf("非法字体 id %q 应 400,得到 %d", bad, status) } if got.Error == nil || !strings.Contains(got.Error.Message, "fontTitle") { t.Fatalf("错误消息应指明字段:%+v", got.Error) } } title := "builtin:noto-serif" body := "custom:my-font.ttf" if status, got := put(&title, &body); status != http.StatusOK { t.Fatalf("合法字体应 200,得到 %d %+v", status, got.Error) } settings := decodeData[map[string]any](t, mustGetSettings(t, env, client)) display := settings["display"].(map[string]any) if display["fontTitle"] != "builtin:noto-serif" || display["fontBody"] != "custom:my-font.ttf" { t.Fatalf("字体未落库:%+v", display) } // 空串表示回到默认字体,必须下发显式 null。 empty := "" if status, got := put(nil, &empty); status != http.StatusOK { t.Fatalf("清空字体应 200,得到 %d %+v", status, got.Error) } settings = decodeData[map[string]any](t, mustGetSettings(t, env, client)) display = settings["display"].(map[string]any) if display["fontBody"] != nil { t.Fatalf("清空后 fontBody 应为 null:%+v", display) } if display["fontTitle"] != "builtin:noto-serif" { t.Fatalf("只改 fontBody 时不应动到 fontTitle:%+v", display) } } // 删除字体:不存在 404、被设置引用 409(提示先改配置)、改完配置后可以删掉。 func TestDeleteFont(t *testing.T) { env, fontsDir := fontEnv(t) writeFontFile(t, fontsDir, "used.ttf", []byte("used")) writeFontFile(t, fontsDir, "free.ttf", []byte("free")) env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") if status, _ := env.do(client, http.MethodDelete, "/api/fonts/nope.ttf", nil, true); status != http.StatusNotFound { t.Fatalf("删除不存在的字体应 404,得到 %d", status) } used := "custom:used.ttf" if status, got := env.do(client, http.MethodPut, "/api/settings", map[string]any{"display": map[string]any{"fontTitle": used}, "requestId": "req-del-1"}, true); status != http.StatusOK { t.Fatalf("设置字体应 200,得到 %d %+v", status, got.Error) } status, got := env.do(client, http.MethodDelete, "/api/fonts/used.ttf", nil, true) if status != http.StatusConflict { t.Fatalf("被引用的字体应 409,得到 %d", status) } if got.Error == nil || !strings.Contains(got.Error.Message, "正在被大屏使用") { t.Fatalf("409 消息应提示先改配置:%+v", got.Error) } if _, err := os.Stat(filepath.Join(fontsDir, "used.ttf")); err != nil { t.Fatalf("被引用的字体不能被删除: %v", err) } if status, got := env.do(client, http.MethodPut, "/api/settings", map[string]any{"display": map[string]any{"fontTitle": ""}, "requestId": "req-del-2"}, true); status != http.StatusOK { t.Fatalf("清空字体应 200,得到 %d %+v", status, got.Error) } if status, got := env.do(client, http.MethodDelete, "/api/fonts/used.ttf", nil, true); status != http.StatusOK { t.Fatalf("不再被引用的字体应可删除,得到 %d %+v", status, got.Error) } if _, err := os.Stat(filepath.Join(fontsDir, "used.ttf")); !os.IsNotExist(err) { t.Fatalf("删除后文件应消失: %v", err) } // 删除是系统级资源管理,recorder 不能做。 env.createUser("recorder", "recorder-pass", "recorder") recorder, _ := env.client() env.login(recorder, "recorder", "recorder-pass") if status, _ := env.do(recorder, http.MethodDelete, "/api/fonts/free.ttf", nil, true); status != http.StatusForbidden { t.Fatalf("recorder 删除字体应 403,得到 %d", status) } } // 赛事包必须带上上传字体:字体是 uploads/ 下的资源,整包拷走才能在大屏上复现。 func TestExportProjectZipIncludesFonts(t *testing.T) { env, _ := fontEnv(t) env.seed(t) env.createUser("admin", "admin-pass", "admin") client, _ := env.client() env.login(client, "admin", "admin-pass") if status, got := env.postFont(t, client, "思源宋体.ttf", []byte("fake-font")); status != http.StatusCreated { t.Fatalf("上传字体应 201,得到 %d %+v", status, got.Error) } resp, err := client.Get(env.server.URL + "/api/export/project.zip") if err != nil { t.Fatalf("导出赛事包: %v", err) } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("导出赛事包失败 status=%d err=%v", resp.StatusCode, err) } zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) if err != nil { t.Fatalf("读取 zip: %v", err) } found := false for _, f := range zr.File { if f.Name == "uploads/fonts/思源宋体.ttf" { found = true } } if !found { names := make([]string, 0, len(zr.File)) for _, f := range zr.File { names = append(names, f.Name) } t.Fatalf("赛事包缺少 uploads/fonts/思源宋体.ttf,实际含 %v", names) } } // mustGetSettings 读一次 GET /api/settings,返回统一响应体。 func mustGetSettings(t *testing.T, env *testEnv, client *http.Client) envelope { t.Helper() status, got := env.do(client, http.MethodGet, "/api/settings", nil, false) if status != http.StatusOK { t.Fatalf("读取设置应 200,得到 %d %+v", status, got.Error) } return got }