10d150ac2b
leo 2026-08-12:「人類進 Portal 輸入帳密表示你是主人,可以查到你權限所有東西;
AI 透過輸入帳密的 MCP 查詢表示是授權的 AI,可以查到主人允許查的任何東西。」
「掛上 MCP 並輸入帳密,那個動作本身就是授權」⇒ 下游不得再要求第二次認證。
病根(不是金鑰沒同步,是身分沒接住):
oauth/routes.ts 驗完 Portal 帳密只留下 `loginOk = res.ok` 一個布林值,身分當場丟棄,
namespace 改從 `MCP_OWNER_NAMESPACE || "leo"` 拿。於是查詢時手上沒有身分可帶,
只好用 KBDB_INTERNAL_TOKEN 直打 KBDB——那條路繞過 portal 所有庫過濾,
而且不管誰登入都看到同一格、看到全部。CLI 也從不注入 MCP_OWNER_NAMESPACE,
所以那個 "leo" 預設值是每台實例的實際行為,不是理論上的邊角。
修法(走既有那條路,不發明新的):
1. 接住身分:/authorize 解析 /portal/login 回應,把 portal session token +
display_name/role/libraries 存進 authorization code → access token。
/portal/login 補回 session_expires_in,access_token TTL 夾成
min(自己的 TTL, portal session TTL)——不讓「MCP 還連著、底下 session 早死」。
cypher 回 200 但沒給 session_token(舊版)→ 不發碼,不簽一張沒有身分的 token。
2. 攜帶身分:kbdb_* 全部改走 cypher `/portal/data/*`,Authorization 帶登入者的
session。庫過濾/租戶注入/停用即時生效全在 server 側,與人類走 portal 網頁同一道閘。
kbdb_graph_neighbors 因此不再需要 kbdb_base(server 自己知道查哪個庫)。
藏書地圖(含連線時注入 instructions 的那份)同樣只回有權限的庫,快取改 per-session
分格——地圖本身就是情報,不能讓先連上的人把視野留給下一個。
3. fail-closed:舊 token 沒有身分 → 誠實要求重新連線,不偷偷退回服務金鑰那條老路。
服務級憑據(static token / partner key)維持既有 KBDB 直連,arcrun_* 零回歸。
新增 cypher portal 資料面端點(能力長在 API,MCP 只暴露;rule 07):
GET /portal/data/map、/portal/data/map/:library
GET /portal/data/templates、POST /portal/data/templates
GET /portal/data/records/by-template/:t、GET /portal/data/records/:id
POST /portal/data/records
全部:呼叫端自帶 owner_id 一律不生效;越權與不存在同回 404;寫入 owner_id 由 server 定死。
KBDB base:`GET /records/:id` 與 by-template 補回 owner_id 欄位——原本不回,
呼叫端無從判斷「這筆是不是我的」,按 id 直讀等於沒有租戶邊界。
沒動:KBDB fail-closed 閘、任何金鑰、租戶字串仍不下發給呼叫端。
驗證:
mcp tsc 綠;vitest 113/113 綠(改前 48 綠 29 紅)
cypher vitest 400 綠 / 14 紅,14 紅與 base commit a24f291 逐條相同(既有)
kbdb vitest 208 綠 / 5 紅,5 紅同為既有(migrations/*.sql 被 gitignore)
端到端 ◐ 未驗:需部署到 leo21c,那道閘要 leo 親手解(見 PR)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
439 lines
19 KiB
TypeScript
439 lines
19 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||
import type { Env } from "../../../src/types.js";
|
||
import { registerGetMap } from "../../../src/tools/kbdb_map.js";
|
||
import {
|
||
buildLibraryMapInstructions,
|
||
renderLibraryMapLines,
|
||
__resetLibraryMapInstructionsCacheForTests,
|
||
} from "../../../src/lib/library-map.js";
|
||
import type { KnowledgeIdentity } from "../../../src/lib/portal-client.js";
|
||
|
||
/** 服務級憑據(static token / partner key)——既有 KBDB 直連路徑,行為零變更。 */
|
||
const SERVICE: KnowledgeIdentity = { kind: "service" };
|
||
/** 有人輸入 Portal 帳密授權的連線——走 cypher 的 portal 資料面(只看得到自己有權限的庫)。 */
|
||
const PORTAL: KnowledgeIdentity = {
|
||
kind: "portal",
|
||
portal: { session: "sess-abc", display_name: "Leo", role: "admin", libraries: ["kb"] },
|
||
};
|
||
/** 本次改版前簽發的舊 token(沒有身分)。 */
|
||
const STALE: KnowledgeIdentity = { kind: "stale" };
|
||
|
||
/** 假 CYPHER_EXECUTOR binding(portal 資料面用)。 */
|
||
function makePortalEnv(respond: (url: URL, init?: RequestInit) => Response) {
|
||
const calls: { url: URL; init?: RequestInit }[] = [];
|
||
const env = {
|
||
CYPHER_EXECUTOR: {
|
||
fetch: async (input: string, init?: RequestInit) => {
|
||
const url = new URL(input);
|
||
calls.push({ url, init });
|
||
return respond(url, init);
|
||
},
|
||
},
|
||
} as unknown as Env;
|
||
return { env, calls };
|
||
}
|
||
|
||
// ── 假 McpServer:只攔 tool 註冊,抓出 handler 直接呼叫(比照 kbdb-graph.test.ts)──────
|
||
type ToolHandler = (args: Record<string, unknown>) => Promise<{
|
||
content: { type: string; text: string }[];
|
||
isError?: boolean;
|
||
}>;
|
||
|
||
function makeServer() {
|
||
const tools = new Map<string, { description: string; handler: ToolHandler }>();
|
||
const server = {
|
||
tool(name: string, description: string, _schema: unknown, handler: ToolHandler) {
|
||
tools.set(name, { description, handler });
|
||
},
|
||
};
|
||
return { server: server as unknown as McpServer, tools };
|
||
}
|
||
|
||
// ── 假 KBDB service binding:記錄請求、回預設 response ─────────────────────────────
|
||
function makeEnv(respond: (url: URL, init?: RequestInit) => Response) {
|
||
const calls: { url: URL; init?: RequestInit }[] = [];
|
||
const env = {
|
||
KBDB: {
|
||
fetch: async (input: string, init?: RequestInit) => {
|
||
const url = new URL(input);
|
||
calls.push({ url, init });
|
||
return respond(url, init);
|
||
},
|
||
},
|
||
KBDB_INTERNAL_TOKEN: "test-token",
|
||
} as unknown as Env;
|
||
return { env, calls };
|
||
}
|
||
|
||
function parseResult(r: { content: { text: string }[] }) {
|
||
return JSON.parse(r.content[0].text) as Record<string, unknown>;
|
||
}
|
||
|
||
const KB_ROW = {
|
||
library: "kb",
|
||
narrative: "leo 的知識庫主庫",
|
||
top_entities: ["00-INDEX", "kb/00-INDEX", "Gitea"],
|
||
triplet_count: 111,
|
||
updated_at: 1784451880,
|
||
};
|
||
|
||
describe("kbdb_get_map: registration", () => {
|
||
it("registers under kbdb_* prefix (D17) with the 'call this first' hint in description", () => {
|
||
const { server, tools } = makeServer();
|
||
const { env } = makeEnv(() => new Response("{}"));
|
||
registerGetMap(server, env, SERVICE);
|
||
expect(tools.has("kbdb_get_map")).toBe(true);
|
||
// 任務規格:description 必含「不確定該查什麼時,先呼叫此工具」
|
||
expect(tools.get("kbdb_get_map")!.description).toContain("不確定該查什麼時,先呼叫此工具");
|
||
});
|
||
});
|
||
|
||
describe("kbdb_get_map: 全館地圖(無參數)", () => {
|
||
it("hits GET /map via KBDB binding and returns per-library rows", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env, calls } = makeEnv(
|
||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||
);
|
||
registerGetMap(server, env, SERVICE);
|
||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||
|
||
expect(calls).toHaveLength(1);
|
||
expect(calls[0].url.pathname).toBe("/map");
|
||
// kbdbFetch 慣例:KBDB_INTERNAL_TOKEN 注入 Authorization
|
||
expect(new Headers(calls[0].init?.headers).get("Authorization")).toBe("Bearer test-token");
|
||
|
||
const body = parseResult(res);
|
||
expect(body.ok).toBe(true);
|
||
const data = body.data as { libraries: { library: string; top_entities: string[] }[]; count: number };
|
||
expect(data.count).toBe(1);
|
||
expect(data.libraries[0].library).toBe("kb");
|
||
expect(data.libraries[0].top_entities).toEqual(["00-INDEX", "kb/00-INDEX", "Gitea"]);
|
||
});
|
||
|
||
it("owner_id is forwarded as query param", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env, calls } = makeEnv(
|
||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||
);
|
||
registerGetMap(server, env, SERVICE);
|
||
await tools.get("kbdb_get_map")!.handler({ owner_id: "leo" });
|
||
expect(calls[0].url.searchParams.get("owner_id")).toBe("leo");
|
||
});
|
||
|
||
it("top_entities in JSON-string form is parsed defensively (live-observed slot shape)", async () => {
|
||
const { server, tools } = makeServer();
|
||
const row = {
|
||
...KB_ROW,
|
||
top_entities: '[{"name":"00-INDEX","degree":29},{"name":"Gitea","degree":7}]',
|
||
triplet_count: "111", // slot 底層存字串 → 要轉數字
|
||
};
|
||
const { env } = makeEnv(
|
||
() => new Response(JSON.stringify({ success: true, libraries: [row], count: 1 })),
|
||
);
|
||
registerGetMap(server, env, SERVICE);
|
||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||
const data = parseResult(res).data as {
|
||
libraries: { top_entities: string[]; triplet_count: number }[];
|
||
};
|
||
expect(data.libraries[0].top_entities).toEqual(["00-INDEX", "Gitea"]);
|
||
expect(data.libraries[0].triplet_count).toBe(111);
|
||
});
|
||
|
||
it("空庫誠實回報+提示跑 POST /map/recompute backfill(鐵律:不假綠不 crash)", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env } = makeEnv(
|
||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||
);
|
||
registerGetMap(server, env, SERVICE);
|
||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||
const body = parseResult(res);
|
||
expect(body.ok).toBe(true);
|
||
expect((body.data as { count: number }).count).toBe(0);
|
||
expect(JSON.stringify(body.hints)).toContain("POST /map/recompute");
|
||
});
|
||
|
||
// 2026-08-08:舊版說明文字宣稱「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過
|
||
// (總管實測 grep 全 repo 查無任何呼叫點),是假話。修法:GET /map 讀端本身自動核對
|
||
// 即時三元組數並補算,不靠任何外部呼叫者。這裡釘住那句謊言不會再出現在任何 hint 裡。
|
||
it("不再宣稱「地圖由 ingest 尾端自動重算(M3)」——那件事從沒接上過,是假話(已修正措辭)", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env } = makeEnv(
|
||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||
);
|
||
registerGetMap(server, env, SERVICE);
|
||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||
const body = parseResult(res);
|
||
const hintsText = JSON.stringify(body.hints);
|
||
expect(hintsText).not.toContain("地圖由 ingest 尾端自動重算");
|
||
expect(hintsText).not.toContain("(M3)");
|
||
expect(hintsText).not.toContain("(M3)");
|
||
// 誠實的新措辭:空=這個租戶真的沒資料,不是「沒人跑過 recompute」
|
||
expect(hintsText).toContain("這個租戶目前沒有任何三元組資料");
|
||
});
|
||
|
||
it("HTTP error → map_fetch_failed with recompute hint, not a crash", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
|
||
registerGetMap(server, env, SERVICE);
|
||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||
expect(res.isError).toBe(true);
|
||
const body = parseResult(res);
|
||
expect(body.error_code).toBe("map_fetch_failed");
|
||
expect(JSON.stringify(body.next_actions)).toContain("POST /map/recompute");
|
||
});
|
||
});
|
||
|
||
describe("kbdb_get_map: 單庫詳圖(library 參數)", () => {
|
||
const DETAIL = {
|
||
record_id: "e_123",
|
||
library: "kb",
|
||
narrative: "leo 的知識庫主庫",
|
||
content: "kb:leo 的知識庫主庫。核心:00-INDEX",
|
||
top_entities: [{ name: "00-INDEX", degree: 29 }],
|
||
relation_profile: [{ predicate: "連結至", count: 48 }],
|
||
bridges: [{ entity: "Gitea", libraries: ["notes"] }],
|
||
triplet_count: 111,
|
||
commit_hash: null,
|
||
status: "active",
|
||
updated_at: 1784451880,
|
||
};
|
||
|
||
it("hits GET /map/:library and returns full detail", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env, calls } = makeEnv(
|
||
() => new Response(JSON.stringify({ success: true, map: DETAIL })),
|
||
);
|
||
registerGetMap(server, env, SERVICE);
|
||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||
expect(calls[0].url.pathname).toBe("/map/kb");
|
||
const map = (parseResult(res).data as { map: typeof DETAIL }).map;
|
||
expect(map.record_id).toBe("e_123");
|
||
expect(map.relation_profile).toEqual([{ predicate: "連結至", count: 48 }]);
|
||
expect(map.bridges).toEqual([{ entity: "Gitea", libraries: ["notes"] }]);
|
||
});
|
||
|
||
it("JSON-string slot values are parsed into objects(任務規格); broken JSON → 空陣列", async () => {
|
||
const { server, tools } = makeServer();
|
||
const raw = {
|
||
...DETAIL,
|
||
top_entities: '[{"name":"00-INDEX","degree":29}]',
|
||
relation_profile: '[{"predicate":"連結至","count":48}]',
|
||
bridges: "not-json{{", // parse 失敗 → 當空陣列,不 crash
|
||
triplet_count: "111",
|
||
};
|
||
const { env } = makeEnv(() => new Response(JSON.stringify({ success: true, map: raw })));
|
||
registerGetMap(server, env, SERVICE);
|
||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||
expect(res.isError).toBeUndefined();
|
||
const map = (parseResult(res).data as { map: Record<string, unknown> }).map;
|
||
expect(map.top_entities).toEqual([{ name: "00-INDEX", degree: 29 }]);
|
||
expect(map.relation_profile).toEqual([{ predicate: "連結至", count: 48 }]);
|
||
expect(map.bridges).toEqual([]);
|
||
expect(map.triplet_count).toBe(111);
|
||
});
|
||
|
||
it("404 → map_not_found with recompute backfill hint(誠實回報)", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env } = makeEnv(
|
||
() => new Response(JSON.stringify({ success: false, error: "not found" }), { status: 404 }),
|
||
);
|
||
registerGetMap(server, env, SERVICE);
|
||
const res = await tools.get("kbdb_get_map")!.handler({ library: "ghost" });
|
||
expect(res.isError).toBe(true);
|
||
const body = parseResult(res);
|
||
expect(body.error_code).toBe("map_not_found");
|
||
expect(JSON.stringify(body.next_actions)).toContain("POST /map/recompute");
|
||
// 2026-08-08:404 現在的語意是「查無此庫」(地圖是即時核對重算的,已知庫即使空也回 200),
|
||
// 不再是舊版那句「從未 recompute」的曖昧說法。
|
||
expect(String(body.human_message)).toContain("查無庫");
|
||
expect(String(body.human_message)).toContain("從沒出現過");
|
||
});
|
||
|
||
it("binding throws → internal_error, not an unhandled crash", async () => {
|
||
const { server, tools } = makeServer();
|
||
const env = {
|
||
KBDB: {
|
||
fetch: async () => {
|
||
throw new Error("binding down");
|
||
},
|
||
},
|
||
} as unknown as Env;
|
||
registerGetMap(server, env, SERVICE);
|
||
const res = await tools.get("kbdb_get_map")!.handler({ library: "kb" });
|
||
expect(res.isError).toBe(true);
|
||
expect(parseResult(res).error_code).toBe("internal_error");
|
||
});
|
||
});
|
||
|
||
// ── instructions 注入(design §4:拉不到=靜默略過,絕不擋連線)────────────────────────
|
||
describe("buildLibraryMapInstructions", () => {
|
||
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
|
||
|
||
it("renders one compact line per library in the指定格式", async () => {
|
||
const { env } = makeEnv(
|
||
() =>
|
||
new Response(
|
||
JSON.stringify({
|
||
success: true,
|
||
libraries: [
|
||
KB_ROW,
|
||
{ library: "notes", narrative: "手機隨手筆記", top_entities: ["Gitea"], triplet_count: 108 },
|
||
],
|
||
count: 2,
|
||
}),
|
||
),
|
||
);
|
||
const text = await buildLibraryMapInstructions(env, SERVICE);
|
||
expect(text).not.toBeNull();
|
||
// design §4 格式:{library}:{narrative}|核心:{top3}|{triplet_count} triplets
|
||
expect(text!).toContain("kb:leo 的知識庫主庫|核心:00-INDEX、kb/00-INDEX、Gitea|111 triplets");
|
||
expect(text!).toContain("notes:手機隨手筆記|核心:Gitea|108 triplets");
|
||
// 導引句:把地圖跟 get_map 工具接起來(design §6 retrieval 流程)
|
||
expect(text!).toContain("kbdb_get_map");
|
||
});
|
||
|
||
it("HTTP error → null(靜默略過,不 throw 不擋連線)", async () => {
|
||
const { env } = makeEnv(() => new Response("boom", { status: 500 }));
|
||
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||
});
|
||
|
||
it("binding throws → null(靜默略過)", async () => {
|
||
const env = {
|
||
KBDB: {
|
||
fetch: async () => {
|
||
throw new Error("kbdb down");
|
||
},
|
||
},
|
||
} as unknown as Env;
|
||
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||
});
|
||
|
||
it("empty libraries → null(沒地圖就不注入,不塞空段落)", async () => {
|
||
const { env } = makeEnv(
|
||
() => new Response(JSON.stringify({ success: true, libraries: [], count: 0 })),
|
||
);
|
||
await expect(buildLibraryMapInstructions(env, SERVICE)).resolves.toBeNull();
|
||
});
|
||
|
||
it("caches within TTL:same isolate 第二次不再打 /map", async () => {
|
||
const { env, calls } = makeEnv(
|
||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||
);
|
||
const first = await buildLibraryMapInstructions(env, SERVICE);
|
||
const second = await buildLibraryMapInstructions(env, SERVICE);
|
||
expect(second).toBe(first);
|
||
expect(calls).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe("renderLibraryMapLines", () => {
|
||
it("null narrative / string-form top_entities / string count 都容錯", () => {
|
||
const text = renderLibraryMapLines([
|
||
{
|
||
library: "kb",
|
||
narrative: null,
|
||
top_entities: '["00-INDEX"]',
|
||
triplet_count: "5",
|
||
},
|
||
]);
|
||
expect(text).toBe("- kb:(narrative 待補)|核心:00-INDEX|5 triplets");
|
||
});
|
||
|
||
it("empty input → null", () => {
|
||
expect(renderLibraryMapLines([])).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── 2026-08-12:地圖也要跟著登入者的權限走 ────────────────────────────────────
|
||
// 地圖本身就是情報(有哪些庫、各有多少關聯、核心 entity 是誰)——不能整館推給
|
||
// 一個只有部分權限的帳號。
|
||
describe("藏書地圖:登入身分(portal 資料面)", () => {
|
||
beforeEach(() => __resetLibraryMapInstructionsCacheForTests());
|
||
|
||
it("kbdb_get_map 打 /portal/data/map,帶登入者 session,不碰 KBDB 服務金鑰", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env, calls } = makePortalEnv(
|
||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||
);
|
||
registerGetMap(server, env, PORTAL);
|
||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||
expect(res.isError).toBeUndefined();
|
||
expect(calls).toHaveLength(1);
|
||
expect(calls[0].url.pathname).toBe("/portal/data/map");
|
||
expect(new Headers(calls[0].init!.headers as HeadersInit).get("Authorization")).toBe("Bearer sess-abc");
|
||
});
|
||
|
||
it("呼叫端硬塞 owner_id 也不生效(查詢範圍由帳號權限決定,不由呼叫端指定)", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env, calls } = makePortalEnv(
|
||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||
);
|
||
registerGetMap(server, env, PORTAL);
|
||
await tools.get("kbdb_get_map")!.handler({ owner_id: "someone-else" });
|
||
expect(calls[0].url.searchParams.get("owner_id")).toBeNull();
|
||
});
|
||
|
||
it("查沒權限的庫 → 與「不存在」同一句話(不洩存在性)", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env } = makePortalEnv(() => new Response(JSON.stringify({ error: "找不到這筆資料" }), { status: 404 }));
|
||
registerGetMap(server, env, PORTAL);
|
||
const res = await tools.get("kbdb_get_map")!.handler({ library: "secret-lib" });
|
||
expect(res.isError).toBe(true);
|
||
const body = parseResult(res);
|
||
expect(body.error_code).toBe("map_not_found");
|
||
expect(String(body.human_message)).toContain("不在你被授權");
|
||
});
|
||
|
||
it("session 過期(401)→ session_expired,不說「地圖是空的」", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env } = makePortalEnv(
|
||
() => new Response(JSON.stringify({ error: "session 無效或已過期" }), { status: 401 }),
|
||
);
|
||
registerGetMap(server, env, PORTAL);
|
||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||
expect(res.isError).toBe(true);
|
||
expect(parseResult(res).error_code).toBe("session_expired");
|
||
});
|
||
|
||
it("舊 token(沒身分)→ identity_missing,且一個查詢都不發(fail-closed)", async () => {
|
||
const { server, tools } = makeServer();
|
||
const { env, calls } = makePortalEnv(() => new Response("{}"));
|
||
registerGetMap(server, env, STALE);
|
||
const res = await tools.get("kbdb_get_map")!.handler({});
|
||
expect(res.isError).toBe(true);
|
||
expect(parseResult(res).error_code).toBe("identity_missing");
|
||
expect(calls).toHaveLength(0);
|
||
});
|
||
|
||
it("instructions 的地圖也走 portal 資料面(連線開場推的庫名不得超出權限)", async () => {
|
||
const { env, calls } = makePortalEnv(
|
||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||
);
|
||
const text = await buildLibraryMapInstructions(env, PORTAL);
|
||
expect(text).toContain("kb");
|
||
expect(calls[0].url.pathname).toBe("/portal/data/map");
|
||
});
|
||
|
||
it("**快取不跨身分共用**:不同 session 各自打一次,不會拿到別人的視野", async () => {
|
||
const { env, calls } = makePortalEnv(
|
||
() => new Response(JSON.stringify({ success: true, libraries: [KB_ROW], count: 1 })),
|
||
);
|
||
const other: KnowledgeIdentity = {
|
||
kind: "portal",
|
||
portal: { session: "sess-other", display_name: "小明", role: "user", libraries: ["notes"] },
|
||
};
|
||
await buildLibraryMapInstructions(env, PORTAL);
|
||
await buildLibraryMapInstructions(env, other);
|
||
expect(calls).toHaveLength(2); // 兩次真的各打一次
|
||
await buildLibraryMapInstructions(env, PORTAL);
|
||
expect(calls).toHaveLength(2); // 同一 session 第二次才吃快取
|
||
});
|
||
|
||
it("舊 token → 不給地圖(instructions 不外洩任何庫名)", async () => {
|
||
const { env, calls } = makePortalEnv(() => new Response("{}"));
|
||
expect(await buildLibraryMapInstructions(env, STALE)).toBeNull();
|
||
expect(calls).toHaveLength(0);
|
||
});
|
||
});
|