mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-11 21:23:50 +08:00
fix(tasks): serialize no-result decisions with finding writes
This commit is contained in:
1 parent
8e030b1fbd
commit
3306e547b2
11 files changed
+369
-37
No files matched your search
+4
-3
@@ -312,6 +312,7 @@ type FindingGroup struct {
|
||||
TaskName string `json:"task_name"` // 可选任务名称;空=未命名
|
||||
TaskDescription string `json:"task_description"`
|
||||
TaskStatus string `json:"task_status"`
|
||||
TaskStopReason string `json:"task_stop_reason"`
|
||||
Count int `json:"count"`
|
||||
Critical int `json:"critical"`
|
||||
High int `json:"high"`
|
||||
@@ -336,7 +337,7 @@ func (d *DB) ListFindingGroups(f FindingFilter, page, pageSize int) ([]FindingGr
|
||||
}
|
||||
where, args := f.where()
|
||||
grouped := ` FROM findings f LEFT JOIN tasks t ON f.task_id=t.id` + where +
|
||||
` GROUP BY t.id, t.name, t.description, t.status, t.paused, t.queued`
|
||||
` GROUP BY t.id, t.name, t.description, t.status, t.paused, t.queued, t.stop_reason`
|
||||
|
||||
var groupTotal, findingTotal int
|
||||
countQuery := `SELECT COUNT(*), COALESCE(SUM(finding_count),0) FROM (` +
|
||||
@@ -359,7 +360,7 @@ func (d *DB) ListFindingGroups(f FindingFilter, page, pageSize int) ([]FindingGr
|
||||
WHEN t.queued THEN 'queued'
|
||||
WHEN t.paused THEN 'paused'
|
||||
ELSE t.status
|
||||
END, ''),
|
||||
END, ''), COALESCE(t.stop_reason,''),
|
||||
COUNT(*),
|
||||
COUNT(*) FILTER (WHERE f.severity='critical'),
|
||||
COUNT(*) FILTER (WHERE f.severity='high'),
|
||||
@@ -376,7 +377,7 @@ func (d *DB) ListFindingGroups(f FindingFilter, page, pageSize int) ([]FindingGr
|
||||
for rows.Next() {
|
||||
var group FindingGroup
|
||||
var taskID sql.NullInt64
|
||||
if err := rows.Scan(&taskID, &group.TaskName, &group.TaskDescription, &group.TaskStatus, &group.Count,
|
||||
if err := rows.Scan(&taskID, &group.TaskName, &group.TaskDescription, &group.TaskStatus, &group.TaskStopReason, &group.Count,
|
||||
&group.Critical, &group.High, &group.Medium, &group.Low, &group.LastFoundAt); err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
||||
+52
-23
@@ -1,6 +1,7 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -26,15 +27,7 @@ func NormalizeNoResultOptions(opts *TaskCreateOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
// CountQualifyingFindings counts task-local independent findings, using the case
|
||||
// severity for grouped observations. False positives never count; unassessed
|
||||
// findings count only when the caller sets no severity threshold.
|
||||
func (d *DB) CountQualifyingFindings(taskID int64, minSeverity string) (int, error) {
|
||||
if err := NormalizeNoResultOptions(&TaskCreateOptions{NoResultMinSeverity: minSeverity}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var count int
|
||||
err := d.QueryRow(`WITH observations AS (
|
||||
const countQualifyingFindingsSQL = `WITH observations AS (
|
||||
SELECT CASE WHEN m.case_id IS NULL THEN 'f:'||f.id ELSE 'c:'||m.case_id END entity,
|
||||
CASE WHEN m.case_id IS NULL THEN f.severity ELSE c.severity END severity
|
||||
FROM findings f
|
||||
@@ -44,25 +37,61 @@ func (d *DB) CountQualifyingFindings(taskID int64, minSeverity string) (int, err
|
||||
)
|
||||
SELECT count(DISTINCT entity) FROM observations
|
||||
WHERE $2='' OR array_position(ARRAY['low','medium','high','critical'],severity)
|
||||
>= array_position(ARRAY['low','medium','high','critical'],$2)`, taskID, minSeverity).Scan(&count)
|
||||
>= array_position(ARRAY['low','medium','high','critical'],$2)`
|
||||
|
||||
// CountQualifyingFindings counts task-local independent findings, using the case
|
||||
// severity for grouped observations. False positives never count; unassessed
|
||||
// findings count only when the caller sets no severity threshold.
|
||||
func (d *DB) CountQualifyingFindings(taskID int64, minSeverity string) (int, error) {
|
||||
if err := NormalizeNoResultOptions(&TaskCreateOptions{NoResultMinSeverity: minSeverity}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var count int
|
||||
err := d.QueryRow(countQualifyingFindingsSQL, taskID, minSeverity).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// MarkNoResultChecked durably claims the single checkpoint. A terminal task or
|
||||
// an already checked checkpoint returns nil, so concurrent callers cannot stop
|
||||
// a task twice. A failed decision also persists its reason so restart can finish
|
||||
// settling without recounting findings that arrived during the drain.
|
||||
func (d *DB) MarkNoResultChecked(taskID int64, stop bool) (*time.Time, error) {
|
||||
var checked time.Time
|
||||
err := d.QueryRow(`UPDATE tasks SET no_result_checked_at=now(), stop_reason=CASE WHEN $2 THEN 'no_result' ELSE '' END
|
||||
WHERE id=$1 AND deleted_at IS NULL AND no_result_checked_at IS NULL
|
||||
AND status NOT IN ('done','failed','timeout')
|
||||
RETURNING no_result_checked_at`, taskID, stop).Scan(&checked)
|
||||
// CheckNoResultCheckpoint serializes the count and decision with finding writes
|
||||
// using their task row lock. A nil checked timestamp means the checkpoint was
|
||||
// not claimed. Findings committed before the lock is acquired count; later
|
||||
// writes cannot reverse a persisted stop, even during restart/settling.
|
||||
func (d *DB) CheckNoResultCheckpoint(ctx context.Context, taskID int64, minFindings int, minSeverity string) (count int, stop bool, checked *time.Time, err error) {
|
||||
if err = NormalizeNoResultOptions(&TaskCreateOptions{NoResultMinSeverity: minSeverity}); err != nil {
|
||||
return
|
||||
}
|
||||
tx, err := d.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, false, nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var status string
|
||||
var previous sql.NullTime
|
||||
err = tx.QueryRowContext(ctx, `SELECT status, no_result_checked_at FROM tasks
|
||||
WHERE id=$1 AND deleted_at IS NULL FOR UPDATE`, taskID).Scan(&status, &previous)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
return 0, false, nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return 0, false, nil, err
|
||||
}
|
||||
return &checked, nil
|
||||
if previous.Valid || status == "done" || status == "failed" || status == "timeout" {
|
||||
return 0, false, nil, nil
|
||||
}
|
||||
// READ COMMITTED takes a fresh snapshot for this separate statement, after
|
||||
// any finding transaction that held the task lock has committed. Combining
|
||||
// this with the locking SELECT would retain the snapshot from before waiting.
|
||||
if err = tx.QueryRowContext(ctx, countQualifyingFindingsSQL, taskID, minSeverity).Scan(&count); err != nil {
|
||||
return 0, false, nil, err
|
||||
}
|
||||
stop = count < max(1, minFindings)
|
||||
var at time.Time
|
||||
if err = tx.QueryRowContext(ctx, `UPDATE tasks SET no_result_checked_at=clock_timestamp(),
|
||||
stop_reason=CASE WHEN $2 THEN 'no_result' ELSE '' END WHERE id=$1
|
||||
RETURNING no_result_checked_at`, taskID, stop).Scan(&at); err != nil {
|
||||
return 0, false, nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, false, nil, err
|
||||
}
|
||||
return count, stop, &at, nil
|
||||
}
|
||||
+213
-2
@@ -1,8 +1,11 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeNoResultOptions(t *testing.T) {
|
||||
@@ -79,10 +82,10 @@ func TestNoResultPersistenceGuardAndLegacyArchive(t *testing.T) {
|
||||
if stored, err := d.GetTask(task.ID); err != nil || stored.NoResultSeconds != 60 || stored.NoResultMinFindings != 1 || stored.NoResultMinSeverity != "high" {
|
||||
t.Fatalf("creation round trip: %+v, %v", stored, err)
|
||||
}
|
||||
if checked, err := d.MarkNoResultChecked(task.ID, true); err != nil || checked == nil {
|
||||
if _, _, checked, err := d.CheckNoResultCheckpoint(t.Context(), task.ID, 1, "high"); err != nil || checked == nil {
|
||||
t.Fatalf("first check: %v, %v", checked, err)
|
||||
}
|
||||
if checked, err := d.MarkNoResultChecked(task.ID, true); err != nil || checked != nil {
|
||||
if _, _, checked, err := d.CheckNoResultCheckpoint(t.Context(), task.ID, 1, "high"); err != nil || checked != nil {
|
||||
t.Fatalf("second check: %v, %v", checked, err)
|
||||
}
|
||||
if _, _, won, err := d.SetTerminalStatusGuardedReasonRuntime(task.ID, "timeout", StopReasonNoResult); err != nil || !won {
|
||||
@@ -132,3 +135,211 @@ func TestNoResultPersistenceGuardAndLegacyArchive(t *testing.T) {
|
||||
t.Fatalf("legacy defaults: %d %d %q %q, %v", seconds, minimum, severity, reason, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Register close first so all test data (including non-cascading notification
|
||||
// events) is removed before the database connection is closed.
|
||||
func noResultTestDB(t *testing.T) (*DB, *Task) {
|
||||
t.Helper()
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
task, err := d.CreateTaskWithOptions("checkpoint concurrency", "goal", TaskCreateOptions{NoResultSeconds: 60})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, query := range []string{
|
||||
`DELETE FROM notification_events WHERE finding_id IN (SELECT id FROM findings WHERE task_id=$1)`,
|
||||
`DELETE FROM findings WHERE task_id=$1`,
|
||||
} {
|
||||
if _, err := d.Exec(query, task.ID); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
if err := d.DeleteTask(task.ID); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
return d, task
|
||||
}
|
||||
|
||||
// Observe an actual PostgreSQL lock wait before starting the 500ms assertion,
|
||||
// rather than assuming the goroutine has been scheduled by then.
|
||||
func waitForNoResultLockWait(t *testing.T, d *DB, blockerPID int) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
tick := time.NewTicker(10 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
var blocked bool
|
||||
if err := d.QueryRowContext(ctx, `SELECT EXISTS (
|
||||
SELECT 1 FROM pg_stat_activity WHERE $1=ANY(pg_blocking_pids(pid))
|
||||
AND query LIKE '%tasks%')`, blockerPID).Scan(&blocked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if blocked {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("operation never waited for the task row lock")
|
||||
case <-tick.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoResultCheckpointWaitsForFindingCommit(t *testing.T) {
|
||||
d, task := noResultTestDB(t)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
ready := make(chan int, 1)
|
||||
release := make(chan struct{})
|
||||
writerDone := make(chan struct{})
|
||||
writerErr := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(writerDone)
|
||||
writerErr <- d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
if _, err := RecordFindingTx(ctx, tx, RecordFindingInput{
|
||||
TaskID: task.ID, ExplorationID: task.ExplorationID, Severity: "high", Worker: "test",
|
||||
}, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
var pid int
|
||||
if err := tx.QueryRow(`SELECT pg_backend_pid()`).Scan(&pid); err != nil {
|
||||
return err
|
||||
}
|
||||
ready <- pid
|
||||
select {
|
||||
case <-release:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
})
|
||||
}()
|
||||
defer func() { cancel(); <-writerDone }()
|
||||
var pid int
|
||||
select {
|
||||
case pid = <-ready:
|
||||
case err := <-writerErr:
|
||||
t.Fatalf("record finding: %v", err)
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
type decision struct {
|
||||
count int
|
||||
stop bool
|
||||
checked *time.Time
|
||||
err error
|
||||
}
|
||||
result := make(chan decision, 1)
|
||||
checkerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(checkerDone)
|
||||
count, stop, checked, err := d.CheckNoResultCheckpoint(ctx, task.ID, 1, "high")
|
||||
result <- decision{count, stop, checked, err}
|
||||
}()
|
||||
defer func() { cancel(); <-checkerDone }()
|
||||
waitForNoResultLockWait(t, d, pid)
|
||||
select {
|
||||
case got := <-result:
|
||||
t.Fatalf("checkpoint returned before finding commit: %+v", got)
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
if err := <-writerErr; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := <-result
|
||||
if got.err != nil || got.count != 1 || got.stop || got.checked == nil {
|
||||
t.Fatalf("checkpoint after finding commit: %+v", got)
|
||||
}
|
||||
stored, err := d.GetTask(task.ID)
|
||||
if err != nil || stored.StopReason != "" || stored.NoResultCheckedAt == nil || !stored.NoResultCheckedAt.Equal(*got.checked) {
|
||||
t.Fatalf("persisted checkpoint: %+v, %v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoResultCheckpointBlocksLaterFinding(t *testing.T) {
|
||||
d, task := noResultTestDB(t)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
count, stop, checked, err := d.CheckNoResultCheckpoint(ctx, task.ID, 1, "high")
|
||||
if err != nil || count != 0 || !stop || checked == nil {
|
||||
t.Fatalf("initial checkpoint: %d %v %v %v", count, stop, checked, err)
|
||||
}
|
||||
tx, err := d.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := LockTaskEvidenceTx(tx, task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var pid int
|
||||
if err := tx.QueryRow(`SELECT pg_backend_pid()`).Scan(&pid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writerErr := make(chan error, 1)
|
||||
writerDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(writerDone)
|
||||
writerErr <- d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
_, err := RecordFindingTx(ctx, tx, RecordFindingInput{
|
||||
TaskID: task.ID, ExplorationID: task.ExplorationID, Severity: "high", Worker: "test",
|
||||
}, nil)
|
||||
return err
|
||||
})
|
||||
}()
|
||||
defer func() { cancel(); <-writerDone }()
|
||||
waitForNoResultLockWait(t, d, pid)
|
||||
select {
|
||||
case err := <-writerErr:
|
||||
t.Fatalf("finding bypassed task lock: %v", err)
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := <-writerErr; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, err := d.GetTask(task.ID)
|
||||
if err != nil || stored.StopReason != StopReasonNoResult || stored.NoResultCheckedAt == nil || !stored.NoResultCheckedAt.Equal(*checked) {
|
||||
t.Fatalf("later finding changed checkpoint: %+v, %v", stored, err)
|
||||
}
|
||||
if count, err := d.CountQualifyingFindings(task.ID, "high"); err != nil || count != 1 {
|
||||
t.Fatalf("later finding not committed: %d, %v", count, err)
|
||||
}
|
||||
if _, _, again, err := d.CheckNoResultCheckpoint(ctx, task.ID, 1, "high"); err != nil || again != nil {
|
||||
t.Fatalf("checkpoint claimed again: %v, %v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoResultCheckpointSkipsUnavailableTasks(t *testing.T) {
|
||||
for _, status := range []string{"done", "failed", "timeout", "deleted", "missing"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
d, task := noResultTestDB(t)
|
||||
id := task.ID
|
||||
switch status {
|
||||
case "deleted":
|
||||
_, err := d.Exec(`UPDATE tasks SET deleted_at=clock_timestamp() WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "missing":
|
||||
id = -1
|
||||
default:
|
||||
if err := d.SetStatus(id, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if count, stop, checked, err := d.CheckNoResultCheckpoint(t.Context(), id, 1, ""); err != nil || count != 0 || stop || checked != nil {
|
||||
t.Fatalf("unavailable task claimed: %d %v %v %v", count, stop, checked, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -222,13 +222,8 @@ func (e *Engine) checkNoResultCheckpoint(t *Task) (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
count, err := e.m.pg.CountQualifyingFindings(id, t.NoResultMinSeverity)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
minimum := max(1, t.NoResultMinFindings)
|
||||
stop := count < minimum
|
||||
checked, err := e.m.pg.MarkNoResultChecked(id, stop)
|
||||
count, stop, checked, err := e.m.pg.CheckNoResultCheckpoint(context.Background(), id, minimum, t.NoResultMinSeverity)
|
||||
if err != nil || checked == nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -76,7 +76,9 @@ func (s *Server) findingGroups(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
if task, ok := s.m.Task(i64s(*groups[i].TaskID)); ok {
|
||||
groups[i].TaskStatus = s.resolvedTaskStatus(task)
|
||||
state := task.lifecycleSnapshot()
|
||||
groups[i].TaskStatus = s.resolvedTaskStatusFromSnapshot(task, state)
|
||||
groups[i].TaskStopReason = state.StopReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,84 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestFindingGroupsNoResultStopReason(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { m.Close() })
|
||||
marker := fmt.Sprintf("__no_result_groups_%d__", time.Now().UnixNano())
|
||||
task, err := m.CreateTask("stopped finding group", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := mustTaskID(t, task.ID)
|
||||
t.Cleanup(func() {
|
||||
if _, err := m.pg.Exec(`DELETE FROM findings WHERE vulnclass=$1`, marker); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if err := m.pg.DeleteTask(id); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
for _, taskID := range []int64{id, 0} {
|
||||
if _, err := m.pg.AddFinding(taskID, 0, marker, "", db.SeverityHigh, "", "", "test", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
engine := NewEngine(m)
|
||||
engine.SetAgentResolver(func(*Task) (*agent.Planner, *agent.Worker) { return &agent.Planner{}, &agent.Worker{} })
|
||||
engine.started.Store(task.ID, true)
|
||||
for _, tc := range []struct {
|
||||
name, dbStatus, dbReason, liveStatus, liveReason, wantStatus, wantReason string
|
||||
live bool
|
||||
}{
|
||||
{name: "persisted no result", dbStatus: "timeout", dbReason: db.StopReasonNoResult, wantStatus: "timeout", wantReason: db.StopReasonNoResult},
|
||||
{name: "persisted timeout", dbStatus: "timeout", wantStatus: "timeout"},
|
||||
{name: "live no result overrides database", dbStatus: "running", live: true, liveStatus: "timeout", liveReason: db.StopReasonNoResult, wantStatus: "timeout", wantReason: db.StopReasonNoResult},
|
||||
{name: "live timeout clears stale reason", dbStatus: "timeout", dbReason: db.StopReasonNoResult, live: true, liveStatus: "timeout", wantStatus: "timeout"},
|
||||
{name: "revival clears stale reason", dbStatus: "timeout", dbReason: db.StopReasonNoResult, live: true, liveStatus: "running", wantStatus: "running"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := m.pg.Exec(`UPDATE tasks SET status=$2, stop_reason=$3 WHERE id=$1`, id, tc.dbStatus, tc.dbReason); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task.updateLifecycle(func(s *taskLifecycleState) { s.Status = tc.liveStatus; s.StopReason = tc.liveReason })
|
||||
s := &Server{m: m}
|
||||
if tc.live {
|
||||
s.engine = engine
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
s.findingGroups(rec, httptest.NewRequest(http.MethodGet, "/api/exploration/findings/groups?vulnclass="+marker, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var page struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page.Items) != 2 {
|
||||
t.Fatalf("groups=%s", rec.Body.String())
|
||||
}
|
||||
for _, group := range page.Items {
|
||||
wantStatus, wantReason := tc.wantStatus, tc.wantReason
|
||||
if group["task_id"] == nil {
|
||||
wantStatus, wantReason = "", ""
|
||||
}
|
||||
if group["task_status"] != wantStatus || group["task_stop_reason"] != wantReason {
|
||||
t.Fatalf("group=%+v want status=%q reason=%q", group, wantStatus, wantReason)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingPaginationParam(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
raw, name string
|
||||
|
||||
@@ -1288,6 +1288,10 @@ func (s *Server) resolvedTaskStatus(t *Task) string {
|
||||
return "created"
|
||||
}
|
||||
lifecycle := t.lifecycleSnapshot()
|
||||
return s.resolvedTaskStatusFromSnapshot(t, lifecycle)
|
||||
}
|
||||
|
||||
func (s *Server) resolvedTaskStatusFromSnapshot(t *Task, lifecycle taskLifecycleState) string {
|
||||
switch {
|
||||
case isTerminalStatus(lifecycle.Status):
|
||||
return lifecycle.Status
|
||||
|
||||
@@ -1120,7 +1120,13 @@ export default function DashboardPage() {
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge domain="task" value={t.status} dot className="px-1.5 py-0 text-[10px]" />
|
||||
<StatusBadge
|
||||
domain="task"
|
||||
value={t.status}
|
||||
stopReason={t.stop_reason}
|
||||
dot
|
||||
className="px-1.5 py-0 text-[10px]"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge
|
||||
|
||||
@@ -1167,7 +1167,14 @@ export default function FindingsPage() {
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{group.task_status && <StatusBadge domain="task" value={group.task_status} dot />}
|
||||
{group.task_status && (
|
||||
<StatusBadge
|
||||
domain="task"
|
||||
value={group.task_status}
|
||||
stopReason={group.task_stop_reason}
|
||||
dot
|
||||
/>
|
||||
)}
|
||||
{SEVERITIES.map((level) => {
|
||||
const count = group[level];
|
||||
if (count === 0) return null;
|
||||
|
||||
@@ -2102,6 +2102,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
task_name: owner?.name ?? "",
|
||||
task_description: owner?.description ?? items[0]?.task_description ?? "",
|
||||
task_status: owner?.status ?? "",
|
||||
task_stop_reason: owner?.stop_reason ?? "",
|
||||
count: items.length,
|
||||
critical: items.filter((finding) => finding.severity === "critical").length,
|
||||
high: items.filter((finding) => finding.severity === "high").length,
|
||||
|
||||
@@ -561,6 +561,7 @@ export interface FindingGroup {
|
||||
task_name?: string; // 可选任务名称;空/缺省=未命名
|
||||
task_description: string;
|
||||
task_status: string;
|
||||
task_stop_reason?: string;
|
||||
count: number;
|
||||
critical: number;
|
||||
high: number;
|
||||
|
||||
Reference in new issue
Block a user