/** * 失败模式(先于实现记录): * 规则适配: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); } });