Files
console-dev/tests/review-routes.e2e.test.mjs
T

537 lines
18 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: "待回复",
can_operate: 1,
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 = "处理完毕";
ticket.can_operate = 0;
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" });
assert.equal(await editor.getAttribute("contenteditable"), "false");
assert.equal(await page.locator("#reply-files").isDisabled(), true);
assert.equal(
await page
.getByRole("button", { name: "提交回复", exact: true })
.isDisabled(),
true,
);
assert.equal(
await page.getByRole("button", { name: "引用", exact: true }).count(),
0,
);
assert.equal(
await page.getByRole("button", { name: "催单", exact: true }).count(),
0,
);
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,
permissionRevocationDisablesActions: true,
};
}),
);
// 补齐既有工单场景的权限空隙:只读工单在动态详情和旧菜单路由中都不能从展示文案推断可操作。
for (const [routePath, status] of [
["/tickets/2", "处理中"],
["/tickets/ticket?id=2", "服务已结束"],
]) {
test(
`ticket can_operate denies writes at ${routePath} with custom display status`,
{ timeout: 25000 },
(t) =>
scenario(t, { loginMode: "password-only" }, async (page) => {
const writes = [];
const ticket = {
id: 2,
ticket_num: "AUDIT-2",
title: "只读工单",
content: "<p>历史工单仍可阅读</p>",
status,
can_operate: 0,
create_time: 1730000000,
username: "audit-user",
client_id: 1,
color: "#777",
attachment: [],
replies: [
{
id: 10,
content: "<p>历史客服回复</p>",
attachment: [],
create_time: 1730000001,
type: "Admin",
admin_name: "客服",
quote_reply_id: 0,
quote_info: null,
},
],
};
await page.route(
(url) =>
url.origin === backendOrigin &&
(url.pathname.startsWith("/console/v1/ticket/") ||
url.pathname === "/console/v1/upload"),
async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname.replace(
"/console/v1",
"",
);
if (path === "/ticket/config")
return fulfillApi(route, { ticket_notice_open: 0 });
if (request.method() === "GET" && path === "/ticket/2")
return fulfillApi(route, { ticket });
if (["POST", "PUT"].includes(request.method())) {
writes.push({ method: request.method(), path });
return fulfillApi(route, { save_name: "unused-attachment.txt" });
}
return route.fallback();
},
);
await login(page, routePath);
await page
.getByRole("heading", { name: ticket.title, exact: true })
.waitFor();
assert.equal(
await page.getByText("历史客服回复", { exact: true }).isVisible(),
true,
);
const editor = page.getByRole("textbox", {
name: "回复内容",
exact: true,
});
assert.equal(await editor.getAttribute("contenteditable"), "false");
assert.equal(await page.locator("#reply-files").isDisabled(), true);
assert.equal(
await page
.getByRole("button", { name: "提交回复", exact: true })
.isDisabled(),
true,
);
for (const name of ["引用", "催单", "关闭工单"])
assert.equal(
await page.getByRole("button", { name, exact: true }).count(),
0,
);
// 文件输入收到浏览器 change 事件时,处理函数也必须遵守权限;仅修改 HTTP 外部边界。
await page.locator("#reply-files").setInputFiles({
name: "read-only-ticket.txt",
mimeType: "text/plain",
buffer: Buffer.from("local permission regression"),
});
assert.deepEqual(writes, []);
return {
routePath,
displayStatus: status,
serverCanOperate: ticket.can_operate,
historicalRepliesReadable: true,
editingAndWritesBlocked: true,
writes,
};
}),
);
}
test(
"in-flight ticket uploads stop the remaining files when server permission is revoked",
{ timeout: 25000 },
(t) =>
scenario(t, { loginMode: "password-only" }, async (page) => {
const uploads = [];
const ticket = {
id: 2,
ticket_num: "AUDIT-2",
title: "上传期间关闭工单",
content: "<p>本机权限并发验收</p>",
status: "处理中",
can_operate: 1,
create_time: 1730000000,
username: "audit-user",
client_id: 1,
color: "#777",
attachment: [],
replies: [],
};
let releaseFirstUpload;
const firstUploadResponse = new Promise((resolve) => {
releaseFirstUpload = resolve;
});
await page.route(
(url) =>
url.origin === backendOrigin &&
(url.pathname.startsWith("/console/v1/ticket/") ||
url.pathname === "/console/v1/upload"),
async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname.replace(
"/console/v1",
"",
);
if (path === "/ticket/config")
return fulfillApi(route, { ticket_notice_open: 0 });
if (request.method() === "GET" && path === "/ticket/2")
return fulfillApi(route, { ticket });
if (request.method() === "PUT" && path === "/ticket/2/close") {
ticket.can_operate = 0;
ticket.status = "处理完毕";
return fulfillApi(route, {});
}
if (request.method() === "POST" && path === "/upload") {
const index = uploads.push({ method: "POST", path });
if (index === 1) await firstUploadResponse;
return fulfillApi(route, { save_name: `attachment-${index}.txt` });
}
return route.fallback();
},
);
try {
await login(page, "/tickets/2");
await page
.getByRole("heading", { name: ticket.title, exact: true })
.waitFor();
await page.locator("#reply-files").setInputFiles(
["first.txt", "second.txt"].map((name) => ({
name,
mimeType: "text/plain",
buffer: Buffer.from("local permission regression"),
})),
);
await eventually(
() => uploads.length,
(count) => count === 1,
"First upload reached the local HTTP boundary",
);
await page
.getByRole("button", { name: "关闭工单", exact: true })
.click();
await page
.getByRole("button", { name: "关闭工单", exact: true })
.waitFor({ state: "hidden" });
releaseFirstUpload();
await page
.getByText("正在上传…", { exact: true })
.waitFor({ state: "hidden" });
assert.equal(
uploads.length,
1,
"Revoked permission must stop the second upload",
);
assert.equal(await page.locator(".reply-file-list").count(), 0);
return {
selectedFiles: 2,
uploadsStarted: uploads.length,
serverCanOperate: ticket.can_operate,
alreadyStartedUploadCompleted: true,
remainingUploadsStopped: true,
revokedUploadNotAddedToDraft: true,
};
} finally {
releaseFirstUpload();
}
}),
);