mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
fix(web): restore chronological sorting in task findings
This commit is contained in:
1 parent
e740334b90
commit
80316252b8
11 files changed
+338
-65
No files matched your search
+1
-1
@@ -123,7 +123,7 @@ func (d *DB) ListFindingCases(filter FindingFilter, page, size int) ([]FindingCa
|
||||
if err := d.QueryRow(base+` SELECT count(*) FROM entities`, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
order := `e.created DESC,e.fid DESC`
|
||||
order := `e.created ` + filter.timeOrder() + `,e.fid DESC`
|
||||
if filter.Sort == "severity" {
|
||||
order = `e.severity_rank DESC,` + order
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFindingGroupTimeSort(t *testing.T) {
|
||||
d, task, ids := timeSortFixture(t)
|
||||
other, err := d.CreateTask("newer low severity task", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM findings WHERE task_id=$1`, other.ID); _ = d.DeleteTask(other.ID) })
|
||||
marker := fmt.Sprintf("time-sort-%d", time.Now().UnixNano())
|
||||
if _, err := d.Exec(`UPDATE findings SET vulnclass=$2 WHERE task_id=$1`, task.ID, marker); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
latest, err := d.AddFinding(other.ID, 0, marker, "latest low", "low", "summary", "evidence", "worker", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.Exec(`UPDATE findings SET created_at='2026-10-28T12:00:00Z' WHERE id=$1`, latest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
field, direction string
|
||||
want int64
|
||||
}{
|
||||
{"time", "desc", other.ID},
|
||||
{"time", "asc", task.ID},
|
||||
{"severity", "desc", task.ID},
|
||||
{"severity", "asc", task.ID},
|
||||
} {
|
||||
groups, total, reports, err := d.ListFindingGroups(FindingFilter{VulnClass: marker, Sort: tc.field, TimeDirection: tc.direction}, 1, 1)
|
||||
if err != nil || total != 2 || reports != len(ids)+1 || len(groups) != 1 || groups[0].TaskID == nil || *groups[0].TaskID != tc.want {
|
||||
t.Fatalf("%s/%s groups=%+v total=%d reports=%d err=%v", tc.field, tc.direction, groups, total, reports, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingTimeDirectionAllowlist(t *testing.T) {
|
||||
for _, raw := range []string{"", "desc", "invalid", "ASC", "asc; SELECT 1"} {
|
||||
if got := (FindingFilter{TimeDirection: raw}).timeOrder(); got != "DESC" {
|
||||
t.Fatalf("timeOrder(%q)=%q, want DESC", raw, got)
|
||||
}
|
||||
}
|
||||
if got := (FindingFilter{TimeDirection: "asc"}).timeOrder(); got != "ASC" {
|
||||
t.Fatalf("timeOrder(asc)=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A newer low-severity record and an older high-severity record make time
|
||||
// ordering observably different from severity ordering in both directions.
|
||||
func timeSortFixture(t *testing.T) (*DB, *Task, []int64) {
|
||||
t.Helper()
|
||||
d, task, ids := caseTestDB(t)
|
||||
for i, day := range []int{25, 26, 27, 24} {
|
||||
if _, err := d.Exec(`UPDATE findings SET created_at=$2::timestamptz WHERE id=$1`, ids[i], fmt.Sprintf("2026-10-%dT12:00:00Z", day)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := d.Exec(`UPDATE findings SET severity='critical' WHERE id=$1`, ids[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.Exec(`UPDATE findings SET severity='high' WHERE id=$1`, ids[1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d, task, ids
|
||||
}
|
||||
|
||||
func TestFindingTimeSortPages(t *testing.T) {
|
||||
d, task, ids := timeSortFixture(t)
|
||||
for _, tc := range []struct {
|
||||
name, field, direction string
|
||||
want []int64
|
||||
}{
|
||||
{"default newest first", "", "", []int64{ids[2], ids[1], ids[0], ids[3]}},
|
||||
{"time descending", "time", "desc", []int64{ids[2], ids[1], ids[0], ids[3]}},
|
||||
{"time ascending", "time", "asc", []int64{ids[3], ids[0], ids[1], ids[2]}},
|
||||
{"severity descending time", "severity", "desc", []int64{ids[0], ids[1], ids[3], ids[2]}},
|
||||
{"severity ascending time", "severity", "asc", []int64{ids[0], ids[3], ids[1], ids[2]}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
filter := FindingFilter{TaskID: fmt.Sprint(task.ID), Sort: tc.field, TimeDirection: tc.direction}
|
||||
var got []int64
|
||||
for page := 1; page <= 2; page++ {
|
||||
rows, total, err := d.ListFindingsPage(filter, page, 2)
|
||||
if err != nil || total != 4 || len(rows) != 2 {
|
||||
t.Fatalf("page %d: rows=%d total=%d err=%v", page, len(rows), total, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
got = append(got, row.ID)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("paged order=%v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := d.Exec(`UPDATE findings SET created_at='2026-10-27T12:00:00Z' WHERE task_id=$1`, task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, direction := range []string{"asc", "desc"} {
|
||||
rows, _, err := d.ListFindingsPage(FindingFilter{TaskID: fmt.Sprint(task.ID), Sort: "time", TimeDirection: direction}, 1, 4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, row := range rows {
|
||||
if row.ID != ids[len(ids)-1-i] {
|
||||
t.Fatalf("unstable timestamp tie: %+v", rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseTimeSortPages(t *testing.T) {
|
||||
d, task, ids := timeSortFixture(t)
|
||||
caseID, err := d.MergeFindingCase(context.Background(), task.ID, []int64{ids[0], ids[2]}, "same defect", "shared handler", "human")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
latest, err := d.AddFinding(task.ID, 0, "other", "latest low", "low", "summary", "evidence", "worker", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.Exec(`UPDATE findings SET created_at='2026-10-28T12:00:00Z' WHERE id=$1`, latest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := func(id int64) string { return fmt.Sprintf("f:%d", id) }
|
||||
c := fmt.Sprintf("c:%d", caseID)
|
||||
for _, tc := range []struct {
|
||||
name, field, direction string
|
||||
want []string
|
||||
}{
|
||||
{"default newest first", "", "", []string{f(latest), c, f(ids[1]), f(ids[3])}},
|
||||
{"time descending", "time", "desc", []string{f(latest), c, f(ids[1]), f(ids[3])}},
|
||||
{"time ascending", "time", "asc", []string{f(ids[3]), f(ids[1]), c, f(latest)}},
|
||||
{"severity descending time", "severity", "desc", []string{c, f(ids[1]), f(ids[3]), f(latest)}},
|
||||
{"severity ascending time", "severity", "asc", []string{c, f(ids[3]), f(ids[1]), f(latest)}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
filter := FindingFilter{TaskID: fmt.Sprint(task.ID), Sort: tc.field, TimeDirection: tc.direction}
|
||||
var got []string
|
||||
for page := 1; page <= 2; page++ {
|
||||
rows, total, err := d.ListFindingCases(filter, page, 2)
|
||||
if err != nil || total != 4 || len(rows) != 2 {
|
||||
t.Fatalf("page %d: rows=%d total=%d err=%v", page, len(rows), total, err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Case != nil {
|
||||
got = append(got, fmt.Sprintf("c:%d", row.Case.ID))
|
||||
} else {
|
||||
got = append(got, f(row.FindingID))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("paged order=%v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Filtering a folder uses its matching members' timestamps, not merge time.
|
||||
rows, total, err := d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "low", Sort: "time", TimeDirection: "asc"}, 1, 10)
|
||||
if err != nil || total != 2 || len(rows) != 2 || rows[0].Case == nil || rows[0].Case.ID != caseID || rows[1].FindingID != latest {
|
||||
t.Fatalf("filtered case order=%+v total=%d err=%v", rows, total, err)
|
||||
}
|
||||
}
|
||||
+22
-13
@@ -150,15 +150,16 @@ func (d *DB) ListFindings(limit int) ([]*DBFinding, error) {
|
||||
}
|
||||
|
||||
// FindingFilter narrows a paginated findings query. Empty-string fields mean "no
|
||||
// filter on that column". Sort is "severity" (severity desc, then newest) or
|
||||
// anything else (newest first).
|
||||
// filter on that column". Sort is "severity" (severity desc, then time) or
|
||||
// anything else (time only). TimeDirection defaults to newest first.
|
||||
type FindingFilter struct {
|
||||
Severity string // high | medium | low
|
||||
Status string // pending | false_positive | ignored | resolved
|
||||
VulnClass string
|
||||
TaskID string // 任务 id(字符串形式;空/非法 = 不按任务筛选)
|
||||
Query string // 名称/类型/摘要/证据/报告正文的模糊检索关键词
|
||||
Sort string // "severity" | "time"
|
||||
Severity string // high | medium | low
|
||||
Status string // pending | false_positive | ignored | resolved
|
||||
VulnClass string
|
||||
TaskID string // 任务 id(字符串形式;空/非法 = 不按任务筛选)
|
||||
Query string // 名称/类型/摘要/证据/报告正文的模糊检索关键词
|
||||
Sort string // "severity" | "time"
|
||||
TimeDirection string // "asc" | "desc"; applies to time, not the severity ranking
|
||||
// AssetScope 是资产树的节点 key(a:<id> / c:<id> / r:<domain> / __none__),
|
||||
// 选中一个节点等于选中它的整棵子树。空 = 不按资产筛选。
|
||||
AssetScope string
|
||||
@@ -169,6 +170,14 @@ type FindingFilter struct {
|
||||
assetMiss bool // 选中的节点在当前筛选下不存在 → 结果恒空
|
||||
}
|
||||
|
||||
// timeOrder only returns SQL keywords from a fixed allowlist.
|
||||
func (f FindingFilter) timeOrder() string {
|
||||
if f.TimeDirection == "asc" {
|
||||
return "ASC"
|
||||
}
|
||||
return "DESC"
|
||||
}
|
||||
|
||||
// FindingUnassignedTask is the task filter sentinel for findings whose task is
|
||||
// absent. That includes rows created without a task and rows retained after their
|
||||
// originating task was deleted (the findings FK is ON DELETE SET NULL).
|
||||
@@ -259,10 +268,10 @@ func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding
|
||||
return []*DBFinding{}, total, nil
|
||||
}
|
||||
|
||||
order := "f.created_at DESC, f.id DESC"
|
||||
order := "f.created_at " + f.timeOrder() + ", f.id DESC"
|
||||
if f.Sort == "severity" {
|
||||
// critical > high > medium > low > 其它, then newest first.
|
||||
order = `CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, f.created_at DESC, f.id DESC`
|
||||
// critical > high > medium > low > 其它, then the requested time direction.
|
||||
order = `CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, ` + order
|
||||
}
|
||||
pageArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize)
|
||||
q := fmt.Sprintf(`
|
||||
@@ -324,9 +333,9 @@ func (d *DB) ListFindingGroups(f FindingFilter, page, pageSize int) ([]FindingGr
|
||||
return []FindingGroup{}, groupTotal, findingTotal, nil
|
||||
}
|
||||
|
||||
order := "MAX(f.created_at) DESC, t.id DESC NULLS LAST"
|
||||
order := "MAX(f.created_at) " + f.timeOrder() + ", t.id DESC NULLS LAST"
|
||||
if f.Sort == "severity" {
|
||||
order = `MAX(CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END) DESC, MAX(f.created_at) DESC, t.id DESC NULLS LAST`
|
||||
order = `MAX(CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END) DESC, ` + order
|
||||
}
|
||||
pageArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize)
|
||||
query := fmt.Sprintf(`SELECT t.id, COALESCE(t.name,''), COALESCE(t.description,''), COALESCE(
|
||||
|
||||
@@ -33,10 +33,11 @@ func findingFilterFromQuery(q url.Values) db.FindingFilter {
|
||||
Status: normFilter(q.Get("status")),
|
||||
VulnClass: normFilter(q.Get("vulnclass")),
|
||||
// task_id(独立于会切到「按任务节点」分支的 task 参数):全局表按任务筛选。
|
||||
TaskID: normFilter(q.Get("task_id")),
|
||||
Query: q.Get("q"),
|
||||
Sort: q.Get("sort"),
|
||||
AssetScope: strings.TrimSpace(q.Get("asset_scope")),
|
||||
TaskID: normFilter(q.Get("task_id")),
|
||||
Query: q.Get("q"),
|
||||
Sort: q.Get("sort"),
|
||||
TimeDirection: q.Get("time_direction"),
|
||||
AssetScope: strings.TrimSpace(q.Get("asset_scope")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindingFilterTimeDirection(t *testing.T) {
|
||||
for _, direction := range []string{"", "asc", "desc"} {
|
||||
q := url.Values{"task_id": {"9"}, "sort": {"time"}, "time_direction": {direction}, "severity": {"high"}, "q": {"keyword"}}
|
||||
f := findingFilterFromQuery(q)
|
||||
if f.Sort != "time" || f.TimeDirection != direction || f.TaskID != "9" || f.Severity != "high" || f.Query != "keyword" {
|
||||
t.Fatalf("filter lost sorting or search state: %+v", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,16 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { api } from "@/lib/api";
|
||||
import { filterAndSortFindings } from "@/lib/finding-severity";
|
||||
import { type FindingSortField, filterAndSortFindings } from "@/lib/finding-severity";
|
||||
import { useStoredSortPreference } from "@/lib/sort-preference";
|
||||
import { SEVERITY_FILTER_OPTIONS, statusMeta } from "@/lib/status";
|
||||
import type { Finding, FindingStatus, Severity } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FindingSortField = "time";
|
||||
|
||||
const FINDING_SORT_FIELDS: readonly FindingSortField[] = ["time"];
|
||||
const FINDING_SORT_FIELDS: readonly FindingSortField[] = ["time", "severity"];
|
||||
const FINDING_SORT_PREFERENCE_KEY = "artex_task_findings_sort";
|
||||
|
||||
function findingLabel(finding: Finding): string {
|
||||
@@ -185,7 +183,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
|
||||
// Discover inherited sources before filtering; finding-cases owns folder matching.
|
||||
const taskFindings = findings.filter((f) => f.task_id === taskId || f.inherited);
|
||||
const items = filterAndSortFindings(taskFindings, severity, sortPreference.direction, query);
|
||||
const items = filterAndSortFindings(taskFindings, severity, sortPreference, query);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -204,6 +202,42 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<Select
|
||||
value={sortPreference.field}
|
||||
onValueChange={(field) => {
|
||||
if (field === "time" || field === "severity") {
|
||||
setSortPreference((current) => ({ ...current, field }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-36" aria-label="发现排序方式">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="time">按时间排序</SelectItem>
|
||||
<SelectItem value="severity">按严重度排序</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`${sortPreference.field === "severity" ? "同等级" : "全部"}发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
|
||||
onClick={() =>
|
||||
setSortPreference((current) => ({
|
||||
...current,
|
||||
direction: current.direction === "asc" ? "desc" : "asc",
|
||||
}))
|
||||
}
|
||||
>
|
||||
{sortPreference.direction === "asc" ? (
|
||||
<ArrowUpIcon aria-hidden="true" />
|
||||
) : (
|
||||
<ArrowDownIcon aria-hidden="true" />
|
||||
)}
|
||||
{sortPreference.field === "severity" ? "同等级" : ""}
|
||||
{sortPreference.direction === "asc" ? "最早在前" : "最新在前"}
|
||||
</Button>
|
||||
<form
|
||||
className="flex w-full items-center gap-2 sm:w-auto"
|
||||
onSubmit={(event) => {
|
||||
@@ -233,7 +267,8 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
task: taskId,
|
||||
severity: severity === "all" ? undefined : severity,
|
||||
query: query || undefined,
|
||||
sort: "severity",
|
||||
sort: sortPreference.field,
|
||||
timeDirection: sortPreference.direction,
|
||||
}}
|
||||
contextTask={taskId}
|
||||
/>
|
||||
@@ -252,7 +287,8 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
task: source,
|
||||
severity: severity === "all" ? undefined : severity,
|
||||
query: query || undefined,
|
||||
sort: "severity",
|
||||
sort: sortPreference.field,
|
||||
timeDirection: sortPreference.direction,
|
||||
}}
|
||||
contextTask={taskId}
|
||||
readOnly
|
||||
@@ -264,25 +300,17 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
<Card className="overflow-hidden py-0">
|
||||
<CardContent className="px-0">
|
||||
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 flex-1">漏洞(按严重度排序)</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
|
||||
aria-label={`同等级发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
|
||||
onClick={() =>
|
||||
setSortPreference((current) => ({
|
||||
field: "time",
|
||||
direction: current.direction === "asc" ? "desc" : "asc",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span>同等级发现时间</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
漏洞({sortPreference.field === "severity" ? "按严重度排序" : "按时间排序"})
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>{sortPreference.field === "severity" ? "同等级发现时间" : "发现时间"}</span>
|
||||
{sortPreference.direction === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.5" />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{items.map((f) => (
|
||||
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
|
||||
|
||||
@@ -241,6 +241,7 @@ function findingFilterParams(q: Omit<FindingQuery, "page" | "pageSize">): URLSea
|
||||
if (q.task && q.task !== "all") p.set("task_id", q.task);
|
||||
if (q.query?.trim()) p.set("q", q.query.trim());
|
||||
if (q.sort) p.set("sort", q.sort);
|
||||
if (q.timeDirection) p.set("time_direction", q.timeDirection);
|
||||
if (q.assetScope) p.set("asset_scope", q.assetScope);
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -21,25 +21,59 @@ const rows = [
|
||||
finding("5", "high", "2026-10-26T12:00:00Z", { inherited: true, source_task_id: "source" }),
|
||||
];
|
||||
|
||||
test("time descending sorts across severity levels instead of promoting older critical findings", () => {
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(rows, "all", { field: "time", direction: "desc" }).map((row) => row.id),
|
||||
["5", "4", "1", "2", "3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("time ascending can place an older low-severity finding before a critical finding", () => {
|
||||
const oldest = finding("6", "low", "2026-10-23T12:00:00Z");
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings([...rows, oldest], "all", { field: "time", direction: "asc" }).map((row) => row.id),
|
||||
["6", "3", "2", "5", "4", "1"],
|
||||
);
|
||||
});
|
||||
|
||||
test("time sorting preserves severity/search filters and inherited records without mutating the snapshot", () => {
|
||||
const snapshot = Object.freeze(rows.map((row) => Object.freeze({ ...row, evidence: "Shared evidence" })));
|
||||
for (const direction of ["asc", "desc"]) {
|
||||
const result = filterAndSortFindings(snapshot, "high", { field: "time", direction }, " EVIDENCE ");
|
||||
assert.deepEqual(
|
||||
result.map((row) => row.id),
|
||||
direction === "asc" ? ["2", "5"] : ["5", "2"],
|
||||
);
|
||||
assert.equal(result.find((row) => row.id === "5").source_task_id, "source");
|
||||
}
|
||||
assert.deepEqual(
|
||||
snapshot.map((row) => row.id),
|
||||
rows.map((row) => row.id),
|
||||
);
|
||||
});
|
||||
|
||||
test("all keeps every report and orders severity before newest time", () => {
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(rows, "all", "desc").map((row) => row.id),
|
||||
filterAndSortFindings(rows, "all", { field: "severity", direction: "desc" }).map((row) => row.id),
|
||||
["3", "5", "2", "4", "1"],
|
||||
);
|
||||
});
|
||||
|
||||
test("each severity filters reports including inherited reports", () => {
|
||||
for (const severity of ["critical", "high", "medium", "low"]) {
|
||||
const items = filterAndSortFindings(rows, severity, "desc");
|
||||
const items = filterAndSortFindings(rows, severity, { field: "severity", direction: "desc" });
|
||||
assert.equal(items.length, severity === "high" ? 2 : 1);
|
||||
assert.ok(items.every((row) => row.severity === severity));
|
||||
}
|
||||
assert.equal(filterAndSortFindings(rows, "high", "desc")[0].source_task_id, "source");
|
||||
assert.equal(
|
||||
filterAndSortFindings(rows, "high", { field: "severity", direction: "desc" })[0].source_task_id,
|
||||
"source",
|
||||
);
|
||||
});
|
||||
|
||||
test("ascending time changes only the order within a severity", () => {
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(rows, "all", "asc").map((row) => row.id),
|
||||
filterAndSortFindings(rows, "all", { field: "severity", direction: "asc" }).map((row) => row.id),
|
||||
["3", "2", "5", "4", "1"],
|
||||
);
|
||||
});
|
||||
@@ -48,29 +82,32 @@ test("matching severity and timestamp use descending numeric IDs", () => {
|
||||
const tied = [finding("2", "high", rows[0].ts), finding("10", "high", rows[0].ts)];
|
||||
for (const direction of ["asc", "desc"]) {
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(tied, "all", direction).map((row) => row.id),
|
||||
filterAndSortFindings(tied, "all", { field: "severity", direction }).map((row) => row.id),
|
||||
["10", "2"],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("empty input and a severity without matches return an empty list", () => {
|
||||
assert.deepEqual(filterAndSortFindings([], "all", "desc"), []);
|
||||
assert.deepEqual(filterAndSortFindings([rows[0]], "critical", "desc"), []);
|
||||
assert.deepEqual(filterAndSortFindings([], "all", { field: "severity", direction: "desc" }), []);
|
||||
assert.deepEqual(filterAndSortFindings([rows[0]], "critical", { field: "severity", direction: "desc" }), []);
|
||||
});
|
||||
|
||||
test("filtering and sorting preserve the polling snapshot", () => {
|
||||
const snapshot = Object.freeze(rows.map((row) => Object.freeze({ ...row })));
|
||||
const before = [...snapshot];
|
||||
assert.notEqual(filterAndSortFindings(snapshot, "all", "desc"), snapshot);
|
||||
filterAndSortFindings(snapshot, "high", "asc");
|
||||
assert.notEqual(filterAndSortFindings(snapshot, "all", { field: "severity", direction: "desc" }), snapshot);
|
||||
filterAndSortFindings(snapshot, "high", { field: "severity", direction: "asc" });
|
||||
assert.deepEqual(snapshot, before);
|
||||
});
|
||||
|
||||
test("keywords match each searchable field as a trimmed, case-insensitive substring", () => {
|
||||
for (const field of ["name", "vulnclass", "summary", "evidence"]) {
|
||||
const match = finding("match", "high", rows[0].ts, { [field]: "prefix NeEdLe suffix" });
|
||||
assert.deepEqual(filterAndSortFindings([rows[0], match], "all", "desc", " nEEdle "), [match]);
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings([rows[0], match], "all", { field: "severity", direction: "desc" }, " nEEdle "),
|
||||
[match],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,8 +118,11 @@ test("unmatched keywords and missing searchable fields return no matches", () =>
|
||||
summary: undefined,
|
||||
evidence: undefined,
|
||||
});
|
||||
assert.deepEqual(filterAndSortFindings([...rows, missing], "all", "desc", "needle"), []);
|
||||
assert.deepEqual(filterAndSortFindings([], "all", "desc", "needle"), []);
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings([...rows, missing], "all", { field: "severity", direction: "desc" }, "needle"),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(filterAndSortFindings([], "all", { field: "severity", direction: "desc" }, "needle"), []);
|
||||
});
|
||||
|
||||
test("undefined, empty and whitespace keywords preserve severity filtering and sorting", () => {
|
||||
@@ -90,8 +130,8 @@ test("undefined, empty and whitespace keywords preserve severity filtering and s
|
||||
for (const severity of ["all", "high"]) {
|
||||
for (const direction of ["asc", "desc"]) {
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(rows, severity, direction, query),
|
||||
filterAndSortFindings(rows, severity, direction),
|
||||
filterAndSortFindings(rows, severity, { field: "severity", direction }, query),
|
||||
filterAndSortFindings(rows, severity, { field: "severity", direction }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -101,12 +141,12 @@ test("undefined, empty and whitespace keywords preserve severity filtering and s
|
||||
test("keywords combine with severity and preserve time ordering for inherited reports", () => {
|
||||
const matches = rows.map((row) => ({ ...row, evidence: "Shared keyword" }));
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(matches, "high", "desc", "KEYWORD").map((row) => row.id),
|
||||
filterAndSortFindings(matches, "high", { field: "severity", direction: "desc" }, "KEYWORD").map((row) => row.id),
|
||||
["5", "2"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
filterAndSortFindings(matches, "high", "asc", "keyword").map((row) => row.id),
|
||||
filterAndSortFindings(matches, "high", { field: "severity", direction: "asc" }, "keyword").map((row) => row.id),
|
||||
["2", "5"],
|
||||
);
|
||||
assert.deepEqual(filterAndSortFindings(matches, "high", "desc", "absent"), []);
|
||||
assert.deepEqual(filterAndSortFindings(matches, "high", { field: "severity", direction: "desc" }, "absent"), []);
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { SortPreference } from "./sort-preference";
|
||||
import type { Finding, Severity } from "./types";
|
||||
|
||||
export type FindingSortField = "time" | "severity";
|
||||
|
||||
const SEVERITY_RANK: Record<Severity, number> = { critical: 4, high: 3, medium: 2, low: 1 };
|
||||
|
||||
export function filterAndSortFindings(
|
||||
findings: readonly Finding[],
|
||||
severity: "all" | Severity,
|
||||
timeDirection: "asc" | "desc",
|
||||
sort: SortPreference<FindingSortField>,
|
||||
query?: string,
|
||||
): Finding[] {
|
||||
const keyword = query?.trim().toLowerCase() ?? "";
|
||||
@@ -20,9 +23,9 @@ export function filterAndSortFindings(
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const severityCompared = (SEVERITY_RANK[b.severity] ?? 0) - (SEVERITY_RANK[a.severity] ?? 0);
|
||||
if (severityCompared !== 0) return severityCompared;
|
||||
if (sort.field === "severity" && severityCompared !== 0) return severityCompared;
|
||||
const timeCompared = Date.parse(a.ts) - Date.parse(b.ts);
|
||||
if (timeCompared !== 0) return timeDirection === "asc" ? timeCompared : -timeCompared;
|
||||
if (timeCompared !== 0) return sort.direction === "asc" ? timeCompared : -timeCompared;
|
||||
return b.id.localeCompare(a.id, undefined, { numeric: true });
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isCompanyScopeKind,
|
||||
normalizeCompanyScopeValue,
|
||||
} from "../company-scope";
|
||||
import { filterAndSortFindings } from "../finding-severity";
|
||||
import type {
|
||||
Activity,
|
||||
ArchiveBatchItem,
|
||||
@@ -1962,11 +1963,13 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/exploration/finding-case-review" && m === "POST")
|
||||
throw new Error("演示模式不执行 Reporter 整理,请在已配置模型的实例中使用");
|
||||
if (path === "/exploration/finding-cases" && m === "GET") {
|
||||
const list = mockApplyAssetScope(mockFilterFindings(q), q.get("asset_scope"));
|
||||
const ranks = { critical: 4, high: 3, medium: 2, low: 1 } as const;
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
(q.get("sort") === "severity" ? ranks[b.severity] - ranks[a.severity] : 0) || +new Date(b.ts) - +new Date(a.ts),
|
||||
const list = filterAndSortFindings(
|
||||
mockApplyAssetScope(mockFilterFindings(q), q.get("asset_scope")),
|
||||
"all",
|
||||
{
|
||||
field: q.get("sort") === "severity" ? "severity" : "time",
|
||||
direction: q.get("time_direction") === "asc" ? "asc" : "desc",
|
||||
},
|
||||
);
|
||||
const page = Math.max(1, Number(q.get("page")) || 1);
|
||||
const size = Math.min(100, Math.max(1, Number(q.get("limit")) || 20));
|
||||
|
||||
@@ -602,6 +602,7 @@ export interface FindingQuery {
|
||||
task?: string; // 任务 id;"all"/空 = 不按任务筛选
|
||||
query?: string;
|
||||
sort?: "severity" | "time";
|
||||
timeDirection?: "asc" | "desc"; // Missing = newest first; secondary to severity when sort="severity".
|
||||
// 资产树节点 key;选中一个节点 = 选中它的整棵子树。空 = 不按资产筛选。
|
||||
assetScope?: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user