feat: 按域名授权、iframe 多帧填充、自动提交限流修正,并补齐工程规范与文档
安全与权限: - 站点访问权限改为 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 同步
This commit is contained in:
+275
-30
@@ -20,23 +20,31 @@ const tick = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
// autoSubmit 的最小延迟是 300ms(content.js 里 Math.max(300, delayMs))
|
||||
const AFTER_SUBMIT = 400;
|
||||
|
||||
function createChromeMock({ configs = [], attempts = {} } = {}) {
|
||||
function createChromeMock({ configs = [], attempts = {}, activeEnvId = null } = {}) {
|
||||
const state = {
|
||||
configs,
|
||||
activeEnvId,
|
||||
attempts: new Map(Object.entries(attempts)),
|
||||
calls: [],
|
||||
messageListener: null,
|
||||
// 每个帧注入 content.js 都会注册一个监听器;allFrames 场景下用最后一个(最内层帧)
|
||||
messageListeners: [],
|
||||
};
|
||||
|
||||
const api = {
|
||||
runtime: {
|
||||
id: "mock-extension-id",
|
||||
onMessage: { addListener: (fn) => { state.messageListener = fn; } },
|
||||
onMessage: {
|
||||
addListener: (fn) => {
|
||||
state.messageListener = fn;
|
||||
state.messageListeners.push(fn);
|
||||
},
|
||||
},
|
||||
sendMessage: (message) => {
|
||||
state.calls.push(message.action);
|
||||
switch (message.action) {
|
||||
case "loadAllConfigs":
|
||||
return Promise.resolve({ configs: state.configs });
|
||||
return Promise.resolve({ configs: state.configs, activeEnvId: state.activeEnvId });
|
||||
case "getVaultStatus":
|
||||
return Promise.resolve({ hasVault: false, unlocked: true, pendingImport: false });
|
||||
case "getDomainAttempts": {
|
||||
@@ -68,28 +76,64 @@ function createChromeMock({ configs = [], attempts = {} } = {}) {
|
||||
return { api, state };
|
||||
}
|
||||
|
||||
/** 在 jsdom 页面里执行 utils.js + content.js(与浏览器一致:按普通脚本顺序执行) */
|
||||
function loadContentPage({ html = "", url = "https://example.com/login", configs = [], attempts = {} } = {}) {
|
||||
/**
|
||||
* jsdom 没有布局引擎(offsetParent 恒为 null、offsetWidth 恒为 0),
|
||||
* 而 content.js 会用它们判断元素是否可见。这里按 realm 逐个打补丁:
|
||||
* 主窗口与每个 iframe 都各有自己的 HTMLElement.prototype,必须分别处理。
|
||||
*/
|
||||
function patchLayout(win) {
|
||||
Object.defineProperty(win.HTMLElement.prototype, "offsetWidth", {
|
||||
get() {
|
||||
return 100;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(win.HTMLElement.prototype, "offsetHeight", {
|
||||
get() {
|
||||
return 20;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(win.HTMLElement.prototype, "offsetParent", {
|
||||
get() {
|
||||
return this.parentElement || this.ownerDocument.body;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
// jsdom 未实现 CSS.escape(真实浏览器有)
|
||||
if (!win.CSS) win.CSS = {};
|
||||
if (!win.CSS.escape)
|
||||
win.CSS.escape = (s) => String(s).replace(/[^a-zA-Z0-9_\u00a0-\uffff-]/g, (c) => "\\" + c);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 jsdom 页面里执行 utils.js + content.js(与浏览器一致:按普通脚本顺序执行)。
|
||||
* setup 回调在注入脚本之前执行,用于准备"脚本加载前就必须存在"的结构(例如 Shadow Root)。
|
||||
*/
|
||||
function loadContentPage({
|
||||
html = "",
|
||||
url = "https://example.com/login",
|
||||
configs = [],
|
||||
attempts = {},
|
||||
activeEnvId = null,
|
||||
timing,
|
||||
setup,
|
||||
} = {}) {
|
||||
const dom = new JSDOM(`<!DOCTYPE html><html><body>${html}</body></html>`, {
|
||||
url,
|
||||
runScripts: "dangerously",
|
||||
});
|
||||
const win = dom.window;
|
||||
|
||||
// 让所有元素默认"可见"(jsdom 无布局引擎)
|
||||
Object.defineProperty(win.HTMLElement.prototype, "offsetWidth", { get() { return 100; }, configurable: true });
|
||||
Object.defineProperty(win.HTMLElement.prototype, "offsetHeight", { get() { return 20; }, configurable: true });
|
||||
Object.defineProperty(win.HTMLElement.prototype, "offsetParent", {
|
||||
get() { return this.parentElement || this.ownerDocument.body; },
|
||||
configurable: true,
|
||||
});
|
||||
// jsdom 未实现 CSS.escape(真实浏览器有)
|
||||
if (!win.CSS) win.CSS = {};
|
||||
if (!win.CSS.escape) win.CSS.escape = (s) => String(s).replace(/[^a-zA-Z0-9_\u00a0-\uffff-]/g, (c) => "\\" + c);
|
||||
patchLayout(win);
|
||||
// 缩短时间常量(30 秒超时 / 4 秒结果观察),否则这些用例要真等半分钟
|
||||
if (timing) win.__autoLoginTiming = timing;
|
||||
|
||||
const chrome = createChromeMock({ configs, attempts });
|
||||
const chrome = createChromeMock({ configs, attempts, activeEnvId });
|
||||
win.chrome = chrome.api;
|
||||
|
||||
if (setup) setup(win, win.document);
|
||||
|
||||
const inject = (code) => {
|
||||
const el = win.document.createElement("script");
|
||||
el.textContent = code;
|
||||
@@ -139,7 +183,10 @@ describe("content - 字段探测", () => {
|
||||
|
||||
assert.equal(ctx.win.findPasswordField({}).getAttribute("name"), "pass");
|
||||
assert.equal(ctx.win.findUsernameField({}).getAttribute("name"), "username");
|
||||
assert.equal(ctx.win.findSubmitButton({}, ctx.doc.querySelector("input[type=password]")).textContent, "登录");
|
||||
assert.equal(
|
||||
ctx.win.findSubmitButton({}, ctx.doc.querySelector("input[type=password]")).textContent,
|
||||
"登录",
|
||||
);
|
||||
});
|
||||
|
||||
it("显式选择器优先于自动探测", () => {
|
||||
@@ -279,7 +326,12 @@ describe("content - 填充与提交", () => {
|
||||
});
|
||||
let submitted = 0;
|
||||
const form = ctx.doc.querySelector("form");
|
||||
Object.defineProperty(form, "requestSubmit", { value: () => { submitted++; }, configurable: true });
|
||||
Object.defineProperty(form, "requestSubmit", {
|
||||
value: () => {
|
||||
submitted++;
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
ctx.win.fillForm({ username: "u", password: "p", autoSubmit: true }, true);
|
||||
await tick(AFTER_SUBMIT);
|
||||
@@ -295,7 +347,11 @@ describe("content - 自动填充主流程", () => {
|
||||
</form>`;
|
||||
|
||||
it("域名命中配置时自动填充", async () => {
|
||||
const ctx = loadContentPage({ html: LOGIN_HTML, url: "https://example.com/login", configs: [makeConfig()] });
|
||||
const ctx = loadContentPage({
|
||||
html: LOGIN_HTML,
|
||||
url: "https://example.com/login",
|
||||
configs: [makeConfig()],
|
||||
});
|
||||
await tick(60);
|
||||
|
||||
assert.equal(ctx.doc.getElementById("u").value, "auto-user");
|
||||
@@ -303,7 +359,11 @@ describe("content - 自动填充主流程", () => {
|
||||
});
|
||||
|
||||
it("域名不匹配时什么都不做", async () => {
|
||||
const ctx = loadContentPage({ html: LOGIN_HTML, url: "https://other.com/login", configs: [makeConfig()] });
|
||||
const ctx = loadContentPage({
|
||||
html: LOGIN_HTML,
|
||||
url: "https://other.com/login",
|
||||
configs: [makeConfig()],
|
||||
});
|
||||
await tick(60);
|
||||
|
||||
assert.equal(ctx.doc.getElementById("u").value, "");
|
||||
@@ -327,7 +387,7 @@ describe("content - 自动填充主流程", () => {
|
||||
assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts"));
|
||||
});
|
||||
|
||||
it("失败次数达到上限后只填充、不自动提交", async () => {
|
||||
it("达到上限后只填充、不自动提交,并给出可见提示", async () => {
|
||||
const ctx = loadContentPage({
|
||||
html: LOGIN_HTML,
|
||||
configs: [makeConfig({ autoSubmit: true })],
|
||||
@@ -340,6 +400,80 @@ describe("content - 自动填充主流程", () => {
|
||||
assert.equal(ctx.doc.getElementById("u").value, "auto-user", "仍然填充");
|
||||
assert.equal(clicks, 0, "不应自动提交");
|
||||
assert.ok(!ctx.chrome.state.calls.includes("incrementDomainAttempts"));
|
||||
// 用户必须能看懂"为什么只填不提交",否则会以为扩展坏了
|
||||
assert.ok(ctx.doc.body.innerHTML.includes("attemptToastTitle"), "应显示暂停提示浮窗");
|
||||
});
|
||||
|
||||
it("命中配置但页面上没有登录框时不消耗计数(浏览站内页面不会被误扣)", async () => {
|
||||
const ctx = loadContentPage({
|
||||
html: `<div>登录成功后的站内页面</div>`,
|
||||
configs: [makeConfig({ autoSubmit: true })],
|
||||
timing: { fillTimeoutMs: 120 },
|
||||
});
|
||||
await tick(500);
|
||||
|
||||
assert.ok(!ctx.chrome.state.calls.includes("incrementDomainAttempts"), "没有提交就不应计数");
|
||||
assert.ok(ctx.chrome.state.calls.includes("resetDomainAttempts"), "始终没有登录框 → 清掉旧计数");
|
||||
});
|
||||
|
||||
it("只有真正触发提交之后才累加计数", async () => {
|
||||
const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ autoSubmit: true })] });
|
||||
ctx.doc.getElementById("btn").addEventListener("click", () => {});
|
||||
|
||||
await tick(150);
|
||||
assert.ok(!ctx.chrome.state.calls.includes("incrementDomainAttempts"), "提交前不应计数");
|
||||
|
||||
await tick(300); // 提交延迟 300ms
|
||||
assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts"), "提交后才计数");
|
||||
});
|
||||
|
||||
it("提交后登录框消失视为登录成功并重置计数(SPA 场景)", async () => {
|
||||
const ctx = loadContentPage({
|
||||
html: LOGIN_HTML,
|
||||
configs: [makeConfig({ autoSubmit: true })],
|
||||
timing: { submitResultCheckMs: 120 },
|
||||
});
|
||||
ctx.doc.getElementById("btn").addEventListener("click", () => {});
|
||||
|
||||
await tick(330); // 300ms 时已提交
|
||||
assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts"), "提交后先计数");
|
||||
|
||||
// 模拟 SPA 登录成功:登录表单从页面上消失
|
||||
ctx.doc.querySelector("form").remove();
|
||||
|
||||
await tick(160);
|
||||
assert.ok(ctx.chrome.state.calls.includes("resetDomainAttempts"), "登录成功应重置计数");
|
||||
});
|
||||
|
||||
it("提交后登录框仍在(登录失败)不重置计数", async () => {
|
||||
const ctx = loadContentPage({
|
||||
html: LOGIN_HTML,
|
||||
configs: [makeConfig({ autoSubmit: true })],
|
||||
timing: { submitResultCheckMs: 120 },
|
||||
});
|
||||
ctx.doc.getElementById("btn").addEventListener("click", () => {});
|
||||
|
||||
await tick(560); // 覆盖 300ms 提交 + 120ms 结果观察
|
||||
assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts"));
|
||||
assert.ok(!ctx.chrome.state.calls.includes("resetDomainAttempts"), "登录框仍在,视为失败,不应重置");
|
||||
});
|
||||
|
||||
it("被禁用的配置不再阻止计数重置", async () => {
|
||||
const ctx = loadContentPage({
|
||||
url: "https://example.com/login/page",
|
||||
html: `<div id="app"></div>`,
|
||||
configs: [
|
||||
makeConfig({ id: "a", domain: "example.com/login/*" }),
|
||||
makeConfig({ id: "b", domain: "example.com", enabled: false }),
|
||||
],
|
||||
});
|
||||
|
||||
// 从 /login/page 跳到 /dashboard:只有被禁用的那条还匹配旧地址
|
||||
ctx.dom.reconfigure({ url: "https://example.com/dashboard" });
|
||||
ctx.doc.getElementById("app").textContent = "route changed"; // 触发 MutationObserver
|
||||
await tick(80);
|
||||
|
||||
assert.ok(ctx.chrome.state.calls.includes("resetDomainAttempts"), "启用中的配置离开匹配范围应重置");
|
||||
});
|
||||
|
||||
it("失败计数超过冷却时间后重新计数并允许提交", async () => {
|
||||
@@ -416,9 +550,115 @@ describe("content - SPA 延迟渲染", () => {
|
||||
<input type="password" id="p">
|
||||
</form>`;
|
||||
|
||||
await tick(700); // 重试间隔 500ms
|
||||
await tick(700);
|
||||
assert.equal(ctx.doc.getElementById("u").value, "auto-user");
|
||||
});
|
||||
|
||||
it("Shadow DOM 内部后渲染的登录框也能填充(light DOM 完全没变)", async () => {
|
||||
let shadow;
|
||||
loadContentPage({
|
||||
html: `<div id="host"></div>`,
|
||||
configs: [makeConfig()],
|
||||
setup: (win, doc) => {
|
||||
shadow = doc.getElementById("host").attachShadow({ mode: "open" });
|
||||
},
|
||||
});
|
||||
|
||||
// 首轮扫描:发现空的 shadow root 并开始监听它
|
||||
await tick(80);
|
||||
assert.equal(shadow.querySelectorAll("input").length, 0);
|
||||
|
||||
// 只改 shadow 内部,body 上的 MutationObserver 感知不到这种变化
|
||||
shadow.innerHTML = `<form>
|
||||
<input type="text" name="username" id="u">
|
||||
<input type="password" id="p">
|
||||
</form>`;
|
||||
|
||||
await tick(700);
|
||||
assert.equal(shadow.getElementById("u").value, "auto-user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("content - iframe 内的登录表单(allFrames)", () => {
|
||||
const FORM_HTML = `<form>
|
||||
<input type="text" name="username" id="u">
|
||||
<input type="password" id="p">
|
||||
</form>`;
|
||||
|
||||
/** 在页面里创建同源 iframe,并按 allFrames 的方式往帧内注入脚本 */
|
||||
function injectIntoFrame(ctx, html) {
|
||||
const iframe = ctx.doc.createElement("iframe");
|
||||
ctx.doc.body.appendChild(iframe);
|
||||
const iwin = iframe.contentWindow;
|
||||
const idoc = iframe.contentDocument;
|
||||
idoc.body.innerHTML = html;
|
||||
patchLayout(iwin); // iframe 是独立的 realm,需要单独打布局补丁
|
||||
iwin.chrome = ctx.chrome.api;
|
||||
for (const code of [UTILS_CODE, CONTENT_CODE]) {
|
||||
const el = idoc.createElement("script");
|
||||
el.textContent = code;
|
||||
idoc.head.appendChild(el);
|
||||
}
|
||||
return { iwin, idoc };
|
||||
}
|
||||
|
||||
it("iframe 内的表单能被填充(按 iframe 自己的地址匹配)", async () => {
|
||||
// iframe 在 jsdom 里是 about:blank,用通配规则让它命中;顶层帧没有表单所以不会误填
|
||||
const ctx = loadContentPage({ html: `<div id="top"></div>`, configs: [makeConfig({ domain: "*" })] });
|
||||
const { idoc } = injectIntoFrame(ctx, FORM_HTML);
|
||||
|
||||
await tick(150);
|
||||
assert.equal(idoc.getElementById("u").value, "auto-user");
|
||||
assert.equal(idoc.getElementById("p").value, "auto-pass");
|
||||
});
|
||||
|
||||
it("子帧命中多个环境时不弹浮窗,按当前激活环境填充(顶层帧照旧弹窗)", async () => {
|
||||
const ctx = loadContentPage({
|
||||
html: `<div id="top"></div>`,
|
||||
configs: [
|
||||
makeConfig({ id: "c1", envId: "e1", envName: "个人", domain: "*", username: "personal@x.com" }),
|
||||
makeConfig({ id: "c2", envId: "e2", envName: "生产", domain: "*", username: "prod@x.com" }),
|
||||
],
|
||||
activeEnvId: "e2",
|
||||
});
|
||||
const { idoc } = injectIntoFrame(ctx, FORM_HTML);
|
||||
|
||||
await tick(150);
|
||||
assert.equal(idoc.getElementById("u").value, "prod@x.com", "子帧应按激活环境选用凭据");
|
||||
assert.equal(idoc.getElementById("auto-login-env-picker"), null, "子帧不弹浮窗");
|
||||
assert.ok(ctx.doc.getElementById("auto-login-env-picker"), "顶层帧仍然弹浮窗");
|
||||
});
|
||||
|
||||
it("子帧收到 manualFillMulti 时同样按激活环境填充", async () => {
|
||||
const ctx = loadContentPage({
|
||||
html: `<div id="top"></div>`,
|
||||
configs: [
|
||||
makeConfig({ id: "c1", envId: "e1", domain: "*", username: "personal@x.com" }),
|
||||
makeConfig({ id: "c2", envId: "e2", domain: "*", username: "prod@x.com" }),
|
||||
],
|
||||
activeEnvId: "e2",
|
||||
});
|
||||
const { idoc } = injectIntoFrame(ctx, FORM_HTML);
|
||||
// 最后注册的监听器来自最内层帧
|
||||
const listeners = ctx.chrome.state.messageListeners;
|
||||
const frameListener = listeners[listeners.length - 1];
|
||||
|
||||
frameListener({ action: "manualFillMulti", configs: [...ctx.chrome.state.configs] }, {}, () => {});
|
||||
await tick(150);
|
||||
|
||||
assert.equal(idoc.getElementById("u").value, "prod@x.com");
|
||||
assert.equal(idoc.getElementById("auto-login-env-picker"), null);
|
||||
});
|
||||
|
||||
it("pickConfigForFrame:优先当前激活环境,其次第一条", () => {
|
||||
const ctx = loadContentPage({ html: "" });
|
||||
const a = makeConfig({ id: "a", envId: "e1" });
|
||||
const b = makeConfig({ id: "b", envId: "e2" });
|
||||
|
||||
assert.equal(ctx.win.pickConfigForFrame([a, b], "e2").id, "b");
|
||||
assert.equal(ctx.win.pickConfigForFrame([a, b], "e9").id, "a");
|
||||
assert.equal(ctx.win.pickConfigForFrame([a, b], null).id, "a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("content - 手动填充消息", () => {
|
||||
@@ -434,7 +674,9 @@ describe("content - 手动填充消息", () => {
|
||||
ctx.chrome.state.messageListener(
|
||||
{ action: "manualFill", config: makeConfig({ username: "manual@x.com" }) },
|
||||
{},
|
||||
(r) => { response = r; }
|
||||
(r) => {
|
||||
response = r;
|
||||
},
|
||||
);
|
||||
await tick(30);
|
||||
|
||||
@@ -447,11 +689,9 @@ describe("content - 手动填充消息", () => {
|
||||
const ctx = loadContentPage({ html: `<div>没有表单</div>` });
|
||||
let response = null;
|
||||
|
||||
ctx.chrome.state.messageListener(
|
||||
{ action: "manualFill", config: makeConfig() },
|
||||
{},
|
||||
(r) => { response = r; }
|
||||
);
|
||||
ctx.chrome.state.messageListener({ action: "manualFill", config: makeConfig() }, {}, (r) => {
|
||||
response = r;
|
||||
});
|
||||
await tick(30);
|
||||
|
||||
assert.equal(response.success, false);
|
||||
@@ -464,9 +704,14 @@ describe("content - 手动填充消息", () => {
|
||||
let response = null;
|
||||
|
||||
ctx.chrome.state.messageListener(
|
||||
{ action: "manualFillMulti", configs: [makeConfig({ id: "c1" }), makeConfig({ id: "c2", envId: "e2", envName: "生产" })] },
|
||||
{
|
||||
action: "manualFillMulti",
|
||||
configs: [makeConfig({ id: "c1" }), makeConfig({ id: "c2", envId: "e2", envName: "生产" })],
|
||||
},
|
||||
{},
|
||||
(r) => { response = r; }
|
||||
(r) => {
|
||||
response = r;
|
||||
},
|
||||
);
|
||||
await tick(30);
|
||||
|
||||
|
||||
@@ -11,12 +11,18 @@ function createMemoryStorage() {
|
||||
const makeArea = (store) => ({
|
||||
async get(keys) {
|
||||
const result = {};
|
||||
if (Array.isArray(keys)) keys.forEach((k) => { if (k in store) result[k] = store[k]; });
|
||||
else if (typeof keys === "string") { if (keys in store) result[keys] = store[keys]; }
|
||||
else Object.assign(result, store);
|
||||
if (Array.isArray(keys))
|
||||
keys.forEach((k) => {
|
||||
if (k in store) result[k] = store[k];
|
||||
});
|
||||
else if (typeof keys === "string") {
|
||||
if (keys in store) result[keys] = store[keys];
|
||||
} else Object.assign(result, store);
|
||||
return result;
|
||||
},
|
||||
async set(items) { Object.assign(store, items); },
|
||||
async set(items) {
|
||||
Object.assign(store, items);
|
||||
},
|
||||
async remove(keys) {
|
||||
if (Array.isArray(keys)) keys.forEach((k) => delete store[k]);
|
||||
else delete store[keys];
|
||||
|
||||
+11
-5
@@ -13,7 +13,9 @@ function createMemoryStorage() {
|
||||
async get(keys) {
|
||||
const result = {};
|
||||
if (Array.isArray(keys)) {
|
||||
keys.forEach((k) => { if (k in store) result[k] = store[k]; });
|
||||
keys.forEach((k) => {
|
||||
if (k in store) result[k] = store[k];
|
||||
});
|
||||
} else if (typeof keys === "string") {
|
||||
if (keys in store) result[keys] = store[keys];
|
||||
} else {
|
||||
@@ -21,12 +23,16 @@ function createMemoryStorage() {
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async set(items) { Object.assign(store, items); },
|
||||
async set(items) {
|
||||
Object.assign(store, items);
|
||||
},
|
||||
async remove(keys) {
|
||||
if (Array.isArray(keys)) keys.forEach((k) => delete store[k]);
|
||||
else delete store[keys];
|
||||
},
|
||||
_dump() { return store; },
|
||||
_dump() {
|
||||
return store;
|
||||
},
|
||||
});
|
||||
return { local: makeArea(local), session: makeArea(session), localStore: local, sessionStore: session };
|
||||
}
|
||||
@@ -73,7 +79,7 @@ describe("env-store - 环境 CRUD", () => {
|
||||
{ id: "e2", name: "测试", configs: [] },
|
||||
{ id: "e3", name: "生产", configs: [] },
|
||||
],
|
||||
"e1"
|
||||
"e1",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -143,7 +149,7 @@ describe("env-store - 配置 CRUD", () => {
|
||||
{ id: "e1", name: "个人", configs: [] },
|
||||
{ id: "e2", name: "测试", configs: [] },
|
||||
],
|
||||
"e1"
|
||||
"e1",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+19
-7
@@ -25,7 +25,7 @@ function createFakeIndexedDB() {
|
||||
return req;
|
||||
}
|
||||
|
||||
function makeDb(name) {
|
||||
function makeDb() {
|
||||
const stores = new Map();
|
||||
return {
|
||||
objectStoreNames: { contains: (n) => stores.has(n) },
|
||||
@@ -37,9 +37,17 @@ function createFakeIndexedDB() {
|
||||
const data = stores.get(storeName);
|
||||
const tx = { error: null, onabort: null };
|
||||
tx.objectStore = () => ({
|
||||
put: (value, key) => makeRequest(() => { data.set(key, value); return key; }),
|
||||
put: (value, key) =>
|
||||
makeRequest(() => {
|
||||
data.set(key, value);
|
||||
return key;
|
||||
}),
|
||||
get: (key) => makeRequest(() => data.get(key)),
|
||||
delete: (key) => makeRequest(() => { data.delete(key); return undefined; }),
|
||||
delete: (key) =>
|
||||
makeRequest(() => {
|
||||
data.delete(key);
|
||||
return undefined;
|
||||
}),
|
||||
});
|
||||
return tx;
|
||||
},
|
||||
@@ -49,14 +57,18 @@ function createFakeIndexedDB() {
|
||||
return {
|
||||
open(name) {
|
||||
const req = {
|
||||
result: undefined, error: null,
|
||||
onsuccess: null, onerror: null, onupgradeneeded: null, onblocked: null,
|
||||
result: undefined,
|
||||
error: null,
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
onupgradeneeded: null,
|
||||
onblocked: null,
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
let db = databases.get(name);
|
||||
const isNew = !db;
|
||||
if (isNew) {
|
||||
db = makeDb(name);
|
||||
db = makeDb();
|
||||
databases.set(name, db);
|
||||
}
|
||||
req.result = db;
|
||||
@@ -112,7 +124,7 @@ describe("key-store - 密钥句柄存取", () => {
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
new TextEncoder().encode("secret")
|
||||
new TextEncoder().encode("secret"),
|
||||
);
|
||||
|
||||
await K.saveKey(key);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
// tests/permissions.test.js
|
||||
// 测试 permissions.js:可选主机权限的授权状态判断与申请逻辑
|
||||
// 运行:node --test tests/permissions.test.js
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { toMatchPatterns } = require("../dist/utils.js");
|
||||
|
||||
/** 构造一个假的 chrome.permissions,granted 集合可观察 */
|
||||
function createFakePermissions(granted = []) {
|
||||
const state = {
|
||||
granted: new Set(granted),
|
||||
requestCalls: [],
|
||||
containsCalls: 0,
|
||||
throwOnContains: false,
|
||||
};
|
||||
return {
|
||||
state,
|
||||
api: {
|
||||
contains: async ({ origins }) => {
|
||||
state.containsCalls++;
|
||||
if (state.throwOnContains) throw new Error("boom");
|
||||
return origins.every((o) => state.granted.has(o));
|
||||
},
|
||||
request: async ({ origins }) => {
|
||||
state.requestCalls.push(origins);
|
||||
origins.forEach((o) => state.granted.add(o));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadPerms(permissionsApi) {
|
||||
global.toMatchPatterns = toMatchPatterns; // permissions.js 会延迟读取该全局函数
|
||||
global.chrome = permissionsApi === null ? {} : { permissions: permissionsApi };
|
||||
delete require.cache[require.resolve("../dist/permissions.js")];
|
||||
return require("../dist/permissions.js");
|
||||
}
|
||||
|
||||
describe("permissions - patternsForDomain", () => {
|
||||
beforeEach(() => {
|
||||
global.toMatchPatterns = toMatchPatterns;
|
||||
});
|
||||
|
||||
it("返回 http / https 两条模式", () => {
|
||||
const P = loadPerms(createFakePermissions().api);
|
||||
assert.deepEqual(P.patternsForDomain("example.com"), ["http://example.com/*", "https://example.com/*"]);
|
||||
});
|
||||
|
||||
it("通配符域名同样可转成可申请的 origin", () => {
|
||||
const P = loadPerms(createFakePermissions().api);
|
||||
assert.deepEqual(P.patternsForDomain("*.example.com"), [
|
||||
"http://*.example.com/*",
|
||||
"https://*.example.com/*",
|
||||
]);
|
||||
});
|
||||
|
||||
it("无法表达的规则返回空数组", () => {
|
||||
const P = loadPerms(createFakePermissions().api);
|
||||
assert.deepEqual(P.patternsForDomain("*"), []);
|
||||
assert.deepEqual(P.patternsForDomain(""), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissions - partitionPatterns", () => {
|
||||
it("按已授权拆分成 granted / missing", async () => {
|
||||
const fake = createFakePermissions(["https://a.com/*"]);
|
||||
const P = loadPerms(fake.api);
|
||||
|
||||
const result = await P.partitionPatterns(["https://a.com/*", "http://b.com/*", "https://b.com/*"]);
|
||||
|
||||
assert.deepEqual(result.granted, ["https://a.com/*"]);
|
||||
assert.deepEqual(result.missing, ["http://b.com/*", "https://b.com/*"]);
|
||||
});
|
||||
|
||||
it("contains 抛错时按未授权处理", async () => {
|
||||
const fake = createFakePermissions(["https://a.com/*"]);
|
||||
fake.state.throwOnContains = true;
|
||||
const P = loadPerms(fake.api);
|
||||
|
||||
const result = await P.partitionPatterns(["https://a.com/*"]);
|
||||
assert.deepEqual(result.granted, []);
|
||||
assert.deepEqual(result.missing, ["https://a.com/*"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissions - partitionDomains", () => {
|
||||
it("任一 scheme 已授权即算该域名已授权", async () => {
|
||||
const fake = createFakePermissions(["https://a.com/*"]);
|
||||
const P = loadPerms(fake.api);
|
||||
|
||||
const result = await P.partitionDomains(["a.com", "b.com"]);
|
||||
assert.deepEqual(result.granted, ["a.com"]);
|
||||
assert.deepEqual(result.missing, ["b.com"]);
|
||||
});
|
||||
|
||||
it("完全未授权的域名进入 missing", async () => {
|
||||
const P = loadPerms(createFakePermissions().api);
|
||||
const result = await P.partitionDomains(["a.com"]);
|
||||
assert.deepEqual(result.missing, ["a.com"]);
|
||||
});
|
||||
|
||||
it("无法转成模式的域名视为未授权", async () => {
|
||||
const P = loadPerms(createFakePermissions().api);
|
||||
const result = await P.partitionDomains(["*"]);
|
||||
assert.deepEqual(result.missing, ["*"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissions - requestForDomain", () => {
|
||||
it("已授权时直接返回 true 且不重复申请", async () => {
|
||||
const fake = createFakePermissions(["http://a.com/*", "https://a.com/*"]);
|
||||
const P = loadPerms(fake.api);
|
||||
|
||||
assert.equal(await P.requestForDomain("a.com"), true);
|
||||
assert.equal(fake.state.requestCalls.length, 0, "已授权不应再次弹框");
|
||||
});
|
||||
|
||||
it("未授权时申请全部模式", async () => {
|
||||
const fake = createFakePermissions();
|
||||
const P = loadPerms(fake.api);
|
||||
|
||||
assert.equal(await P.requestForDomain("a.com"), true);
|
||||
assert.deepEqual(fake.state.requestCalls, [["http://a.com/*", "https://a.com/*"]]);
|
||||
});
|
||||
|
||||
it("无法转换的域名不会发起申请", async () => {
|
||||
const fake = createFakePermissions();
|
||||
const P = loadPerms(fake.api);
|
||||
|
||||
assert.equal(await P.requestForDomain("*"), false);
|
||||
assert.equal(fake.state.requestCalls.length, 0);
|
||||
});
|
||||
|
||||
it("环境没有 chrome.permissions 时返回 false", async () => {
|
||||
const P = loadPerms(null);
|
||||
assert.equal(await P.requestForDomain("a.com"), false);
|
||||
});
|
||||
|
||||
it("申请被拒绝时返回 false(不抛错)", async () => {
|
||||
const fake = createFakePermissions();
|
||||
fake.api.request = async () => false;
|
||||
const P = loadPerms(fake.api);
|
||||
|
||||
assert.equal(await P.requestForDomain("a.com"), false);
|
||||
});
|
||||
});
|
||||
+88
-24
@@ -4,7 +4,7 @@
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { isDomainMatch, escapeHtml, toMatchPatterns } = require("../dist/utils.js");
|
||||
const { isDomainMatch, escapeHtml, toMatchPatterns, pickFrameWithForm } = require("../dist/utils.js");
|
||||
|
||||
describe("isDomainMatch - 纯 hostname 匹配", () => {
|
||||
it("精确域名匹配", () => {
|
||||
@@ -83,10 +83,7 @@ describe("isDomainMatch - hostname + path 匹配", () => {
|
||||
|
||||
describe("toMatchPatterns - 配置域名转 match pattern", () => {
|
||||
it("纯域名同时生成 http / https 两条", () => {
|
||||
assert.deepEqual(toMatchPatterns("example.com"), [
|
||||
"http://example.com/*",
|
||||
"https://example.com/*",
|
||||
]);
|
||||
assert.deepEqual(toMatchPatterns("example.com"), ["http://example.com/*", "https://example.com/*"]);
|
||||
});
|
||||
|
||||
it("带协议前缀的配置会被剥离", () => {
|
||||
@@ -97,29 +94,17 @@ describe("toMatchPatterns - 配置域名转 match pattern", () => {
|
||||
});
|
||||
|
||||
it("子域名通配符 *.example.com 保持原样", () => {
|
||||
assert.deepEqual(toMatchPatterns("*.example.com"), [
|
||||
"http://*.example.com/*",
|
||||
"https://*.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/*",
|
||||
]);
|
||||
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/*",
|
||||
]);
|
||||
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*",
|
||||
]);
|
||||
assert.deepEqual(toMatchPatterns("oa.com/admin"), ["http://oa.com/admin*", "https://oa.com/admin*"]);
|
||||
});
|
||||
|
||||
it("去掉端口(match pattern 不支持端口)", () => {
|
||||
@@ -146,8 +131,87 @@ describe("toMatchPatterns - 配置域名转 match pattern", () => {
|
||||
it("无法表达的规则返回空数组", () => {
|
||||
assert.deepEqual(toMatchPatterns(""), []);
|
||||
assert.deepEqual(toMatchPatterns(null), []);
|
||||
assert.deepEqual(toMatchPatterns("*"), []); // 裸通配
|
||||
assert.deepEqual(toMatchPatterns("a.*.b.com"), []); // 中间通配的 host
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,7 +227,7 @@ describe("escapeHtml", () => {
|
||||
it("组合转义", () => {
|
||||
assert.equal(
|
||||
escapeHtml('<a href="x" onclick="alert(\'hi\')">text & more</a>'),
|
||||
"<a href="x" onclick="alert('hi')">text & more</a>"
|
||||
"<a href="x" onclick="alert('hi')">text & more</a>",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user