让 /admin/ops 错误详情正确归因 API key 并补全早退场景字段,合并三项改动: - 鉴权早退补全用户/分组/平台字段:引入 ops fallback key(ContextKeyOpsFallbackAPIKey), apiKey 一加载成功即写入,覆盖分组停用/删除、Key 停用/过期/额度、用户停用、IP 限制等早退 路径;ops 错误日志改用 getOpsAPIKey(正式 key 优先、回退键兜底),不改「已鉴权」语义。 - 已删除 key 归因(迁移 145):删除 key 时同一事务写 deleted_api_key_audits 映射,认证失败 时用明文反查命中原所有者,错误详情展示「已删除 Key 所有者」「尝试的 Key 前缀」。 - 有效 key 报错快照前缀(迁移 147):对绑定有效 key 的错误,落库时快照明文前 8 位到 api_key_prefix(与 attempted_key_prefix 互斥),key 之后被删仍保留报错当时真实前缀。 均仅对上线后新产生的错误/删除生效。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
3.1 KiB
Go
119 lines
3.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func TestLooksLikeSystemKey(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want bool
|
|
}{
|
|
{"sk-abcdef0123456789", true},
|
|
{"ABCdef_-0123456789", true},
|
|
{"short", false},
|
|
{"with space xxxxxxxxxx", false},
|
|
{"汉字key1234567890", false},
|
|
{"", false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := looksLikeSystemKey(c.in); got != c.want {
|
|
t.Errorf("looksLikeSystemKey(%q)=%v want %v", c.in, got, c.want)
|
|
}
|
|
}
|
|
long := make([]byte, 129)
|
|
for i := range long {
|
|
long[i] = 'a'
|
|
}
|
|
if looksLikeSystemKey(string(long)) {
|
|
t.Errorf("129-char key should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestKeyPrefix(t *testing.T) {
|
|
if got := keyPrefix("sk-3f2a9c7e", 8); got != "sk-3f2a9" {
|
|
t.Errorf("keyPrefix=%q want %q", got, "sk-3f2a9")
|
|
}
|
|
if got := keyPrefix("abc", 8); got != "abc" {
|
|
t.Errorf("short key should be returned as-is, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractAttemptedKey(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
cases := []struct {
|
|
name string
|
|
headers map[string]string
|
|
want string
|
|
}{
|
|
{
|
|
name: "Bearer in Authorization",
|
|
headers: map[string]string{"Authorization": "Bearer sk-testkey0123456789"},
|
|
want: "sk-testkey0123456789",
|
|
},
|
|
{
|
|
name: "Bearer case-insensitive",
|
|
headers: map[string]string{"Authorization": "BEARER sk-testkey0123456789"},
|
|
want: "sk-testkey0123456789",
|
|
},
|
|
{
|
|
name: "x-api-key header",
|
|
headers: map[string]string{"x-api-key": "sk-xapikey0123456789"},
|
|
want: "sk-xapikey0123456789",
|
|
},
|
|
{
|
|
name: "x-goog-api-key header",
|
|
headers: map[string]string{"x-goog-api-key": "sk-goog0123456789"},
|
|
want: "sk-goog0123456789",
|
|
},
|
|
{
|
|
name: "Authorization takes priority over x-api-key",
|
|
headers: map[string]string{"Authorization": "Bearer sk-auth0123456789", "x-api-key": "sk-xapi0123456789"},
|
|
want: "sk-auth0123456789",
|
|
},
|
|
{
|
|
name: "x-api-key takes priority over x-goog-api-key",
|
|
headers: map[string]string{"x-api-key": "sk-xapi0123456789", "x-goog-api-key": "sk-goog0123456789"},
|
|
want: "sk-xapi0123456789",
|
|
},
|
|
{
|
|
name: "no key headers",
|
|
headers: map[string]string{},
|
|
want: "",
|
|
},
|
|
{
|
|
name: "Bearer with leading/trailing spaces trimmed",
|
|
headers: map[string]string{"Authorization": "Bearer sk-trimmed0123456789 "},
|
|
want: "sk-trimmed0123456789",
|
|
},
|
|
{
|
|
// 非 Bearer Authorization 应被忽略,继续 fall-through 到 x-api-key(与认证中间件一致)
|
|
name: "non-Bearer Authorization falls through to x-api-key",
|
|
headers: map[string]string{"Authorization": "junk-not-bearer", "x-api-key": "sk-realkey1234567"},
|
|
want: "sk-realkey1234567",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
|
for k, v := range tc.headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
c.Request = req
|
|
|
|
got := extractAttemptedKey(c)
|
|
if got != tc.want {
|
|
t.Errorf("extractAttemptedKey(%v) = %q, want %q", tc.headers, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|