Files
CyberStrikeAI/internal/workflow/draft_generator_test.go
T
2026-07-31 21:21:47 +08:00

214 lines
8.8 KiB
Go

package workflow
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"cyberstrike-ai/internal/config"
)
func TestGenerateDraftFromNaturalLanguageHighRiskAddsHITLAndValidGraph(t *testing.T) {
result, err := GenerateDraftFromNaturalLanguage(context.Background(), DraftRequest{
Prompt: "对目标资产做端口扫描,如果发现高危端口就执行加固脚本,最后输出报告",
Options: DraftOptions{
IncludeObjective: true,
AllowSchedule: false,
AllowHighRisk: false,
},
AvailableTools: []DraftTool{{Key: "nmap", Name: "nmap", Enabled: true}},
})
if err != nil {
t.Fatalf("GenerateDraftFromNaturalLanguage: %v", err)
}
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("generated graph should validate: %v\n%s", err, raw)
}
if !result.Audit.HighRisk || !result.Audit.NeedsHITL || len(result.Audit.RiskWarnings) == 0 {
t.Fatalf("audit did not flag high-risk HITL path: %#v", result.Audit)
}
var hasTool, hasHITL, hasConfirmation bool
for _, node := range result.Graph.Nodes {
if node.Type == "tool" && cfgString(node.Config, "tool_name") == "nmap" {
hasTool = true
}
if node.Type == "hitl" {
hasHITL = true
}
if cfgString(node.Config, "requires_human_confirmation") == "true" {
hasConfirmation = true
}
}
if !hasTool || !hasHITL || !hasConfirmation {
t.Fatalf("expected nmap tool, HITL, and confirmation marker; tool=%v hitl=%v confirmation=%v", hasTool, hasHITL, hasConfirmation)
}
}
func TestGenerateDraftAllowHighRiskStillLabelsConditionBranch(t *testing.T) {
result, err := GenerateDraftFromNaturalLanguage(context.Background(), DraftRequest{
Prompt: "如果漏洞扫描发现高危漏洞,允许生成执行修复脚本的草稿并输出报告",
Options: DraftOptions{
AllowHighRisk: true,
},
AvailableTools: []DraftTool{{Key: "nuclei", Name: "nuclei", Enabled: true}},
})
if err != nil {
t.Fatalf("GenerateDraftFromNaturalLanguage: %v", err)
}
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("generated graph should validate: %v\n%s", err, raw)
}
branches := map[string]bool{}
for _, edge := range result.Graph.Edges {
if branch := cfgString(edge.Config, "branch"); branch != "" {
branches[branch] = true
}
}
if !branches["true"] || !branches["false"] {
t.Fatalf("condition branches = %#v, want true and false", branches)
}
}
func TestGenerateDraftFromLLMUsesOpenAICompatibleEndpoint(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
if r.URL.Path != "/chat/completions" {
t.Fatalf("path = %s, want /chat/completions", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Fatalf("authorization = %q", got)
}
var payload struct {
Temperature float64 `json:"temperature"`
ResponseFormat struct {
Type string `json:"type"`
} `json:"response_format"`
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if payload.Temperature != 0 || payload.ResponseFormat.Type != "json_object" {
t.Fatalf("unexpected structured output controls: temperature=%v response_format=%#v", payload.Temperature, payload.ResponseFormat)
}
if len(payload.Messages) == 0 || strings.Contains(payload.Messages[0].Content, "start|tool|agent") {
t.Fatalf("system prompt still contains pipe enum: %q", payload.Messages[0].Content)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"meta\":{\"id\":\"llm-port-scan\",\"name\":\"端口扫描\",\"description\":\"端口扫描\",\"enabled\":true},\"graph\":{\"nodes\":[{\"id\":\"start-1\",\"type\":\"start\",\"label\":\"开始\",\"position\":{\"x\":120,\"y\":150},\"config\":{\"input_keys\":\"message, target\"}},{\"id\":\"tool-2\",\"type\":\"tool\",\"label\":\"端口扫描\",\"position\":{\"x\":330,\"y\":150},\"config\":{\"tool_name\":\"nmap\",\"arguments\":\"{\\\"target\\\":\\\"{{inputs.target}}\\\"}\",\"timeout_seconds\":\"120\",\"join_strategy\":\"all_merge\"}},{\"id\":\"output-3\",\"type\":\"output\",\"label\":\"输出报告\",\"position\":{\"x\":540,\"y\":150},\"config\":{\"source_binding\":{\"from\":\"previous\",\"field\":\"output\"},\"join_strategy\":\"all_merge\"}}],\"edges\":[{\"id\":\"edge-1\",\"source\":\"start-1\",\"target\":\"tool-2\"},{\"id\":\"edge-2\",\"source\":\"tool-2\",\"target\":\"output-3\"}],\"config\":{\"schema_version\":1}},\"capabilities\":[{\"label\":\"端口扫描\",\"tool_name\":\"nmap\",\"tool_candidates\":[\"nmap\"]}],\"audit\":{\"assumptions\":[]}}"}}]}`))
}))
defer srv.Close()
result, err := GenerateDraftFromLLM(context.Background(), DraftRequest{
Prompt: "对目标做端口扫描并输出报告",
AvailableTools: []DraftTool{{Key: "nmap", Name: "nmap", Enabled: true}},
}, config.OpenAIConfig{APIKey: "test-key", BaseURL: srv.URL, Model: "test-model"}, nil)
if err != nil {
t.Fatalf("GenerateDraftFromLLM: %v", err)
}
if !called {
t.Fatal("expected LLM endpoint to be called")
}
if result.Generator != "llm" || !result.Audit.Savable || result.Meta.ID != "llm-port-scan" {
t.Fatalf("unexpected result: %#v", result)
}
for _, node := range result.Graph.Nodes {
if node.Type == "output" && cfgString(node.Config, "output_key") != "result" {
t.Fatalf("output_key = %q, want result", cfgString(node.Config, "output_key"))
}
}
}
func TestGenerateDraftFromLLMReturnsErrorOnMalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"meta\":{\"id\":\"bad\"},|\"graph\":{\"nodes\":[]}}"}}]}`))
}))
defer srv.Close()
_, err := GenerateDraftFromLLM(context.Background(), DraftRequest{
Prompt: "随便生成一个工作流,要求所有节点都用到输出变量",
Options: DraftOptions{
IncludeObjective: true,
},
}, config.OpenAIConfig{APIKey: "test-key", BaseURL: srv.URL, Model: "test-model"}, nil)
if err == nil {
t.Fatal("expected malformed JSON error")
}
if !strings.Contains(err.Error(), "解析大模型工作流 JSON 失败") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestNormalizeLLMDraftRepairsMissingRequiredConfig(t *testing.T) {
result := normalizeLLMDraft("随便生成一个工作流", DraftRequest{}, llmDraftEnvelope{
Graph: graphDef{
Nodes: []graphNode{
{ID: "start-1", Type: "start", Label: "开始", Config: map[string]any{}},
{ID: "agent-1", Type: "agent", Label: "分析", Config: map[string]any{}},
{ID: "out-1", Type: "output", Label: "输出结果", Config: map[string]any{}},
},
Edges: []graphEdge{
{ID: "e1", Source: "start-1", Target: "agent-1"},
{ID: "e2", Source: "agent-1", Target: "out-1"},
},
},
})
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("normalized graph should validate: %v\n%s", err, raw)
}
var agentKey, outputKey string
for _, node := range result.Graph.Nodes {
switch node.Type {
case "agent":
agentKey = cfgString(node.Config, "output_key")
case "output":
outputKey = cfgString(node.Config, "output_key")
}
}
if agentKey == "" || outputKey != "result" {
t.Fatalf("agentKey=%q outputKey=%q", agentKey, outputKey)
}
}
func TestNormalizeLLMDraftRepairsConditionBranches(t *testing.T) {
result := normalizeLLMDraft("如果发现异常则输出详情,否则输出正常", DraftRequest{}, llmDraftEnvelope{
Graph: graphDef{
Nodes: []graphNode{
{ID: "start-1", Type: "start", Label: "开始", Config: map[string]any{}},
{ID: "cond-1", Type: "condition", Label: "判断", Config: map[string]any{"expression": `{{inputs.message}} != ""`}},
{ID: "out-yes", Type: "output", Label: "异常", Config: map[string]any{}},
{ID: "out-no", Type: "output", Label: "正常", Config: map[string]any{}},
},
Edges: []graphEdge{
{ID: "e1", Source: "start-1", Target: "cond-1"},
{ID: "e2", Source: "cond-1", Target: "out-yes"},
{ID: "e3", Source: "cond-1", Target: "out-no"},
},
},
})
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("normalized graph should validate: %v\n%s", err, raw)
}
branches := map[string]bool{}
for _, edge := range result.Graph.Edges {
if edge.Source == "cond-1" {
branches[cfgString(edge.Config, "branch")] = true
}
}
if !branches["true"] || !branches["false"] {
t.Fatalf("branches = %#v, want true and false", branches)
}
}