From 7dce4cadde54b6a9d33f458c596da44c872f7426 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:42:23 +0800 Subject: [PATCH] =?UTF-8?q?test:=20keys=20CRUD=20=E7=9A=84=20HTTP=20?= =?UTF-8?q?=E7=BA=A7=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖完整生命周期(明文一次性返回、列表不泄漏明文、改配额、删除) 与用户隔离(跨用户 patch/delete 返回 404 不泄漏存在性)。 SessionAuth 以桩替换,专注 handler 语义。 --- server/internal/api/keys_test.go | 145 +++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 server/internal/api/keys_test.go diff --git a/server/internal/api/keys_test.go b/server/internal/api/keys_test.go new file mode 100644 index 0000000..41eda12 --- /dev/null +++ b/server/internal/api/keys_test.go @@ -0,0 +1,145 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/openteam/server/internal/api/middleware" + "github.com/openteam/server/internal/app" + "github.com/openteam/server/internal/store" + "gorm.io/gorm" +) + +// keysTestEnv 起一个只挂 keys 路由的引擎,SessionAuth 用桩替换—— +// 直接把用户塞进会话上下文,绕过 JWT/argon2,专注测 handler 语义。 +type keysTestEnv struct { + t *testing.T + db *gorm.DB + user *store.User + r *gin.Engine +} + +func newKeysTestEnv(t *testing.T) *keysTestEnv { + t.Helper() + gin.SetMode(gin.TestMode) + db, err := store.Open("sqlite", "/tmp/api_keys_test_"+time.Now().Format("150405.000000000")+".db") + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + sqlDB.Close() + }) + u := store.User{Username: "u1", Email: "u1@x.com", PasswordHash: "x", + Role: store.RoleUser, Balance: 100, Status: store.UserStatusActive} + if err := db.Create(&u).Error; err != nil { + t.Fatal(err) + } + + h := &Handler{a: &app.App{DB: db}} + r := gin.New() + g := r.Group("", func(c *gin.Context) { + c.Set(middleware.CtxSessionUser, &u) + c.Next() + }) + g.POST("/keys", h.CreateKey) + g.GET("/keys", h.ListKeys) + g.PATCH("/keys/:id", h.PatchKey) + g.DELETE("/keys/:id", h.DeleteKey) + + return &keysTestEnv{t: t, db: db, user: &u, r: r} +} + +func (e *keysTestEnv) do(method, path string, body any) *httptest.ResponseRecorder { + e.t.Helper() + var buf *bytes.Buffer + if body != nil { + b, _ := json.Marshal(body) + buf = bytes.NewBuffer(b) + } else { + buf = bytes.NewBuffer(nil) + } + w := httptest.NewRecorder() + req := httptest.NewRequest(method, path, buf) + req.Header.Set("Content-Type", "application/json") + e.r.ServeHTTP(w, req) + return w +} + +// 完整生命周期:创建(明文仅一次返回)→ 列表(只有前缀)→ 改限额 → 删除后列表不含。 +func TestKeysLifecycle(t *testing.T) { + e := newKeysTestEnv(t) + + // 创建:明文一次性返回,库存哈希 + w := e.do(http.MethodPost, "/keys", map[string]any{"name": "dev", "quota_requests_per_day": 10}) + if w.Code != http.StatusCreated { + t.Fatalf("create: %d %s", w.Code, w.Body.String()) + } + var created struct { + Data struct { + Key string `json:"key"` + ID uint64 `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatal(err) + } + if len(created.Data.Key) < 48 { + t.Fatalf("明文 key 应完整返回,实际 %q", created.Data.Key) + } + + // 列表:不应含明文 + w = e.do(http.MethodGet, "/keys", nil) + if w.Code != http.StatusOK || bytes.Contains(w.Body.Bytes(), []byte(created.Data.Key)) { + t.Fatalf("列表泄漏明文 key: %d %s", w.Code, w.Body.String()) + } + + // 补丁:改日请求数配额 + w = e.do(http.MethodPatch, "/keys/"+strconv.FormatUint(created.Data.ID, 10), map[string]any{"quota_requests_per_day": 20}) + if w.Code != http.StatusOK { + t.Fatalf("patch: %d %s", w.Code, w.Body.String()) + } + var k store.APIKey + e.db.First(&k, created.Data.ID) + if k.QuotaRequestsPerDay == nil || *k.QuotaRequestsPerDay != 20 { + t.Fatalf("配额应改为 20,实际 %+v", k.QuotaRequestsPerDay) + } + + // 删除:状态置 revoked(或删行),列表不再返回 + w = e.do(http.MethodDelete, "/keys/"+strconv.FormatUint(created.Data.ID, 10), nil) + if w.Code != http.StatusOK { + t.Fatalf("delete: %d %s", w.Code, w.Body.String()) + } + w = e.do(http.MethodGet, "/keys", nil) + if bytes.Contains(w.Body.Bytes(), []byte(`"dev"`)) { + t.Fatal("删除后列表不应再返回该密钥") + } +} + +// 用户隔离:A 的密钥 B 看不见也改不了。 +func TestKeysUserIsolation(t *testing.T) { + e := newKeysTestEnv(t) + other := store.User{Username: "u2", Email: "u2@x.com", PasswordHash: "x", + Role: store.RoleUser, Balance: 0, Status: store.UserStatusActive} + e.db.Create(&other) + var k store.APIKey + k = store.APIKey{UserID: other.ID, Name: "theirs", KeyHash: "h2", KeyPrefix: "pre", + Status: store.KeyStatusActive} + e.db.Create(&k) + + // 当前用户(u1)patch 别人的密钥:404(按 user_id 过滤,不泄漏存在性) + w := e.do(http.MethodPatch, "/keys/"+strconv.FormatUint(k.ID, 10), map[string]any{"name": "hijack"}) + if w.Code != http.StatusNotFound { + t.Fatalf("跨用户 patch 应 404,实际 %d %s", w.Code, w.Body.String()) + } + w = e.do(http.MethodDelete, "/keys/"+strconv.FormatUint(k.ID, 10), nil) + if w.Code != http.StatusNotFound { + t.Fatalf("跨用户 delete 应 404,实际 %d %s", w.Code, w.Body.String()) + } +}