Files
heicode-mananger/heicode/controller/swarm_model_key_test.go
T
chenchenandClaude Opus 4.8 c494e9576e feat(swarm): #60 模型 key 注入(方案A)+ pool_terminated 吊销握手
对接 agent_swarm PR#43 定死的参数,实现 HM 侧的 per-user 模型 key 注入:

- A.1 粒度/命名:为认证用户 mint 一把 per-user sk-(getOrMintSwarmModelToken),
  跨该用户所有 swarm run 复用;KV 密文名 swarm-model-key-<user_id>。
- A.2 KV value:JSON {"openai_api_key":"sk-..."},对齐 Swarm
  orchestrator/agent_launcher._extract_model_key 解析字段。
- A.4 OPENAI_API_BASE 为 Swarm 部署常量,HM 不经 billing_context 下发。
- A.5 吊销:事件驱动。注册 swarm.pool_terminated(swarm_lifecycle 类),
  回调 handleSwarmPoolTerminated 删 KV 密文 + 软删 token;user_id 优先取
  payload,缺失回退 deployment 上下文。

createAgentDeploymentFromPlan 在 swarm 模式且 billing_context 未自带 secret_ref
时调 provisionSwarmModelKey,把 azkv:// 引用写进 billing_context.secret_ref 下发。
Key Vault 未配置/不可达时降级:记日志、secret_ref 留空,不阻断 create(联调前可用)。
明文 sk- 仅经 secret_ref 服务端解析,绝不入代码/日志/事件/argv。

测试:swarm_model_key_test.go 覆盖 per-user 复用、吊销重 mint、pool_terminated
回调(payload/上下文两路 user_id)、非目标事件不误吊销、事件已注册。

影响面:agent_swarm(契约消费侧)、密钥(per-user sk- mint+吊销)、计费
(走 User.Quota)、审计(回调入库+审计事件)。不影响 Client/CodeGW/发布链路。
未决依赖:A.3(Swarm 读 heicode-kv 的 RBAC,运维授权 pending)是端到端联调前置,
不阻塞本 PR。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:30:32 +08:00

140 lines
4.6 KiB
Go

package controller
import (
"fmt"
"strings"
"testing"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
func setupSwarmModelKeyTestDB(t *testing.T) {
t.Helper()
common.UsingSQLite = true
common.UsingMySQL = false
common.UsingPostgreSQL = false
common.RedisEnabled = false
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
model.DB = db
require.NoError(t, db.AutoMigrate(&model.Token{}))
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
model.DB = nil
})
}
func TestSwarmModelKeyNames(t *testing.T) {
require.Equal(t, "swarm:user:42", swarmModelKeyTokenName(42))
require.Equal(t, "swarm-model-key-42", swarmModelKeySecretName(42))
}
func TestGetOrMintSwarmModelTokenReusesPerUser(t *testing.T) {
setupSwarmModelKeyTestDB(t)
// A.1: first call mints, second call reuses the SAME sk- across runs.
first, err := getOrMintSwarmModelToken(7001)
require.NoError(t, err)
require.True(t, strings.HasPrefix(first, "sk-"))
second, err := getOrMintSwarmModelToken(7001)
require.NoError(t, err)
require.Equal(t, first, second, "per-user swarm key must be reused across runs")
// only one token persisted for the user, and it is hidden + system-managed.
var toks []model.Token
require.NoError(t, model.DB.Where("user_id = ?", 7001).Find(&toks).Error)
require.Len(t, toks, 1)
require.Equal(t, swarmModelKeyTokenName(7001), toks[0].Name)
require.True(t, toks[0].HideFromUserUI)
require.True(t, toks[0].UnlimitedQuota)
require.EqualValues(t, -1, toks[0].ExpiredTime)
// distinct users get distinct keys.
other, err := getOrMintSwarmModelToken(7002)
require.NoError(t, err)
require.NotEqual(t, first, other)
// invalid user rejected.
_, err = getOrMintSwarmModelToken(0)
require.Error(t, err)
}
func TestRevokeSwarmModelKeyReMintsFresh(t *testing.T) {
setupSwarmModelKeyTestDB(t)
first, err := getOrMintSwarmModelToken(7003)
require.NoError(t, err)
// revoke soft-deletes the token (KV not configured -> delete logged, non-fatal).
revokeSwarmModelKey(7003)
var live []model.Token
require.NoError(t, model.DB.Where("user_id = ?", 7003).Find(&live).Error)
require.Len(t, live, 0, "revoked swarm token must be soft-deleted")
// next provision mints a brand-new key (A.5: stop -> revoke -> new key on re-run).
second, err := getOrMintSwarmModelToken(7003)
require.NoError(t, err)
require.NotEqual(t, first, second)
}
func TestHandleSwarmPoolTerminatedRevokesByPayloadUserID(t *testing.T) {
setupSwarmModelKeyTestDB(t)
_, err := getOrMintSwarmModelToken(7004)
require.NoError(t, err)
payload := agentCallbackEnvelope{
EventType: "swarm.pool_terminated",
Payload: map[string]any{"user_id": "7004", "secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/swarm-model-key-7004"},
}
handleSwarmPoolTerminated(payload, agentDeploymentRecord{})
var live []model.Token
require.NoError(t, model.DB.Where("user_id = ?", 7004).Find(&live).Error)
require.Len(t, live, 0)
}
func TestHandleSwarmPoolTerminatedFallsBackToDeploymentUser(t *testing.T) {
setupSwarmModelKeyTestDB(t)
_, err := getOrMintSwarmModelToken(7005)
require.NoError(t, err)
// no user_id in payload -> fall back to deployment user context.
payload := agentCallbackEnvelope{EventType: "swarm.pool_terminated", Payload: map[string]any{}}
record := agentDeploymentRecord{}
record.Plan.UserContext.UserID = "7005"
handleSwarmPoolTerminated(payload, record)
var live []model.Token
require.NoError(t, model.DB.Where("user_id = ?", 7005).Find(&live).Error)
require.Len(t, live, 0)
}
func TestHandleSwarmPoolTerminatedIgnoresOtherEvents(t *testing.T) {
setupSwarmModelKeyTestDB(t)
_, err := getOrMintSwarmModelToken(7006)
require.NoError(t, err)
// wrong event type -> no revocation.
handleSwarmPoolTerminated(agentCallbackEnvelope{EventType: "swarm.stopped", Payload: map[string]any{"user_id": "7006"}}, agentDeploymentRecord{})
var live []model.Token
require.NoError(t, model.DB.Where("user_id = ?", 7006).Find(&live).Error)
require.Len(t, live, 1)
}
func TestSwarmPoolTerminatedEventRegistered(t *testing.T) {
// control-plane lifecycle event must be a known callback type so the schema
// validator accepts it and routes it to the swarm_lifecycle category.
_, hasFields := agentCallbackEventRequiredFields["swarm.pool_terminated"]
require.True(t, hasFields)
require.Equal(t, "swarm_lifecycle", agentCallbackEventCategories["swarm.pool_terminated"])
}