#!/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); });