Files
ARTEX/server/finding_reasoning_test.go
T
BaaRai 8c9c1fc0ab feat(findings): optional counterevidence and confidence_rationale
Add two optional free-text fields to findings so a reporter can record
what was tested and did not work (counterevidence) and why the
conclusion holds (confidence_rationale).

- db: idempotent ADD COLUMN IF NOT EXISTS, TEXT NOT NULL DEFAULT '',
  no CHECK; RecordFindingInput, the INSERT, the shared select list and
  all three scan sites carry them.
- agent: report_finding accepts both as optional strings; the worker
  and pentest prompts say to fill them when confidence is not high.
- server: FindingDTO (list and detail) carries both, omitted when empty;
  existing databases get the two schema properties added to the stored
  report_finding row once, without overwriting user edits.
- web: the finding detail page shows each section only when non-empty.

Both fields stay optional: a missing field never refuses a finding.
2026-11-11 23:43:12 +01:00

95 lines
3.7 KiB
Go

package server
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/Autumn-27/artex/db"
)
// Pure: FindingDTO carries both reasoning fields and omits them when empty.
func TestFindingDTOReasoningFields(t *testing.T) {
d := findingFromDB(&db.DBFinding{ID: 1, Counterevidence: "blocked by WAF", ConfidenceRationale: "row of tenant B returned"}, nil)
if d.Counterevidence != "blocked by WAF" || d.ConfidenceRationale != "row of tenant B returned" {
t.Fatalf("DTO lost reasoning fields: %+v", d)
}
raw, _ := json.Marshal(d)
if !strings.Contains(string(raw), `"counterevidence":"blocked by WAF"`) || !strings.Contains(string(raw), `"confidence_rationale":"row of tenant B returned"`) {
t.Fatalf("JSON missing reasoning fields: %s", raw)
}
raw, _ = json.Marshal(findingFromDB(&db.DBFinding{ID: 2}, nil))
if strings.Contains(string(raw), "counterevidence") || strings.Contains(string(raw), "confidence_rationale") {
t.Fatalf("empty reasoning fields should be omitted: %s", raw)
}
}
// PG: the detail endpoint returns the stored fields, and the report_finding
// schema upgrade adds both properties to an existing (customized) row without
// overwriting it.
func TestFindingReasoningDetailAndSchemaUpgrade(t *testing.T) {
s, f, req := trafficEvidenceServer(t)
pg := s.m.pg
var taskID int64
if err := pg.QueryRow(`SELECT task_id FROM findings WHERE id=$1`, f.FindingID).Scan(&taskID); err != nil {
t.Fatal(err)
}
var expID int64
if err := pg.QueryRow(`SELECT exploration_id FROM tasks WHERE id=$1`, taskID).Scan(&expID); err != nil {
t.Fatal(err)
}
rec, err := pg.Exploration(expID).RecordFinding(context.Background(), db.RecordFindingInput{TaskID: taskID, VulnClass: "TEST", Severity: "low", Summary: "reasoning detail", Worker: "test",
Counterevidence: "admin path returned 403", ConfidenceRationale: "user path returned the admin record"})
if err != nil {
t.Fatal(err)
}
w := req("GET", fmt.Sprintf("/api/exploration/findings/%d", rec.FindingID), "")
if w.Code != 200 {
t.Fatalf("detail status %d: %s", w.Code, w.Body)
}
var got FindingDTO
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Counterevidence != "admin path returned 403" || got.ConfidenceRationale != "user path returned the admin record" {
t.Fatalf("detail DTO lost reasoning fields: %+v", got)
}
old, err := pg.GetTool("report_finding")
if err != nil || old == nil {
t.Fatal("missing report_finding", err)
}
t.Cleanup(func() {
bindings, _ := json.Marshal(old.Agents)
pg.UpdateTool(old.Key, old.Description, old.Schema, bindings, old.Enabled)
})
custom := json.RawMessage(`{"type":"object","properties":{"evidence":{"type":"string","description":"user evidence instructions"}},"required":["evidence"]}`)
if err := pg.UpdateTool("report_finding", "user description", custom, json.RawMessage(`["worker"]`), false); err != nil {
t.Fatal(err)
}
for range 2 { // idempotent
s.seedFindingEvidenceSchema()
}
tool, err := pg.GetTool("report_finding")
if err != nil || tool == nil {
t.Fatal(err)
}
var schema struct {
Properties map[string]map[string]any `json:"properties"`
Required []string `json:"required"`
}
if err := json.Unmarshal(tool.Schema, &schema); err != nil {
t.Fatal(err)
}
for _, name := range []string{"counterevidence", "confidence_rationale"} {
if schema.Properties[name]["type"] != "string" {
t.Fatalf("upgrade did not add %s: %s", name, tool.Schema)
}
}
if tool.Enabled || tool.Description != "user description" || len(schema.Required) != 1 || schema.Required[0] != "evidence" || schema.Properties["evidence"]["description"] != "user evidence instructions" {
t.Fatalf("custom configuration overwritten: %+v %s", tool, tool.Schema)
}
}