修复商品规则适配与安全随机密码生成

This commit is contained in:
cieanyau committed 2026-11-06 01:47:43 +08:00
1 parent c1c8bf2696
commit 51a496822e
3 files changed
+448 -58

No files matched your search

+393
View File
@@ -0,0 +1,393 @@
/**
* 失败模式(先于实现记录):
* 规则适配:POSIX、引用块、未知 escape、Unicode/换行模式、补集和高级分组即使能被 JS
* 编译也未必等价;不确定时必须交后端,常见 ASCII 规则仍须拦截无效输入。
* 密码算法:缺少/故障的安全随机源不能降级;字符选择和洗牌均不能有模偏差;
* 默认、短于三位和较长长度必须保留原字符集及三类字符,不能依赖 Math.random。
* 浏览器场景走真实登录、商品配置/结算与实例重置密码,只替换外部 HTTP/随机源边界。
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import { t as password } from "../app/lib/password-2.js";
import { n as fieldPattern } from "../app/lib/product-fields.js";
import {
backendOrigin,
eventually,
login,
scenario,
} from "./helpers/console-browser.mjs";
const alphabet = /^[abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789]+$/;
function assertPassword(value, length) {
// 不把生成密码写入断言失败或验收附件;清单只保存这些不含秘密的性质。
assert.equal(value.length, length, "Password length must match its contract");
assert.equal(
alphabet.test(value),
true,
"Password alphabet must remain unchanged",
);
assert.equal(/[a-z]/.test(value), true, "Password needs a lowercase letter");
assert.equal(/[A-Z]/.test(value), true, "Password needs an uppercase letter");
assert.equal(/[2-9]/.test(value), true, "Password needs a digit");
}
async function boundary(page, fields = []) {
const state = { settlements: [], resets: [], rejectFields: false };
await page
.context()
.route(`${backendOrigin}/console/v1/**`, async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname.replace(
/^\/console\/v1/,
"",
);
const reply = (data, extra = {}) => {
assert.ok(
request.headers().authorization,
"Scenario APIs require the real login session",
);
return route.fulfill({
json: { status: 200, msg: "", data, ...extra },
headers: { "Access-Control-Allow-Origin": "*" },
});
};
if (path === "/product/123")
return reply({
product: {
id: 123,
name: "本地字段验收商品",
module: "idcsmart_common",
description: "",
},
});
if (path === "/idcsmart_common/product/123/configoption")
return reply({
common_product: { pay_type: "free" },
configoptions: [],
});
if (path === "/product/123/self_defined_field/order_page")
return reply({ data: fields });
if (path === "/product/123/config_option")
return reply({ price_total: "0.00" });
if (path === "/purchase_limit/check")
return reply({ allowed: true, message: "" });
if (path === "/product/settle") {
state.settlements.push(request.postDataJSON());
if (state.rejectFields)
return reply({}, { status: 400, msg: "字段内容未通过服务端校验" });
return reply({ order_id: 42, amount: "0.00" });
}
if (path === "/host/5")
return reply({
host: {
id: 5,
product_id: 123,
module: "mf_cloud",
name: "本地密码验收实例",
status: "Active",
billing_cycle: "monthly",
upgrade_allowed: false,
},
});
if (path === "/host/5/view") return reply({ module: "mf_cloud" });
if (path === "/mf_cloud/5")
return reply({
image: { id: 6, name: "Ubuntu 24.04" },
port: 22,
username: "root",
cpu: 2,
memory: 4,
config: { reinstall_sms_verify: 0, reset_password_sms_verify: 0 },
});
if (path === "/mf_cloud/5/status")
return reply({ status: "on", rescue: false });
if (path === "/mf_cloud/5/disk") return reply({ list: [] });
if (path === "/mf_cloud/5/flow")
return reply({ total: 100, used: 0, leave: 100 });
if (path === "/mf_cloud/5/real_data")
return reply({ cpu: 0, memory: 0, disk: 0 });
if (path === "/mf_cloud/5/chart") return reply({ list: [] });
if (path === "/product/123/mf_cloud/image") return reply({ list: [] });
if (path === "/mf_cloud/5/reset_password") {
state.resets.push(request.postDataJSON());
return reply({});
}
return route.fallback();
});
return state;
}
test(
"product fields preserve PCRE-only values through the real checkout",
{ timeout: 45000 },
(t) =>
scenario(t, {}, async (page) => {
const cases = [
["ASCII编号", "/^[A-Z]+$/", "ABC"],
["POSIX字母", "/^[[:alpha:]]+$/", "ABC"],
["引用块", "/^\\Qa+b\\E$/", "a+b"],
["水平空白", "/^a\\hb$/", "a b"],
["垂直空白", "/^a\\vb$/", "a\nb"],
["换行约定", "/^a\\Rb$/", "a\nb"],
["八进制字符", "/^\\o{101}$/", "A"],
["十六进制字符", "/^\\x{41}$/", "A"],
["PCRE空白", "/^a\\sb$/", "a b"],
];
const fields = cases.map(([field_name, regexpr], index) => ({
id: index + 1,
field_type: "textarea",
field_name,
regexpr,
is_required: 1,
}));
const state = await boundary(page, fields);
await login(page, "/products/123");
for (const [index, [, , value]] of cases.entries())
await page.locator(`#product-field-${index + 1}`).fill(value);
const buy = page.getByRole("button", { name: "立即购买", exact: true });
const reprice = page.getByRole("button", {
name: /^(计算报价|重新获取报价)$/,
});
if (await reprice.isVisible()) await reprice.click();
// 单纯 ASCII 规则仍在浏览器拒绝,不能为了解决 PCRE 误判而关闭所有验证。
await page.locator("#product-field-1").fill("abc");
if (await reprice.isVisible()) await reprice.click();
await buy.click();
await page.getByText("ASCII编号格式不正确", { exact: true }).waitFor();
assert.equal(state.settlements.length, 0);
await page.locator("#product-field-1").fill("ABC");
if (await reprice.isVisible()) await reprice.click();
await buy.click();
await eventually(
() => state.settlements.length,
(n) => n === 1,
"Valid PCRE values must reach the real settlement request",
);
assert.deepEqual(
state.settlements[0].self_defined_field,
Object.fromEntries(
cases.map(([, , value], index) => [String(index + 1), value]),
),
);
return {
asciiInvalidBlocked: true,
pcreFieldValuesPreserved: true,
fieldCount: cases.length,
};
}),
);
test(
"server-only field rules preserve the backend validation result",
{ timeout: 30000 },
(t) =>
scenario(t, {}, async (page) => {
const state = await boundary(page, [
{
id: 1,
field_type: "text",
field_name: "水平空白",
regexpr: "/^\\h+$/",
is_required: 1,
},
]);
state.rejectFields = true;
await login(page, "/products/123");
await page.locator("#product-field-1").fill("h");
const reprice = page.getByRole("button", {
name: /^(计算报价|重新获取报价)$/,
});
if (await reprice.isVisible()) await reprice.click();
await page.getByRole("button", { name: "立即购买", exact: true }).click();
await page
.getByText("字段内容未通过服务端校验", { exact: true })
.waitFor();
assert.equal(state.settlements.length, 1);
assert.equal(state.settlements[0].self_defined_field["1"], "h");
return { serverValidationRequired: true, backendRejectionVisible: true };
}),
);
test(
"instance password generation uses browser CSPRNG and reaches reset HTTP",
{ timeout: 35000 },
(t) =>
scenario(t, {}, async (page) => {
const state = await boundary(page);
// 系统随机源是外部边界。只计数真实 WebCrypto,不替换产品内部函数,也不保存密码。
await page.addInitScript(() => {
const original = crypto.getRandomValues.bind(crypto);
window.randomSourceCalls = 0;
crypto.getRandomValues = (array) => {
window.randomSourceCalls++;
return original(array);
};
});
await login(page, "/servers/server?id=5");
await page.getByRole("tab", { name: "访问控制", exact: true }).click();
const before = await page.evaluate(() => window.randomSourceCalls);
await page.getByRole("button", { name: "重置密码", exact: true }).click();
const dialog = page.getByRole("dialog", {
name: "重置密码",
exact: true,
});
const input = dialog.getByLabel("新密码", { exact: true });
assertPassword(await input.inputValue(), 12);
const afterOpen = await page.evaluate(() => window.randomSourceCalls);
assert.equal(
afterOpen > before,
true,
"Opening reset must use WebCrypto",
);
await dialog.getByRole("button", { name: "随机", exact: true }).click();
const generated = await input.inputValue();
assertPassword(generated, 12);
assert.equal(
(await page.evaluate(() => window.randomSourceCalls)) > afterOpen,
true,
"Regenerating the password must use WebCrypto",
);
await dialog.getByRole("button", { name: "确定", exact: true }).click();
await eventually(
() => state.resets.length,
(n) => n === 1,
"Generated password must reach reset HTTP",
);
assert.equal(
state.resets[0].password === generated,
true,
"Reset must use the displayed password",
);
return {
length: 12,
originalAlphabet: true,
allCharacterClasses: true,
secureRandomUsed: true,
resetMatchesInput: true,
};
}),
);
test("password algorithm rejects biased draws and fails closed without secure randomness", () => {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto");
const originalRandom = Math.random;
const insecureSource = new Error(
"Non-cryptographic randomness must not be used",
);
try {
Math.random = () => {
throw insecureSource;
};
// UINT32_MAX 不属于 23 字符集和三位洗牌的等分区间;8/2 等整除区间无需拒绝。
let draws = [
0xffffffff, 0, 0xffffffff, 0, 0xffffffff, 0xffffffff, 1, 0xffffffff,
];
Object.defineProperty(globalThis, "crypto", {
configurable: true,
value: {
getRandomValues(array) {
for (let index = 0; index < array.length; index++)
array[index] = draws.shift() ?? 0;
return array;
},
},
});
const short = password(3);
assertPassword(short, 3);
assert.equal(
short === "a9A",
true,
"Biased character/shuffle draws must be retried",
);
draws = [0, 0, 0, 0xffffffff, 0, 0xffffffff, 0xffffffff, 0, 0xffffffff];
const longer = password(4);
assertPassword(longer, 4);
assert.equal(
longer === "2Aaa",
true,
"The combined alphabet must also reject biased draws",
);
for (const length of [undefined, 0, 2, 64]) {
const expected = length === undefined ? 12 : Math.max(3, length);
assertPassword(password(length), expected);
}
Object.defineProperty(globalThis, "crypto", {
configurable: true,
value: undefined,
});
assert.throws(
() => password(),
(error) => error instanceof Error && error !== insecureSource,
);
Object.defineProperty(globalThis, "crypto", {
configurable: true,
value: {},
});
assert.throws(
() => password(),
(error) => error instanceof Error && error !== insecureSource,
);
const unavailable = new Error("Secure source unavailable");
Object.defineProperty(globalThis, "crypto", {
configurable: true,
value: {
getRandomValues() {
throw unavailable;
},
},
});
assert.throws(
() => password(),
(error) => error === unavailable,
);
} finally {
Math.random = originalRandom;
if (descriptor) Object.defineProperty(globalThis, "crypto", descriptor);
else delete globalThis.crypto;
}
});
test("field adapter delegates unproven PCRE semantics and retains equivalent ASCII subset", () => {
for (const rule of [
"/^[[:alpha:]]+$/",
"/^\\Qa+b\\E$/",
"/^\\h+$/",
"/^\\H+$/",
"/^\\v+$/",
"/^\\V+$/",
"/^\\R+$/",
"/^\\X+$/",
"/^\\s+$/",
"/^\\S+$/",
"/^\\w+$/",
"/^\\W+$/",
"/^\\D+$/",
"/^\\q+$/",
"/^\\o{101}$/",
"/^\\x{41}$/",
"/^[^a]+$/",
"/^.+$/",
"/^a$/m",
"/^a$/u",
"/^a$/x",
"/^(?i)a$/",
"/^(?>a)$/",
"/^(a)\\1$/",
"/^\\p{L}+$/u",
"/^é+$/",
"/^(?=a)a$/",
])
assert.equal(fieldPattern(rule).patternServerOnly, true, rule);
for (const [rule, valid, invalid] of [
["/^[A-Z][A-Z0-9_-]{1,8}$/i", "aB_2", "éé"],
["/^\\d{2}$/", "23", "ab"],
["/\\A(?:abc|def)\\z/", "abc", "abc\n"],
["/^[abc]+$/", "abc\n", "abc\r"],
]) {
const normalized = fieldPattern(rule);
assert.notEqual(normalized.patternServerOnly, true, rule);
const regex = new RegExp(normalized.pattern, normalized.patternFlags);
assert.equal(regex.test(valid), true, rule);
assert.equal(regex.test(invalid), false, rule);
}
});