覆盖完整生命周期(明文一次性返回、列表不泄漏明文、改配额、删除) 与用户隔离(跨用户 patch/delete 返回 404 不泄漏存在性)。 SessionAuth 以桩替换,专注 handler 语义。
146 lines
4.5 KiB
Go
146 lines
4.5 KiB
Go
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())
|
|
}
|
|
}
|