feat(findings): 漏洞删除功能(二次确认)

- db: DeleteFinding(id) 删 findings 行 + 来源探索节点(kind='finding'),
  级联清理 edges/node_assets、置空 activity 引用;返回受影响行数。
- server: DELETE /api/exploration/findings/{id}。
- web: api.deleteFinding;三处均可删且带 AlertDialog 二次确认——
  发现列表页独立「操作」列(每行垃圾桶按钮)、漏洞详情页头部删除按钮、
  任务详情·发现 Tab 展开区删除按钮。两处列表同源,删一次全部消失。
- 测试 TestDeleteFinding:findings 行 + 来源节点均被删,重复删返回(0,nil)。

go build/vet、tsc 通过。
This commit is contained in:
Autumn-27 committed 2026-08-19 10:28:06 -04:00
1 parent 8047bb7aca
commit 90030051ce
7 files changed
+250 -8

No files matched your search

+21
View File
@@ -308,6 +308,27 @@ func (d *DB) GetFinding(id int64) (*DBFinding, error) {
return f, nil
}
// DeleteFinding removes a finding entirely: the standalone findings row and its
// originating exploration node (kind='finding'), so it disappears from the findings
// list, the per-task 发现 Tab, and the exploration graph alike. Deleting the node
// cascades its edges + node_assets and nulls any activity referencing it. Returns
// rows affected (0 = no finding with that id).
func (d *DB) DeleteFinding(id int64) (int64, error) {
var nodeID *int64
err := d.QueryRow(`DELETE FROM findings WHERE id=$1 RETURNING node_id`, id).Scan(&nodeID)
if err == sql.ErrNoRows {
return 0, nil
}
if err != nil {
return 0, err
}
if nodeID != nil {
// best-effort: the finding is already gone; a stray node must not fail the op.
_, _ = d.Exec(`DELETE FROM exploration_nodes WHERE id=$1 AND kind='finding'`, *nodeID)
}
return 1, nil
}
// SetFindingStatus updates one finding's triage state. Returns rows affected.
func (d *DB) SetFindingStatus(id int64, status string) (int64, error) {
res, err := d.Exec(`UPDATE findings SET status=$1 WHERE id=$2`, status, id)
+48
View File
@@ -5,6 +5,54 @@ import (
"testing"
)
// TestDeleteFinding verifies删除漏洞 removes both the findings row and its
// originating exploration node (kind='finding').
func TestDeleteFinding(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
defer d.Close()
tk, err := d.CreateTask("删除漏洞测试", "目标", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
defer d.DeleteTask(tk.ID)
// seed a finding node in the task's exploration graph, then a findings row on it
es := d.Exploration(tk.ExplorationID)
nodeID, err := es.AddNode(KindFinding, map[string]any{"summary": "x", "severity": "high"}, 5, "confirmed", "worker", nil)
if err != nil {
t.Fatal(err)
}
fid, err := d.AddFinding(tk.ID, nodeID, "XSS", "反射型 XSS", "high", "summary", "poc", "worker", nil)
if err != nil {
t.Fatal(err)
}
n, err := d.DeleteFinding(fid)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("DeleteFinding rows: want 1, got %d", n)
}
if f, _ := d.GetFinding(fid); f != nil {
t.Fatalf("finding row should be gone, got %+v", f)
}
var cnt int
d.QueryRow(`SELECT count(*) FROM exploration_nodes WHERE id=$1`, nodeID).Scan(&cnt)
if cnt != 0 {
t.Fatalf("originating finding node should be deleted, still %d", cnt)
}
// deleting a non-existent finding is a no-op (0 rows), not an error
if n, err := d.DeleteFinding(fid); err != nil || n != 0 {
t.Fatalf("re-delete: want (0,nil), got (%d,%v)", n, err)
}
}
// TestFindingsPageAndStats exercises ListFindingsPage (filter/sort/paging) and
// FindingStats against the live dev PG. It tags its rows with a unique vulnclass
// so assertions are isolated from any pre-existing data, and cleans up after.