Files
Arcrun/cypher-executor/tests/kbdb-records-entry-ids-proxy.test.ts
T
uncle6me-web 9ba6ba75fc feat(cypher): POST /kbdb/records 開通 entry_ids 通道(Arcrun#128 的對外門)
基本盤補好而通道不開=能力在、沒人打得到——同 b6ef0f0 那次 PATCH 的形狀。
#129(wiki template)與 #130(三元組正規化)的寫入端走的就是這扇門。

改了兩件事(維持純轉發,判斷全在基本盤,薄殼鐵律見檔頭):
· 舊版寫死 `!body.values → 400`:只給 entry_ids 的合法請求會被自己的 proxy 擋掉
  ⇒ 改成「values 與 entry_ids 至少要有一個」
· 轉發 body 帶上 entry_ids;沒給的那個 key 就不塞(不憑空給基本盤一個空物件)

租戶隔離沿用本檔既有做法:**忽略 caller 自帶的 owner_id,一律注入 header 身份**。
配合基本盤這次加的檢查(被參照 entry 的 owner_id 必須與注入的租戶相符),
「呼叫端可以自己指定 entry_id」這條新路徑不會變成跨租戶的門。

驗(tests/kbdb-records-entry-ids-proxy.test.ts,6 條,fetchMock 假 host+斷網)
· 無 X-Arcrun-API-Key → 401 不碰 KBDB
· 只給 entry_ids → 轉發成功,body 為 {template, entry_ids, owner_id:'leo'}(無 values key),
  且 caller 自帶的 owner_id:'alice' 被忽略
· values 與 entry_ids 混用 → 兩個都轉過去
· 舊呼叫端只給 values → 轉發 body 與過去逐字相同,不夾帶 entry_ids
· 兩個都沒給 → 400 不轉發;base 擋跨租戶(400)→ 原樣透傳不假裝成功

cypher-executor 全套 445 綠 / 14 紅——那 14 條是**既有**紅燈
(auth-dispatcher/console-library-map-page/executor/portal-admin/portal-data),
已用 `git stash` 拿掉本次改動複跑同五個檔複驗:同樣 14 紅,與本次無關。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 14:48:01 +08:00

127 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* POST /kbdb/records — `entry_ids` 通道(Arcrun#128
*
* 背景:基本盤 kbdb 的 createRecord 現在接受 `entry_ids`slot 指向**既有** entry 的 id
* 不新建、不複製)。這條 proxy 之前寫死只轉發 `values`,且沒有 values 就 400
* ⇒ 走 X-Arcrun-API-Key 的呼叫者(#129 的 wiki 寫入端、#130 的三元組正規化)打不到新能力,
* 等於基本盤補好了、通道沒開(同 b6ef0f0 那次 PATCH 的形狀)。
*
* 驗的是 IO 接線(判斷真身在基本盤,這裡只測轉發,比照 kbdb-records-patch-proxy.test.ts):
* 1. 租戶閘:無 X-Arcrun-API-Key → 401 不碰 KBDB
* 2. 只給 entry_ids(沒有 values)→ 轉發成功(舊版這裡是 400)
* 3. 轉發的 body:帶 entry_ids **注入租戶當 owner_id**caller 自帶的 owner_id 被忽略)
* 4. values 與 entry_ids 混用 → 兩個都轉過去
* 5. 兩個都沒給 → 400,不轉發
* 6. base 擋跨租戶(400)→ 原樣透傳,不假裝成功
*
* KBDB 打 fetchMock 假 hostwrangler.test.toml KBDB_BASE_URL=https://kbdb.test)+
* disableNetConnect——測試絕不外連。
*/
import { SELF, fetchMock } from 'cloudflare:test';
import { beforeAll, afterEach, describe, it, expect } from 'vitest';
const KEY = { 'X-Arcrun-API-Key': 'leo', 'Content-Type': 'application/json' };
beforeAll(() => {
fetchMock.activate();
fetchMock.disableNetConnect();
});
afterEach(() => fetchMock.assertNoPendingInterceptors());
describe('POST /kbdb/recordsentry_ids)— 租戶閘與參數', () => {
it('無 X-Arcrun-API-Key → 401,不碰 KBDB', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1' } }),
});
expect(res.status).toBe(401);
});
it('values 與 entry_ids 都沒給 → 400,不轉發', async () => {
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ template: 'wiki' }),
});
expect(res.status).toBe(400);
});
});
describe('POST /kbdb/recordsentry_ids)— 轉發', () => {
it('只給 entry_ids(沒有 values)→ 轉發,且 owner_id 由租戶身份注入', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records',
method: 'POST',
// 沒有 values 這個 key(不要憑空塞一個空物件給基本盤)
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'leo' }),
})
.reply(200, {
success: true,
record: { record_id: 'rec_1', template_id: 'tpl-wiki', values: { gloss: '既有', points: '既有2' }, owner_id: 'leo' },
});
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
// caller 自帶 owner_id:必須被忽略(防跨租戶寫入,本檔既有慣例)
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_1', points: 'e_2' }, owner_id: 'alice' }),
});
expect(res.status).toBe(200);
const data = (await res.json()) as { success: boolean; record: { values: Record<string, string> } };
expect(data.success).toBe(true);
expect(data.record.values.gloss).toBe('既有');
});
it('values 與 entry_ids 混用 → 兩個都轉過去', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records',
method: 'POST',
body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' }, owner_id: 'leo' }),
})
.reply(200, { success: true, record: { record_id: 'rec_2', template_id: 'tpl-wiki', values: {}, owner_id: 'leo' } });
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ template: 'wiki', values: { title: '新建' }, entry_ids: { gloss: 'e_1' } }),
});
expect(res.status).toBe(200);
});
it('舊呼叫端(只給 values)→ 轉發的 body 不夾帶 entry_ids,行為與過去相同', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({
path: '/records',
method: 'POST',
body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' }, owner_id: 'leo' }),
})
.reply(200, { success: true, record: { record_id: 'rec_3', template_id: 'tpl-triplet', values: {}, owner_id: 'leo' } });
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ template: 'triplet', values: { subject: 'A', predicate: 'r', object: 'B' } }),
});
expect(res.status).toBe(200);
});
it('base 擋下跨租戶參照(400)→ 原樣透傳,不假裝成功', async () => {
fetchMock
.get('https://kbdb.test')
.intercept({ path: '/records', method: 'POST' })
.reply(400, { success: false, error: 'entry owner mismatch: e_x(alice) != leo' });
const res = await SELF.fetch('http://localhost/kbdb/records', {
method: 'POST',
headers: KEY,
body: JSON.stringify({ template: 'wiki', entry_ids: { gloss: 'e_x' } }),
});
expect(res.status).toBe(400);
const data = (await res.json()) as { success: boolean; error: string };
expect(data.success).toBe(false);
expect(data.error).toContain('owner mismatch');
});
});