first commit
This commit is contained in:
commit
db5762abd4
687 files changed
+171307
No files matched your search
@@ -0,0 +1 @@
|
||||
fixtures/runs/
|
||||
@@ -0,0 +1,31 @@
|
||||
# 浏览器端到端验收
|
||||
|
||||
先在 `frontend` 安装项目依赖和 `playwright`,执行 `npm run build`,再执行 `npm test`。
|
||||
测试使用 Node 自带的 `node:test`,直接操作实际构建后的页面,不替换项目内部模块。
|
||||
|
||||
测试会自动启动两个仅监听 `127.0.0.1` 的独立进程:构建后的 Nuxt 预览服务和 HTTP 假后端。
|
||||
后端地址仅通过生产已有的 `NUXT_PUBLIC_API_BASE` 注入。不会读取 `.env`,不会请求生产服务;
|
||||
浏览器访问两个本地 origin 之外的任何 HTTP 地址都会被阻断并让场景失败。
|
||||
|
||||
默认 Chrome 路径是 macOS 的 `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`。
|
||||
其他平台通过 `CHROME_PATH` 指定 Chromium/Chrome 可执行文件,或指定已安装的 Playwright Chromium:
|
||||
|
||||
```sh
|
||||
CHROME_PATH=/absolute/path/to/chrome npm test
|
||||
```
|
||||
|
||||
默认测试当前 `frontend/.output`。如需验证其他工作树中已经构建的前端,可设置
|
||||
`E2E_APP_ROOT=/absolute/path/to/frontend`;测试依赖仍从当前工作树解析。
|
||||
|
||||
每次运行在 `tests/fixtures/runs/run-*` 下生成截图和 `manifest.json`。
|
||||
清单只包含稳定的场景结果、行为观察、请求形状以及浏览器错误/网络越界;不包含随机端口、
|
||||
时间戳、请求顺序、密码或令牌。该目录被忽略,不会提交生成物。
|
||||
另有各场景的 `.requests.json` 诊断附件,保留请求先后顺序和是否携带认证,不保存凭证值。
|
||||
|
||||
覆盖登录协议、未登录/失效会话保护、空菜单权威性、SSH 搜索的 10/10/5 分页与重置、
|
||||
挂起退出接口时立即清理本地登录、危险返回地址、支付成功及挂起状态请求下的独立支付期限。
|
||||
还覆盖临时登录凭证的首次验证、已有会话再验证、失败时地址脱敏及原页面重试,打开支付页时退出/失效不重新初始化,
|
||||
以及使用同一浏览器真实存储事件的双标签页退出同步;所有会话都来自实际登录或临时凭证入口,不预置 JWT。
|
||||
模板改写的回归场景还覆盖短信发送协议、60 秒倒计时、登录方式禁用配置及 SSH 新增/编辑保存/删除末页恢复。
|
||||
支付超时使用浏览器的外部时钟推进,不添加测试专用产品配置。假后端数据契约来自现有部署产物。
|
||||
真实服务器联调、真实支付渠道、短信/邮件投递、第三方 CAPTCHA 及完整安全挑战流程不在离线验收范围内。
|
||||
File diff suppressed because it is too large.
Load diff
Vendored
+268
@@ -0,0 +1,268 @@
|
||||
import { createServer } from "node:http";
|
||||
|
||||
const token = "e2e-session-token";
|
||||
const account = {
|
||||
id: 1,
|
||||
username: "audit-user",
|
||||
email: "audit@example.invalid",
|
||||
credit: "100.00",
|
||||
};
|
||||
const initialKeys = Array.from({ length: 28 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name:
|
||||
index < 25
|
||||
? `audit-${String(index + 1).padStart(2, "0")}`
|
||||
: `other-${index + 1}`,
|
||||
public_key: `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestPublicKey${index + 1}`,
|
||||
finger_print: `SHA256:fixture-${index + 1}`,
|
||||
}));
|
||||
|
||||
let options = {};
|
||||
let requests = [];
|
||||
let paid = false;
|
||||
let keys = initialKeys.map((key) => ({ ...key }));
|
||||
|
||||
function reply(response, data = {}, extra = {}, httpStatus = 200) {
|
||||
response.writeHead(httpStatus, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ status: httpStatus, msg: "", data, ...extra }));
|
||||
}
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
response.setHeader("Access-Control-Allow-Origin", "*");
|
||||
response.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Authorization, Content-Type, Accept",
|
||||
);
|
||||
response.setHeader(
|
||||
"Access-Control-Allow-Methods",
|
||||
"GET, POST, PUT, DELETE, OPTIONS",
|
||||
);
|
||||
if (request.method === "OPTIONS") return response.writeHead(204).end();
|
||||
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
let body;
|
||||
try {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString();
|
||||
body = raw ? JSON.parse(raw) : undefined;
|
||||
} catch {
|
||||
return reply(response, {}, { msg: "Invalid fixture request body" }, 400);
|
||||
}
|
||||
|
||||
if (url.pathname === "/__control/reset" && request.method === "POST") {
|
||||
options = body ?? {};
|
||||
requests = [];
|
||||
paid = false;
|
||||
keys = initialKeys
|
||||
.slice(0, options.sshLastPageSingleRow ? 11 : initialKeys.length)
|
||||
.map((key) => ({ ...key }));
|
||||
return reply(response);
|
||||
}
|
||||
if (url.pathname === "/__control/options" && request.method === "POST") {
|
||||
options = { ...options, ...body };
|
||||
return reply(response);
|
||||
}
|
||||
if (url.pathname === "/__control/requests") return reply(response, requests);
|
||||
|
||||
const path = url.pathname.replace(/^\/console\/v1/, "");
|
||||
const record = {
|
||||
method: request.method,
|
||||
path,
|
||||
query: Object.fromEntries(url.searchParams),
|
||||
body,
|
||||
authorization: request.headers.authorization ?? null,
|
||||
referrer: request.headers.referer ?? null,
|
||||
handled: true,
|
||||
};
|
||||
requests.push(record);
|
||||
|
||||
if (path === "/common" && request.method === "GET") {
|
||||
return reply(response, {
|
||||
website_name: "本地前端验收",
|
||||
login_phone_password: options.loginMode === "sms-only" ? 0 : 1,
|
||||
login_email_password: options.loginMode === "sms-only" ? 0 : 1,
|
||||
login_phone_verify: options.loginMode === "password-only" ? 0 : 1,
|
||||
first_login_method:
|
||||
options.loginMode === "password-only" ? "code" : "password",
|
||||
captcha_client_login: 0,
|
||||
captcha_client_login_error: 0,
|
||||
captcha_client_login_error_3_times: 0,
|
||||
register_phone: 1,
|
||||
register_email: 1,
|
||||
plugin_configuration: { mp_weixin_notice: { scan_login: 0 } },
|
||||
oauth: [],
|
||||
});
|
||||
}
|
||||
if (path === "/login" && request.method === "POST")
|
||||
return reply(response, { jwt: token });
|
||||
if (path === "/phone/code" && request.method === "POST")
|
||||
return reply(response);
|
||||
|
||||
if (request.headers.authorization !== `Bearer ${token}`) {
|
||||
record.invalidAuthorization = true;
|
||||
return reply(response, {}, { msg: "Missing fixture session" }, 401);
|
||||
}
|
||||
|
||||
if (path === "/index" && request.method === "GET" && options.expiredLegacy)
|
||||
return reply(
|
||||
response,
|
||||
{},
|
||||
{ status: 401, msg: "Expired legacy fixture session" },
|
||||
);
|
||||
if (path === "/index" && request.method === "GET")
|
||||
return reply(response, { account });
|
||||
if (path === "/account" && request.method === "GET")
|
||||
return reply(response, { account });
|
||||
if (path === "/menu" && request.method === "GET") {
|
||||
return reply(response, {
|
||||
menu: options.emptyMenu
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 1,
|
||||
name: "SSH 密钥",
|
||||
menu_type: "custom",
|
||||
url: "/security/ssh-keys",
|
||||
},
|
||||
{ id: 2, name: "订单", menu_type: "custom", url: "/orders" },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (path === "/client_care/mail" && request.method === "GET") {
|
||||
return reply(response, { credit_limit: { count: 0, list: [], type: [] } });
|
||||
}
|
||||
if (path === "/logout" && request.method === "POST") {
|
||||
if (options.hangLogout) return;
|
||||
return reply(response);
|
||||
}
|
||||
if (path === "/ssh_key" && request.method === "GET") {
|
||||
if (options.hangSshBody) {
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.write('{"status":200,"msg":"","data":');
|
||||
return;
|
||||
}
|
||||
if (options.expiredSession)
|
||||
return reply(
|
||||
response,
|
||||
{},
|
||||
{ status: 401, msg: "Expired fixture session" },
|
||||
);
|
||||
const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
|
||||
const limit = Math.max(1, Number(url.searchParams.get("limit")) || 10);
|
||||
return reply(response, {
|
||||
list: keys.slice((page - 1) * limit, page * limit),
|
||||
count: keys.length,
|
||||
});
|
||||
}
|
||||
if (path === "/ssh_key" && request.method === "POST") {
|
||||
const id = Math.max(0, ...keys.map((key) => key.id)) + 1;
|
||||
keys.push({ id, ...body, finger_print: `SHA256:fixture-${id}` });
|
||||
return reply(response, { id });
|
||||
}
|
||||
const keyId = /^\/ssh_key\/(\d+)$/.exec(path);
|
||||
if (keyId && request.method === "PUT") {
|
||||
const key = keys.find((entry) => entry.id === Number(keyId[1]));
|
||||
if (!key) return reply(response, {}, { msg: "Fixture key not found" }, 404);
|
||||
Object.assign(key, body);
|
||||
return reply(response);
|
||||
}
|
||||
if (keyId && request.method === "DELETE") {
|
||||
keys = keys.filter((entry) => entry.id !== Number(keyId[1]));
|
||||
return reply(response);
|
||||
}
|
||||
if (path === "/order/42" && request.method === "GET") {
|
||||
if (options.expiredPayment)
|
||||
return reply(
|
||||
response,
|
||||
{},
|
||||
{ status: 401, msg: "Expired payment fixture session" },
|
||||
);
|
||||
return reply(response, {
|
||||
order: {
|
||||
id: 42,
|
||||
amount: "20.00",
|
||||
credit: "0.00",
|
||||
amount_unpaid: paid ? "0.00" : "20.00",
|
||||
type: "new",
|
||||
status: paid ? "Paid" : "Unpaid",
|
||||
create_time: 1700000000,
|
||||
pay_time: paid ? 1700000060 : 0,
|
||||
gateway: "alipay",
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
product_name: "本地测试实例",
|
||||
description: "前端支付验收",
|
||||
amount: "20.00",
|
||||
},
|
||||
],
|
||||
},
|
||||
customfields: { is_pre_invoice: 0 },
|
||||
});
|
||||
}
|
||||
if (path === "/order/42/transaction_record" && request.method === "GET") {
|
||||
return reply(response, {
|
||||
list: paid
|
||||
? [{ amount: "20.00", create_time: 1700000060, description: "alipay" }]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
if (path === "/gateway" && request.method === "GET") {
|
||||
return reply(response, {
|
||||
list: [{ id: 1, name: "alipay", title: "本地测试支付" }],
|
||||
});
|
||||
}
|
||||
if (path === "/voucher/pay" && request.method === "GET")
|
||||
return reply(response, { list: [] });
|
||||
if (path === "/pay" && request.method === "POST") {
|
||||
if (options.hangPaymentStatus)
|
||||
return reply(response, { html: '<div role="status">本地支付渠道</div>' });
|
||||
paid = true;
|
||||
return reply(response, {}, { code: "Paid" });
|
||||
}
|
||||
if (path === "/pay/42/status" && request.method === "GET") {
|
||||
if (options.hangPaymentStatus) return;
|
||||
return reply(response, {}, { code: paid ? "Paid" : "Unpaid" });
|
||||
}
|
||||
if (path === "/certification/info" && request.method === "GET") {
|
||||
return reply(response, {
|
||||
certification_open: 0,
|
||||
certification_company_open: 0,
|
||||
is_certification: 0,
|
||||
});
|
||||
}
|
||||
if (
|
||||
["/ticket", "/announcement", "/host"].includes(path) &&
|
||||
request.method === "GET"
|
||||
) {
|
||||
return reply(response, {
|
||||
list: [],
|
||||
count: 0,
|
||||
...(path === "/host" ? { server_page: "classified-v1" } : {}),
|
||||
});
|
||||
}
|
||||
if (path === "/withdraw/rule/credit" && request.method === "GET")
|
||||
return reply(response, {});
|
||||
if (path === "/recommend/promoter" && request.method === "GET")
|
||||
return reply(response, { promoter: { permission: 0 } });
|
||||
|
||||
record.handled = false;
|
||||
reply(
|
||||
response,
|
||||
{},
|
||||
{ msg: `Unhandled fixture endpoint: ${record.method} ${path}` },
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ origin: `http://127.0.0.1:${server.address().port}` })}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
server.closeAllConnections();
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
Reference in new issue
Block a user