优化
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
// tests/content-fill.test.js
|
||||
// 用 jsdom(真实 DOM)测试 content.js 的登录表单探测与填充引擎:
|
||||
// 字段猜测、隐藏元素跳过、Shadow DOM 穿透、HTML 片段定位、
|
||||
// 自动提交/验证码拦截、失败次数限制、多环境浮窗、SPA 重试、手动填充消息。
|
||||
// 运行:node --test tests/content-fill.test.js
|
||||
//
|
||||
// jsdom 不实现布局(offsetWidth/offsetParent 恒为 0/null),因此这里统一把元素视为"可见",
|
||||
// 需要模拟隐藏时用 hide() 单独覆盖 —— 这样能真实覆盖 content.js 里的可见性判断分支。
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { JSDOM } = require("jsdom");
|
||||
|
||||
const UTILS_CODE = fs.readFileSync(path.join(__dirname, "..", "dist", "utils.js"), "utf8");
|
||||
const CONTENT_CODE = fs.readFileSync(path.join(__dirname, "..", "dist", "content.js"), "utf8");
|
||||
|
||||
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 = {} } = {}) {
|
||||
const state = {
|
||||
configs,
|
||||
attempts: new Map(Object.entries(attempts)),
|
||||
calls: [],
|
||||
messageListener: null,
|
||||
};
|
||||
|
||||
const api = {
|
||||
runtime: {
|
||||
id: "mock-extension-id",
|
||||
onMessage: { addListener: (fn) => { state.messageListener = fn; } },
|
||||
sendMessage: (message) => {
|
||||
state.calls.push(message.action);
|
||||
switch (message.action) {
|
||||
case "loadAllConfigs":
|
||||
return Promise.resolve({ configs: state.configs });
|
||||
case "getVaultStatus":
|
||||
return Promise.resolve({ hasVault: false, unlocked: true, pendingImport: false });
|
||||
case "getDomainAttempts": {
|
||||
const key = `${message.envId}::${message.domain}`;
|
||||
return Promise.resolve({ attempts: state.attempts.get(key) || { count: 0, lastAttempt: 0 } });
|
||||
}
|
||||
case "incrementDomainAttempts": {
|
||||
const key = `${message.envId}::${message.domain}`;
|
||||
const cur = state.attempts.get(key) || { count: 0, lastAttempt: 0 };
|
||||
cur.count += 1;
|
||||
cur.lastAttempt = Date.now();
|
||||
state.attempts.set(key, cur);
|
||||
return Promise.resolve({ attempts: cur });
|
||||
}
|
||||
case "resetDomainAttempts":
|
||||
return Promise.resolve({ success: true });
|
||||
default:
|
||||
return Promise.resolve({});
|
||||
}
|
||||
},
|
||||
},
|
||||
i18n: { getMessage: (key) => key },
|
||||
storage: {
|
||||
local: { get: async () => ({}), set: async () => {}, remove: async () => {} },
|
||||
session: { get: async () => ({}), set: async () => {}, remove: async () => {} },
|
||||
},
|
||||
};
|
||||
|
||||
return { api, state };
|
||||
}
|
||||
|
||||
/** 在 jsdom 页面里执行 utils.js + content.js(与浏览器一致:按普通脚本顺序执行) */
|
||||
function loadContentPage({ html = "", url = "https://example.com/login", configs = [], attempts = {} } = {}) {
|
||||
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);
|
||||
|
||||
const chrome = createChromeMock({ configs, attempts });
|
||||
win.chrome = chrome.api;
|
||||
|
||||
const inject = (code) => {
|
||||
const el = win.document.createElement("script");
|
||||
el.textContent = code;
|
||||
win.document.head.appendChild(el);
|
||||
};
|
||||
inject(UTILS_CODE);
|
||||
inject(CONTENT_CODE);
|
||||
|
||||
return { dom, win, doc: win.document, chrome };
|
||||
}
|
||||
|
||||
/** 模拟元素不可见(隐藏域 / display:none) */
|
||||
function hide(el) {
|
||||
Object.defineProperty(el, "offsetWidth", { get: () => 0, configurable: true });
|
||||
Object.defineProperty(el, "offsetParent", { get: () => null, configurable: true });
|
||||
return el;
|
||||
}
|
||||
|
||||
function makeConfig(overrides = {}) {
|
||||
return {
|
||||
id: "cfg1",
|
||||
envId: "env1",
|
||||
envName: "测试",
|
||||
domain: "example.com",
|
||||
alias: null,
|
||||
username: "auto-user",
|
||||
password: "auto-pass",
|
||||
autoSubmit: false,
|
||||
usernameSelector: null,
|
||||
passwordSelector: null,
|
||||
submitSelector: null,
|
||||
enabled: true,
|
||||
delayMs: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("content - 字段探测", () => {
|
||||
it("标准登录表单:用户名 / 密码 / 提交按钮都能找到", () => {
|
||||
const ctx = loadContentPage({
|
||||
html: `<form>
|
||||
<input type="text" name="username">
|
||||
<input type="password" name="pass">
|
||||
<button type="submit">登录</button>
|
||||
</form>`,
|
||||
});
|
||||
|
||||
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, "登录");
|
||||
});
|
||||
|
||||
it("显式选择器优先于自动探测", () => {
|
||||
const ctx = loadContentPage({
|
||||
html: `<form>
|
||||
<input type="password" id="wrong">
|
||||
<input type="password" id="right">
|
||||
<input type="text" id="u1">
|
||||
<input type="text" id="u2" name="account">
|
||||
</form>`,
|
||||
});
|
||||
|
||||
const config = { passwordSelector: "#right", usernameSelector: "#u2" };
|
||||
assert.equal(ctx.win.findPasswordField(config).id, "right");
|
||||
assert.equal(ctx.win.findUsernameField(config).id, "u2");
|
||||
});
|
||||
|
||||
it("用户名按 name / placeholder 关键词猜测", () => {
|
||||
const ctx = loadContentPage({
|
||||
html: `<form>
|
||||
<input type="text" placeholder="请输入手机号" id="phone">
|
||||
<input type="text" placeholder="登录账号" id="account">
|
||||
<input type="password">
|
||||
</form>`,
|
||||
});
|
||||
assert.equal(ctx.win.findUsernameField({}).id, "account");
|
||||
});
|
||||
|
||||
it("隐藏的密码框被跳过,选可见的那个", () => {
|
||||
const ctx = loadContentPage({
|
||||
html: `<form>
|
||||
<input type="password" id="ghost">
|
||||
<input type="password" id="real">
|
||||
</form>`,
|
||||
});
|
||||
hide(ctx.doc.getElementById("ghost"));
|
||||
assert.equal(ctx.win.findPasswordField({}).id, "real");
|
||||
});
|
||||
|
||||
it("页面上没有密码框时返回 null,fillForm 返回 false", () => {
|
||||
const ctx = loadContentPage({ html: `<form><input type="text" name="q"></form>` });
|
||||
assert.equal(ctx.win.findPasswordField({}), null);
|
||||
assert.equal(ctx.win.fillForm({ username: "u", password: "p", autoSubmit: false }, false), false);
|
||||
});
|
||||
|
||||
it("提交按钮按文案识别(非 type=submit)", () => {
|
||||
const ctx = loadContentPage({
|
||||
html: `<form><input type="password"><button id="btn">Sign in</button></form>`,
|
||||
});
|
||||
assert.equal(ctx.win.findSubmitButton({}, ctx.doc.querySelector("input[type=password]")).id, "btn");
|
||||
});
|
||||
|
||||
it("能穿透 Shadow DOM 找到登录框", () => {
|
||||
const ctx = loadContentPage({ html: `<div id="host"></div>` });
|
||||
const shadow = ctx.doc.getElementById("host").attachShadow({ mode: "open" });
|
||||
shadow.innerHTML = `<form>
|
||||
<input type="text" name="user">
|
||||
<input type="password" name="pass">
|
||||
<button type="submit">登录</button>
|
||||
</form>`;
|
||||
|
||||
assert.equal(ctx.win.findPasswordField({}).getAttribute("name"), "pass");
|
||||
assert.equal(ctx.win.findUsernameField({}).getAttribute("name"), "user");
|
||||
});
|
||||
|
||||
it("resolveElement 支持选择器与整段 HTML 片段两种写法", () => {
|
||||
const ctx = loadContentPage({
|
||||
html: `<form><input type="password" id="pwd" name="pass"></form>`,
|
||||
});
|
||||
assert.equal(ctx.win.resolveElement("input[name=pass]").id, "pwd");
|
||||
// 从 HTML 片段反推选择器(用户直接粘贴元素代码的场景)
|
||||
assert.equal(ctx.win.resolveElement('<input type="password" id="pwd">').id, "pwd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("content - 填充与提交", () => {
|
||||
it("填充用户名和密码,并对两个字段派发 input/change 事件", () => {
|
||||
const ctx = loadContentPage({
|
||||
html: `<form>
|
||||
<input type="text" name="username" id="u">
|
||||
<input type="password" id="p">
|
||||
<button type="submit" id="btn">登录</button>
|
||||
</form>`,
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for (const id of ["u", "p"]) {
|
||||
const el = ctx.doc.getElementById(id);
|
||||
el.addEventListener("input", () => events.push(`${id}:input`));
|
||||
el.addEventListener("change", () => events.push(`${id}:change`));
|
||||
}
|
||||
|
||||
assert.equal(ctx.win.fillForm({ username: "alice", password: "secret", autoSubmit: false }, false), true);
|
||||
assert.equal(ctx.doc.getElementById("u").value, "alice");
|
||||
assert.equal(ctx.doc.getElementById("p").value, "secret");
|
||||
assert.deepEqual(events, ["u:input", "u:change", "p:input", "p:change"]);
|
||||
});
|
||||
|
||||
it("autoSubmit 为真时延迟点击提交按钮", async () => {
|
||||
const ctx = loadContentPage({
|
||||
html: `<form><input type="text" name="username"><input type="password"><button type="submit" id="btn">登录</button></form>`,
|
||||
});
|
||||
const btn = ctx.doc.getElementById("btn");
|
||||
let clicks = 0;
|
||||
btn.addEventListener("click", () => clicks++);
|
||||
|
||||
ctx.win.fillForm({ username: "u", password: "p", autoSubmit: true }, true);
|
||||
assert.equal(clicks, 0, "不应立即点击");
|
||||
|
||||
await tick(AFTER_SUBMIT);
|
||||
assert.equal(clicks, 1);
|
||||
});
|
||||
|
||||
it("检测到验证码时不自动提交,并弹出提示", async () => {
|
||||
const ctx = loadContentPage({
|
||||
html: `<form>
|
||||
<input type="text" name="username">
|
||||
<input type="password">
|
||||
<div class="g-recaptcha"></div>
|
||||
<button type="submit" id="btn">登录</button>
|
||||
</form>`,
|
||||
});
|
||||
const btn = ctx.doc.getElementById("btn");
|
||||
let clicks = 0;
|
||||
btn.addEventListener("click", () => clicks++);
|
||||
|
||||
ctx.win.fillForm({ username: "u", password: "p", autoSubmit: true }, true);
|
||||
await tick(AFTER_SUBMIT);
|
||||
|
||||
assert.equal(clicks, 0, "有验证码时不能自动提交");
|
||||
assert.ok(ctx.doc.body.innerHTML.includes("captchaToastTitle"), "应显示验证码提示浮窗");
|
||||
});
|
||||
|
||||
it("没有提交按钮但在 form 内时回退到 requestSubmit", async () => {
|
||||
const ctx = loadContentPage({
|
||||
html: `<form><input type="text" name="username"><input type="password"></form>`,
|
||||
});
|
||||
let submitted = 0;
|
||||
const form = ctx.doc.querySelector("form");
|
||||
Object.defineProperty(form, "requestSubmit", { value: () => { submitted++; }, configurable: true });
|
||||
|
||||
ctx.win.fillForm({ username: "u", password: "p", autoSubmit: true }, true);
|
||||
await tick(AFTER_SUBMIT);
|
||||
assert.equal(submitted, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("content - 自动填充主流程", () => {
|
||||
const LOGIN_HTML = `<form>
|
||||
<input type="text" name="username" id="u">
|
||||
<input type="password" id="p">
|
||||
<button type="submit" id="btn">登录</button>
|
||||
</form>`;
|
||||
|
||||
it("域名命中配置时自动填充", async () => {
|
||||
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");
|
||||
assert.equal(ctx.doc.getElementById("p").value, "auto-pass");
|
||||
});
|
||||
|
||||
it("域名不匹配时什么都不做", async () => {
|
||||
const ctx = loadContentPage({ html: LOGIN_HTML, url: "https://other.com/login", configs: [makeConfig()] });
|
||||
await tick(60);
|
||||
|
||||
assert.equal(ctx.doc.getElementById("u").value, "");
|
||||
assert.equal(ctx.doc.getElementById("p").value, "");
|
||||
});
|
||||
|
||||
it("配置被禁用时不填充", async () => {
|
||||
const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ enabled: false })] });
|
||||
await tick(60);
|
||||
assert.equal(ctx.doc.getElementById("u").value, "");
|
||||
});
|
||||
|
||||
it("未达失败上限时自动提交并累加计数", async () => {
|
||||
const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ autoSubmit: true })] });
|
||||
let clicks = 0;
|
||||
ctx.doc.getElementById("btn").addEventListener("click", () => clicks++);
|
||||
|
||||
await tick(AFTER_SUBMIT);
|
||||
assert.equal(ctx.doc.getElementById("u").value, "auto-user");
|
||||
assert.equal(clicks, 1);
|
||||
assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts"));
|
||||
});
|
||||
|
||||
it("失败次数达到上限后只填充、不自动提交", async () => {
|
||||
const ctx = loadContentPage({
|
||||
html: LOGIN_HTML,
|
||||
configs: [makeConfig({ autoSubmit: true })],
|
||||
attempts: { "env1::example.com": { count: 3, lastAttempt: Date.now() } },
|
||||
});
|
||||
let clicks = 0;
|
||||
ctx.doc.getElementById("btn").addEventListener("click", () => clicks++);
|
||||
|
||||
await tick(AFTER_SUBMIT);
|
||||
assert.equal(ctx.doc.getElementById("u").value, "auto-user", "仍然填充");
|
||||
assert.equal(clicks, 0, "不应自动提交");
|
||||
assert.ok(!ctx.chrome.state.calls.includes("incrementDomainAttempts"));
|
||||
});
|
||||
|
||||
it("失败计数超过冷却时间后重新计数并允许提交", async () => {
|
||||
const ctx = loadContentPage({
|
||||
html: LOGIN_HTML,
|
||||
configs: [makeConfig({ autoSubmit: true })],
|
||||
attempts: { "env1::example.com": { count: 3, lastAttempt: Date.now() - 10 * 60 * 1000 } },
|
||||
});
|
||||
let clicks = 0;
|
||||
ctx.doc.getElementById("btn").addEventListener("click", () => clicks++);
|
||||
|
||||
await tick(AFTER_SUBMIT);
|
||||
assert.equal(clicks, 1, "冷却后应重新允许提交");
|
||||
});
|
||||
});
|
||||
|
||||
describe("content - 多环境命中", () => {
|
||||
const LOGIN_HTML = `<form>
|
||||
<input type="text" name="username" id="u">
|
||||
<input type="password" id="p">
|
||||
</form>`;
|
||||
|
||||
it("多个环境命中时弹出选择浮窗,点击后填充对应账号", async () => {
|
||||
const ctx = loadContentPage({
|
||||
html: LOGIN_HTML,
|
||||
configs: [
|
||||
makeConfig({ id: "c1", envId: "e1", envName: "个人", username: "personal@x.com" }),
|
||||
makeConfig({ id: "c2", envId: "e2", envName: "生产", username: "prod@x.com" }),
|
||||
],
|
||||
});
|
||||
await tick(60);
|
||||
|
||||
const picker = ctx.doc.getElementById("auto-login-env-picker");
|
||||
assert.ok(picker, "应出现多环境选择浮窗");
|
||||
assert.equal(ctx.doc.getElementById("u").value, "", "选择前不填充");
|
||||
|
||||
const buttons = [...picker.querySelectorAll("button")];
|
||||
const prodBtn = buttons.find((b) => b.textContent.includes("生产"));
|
||||
prodBtn.dispatchEvent(new ctx.win.MouseEvent("click", { bubbles: true }));
|
||||
await tick(60);
|
||||
|
||||
assert.equal(ctx.doc.getElementById("u").value, "prod@x.com");
|
||||
assert.equal(ctx.doc.getElementById("auto-login-env-picker"), null, "选择后浮窗关闭");
|
||||
});
|
||||
|
||||
it("点击取消关闭浮窗且不填充", async () => {
|
||||
const ctx = loadContentPage({
|
||||
html: LOGIN_HTML,
|
||||
configs: [makeConfig({ id: "c1" }), makeConfig({ id: "c2", envId: "e2", envName: "生产" })],
|
||||
});
|
||||
await tick(60);
|
||||
|
||||
const picker = ctx.doc.getElementById("auto-login-env-picker");
|
||||
const cancelBtn = [...picker.querySelectorAll("button")].pop();
|
||||
cancelBtn.dispatchEvent(new ctx.win.MouseEvent("click", { bubbles: true }));
|
||||
|
||||
assert.equal(ctx.doc.getElementById("auto-login-env-picker"), null);
|
||||
assert.equal(ctx.doc.getElementById("u").value, "");
|
||||
});
|
||||
});
|
||||
|
||||
describe("content - SPA 延迟渲染", () => {
|
||||
it("表单晚于脚本出现时,重试后仍能填充", async () => {
|
||||
const ctx = loadContentPage({
|
||||
html: `<div id="app"></div>`,
|
||||
configs: [makeConfig()],
|
||||
});
|
||||
await tick(80);
|
||||
assert.equal(ctx.doc.querySelectorAll("input").length, 0, "此时还没有表单");
|
||||
|
||||
// 模拟 SPA 前端稍后渲染出登录表单
|
||||
ctx.doc.getElementById("app").innerHTML = `<form>
|
||||
<input type="text" name="username" id="u">
|
||||
<input type="password" id="p">
|
||||
</form>`;
|
||||
|
||||
await tick(700); // 重试间隔 500ms
|
||||
assert.equal(ctx.doc.getElementById("u").value, "auto-user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("content - 手动填充消息", () => {
|
||||
const LOGIN_HTML = `<form>
|
||||
<input type="text" name="username" id="u">
|
||||
<input type="password" id="p">
|
||||
</form>`;
|
||||
|
||||
it("manualFill 填充指定配置并回包 success", async () => {
|
||||
const ctx = loadContentPage({ html: LOGIN_HTML });
|
||||
let response = null;
|
||||
|
||||
ctx.chrome.state.messageListener(
|
||||
{ action: "manualFill", config: makeConfig({ username: "manual@x.com" }) },
|
||||
{},
|
||||
(r) => { response = r; }
|
||||
);
|
||||
await tick(30);
|
||||
|
||||
// 注意:response 由 jsdom realm 创建,跨 realm 不能用 deepEqual(原型不同)
|
||||
assert.equal(response.success, true);
|
||||
assert.equal(ctx.doc.getElementById("u").value, "manual@x.com");
|
||||
});
|
||||
|
||||
it("manualFill 找不到表单时回包诊断信息", async () => {
|
||||
const ctx = loadContentPage({ html: `<div>没有表单</div>` });
|
||||
let response = null;
|
||||
|
||||
ctx.chrome.state.messageListener(
|
||||
{ action: "manualFill", config: makeConfig() },
|
||||
{},
|
||||
(r) => { response = r; }
|
||||
);
|
||||
await tick(30);
|
||||
|
||||
assert.equal(response.success, false);
|
||||
assert.equal(response.diag.total, 0);
|
||||
assert.equal(response.diag.forms, 0);
|
||||
});
|
||||
|
||||
it("manualFillMulti 弹出多环境浮窗", async () => {
|
||||
const ctx = loadContentPage({ html: LOGIN_HTML });
|
||||
let response = null;
|
||||
|
||||
ctx.chrome.state.messageListener(
|
||||
{ action: "manualFillMulti", configs: [makeConfig({ id: "c1" }), makeConfig({ id: "c2", envId: "e2", envName: "生产" })] },
|
||||
{},
|
||||
(r) => { response = r; }
|
||||
);
|
||||
await tick(30);
|
||||
|
||||
assert.equal(response.success, true);
|
||||
assert.ok(ctx.doc.getElementById("auto-login-env-picker"));
|
||||
});
|
||||
});
|
||||
@@ -137,6 +137,51 @@ describe("crypto-store - 设置/解锁/锁定", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("crypto-store - unlockWithKey(密钥句柄恢复)", () => {
|
||||
it("用 setupVault 返回的密钥可重新解锁,无需口令", async () => {
|
||||
const storage = createMemoryStorage();
|
||||
const C = loadCryptoStore(storage);
|
||||
const key = await C.setupVault("pw", [{ id: "e1", name: "个人", configs: [{ domain: "a.com" }] }]);
|
||||
await C.lock();
|
||||
assert.equal(await C.isUnlocked(), false);
|
||||
|
||||
// 模拟 SW 重启后从 IndexedDB 取回句柄
|
||||
const environments = await C.unlockWithKey(key);
|
||||
assert.equal(environments.length, 1);
|
||||
assert.equal(environments[0].name, "个人");
|
||||
assert.equal(await C.isUnlocked(), true);
|
||||
assert.ok(storage.sessionStore.environments);
|
||||
// 全程没有明文口令落进任何存储
|
||||
assert.equal(storage.sessionStore.__autoPwd, undefined);
|
||||
});
|
||||
|
||||
it("密钥与 vault 不匹配时抛错", async () => {
|
||||
const storage = createMemoryStorage();
|
||||
const C = loadCryptoStore(storage);
|
||||
await C.setupVault("pw", [{ id: "e1", name: "x", configs: [] }]);
|
||||
await C.lock();
|
||||
|
||||
const otherKey = await C.deriveKey("别的口令", C.randomBytes(16), C.PBKDF2_ITERATIONS);
|
||||
await assert.rejects(() => C.unlockWithKey(otherKey));
|
||||
assert.equal(await C.isUnlocked(), false);
|
||||
});
|
||||
|
||||
it("未设置口令时 unlockWithKey 抛错", async () => {
|
||||
const C = loadCryptoStore(createMemoryStorage());
|
||||
const key = await C.deriveKey("pw", C.randomBytes(16), C.PBKDF2_ITERATIONS);
|
||||
await assert.rejects(() => C.unlockWithKey(key), /尚未设置口令/);
|
||||
});
|
||||
|
||||
it("重设口令后旧句柄失效(背景会据此丢弃句柄)", async () => {
|
||||
const storage = createMemoryStorage();
|
||||
const C = loadCryptoStore(storage);
|
||||
const oldKey = await C.setupVault("old-pw", [{ id: "e1", name: "x", configs: [] }]);
|
||||
await C.setupVault("new-pw", [{ id: "e1", name: "x", configs: [] }]);
|
||||
await C.lock();
|
||||
await assert.rejects(() => C.unlockWithKey(oldKey));
|
||||
});
|
||||
});
|
||||
|
||||
describe("crypto-store - persistEncrypted", () => {
|
||||
it("用密钥重新加密 environments 写回 local", async () => {
|
||||
const storage = createMemoryStorage();
|
||||
|
||||
@@ -42,7 +42,11 @@ function loadContentJs(mockDoc) {
|
||||
document: mockDoc,
|
||||
chrome: {
|
||||
runtime: {
|
||||
id: "mock-extension-id",
|
||||
onMessage: { addListener() {} },
|
||||
// content.js 加载时会自动调用 loadAllConfigs → sendMessage,
|
||||
// 缺少该 mock 会抛 "_sendMessage is not a function"(未处理拒绝,污染测试结果)
|
||||
sendMessage: () => Promise.resolve({ configs: [] }),
|
||||
},
|
||||
// i18n mock:getMessage 返回 key 自身作为文案(fallback)
|
||||
i18n: { getMessage: (key) => key || "" },
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// tests/key-store.test.js
|
||||
// 测试 key-store.js:把不可导出的 CryptoKey 句柄存取到 IndexedDB
|
||||
// 运行:node --test tests/key-store.test.js
|
||||
//
|
||||
// 说明:用极简 IndexedDB shim 覆盖模块的异步流程与 API 语义;
|
||||
// 真实的 structured clone(CryptoKey 句柄持久化)语义由浏览器保证,Node 侧无法完整模拟。
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
function createFakeIndexedDB() {
|
||||
const databases = new Map();
|
||||
|
||||
function makeRequest(getResult) {
|
||||
const req = { result: undefined, error: null, onsuccess: null, onerror: null };
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
req.result = getResult();
|
||||
if (req.onsuccess) req.onsuccess();
|
||||
} catch (err) {
|
||||
req.error = err;
|
||||
if (req.onerror) req.onerror();
|
||||
}
|
||||
});
|
||||
return req;
|
||||
}
|
||||
|
||||
function makeDb(name) {
|
||||
const stores = new Map();
|
||||
return {
|
||||
objectStoreNames: { contains: (n) => stores.has(n) },
|
||||
createObjectStore: (n) => {
|
||||
stores.set(n, new Map());
|
||||
return {};
|
||||
},
|
||||
transaction(storeName) {
|
||||
const data = stores.get(storeName);
|
||||
const tx = { error: null, onabort: null };
|
||||
tx.objectStore = () => ({
|
||||
put: (value, key) => makeRequest(() => { data.set(key, value); return key; }),
|
||||
get: (key) => makeRequest(() => data.get(key)),
|
||||
delete: (key) => makeRequest(() => { data.delete(key); return undefined; }),
|
||||
});
|
||||
return tx;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
open(name) {
|
||||
const req = {
|
||||
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);
|
||||
databases.set(name, db);
|
||||
}
|
||||
req.result = db;
|
||||
if (isNew && req.onupgradeneeded) req.onupgradeneeded();
|
||||
if (req.onsuccess) req.onsuccess();
|
||||
});
|
||||
return req;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadKeyStore() {
|
||||
delete require.cache[require.resolve("../dist/key-store.js")];
|
||||
return require("../dist/key-store.js");
|
||||
}
|
||||
|
||||
async function makeAesKey() {
|
||||
// extractable = false:这正是 vault 派生密钥的形态
|
||||
return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
||||
}
|
||||
|
||||
describe("key-store - 密钥句柄存取", () => {
|
||||
let K;
|
||||
|
||||
beforeEach(() => {
|
||||
global.indexedDB = createFakeIndexedDB();
|
||||
K = loadKeyStore();
|
||||
});
|
||||
|
||||
it("初始没有句柄时 loadKey 返回 null", async () => {
|
||||
assert.equal(await K.loadKey(), null);
|
||||
});
|
||||
|
||||
it("saveKey 后可以取回同一个密钥对象", async () => {
|
||||
const key = await makeAesKey();
|
||||
await K.saveKey(key);
|
||||
const loaded = await K.loadKey();
|
||||
assert.ok(loaded);
|
||||
assert.equal(loaded.algorithm.name, "AES-GCM");
|
||||
});
|
||||
|
||||
it("取回的密钥仍不可导出(句柄而非原始字节)", async () => {
|
||||
const key = await makeAesKey();
|
||||
await K.saveKey(key);
|
||||
const loaded = await K.loadKey();
|
||||
assert.equal(loaded.extractable, false);
|
||||
await assert.rejects(() => crypto.subtle.exportKey("raw", loaded));
|
||||
});
|
||||
|
||||
it("取回的密钥可以正常解密", async () => {
|
||||
const key = await makeAesKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
new TextEncoder().encode("secret")
|
||||
);
|
||||
|
||||
await K.saveKey(key);
|
||||
const loaded = await K.loadKey();
|
||||
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, loaded, ciphertext);
|
||||
assert.equal(new TextDecoder().decode(plain), "secret");
|
||||
});
|
||||
|
||||
it("重复 saveKey 覆盖旧句柄", async () => {
|
||||
const first = await makeAesKey();
|
||||
await K.saveKey(first);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const payload = new TextEncoder().encode("hello");
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, first, payload);
|
||||
|
||||
const second = await makeAesKey();
|
||||
await K.saveKey(second);
|
||||
const loaded = await K.loadKey();
|
||||
|
||||
// 取回的是第二个密钥,用旧密钥加密的数据解不开
|
||||
await assert.rejects(() => crypto.subtle.decrypt({ name: "AES-GCM", iv }, loaded, ciphertext));
|
||||
});
|
||||
|
||||
it("deleteKey 后取不到句柄(锁定即丢弃)", async () => {
|
||||
await K.saveKey(await makeAesKey());
|
||||
await K.deleteKey();
|
||||
assert.equal(await K.loadKey(), null);
|
||||
});
|
||||
|
||||
it("isAvailable 在支持 IndexedDB 时为 true", async () => {
|
||||
assert.equal(await K.isAvailable(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("key-store - 不支持 IndexedDB 时降级", () => {
|
||||
it("loadKey 返回 null 而不是抛错", async () => {
|
||||
delete global.indexedDB;
|
||||
const K = loadKeyStore();
|
||||
assert.equal(await K.loadKey(), null);
|
||||
assert.equal(await K.isAvailable(), false);
|
||||
});
|
||||
});
|
||||
+71
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { isDomainMatch, escapeHtml } = require("../dist/utils.js");
|
||||
const { isDomainMatch, escapeHtml, toMatchPatterns } = require("../dist/utils.js");
|
||||
|
||||
describe("isDomainMatch - 纯 hostname 匹配", () => {
|
||||
it("精确域名匹配", () => {
|
||||
@@ -81,6 +81,76 @@ describe("isDomainMatch - hostname + path 匹配", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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("escapeHtml", () => {
|
||||
it("转义 5 个特殊字符", () => {
|
||||
assert.equal(escapeHtml("<"), "<");
|
||||
|
||||
Reference in New Issue
Block a user