package db import ( "context" "encoding/json" "fmt" "reflect" "strings" "sync" "testing" ) func caseTestDB(t *testing.T) (*DB, *Task, []int64) { t.Helper() d, err := Open(testDSN(t)) if err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) task, err := d.CreateTask("case integration", "goal", nil, 0, 0) if err != nil { t.Fatal(err) } ids := []int64{} for _, sev := range []string{"high", "medium", "low", "high"} { id, err := d.AddFinding(task.ID, 0, "IDOR", "order access", sev, "GET /orders/{id}", "original PoC "+sev, "worker", nil) if err != nil { t.Fatal(err) } ids = append(ids, id) } t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM findings WHERE task_id=$1`, task.ID); _ = d.DeleteTask(task.ID) }) return d, task, ids } func TestFindingCaseMergePreservesRecordsAndVersions(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() id, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "订单越权", "同一接口同一权限检查缺失", "reporter") if err != nil { t.Fatal(err) } c, err := d.GetFindingCase(id) if err != nil { t.Fatal(err) } if c.Count != 3 || c.High != 1 || c.Medium != 1 || c.Low != 1 { t.Fatalf("counts %+v", c) } stats, err := d.DistinctFindingStats(fmt.Sprint(task.ID)) if err != nil { t.Fatal(err) } batch, err := d.DistinctFindingStatsByTask() if err != nil || batch[fmt.Sprint(task.ID)] == nil || *batch[fmt.Sprint(task.ID)] != *stats { t.Fatalf("batch stats %+v %v", batch, err) } if stats.Total != 2 || stats.Reports != 4 || stats.Unassessed != 1 { t.Fatalf("stats %+v", stats) } again, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "same", "same", "reporter") if err != nil || again != id { t.Fatalf("idempotency %d %v", again, err) } same, _ := d.GetFindingCase(id) if same.Version != c.Version { t.Fatal("idempotent merge invalidated report") } if err := d.UpdateFindingCaseReport(ctx, id, c.Version, "订单越权", "complete PoC", "high", "verified sensitive data"); err != nil { t.Fatal(err) } for i, fid := range ids[:3] { f, err := d.GetFinding(fid) if err != nil { t.Fatal(err) } if f.Severity != []string{"high", "medium", "low"}[i] || f.Evidence == "" || f.Status != FindingPending { t.Fatalf("original overwritten %+v", f) } } if _, err := d.Exec(`UPDATE findings SET evidence='new proof' WHERE id=$1`, ids[1]); err != nil { t.Fatal(err) } if err := d.UpdateFindingCaseReport(ctx, id, c.Version, "x", "stale", "low", "x"); err != ErrFindingCaseConflict { t.Fatalf("stale accepted %v", err) } if err := d.RemoveFindingCaseMember(ctx, id, ids[1], "different cause"); err != nil { t.Fatal(err) } if _, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "x", "x", "reporter"); err == nil { t.Fatal("human veto ignored") } if err := d.RemoveFindingCaseMember(ctx, id, ids[2], "undo"); err != nil { t.Fatal(err) } c, _ = d.GetFindingCase(id) if c.Active { t.Fatal("singleton remains group") } for _, fid := range ids[:3] { g, err := d.FindingCaseID(fid) if err != nil || g != 0 { t.Fatalf("dangling membership %d %v", g, err) } } } func TestFindingCaseScopeSuggestionsAndDeletion(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() other, err := d.CreateTask("other", "goal", nil, 0, 0) if err != nil { t.Fatal(err) } defer d.DeleteTask(other.ID) fid, err := d.AddFinding(other.ID, 0, "IDOR", "other", "high", "same", "poc", "worker", nil) if err != nil { t.Fatal(err) } defer d.Exec(`DELETE FROM findings WHERE id=$1`, fid) if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[0], fid}, "x", "x", "reporter"); err == nil { t.Fatal("cross task accepted") } if _, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], fid}, "x", "x"); err == nil { t.Fatal("cross task suggestion accepted") } sid, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[1]}, "order", "uncertain") if err != nil { t.Fatal(err) } if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false, nil); err != nil { t.Fatal(err) } if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "x", "x", "reporter"); err == nil { t.Fatal("rejection ignored") } cid, err := d.MergeFindingCase(ctx, task.ID, ids[2:], "x", "proof", "reporter") if err != nil { t.Fatal(err) } if _, err := d.DeleteFinding(ids[3]); err != nil { t.Fatal(err) } c, _ := d.GetFindingCase(cid) if c.Active { t.Fatal("deletion did not dissolve group") } f, err := d.GetFinding(ids[2]) if err != nil || f == nil { t.Fatal("surviving finding lost") } } func TestFindingCaseConcurrentUnion(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() var wg sync.WaitGroup errs := make(chan error, 3) for _, pair := range [][]int64{{ids[0], ids[1]}, {ids[1], ids[2]}, {ids[2], ids[3]}} { pair := pair wg.Add(1) go func() { defer wg.Done() _, err := d.MergeFindingCase(ctx, task.ID, pair, "orders", "same authorization check", "reporter") errs <- err }() } wg.Wait() close(errs) for err := range errs { if err != nil { t.Fatal(err) } } group := int64(0) for _, fid := range ids { cid, err := d.FindingCaseID(fid) if err != nil || cid == 0 { t.Fatalf("membership %v", err) } if group != 0 && cid != group { t.Fatal("overlapping parallel groups") } group = cid } c, _ := d.GetFindingCase(group) if c.Count != 4 { t.Fatalf("lost member %+v", c) } rows, total, err := d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "low"}, 1, 10) if err != nil || total != 1 || len(rows) != 1 || rows[0].Case.Count != 4 || len(rows[0].MatchedIDs) != 1 { t.Fatalf("filter/aggregation %d %+v %v", total, rows, err) } } func TestFindingCaseTaskDeletionAndRepeatedDetach(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() cid, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "orders", "same missing permission", "reporter") if err != nil { t.Fatal(err) } // A source task can disappear without deleting standalone reports or breaking old links. if err := d.DeleteTask(task.ID); err != nil { t.Fatal(err) } defer func() { for _, id := range ids { _, _ = d.DeleteFinding(id) } }() c, err := d.GetFindingCase(cid) if err != nil || c == nil || c.TaskID != nil || c.Count != 3 { t.Fatalf("retention %+v %v", c, err) } if err := d.RemoveFindingCaseMember(ctx, cid, ids[0], "undo"); err != nil { t.Fatal(err) } if err := d.RemoveFindingCaseMember(ctx, cid, ids[0], "retry"); err != nil { t.Fatal(err) } if err := d.RemoveFindingCaseMember(ctx, cid, ids[1], "undo"); err != nil { t.Fatal(err) } if err := d.RemoveFindingCaseMember(ctx, cid, ids[2], "released by dissolution"); err != nil { t.Fatal(err) } c, _ = d.GetFindingCase(cid) if c.Active || c.Count != 0 { t.Fatalf("folder not dissolved %+v", c) } for _, id := range ids[:3] { f, e := d.GetFinding(id) if e != nil || f == nil || f.Evidence == "" { t.Fatalf("original lost %d %v", id, e) } } } func TestFindingCaseSuggestionResolutionIsAtomic(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() sid, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[1]}, "orders", "uncertain") if err != nil { t.Fatal(err) } results := make(chan error, 2) var wg sync.WaitGroup for _, accept := range []bool{true, false} { wg.Add(1) go func(a bool) { defer wg.Done(); _, e := d.ResolveFindingCaseSuggestion(ctx, sid, a, nil); results <- e }(accept) } wg.Wait() close(results) success := 0 for err := range results { if err == nil { success++ } } if success != 1 { t.Fatalf("%d successful conflicting resolutions", success) } var state string if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, sid).Scan(&state); err != nil { t.Fatal(err) } cid, _ := d.FindingCaseID(ids[0]) if (state == "accepted") != (cid > 0) { t.Fatalf("inconsistent resolution %s %d", state, cid) } } func TestFindingCaseCandidatesAreCompactAndRelated(t *testing.T) { d, task, ids := caseTestDB(t) if _, err := d.Exec(`UPDATE findings SET vulnclass='SQLi',name='search injection' WHERE id=$1`, ids[3]); err != nil { t.Fatal(err) } items, total, _, err := d.FindingCaseCandidates(ids[0], 1, 1) if err != nil || total != 2 || len(items) != 1 { t.Fatalf("candidate pagination %d %+v %v", total, items, err) } if _, ok := items[0]["report"]; ok { t.Fatal("report included in candidate summary") } if _, ok := items[0]["evidence"]; ok { t.Fatal("evidence included in candidate summary") } cid, err := d.MergeFindingCase(context.Background(), task.ID, ids[:2], "orders", "proof", "reporter") if err != nil { t.Fatal(err) } if err := d.RemoveFindingCaseMember(context.Background(), cid, ids[1], "different cause"); err != nil { t.Fatal(err) } _, total, _, err = d.FindingCaseCandidates(ids[0], 1, 20) if err != nil || total != 1 { t.Fatalf("veto excluded %d %v", total, err) } } func TestFindingCaseViewContextAndAssetFiltering(t *testing.T) { d, task, ids := caseTestDB(t) if _, err := d.Exec(`UPDATE tasks SET name='Order API' WHERE id=$1`, task.ID); err != nil { t.Fatal(err) } assets := []int64{} for i := 0; i < 6; i++ { aid := seedTreeAsset(t, d, "service", map[string]any{"url": fmt.Sprintf("https://view-context-%d-%d.example", task.ID, i)}) assets = append(assets, aid) } t.Cleanup(func() { for _, a := range assets { _, _ = d.Exec(`DELETE FROM assets WHERE id=$1`, a) } }) for i, id := range ids[:3] { raw, _ := json.Marshal(assets[i*2 : i*2+2]) if _, err := d.Exec(`UPDATE findings SET asset_ids=$2 WHERE id=$1`, id, string(raw)); err != nil { t.Fatal(err) } } cid, err := d.MergeFindingCase(t.Context(), task.ID, ids[:3], "order access", "same permission check", "human") if err != nil { t.Fatal(err) } contexts, err := d.FindingCaseListContexts([]int64{cid}) if err != nil { t.Fatal(err) } c := contexts[cid] if c.TaskName != "Order API" || c.TaskDescription != task.Description || c.AssetCount != 6 || !reflect.DeepEqual(c.AssetIDs, assets[:4]) || c.LastFoundAt.IsZero() { t.Fatalf("context %+v", c) } filter := FindingFilter{TaskID: fmt.Sprint(task.ID), AssetScope: fmt.Sprintf("a:%d", assets[0])} rows, total, err := d.ListFindingCases(filter, 1, 20) if err != nil || total != 1 || len(rows) != 1 || rows[0].Case == nil || rows[0].Case.ID != cid || !reflect.DeepEqual(rows[0].MatchedIDs, ids[:1]) { t.Fatalf("filtered rows %+v total %d err %v", rows, total, err) } members, n, err := d.FindingCaseMembers(cid, 1, 20) if err != nil || n != 3 || len(members) != 3 { t.Fatalf("all folder members %d %v", n, err) } matches, err := d.MatchingFindingReportCount(filter) if err != nil || matches != 1 { t.Fatalf("matching reports %d %v", matches, err) } rows, total, err = d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "medium"}, 1, 20) if err != nil || total != 1 || len(rows) != 1 || rows[0].Case.Count != 3 || rows[0].Case.High != 1 || rows[0].Case.Medium != 1 || rows[0].Case.Low != 1 { t.Fatalf("severity filter %+v %v", rows, err) } matches, err = d.MatchingFindingReportCount(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "medium"}) if err != nil || matches != 1 { t.Fatalf("severity reports %d %v", matches, err) } // Group has no unified severity yet; it must sort by its original high report. if _, err := d.Exec(`UPDATE findings SET severity='low' WHERE id=$1`, ids[3]); err != nil { t.Fatal(err) } rows, total, err = d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Sort: "severity"}, 1, 1) if err != nil || total != 2 || len(rows) != 1 || rows[0].Case == nil || rows[0].Case.ID != cid { t.Fatalf("original severity order %+v %v", rows, err) } } func TestFindingCaseSuggestionClusterLifecycle(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "same handler evidence") if err != nil { t.Fatal(err) } items, err := d.FindingCaseSuggestions(task.ID) if err != nil || len(items) != 1 || len(items[0].FindingIDs) != 3 { t.Fatalf("cluster listing %+v %v", items, err) } if reused, err := d.SuggestFindingCase(ctx, task.ID, ids[:2], "weaker", "subset"); err != nil || reused != sid { t.Fatalf("pending superset was not reused: %d %v", reused, err) } if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, nil); err != nil { t.Fatal(err) } var state string if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, sid).Scan(&state); err != nil || state != "accepted" { t.Fatalf("cluster state %q %v", state, err) } for _, fid := range ids[:3] { if cid, err := d.FindingCaseID(fid); err != nil || cid == 0 { t.Fatalf("member %d not merged: %d %v", fid, cid, err) } } } func TestFindingCaseSuggestionSweepScopesEachCard(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() first, err := d.SuggestFindingCase(ctx, task.ID, ids[:2], "first", "first group") if err != nil { t.Fatal(err) } second, err := d.SuggestFindingCase(ctx, task.ID, ids[2:], "second", "second group") if err != nil { t.Fatal(err) } if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "first", "same root cause", "human"); err != nil { t.Fatal(err) } var firstState, secondState string if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, first).Scan(&firstState); err != nil { t.Fatal(err) } if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, second).Scan(&secondState); err != nil { t.Fatal(err) } if firstState != "accepted" || secondState != "pending" { t.Fatalf("suggestion states first=%q second=%q", firstState, secondState) } } func TestFindingCaseSuggestionSubsetSuperseded(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() subset, err := d.SuggestFindingCase(ctx, task.ID, ids[:2], "orders", "partial evidence") if err != nil { t.Fatal(err) } superset, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "complete group evidence") if err != nil { t.Fatal(err) } if subset == superset { t.Fatal("subset suggestion was reused instead of superseded") } var state string if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, subset).Scan(&state); err != nil || state != "superseded" { t.Fatalf("subset state %q %v", state, err) } } func TestFindingCaseSuggestionAcceptExclusionAndReject(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "same component") if err != nil { t.Fatal(err) } if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, []int64{ids[2]}); err != nil { t.Fatal(err) } var blocks int if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2`, ids[0], ids[2]).Scan(&blocks); err != nil || blocks != 1 { t.Fatalf("excluded member was not vetoed: %d %v", blocks, err) } if cid, _ := d.FindingCaseID(ids[2]); cid != 0 { t.Fatalf("excluded member entered case %d", cid) } } func TestFindingCaseSuggestionExclusionBlocksOnlyKeptSuggestionMembers(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "existing folder", "same root cause", "human"); err != nil { t.Fatal(err) } sid, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[2], ids[3]}, "orders", "same component") if err != nil { t.Fatal(err) } if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, []int64{ids[3]}); err != nil { t.Fatal(err) } var folderMateBlock, keptBlock int if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2`, ids[1], ids[3]).Scan(&folderMateBlock); err != nil { t.Fatal(err) } if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2`, ids[0], ids[3]).Scan(&keptBlock); err != nil { t.Fatal(err) } if folderMateBlock != 0 || keptBlock != 1 { t.Fatalf("exclusion blocks folder mate=%d kept=%d", folderMateBlock, keptBlock) } } func TestFindingCaseSuggestionRejectVetoesAllPairs(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "same component") if err != nil { t.Fatal(err) } if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false, nil); err != nil { t.Fatal(err) } var count int if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=ANY($1::bigint[]) AND right_id=ANY($1::bigint[])`, ids[:3]).Scan(&count); err != nil || count != 3 { t.Fatalf("reject did not veto all pairs: %d %v", count, err) } } func TestFindingCaseSuggestionRejectSkipsExistingFolderAndBlockedSuggest(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "orders", "same handler", "human"); err != nil { t.Fatal(err) } sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "same handler") if err != nil { t.Fatal(err) } if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false, nil); err != nil { t.Fatal(err) } var blocked int if err := d.QueryRow(`SELECT count(*) FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2`, ids[0], ids[1]).Scan(&blocked); err != nil || blocked != 0 { t.Fatalf("same-folder pair was vetoed: %d %v", blocked, err) } if _, err := d.SuggestFindingCase(ctx, task.ID, []int64{ids[0], ids[2]}, "orders", "retry"); err == nil || !strings.Contains(err.Error(), fmt.Sprintf("#%d/#%d", ids[0], ids[2])) { t.Fatalf("blocked pair was accepted or unnamed: %v", err) } } func TestFindingCaseSuggestionExclusionCannotPullExistingFolder(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "orders", "same handler", "human"); err != nil { t.Fatal(err) } sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "same handler") if err != nil { t.Fatal(err) } if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, []int64{ids[1]}); err == nil { t.Fatal("excluded member was pulled through its existing folder") } if cid, _ := d.FindingCaseID(ids[2]); cid != 0 { t.Fatalf("failed exclusion merged remaining record into case %d", cid) } var state string if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, sid).Scan(&state); err != nil || state != "pending" { t.Fatalf("failed exclusion changed suggestion state: %q %v", state, err) } } func TestFindingCaseSuggestionAnchorDeletionAndLegacyPair(t *testing.T) { d, task, ids := caseTestDB(t) ctx := context.Background() sid, err := d.SuggestFindingCase(ctx, task.ID, ids[:3], "orders", "same handler") if err != nil { t.Fatal(err) } if _, err := d.DeleteFinding(ids[0]); err != nil { t.Fatal(err) } items, err := d.FindingCaseSuggestions(task.ID) if err != nil || len(items) != 1 || items[0].ID != sid || items[0].LeftID != ids[1] || items[0].RightID != ids[2] { t.Fatalf("anchor deletion lost group: %+v %v", items, err) } if got := items[0].FindingIDs; len(got) != 2 || got[0] != fmt.Sprint(ids[1]) || got[1] != fmt.Sprint(ids[2]) { t.Fatalf("surviving members %+v", got) } if _, err := d.Exec(`DELETE FROM finding_case_suggestion_members WHERE suggestion_id=$1`, sid); err != nil { t.Fatal(err) } if _, err := d.Exec(`UPDATE finding_case_suggestions SET left_id=$2,right_id=$3 WHERE id=$1`, sid, ids[1], ids[3]); err != nil { t.Fatal(err) } legacy, err := d.FindingCaseSuggestions(task.ID) if err != nil || len(legacy) != 1 || len(legacy[0].FindingIDs) != 2 { t.Fatalf("legacy pair was not listed: %+v %v", legacy, err) } if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, true, nil); err != nil { t.Fatal(err) } } func TestFindingCaseCandidatesScoreAndTier(t *testing.T) { d, _, ids := caseTestDB(t) if _, err := d.Exec(`UPDATE findings SET vulnclass='IDOR',name='order access',asset_ids='[101]'::jsonb WHERE id=$1`, ids[0]); err != nil { t.Fatal(err) } if _, err := d.Exec(`UPDATE findings SET vulnclass='idor',name='different',asset_ids='[101]'::jsonb WHERE id=$1`, ids[1]); err != nil { t.Fatal(err) } if _, err := d.Exec(`UPDATE findings SET vulnclass='SQLi',name='different',asset_ids='[101]'::jsonb WHERE id=$1`, ids[2]); err != nil { t.Fatal(err) } if _, err := d.Exec(`UPDATE findings SET vulnclass='SQLi',name='order access',asset_ids='[]'::jsonb WHERE id=$1`, ids[3]); err != nil { t.Fatal(err) } items, total, strong, err := d.FindingCaseCandidates(ids[0], 1, 20) if err != nil || total != 3 || strong != 1 || len(items) != 3 { t.Fatalf("candidate totals %d %d %+v %v", total, strong, items, err) } if items[0]["finding_id"] != fmt.Sprint(ids[1]) || items[0]["score"] != 7 || items[0]["tier"] != "strong" || !reflect.DeepEqual(items[0]["hit_reasons"], []string{"同漏洞类型", "资产重叠"}) { t.Fatalf("strong candidate %+v", items[0]) } if items[1]["finding_id"] != fmt.Sprint(ids[2]) || items[1]["score"] != 2 || items[1]["tier"] != "weak" { t.Fatalf("asset candidate %+v", items[1]) } if items[2]["finding_id"] != fmt.Sprint(ids[3]) || items[2]["score"] != 1 { t.Fatalf("name candidate %+v", items[2]) } }