新增 GET /api/heicode/preflight?template_id=&binding_ids=1,2,3 —— agent 启动前的统一 确认层(#29 EPIC 的 #39 缺失项检测 + #40 可读执行摘要): - #39 缺失项检测:必需资源类别(git/sk/project_document/cloud_account)未绑定、budget (余额≤0)、agent_slot(在跑数达 tier 上限)。ready = missing 为空。 - #40 可读执行摘要:agent 角色、脱敏资源视图、高危操作、预算(剩余额度/quota_per_unit/ tier 上限/当前在跑数)、审批策略。 - 红线:resources 只暴露 type/provider/name/status/has_secret(布尔),绝不含 secret_ref/channelId/base_url/price。 - 高危操作固定 enum:production_deploy/db_write/cloud_resource_delete/ production_secret/large_budget,由已绑资源类型推导,均 requires_approval。 判定逻辑抽为纯函数 computePreflight,单测覆盖:全缺失、就绪、槽位满、高危 enum、 敏感字段不泄露(序列化断言)。复用既有 ResourceBinding/模板/GetUserMaxAgents/部署门禁 口径,不改部署/计费逻辑。文档补 §4.1。 #41(confirm + 审计 + 防篡改版本校验)作为后续 POST /preflight/confirm 实现。 Affects: Manager only(新增只读端点)。无 Client/Swarm 代码改动,无计费/审计 schema 改动。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
97 lines
3.6 KiB
Go
97 lines
3.6 KiB
Go
package controller
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"github.com/heicode/manager/model"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func tpl() model.AgentTemplate {
|
|
return model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "opus"}
|
|
}
|
|
|
|
// #39: 全部必需类别缺失 + 预算不足 → missing 覆盖各项,ready=false。
|
|
func TestComputePreflight_AllMissing(t *testing.T) {
|
|
s := computePreflight(tpl(), nil, nil, 0, 500000, 5, 0)
|
|
kinds := map[string]bool{}
|
|
for _, m := range s.Missing {
|
|
kinds[m.Kind] = true
|
|
}
|
|
require.True(t, kinds["git"])
|
|
require.True(t, kinds["sk"])
|
|
require.True(t, kinds["project_document"])
|
|
require.True(t, kinds["cloud_account"])
|
|
require.True(t, kinds["budget"], "余额为 0 应报 budget 缺失")
|
|
require.False(t, kinds["agent_slot"], "0/5 未满,不应报 agent_slot")
|
|
require.False(t, s.Ready)
|
|
}
|
|
|
|
// #39: 全部齐备 + 有余额 + 槽位未满 → ready=true。
|
|
func TestComputePreflight_Ready(t *testing.T) {
|
|
res := []preflightResource{
|
|
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true},
|
|
{BindingID: 2, Type: "sk", Provider: "custom", Name: "sk-pack", Status: "active", HasSecret: true},
|
|
{BindingID: 3, Type: "project_document", Provider: "custom", Name: "doc", Status: "active"},
|
|
{BindingID: 4, Type: "cloud_account", Provider: "azure", Name: "sub", Status: "active", HasSecret: true},
|
|
}
|
|
s := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 1)
|
|
require.Empty(t, s.Missing)
|
|
require.True(t, s.Ready)
|
|
}
|
|
|
|
// #39: agent 槽位已满 → ready=false + agent_slot 缺失项。
|
|
func TestComputePreflight_AgentSlotFull(t *testing.T) {
|
|
res := []preflightResource{
|
|
{BindingID: 1, Type: "git"}, {BindingID: 2, Type: "sk"},
|
|
{BindingID: 3, Type: "project_document"}, {BindingID: 4, Type: "cloud_account"},
|
|
}
|
|
s := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 5)
|
|
require.False(t, s.Ready)
|
|
found := false
|
|
for _, m := range s.Missing {
|
|
if m.Kind == "agent_slot" {
|
|
found = true
|
|
}
|
|
}
|
|
require.True(t, found)
|
|
}
|
|
|
|
// #40: 高危操作只用固定 enum,并按已绑资源类型推导。
|
|
func TestComputePreflight_HighRiskEnum(t *testing.T) {
|
|
res := []preflightResource{
|
|
{BindingID: 1, Type: "git"},
|
|
{BindingID: 2, Type: "database"},
|
|
{BindingID: 3, Type: "cloud_account"},
|
|
}
|
|
s := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 0)
|
|
ops := map[string]bool{}
|
|
for _, h := range s.HighRiskOps {
|
|
require.Contains(t, highRiskOpLabels, h.Op, "high-risk op 必须是固定 enum")
|
|
require.True(t, h.RequiresApproval)
|
|
ops[h.Op] = true
|
|
}
|
|
require.True(t, ops[highRiskProductionDeploy]) // git
|
|
require.True(t, ops[highRiskDBWrite]) // database
|
|
require.True(t, ops[highRiskCloudDelete]) // cloud_account
|
|
require.True(t, ops[highRiskProductionSecret]) // cloud_account
|
|
require.True(t, ops[highRiskLargeBudget]) // 标准确认项
|
|
}
|
|
|
|
// #40 红线:resource 视图序列化后绝不含 secret_ref/channel_id/base_url/price。
|
|
func TestComputePreflight_NoSensitiveFieldsLeaked(t *testing.T) {
|
|
res := []preflightResource{
|
|
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true},
|
|
}
|
|
s := computePreflight(tpl(), res, []int{99}, 1_000_000, 500000, 5, 0)
|
|
b, err := json.Marshal(s)
|
|
require.NoError(t, err)
|
|
out := string(b)
|
|
for _, banned := range []string{"secret_ref", "channel_id", "channelId", "base_url", "baseUrl", "price"} {
|
|
require.NotContains(t, out, banned, "执行摘要不得暴露敏感字段: "+banned)
|
|
}
|
|
require.Contains(t, out, "\"has_secret\":true") // 只暴露布尔
|
|
require.Contains(t, out, "\"invalid_bindings\":[99]")
|
|
}
|