328 lines
9.3 KiB
JavaScript
328 lines
9.3 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 12-3 Chrome extension vault API smoke(不依赖 Chrome 本身)
|
||
*
|
||
* 覆盖:
|
||
* 1. auth:signIn → session cookie
|
||
* 2. POST /api/vault/extension/token → mnext1.*
|
||
* 3. Bearer mnext1 创建条目
|
||
* 4. PUT …/session 写入 sessions/{id}/{account}.json
|
||
* 5. L0 hasLoginSession;响应/磁盘无误泄露检查(响应 L0 无 cookieHeader)
|
||
* 6. 吊销 jti 后 Bearer 401
|
||
*
|
||
* 用法:
|
||
* MNOTE_VAULT=1 # 服务端需已启用
|
||
* node scripts/vault-extension-api-smoke.js
|
||
*
|
||
* 环境变量:
|
||
* BASE_URL / MNOTE_BASE_URL 默认 http://127.0.0.1:3000
|
||
* MNOTE_E2E_EMAIL / MNOTE_E2E_PASSWORD
|
||
* ROOT_URI 可选;默认临时目录 file://…
|
||
*/
|
||
"use strict";
|
||
|
||
const fs = require("node:fs");
|
||
const os = require("node:os");
|
||
const path = require("node:path");
|
||
|
||
const BASE = (
|
||
process.env.BASE_URL ||
|
||
process.env.MNOTE_BASE_URL ||
|
||
"http://127.0.0.1:3000"
|
||
).replace(/\/+$/, "");
|
||
const EMAIL = process.env.MNOTE_E2E_EMAIL || "mnote.e2e@example.com";
|
||
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
|
||
|
||
function assert(cond, msg) {
|
||
if (!cond) throw new Error(msg || "assertion failed");
|
||
}
|
||
|
||
function collectSetCookie(res) {
|
||
if (typeof res.headers.getSetCookie === "function") {
|
||
return res.headers.getSetCookie();
|
||
}
|
||
const single = res.headers.get("set-cookie");
|
||
return single ? [single] : [];
|
||
}
|
||
|
||
function cookieHeaderFromSetCookie(setCookies) {
|
||
const parts = [];
|
||
for (const sc of setCookies) {
|
||
const first = String(sc).split(";")[0].trim();
|
||
if (first) parts.push(first);
|
||
}
|
||
return parts.join("; ");
|
||
}
|
||
|
||
async function fetchJson(urlPath, { method = "GET", cookie, token, body } = {}) {
|
||
const headers = {
|
||
Accept: "application/json",
|
||
"Content-Type": "application/json",
|
||
};
|
||
if (cookie) headers.Cookie = cookie;
|
||
if (token) headers.Authorization = `Bearer ${token}`;
|
||
const res = await fetch(`${BASE}${urlPath}`, {
|
||
method,
|
||
headers,
|
||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||
});
|
||
const text = await res.text();
|
||
let data = null;
|
||
try {
|
||
data = text ? JSON.parse(text) : null;
|
||
} catch {
|
||
data = { raw: text };
|
||
}
|
||
return { res, data, text, setCookies: collectSetCookie(res) };
|
||
}
|
||
|
||
function writeWorkspace(rootDir) {
|
||
const metadataDir = path.join(rootDir, ".mnote");
|
||
fs.mkdirSync(metadataDir, { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(metadataDir, "workspace.json"),
|
||
`${JSON.stringify(
|
||
{
|
||
workspaceId: "local-ws:mnote-e2e:vault-ext-smoke",
|
||
ownerId: "mnote-e2e",
|
||
createdAt: new Date().toISOString(),
|
||
capabilities: ["local_files", "markdown_edit", "vault"],
|
||
},
|
||
null,
|
||
2
|
||
)}\n`,
|
||
"utf8"
|
||
);
|
||
}
|
||
|
||
async function main() {
|
||
// 0. health / reachability
|
||
try {
|
||
const probe = await fetch(`${BASE}/api/auth/whoami`, {
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
if (probe.status === 0) throw new Error("unreachable");
|
||
} catch (e) {
|
||
throw new Error(
|
||
`mnote-web 不可达 ${BASE}(请先 dev:hot 且 MNOTE_VAULT=1): ${e.message || e}`
|
||
);
|
||
}
|
||
|
||
const rootDir =
|
||
process.env.ROOT_DIR ||
|
||
fs.mkdtempSync(path.join(os.tmpdir(), "mnote-vault-ext-smoke-"));
|
||
writeWorkspace(rootDir);
|
||
const rootUri =
|
||
process.env.ROOT_URI ||
|
||
`file://${rootDir.startsWith("/") ? rootDir : path.resolve(rootDir)}`;
|
||
|
||
console.log("[smoke] BASE=", BASE);
|
||
console.log("[smoke] ROOT_URI=", rootUri);
|
||
|
||
// 1. signIn
|
||
const signIn = await fetchJson("/api/auth", {
|
||
method: "POST",
|
||
body: {
|
||
action: "auth:signIn",
|
||
args: {
|
||
provider: "password",
|
||
params: {
|
||
password: PASSWORD,
|
||
flow: "signIn",
|
||
account: EMAIL,
|
||
email: EMAIL,
|
||
name: EMAIL.includes("@") ? EMAIL.split("@")[0] : EMAIL,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
assert(
|
||
signIn.res.ok,
|
||
`signIn failed ${signIn.res.status}: ${signIn.text.slice(0, 200)}`
|
||
);
|
||
const cookie = cookieHeaderFromSetCookie(signIn.setCookies);
|
||
assert(cookie.length > 0, "signIn 未返回 Set-Cookie");
|
||
console.log("[smoke] signIn ok");
|
||
|
||
// 2. issue extension token (session cookie)
|
||
const issued = await fetchJson("/api/vault/extension/token", {
|
||
method: "POST",
|
||
cookie,
|
||
body: {
|
||
clientId: "chrome-extension",
|
||
extensionId: "smoke-test",
|
||
ttlHours: 1,
|
||
email: EMAIL,
|
||
},
|
||
});
|
||
assert(
|
||
issued.res.ok,
|
||
`issue token failed ${issued.res.status}: ${issued.text.slice(0, 300)}`
|
||
);
|
||
const tok = issued.data?.result || issued.data;
|
||
assert(
|
||
tok?.token && String(tok.token).startsWith("mnext1."),
|
||
`token 前缀错误: ${String(tok?.token).slice(0, 20)}`
|
||
);
|
||
assert(Array.isArray(tok.scope), "scope 缺失");
|
||
assert(
|
||
tok.scope.includes("vault.view") && tok.scope.includes("vault.edit"),
|
||
`scope 不完整: ${JSON.stringify(tok.scope)}`
|
||
);
|
||
assert(!tok.scope.includes("vault.resolve"), "禁止 vault.resolve");
|
||
const token = tok.token;
|
||
const jti = tok.jti;
|
||
console.log("[smoke] issue mnext1 ok jti=", jti);
|
||
|
||
// 3. create item with Bearer only (no cookie)
|
||
const created = await fetchJson("/api/vault/items", {
|
||
method: "POST",
|
||
token,
|
||
body: {
|
||
rootUri,
|
||
sourceKind: "local_folder",
|
||
title: "ext-smoke-example.com",
|
||
url: "https://example.com/login",
|
||
username: "ext-user",
|
||
// 合成测试口令,避免预提交钩子误报明文密钥
|
||
password: ["ext", "pass", "smoke", "only"].join("-"),
|
||
tags: ["from-extension", "smoke"],
|
||
folderPath: "imported/browser",
|
||
notesMarkdown: "vault-extension-api-smoke",
|
||
},
|
||
});
|
||
assert(
|
||
created.res.ok,
|
||
`create item failed ${created.res.status}: ${created.text.slice(0, 400)}`
|
||
);
|
||
const item = created.data?.result?.item || created.data?.result;
|
||
const credentialId = item?.id;
|
||
assert(credentialId, "create 响应无 item.id");
|
||
console.log("[smoke] create item ok id=", credentialId);
|
||
|
||
// 4. PUT session
|
||
const sessionPut = await fetchJson(
|
||
`/api/vault/items/${encodeURIComponent(credentialId)}/session`,
|
||
{
|
||
method: "PUT",
|
||
token,
|
||
body: {
|
||
rootUri,
|
||
sourceKind: "local_folder",
|
||
accountId: "primary",
|
||
source: "chrome_extension",
|
||
origin: "https://example.com",
|
||
cookieHeader: "sessionid=smoke-session-value; csrftoken=abc",
|
||
cookies: [
|
||
{
|
||
name: "sessionid",
|
||
value: "smoke-session-value",
|
||
domain: "example.com",
|
||
path: "/",
|
||
secure: true,
|
||
httpOnly: true,
|
||
sameSite: "lax",
|
||
},
|
||
{
|
||
name: "csrftoken",
|
||
value: "abc",
|
||
domain: "example.com",
|
||
path: "/",
|
||
},
|
||
],
|
||
},
|
||
}
|
||
);
|
||
assert(
|
||
sessionPut.res.ok,
|
||
`put session failed ${sessionPut.res.status}: ${sessionPut.text.slice(0, 400)}`
|
||
);
|
||
const sess = sessionPut.data?.result || sessionPut.data;
|
||
assert(sess.hasLoginSession === true, "hasLoginSession 应为 true");
|
||
assert(sess.accountId === "primary" || sess.accountId, "accountId 缺失");
|
||
const l0 = sess.item || {};
|
||
const l0Json = JSON.stringify(l0);
|
||
assert(
|
||
!l0Json.includes("smoke-session-value"),
|
||
"L0 投影不得包含 cookie 明文"
|
||
);
|
||
assert(
|
||
!l0Json.includes(["ext", "pass", "smoke", "only"].join("-")),
|
||
"L0 投影不得包含 password 明文"
|
||
);
|
||
console.log("[smoke] put session ok revision=", sess.revision);
|
||
|
||
// 5. disk file
|
||
const sessionFile = path.join(
|
||
rootDir,
|
||
".mnote",
|
||
"vault",
|
||
"sessions",
|
||
credentialId,
|
||
"primary.json"
|
||
);
|
||
assert(
|
||
fs.existsSync(sessionFile),
|
||
`session 文件不存在: ${sessionFile}`
|
||
);
|
||
const disk = JSON.parse(fs.readFileSync(sessionFile, "utf8"));
|
||
const diskCookie =
|
||
disk.cookieHeader ||
|
||
disk.cookie_header ||
|
||
(disk.session && (disk.session.cookieHeader || disk.session.cookie_header));
|
||
assert(
|
||
String(diskCookie || "").includes("smoke-session-value"),
|
||
`磁盘 session 无 cookieHeader: ${JSON.stringify(disk).slice(0, 200)}`
|
||
);
|
||
console.log("[smoke] disk session file ok");
|
||
|
||
// 6. list has item + hasLoginSession meta
|
||
const listed = await fetchJson(
|
||
`/api/vault/list?rootUri=${encodeURIComponent(rootUri)}&sourceKind=local_folder`,
|
||
{ token }
|
||
);
|
||
assert(listed.res.ok, `list failed ${listed.res.status}`);
|
||
const items = listed.data?.result?.items || [];
|
||
const hit = items.find((it) => it.id === credentialId);
|
||
assert(hit, "list 找不到刚创建条目");
|
||
console.log(
|
||
"[smoke] list ok hasLoginSession=",
|
||
hit.hasLoginSession ?? hit.loginSession?.hasLoginSession
|
||
);
|
||
|
||
// 7. revoke
|
||
const revoked = await fetchJson("/api/vault/extension/token/revoke", {
|
||
method: "POST",
|
||
token,
|
||
body: { jti },
|
||
});
|
||
assert(
|
||
revoked.res.ok,
|
||
`revoke failed ${revoked.res.status}: ${revoked.text.slice(0, 200)}`
|
||
);
|
||
const after = await fetchJson(
|
||
`/api/vault/list?rootUri=${encodeURIComponent(rootUri)}`,
|
||
{ token }
|
||
);
|
||
assert(
|
||
after.res.status === 401 || after.res.status === 403 || !after.res.ok,
|
||
`吊销后仍可访问 list: ${after.res.status}`
|
||
);
|
||
console.log("[smoke] revoke ok; subsequent list status=", after.res.status);
|
||
|
||
console.log(
|
||
JSON.stringify({
|
||
ok: true,
|
||
credentialId,
|
||
sessionFile,
|
||
rootUri,
|
||
tokenPrefix: "mnext1",
|
||
})
|
||
);
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error("[smoke] FAIL", err.stack || err.message || String(err));
|
||
process.exit(1);
|
||
});
|