test: keys CRUD 的 HTTP 级测试

覆盖完整生命周期(明文一次性返回、列表不泄漏明文、改配额、删除)
与用户隔离(跨用户 patch/delete 返回 404 不泄漏存在性)。
SessionAuth 以桩替换,专注 handler 语义。
This commit is contained in:
Sakurasan committed 2026-10-30 01:42:23 +08:00
1 parent 99829ef745
commit 7dce4cadde
1 file changed
+145
+145
View File
@@ -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())
}
}