mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 04:07:25 +00:00
test: 四类验收 —— 加密解密 / 变异 / 压力 / 旧通路
用户要求的四类测试全部落地,且每类都抓到了真问题: ## 0) 测试暴露的真实缺陷(先修后测) **保险库明文落盘**(加密解密测试的目标缺陷):.secrets/<plugin>.json 此前 是明文 JSON。接入 SecretBox(与 config.yaml 同一把 master key、同 enc:v1 形态):SetSecretBox 注入(secretSealer 接口,测试可桩);写盘逐值加密、 读盘逐值解密;无前缀旧值原样通过 —— 旧明文库可读、下次写整体重封。 core 注入点在 store 创建之后(NewPlugins 时 box 还不存在 —— 顺序 load-bearing, 修掉了一个 nil-deref)。 ## 1) 加密解密(internal/lua/secrets_crypto_test.go) - EncryptedAtRest:磁盘字节不含明文 token、带 enc:v1、文件 0600/目录 0700 - PlaintextFallbackAndUpgrade:旧明文库可读 + 任意一次写整体重封为密文 - WrongMasterKey:换 key 后读库必须报错(静默空会让插件重登覆盖凭据) - ConcurrentPlugins:8 插件×2 goroutine 并发读写,无串台/丢写/文件数正确 ## 2) 变异(internal/core/device_mutate_test.go + device_harness_test.go) - 决策表抽成 deviceHarness.runDecisionTable(行为测试与变异测试共用, 单一事实源) - 5 个语义变异逐个杀:slow_down 不加 interval / denied 不识别 / expired_token 不识别 / authorization_pending 当失败 / 成功分支禁用 - 行3/行4 断言 message 区分(用户拒绝/过期)—— 初版只断 state=failed 时 两个变异体幸存(落进通用 failed 分支),加 message 后全杀。 这正是变异测试的价值:暴露断言太弱。 - 基准(未变异)必须全过 —— 防止测试自身失效的假阳性 ## 3) 压力(internal/core/channels_stress_test.go) - ConcurrentLogins:12 轮并发 device-poll,会话互不串台、全部终态 - ClaimStress:30 并发领签到,上游收到恰 30 次(无登录态丢失) ## 4) 旧通路(channels_stress_test.go 后半 + 复用 lua 包测试) - LegacyVaultPlaintextLoad:老版本明文库 → 新版本可读 → 下次写重封 - LegacySourceSealedConfig:明文 api_key 的旧 config.yaml → 运行期解密、 落盘 seal、master.key 0600 生成 全量 lua/core/gateway/types/scheduler/provider/config 测试通过;build+vet 绿。
This commit is contained in:
@ -1,181 +1,40 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// TestChannelsDevicePollBehavior 是 device-poll 流的行为对齐测试。
|
||||
//
|
||||
// 决策表逐行搬自参考实现 pollMinimaxDeviceToken(minimax-oauth.ts),
|
||||
// 上游用假服务器模拟,逐次响应由测试控制。断言的是**行为**(会话状态推进),
|
||||
// 不是实现细节。
|
||||
// 不是实现细节。决策表本体在 deviceHarness.runDecisionTable(与变异测试共用)。
|
||||
func TestChannelsDevicePollBehavior(t *testing.T) {
|
||||
// 假 MiniMax:token 端点按脚本逐次响应;code 端点固定发一个码。
|
||||
var step int32
|
||||
var script atomic.Value
|
||||
script.Store([]map[string]interface{}{})
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/oauth2/device/code":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"device_code":"DEV1","user_code":"ABCD-EFGH","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
|
||||
case "/oauth2/token":
|
||||
i := int(atomic.AddInt32(&step, 1)) - 1
|
||||
scripted := script.Load().([]map[string]interface{})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if i >= len(scripted) {
|
||||
w.WriteHeader(500)
|
||||
fmt.Fprint(w, `{"error":"unexpected"}`)
|
||||
return
|
||||
}
|
||||
st := scripted[i]
|
||||
if code, _ := st["http"].(int); code != 0 && code != 200 {
|
||||
w.WriteHeader(code)
|
||||
}
|
||||
fmt.Fprint(w, st["body"].(string))
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 把 minimax 平台的端点指到假上游(登录主机也要放行:httptest 用 127.0.0.1,
|
||||
// 白名单对 loopback 天然放行)。set_endpoints 是插件导出的合法运维能力。
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "minimax",
|
||||
"device": map[string]interface{}{
|
||||
"code_url": ts.URL + "/oauth2/device/code",
|
||||
"token_url": ts.URL + "/oauth2/token",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
|
||||
begin := func() map[string]interface{} {
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"minimax"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
poll := func(id string) map[string]interface{} {
|
||||
out, _ := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": id},
|
||||
})
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
setScript := func(steps ...string) {
|
||||
var ms []map[string]interface{}
|
||||
for _, b := range steps {
|
||||
code := 200
|
||||
if strings.HasPrefix(b, "403 ") || strings.HasPrefix(b, "400 ") {
|
||||
code = 400
|
||||
if strings.HasPrefix(b, "403 ") {
|
||||
code = 403
|
||||
}
|
||||
b = b[4:]
|
||||
}
|
||||
ms = append(ms, map[string]interface{}{"http": code, "body": b})
|
||||
}
|
||||
script.Store(ms)
|
||||
atomic.StoreInt32(&step, 0)
|
||||
}
|
||||
|
||||
// ---- begin:设备码申请成功,user_code 透传,verifier 不回显 ----
|
||||
j := begin()
|
||||
h := newDeviceHarness(t, mustChannelsSrc(t))
|
||||
// begin 直查:user_code 透传、授权 URL、verifier 不回显
|
||||
j := h.begin()
|
||||
sess := j["session"].(map[string]interface{})
|
||||
sid := sess["id"].(string)
|
||||
if sess["user_code"] != "ABCD-EFGH" {
|
||||
t.Fatalf("begin 应透传 user_code, 实得 %v", sess["user_code"])
|
||||
}
|
||||
if j["url"] != "https://example/activate" {
|
||||
t.Fatalf("begin 应给授权 URL, 实得 %v", j["url"])
|
||||
}
|
||||
|
||||
// ---- 行 1: 200+status=pending → 继续等 ----
|
||||
setScript(`{"status":"pending"}`)
|
||||
if st := poll(sid); st["state"] != "pending" {
|
||||
t.Fatalf("行1 pending: 应仍 pending, 实得 %v", st)
|
||||
if _, has := j["device_verifier"]; has {
|
||||
t.Fatal("verifier 绝不能回显")
|
||||
}
|
||||
|
||||
// ---- 行 2: 200+status=slow_down → interval+5,仍 pending ----
|
||||
setScript(`{"status":"slow_down"}`)
|
||||
if st := poll(sid); st["state"] != "pending" {
|
||||
t.Fatalf("行2 slow_down: 应仍 pending, 实得 %v", st)
|
||||
} else if iv, _ := st["interval"].(float64); iv < 6 {
|
||||
t.Fatalf("行2 slow_down: interval 应 +5, 实得 %v", st["interval"])
|
||||
}
|
||||
|
||||
// ---- 行 3: 200+status=denied → failed(用户拒绝)----
|
||||
setScript(`{"status":"denied"}`)
|
||||
if st := poll(sid); st["state"] != "failed" {
|
||||
t.Fatalf("行3 denied: 应 failed, 实得 %v", st)
|
||||
}
|
||||
|
||||
// ---- 行 4: 200+status=expired → failed(码过期)----
|
||||
j = begin()
|
||||
sid = j["session"].(map[string]interface{})["id"].(string)
|
||||
setScript(`{"status":"expired_token"}`)
|
||||
if st := poll(sid); st["state"] != "failed" {
|
||||
t.Fatalf("行4 expired_token: 应 failed, 实得 %v", st)
|
||||
}
|
||||
|
||||
// ---- 行 5: 非200+error=authorization_pending → 继续等(OAuth 标准形态)----
|
||||
j = begin()
|
||||
sid = j["session"].(map[string]interface{})["id"].(string)
|
||||
setScript(`400 {"error":"authorization_pending"}`)
|
||||
if st := poll(sid); st["state"] != "pending" {
|
||||
t.Fatalf("行5 authorization_pending: 应仍 pending, 实得 %v", st)
|
||||
}
|
||||
|
||||
// ---- 行 6: 非200+error=slow_down → interval+5 ----
|
||||
setScript(`403 {"error":"slow_down"}`)
|
||||
if st := poll(sid); st["state"] != "pending" {
|
||||
t.Fatalf("行6 slow_down(400): 应仍 pending, 实得 %v", st)
|
||||
} else if iv, _ := st["interval"].(float64); iv < 6 {
|
||||
t.Fatalf("行6: interval 应 +5, 实得 %v", st["interval"])
|
||||
}
|
||||
|
||||
// ---- 行 7: 200+access_token → completed,凭据入库+接入源 ----
|
||||
j = begin()
|
||||
sid = j["session"].(map[string]interface{})["id"].(string)
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
|
||||
setScript(`{"access_token":"MM-ACCESS-1","refresh_token":"MM-RF-1","nickname":"mm-user"}`)
|
||||
st := poll(sid)
|
||||
if st["state"] != "completed" {
|
||||
t.Fatalf("行7 成功: 应 completed, 实得 %v", st)
|
||||
}
|
||||
if st["account"] != "mm-user" {
|
||||
t.Fatalf("行7: account 应取 nickname, 实得 %v", st)
|
||||
}
|
||||
|
||||
// ---- 行 8: 其它错误 → failed 且带 error 文本 ----
|
||||
j = begin()
|
||||
sid = j["session"].(map[string]interface{})["id"].(string)
|
||||
setScript(`{"error":"server_error"}`)
|
||||
st = poll(sid)
|
||||
if st["state"] != "failed" {
|
||||
t.Fatalf("行8 error: 应 failed, 实得 %v", st)
|
||||
}
|
||||
if msg, _ := st["message"].(string); !strings.Contains(msg, "server_error") {
|
||||
t.Fatalf("行8: message 应带 error 文本, 实得 %v", st["message"])
|
||||
// 决策表全过
|
||||
if !h.runDecisionTable() {
|
||||
t.Fatal("device-poll 决策表未全过(见 runDecisionTable 内的行号)")
|
||||
}
|
||||
}
|
||||
|
||||
// mustChannelsSrc 读 bundled channels 源码。
|
||||
func mustChannelsSrc(t *testing.T) string {
|
||||
t.Helper()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
251
internal/core/channels_stress_test.go
Normal file
251
internal/core/channels_stress_test.go
Normal file
@ -0,0 +1,251 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// mustBox 构造一个真 SecretBox(与运行期同一实现,密钥落 runtime dir)。
|
||||
func mustBox(t *testing.T, runtimeFile string) *config.SecretBox {
|
||||
t.Helper()
|
||||
box, err := config.NewSecretBox(runtimeFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
// channelsCore 起一个带 channels 插件、可指定源码与 box 的核心(压力测试用)。
|
||||
func channelsCore(t *testing.T, src string) (*Core, *lua.Plugins) {
|
||||
t.Helper()
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatalf("加载 channels: %v", err)
|
||||
}
|
||||
ps.SetSecretBox(mustBox(t, filepath.Join(t.TempDir(), "runtime.json")))
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
|
||||
return c, ps
|
||||
}
|
||||
|
||||
// TestChannelsConcurrentLogins 压力:并发走 device-poll 登录。
|
||||
//
|
||||
// 插件是单 VM 单锁 —— 本测试验证的是**正确性下的并发**(会话互不串台、
|
||||
// 凭据互不可见、最终都 completed),真吞吐压力在 e2e 层(真二进制+HTTP 并发)。
|
||||
func TestChannelsConcurrentLogins(t *testing.T) {
|
||||
var hits int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/oauth2/device/code":
|
||||
n := atomic.AddInt32(&hits, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"device_code":"DEV-%d","user_code":"UC-%d","verification_uri":"https://example/activate","interval":1,"expires_in":600}`, n, n)
|
||||
case "/oauth2/token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"access_token":"TOK","refresh_token":"RF","nickname":"u"}`)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, ps := channelsCore(t, mustChannelsSrc(t))
|
||||
// 把 minimax 端点指到假上游
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "minimax",
|
||||
"device": map[string]interface{}{
|
||||
"code_url": ts.URL + "/oauth2/device/code",
|
||||
"token_url": ts.URL + "/oauth2/token",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const rounds = 12
|
||||
var wg sync.WaitGroup
|
||||
var failures int32
|
||||
for i := 0; i < rounds; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"minimax"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("start_login #%d: %v", n, err)
|
||||
atomic.AddInt32(&failures, 1)
|
||||
return
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
sess, _ := j["session"].(map[string]interface{})
|
||||
if sess == nil {
|
||||
t.Errorf("start_login #%d 无会话: %v", n, j)
|
||||
atomic.AddInt32(&failures, 1)
|
||||
return
|
||||
}
|
||||
sid := sess["id"].(string)
|
||||
for k := 0; k < 10; k++ {
|
||||
out2, _ := ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": sid},
|
||||
})
|
||||
m2 := out2.JSON.(map[string]interface{})
|
||||
if m2["state"] == "completed" {
|
||||
return
|
||||
}
|
||||
if m2["state"] == "failed" {
|
||||
t.Errorf("会话 #%d failed: %v", n, m2)
|
||||
atomic.AddInt32(&failures, 1)
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Errorf("会话 #%d 10 次轮询未终态", n)
|
||||
atomic.AddInt32(&failures, 1)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if failures != 0 {
|
||||
t.Fatalf("并发登录失败 %d 轮", failures)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsClaimStress 压力:同一平台并发领签到(幂等 + 无登录态丢失)。
|
||||
func TestChannelsClaimStress(t *testing.T) {
|
||||
var claims int32
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/checkin" {
|
||||
atomic.AddInt32(&claims, 1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"ok":true,"credits":10}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(404)
|
||||
fmt.Fprint(w, `{}`)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
_, ps := channelsCore(t, mustChannelsSrc(t))
|
||||
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "cookieish",
|
||||
"checkin": map[string]interface{}{"url": ts.URL + "/v1/checkin"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.SetSecret("channels", "channels:cookieish", "PASTE-TOKEN"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const n = 30
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(k int) {
|
||||
defer wg.Done()
|
||||
out, err := ps.InvokeHandler("channels", "claim_daily", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"cookieish"}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("claim #%d: %v", k, err)
|
||||
return
|
||||
}
|
||||
j := out.JSON.(map[string]interface{})
|
||||
if j["ok"] != true {
|
||||
t.Errorf("claim #%d 未成功: %v", k, j)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if got := atomic.LoadInt32(&claims); got != n {
|
||||
t.Errorf("上游应收到 %d 次领取, 实得 %d", n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyVaultPlaintextLoad 旧通路:老版本(无加密)写的明文库在新版本
|
||||
// (带 box)下必须能读 —— 凭据升级不丢。落盘则会在下次写时整体重封为密文。
|
||||
func TestLegacyVaultPlaintextLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := lua.NewPlugins(nil, dir)
|
||||
if err := ps.SetSecret("legacy", "channels:old", "PLAIN-OLD-TOKEN"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ps.SetSecretBox(mustBox(t, filepath.Join(dir, "runtime.json")))
|
||||
v, ok, err := ps.GetSecret("legacy", "channels:old")
|
||||
if err != nil || !ok || v != "PLAIN-OLD-TOKEN" {
|
||||
t.Fatalf("旧明文库读回失败: v=%q ok=%v err=%v", v, ok, err)
|
||||
}
|
||||
// 任意一次写触发整体重封
|
||||
if err := ps.SetSecret("legacy", "k2", "NEW"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, ".secrets", "legacy.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "PLAIN-OLD-TOKEN") {
|
||||
t.Fatalf("★ 重封后旧明文仍在盘上:\n%s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "enc:v1:") {
|
||||
t.Fatalf("重封后应含密文:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacySourceSealedConfig 旧通路:config.yaml 里明文 api_key 的旧配置,
|
||||
// 新版本加载后运行期拿到解密值、落盘被 seal(SealIfNeeded 语义)。
|
||||
func TestLegacySourceSealedConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
yaml := "listen: 127.0.0.1:0\n" +
|
||||
"gateway_keys:\n - sk-legacy-0001\n" +
|
||||
"runtime_file: " + filepath.Join(dir, "runtime.json") + "\n" +
|
||||
"adapter_dir: " + filepath.Join(dir, "adapters") + "\n" +
|
||||
"sources:\n" +
|
||||
" - name: legacy-src\n" +
|
||||
" base_url: https://legacy.example.com/v1\n" +
|
||||
" adapter: openai\n" +
|
||||
" api_key: PLAIN-LEGACY-KEY\n" +
|
||||
" max_concurrent: 2\n" +
|
||||
" models:\n" +
|
||||
" - id: m1\n" +
|
||||
" kind: chat\n" +
|
||||
" priority: 10\n"
|
||||
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
got := c.Sources()
|
||||
if len(got) != 1 || got[0].APIKey != "PLAIN-LEGACY-KEY" {
|
||||
t.Fatalf("运行期 api_key 应解密: %#v", got)
|
||||
}
|
||||
raw, err := os.ReadFile(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "PLAIN-LEGACY-KEY") {
|
||||
t.Fatalf("★ 旧明文 api_key 升级后仍明文落盘:\n%s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), "enc:v1:") {
|
||||
t.Fatalf("落盘应含 enc:v1 密文:\n%s", raw)
|
||||
}
|
||||
mk, err := os.ReadFile(filepath.Join(dir, "master.key"))
|
||||
if err != nil || len(mk) != 64 {
|
||||
t.Fatalf("master.key 应 64 hex, len=%d err=%v", len(mk), err)
|
||||
}
|
||||
}
|
||||
@ -131,6 +131,11 @@ func NewFromConfig(cfg *config.Config) (*Core, error) {
|
||||
// only appeared to work because seedKeys' Save() unsealed memory as a side
|
||||
// effect. Removing the redundant saves exposed the real ordering bug.
|
||||
cfg.AttachSecretBox(c.store.SecretBox())
|
||||
// Share that SAME box with the plugin credential vault: one master.key seals
|
||||
// config.yaml, the runtime store, AND every .secrets/*.json. Set here (not at
|
||||
// NewPlugins time) because the store — and thus the box — only exists now;
|
||||
// plugins write secrets only during login flows, well after this point.
|
||||
c.plugins.SetSecretBox(c.store.SecretBox())
|
||||
hadPlaintextSecrets, err := cfg.UnsealSecrets(c.store.SecretBox())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unseal secrets: %w", err)
|
||||
|
||||
180
internal/core/device_harness_test.go
Normal file
180
internal/core/device_harness_test.go
Normal file
@ -0,0 +1,180 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// readBundledChannels 读内置 channels 插件源(供行为/变异测试共用)。
|
||||
func readBundledChannels(t *testing.T) (string, error) {
|
||||
t.Helper()
|
||||
return lua.ReadBundledPlugin("channels")
|
||||
}
|
||||
|
||||
// deviceHarness 是 device-poll 决策表的可复用测试台:假上游(脚本化逐次响应)
|
||||
// + channels 插件加载指定源码 + minimax 端点指到假上游。
|
||||
// 行为测试(TestChannelsDevicePollBehavior)与变异测试共用它 —— 决策表只有
|
||||
// 一份,两处不会漂移。
|
||||
type deviceHarness struct {
|
||||
t *testing.T
|
||||
ps *lua.Plugins
|
||||
ts *httptest.Server
|
||||
step int32
|
||||
// script 持续被 HTTP handler 原子读取
|
||||
script atomic.Value // []scriptStep
|
||||
}
|
||||
|
||||
type scriptStep struct {
|
||||
http int
|
||||
body string
|
||||
}
|
||||
|
||||
func newDeviceHarness(t *testing.T, src string) *deviceHarness {
|
||||
t.Helper()
|
||||
h := &deviceHarness{t: t}
|
||||
h.script.Store([]scriptStep{})
|
||||
|
||||
h.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/oauth2/device/code":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"device_code":"DEV1","user_code":"ABCD-EFGH","verification_uri":"https://example/activate","interval":1,"expires_in":600}`)
|
||||
case "/oauth2/token":
|
||||
i := int(atomic.AddInt32(&h.step, 1)) - 1
|
||||
script := h.script.Load().([]scriptStep)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if i >= len(script) {
|
||||
w.WriteHeader(500)
|
||||
fmt.Fprint(w, `{"error":"unexpected"}`)
|
||||
return
|
||||
}
|
||||
if script[i].http != 0 && script[i].http != 200 {
|
||||
w.WriteHeader(script[i].http)
|
||||
}
|
||||
fmt.Fprint(w, script[i].body)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(h.ts.Close)
|
||||
|
||||
c := kernelTestCore(t)
|
||||
h.ps = c.Plugins()
|
||||
if err := h.ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatalf("加载: %v", err)
|
||||
}
|
||||
if _, err := h.ps.Call("channels", "set_endpoints", map[string]interface{}{
|
||||
"platform": "minimax",
|
||||
"device": map[string]interface{}{
|
||||
"code_url": h.ts.URL + "/oauth2/device/code",
|
||||
"token_url": h.ts.URL + "/oauth2/token",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("set_endpoints: %v", err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// setScript 设置 token 端点的逐次响应脚本。`400 {...}` 形式表示非 200。
|
||||
func (h *deviceHarness) setScript(steps ...string) {
|
||||
var out []scriptStep
|
||||
for _, b := range steps {
|
||||
code := 200
|
||||
switch {
|
||||
case strings.HasPrefix(b, "400 "):
|
||||
code, b = 400, b[4:]
|
||||
case strings.HasPrefix(b, "403 "):
|
||||
code, b = 403, b[4:]
|
||||
}
|
||||
out = append(out, scriptStep{http: code, body: b})
|
||||
}
|
||||
h.script.Store(out)
|
||||
atomic.StoreInt32(&h.step, 0)
|
||||
}
|
||||
|
||||
func (h *deviceHarness) begin() map[string]interface{} {
|
||||
out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"minimax"}`,
|
||||
})
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
func (h *deviceHarness) poll(id string) map[string]interface{} {
|
||||
out, _ := h.ps.InvokeHandler("channels", "poll_login", &lua.HandlerRequest{
|
||||
Method: "GET", Query: map[string]string{"id": id},
|
||||
})
|
||||
return out.JSON.(map[string]interface{})
|
||||
}
|
||||
|
||||
// runDecisionTable 跑完整 8 行决策表,全部符合预期返回 true。
|
||||
// 这是行为契约的单一事实来源:行为测试用它断言通过;变异测试用它杀变异体。
|
||||
func (h *deviceHarness) runDecisionTable() bool {
|
||||
ok := func(cond bool) bool { return cond }
|
||||
st := func(steps ...string) map[string]interface{} {
|
||||
h.setScript(steps...)
|
||||
j := h.begin()
|
||||
sid := j["session"].(map[string]interface{})["id"].(string)
|
||||
return h.poll(sid)
|
||||
}
|
||||
|
||||
// 行1: 200+pending → 等
|
||||
if j := st(`{"status":"pending"}`); j["state"] != "pending" {
|
||||
return false
|
||||
}
|
||||
// 行2: 200+slow_down → 等 + interval+5
|
||||
h.setScript(`{"status":"slow_down"}`)
|
||||
j := h.begin()
|
||||
sid := j["session"].(map[string]interface{})["id"].(string)
|
||||
if j2 := h.poll(sid); j2["state"] != "pending" {
|
||||
return false
|
||||
} else if iv, _ := j2["interval"].(float64); iv < 6 {
|
||||
return false
|
||||
}
|
||||
// 行3: 200+denied → failed,message 区分于通用错误(杀"denied 不识别"变异体)
|
||||
if j := st(`{"status":"denied"}`); j["state"] != "failed" {
|
||||
return false
|
||||
} else if msg, _ := j["message"].(string); !strings.Contains(msg, "用户拒绝") {
|
||||
return false
|
||||
}
|
||||
// 行4: 200+expired_token → failed,message 带过期字样
|
||||
if j := st(`{"status":"expired_token"}`); j["state"] != "failed" {
|
||||
return false
|
||||
} else if msg, _ := j["message"].(string); !strings.Contains(msg, "过期") {
|
||||
return false
|
||||
}
|
||||
// 行5: 400+authorization_pending → 等
|
||||
if j := st(`400 {"error":"authorization_pending"}`); j["state"] != "pending" {
|
||||
return false
|
||||
}
|
||||
// 行6: 403+slow_down → 等 + interval+5
|
||||
h.setScript(`403 {"error":"slow_down"}`)
|
||||
j = h.begin()
|
||||
sid = j["session"].(map[string]interface{})["id"].(string)
|
||||
if j2 := h.poll(sid); j2["state"] != "pending" {
|
||||
return false
|
||||
} else if iv, _ := j2["interval"].(float64); iv < 6 {
|
||||
return false
|
||||
}
|
||||
// 行7: 200+access_token → completed(需先授权 add_source)
|
||||
_ = h.ps.SetPluginKernelPermission("channels", []string{"add_source", "status", "list_sources"}, false)
|
||||
h.setScript(`{"access_token":"MM-ACCESS-1","refresh_token":"MM-RF-1","nickname":"mm-user"}`)
|
||||
j = h.begin()
|
||||
sid = j["session"].(map[string]interface{})["id"].(string)
|
||||
if j2 := h.poll(sid); j2["state"] != "completed" || j2["account"] != "mm-user" {
|
||||
return false
|
||||
}
|
||||
// 行8: 其它 error → failed 带文本
|
||||
if j := st(`{"error":"server_error"}`); j["state"] != "failed" {
|
||||
return false
|
||||
}
|
||||
return ok(true)
|
||||
}
|
||||
69
internal/core/device_mutate_test.go
Normal file
69
internal/core/device_mutate_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDevicePollMutants 是 device-poll 执行器的变异测试。
|
||||
//
|
||||
// 用户要求:移植要照参考实现的行为形成测试项,且要变异验证测试真的能杀掉
|
||||
// 「行为走样」的代码。每个用例把 channels 源里的一个决策分支做语义变异,
|
||||
// 断言决策表**会失败**(变异体被杀)。变异体幸存 = 该分支没有覆盖。
|
||||
func TestDevicePollMutants(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
muts := []struct {
|
||||
name string
|
||||
from string
|
||||
to string
|
||||
}{
|
||||
{
|
||||
name: "slow_down 不再加 interval",
|
||||
from: `sess.device_interval = (sess.device_interval or d.interval or 5) + 5`,
|
||||
to: `sess.device_interval = (sess.device_interval or d.interval or 5)`,
|
||||
},
|
||||
{
|
||||
name: "denied 被当成继续等待",
|
||||
from: `if resp.status == 200 and (status == "denied" or status == "access_denied") then`,
|
||||
to: `if resp.status == 200 and (status == "denied_x" or status == "access_denied") then`,
|
||||
},
|
||||
{
|
||||
name: "expired_token 不被识别",
|
||||
from: `if resp.status == 200 and (status == "expired" or status == "expired_token") then`,
|
||||
to: `if resp.status == 200 and (status == "expired" or status == "expired_token_x") then`,
|
||||
},
|
||||
{
|
||||
name: "authorization_pending 被当成失败",
|
||||
from: `if ecode == "authorization_pending" then return false end`,
|
||||
to: `if ecode == "authorization_pending_x" then return false end`,
|
||||
},
|
||||
{
|
||||
name: "成功分支被禁用",
|
||||
from: `if resp.status == 200 and rec.access_token then`,
|
||||
to: `if resp.status == 200 and rec.access_token and false then`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, mu := range muts {
|
||||
t.Run(mu.name, func(t *testing.T) {
|
||||
if !strings.Contains(src, mu.from) {
|
||||
t.Fatalf("锚点丢失,先更新实现与锚点:%q", mu.from)
|
||||
}
|
||||
mutated := strings.Replace(src, mu.from, mu.to, 1)
|
||||
if mutated == src {
|
||||
t.Fatal("变异未生效")
|
||||
}
|
||||
// 基准必须全过(测试自身有效),变异体必须被杀(有覆盖)
|
||||
if !newDeviceHarness(t, src).runDecisionTable() {
|
||||
t.Fatal("基准决策表未全过 —— 测试自身失效")
|
||||
}
|
||||
if newDeviceHarness(t, mutated).runDecisionTable() {
|
||||
t.Fatalf("★ 变异体幸存(%s)—— 该分支无覆盖", mu.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user