9ba6ba75fc
基本盤補好而通道不開=能力在、沒人打得到——同 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>
127 lines
5.5 KiB
TypeScript
127 lines
5.5 KiB
TypeScript
/**
|
||
* 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 假 host(wrangler.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/records(entry_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/records(entry_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');
|
||
});
|
||
});
|