Files
mnote/scripts/vault-extension-api-smoke.js
T

328 lines
9.3 KiB
JavaScript
Raw Normal View History

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