316 lines
11 KiB
JavaScript
316 lines
11 KiB
JavaScript
/**
|
|
* 通过真实页面操作补齐安全规则输入边界与旧工单查询路由的回归场景。
|
|
* 仅在已配置的本地 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,
|
|
};
|
|
}),
|
|
);
|
|
|
|
// 每种拒绝输入使用全新的浏览器上下文;上一轮的错误提示不能冒充本轮已处理完成。
|
|
for (const [name, input] of [
|
|
["unknown direction", "ot tcp 22 0.0.0.0/0 misspelled-direction"],
|
|
["unknown protocol", "in tcq 22 0.0.0.0/0 misspelled-protocol"],
|
|
[
|
|
"a valid rule followed by an unknown direction",
|
|
"out tcp 443 0.0.0.0/0 valid-first\not tcp 22 0.0.0.0/0 invalid-second",
|
|
],
|
|
]) {
|
|
test(
|
|
`security group batches reject ${name} without partial writes`,
|
|
{ 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);
|
|
// 弹窗本来就有 info 级使用说明;只有错误级别的可见提示才表示本次校验失败。
|
|
const errorAlert = dialog.locator('[role="alert"].p-message-error');
|
|
assert.equal(await errorAlert.count(), 0);
|
|
await dialog.locator("textarea").fill(input);
|
|
await dialog.getByRole("button", { name: "添加", exact: true }).click();
|
|
// 等到可观察的拒绝或错误下发,不用休眠长度推断“没有发请求”。
|
|
await eventually(
|
|
async () => writes.length > 0 || (await errorAlert.isVisible()),
|
|
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.equal(await errorAlert.isVisible(), true);
|
|
return { rejectedInput: name, 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,
|
|
};
|
|
}),
|
|
);
|