修复安全组规则方向校验与旧工单切换错对象

This commit is contained in:
cieanyau committed 2026-11-06 01:38:19 +08:00
1 parent bcb47f102c
commit 4bc84717ff
3 files changed
+323 -4

No files matched your search

+9 -3
View File
@@ -579,7 +579,7 @@ var Te = {
let Q = ref(!1),
$ = ref(``),
Lt = ref(!1);
// 每行按方向、协议、端口、IP 和可选描述解析;空行忽略,不足四列明确报格式错误。
// 先把自由文本收敛为方向、协议的封闭枚举;任一行无效就拒绝整批,不能猜成入站规则。
function Rt(e) {
let t = [];
for (let n of e.split(`
@@ -589,9 +589,15 @@ var Te = {
let r = e.split(/\s+/);
if (r.length < 4) throw Error(`格式不正确:${e}`);
let [ee, i, te = ``, a = ``, ...o] = r;
let direction = ee.toLowerCase(),
protocol = i.toLowerCase();
if (!["in", "out"].includes(direction))
throw Error(`规则方向必须为 in 或 out:${ee}`);
if (!["tcp", "udp", "icmp", "all"].includes(protocol))
throw Error(`规则协议必须为 tcp、udp、icmp 或 all:${i}`);
t.push({
direction: ee === `out` ? `out` : `in`,
protocol: (i || `tcp`).toLowerCase(),
direction,
protocol,
port: te,
ip: a,
description: o.join(` `) || void 0,
+4 -1
View File
@@ -1,10 +1,13 @@
<script>
/**
* 旧工单详情路由适配层:复用动态工单详情页,详情 ID 由被复用页从路由参数/查询中读取。
* 旧工单详情路由适配层:复用动态详情页;完整地址变化时重建详情,避免沿用上一工单的 ID 与草稿。
*/
import "~/assets/styles/TicketAttachments.DtbJGLkE.css";
import "~/assets/styles/RichTextEditor.NkmgPT5m.css";
import "~/assets/styles/_id_.C-0--Lx-.css";
definePageMeta({
key: (route) => route.fullPath,
});
import { createBlock } from "vue";
import { defineComponent } from "vue";
import { openBlock } from "vue";
+310
View File
@@ -0,0 +1,310 @@
/**
* 通过真实页面操作补齐安全规则输入边界与旧工单查询路由的回归场景。
* 仅在已配置的本地 HTTP API 边界提供响应,路由、组件、会话和表单均使用生产实现。
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import {
appOrigin,
backendOrigin,
eventually,
fixtureRequest,
login,
scenario,
} from "./helpers/console-browser.mjs";
async function fulfillApi(route, data) {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "Access-Control-Allow-Origin": appOrigin },
body: JSON.stringify({ status: 200, msg: "", data }),
});
}
async function securityGroupBoundary(page) {
const writes = [];
const group = {
id: 1,
name: "审计安全组",
description: "本地规则输入验收",
rule_num: 0,
host_num: 0,
create_time: 1730000000,
};
await page.route(
(url) =>
url.origin === backendOrigin &&
url.pathname.startsWith("/console/v1/security_group"),
async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname.replace("/console/v1", "");
if (request.method() === "GET" && path === "/security_group")
return fulfillApi(route, { list: [group], count: 1 });
if (request.method() === "GET" && path === "/security_group/1/rule")
return fulfillApi(route, { list: [], count: 0 });
if (
request.method() === "POST" &&
path === "/security_group/1/rule/batch"
) {
// 只保留本场景的规则内容与认证布尔值,不把请求头或登录凭证写入验收产物。
const body = request.postDataJSON();
writes.push({
path,
body,
authenticated: !!request.headers().authorization,
});
return fulfillApi(route, { success_num: body.rule.length });
}
return route.fallback();
},
);
return writes;
}
async function openBatchEditor(page) {
await page.getByRole("button", { name: "快速添加", exact: true }).click();
const dialog = page.getByRole("dialog");
await dialog.getByText("高级规则编辑", { exact: true }).click();
return dialog;
}
test(
"security group batches preserve normalized inbound and outbound directions",
{ timeout: 25000 },
(t) =>
scenario(t, { loginMode: "password-only" }, async (page) => {
const writes = await securityGroupBoundary(page);
await login(page, "/security/groups/group?id=1");
const dialog = await openBatchEditor(page);
await dialog
.locator("textarea")
.fill(
[
"in tcp 22 0.0.0.0/0 inbound-lowercase",
"out tcp 443 0.0.0.0/0 outbound-lowercase",
"IN TCP 80 0.0.0.0/0 inbound-uppercase",
"OUT UDP 53 0.0.0.0/0 outbound-uppercase",
].join("\n"),
);
await dialog.getByRole("button", { name: "添加", exact: true }).click();
await dialog.waitFor({ state: "hidden" });
assert.equal(writes.length, 1);
assert.equal(writes[0].authenticated, true);
assert.deepEqual(writes[0].body.rule, [
{
direction: "in",
protocol: "tcp",
port: "22",
ip: "0.0.0.0/0",
description: "inbound-lowercase",
},
{
direction: "out",
protocol: "tcp",
port: "443",
ip: "0.0.0.0/0",
description: "outbound-lowercase",
},
{
direction: "in",
protocol: "tcp",
port: "80",
ip: "0.0.0.0/0",
description: "inbound-uppercase",
},
{
direction: "out",
protocol: "udp",
port: "53",
ip: "0.0.0.0/0",
description: "outbound-uppercase",
},
]);
return {
acceptedDirections: writes[0].body.rule.map((rule) => rule.direction),
acceptedProtocols: writes[0].body.rule.map((rule) => rule.protocol),
submittedBatches: writes.length,
};
}),
);
test(
"security group batches reject unknown enums without submitting partial rules",
{ timeout: 25000 },
(t) =>
scenario(t, { loginMode: "password-only" }, async (page) => {
const writes = await securityGroupBoundary(page);
await login(page, "/security/groups/group?id=1");
const dialog = await openBatchEditor(page);
const invalidInputs = [
"ot tcp 22 0.0.0.0/0 misspelled-direction",
"in tcq 22 0.0.0.0/0 misspelled-protocol",
"out tcp 443 0.0.0.0/0 valid-first\not tcp 22 0.0.0.0/0 invalid-second",
];
for (const input of invalidInputs) {
await dialog.locator("textarea").fill(input);
await dialog.getByRole("button", { name: "添加", exact: true }).click();
// 等到可观察的拒绝或错误下发,不用休眠长度推断“没有发请求”。
await eventually(
async () =>
writes.length > 0 || (await dialog.getByRole("alert").count()) > 0,
Boolean,
"Invalid rules must produce a form error or expose an unexpected write",
);
assert.equal(
writes.length,
0,
"Unknown enums must never reach the API",
);
assert.equal(await dialog.isVisible(), true);
assert.ok((await dialog.getByRole("alert").count()) > 0);
}
return { rejectedInputs: invalidInputs.length, submittedBatches: 0 };
}),
);
test(
"legacy ticket menu query navigation resets drafts and writes only the selected ticket",
{ timeout: 30000 },
(t) =>
scenario(t, { loginMode: "password-only" }, async (page) => {
const requests = [];
const tickets = new Map(
[1, 2].map((id) => [
id,
{
id,
ticket_num: `AUDIT-${id}`,
title: `审计工单${id}`,
content: `<p>工单${id}内容</p>`,
attachment: [],
replies: [],
status: "待回复",
create_time: 1730000000,
username: "audit-user",
client_id: 1,
color: "#777",
},
]),
);
await page.route(
(url) =>
url.origin === backendOrigin &&
(url.pathname === "/console/v1/menu" ||
url.pathname.startsWith("/console/v1/ticket/")),
async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname.replace(
"/console/v1",
"",
);
if (path === "/menu")
return fulfillApi(route, {
menu: [1, 2].map((id) => ({
id,
name: `打开工单${id}`,
menu_type: "custom",
url: `ticketDetails.htm?id=${id}`,
})),
});
if (path === "/ticket/config")
return fulfillApi(route, { ticket_notice_open: 0 });
const match = path.match(/^\/ticket\/(1|2)(?:\/(reply|close))?$/);
if (!match) return route.fallback();
const ticket = tickets.get(Number(match[1]));
requests.push({
method: request.method(),
path,
authenticated: !!request.headers().authorization,
});
if (request.method() === "GET" && !match[2])
return fulfillApi(route, { ticket });
if (request.method() === "POST" && match[2] === "reply") {
ticket.replies.push({
id: 10,
content: request.postDataJSON().content,
attachment: [],
create_time: 1730000001,
type: "Client",
client_name: "audit-user",
client_id: 1,
quote_reply_id: 0,
quote_info: null,
});
return fulfillApi(route, {});
}
if (request.method() === "PUT" && match[2] === "close") {
ticket.status = "已关闭";
return fulfillApi(route, {});
}
return route.fallback();
},
);
await login(page, "/tickets/ticket?id=1");
await page
.getByRole("heading", { name: "审计工单1", exact: true })
.waitFor();
const editor = page.getByRole("textbox", {
name: "回复内容",
exact: true,
});
await editor.fill("第一张工单尚未提交的草稿");
// 使用真正的 Nuxt 菜单链接更换 query,不访问组件实例,也不以整页刷新掩盖复用错误。
await page
.getByRole("navigation", { name: "会员中心导航" })
.getByRole("link", { name: "打开工单2", exact: true })
.click();
await page.waitForURL(
(url) =>
url.origin === appOrigin &&
url.pathname === "/tickets/ticket" &&
url.searchParams.get("id") === "2",
);
await page
.getByRole("heading", { name: "审计工单2", exact: true })
.waitFor();
assert.equal((await editor.innerText()).trim(), "");
await editor.fill("第二张工单的回复");
await page.getByRole("button", { name: "提交回复", exact: true }).click();
await eventually(
() => requests.filter((request) => request.method === "POST"),
(writes) => writes.length === 1,
"The reply must be sent to the selected ticket",
);
await page.getByRole("button", { name: "关闭工单", exact: true }).click();
await eventually(
() => requests.filter((request) => request.method === "PUT"),
(writes) => writes.length === 1,
"The close action must be sent to the selected ticket",
);
await page
.getByRole("button", { name: "关闭工单", exact: true })
.waitFor({ state: "hidden" });
const writes = requests.filter((request) => request.method !== "GET");
assert.deepEqual(
writes.map(({ method, path }) => ({ method, path })),
[
{ method: "POST", path: "/ticket/2/reply" },
{ method: "PUT", path: "/ticket/2/close" },
],
);
assert.equal(
requests.every((request) => request.authenticated),
true,
);
assert.equal(tickets.get(1).status, "待回复");
assert.equal(
(await fixtureRequest("requests")).filter(
(request) => request.path === "/login",
).length,
1,
);
return {
selectedTicketId: 2,
previousDraftDiscarded: true,
writes: writes.map(({ method, path }) => ({ method, path })),
previousTicketUnchanged: true,
};
}),
);