package handler import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "net/http/httptest" "path/filepath" "testing" "github.com/agentmail/gateway/internal/blob" "github.com/agentmail/gateway/internal/config" "github.com/agentmail/gateway/internal/db" "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/models" "github.com/agentmail/gateway/internal/repo" ) /* 用户外观(主题 + 壁纸)落在服务端 —— 2026-09-14。 用户原话:「为什么背景是保存在本地而不是服务器!」当时的实情是主题与壁纸只写 localStorage:换设备就没了,而且**多账号共用一份**(键是全局常量)。这组判据钉住 四件事: 1. 存下来了、读得回来(账号级); 2. ★ 两个账号互不干扰(就是上面那条缺陷); 3. 图片走 blob,**换设备能拿到**(GET image 真返回字节); 4. ★ blob GC 不会把壁纸当孤儿删掉 —— 我在实现前先查了 GC,它只认 attachments / calendar_attachments;漏了那张表就会表现为"图 404、设置却显示已设置"。 */ // setupAppearanceDB 起一个带真实用户与 blob 目录的测试库。 func setupAppearanceDB(t *testing.T) { t.Helper() dir := t.TempDir() if err := db.Connect(context.Background(), "sqlite://"+filepath.Join(dir, "t.db")); err != nil { t.Fatalf("connect: %v", err) } if err := db.Migrate(context.Background()); err != nil { t.Fatalf("migrate: %v", err) } // 两个用户:多账号隔离是这组判据的重点 for _, u := range []string{"alice", "bob"} { if _, err := db.DB.ExecContext(context.Background(), `INSERT INTO users (username, password_hash, role) VALUES ($1, 'x', 'user')`, u); err != nil { t.Fatalf("建用户 %s: %v", u, err) } } // 真实 blob 目录(壁纸上传统统落到这里) Blobs = mustBlobStore(t, filepath.Join(dir, "blobs")) t.Cleanup(func() { db.Close() }) } func mustBlobStore(t *testing.T, root string) *blob.Store { t.Helper() st, err := blob.New(root) if err != nil { t.Fatalf("blob store: %v", err) } return st } func asUser(r *http.Request, username string) *http.Request { ctx := context.WithValue(r.Context(), middleware.UserKey, &models.User{Username: username}) return r.WithContext(ctx) } func putAppearance(t *testing.T, username string, body map[string]any) *httptest.ResponseRecorder { t.Helper() raw, _ := json.Marshal(body) req := httptest.NewRequest(http.MethodPut, "/api/v1/me/appearance", bytes.NewReader(raw)) req.Header.Set("Content-Type", "application/json") resp := httptest.NewRecorder() PutAppearance(resp, asUser(req, username)) return resp } func getAppearance(t *testing.T, username string) map[string]any { t.Helper() req := httptest.NewRequest(http.MethodGet, "/api/v1/me/appearance", nil) resp := httptest.NewRecorder() GetAppearance(resp, asUser(req, username)) if resp.Code != http.StatusOK { t.Fatalf("GET appearance = %d(%s)", resp.Code, resp.Body.String()) } var out map[string]any if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil { t.Fatal(err) } return out } /** 一张最小的合法 PNG(1×1 透明)。 */ func pngBytes() []byte { return []byte{ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, } } func uploadAppearanceImage(t *testing.T, username string, content []byte, filename, ctype string) *httptest.ResponseRecorder { t.Helper() var buf bytes.Buffer mw := multipart.NewWriter(&buf) fw, err := mw.CreatePart(map[string][]string{ "Content-Disposition": {fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename)}, "Content-Type": {ctype}, }) if err != nil { t.Fatal(err) } if _, err := fw.Write(content); err != nil { t.Fatal(err) } mw.Close() req := httptest.NewRequest(http.MethodPost, "/api/v1/me/appearance/image", &buf) req.Header.Set("Content-Type", mw.FormDataContentType()) resp := httptest.NewRecorder() UploadAppearanceImage(resp, asUser(req, username)) return resp } func TestAppearanceRoundTrip(t *testing.T) { setupAppearanceDB(t) // 没设置过:默认值 + saved=false("从没设置过"不是错误,不该 404) fresh := getAppearance(t, "alice") if fresh["saved"] != false { t.Fatalf("没设置过时 saved 应为 false,实际 %v", fresh["saved"]) } if fresh["bg_kind"] != "none" { t.Fatalf("默认背景档应为 none,实际 %v", fresh["bg_kind"]) } resp := putAppearance(t, "alice", map[string]any{ "theme": "dark", "bg_kind": "preset", "bg_preset_id": "aurora", "bg_dim": 30, "bg_blur": 6, }) if resp.Code != http.StatusOK { t.Fatalf("PUT = %d(%s)", resp.Code, resp.Body.String()) } got := getAppearance(t, "alice") if got["theme"] != "dark" || got["bg_kind"] != "preset" || got["bg_dim"].(float64) != 30 { t.Fatalf("读回的值不对:%v", got) } } // ★ 多账号各一份 —— 这是"放本地"时最直接的缺陷(同一台机器换账号背景不跟着走)。 func TestAppearanceIsPerAccount(t *testing.T) { setupAppearanceDB(t) putAppearance(t, "alice", map[string]any{"theme": "dark", "bg_kind": "preset", "bg_preset_id": "ocean"}) putAppearance(t, "bob", map[string]any{"theme": "light", "bg_kind": "none"}) a, b := getAppearance(t, "alice"), getAppearance(t, "bob") if a["bg_preset_id"] != "ocean" || a["theme"] != "dark" { t.Fatalf("alice 的外观被串了:%v", a) } if b["theme"] != "light" { t.Fatalf("★ bob 的外观受 alice 影响:%v", b) } } // 越界/非法值必须被夹住:这些值最终会写进 CSS 变量,越界会让界面不可读。 func TestAppearanceNormalizesBadInput(t *testing.T) { setupAppearanceDB(t) putAppearance(t, "alice", map[string]any{ "theme": "neon", "bg_kind": "video", "bg_dim": 999, "bg_blur": -5, }) got := getAppearance(t, "alice") if got["theme"] != "system" || got["bg_kind"] != "none" { t.Fatalf("认不出的 theme/kind 应退回默认,实际 %v / %v", got["theme"], got["bg_kind"]) } if got["bg_dim"].(float64) != 90 || got["bg_blur"].(float64) != 0 { t.Fatalf("dim/blur 应被夹进范围,实际 %v / %v", got["bg_dim"], got["bg_blur"]) } } // 壁纸本体:上传后**换设备也能拿到同样的字节**(这就是"放服务器"的意义)。 func TestAppearanceImageUploadAndFetch(t *testing.T) { setupAppearanceDB(t) data := pngBytes() resp := uploadAppearanceImage(t, "alice", data, "wall.png", "image/png") if resp.Code != http.StatusOK { t.Fatalf("上传 = %d(%s)", resp.Code, resp.Body.String()) } got := getAppearance(t, "alice") if got["has_image"] != true { t.Fatalf("上传后 has_image 应为 true:%v", got) } if got["image_url"] != "/api/v1/me/appearance/image" { t.Fatalf("应给出 image_url,实际 %v", got["image_url"]) } req := httptest.NewRequest(http.MethodGet, "/api/v1/me/appearance/image", nil) rec := httptest.NewRecorder() GetAppearanceImage(rec, asUser(req, "alice")) if rec.Code != http.StatusOK { t.Fatalf("取图 = %d", rec.Code) } if !bytes.Equal(rec.Body.Bytes(), data) { t.Fatalf("取回的字节与上传的不一致(%d vs %d 字节)", rec.Body.Len(), len(data)) } if ct := rec.Header().Get("Content-Type"); ct != "image/png" { t.Fatalf("Content-Type = %q", ct) } } // 非图片要当场拒(浏览器会把非图片渲染成空白,用户只会看到"设置了却没变化")。 func TestAppearanceImageRejectsNonImage(t *testing.T) { setupAppearanceDB(t) resp := uploadAppearanceImage(t, "alice", []byte("#!/bin/sh\necho hi\n"), "evil.sh", "text/x-shellscript") if resp.Code != http.StatusUnsupportedMediaType { t.Fatalf("非图片应为 415,实际 %d(%s)", resp.Code, resp.Body.String()) } if getAppearance(t, "alice")["has_image"] != false { t.Fatal("被拒的上传不该留下记录") } } // 超过上限:明确 413,不静默截断(截断的图会以损坏文件的形式存下来)。 func TestAppearanceImageSizeLimit(t *testing.T) { setupAppearanceDB(t) // config.C 由 main 在启动时装载,单测里可能是 nil —— 先保证有一个可改的实例, // 跑完再复原(改全局是有代价的,所以只在这条判据里、且立刻还原)。 if config.C == nil { config.C = &config.Config{} defer func() { config.C = nil }() } saved := config.C.MaxAppearanceBytes config.C.MaxAppearanceBytes = 1024 defer func() { config.C.MaxAppearanceBytes = saved }() big := append(pngBytes(), bytes.Repeat([]byte{0}, 4096)...) resp := uploadAppearanceImage(t, "alice", big, "big.png", "image/png") if resp.Code != http.StatusRequestEntityTooLarge { t.Fatalf("超限应为 413,实际 %d(%s)", resp.Code, resp.Body.String()) } } // 删除壁纸:记录清空,且读图变 404。 func TestAppearanceImageDelete(t *testing.T) { setupAppearanceDB(t) if resp := uploadAppearanceImage(t, "alice", pngBytes(), "w.png", "image/png"); resp.Code != http.StatusOK { t.Fatalf("上传 = %d", resp.Code) } req := httptest.NewRequest(http.MethodDelete, "/api/v1/me/appearance/image", nil) rec := httptest.NewRecorder() DeleteAppearanceImage(rec, asUser(req, "alice")) if rec.Code != http.StatusOK { t.Fatalf("删除 = %d", rec.Code) } if getAppearance(t, "alice")["has_image"] != false { t.Fatal("删除后 has_image 应为 false") } req2 := httptest.NewRequest(http.MethodGet, "/api/v1/me/appearance/image", nil) rec2 := httptest.NewRecorder() GetAppearanceImage(rec2, asUser(req2, "alice")) if rec2.Code != http.StatusNotFound { t.Fatalf("删除后取图应为 404,实际 %d", rec2.Code) } } // 未登录一律 401(壁纸是个人内容,不能匿名读)。 func TestAppearanceRequiresAuth(t *testing.T) { setupAppearanceDB(t) for name, fn := range map[string]func(http.ResponseWriter, *http.Request){ "GET": GetAppearance, "PUT": PutAppearance, "POST image": UploadAppearanceImage, "GET image": GetAppearanceImage, "DELETE image": DeleteAppearanceImage, } { method := http.MethodGet if name == "PUT" { method = http.MethodPut } else if name == "POST image" { method = http.MethodPost } else if name == "DELETE image" { method = http.MethodDelete } rec := httptest.NewRecorder() fn(rec, httptest.NewRequest(method, "/api/v1/me/appearance", nil)) if rec.Code != http.StatusUnauthorized { t.Errorf("%s 未登录应为 401,实际 %d", name, rec.Code) } } } // ★ 壁纸不能被 blob GC 当成孤儿删掉。 // // 实现前特意先读了 SweepUnreferencedBlobs:它只认 attachments / calendar_attachments // 两张引用表。漏掉 user_appearance 的话,壁纸文件会在下一次 GC 时消失,而库里那行 // 还在 —— 表现为"图 404、设置却显示已设置"。 func TestAppearanceImageSurvivesBlobGC(t *testing.T) { setupAppearanceDB(t) data := pngBytes() if resp := uploadAppearanceImage(t, "alice", data, "wall.png", "image/png"); resp.Code != http.StatusOK { t.Fatalf("上传 = %d", resp.Code) } sum := sha256.Sum256(data) sha := hex.EncodeToString(sum[:]) if !Blobs.Exists(sha) { t.Fatal("上传后 blob 应当存在") } // minAge=0:让 GC 立刻把"无人引用"的文件当孤儿(正是要验的窗口) if _, err := repo.SweepUnreferencedBlobs(context.Background(), Blobs, 0); err != nil { t.Fatalf("GC: %v", err) } if !Blobs.Exists(sha) { t.Fatal("★ 壁纸被 GC 删掉了 —— SweepUnreferencedBlobs 的引用源少了 user_appearance") } // 反向对照:一个谁都没引用的文件必须被清掉,否则说明 GC 其实没在干活 // (这条判据就没有分辨率 —— "壁纸还在"可能只是因为 GC 根本没跑)。 // 孤儿用 store 自己的 Put 造:手拼路径不合 blob 的布局,会得到一个假失败。 orphanSum, _, err := Blobs.Put(bytes.NewReader([]byte("nobody references me")), 1024) if err != nil { t.Fatal(err) } if !Blobs.Exists(orphanSum) { t.Fatal("孤儿文件应当先被写进去") } if _, err := repo.SweepUnreferencedBlobs(context.Background(), Blobs, 0); err != nil { t.Fatalf("GC(2): %v", err) } if Blobs.Exists(orphanSum) { t.Fatal("孤儿文件应当被清掉(否则这条判据无法区分「被保护」与「GC 没跑」)") } if !Blobs.Exists(sha) { t.Fatal("第二轮 GC 之后壁纸仍必须存在") } } // 读图端点必须原样吐字节(不能只回长度就完事)。 func TestAppearanceImageServesBytes(t *testing.T) { setupAppearanceDB(t) data := pngBytes() uploadAppearanceImage(t, "alice", data, "w.png", "image/png") req := httptest.NewRequest(http.MethodGet, "/api/v1/me/appearance/image", nil) rec := httptest.NewRecorder() GetAppearanceImage(rec, asUser(req, "alice")) body, _ := io.ReadAll(rec.Body) if !bytes.Equal(body, data) { t.Fatalf("取回 %d 字节,期望 %d", len(body), len(data)) } }