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