Files
openteam/server/internal/api/keys_test.go
T
Sakurasan b2e500ff51 feat: 金额 decimal 精确计费、failover 按失败类型封顶、渠道小时桶预聚合、分层守护测试
- 计费:19 个金额字段 float64→decimal.Decimal,DB 列保持 numeric(20,8) 零迁移;
  API 金额仍为 JSON number(MarshalJSONWithoutQuotes),前端零改动
- failover:保留时间预算,新增 OT_PROXY_FAILOVER_CAPS_NET/SERVER/RATE/QUOTA
  按失败类型封顶换渠道次数(429/配额类瞬时失败时间预算形同虚设)
- 预聚合:新增 usage_channel_hourlies(schema v10),记账事务内同步 upsert,
  长窗口渠道统计走桶不再扫明细,spent_last_30d 改扫 usage_daily,v10 一次性回填幂等
- 守护:internal/arch_test.go 用 go/parser 强制分层依赖(fail-closed)
2026-11-09 00:41:48 +08:00

147 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"
"github.com/shopspring/decimal"
"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: decimal.NewFromInt(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: decimal.Zero, 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())
}
}