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
@@ -19,7 +19,9 @@
import { planResources, applyResourcePlan, ResourcePlanBlocked, bindingKey } from '../rule.mjs';
import { createCloudflareResourceApi } from '../cf-resource-api.mjs';
import { makeAccount, installerRequirements, requirements, KV_BINDINGS, BASE_NAME } from './fixture-account.mjs';
import {
makeAccount, installerRequirements, requirements, KV_BINDINGS, BASE_NAME, cfRejectsDuplicateNames,
} from './fixture-account.mjs';
let failed = 0;
/** @param {boolean} cond @param {string} what */
@@ -32,31 +34,8 @@ function section(title) {
console.log(`\n━━━ ${title} ━━━`);
}
/**
* 真實 CF 的行為:**同名 KV 建不出來**。這一行就是封測者撞到的那道牆——
* fixture 過去沒有模擬它,所以「重建一批孤兒」這個當年的假設從來沒有被戳破。
* @param {ReturnType<typeof makeAccount>} account
*/
function cfRejectsDuplicateNames(account) {
const inner = account.fetch;
/** @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\/[^/]+/, '');
if (path === '/storage/kv/namespaces' && (init?.method ?? 'GET').toUpperCase() === 'POST') {
const title = JSON.parse(String(init?.body)).title;
const listed = await (await inner(`https://api.cloudflare.com/client/v4/accounts/x/storage/kv/namespaces`, {})).json();
if (listed.result.some((/** @type {{title: string}} */ n) => n.title === title)) {
return new Response(JSON.stringify({
success: false, result: null,
errors: [{ message: 'a namespace with this account ID and title already exists' }],
}), { status: 400, headers: { 'Content-Type': 'application/json' } });
}
}
return inner(input, init);
};
}
// 「真 CF 會拒絕同名」這個替身搬去 fixture-account.mjs 了(pagination.mjs 也要用同一份,
// 而且它必須自己會翻頁——只看第一頁的版本會在「帳號上資源很多」的測試裡漏認同名)。
/** @param {ReturnType<typeof makeAccount>} account */
const apiFor = (account, fetchImpl) =>