Files
pure-note/internal/httpapi/api_test.go
T
wangairnan 6e83426ca9 HTTP 层与单二进制入口:路由 handler、会话/CSRF/限流防线、SPA 嵌入与 meta 注入
- internal/httpapi:§7.1 全部路由(公开浏览 / 管理端 / 认证 / feed),
  服务端统一可见性过滤(含回收站仅 admin 出口)、图片魔数校验与
  immutable/no-store 缓存头分流、统一 404 防枚举、slug 自解冲突与
  409 字段级错误、fail-only 登录限流(429 + Retry-After)、
  设置白名单(永不序列化口令哈希)
- internal/webui:go:embed dist + SPA fallback(资产指纹长缓存、
  深链回退 index.html)+ html/template 元信息注入(仅可见笔记)
- cmd/pure-note:serve/init/backup/gc/version 子命令,优雅停机与
  每小时会话清理
- 含全部 §13 测试组:表驱动可见性矩阵、迁移守卫、认证会话、CSRF、
  上传、回收站/gc、slug 策略、设置白名单、webui MapFS 单测
2026-09-08 08:14:23 +08:00

563 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"pure-note/internal/middleware"
)
// ---- 认证会话组(§13)----
func TestLoginFlow(t *testing.T) {
e := newEnv(t)
// 匿名 /api/me
c := e.client()
resp, body := e.get(c, "/api/me")
if resp.StatusCode != 200 || strings.Contains(string(body), `"authenticated":true`) {
t.Errorf("匿名 /api/me 应为 authenticated:false: %s", body)
}
// 错误口令:统一 401 文案
resp, body = e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":"wrong"}`), nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("错误口令应 401,实际 %d", resp.StatusCode)
}
if !strings.Contains(string(body), "用户名或密码错误") {
t.Errorf("统一文案缺失: %s", body)
}
// 空口令同样 401
resp, _ = e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":""}`), nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("空口令应 401,实际 %d", resp.StatusCode)
}
// 正确口令
resp, body = e.do(c, http.MethodPost, "/api/auth/login", []byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
if resp.StatusCode != 200 {
t.Fatalf("登录失败: %d %s", resp.StatusCode, body)
}
var out struct {
Data struct {
CSRFToken string `json:"csrf_token"`
} `json:"data"`
}
_ = json.Unmarshal(body, &out)
if out.Data.CSRFToken == "" {
t.Fatal("登录响应应含 csrf_token")
}
// 认证态 /api/me
resp, body = e.get(c, "/api/me")
if !strings.Contains(string(body), `"authenticated":true`) || !strings.Contains(string(body), out.Data.CSRFToken) {
t.Errorf("认证态 /api/me 应含同一 csrf_token: %s", body)
}
// 登出
resp, _ = e.do(c, http.MethodPost, "/api/auth/logout", nil, nil)
if resp.StatusCode != 200 {
t.Errorf("登出应 200: %d", resp.StatusCode)
}
// 登出后会话失效
resp, body = e.get(c, "/api/me")
if strings.Contains(string(body), `"authenticated":true`) {
t.Errorf("登出后 /api/me 应为匿名: %s", body)
}
}
func TestLoginRateLimit(t *testing.T) {
// 真实限流参数:per-IP 10/5min,per-账号 5/10min
e := newEnvRealLimits(t)
c := e.client()
// 前 5 次失败:401
for i := 0; i < 5; i++ {
resp, _ := e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":"wrong"}`), nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("第 %d 次失败应 401,实际 %d", i+1, resp.StatusCode)
}
}
// 第 6 次:账号桶耗尽 → 429 + Retry-After
resp, body := e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":"wrong"}`), nil)
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("第 6 次应 429,实际 %d %s", resp.StatusCode, body)
}
if resp.Header.Get("Retry-After") == "" {
t.Error("429 应带 Retry-After")
}
// 正确口令也被 429(桶已空)
resp, _ = e.do(c, http.MethodPost, "/api/auth/login", []byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
if resp.StatusCode != http.StatusTooManyRequests {
t.Errorf("桶空时正确口令也应 429,实际 %d", resp.StatusCode)
}
// 成功不计费:1 次失败后成功仍可
e2 := newEnvRealLimits(t)
c2 := e2.client()
_, _ = e2.do(c2, http.MethodPost, "/api/auth/login", []byte(`{"password":"wrong"}`), nil)
resp, _ = e2.do(c2, http.MethodPost, "/api/auth/login", []byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
if resp.StatusCode != 200 {
t.Errorf("失败 1 次后正确口令应可登录: %d", resp.StatusCode)
}
}
func newEnvRealLimits(t *testing.T) *testEnv {
t.Helper()
return newEnvCustom(t,
nil, // 全局用高容量桶(隔离测试)
middleware.NewLimiter(10.0/(5*60), 10, 4096, 10*time.Minute),
middleware.NewLimiter(5.0/(10*60), 5, 16, 10*time.Minute),
)
}
func TestPasswordChange(t *testing.T) {
e := newEnv(t)
admin := e.loginAdmin()
// 旧密码错误
resp, body := e.do(admin, http.MethodPost, "/api/admin/password",
[]byte(`{"old_password":"wrong","new_password":"new-password-456"}`), e.adminHeaders())
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("旧密码错误应 401: %d %s", resp.StatusCode, body)
}
// 新密码太弱
resp, _ = e.do(admin, http.MethodPost, "/api/admin/password",
[]byte(fmt.Sprintf(`{"old_password":%q,"new_password":"short"}`, testPassword)), e.adminHeaders())
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("弱口令应 400: %d", resp.StatusCode)
}
// 成功
resp, _ = e.do(admin, http.MethodPost, "/api/admin/password",
[]byte(fmt.Sprintf(`{"old_password":%q,"new_password":"new-password-456"}`, testPassword)), e.adminHeaders())
if resp.StatusCode != 200 {
t.Fatalf("改密应成功: %d", resp.StatusCode)
}
// 旧口令不能再登录
c := e.client()
resp, _ = e.do(c, http.MethodPost, "/api/auth/login", []byte(fmt.Sprintf(`{"password":%q}`, testPassword)), nil)
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("旧口令应失效: %d", resp.StatusCode)
}
// 新口令可登录;当前会话不失效(§7.3-7)
resp, body = e.get(admin, "/api/me")
if !strings.Contains(string(body), `"authenticated":true`) {
t.Errorf("改密不应失效当前会话: %s", body)
}
c2 := e.client()
resp, _ = e.do(c2, http.MethodPost, "/api/auth/login", []byte(`{"password":"new-password-456"}`), nil)
if resp.StatusCode != 200 {
t.Errorf("新口令应可登录: %d", resp.StatusCode)
}
}
func TestSessionRotation(t *testing.T) {
e := newEnv(t)
c := e.client()
// 直接构造一个 2 天后过期的会话(< 3 天阈值)
token := "tok-" + fmt.Sprint(time.Now().UnixNano())
csrf := "csrf-" + fmt.Sprint(time.Now().UnixNano())
now := time.Now().Unix()
if err := e.st.CreateSession(hashToken(token), csrf, now, now+2*86400); err != nil {
t.Fatal(err)
}
req, _ := http.NewRequest(http.MethodGet, e.ts.URL+"/api/me", nil)
req.AddCookie(&http.Cookie{Name: "pn_session", Value: token})
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
// 响应应 Set-Cookie 轮换新 token
var newCookie *http.Cookie
for _, ck := range resp.Cookies() {
if ck.Name == "pn_session" && ck.Value != "" {
newCookie = ck
}
}
if newCookie == nil || newCookie.Value == token {
t.Fatal("距过期 <3 天应轮换 Cookie token")
}
// csrf 保持不变(前端无感,§7.3-5)
b := new(bytes.Buffer)
_, _ = b.ReadFrom(resp.Body)
if !strings.Contains(b.String(), csrf) {
t.Errorf("轮换后 csrf_token 应不变: %s", b.String())
}
// 旧 token 行删除、新 token 行存在
if _, err := e.st.GetSession(hashToken(token)); err == nil {
t.Error("旧 token 应已删除")
}
sess, err := e.st.GetSession(hashToken(newCookie.Value))
if err != nil {
t.Fatalf("新 token 应存在: %v", err)
}
if sess.CSRFToken != csrf {
t.Error("新行 csrf 应保持不变")
}
// 长期有效会话不轮换
e2 := newEnv(t)
a := e2.loginAdmin()
resp, _ = e2.get(a, "/api/admin/notes")
if len(resp.Cookies()) > 0 {
t.Errorf("距过期 >3 天不应轮换")
}
}
// ---- CSRF 组(§13)----
func TestCSRF(t *testing.T) {
e := newEnv(t)
admin := e.loginAdmin()
// 无 token 403
resp, _ := e.do(admin, http.MethodPost, "/api/admin/notes",
[]byte(`{"title":"x","status":"private"}`), map[string]string{"Content-Type": "application/json"})
if resp.StatusCode != http.StatusForbidden {
t.Errorf("无 CSRF token 应 403: %d", resp.StatusCode)
}
// 错 token 403
bad := map[string]string{"X-CSRF-Token": "bad", "Content-Type": "application/json"}
resp, _ = e.do(admin, http.MethodPost, "/api/admin/notes", []byte(`{"title":"x","status":"private"}`), bad)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("错误 CSRF token 应 403: %d", resp.StatusCode)
}
// 跨源 Origin 拒绝(admin POST)
evil := map[string]string{
"Origin": "http://evil.example", "X-CSRF-Token": e.csrf, "Content-Type": "application/json",
}
resp, _ = e.do(admin, http.MethodPost, "/api/admin/notes", []byte(`{"title":"x","status":"private"}`), evil)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("跨源 Origin 应 403: %d", resp.StatusCode)
}
// 跨源 login 拒绝
c := e.client()
resp, _ = e.do(c, http.MethodPost, "/api/auth/login", []byte(`{"password":"x"}`),
map[string]string{"Origin": "http://evil.example"})
if resp.StatusCode != http.StatusForbidden {
t.Errorf("跨源 login 应 403: %d", resp.StatusCode)
}
// 跨源 logout 拒绝
resp, _ = e.do(admin, http.MethodPost, "/api/auth/logout", nil,
map[string]string{"Origin": "http://evil.example"})
if resp.StatusCode != http.StatusForbidden {
t.Errorf("跨源 logout 应 403: %d", resp.StatusCode)
}
// 缺 Origin 且缺 Referer 的变更请求拒绝
c2 := e.client()
req, _ := http.NewRequest(http.MethodPost, e.ts.URL+"/api/auth/login",
strings.NewReader(`{"password":"x"}`))
req.Header.Set("Content-Type", "application/json")
resp2, err := c2.Do(req)
if err != nil {
t.Fatal(err)
}
resp2.Body.Close()
if resp2.StatusCode != http.StatusForbidden {
t.Errorf("无 Origin/Referer 应 403: %d", resp2.StatusCode)
}
}
// ---- 设置白名单组(§9.3)----
func TestSettingsWhitelist(t *testing.T) {
e := newEnv(t)
admin := e.loginAdmin()
// GET 不含 admin_password_hash
resp, body := e.get(admin, "/api/admin/settings")
if resp.StatusCode != 200 {
t.Fatalf("settings GET: %d", resp.StatusCode)
}
if strings.Contains(string(body), "password") || strings.Contains(string(body), "argon2") {
t.Errorf("settings 响应不得含口令哈希: %s", body)
}
// 匿名 /api/site 同样不含
_, body = e.get(e.client(), "/api/site")
if strings.Contains(string(body), "password") || strings.Contains(string(body), "argon2") {
t.Errorf("/api/site 不得含口令哈希: %s", body)
}
// PUT 未知键拒绝
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
[]byte(`{"admin_password_hash":"evil"}`), e.adminHeaders())
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("未知键应 400: %d", resp.StatusCode)
}
// PUT 合法键
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
[]byte(`{"site_title":"新标题","site_desc":"描述","page_size":5}`), e.adminHeaders())
if resp.StatusCode != 200 {
t.Errorf("合法 PUT 应 200: %d", resp.StatusCode)
}
// page_size 生效为公开列表默认
_, body = e.get(e.client(), "/api/notes")
if !strings.Contains(string(body), `"page_size":5`) {
t.Errorf("page_size 设置应生效: %s", body)
}
// page_size 越界
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
[]byte(`{"page_size":0}`), e.adminHeaders())
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("page_size=0 应 400: %d", resp.StatusCode)
}
}
// ---- slug 策略组(§13)----
func TestSlugPolicy(t *testing.T) {
e := newEnv(t)
admin := e.loginAdmin()
// 纯中文标题 → post-YYYYMMDD
cnDate := time.Now().Format("20060102")
n1 := e.createNote(admin, "", "中文标题一", "内容", "public", nil)
if n1["slug"] != "post-"+cnDate {
t.Errorf("中文标题应生成 post-%s,实际 %v", cnDate, n1["slug"])
}
// 同日冲突 → 自动后缀 -2
n2 := e.createNote(admin, "", "另一篇中文", "内容", "public", nil)
if n2["slug"] != "post-"+cnDate+"-2" {
t.Errorf("同日冲突应后缀 -2,实际 %v", n2["slug"])
}
// ASCII 标题 → 小写连字符
n3 := e.createNote(admin, "", "Hello World 42", "内容", "public", nil)
if n3["slug"] != "hello-world-42" {
t.Errorf("ASCII 标题应 slugify,实际 %v", n3["slug"])
}
// 与回收站冲突:软删除后同标题仍被占用
id := int64(n3["id"].(float64))
if err := e.st.SoftDeleteNote(id, time.Now().Unix()); err != nil {
t.Fatal(err)
}
n4 := e.createNote(admin, "", "Hello World 42?", "内容", "public", nil)
if n4["slug"] != "hello-world-42-2" {
t.Errorf("回收站占用应自解冲突,实际 %v", n4["slug"])
}
// 手改冲突 → 409 + 字段级错误
resp, body := e.do(admin, http.MethodPut, fmt.Sprintf("/api/admin/notes/%d", int64(n4["id"].(float64))),
[]byte(fmt.Sprintf(`{"title":"Hello World 42?","slug":%q,"status":"public"}`, "post-"+cnDate)),
e.adminHeaders())
if resp.StatusCode != http.StatusConflict {
t.Errorf("手改冲突应 409: %d %s", resp.StatusCode, body)
}
var errOut struct {
Error struct {
Code string `json:"code"`
Field string `json:"field"`
} `json:"error"`
}
_ = json.Unmarshal(body, &errOut)
if errOut.Error.Code != "slug_conflict" || errOut.Error.Field != "slug" {
t.Errorf("应返回 slug 字段级错误: %s", body)
}
// 非法 slug 格式
resp, _ = e.do(admin, http.MethodPut, fmt.Sprintf("/api/admin/notes/%d", int64(n4["id"].(float64))),
[]byte(`{"title":"x","slug":"Bad Slug!","status":"public"}`), e.adminHeaders())
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("非法 slug 应 400: %d", resp.StatusCode)
}
}
// ---- 回收站 + gc 组(§13)----
func TestTrashLifecycleAndGC(t *testing.T) {
e := newEnv(t)
admin := e.loginAdmin()
anon := e.client()
imgID := e.uploadPNG(admin, uniquePNG('g'))
note := e.createNote(admin, "", "生命周期", "引用 ![x](/api/images/"+fmt.Sprint(imgID)+")", "public", nil)
id := int64(note["id"].(float64))
slug := note["slug"].(string)
// 删除 → 公开列表/详情不可见
resp, _ := e.do(admin, http.MethodDelete, fmt.Sprintf("/api/admin/notes/%d", id), nil, e.adminHeaders())
if resp.StatusCode != 200 {
t.Fatalf("软删除失败: %d", resp.StatusCode)
}
_, body := e.get(anon, "/api/notes")
if strings.Contains(string(body), slug) {
t.Error("回收站笔记不应出现在公开列表")
}
resp, _ = e.get(anon, "/api/notes/"+slug)
if resp.StatusCode != 404 {
t.Errorf("回收站详情应 404: %d", resp.StatusCode)
}
// 图片同样退出公开(并集语义失效)
resp, _ = e.get(anon, fmt.Sprintf("/api/images/%d", imgID))
if resp.StatusCode != 404 {
t.Errorf("回收站引用图片应 404: %d", resp.StatusCode)
}
// 恢复 → 可见,图片引用天然保全
resp, _ = e.do(admin, http.MethodPost, fmt.Sprintf("/api/admin/trash/%d/restore", id), nil, e.adminHeaders())
if resp.StatusCode != 200 {
t.Fatalf("恢复失败: %d", resp.StatusCode)
}
resp, _ = e.get(anon, "/api/notes/"+slug)
if resp.StatusCode != 200 {
t.Errorf("恢复后应可见: %d", resp.StatusCode)
}
resp, _ = e.get(anon, fmt.Sprintf("/api/images/%d", imgID))
if resp.StatusCode != 200 {
t.Errorf("恢复后图片应匿名可见: %d", resp.StatusCode)
}
// gc:30 天过期物理删除 + 7 天宽限孤儿图片 + 过期会话
// 7 天内的孤儿图片不被删:先造一张新孤儿图(趁会话有效)
freshImg := e.uploadPNG(admin, uniquePNG('h'))
if _, err := e.st.DB().Exec(`DELETE FROM image_refs WHERE image_id=?`, freshImg); err != nil {
t.Fatal(err)
}
old := time.Now().Add(-31 * 24 * time.Hour).Unix()
if _, err := e.st.DB().Exec(`UPDATE notes SET deleted_at=? WHERE id=?`, old, id); err != nil {
t.Fatal(err)
}
if _, err := e.st.DB().Exec(`UPDATE images SET created_at=? WHERE id=?`, old, imgID); err != nil {
t.Fatal(err)
}
// 把管理员会话改为过期
if _, err := e.st.DB().Exec(`UPDATE sessions SET expires_at=?`, time.Now().Add(-time.Hour).Unix()); err != nil {
t.Fatal(err)
}
// dry-run 不改数据
rep, err := e.st.GC(time.Now(), true)
if err != nil {
t.Fatal(err)
}
if len(rep.ExpiredNotes) != 1 || rep.ExpiredSessons < 1 {
t.Errorf("dry-run 应报告 1 篇过期笔记与过期会话: %+v", rep)
}
if _, err := e.st.GetNoteByID(id); err != nil {
t.Error("dry-run 不得删除数据")
}
// commit 执行
rep, err = e.st.GC(time.Now(), false)
if err != nil {
t.Fatal(err)
}
if !rep.Committed || len(rep.ExpiredNotes) != 1 {
t.Errorf("commit 应物理删除: %+v", rep)
}
if _, err := e.st.GetNoteByID(id); err == nil {
t.Error("过期笔记应被物理删除")
}
if _, err := e.st.GetImageMeta(imgID); err != nil {
t.Error("超宽限期的孤儿图片应被删除")
}
if _, err := e.st.GetImageMeta(freshImg); err != nil {
t.Error("7 天内的孤儿图片不应被删除")
}
sessLeft, err := e.st.DeleteExpiredSessions(time.Now().Unix())
_ = sessLeft
if err != nil {
t.Fatal(err)
}
// 过期会话已被 gc 清空:登录表里应无行
var cnt int
if err := e.st.DB().QueryRow(`SELECT COUNT(*) FROM sessions`).Scan(&cnt); err != nil {
t.Fatal(err)
}
if cnt != 0 {
t.Errorf("过期会话应被 gc 清除,剩余 %d", cnt)
}
}
// ---- 上传组(§13)----
func TestUploadValidation(t *testing.T) {
e := newEnv(t)
admin := e.loginAdmin()
upload := func(fileName, contentType string, data []byte) (int, string) {
var body bytes.Buffer
boundary := "bnd"
body.WriteString("--" + boundary + "\r\n")
body.WriteString(`Content-Disposition: form-data; name="file"; filename="` + fileName + `"` + "\r\n")
body.WriteString("Content-Type: " + contentType + "\r\n\r\n")
body.Write(data)
body.WriteString("\r\n--" + boundary + "--\r\n")
req, _ := http.NewRequest(http.MethodPost, e.ts.URL+"/api/admin/images", &body)
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
req.Header.Set("Origin", e.ts.URL)
req.Header.Set("X-CSRF-Token", e.csrf)
resp, err := admin.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out bytes.Buffer
_, _ = out.ReadFrom(resp.Body)
return resp.StatusCode, out.String()
}
// 魔数不符(文本伪装 png)
code, body := upload("fake.png", "image/png", []byte("hello this is not an image"))
if code != http.StatusUnsupportedMediaType {
t.Errorf("魔数不符应 415: %d %s", code, body)
}
// SVG 一律拒绝
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)
code, _ = upload("x.svg", "image/svg+xml", svg)
if code != http.StatusUnsupportedMediaType {
t.Errorf("SVG 应拒绝: %d", code)
}
// 声明类型白名单外
code, _ = upload("x.png", "application/octet-stream", png1x1)
if code != http.StatusUnsupportedMediaType {
t.Errorf("声明类型白名单外应拒绝: %d", code)
}
// 空文件
code, _ = upload("x.png", "image/png", nil)
if code != http.StatusBadRequest {
t.Errorf("空文件应 400: %d", code)
}
// 超 5MB
big := make([]byte, 5<<20+1)
copy(big, png1x1)
code, _ = upload("big.png", "image/png", big)
if code != http.StatusRequestEntityTooLarge {
t.Errorf("超限应 413: %d", code)
}
// 合法 PNG → 201
code, body = upload("ok.png", "image/png", uniquePNG('v'))
if code != http.StatusCreated {
t.Fatalf("合法 PNG 应 201: %d %s", code, body)
}
var out struct {
Data struct {
ID int64 `json:"id"`
URL string `json:"url"`
} `json:"data"`
}
_ = json.Unmarshal([]byte(body), &out)
if out.Data.URL != fmt.Sprintf("/api/images/%d", out.Data.ID) {
t.Errorf("响应应含 id 与 url: %s", body)
}
// 去重命中:同字节再传同 id
code, body = upload("ok2.png", "image/png", uniquePNG('v'))
_ = json.Unmarshal([]byte(body), &out)
if code != http.StatusCreated || out.Data.ID == 0 {
t.Fatalf("重复上传应 201: %d %s", code, body)
}
var first struct {
Data struct {
ID int64 `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal([]byte(fmt.Sprintf(`{"data":{"id":%d}}`, mustImageID(t, e, uniquePNG('v')))), &first)
// 直接查库确认只有一行
var cnt int
if err := e.st.DB().QueryRow(`SELECT COUNT(*) FROM images WHERE size=?`, len(uniquePNG('v'))).Scan(&cnt); err != nil {
t.Fatal(err)
}
if cnt != 1 {
t.Errorf("同字节图片应去重为一行,实际 %d 行", cnt)
}
_ = first
}
func mustImageID(t *testing.T, e *testEnv, png []byte) int64 {
t.Helper()
return e.uploadPNG(e.loginAdmin(), png)
}