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:
@@ -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') {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* list-pagination.mjs — 「帳號上的資源多到一頁裝不下」時的迴歸守衛。
|
||||
*
|
||||
* node shared/resource-rule/tests/list-pagination.mjs
|
||||
*
|
||||
* 【要證的那句話】
|
||||
* 「規則看到的帳號清單,就是帳號上**真正的全部**」——不論那個帳號有多少顆資源。
|
||||
*
|
||||
* 【為什麼這是 Arcrun#123 的續集,而不是一個獨立的小 bug】
|
||||
* 三支清單方法原本只打 `?per_page=100`(只看第一頁)。同一個截斷,
|
||||
* 在 #123 的修法前後**後果不一樣**:
|
||||
*
|
||||
* · 修法**前**:被截掉的是「worker 綁著的那顆」→ 2b 判「綁著的資源不見了」
|
||||
* → 產生 blocker → **停手**。過度保守,但安全。
|
||||
* · 修法**後**:被截掉的是「同名殘骸」→ 2c 判「這個名字沒被佔走」
|
||||
* → **去建 → CF 回 title already exists → #123 的死路原樣回來**。
|
||||
*
|
||||
* ⇒ #123 的修法把這個洞從「叫得太大聲」變成「**安靜地復發**」。
|
||||
* 所以它必須跟 #123 同一批修掉,否則那張票只是把災情延後到「資源比較多的帳號」。
|
||||
*
|
||||
* 【假資料憑什麼代表得了真的 CF】
|
||||
* `fixture-account.mjs` 的 `okPaged` 是照 2026-08-14 在 `geek6688` 帳號**實打**的回應
|
||||
* 逐字抄回來的形狀(唯讀,只列不建)——關鍵是三支端點**形狀不一樣**:
|
||||
* KV 的 `result_info` 有 `total_pages`/D1 **沒有**/Vectorize 根本 `null`(不分頁)。
|
||||
* 假資料要是三支都照 KV 抄,就會養出「拿 `total_pages` 當終止條件」這種在 D1 上必壞的
|
||||
* 實作,而測試全綠。**假資料失真=測了個假的。**
|
||||
*
|
||||
* 零依賴、零建置,跟 demo.mjs 一樣直接 node 跑。
|
||||
*/
|
||||
|
||||
import { planResources, applyResourcePlan, ResourcePlanBlocked } from '../rule.mjs';
|
||||
import { createCloudflareResourceApi } from '../cf-resource-api.mjs';
|
||||
import {
|
||||
makeAccount, installerRequirements, KV_BINDINGS, BASE_NAME, cfRejectsDuplicateNames,
|
||||
} from './fixture-account.mjs';
|
||||
|
||||
let failed = 0;
|
||||
/** @param {boolean} cond @param {string} what */
|
||||
function check(cond, what) {
|
||||
console.log(` ${cond ? '✅' : '❌'} ${what}`);
|
||||
if (!cond) failed++;
|
||||
}
|
||||
/** @param {string} title */
|
||||
function section(title) {
|
||||
console.log(`\n━━━ ${title} ━━━`);
|
||||
}
|
||||
|
||||
const apiFor = (account, fetchImpl) =>
|
||||
createCloudflareResourceApi({ accountId: 'acct-123', apiToken: 'tok-123', fetch: fetchImpl ?? account.fetch });
|
||||
|
||||
/** 帳號上「別人的」資源顆數。250 > 100 ⇒ 我們自己那幾顆一定落在第三頁。 */
|
||||
const DECOY = 250;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
section('① 清單本身:第二頁以後的東西真的被看見了');
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
{
|
||||
const account = makeAccount('installed', { decoyKv: DECOY, decoyD1: DECOY });
|
||||
const api = apiFor(account);
|
||||
|
||||
const kv = await api.listKvNamespaces();
|
||||
check(kv.size === DECOY + KV_BINDINGS.length,
|
||||
`KV 要讀滿 ${DECOY + KV_BINDINGS.length} 顆(實得 ${kv.size})——只看第一頁的話這裡是 100`);
|
||||
// 我們自己那幾顆排在誘餌後面 ⇒ 它們在第三頁。看得到=真的翻過去了。
|
||||
const lastOne = `${BASE_NAME}-kv-${KV_BINDINGS[KV_BINDINGS.length - 1].toLowerCase()}`;
|
||||
check(kv.has(lastOne), `最後一頁那顆(${lastOne})也在清單裡`);
|
||||
|
||||
const d1 = await api.listD1Databases();
|
||||
check(d1.size === DECOY + 1, `D1 要讀滿 ${DECOY + 1} 顆(實得 ${d1.size})`);
|
||||
check(d1.has(`${BASE_NAME}-kbdb`), '第三頁的那顆 D1 也在清單裡');
|
||||
|
||||
// 真的打了三頁,不是靠某個 per_page 開很大蒙混過去
|
||||
const kvPages = account.requestLog.filter((l) => l.startsWith('GET /storage/kv/namespaces'));
|
||||
check(kvPages.length === 3, `KV 清單分三次抓(實得 ${kvPages.length} 次):\n ${kvPages.join('\n ')}`);
|
||||
check(kvPages.some((l) => l.includes('page=3')), '確實有打到 page=3');
|
||||
const d1Pages = account.requestLog.filter((l) => l.startsWith('GET /d1/database'));
|
||||
check(d1Pages.length === 3, `D1 清單分三次抓(實得 ${d1Pages.length} 次)`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
section('② 修法「前」那一面:已裝好的實例,不准因為看不完整就誣告「你的資源不見了」');
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
{
|
||||
// 使用者好好地裝著,只是帳號上東西多。2b 要拿清單確認「綁著的那顆還在」——
|
||||
// 清單被截斷 ⇒ 規則會說「這顆在你的 Cloudflare 帳號上找不到了」⇒ 好好的更新被硬擋。
|
||||
const account = makeAccount('installed', { decoyKv: DECOY, decoyD1: DECOY });
|
||||
const plan = await planResources(apiFor(account), installerRequirements(), 'update');
|
||||
|
||||
check(plan.blockers.length === 0, `不該有任何 blocker(實得 ${plan.blockers.length} 條)`);
|
||||
if (plan.blockers.length) console.log(plan.blockers.map((b) => ` · ${b}`).join('\n'));
|
||||
check(!plan.blockers.join('\n').includes('找不到了'), '不准出現「這顆在你的帳號上找不到了」這種誣告');
|
||||
check(plan.create.length === 0, `一顆都不該新建(實得 ${plan.create.length})`);
|
||||
check(plan.adopt.length === 11, `11 個綁定全部沿用(實得 ${plan.adopt.length})`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
section('③ 修法「後」那一面(安靜復發的那條):半殘帳號 + 資源很多 ⇒ 仍要接回,不准去建');
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
{
|
||||
// 這一格就是本檔存在的理由:
|
||||
// 殘骸在第三頁 → 清單被截斷 → 2c 判「名字沒被佔走」→ 送 POST → CF 拒絕 → #123 復發。
|
||||
// 而且是**安靜地**復發:規則自己覺得一切正常。
|
||||
const account = makeAccount('half-finished', { decoyKv: DECOY, decoyD1: DECOY });
|
||||
const api = apiFor(account, cfRejectsDuplicateNames(account));
|
||||
const plan = await planResources(api, installerRequirements(true, `${BASE_NAME}-db`), 'init');
|
||||
|
||||
check(plan.blockers.length === 0, `不該有任何 blocker(實得 ${plan.blockers.length} 條)`);
|
||||
if (plan.blockers.length) console.log(plan.blockers.map((b) => ` · ${b}`).join('\n'));
|
||||
check(plan.create.length === 0, `一顆都不該新建(實得 ${plan.create.length} 顆要建)`);
|
||||
check(plan.adopt.length === 11, `11 個綁定全部接回來(實得 ${plan.adopt.length})`);
|
||||
check(plan.adopt.every((a) => a.reclaimed === true), '每一顆都標記為「接回上次留下的」');
|
||||
|
||||
// 走完 apply:CF 那道「同名建不出來」的牆還在,這一趟不准撞上去。
|
||||
await applyResourcePlan(api, plan);
|
||||
check(account.created.kv.length === 0 && account.created.d1.length === 0,
|
||||
`帳號上不該多出任何資源(實得 KV ${account.created.kv.length}/D1 ${account.created.d1.length})`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
section('④ 三支端點形狀不同,一支都不能壞');
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
{
|
||||
const account = makeAccount('fresh');
|
||||
const api = apiFor(account);
|
||||
|
||||
// Vectorize:`result_info` 是 null(不分頁)。翻頁邏輯不能因此漏東西、也不能掛掉。
|
||||
await api.createVectorizeIndex('idx-a');
|
||||
await api.createVectorizeIndex('idx-b');
|
||||
await api.createVectorizeIndex('idx-c');
|
||||
const idx = await api.listVectorizeIndexes();
|
||||
check(idx.length === 3 && idx.includes('idx-c'), `不分頁的端點照樣讀得到全部(實得 ${idx.length} 個)`);
|
||||
|
||||
// D1:`result_info` **沒有 total_pages**。拿 total_pages 當終止條件的實作會在這裡爆。
|
||||
const many = makeAccount('installed', { decoyD1: DECOY });
|
||||
const d1 = await apiFor(many).listD1Databases();
|
||||
check(d1.size === DECOY + 1, `D1 沒有 total_pages 也要翻得完(實得 ${d1.size})`);
|
||||
|
||||
// 空帳號:第一頁就是空的,不能誤判成「還有下一頁」而空轉
|
||||
const empty = makeAccount('fresh');
|
||||
const none = await apiFor(empty).listKvNamespaces();
|
||||
check(none.size === 0, `空帳號回 0 顆且不空轉(實得 ${none.size})`);
|
||||
check(empty.requestLog.filter((l) => l.startsWith('GET /storage/kv/namespaces')).length === 1,
|
||||
'空帳號只打一次清單');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
section('⑤ 看不完整時要**大聲停手**,不准安靜地當作看完了');
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
{
|
||||
// CF 說共有 300 筆,卻從第二頁起一筆都不給。這種時候「回一份不完整的清單」
|
||||
// 就是災難的入口(規則會拿它去判斷該不該新建)⇒ 必須 throw ⇒ 變成 blocker ⇒ 整趟停手。
|
||||
const liar = async (input) => {
|
||||
const url = new URL(String(input));
|
||||
if (!url.pathname.endsWith('/storage/kv/namespaces')) {
|
||||
return new Response(JSON.stringify({ success: true, result: [], errors: [], result_info: null }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
const page = Number(url.searchParams.get('page'));
|
||||
const result = page === 1 ? Array.from({ length: 100 }, (_, i) => ({ id: `id-${i}`, title: `t-${i}` })) : [];
|
||||
return new Response(JSON.stringify({
|
||||
success: true, result, errors: [],
|
||||
result_info: { count: result.length, page, per_page: 100, total_count: 300, total_pages: 3 },
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
};
|
||||
const api = createCloudflareResourceApi({ accountId: 'a', apiToken: 't', fetch: /** @type {any} */ (liar) });
|
||||
|
||||
let threw = null;
|
||||
try {
|
||||
await api.listKvNamespaces();
|
||||
} catch (e) {
|
||||
threw = e;
|
||||
}
|
||||
check(threw !== null, '讀不完整 → 要 throw,不准回一份殘缺清單');
|
||||
check(String(threw?.message ?? '').includes('300'), `訊息要說清楚少了什麼(實得:${threw?.message})`);
|
||||
|
||||
// 而且這個 throw 要在規則那一層變成 blocker(fail-closed),不是讓整個安裝器炸掉
|
||||
const plan = await planResources(api, installerRequirements(), 'init');
|
||||
check(plan.blockers.length > 0, '規則要把它變成 blocker');
|
||||
// 讀不到清單的那一種(KV)**一顆都不准排新建**——「不知道」不等於「它沒有」。
|
||||
// (D1 那邊清單讀得到,照規則排新建是對的;反正整份計畫被 blocker 擋著,一顆都不會真的被建。)
|
||||
check(!plan.create.some((c) => c.kind === 'kv_namespace'), '讀不到清單的那一種資源不准排新建');
|
||||
try {
|
||||
await applyResourcePlan(api, plan);
|
||||
check(false, 'applyResourcePlan 應該要丟 ResourcePlanBlocked,但它沒有');
|
||||
} catch (e) {
|
||||
check(e instanceof ResourcePlanBlocked, 'applyResourcePlan 丟 ResourcePlanBlocked');
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${failed === 0 ? '✅ 全部通過' : `❌ ${failed} 項失敗`}`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user