mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
Merge pull request #62 from zabcxky-123/codex/fix-findings-mock-time-sort
fix(web): 对齐 Mock 发现列表的时间排序
This commit is contained in:
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" />
|
||||
) : (
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user