Merge pull request #62 from zabcxky-123/codex/fix-findings-mock-time-sort

fix(web): 对齐 Mock 发现列表的时间排序
This commit is contained in:
RuoJi6 authored and GitHub committed 2026-10-27 01:07:30 -07:00
commit ff7839928a
3 files changed
+156 -24

No files matched your search

@@ -222,7 +222,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
type="button"
variant="outline"
size="sm"
aria-label={`${sortPreference.field === "severity" ? "同等级" : "全部"}发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
aria-label={`${sortPreference.field === "severity" ? "同严重度" : "全部"}发现时间当前${sortPreference.direction === "asc" ? "正序" : "倒序"},点击切换排序方向`}
onClick={() =>
setSortPreference((current) => ({
...current,
@@ -235,8 +235,8 @@ export function FindingsTab({ taskId }: { taskId: string }) {
) : (
<ArrowDownIcon aria-hidden="true" />
)}
{sortPreference.field === "severity" ? "同等级" : ""}
{sortPreference.direction === "asc" ? "最早在前" : "最新在前"}
{sortPreference.field === "severity" ? "同严重度:" : ""}
{sortPreference.direction === "asc" ? "最早优先" : "最新优先"}
</Button>
<form
className="flex w-full items-center gap-2 sm:w-auto"
@@ -304,7 +304,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
漏洞({sortPreference.field === "severity" ? "按严重度排序" : "按时间排序"})
</span>
<span className="inline-flex items-center gap-1">
<span>{sortPreference.field === "severity" ? "同等级发现时间" : "发现时间"}</span>
<span>{sortPreference.field === "severity" ? "同严重度发现时间" : "发现时间"}</span>
{sortPreference.direction === "asc" ? (
<ArrowUpIcon className="size-3.5" />
) : (
+134
View File
@@ -0,0 +1,134 @@
const assert = require("node:assert/strict");
const test = require("node:test");
// Use the existing TypeScript dev dependency to exercise the real mock routes,
// including their extensionless imports, without adding a test runtime.
require("ts-node").register({
skipProject: true,
transpileOnly: true,
compilerOptions: { target: "ES2022", module: "CommonJS", moduleResolution: "Node" },
});
const data = require("./data.ts");
const finding = (id, task, severity, day, status = "pending") => ({
id,
task_id: task,
severity,
ts: `2026-01-${String(day).padStart(2, "0")}T12:00:00Z`,
status,
name: "needle",
vulnclass: "Demo",
summary: "Synthetic sorting fixture",
evidence: "",
assets: [],
});
const fixtures = [
finding("1", "2", "low", 2),
finding("2", "2", "high", 4),
finding("10", "10", "high", 4),
finding("11", "10", "critical", 1),
finding("12", "20", "high", 4),
finding("13", undefined, "high", 4),
finding("14", "30", "low", 5),
finding("15", "40", "critical", 1),
finding("16", "10", "high", 3, "resolved"),
];
// handler.ts snapshots fixtures on import. Restore exported demo data immediately;
// node:test runs this test file in its own process, separate from the application.
const original = data.findings.slice();
let mockHandle;
try {
data.findings.splice(0, data.findings.length, ...fixtures);
({ mockHandle } = require("./handler.ts"));
} finally {
data.findings.splice(0, data.findings.length, ...original);
}
const rowOrders = {
time: {
asc: ["15", "11", "1", "16", "13", "12", "10", "2", "14"],
desc: ["14", "13", "12", "10", "2", "16", "1", "15", "11"],
},
severity: {
asc: ["15", "11", "16", "13", "12", "10", "2", "1", "14"],
desc: ["15", "11", "13", "12", "10", "2", "16", "14", "1"],
},
};
const groupOrders = {
time: {
asc: ["40", "20", "10", "2", null, "30"],
desc: ["30", "20", "10", "2", null, "40"],
},
severity: {
asc: ["40", "10", "20", "2", null, "30"],
desc: ["10", "40", "20", "2", null, "30"],
},
};
const routes = [
{ path: "/exploration/findings", key: (row) => row.id, orders: rowOrders },
{ path: "/exploration/finding-cases", key: (row) => row.finding.id, orders: rowOrders },
{ path: "/exploration/findings/groups", key: (row) => row.task_id, orders: groupOrders },
];
const read = (route, query = {}) =>
mockHandle("GET", `${route.path}?${new URLSearchParams({ page: "1", limit: "100", ...query })}`);
for (const route of routes) {
for (const sort of ["time", "severity"]) {
for (const direction of ["asc", "desc"]) {
test(`${route.path}: ${sort}/${direction} sorts before pagination with stable ID ties`, async () => {
const expected = route.orders[sort][direction];
const full = await read(route, { sort, time_direction: direction });
assert.deepEqual(full.items.map(route.key), expected);
assert.equal(full.total, expected.length);
const paged = [];
for (let page = 1; page <= Math.ceil(expected.length / 2); page++) {
const result = await read(route, { sort, time_direction: direction, page: String(page), limit: "2" });
assert.equal(result.total, expected.length);
assert.equal(result.page, page);
assert.equal(result.page_size, 2);
paged.push(...result.items.map(route.key));
}
assert.deepEqual(paged, expected);
});
}
}
test(`${route.path}: missing or invalid direction defaults to descending`, async () => {
for (const sort of ["time", "severity"]) {
for (const direction of [undefined, "", "ASC", "invalid", "asc; DROP TABLE findings"]) {
const query = { sort, ...(direction === undefined ? {} : { time_direction: direction }) };
assert.deepEqual((await read(route, query)).items.map(route.key), route.orders[sort].desc);
}
}
});
test(`${route.path}: direction preserves combined filters and leaves the snapshot unchanged`, async () => {
const before = await read(route, { sort: "time", time_direction: "desc" });
const grouped = route.path.endsWith("/groups");
for (const direction of ["asc", "desc"]) {
const result = await read(route, {
sort: "time",
time_direction: direction,
severity: "high",
status: "pending",
q: "needle",
});
assert.deepEqual(result.items.map(route.key), grouped ? ["20", "10", "2", null] : ["13", "12", "10", "2"]);
assert.equal(result.total, 4);
if (grouped) assert.equal(result.finding_total, 4);
const empty = await read(route, { time_direction: direction, q: "absent fixture text" });
assert.deepEqual(empty.items, []);
assert.equal(empty.total, 0);
}
const after = await read(route, { sort: "time", time_direction: "desc" });
assert.deepEqual(after, before);
});
}
test("group dates use the latest matching finding after filters", async () => {
const route = routes[2];
const result = await read(route, { status: "resolved", time_direction: "asc" });
assert.equal(result.total, 1);
assert.equal(result.finding_total, 1);
assert.equal(result.items[0].task_id, "10");
assert.equal(result.items[0].last_found_at, fixtures[8].ts);
});
+18 -20
View File
@@ -1972,14 +1972,10 @@ 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 = 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 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));
const scoped = mockFindings.filter((f) => !q.get("task_id") || f.task_id === q.get("task_id"));
@@ -2055,11 +2051,16 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
max_severity: Math.max(...items.map((finding) => severityOrder[finding.severity])),
};
});
groups.sort((left, right) =>
q.get("sort") === "severity"
? right.max_severity - left.max_severity || right.last_found_at.localeCompare(left.last_found_at)
: right.last_found_at.localeCompare(left.last_found_at),
);
groups.sort((left, right) => {
const severityCompared = right.max_severity - left.max_severity;
if (q.get("sort") === "severity" && severityCompared !== 0) return severityCompared;
const timeCompared = Date.parse(left.last_found_at) - Date.parse(right.last_found_at);
if (timeCompared !== 0) return q.get("time_direction") === "asc" ? timeCompared : -timeCompared;
// Match the backend's task ID DESC NULLS LAST tie-breaker.
if (left.task_id === null) return right.task_id === null ? 0 : 1;
if (right.task_id === null) return -1;
return right.task_id.localeCompare(left.task_id, undefined, { numeric: true });
});
const rawPage = Number(q.get("page") ?? 1);
const rawPageSize = Number(q.get("limit") ?? 10);
const page = Number.isFinite(rawPage) && rawPage > 0 ? Math.floor(rawPage) : 1;
@@ -2273,13 +2274,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
}
// 全局:带 page/limit → 分页对象;否则裸数组(dashboard)。
if (!q.has("page") && !q.has("limit")) return mockFindings.map(withFid);
const sev = { critical: 4, high: 3, medium: 2, low: 1 } as const;
const list = mockApplyAssetScope(mockFilterFindings(q), q.get("asset_scope"));
list.sort((a, b) =>
q.get("sort") === "severity"
? sev[b.severity] - sev[a.severity] || +new Date(b.ts) - +new Date(a.ts)
: +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 rawPage = Number(q.get("page") ?? 1);
const rawPageSize = Number(q.get("limit") ?? 20);
const page = Number.isFinite(rawPage) && rawPage > 0 ? Math.floor(rawPage) : 1;