fix(resource-rule): 帳號上資源超過一頁時,規則看到的必須是全部(Arcrun#123 續集)

三支清單方法只打 `?per_page=100`,也就是**只看第一頁**。這個洞在 #123 的修法
前後嚴重度不同,這才是它必須跟那張票一起修的理由:

  · 修法前:被截掉的是「worker 綁著的那顆」→ 2b 判「綁著的資源不見了」
            → blocker → 停手。誣告使用者,但安全。
  · 修法後:被截掉的是「同名殘骸」→ 2c 判「這個名字沒被佔走」
            → 去建 → CF 回 title already exists → #123 的死路原樣回來。

⇒ 修法把它從「叫得太大聲」變成「安靜地復發」。分開出貨等於把 #123 的災情
  延後到「資源比較多的帳號」再爆。

做法:`cfListAll()` 翻到底;翻不完、或數量對不上 CF 回報的 `total_count`,
一律 throw ⇒ 變 blocker ⇒ 整趟停手(README 規則第 3 條)。
「我不知道」不准被當成「它沒有」。

三支端點的分頁行為不一樣(2026-08-14 在 geek6688 帳號實測,唯讀):
  /storage/kv/namespaces  result_info 有 total_pages
  /d1/database            result_info **沒有** total_pages ⇒ 不能拿它當終止條件
  /vectorize/v2/indexes   result_info 是 null,不分頁(分頁參數被忽略)
所以終止條件只用「三支都有或都沒有」的兩件事:result_info 在不在、total_count 對不對得上。

fixture 的清單端點同步照真 CF 的形狀分頁(三支各自不同)——假資料失真就會養出
「拿 total_pages 當終止條件」這種在 D1 上必壞的實作,而測試全綠。

新增 tests/list-pagination.mjs(在舊碼上實測會紅,且第 ③ 段直接重現
「無 blocker → 排 10 顆新建 → CF 回 title already exists」的 #123 死路)。
cli 73 項全綠、demo 與 half-finished-install 全綠。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
richblack
2026-08-14 19:52:24 +08:00
parent 43323b1a78
commit 68f042cfd0
6 changed files with 531 additions and 45 deletions
+109 -3
View File
@@ -117,9 +117,61 @@ export function installerRequirements(claimOwnership = true, d1CreateName = `${B
return out;
}
/**
* 真實 CF 的行為:**同名建不出來**KV 回 400「a namespace with this account ID and title
* already exists」,D1 回 code 7502「Database with name … already exists」——兩條都在
* `geek6688` 帳號上實打驗過)。這一層就是封測者撞到的那道牆。
*
* fixture 過去沒有模擬它,所以「重建一批孤兒」這個假設從來沒被戳破(Arcrun#123)。
*
* 🔴 這支**自己會翻頁**(`per_page` 開很大)。它要是只看第一頁,就會在
* 「帳號上資源很多」的測試裡漏認同名 ⇒ 反而把被測的 bug 蓋住。
*
* @param {ReturnType<typeof makeAccount>} account
* @returns {typeof globalThis.fetch}
*/
export function cfRejectsDuplicateNames(account) {
const inner = account.fetch;
const BASE = 'https://api.cloudflare.com/client/v4/accounts/x';
/** @param {string} path @returns {Promise<any[]>} */
const listAll = async (path) => {
const res = await inner(`${BASE}${path}?per_page=100000&page=1`, {});
return (await res.json()).result ?? [];
};
/** @param {string} message */
const conflict = (message) =>
new Response(JSON.stringify({ success: false, result: null, errors: [{ message }] }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
/** @type {typeof globalThis.fetch} */
// @ts-expect-error — 測試替身
return async (input, init) => {
const url = new URL(typeof input === 'string' ? input : String(input));
const path = url.pathname.replace(/^\/client\/v4\/accounts\/[^/]+/, '');
const method = (init?.method ?? 'GET').toUpperCase();
if (method === 'POST' && (path === '/storage/kv/namespaces' || path === '/d1/database')) {
const body = JSON.parse(String(init?.body));
if (path === '/storage/kv/namespaces') {
const taken = (await listAll(path)).some((/** @type {{title: string}} */ n) => n.title === body.title);
if (taken) return conflict('a namespace with this account ID and title already exists');
} else {
const taken = (await listAll(path)).some((/** @type {{name: string}} */ d) => d.name === body.name);
if (taken) return conflict(`Database with name: '${body.name}' already exists`);
}
}
return inner(input, init);
};
}
/**
* 建一個假帳號 + 對應的 `fetch` 替身。
*
* @param {object} [opts]
* @param {number} [opts.decoyKv] 帳號上另外還有幾顆「別人的」KV(排在我們的前面)
* @param {number} [opts.decoyD1] 同上,D1
*
* @param {Scenario} scenario
* @returns {{
* fetch: typeof globalThis.fetch,
@@ -130,8 +182,12 @@ export function installerRequirements(claimOwnership = true, d1CreateName = `${B
* requestLog: string[],
* }}
*/
export function makeAccount(scenario) {
export function makeAccount(scenario, opts = {}) {
const spec = SCENARIOS[scenario];
// 「這個帳號上還有很多**別人的**資源」。用途:把我們自己那幾顆擠到第二頁以後,
// 驗清單有沒有翻頁。CF 的 KV 上限是每帳號 1,000 顆,>100 是真實會發生的規模。
const decoyKv = opts.decoyKv ?? 0;
const decoyD1 = opts.decoyD1 ?? 0;
/** title → id */
const kv = new Map();
/** name → uuid */
@@ -154,6 +210,11 @@ export function makeAccount(scenario) {
const kvIdByBinding = new Map();
const D1_ID = 'd1id-kbdb-REAL';
// 誘餌**先塞**,我們自己的才排在它們後面 ⇒ 只看第一頁就一定看不到我們的那幾顆。
// (真 CF 的排序不歸我們管;這裡刻意排成「最壞情況」,因為要證的正是最壞情況下也看得到。)
for (let i = 0; i < decoyKv; i++) kv.set(`someone-elses-kv-${String(i).padStart(4, '0')}`, `kvid-decoy-${i}`);
for (let i = 0; i < decoyD1; i++) d1.set(`someone-elses-db-${String(i).padStart(4, '0')}`, `d1id-decoy-${i}`);
// 資源存不存在,與 worker 部署了沒,是**兩件事**(#123:中斷的安裝會讓前者為真、後者為假)。
if (spec.resourcesExist ?? spec.deployed) {
// 帳號上已經有的資源(名字照該情境的慣例取,id 才是身分)
@@ -185,6 +246,48 @@ export function makeAccount(scenario) {
status,
headers: { 'Content-Type': 'application/json' },
});
/**
* 分頁的清單回應——**照真 Cloudflare 的形狀**,不是照我們方便的形狀。
*
* 【這些假資料憑什麼代表得了真的 CF 回應】
* 2026-08-14 拿 `geek6688` 帳號實打過三支端點(唯讀,只列不建),逐字抄回來的:
*
* ```
* GET /storage/kv/namespaces?per_page=5&page=1
* → result_info {"count":5,"page":1,"per_page":5,"total_count":9,"total_pages":2}
* GET /storage/kv/namespaces?per_page=5&page=2
* → 4 筆,result_info {"count":4,"page":2,"per_page":5,"total_count":9,"total_pages":2}
* GET /d1/database?per_page=5&page=1
* → result_info {"count":1,"page":1,"per_page":5,"total_count":1} ← **沒有 total_pages**
* GET /vectorize/v2/indexes?per_page=1&page=1
* → 2 筆(分頁參數被忽略),result_info: null ← **這支不分頁**
* ```
*
* 🔴 **三支的形狀不一樣,這裡就必須不一樣**。假資料要是三支都長成 KV 那樣,
* 就會養出「拿 `total_pages` 當終止條件」這種在 D1 上必壞的實作,而測試全綠。
* 假資料失真=測了個假的,比沒測更糟。
*
* @param {any[]} all 這個端點上「全部」的東西
* @param {URLSearchParams} q 呼叫端帶來的分頁參數
* @param {{totalPages: boolean}} shape 這支端點的 result_info 帶不帶 total_pages
*/
const okPaged = (all, q, shape) => {
const perPage = Number(q.get('per_page')) || 20;
const page = Number(q.get('page')) || 1;
const slice = all.slice((page - 1) * perPage, page * perPage);
const info = {
count: slice.length,
page,
per_page: perPage,
total_count: all.length,
...(shape.totalPages ? { total_pages: Math.max(1, Math.ceil(all.length / perPage)) } : {}),
};
return new Response(JSON.stringify({ success: true, result: slice, errors: [], result_info: info }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
/** @param {string} message @param {number} status */
const fail = (message, status) =>
new Response(JSON.stringify({ success: false, result: null, errors: [{ message }] }), {
@@ -210,7 +313,8 @@ export function makeAccount(scenario) {
}
if (path === '/storage/kv/namespaces' && method === 'GET') {
return ok([...kv].map(([title, id]) => ({ id, title })));
// 真分頁,result_info 帶 total_pages(實測形狀,見 okPaged
return okPaged([...kv].map(([title, id]) => ({ id, title })), url.searchParams, { totalPages: true });
}
if (path === '/storage/kv/namespaces' && method === 'POST') {
const id = `kvid-NEW-${created.kv.length + 1}`;
@@ -219,7 +323,8 @@ export function makeAccount(scenario) {
return ok({ id, title: body.title });
}
if (path === '/d1/database' && method === 'GET') {
return ok([...d1].map(([name, uuid]) => ({ uuid, name })));
// 真分頁,但 result_info **沒有 total_pages**(實測形狀,見 okPaged
return okPaged([...d1].map(([name, uuid]) => ({ uuid, name })), url.searchParams, { totalPages: false });
}
if (path === '/d1/database' && method === 'POST') {
const uuid = `d1id-NEW-${created.d1.length + 1}`;
@@ -228,6 +333,7 @@ export function makeAccount(scenario) {
return ok({ uuid, name: body.name });
}
if (path === '/vectorize/v2/indexes' && method === 'GET') {
// 這支**不分頁**:分頁參數被忽略、`result_info` 是 null(實測,見 okPaged 檔頭那段)
return ok(vectorize.map((name) => ({ name })));
}
if (path === '/vectorize/v2/indexes' && method === 'POST') {