安全与权限: - 站点访问权限改为 optional_host_permissions,保存配置时逐域名授权;移除 host_permissions 与 tabs,安装提示不再出现全站数据访问与浏览记录 - 主口令不再写入 storage.session,改用 IndexedDB 中不可导出的 CryptoKey 句柄恢复解锁,锁定即丢弃 - content script 注入范围只注册已授权域名,并随权限变化即时收敛 自动登录: - 支持 iframe 内的登录表单:脚本注入所有帧,逐帧按自身地址匹配配置 - 手动填充改为逐帧探测,定向发送到真正含密码框的帧 - 限流修正:只在真正触发提交后计数;判定登录成功后立即清零;达到上限时页面给出可见提示;重置判断只看启用中的配置 - SPA 重试改为 DOM 变更门控 + 退避,并单独监听已发现的 Shadow Root 工程化与文档: - 引入 ESLint(扁平配置)与 Prettier,CI 增加 lint 与 format:check - 信息类日志改为 debugLog(默认静默,chrome.storage.local.debugLog 开关) - 测试 123 → 128 用例(新增权限、iframe、限流相关用例) - README / PRIVACY / CHANGELOG 同步
248 lines
8.9 KiB
JavaScript
248 lines
8.9 KiB
JavaScript
// tests/utils.test.js
|
||
// 测试 utils.js 中的纯函数:isDomainMatch(URL 路径匹配)和 escapeHtml(HTML 转义)
|
||
// 运行:node --test tests/utils.test.js
|
||
|
||
const { describe, it } = require("node:test");
|
||
const assert = require("node:assert/strict");
|
||
const { isDomainMatch, escapeHtml, toMatchPatterns, pickFrameWithForm } = require("../dist/utils.js");
|
||
|
||
describe("isDomainMatch - 纯 hostname 匹配", () => {
|
||
it("精确域名匹配", () => {
|
||
assert.equal(isDomainMatch("https://example.com/login", "example.com"), true);
|
||
assert.equal(isDomainMatch("https://example.com/", "example.com"), true);
|
||
assert.equal(isDomainMatch("https://other.com/", "example.com"), false);
|
||
});
|
||
|
||
it("子域名通配符 *.example.com", () => {
|
||
assert.equal(isDomainMatch("https://oa.example.com/", "*.example.com"), true);
|
||
assert.equal(isDomainMatch("https://a.b.example.com/", "*.example.com"), true);
|
||
assert.equal(isDomainMatch("https://example.com/", "*.example.com"), true); // 根域也匹配
|
||
assert.equal(isDomainMatch("https://notexample.com/", "*.example.com"), false);
|
||
});
|
||
|
||
it("任意位置通配符", () => {
|
||
assert.equal(isDomainMatch("https://api.dev.example.com/", "api.*.example.com"), true);
|
||
assert.equal(isDomainMatch("https://api.prod.example.com/", "api.*.example.com"), true);
|
||
assert.equal(isDomainMatch("https://web.dev.example.com/", "api.*.example.com"), false);
|
||
});
|
||
|
||
it("端口和协议不影响匹配", () => {
|
||
assert.equal(isDomainMatch("http://example.com:8080/path", "example.com"), true);
|
||
assert.equal(isDomainMatch("https://example.com:443/", "example.com"), true);
|
||
});
|
||
|
||
it("非法 URL 返回 false", () => {
|
||
assert.equal(isDomainMatch("not-a-url", "example.com"), false);
|
||
assert.equal(isDomainMatch("", "example.com"), false);
|
||
assert.equal(isDomainMatch("https://example.com/", ""), false);
|
||
});
|
||
});
|
||
|
||
describe("isDomainMatch - hostname + path 匹配", () => {
|
||
it("精确路径匹配", () => {
|
||
assert.equal(isDomainMatch("https://oa.com/login", "oa.com/login"), true);
|
||
assert.equal(isDomainMatch("https://oa.com/admin/login", "oa.com/login"), false);
|
||
assert.equal(isDomainMatch("https://oa.com/", "oa.com/login"), false);
|
||
});
|
||
|
||
it("路径前缀通配 login/*", () => {
|
||
assert.equal(isDomainMatch("https://oa.com/login", "oa.com/login/*"), true);
|
||
assert.equal(isDomainMatch("https://oa.com/login/step2", "oa.com/login/*"), true);
|
||
assert.equal(isDomainMatch("https://oa.com/login/sub/deep", "oa.com/login/*"), true);
|
||
assert.equal(isDomainMatch("https://oa.com/logins", "oa.com/login/*"), false);
|
||
assert.equal(isDomainMatch("https://oa.com/admin", "oa.com/login/*"), false);
|
||
});
|
||
|
||
it("路径全通配 *", () => {
|
||
assert.equal(isDomainMatch("https://oa.com/anything", "oa.com/*"), true);
|
||
assert.equal(isDomainMatch("https://oa.com/", "oa.com/*"), true);
|
||
});
|
||
|
||
it("仅 hostname + 空路径匹配该域名所有页面", () => {
|
||
assert.equal(isDomainMatch("https://oa.com/any/path", "oa.com/"), true);
|
||
assert.equal(isDomainMatch("https://oa.com/", "oa.com/"), true);
|
||
});
|
||
|
||
it("路径内任意位置通配", () => {
|
||
assert.equal(isDomainMatch("https://site.com/a/123/b", "site.com/a/*/b"), true);
|
||
assert.equal(isDomainMatch("https://site.com/a/xyz/b", "site.com/a/*/b"), true);
|
||
assert.equal(isDomainMatch("https://site.com/a/123/c", "site.com/a/*/b"), false);
|
||
});
|
||
|
||
it("path 匹配与子域名通配组合", () => {
|
||
assert.equal(isDomainMatch("https://api.dev.com/login", "*.dev.com/login"), true);
|
||
assert.equal(isDomainMatch("https://web.dev.com/admin", "*.dev.com/login"), false);
|
||
assert.equal(isDomainMatch("https://api.dev.com/login/step2", "*.dev.com/login/*"), true);
|
||
});
|
||
|
||
it("带 query 和 hash 的 URL 不影响 path 匹配", () => {
|
||
assert.equal(isDomainMatch("https://oa.com/login?next=/home", "oa.com/login"), true);
|
||
assert.equal(isDomainMatch("https://oa.com/login#section", "oa.com/login"), true);
|
||
});
|
||
});
|
||
|
||
describe("toMatchPatterns - 配置域名转 match pattern", () => {
|
||
it("纯域名同时生成 http / https 两条", () => {
|
||
assert.deepEqual(toMatchPatterns("example.com"), ["http://example.com/*", "https://example.com/*"]);
|
||
});
|
||
|
||
it("带协议前缀的配置会被剥离", () => {
|
||
assert.deepEqual(toMatchPatterns("https://example.com"), [
|
||
"http://example.com/*",
|
||
"https://example.com/*",
|
||
]);
|
||
});
|
||
|
||
it("子域名通配符 *.example.com 保持原样", () => {
|
||
assert.deepEqual(toMatchPatterns("*.example.com"), ["http://*.example.com/*", "https://*.example.com/*"]);
|
||
});
|
||
|
||
it("路径规则按前缀截断到第一个 *", () => {
|
||
assert.deepEqual(toMatchPatterns("oa.com/login/*"), ["http://oa.com/login/*", "https://oa.com/login/*"]);
|
||
// 中间通配无法表达,截断成前缀(放宽注入范围,精度仍由 isDomainMatch 把关)
|
||
assert.deepEqual(toMatchPatterns("site.com/a/*/b"), ["http://site.com/a/*", "https://site.com/a/*"]);
|
||
});
|
||
|
||
it("无通配的路径补上结尾 *", () => {
|
||
assert.deepEqual(toMatchPatterns("oa.com/admin"), ["http://oa.com/admin*", "https://oa.com/admin*"]);
|
||
});
|
||
|
||
it("去掉端口(match pattern 不支持端口)", () => {
|
||
assert.deepEqual(toMatchPatterns("example.com:8080/app"), [
|
||
"http://example.com/app*",
|
||
"https://example.com/app*",
|
||
]);
|
||
});
|
||
|
||
it("中文域名转成 punycode", () => {
|
||
assert.deepEqual(toMatchPatterns("中文域名.中国"), [
|
||
"http://xn--fiq06l2rdsvs.xn--fiqs8s/*",
|
||
"https://xn--fiq06l2rdsvs.xn--fiqs8s/*",
|
||
]);
|
||
});
|
||
|
||
it("query / hash 会被忽略", () => {
|
||
assert.deepEqual(toMatchPatterns("oa.com/login?next=/home#x"), [
|
||
"http://oa.com/login*",
|
||
"https://oa.com/login*",
|
||
]);
|
||
});
|
||
|
||
it("无法表达的规则返回空数组", () => {
|
||
assert.deepEqual(toMatchPatterns(""), []);
|
||
assert.deepEqual(toMatchPatterns(null), []);
|
||
assert.deepEqual(toMatchPatterns("*"), []); // 裸通配
|
||
assert.deepEqual(toMatchPatterns("a.*.b.com"), []); // 中间通配的 host
|
||
});
|
||
});
|
||
|
||
describe("pickFrameWithForm - 从多帧结果里挑出有表单的帧", () => {
|
||
it("只有一个帧有表单时返回该帧", () => {
|
||
assert.equal(
|
||
pickFrameWithForm([
|
||
{ frameId: 0, result: 0 },
|
||
{ frameId: 5, result: 1 },
|
||
]),
|
||
5,
|
||
);
|
||
});
|
||
|
||
it("顶层帧有表单时优先顶层帧", () => {
|
||
assert.equal(
|
||
pickFrameWithForm([
|
||
{ frameId: 0, result: 1 },
|
||
{ frameId: 3, result: 2 },
|
||
]),
|
||
0,
|
||
);
|
||
});
|
||
|
||
it("多个子帧都有表单时取 frameId 最小的", () => {
|
||
assert.equal(
|
||
pickFrameWithForm([
|
||
{ frameId: 7, result: 1 },
|
||
{ frameId: 3, result: 1 },
|
||
]),
|
||
3,
|
||
);
|
||
});
|
||
|
||
it("都没有表单时返回 null", () => {
|
||
assert.equal(
|
||
pickFrameWithForm([
|
||
{ frameId: 0, result: 0 },
|
||
{ frameId: 2, result: 0 },
|
||
]),
|
||
null,
|
||
);
|
||
assert.equal(pickFrameWithForm([]), null);
|
||
assert.equal(pickFrameWithForm(undefined), null);
|
||
});
|
||
|
||
it("缺少 frameId 时按顶层帧处理", () => {
|
||
assert.equal(pickFrameWithForm([{ result: 1 }]), 0);
|
||
});
|
||
});
|
||
|
||
describe("debugLog - 调试日志开关", () => {
|
||
it("默认静默,打开开关后才输出", async () => {
|
||
const logs = [];
|
||
const originalLog = console.log;
|
||
console.log = (...args) => logs.push(args.join(" "));
|
||
|
||
const loadUtils = () => {
|
||
delete require.cache[require.resolve("../dist/utils.js")];
|
||
return require("../dist/utils.js");
|
||
};
|
||
|
||
try {
|
||
delete global.chrome;
|
||
let U = loadUtils();
|
||
assert.equal(await U.initDebugLog(), false);
|
||
U.debugLog("默认不该出现");
|
||
assert.equal(logs.filter((l) => l.includes("默认不该出现")).length, 0);
|
||
|
||
global.chrome = { storage: { local: { get: async () => ({ debugLog: true }) } } };
|
||
U = loadUtils();
|
||
assert.equal(await U.initDebugLog(), true);
|
||
U.debugLog("开启后应出现");
|
||
assert.equal(logs.filter((l) => l.includes("开启后应出现")).length, 1);
|
||
} finally {
|
||
console.log = originalLog;
|
||
delete global.chrome;
|
||
loadUtils();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("escapeHtml", () => {
|
||
it("转义 5 个特殊字符", () => {
|
||
assert.equal(escapeHtml("<"), "<");
|
||
assert.equal(escapeHtml(">"), ">");
|
||
assert.equal(escapeHtml('"'), """);
|
||
assert.equal(escapeHtml("'"), "'");
|
||
assert.equal(escapeHtml("&"), "&");
|
||
});
|
||
|
||
it("组合转义", () => {
|
||
assert.equal(
|
||
escapeHtml('<a href="x" onclick="alert(\'hi\')">text & more</a>'),
|
||
"<a href="x" onclick="alert('hi')">text & more</a>",
|
||
);
|
||
});
|
||
|
||
it("null 和 undefined 返回空串", () => {
|
||
assert.equal(escapeHtml(null), "");
|
||
assert.equal(escapeHtml(undefined), "");
|
||
});
|
||
|
||
it("数字会被转成字符串", () => {
|
||
assert.equal(escapeHtml(123), "123");
|
||
});
|
||
|
||
it("普通文本不变", () => {
|
||
assert.equal(escapeHtml("hello world"), "hello world");
|
||
assert.equal(escapeHtml("中文测试123"), "中文测试123");
|
||
});
|
||
});
|