Files
ARTEX/server/finding_workflow_test.go
T
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

329 lines
15 KiB
Go

package server
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"testing"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
actool "github.com/Autumn-27/norma/tool"
)
func workflowCall(t *testing.T, ctx context.Context, tool actool.CoreTool, input any, wantError bool) string {
t.Helper()
raw, err := json.Marshal(input)
if err != nil {
t.Fatal(err)
}
r, err := tool.Call(ctx, raw, nil)
if err != nil || r.IsError != wantError {
t.Fatalf("%s: error=%v result=%s", tool.Name(), err, r.Flatten())
}
return r.Flatten()
}
func workflowTool(t *testing.T, tools []actool.CoreTool, name string) actool.CoreTool {
t.Helper()
for _, tool := range tools {
if tool.Name() == name {
return tool
}
}
t.Fatalf("missing tool: %s", name)
return nil
}
func TestFindingWorkflowAutoHintToPlannerAndSetting(t *testing.T) {
s, initial, request := trafficEvidenceServer(t)
pg := s.m.pg
ctx := context.Background()
if _, err := pg.Exec(`DELETE FROM settings WHERE key=$1`, settingAgentTrafficBinding); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pg.SetBool(settingAgentTrafficBinding, false) })
if s.settingsPayload()[settingAgentTrafficBinding] != false {
t.Fatal("missing setting must default off")
}
seedServerEvidenceFlow(t, s, "handoff-proof", []byte("verified local proof"))
seedServerEvidenceFlow(t, s, "handoff-baseline", []byte("local baseline"))
seedServerEvidenceFlow(t, s, "handoff-verification", []byte{0, 1, 255})
// Manual binding is independent of the Agent switch.
if r := request("POST", fmt.Sprintf("/api/exploration/findings/%d/traffic", initial.FindingID), `{"traffic_refs":[{"traffic_id":"handoff-proof"}]}`); r.Code != 200 {
t.Fatal(r.Code, r.Body)
}
bind := s.toolBindFindingTraffic()
input := map[string]any{"finding_id": initial.FindingID, "traffic_refs": []db.TrafficRef{{TrafficID: "handoff-baseline"}}}
workflowCall(t, ctx, bind, input, true)
if r := request("PUT", "/api/settings", `{"agent_traffic_binding":true}`); r.Code != 200 {
t.Fatal(r.Code, r.Body)
}
if !pg.GetBool(settingAgentTrafficBinding, false) {
t.Fatal("switch was not persisted")
}
f, _ := pg.GetFinding(initial.FindingID)
task := s.m.ResolveTask(fmt.Sprint(*f.TaskID))
refs := []db.TrafficRef{{TrafficID: "handoff-baseline", Role: "baseline", Note: "normal response"}, {TrafficID: "handoff-proof", Role: "proof", Note: "proves the finding"}, {TrafficID: "handoff-verification", Role: "verification", Note: "binary verification"}}
// Real cross-task Auto hint handler -> persisted graph -> Planner tool.
hintResult := workflowCall(t, ctx, s.toolAddHint(), map[string]any{"task_id": task.ID, "hints": []any{map[string]any{"text": "Report the confirmed local finding with its verified evidence", "traffic_refs": refs}}}, false)
var hints struct {
IDs []int64 `json:"ids"`
}
if err := json.Unmarshal([]byte(hintResult), &hints); err != nil || len(hints.IDs) != 1 || hints.IDs[0] == 0 {
t.Fatal(hintResult, err)
}
graph := workflowCall(t, ctx, s.toolGetTaskGraph(), map[string]any{"task_id": task.ID}, false)
for _, ref := range refs {
if !strings.Contains(graph, ref.TrafficID) {
t.Fatal("handoff lost reference", ref.TrafficID)
}
}
for range 6 {
if _, err := task.Store.AddNode(db.KindFact, map[string]any{"summary": "ID separation"}, 0, "confirmed", "test", nil); err != nil {
t.Fatal(err)
}
}
ts := agent.NewToolSet(task.Store, "planner")
ts.SetTaskID(*f.TaskID)
ts.SetFindingRecorder(s.evidenceStore())
notices := 0
ts.SetNotifyFinding(func(int64, string) { notices++ })
tools, def, cleanup := agent.AugmentTools(ctx, "planner", ts.PlannerTools())
defer cleanup()
if !strings.Contains(def.FindingGuidance, "evidence_hint_id") || !strings.Contains(def.FindingGuidance, "cancel Workers") {
t.Fatal("Planner missed runtime guidance")
}
report := workflowTool(t, tools, "report_finding")
result := workflowCall(t, ctx, report, map[string]any{"vulnclass": "TEST", "severity": "low", "summary": "Planner handoff fixture", "evidence_hint_id": hints.IDs[0]}, false)
var recorded db.RecordedFinding
if err := json.Unmarshal([]byte(strings.SplitN(result, "\n", 2)[1]), &recorded); err != nil {
t.Fatal(err)
}
if recorded.FindingID == recorded.NodeID || len(recorded.Traffic.Bindings) != 3 || notices != 1 {
t.Fatal("bad finding/traffic result", result)
}
for i, b := range recorded.Traffic.Bindings {
if b.Snapshot.SourceTrafficID != refs[i].TrafficID || b.Role != refs[i].Role || b.Note != refs[i].Note {
t.Fatalf("handoff mismatch: %+v", b)
}
}
list := workflowCall(t, ctx, s.toolListTaskFindings(), map[string]any{"task_id": task.ID}, false)
var nodes []struct {
ID int64 `json:"id"`
FindingID int64 `json:"finding_id"`
FindingNodeID int64 `json:"finding_node_id"`
Count int `json:"traffic_count"`
}
if err := json.Unmarshal([]byte(list), &nodes); err != nil {
t.Fatal(err)
}
found := false
for _, n := range nodes {
if n.ID == recorded.NodeID {
found = n.FindingID == recorded.FindingID && n.FindingNodeID == recorded.NodeID && n.Count == 3
}
}
if !found {
t.Fatal("canonical IDs/count missing", list)
}
workflowCall(t, ctx, s.toolGetFindingTraffic(), map[string]any{"finding_id": recorded.FindingID}, false)
wrong := workflowCall(t, ctx, s.toolGetFindingTraffic(), map[string]any{"finding_id": int64(900000000000000000)}, true)
if !strings.Contains(wrong, "standalone finding record ID") {
t.Fatal("ambiguous ID error", wrong)
}
input["finding_id"] = recorded.FindingID
// Duplicate append preserves metadata and version.
workflowCall(t, ctx, bind, input, false)
after, err := pg.GetFindingTraffic(ctx, recorded.FindingID)
if err != nil || len(after.Bindings) != 3 || after.Version != recorded.Traffic.Version || after.Bindings[0].Note != refs[0].Note {
t.Fatal("duplicate changed evidence", after, err)
}
// A malformed handoff must not create a partial finding or notify Planner.
badHint := workflowCall(t, ctx, s.toolAddHint(), map[string]any{"task_id": task.ID, "text": "missing packet", "traffic_refs": []db.TrafficRef{{TrafficID: "missing"}}}, false)
var badID int64
fmt.Sscanf(badHint, "hint added: %d", &badID)
workflowCall(t, ctx, report, map[string]any{"vulnclass": "TEST", "severity": "low", "summary": "must fail", "evidence_hint_id": badID}, true)
if notices != 1 {
t.Fatal("failed handoff notified before commit")
}
child, err := s.m.CreateTaskWithOptions("inherited handoff", "fixture", db.TaskCreateOptions{SourceTaskIDs: []int64{*f.TaskID}})
if err != nil {
t.Fatal(err)
}
childID, _ := strconv.ParseInt(child.ID, 10, 64)
childRow, _ := pg.GetTask(childID)
t.Cleanup(func() { pg.DeleteTask(childRow.ID) })
childCtx := agent.WithRunInfo(ctx, agent.RunInfo{TaskID: childRow.ID})
workflowCall(t, childCtx, bind, input, true)
workflowCall(t, childCtx, s.toolGetFindingTraffic(), map[string]any{"finding_id": recorded.FindingID}, false)
// Existing, already assembled tools must honor a later switch-off.
if r := request("PUT", "/api/settings", `{"agent_traffic_binding":false}`); r.Code != 200 {
t.Fatal(r.Code, r.Body)
}
workflowCall(t, ctx, bind, input, true)
// Turning binding off must still save a confirmed finding from an already
// assembled tool, even if the model sends the old optional evidence fields.
offResult := workflowCall(t, ctx, report, map[string]any{"vulnclass": "TEST", "severity": "low", "summary": "off", "evidence_hint_id": hints.IDs[0]}, false)
var offRecord struct {
db.RecordedFinding
EvidenceStatus string `json:"evidence_status"`
EvidenceNote string `json:"evidence_note"`
}
if err := json.Unmarshal([]byte(strings.SplitN(offResult, "\n", 2)[1]), &offRecord); err != nil {
t.Fatal(err)
}
if offRecord.FindingID <= 0 || len(offRecord.Traffic.Bindings) != 0 || offRecord.EvidenceStatus != "not_bound" || !strings.Contains(offRecord.EvidenceNote, "binding is off") {
t.Fatal("disabled binding discarded finding or bound evidence", offResult)
}
workflowCall(t, ctx, report, map[string]any{"vulnclass": "TCP", "severity": "low", "summary": "no packet needed"}, false)
if notices != 3 {
t.Fatal("optional no-packet report failed")
}
workflowCall(t, ctx, s.toolGetFindingTraffic(), map[string]any{"finding_id": recorded.FindingID}, false)
tools, def, closeTools := agent.AugmentTools(ctx, "planner", ts.PlannerTools())
defer closeTools()
if def.FindingGuidance != "" {
t.Fatal("disabled feature still injects guidance")
}
for _, tool := range tools {
if tool.Name() == "bind_finding_traffic" {
t.Fatal("disabled binding tool exposed")
}
}
}
func TestFindingWorkflowMigrationPreservesUserConfiguration(t *testing.T) {
s, _, _ := trafficEvidenceServer(t)
pg := s.m.pg
for _, key := range []string{"report_finding", "add_hint", "add_task_hint", "traffic_search", "traffic_get", "traffic_blob", "bind_finding_traffic", "get_finding_traffic"} {
old, err := pg.GetTool(key)
if err != nil || old == nil {
t.Fatal("missing tool", key, err)
}
t.Cleanup(func() {
bindings, _ := json.Marshal(old.Agents)
pg.UpdateTool(key, old.Description, old.Schema, bindings, old.Enabled)
})
}
custom := json.RawMessage(`{"type":"object","properties":{"text":{"type":"string","description":"USER TEXT"},"hints":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","description":"USER ITEM"}}}}},"required":["text"]}`)
for _, key := range []string{"report_finding", "add_hint", "add_task_hint"} {
if err := pg.UpdateTool(key, "USER DESCRIPTION", custom, json.RawMessage(`["custom-agent"]`), false); err != nil {
t.Fatal(err)
}
}
for _, key := range []string{"traffic_search", "traffic_get"} {
row, _ := pg.GetTool(key)
bindings := json.RawMessage(`["custom-agent"]`)
if key == "traffic_search" {
bindings = json.RawMessage(`["worker"]`)
}
if err := pg.UpdateTool(key, row.Description, row.Schema, bindings, false); err != nil {
t.Fatal(err)
}
}
if err := pg.SetSetting("finding_workflow_tools_v2_reporter", "false"); err != nil {
t.Fatal(err)
}
s.seedFindingWorkflowTools()
for _, key := range []string{"report_finding", "add_hint", "add_task_hint"} {
row, _ := pg.GetTool(key)
if row.Enabled || row.Description != "USER DESCRIPTION" || len(row.Agents) != 1 || row.Agents[0] != "custom-agent" {
t.Fatal("changed user tool configuration", key)
}
if !strings.Contains(string(row.Schema), "USER TEXT") || !strings.Contains(string(row.Schema), "USER ITEM") {
t.Fatal("changed user schema", key)
}
field := "traffic_refs"
if key == "report_finding" {
field = "evidence_hint_id"
}
if !strings.Contains(string(row.Schema), field) {
t.Fatal("missing optional field", key)
}
}
search, _ := pg.GetTool("traffic_search")
get, _ := pg.GetTool("traffic_get")
if !strings.Contains(search.Description, "accepts a bare host, host:port, or full URL") {
t.Fatal("traffic_search description migration missing host/port guidance")
}
if search.Enabled || !contains(search.Agents, "reporter") || get.Enabled || len(get.Agents) != 1 || get.Agents[0] != "custom-agent" {
t.Fatal("default/custom reader binding migration incorrect")
}
if err := pg.RemoveAgentFromTool("reporter", "traffic_search"); err != nil {
t.Fatal(err)
}
s.seedFindingWorkflowTools()
search, _ = pg.GetTool("traffic_search")
if contains(search.Agents, "reporter") {
t.Fatal("one-time migration undid later unbinding")
}
}
func TestFindingWorkflowReporterBindsBeforeWritingReport(t *testing.T) {
s, f, request := trafficEvidenceServer(t)
pg := s.m.pg
ctx := context.Background()
if err := pg.SetBool(settingAgentTrafficBinding, true); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { pg.SetBool(settingAgentTrafficBinding, false); s.m.SetTrafficEnabled(false) })
if err := s.m.SetTrafficEnabled(true); err != nil {
t.Fatal(err)
}
seedServerEvidenceFlow(t, s, "reporter-proof", []byte("local proof payload"))
seedServerEvidenceFlow(t, s, "reporter-baseline", []byte("local normal response"))
tools, def, cleanup := agent.AugmentTools(ctx, "reporter", nil)
defer cleanup()
if !strings.Contains(def.FindingGuidance, "Auto-link traffic before reporting") || !strings.Contains(def.FindingGuidance, "After a successful bind, call get_finding_traffic again") {
t.Fatal("reporter did not receive binding workflow")
}
for _, name := range []string{"traffic_search", "traffic_get", "get_task_worker_trace", "get_task_node_detail", "bind_finding_traffic", "get_finding_traffic", "update_finding_report"} {
workflowTool(t, tools, name)
}
finding, _ := pg.GetFinding(f.FindingID)
detail := workflowCall(t, ctx, workflowTool(t, tools, "get_task_node_detail"), map[string]any{"task_id": fmt.Sprint(*finding.TaskID), "id": f.NodeID}, false)
if !strings.Contains(detail, `"finding_id"`) || !strings.Contains(detail, `"finding_node_id"`) {
t.Fatal("reporter lacks explicit ID mapping")
}
workflowCall(t, ctx, workflowTool(t, tools, "traffic_search"), map[string]any{"host": "evidence.local"}, false)
for _, id := range []string{"reporter-baseline", "reporter-proof"} {
packet := workflowCall(t, ctx, workflowTool(t, tools, "traffic_get"), map[string]any{"id": id}, false)
if !strings.Contains(packet, "local") {
t.Fatal("reporter cannot inspect packet", packet)
}
}
refs := []db.TrafficRef{{TrafficID: "reporter-baseline", Role: "baseline"}, {TrafficID: "reporter-proof", Role: "proof", Note: "confirmed from recorded response"}}
workflowCall(t, ctx, workflowTool(t, tools, "bind_finding_traffic"), map[string]any{"finding_id": f.FindingID, "traffic_refs": refs}, false)
list := workflowCall(t, ctx, workflowTool(t, tools, "get_finding_traffic"), map[string]any{"finding_id": f.FindingID}, false)
var summary struct {
Version int64 `json:"version"`
}
if err := json.Unmarshal([]byte(list), &summary); err != nil || summary.Version != 1 {
t.Fatal(list, err)
}
workflowCall(t, ctx, workflowTool(t, tools, "update_finding_report"), map[string]any{"finding_id": f.NodeID, "evidence_version": summary.Version, "report": "## Local report\n\nVerified baseline and proof using saved evidence."}, false)
updated, err := pg.GetFinding(f.FindingID)
if err != nil || updated.ReportEvidenceVersion != summary.Version || updated.EvidenceVersion != summary.Version {
t.Fatal("report did not cover post-binding version", updated, err)
}
// Disabling automatic binding still lets Reporter read manually bound snapshots.
if r := request("PUT", "/api/settings", `{"agent_traffic_binding":false}`); r.Code != 200 {
t.Fatal(r.Code, r.Body)
}
off, offDef, closeOff := agent.AugmentTools(ctx, "reporter", nil)
defer closeOff()
if offDef.FindingGuidance != "" {
t.Fatal("off reporter still receives auto-binding guidance")
}
for _, tool := range off {
if tool.Name() == "traffic_search" || tool.Name() == "traffic_get" || tool.Name() == "traffic_blob" || tool.Name() == "bind_finding_traffic" {
t.Fatal("auto-binding tool exposed while off", tool.Name())
}
}
workflowCall(t, ctx, workflowTool(t, off, "get_finding_traffic"), map[string]any{"finding_id": f.FindingID}, false)
workflowTool(t, off, "update_finding_report")
}