修复商品规则适配与安全随机密码生成
This commit is contained in:
1 parent
c1c8bf2696
commit
51a496822e
3 files changed
+448
-58
No files matched your search
+16
-5
@@ -1,10 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* 生成便于辨认的随机密码建议:至少含小写、大写和数字各一位,排除易混字符。
|
* 使用浏览器安全随机源生成密码:至少含小写、大写和数字各一位,排除易混字符。
|
||||||
* 现有实现使用 Math.random(非密码学随机源),不能当作高强度密钥/令牌生成器;
|
* 即使要求长度小于 3,三个必选字符也会保留;安全随机源不可用时明确失败,不降级。
|
||||||
* 即使 e 小于 3,三个必选字符也会保留。此处注释如实说明现有行为,不改变协议。
|
|
||||||
*/
|
*/
|
||||||
function e(e = 12) {
|
function e(e = 12) {
|
||||||
let t = (e) => e[Math.floor(Math.random() * e.length)],
|
const source = globalThis.crypto;
|
||||||
|
if (typeof source?.getRandomValues !== `function`)
|
||||||
|
throw Error(`当前环境不支持安全随机密码生成`);
|
||||||
|
const buffer = new Uint32Array(1);
|
||||||
|
// 只接受能均分为 upperBound 份的区间,避免直接取模让某些字符/洗牌位置概率偏高。
|
||||||
|
function randomIndex(upperBound) {
|
||||||
|
const limit = 2 ** 32 - (2 ** 32 % upperBound);
|
||||||
|
do source.getRandomValues(buffer);
|
||||||
|
while (buffer[0] >= limit);
|
||||||
|
return buffer[0] % upperBound;
|
||||||
|
}
|
||||||
|
let t = (e) => e[randomIndex(e.length)],
|
||||||
n = [
|
n = [
|
||||||
t(`abcdefghjkmnpqrstuvwxyz`),
|
t(`abcdefghjkmnpqrstuvwxyz`),
|
||||||
t(`ABCDEFGHJKMNPQRSTUVWXYZ`),
|
t(`ABCDEFGHJKMNPQRSTUVWXYZ`),
|
||||||
@@ -18,7 +28,8 @@ function e(e = 12) {
|
|||||||
),
|
),
|
||||||
i = [...n, ...r];
|
i = [...n, ...r];
|
||||||
for (let e = i.length - 1; e > 0; e--) {
|
for (let e = i.length - 1; e > 0; e--) {
|
||||||
let t = Math.floor(Math.random() * (e + 1));
|
// Fisher–Yates 与字符选取共用无偏采样,不能在打乱阶段重新引入非安全随机源。
|
||||||
|
let t = randomIndex(e + 1);
|
||||||
[i[e], i[t]] = [i[t], i[e]];
|
[i[e], i[t]] = [i[t], i[e]];
|
||||||
}
|
}
|
||||||
return i.join(``);
|
return i.join(``);
|
||||||
|
|||||||
+39
-53
@@ -36,8 +36,8 @@ function r(n) {
|
|||||||
.filter((e) => e.value !== ``);
|
.filter((e) => e.value !== ``);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 将后端 PCRE 风格 regexpr 的可表达部分转换为 JS RegExp。
|
* 只转换能确认等价的 ASCII PCRE 子集,不能把“JS 能编译”当作跨引擎语义相同。
|
||||||
* 不支持的高级构造标记 patternServerOnly,不能假装浏览器已经完成等价验证。
|
* Unicode、换行模式、未知 escape、POSIX/补集及高级分组均交给后端最终校验。
|
||||||
*/
|
*/
|
||||||
function i(e) {
|
function i(e) {
|
||||||
let n = t(e).trim();
|
let n = t(e).trim();
|
||||||
@@ -74,70 +74,56 @@ function i(e) {
|
|||||||
}
|
}
|
||||||
if (!/^[imsuxADSUXJnr]*$/.test(r))
|
if (!/^[imsuxADSUXJnr]*$/.test(r))
|
||||||
throw Error(`自定义字段验证规则修饰符无效`);
|
throw Error(`自定义字段验证规则修饰符无效`);
|
||||||
let o = /^\(\?([imsx]+)\)/.exec(n);
|
const serverOnly = { pattern: n, patternServerOnly: !0 };
|
||||||
o && ((r += o[1]), (n = n.slice(o[0].length)));
|
// 除 i 外的修饰符涉及字符/换行/量词等引擎差异;宁可多一次后端验证,也不误拒合法值。
|
||||||
let s = [...new Set(r.replace(/[^imsu]/g, ``))].join(``);
|
if (/[^i]/.test(r)) return serverOnly;
|
||||||
if (
|
let s = r ? `i` : ``,
|
||||||
/\(\?(?:[>('|&R0-9]|P[=>]|[imsx-]+:)|\(\*|\\[KCG]|(?:[+*?]|\{\d+(?:,\d*)?\})\+/.test(
|
c = ``,
|
||||||
n,
|
|
||||||
) ||
|
|
||||||
r.includes(`r`) ||
|
|
||||||
(r.includes(`J`) && /\(\?</.test(n))
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
pattern: n,
|
|
||||||
patternFlags: s || void 0,
|
|
||||||
patternServerOnly: !0,
|
|
||||||
};
|
|
||||||
// 逐字符翻译锚点、扩展空白模式和非捕获/非贪婪约定;字符组内不执行这些改写。
|
|
||||||
let c = ``,
|
|
||||||
l = !1;
|
l = !1;
|
||||||
for (let e = 0; e < n.length; e++) {
|
for (let e = 0; e < n.length; e++) {
|
||||||
let t = n[e];
|
let t = n[e];
|
||||||
|
if (t.charCodeAt(0) > 127) return serverOnly;
|
||||||
if (t === `\\`) {
|
if (t === `\\`) {
|
||||||
let t = n[++e],
|
let t = n[++e];
|
||||||
r = {
|
if (t === `d`) c += l ? `0-9` : `[0-9]`;
|
||||||
A: `(?<![\\s\\S])`,
|
else if (!l && t === `A`) c += `(?<![\\s\\S])`;
|
||||||
z: `(?![\\s\\S])`,
|
else if (!l && t === `z`) c += `(?![\\s\\S])`;
|
||||||
Z: `(?=\\n?(?![\\s\\S]))`,
|
else if (!l && t === `Z`) c += `(?=\\n?(?![\\s\\S]))`;
|
||||||
};
|
else if (t && (`nrtf`.includes(t) || `\\^$.*+?()[]{}|/-`.includes(t)))
|
||||||
c += !l && t && r[t] ? r[t] : `\\${t ?? ``}`;
|
c += `\\${t}`;
|
||||||
|
else return serverOnly;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (t === `[`) {
|
||||||
(t === `[` && !l ? (l = !0) : t === `]` && l && (l = !1),
|
// 嵌套方括号可能是 POSIX 类;否定字符组还涉及非 ASCII 编码宽度,不作猜测。
|
||||||
!l && r.includes(`x`))
|
if (l || n[e + 1] === `^`) return serverOnly;
|
||||||
) {
|
l = !0;
|
||||||
if (/\s/.test(t)) continue;
|
} else if (t === `]`) {
|
||||||
if (t === `#`) {
|
if (!l) return serverOnly;
|
||||||
for (
|
l = !1;
|
||||||
;
|
|
||||||
e < n.length &&
|
|
||||||
n[e] !==
|
|
||||||
`
|
|
||||||
`;
|
|
||||||
)
|
|
||||||
e++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!l && t === `$` && r.includes(`D`) && !s.includes(`m`)) {
|
if (l) {
|
||||||
c += `(?![\\s\\S])`;
|
c += t;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!l && t === `(` && r.includes(`n`) && n[e + 1] !== `?`) {
|
if (t === `.` || (t === `(` && n[e + 1] === `*`)) return serverOnly;
|
||||||
|
if (t === `(` && n[e + 1] === `?`) {
|
||||||
|
if (n[e + 2] !== `:`) return serverOnly;
|
||||||
c += `(?:`;
|
c += `(?:`;
|
||||||
|
e += 2;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
((c += t),
|
if (t === `{`) {
|
||||||
!l &&
|
const quantifier = /^\{\d+(?:,\d*)?\}/.exec(n.slice(e));
|
||||||
r.includes(`U`) &&
|
if (!quantifier || n[e + quantifier[0].length] === `+`) return serverOnly;
|
||||||
(`*+`.includes(t) ||
|
c += quantifier[0];
|
||||||
(t === `?` && e > 0 && n[e - 1] !== `(`) ||
|
e += quantifier[0].length - 1;
|
||||||
(t === `}` && /\{\d+(?:,\d*)?\}$/.test(c))) &&
|
continue;
|
||||||
(n[e + 1] === `?` ? e++ : (c += `?`)));
|
}
|
||||||
|
if (t === `}` || (`*+?`.includes(t) && n[e + 1] === `+`)) return serverOnly;
|
||||||
|
// PCRE 默认 $ 只允许最终 LF 前;JS 原生 $ 还会放行 CR/Unicode 行分隔符。
|
||||||
|
c += t === `$` ? `(?=\\n?(?![\\s\\S]))` : t;
|
||||||
}
|
}
|
||||||
r.includes(`A`) && (c = `(?<![\\s\\S])(?:${c})`);
|
|
||||||
try {
|
try {
|
||||||
new RegExp(c, s);
|
new RegExp(c, s);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user