test(resource-rule): 加一支「在真帳號上驗 #123」的腳本(離線測不出 CF 的拒絕)

離線 fixture 證明的是**判斷**對,證明不了「真的 Cloudflare 會不會照我們
以為的方式回應」——而 #123 的病根正是「我們以為 CF 會讓我們重建,
實際上它拒絕」。那種錯只有真帳號驗得出來。

用法:
  CF_API_TOKEN=<token> CF_ACCOUNT_ID=<id> \
    node shared/resource-rule/tests/verify-against-real-account.mjs

它對帳號做的事:
  · 讀:列 KV/D1/Vectorize、讀 worker 綁定
  · 寫:只建一顆名字帶 -zz123tst- 的一次性 KV,finally 保證刪掉
  · 不碰任何既有資源(測試用的 worker script 名刻意不存在,
    所以規則看到的是「沒有人綁著它」= #123 那條路)
安全開關:DRY_RUN=true 只盤點、KEEP=true 不清理(除錯用)

驗三件:① 接回殘骸且一顆都不新建 ② apply 前後帳號顆數不變
③ 沒聲明 createNameIsOurs 時 fail-closed 且訊息不叫人開 CF 後台

⚠️ 雲端 session 跑不了寫入那半(權限分類器擋 CF 寫入),
   DRY_RUN 已對真 youlin 帳號試跑通過(讀到 8 顆 KV)。
   完整那半要在有寫入權限的地方跑。

Refs: inkstone/Arcrun#123
This commit is contained in:
claude-code
2026-08-14 09:57:43 +00:00
parent 2b807da324
commit 43323b1a78
@@ -0,0 +1,116 @@
// @ts-check
/**
* verify-against-real-account.mjs — 在**真的 Cloudflare 帳號**上驗 Arcrun#123。
*
* CF_API_TOKEN=<token> CF_ACCOUNT_ID=<id> node shared/resource-rule/tests/verify-against-real-account.mjs
*
* 【為什麼需要這一支】
* `half-finished-install.mjs` 是離線的(fetch 替身)——它證明**判斷**對,
* 但證明不了「真的 Cloudflare 會不會照我們以為的方式回應」。#123 的整個病根
* 就是**我們以為 CF 會讓我們重建,實際上它拒絕**。那種錯,只有真帳號驗得出來。
*
* 【它會對你的帳號做什麼】
* · 讀:列 KVD1Vectorize、讀幾顆 worker 的綁定
* · 寫:**只建一顆**名字帶 `-zz123tst-` 的一次性 KV,用完**一定刪掉**(finally 保證)
* · 🔴 **不碰你任何既有資源**:不部署 worker、不改綁定、不刪別的東西
* (測試用的 worker script 名是刻意不存在的,所以規則看到的是「沒有人綁著它」)
*
* 安全開關:
* DRY_RUN=true 只盤點與說明會做什麼,不建也不刪
* KEEP=true 測完不刪那顆測試 KV(除錯用;正常不要開)
*/
import { planResources, applyResourcePlan } from '../rule.mjs';
import { createCloudflareResourceApi } from '../cf-resource-api.mjs';
const TOKEN = process.env.CF_API_TOKEN || process.env.CLOUDFLARE_API_TOKEN;
const ACCOUNT = process.env.CF_ACCOUNT_ID;
const DRY = process.env.DRY_RUN === 'true';
const KEEP = process.env.KEEP === 'true';
if (!TOKEN || !ACCOUNT) {
console.error('缺 CF_API_TOKEN 或 CF_ACCOUNT_ID。');
console.error(' CF_API_TOKEN=<token> CF_ACCOUNT_ID=<id> node shared/resource-rule/tests/verify-against-real-account.mjs');
process.exit(1);
}
/** 一次性測試用的實例短碼——刻意帶 zz 前綴,不可能跟真的實例撞。 */
const BASE = 'arcrun-rag-zz123tst';
const TEST_KV_TITLE = `${BASE}-kv-oauth_kv`;
/** 刻意用**不存在**的 worker 名:規則會讀到 404 ⇒「沒有人綁著它」⇒ 走 #123 那條路。 */
const GHOST_WORKER = 'arcrun-mcp-zz123tst-does-not-exist';
const api = createCloudflareResourceApi({ accountId: ACCOUNT, apiToken: TOKEN });
let failed = 0;
const check = (ok, what) => { console.log(` ${ok ? '✅' : '❌'} ${what}`); if (!ok) failed++; };
console.log('Arcrun#123 真帳號驗證');
console.log(`帳號:${ACCOUNT}`);
console.log(`會建一顆:${TEST_KV_TITLE}(測完刪除)${DRY ? ' ← DRY_RUN,不會真的建' : ''}\n`);
// ── 0. 先盤點,讓人看得到「我沒動你的東西」 ────────────────────────────
const before = await api.listKvNamespaces();
console.log(`帳號上現有 KV${before.size}`);
if (before.has(TEST_KV_TITLE)) {
console.log(`⚠️ 帳號上已經有 ${TEST_KV_TITLE}(上次沒清乾淨?)——直接沿用它做這次測試。`);
}
if (DRY) {
console.log('\nDRY_RUN:到此為止,什麼都沒建也沒刪。');
process.exit(0);
}
/** @type {string | undefined} */
let testKvId = before.get(TEST_KV_TITLE);
const weCreatedIt = testKvId === undefined;
try {
// ── 1. 製造「上次裝到一半死掉」:資源在、worker 不在 ──────────────────
if (weCreatedIt) {
testKvId = await api.createKvNamespace(TEST_KV_TITLE);
console.log(`\n① 已建立測試殘骸 ${TEST_KV_TITLE}${testKvId}`);
} else {
console.log(`\n① 沿用既有的 ${TEST_KV_TITLE}${testKvId}`);
}
const reqs = (claim) => [{
kind: /** @type {const} */ ('kv_namespace'),
binding: 'OAUTH_KV',
worker: GHOST_WORKER,
createName: TEST_KV_TITLE,
...(claim ? { createNameIsOurs: true } : {}),
}];
// ── 2. 修好之後:應該接回那一顆,一顆都不建 ──────────────────────────
console.log('\n② 修復後(安裝器聲明 createNameIsOurs');
const plan = await planResources(api, reqs(true), 'init');
check(plan.blockers.length === 0, `不該有 blocker(實得 ${plan.blockers.length}`);
plan.blockers.forEach((b) => console.log(' ·', b.slice(0, 150)));
check(plan.create.length === 0, `不該有東西要新建(實得 ${plan.create.length}`);
check(plan.adopt.length === 1 && plan.adopt[0].reclaimed === true, '應該標記為「接回上次留下的」');
const kvCountBeforeApply = (await api.listKvNamespaces()).size;
const resolved = await applyResourcePlan(api, plan);
const kvCountAfterApply = (await api.listKvNamespaces()).size;
check(kvCountAfterApply === kvCountBeforeApply, `apply 之後帳號上顆數不變(${kvCountBeforeApply}${kvCountAfterApply}`);
check(resolved.get('kv_namespace:OAUTH_KV')?.value === testKvId, '綁到的是那顆殘骸本尊,不是新建的空殼');
// ── 3. 沒聲明來歷:必須 fail-closed(#97 的反向災情) ─────────────────
console.log('\n③ 沒聲明 createNameIsOurs(不能證明是我們的)');
const blocked = await planResources(api, reqs(false), 'init');
check(blocked.blockers.length > 0, '必須停手,不准接管');
check(/RES-NAME-TAKEN/.test(blocked.blockers.join('\n')), '訊息要帶可回報的錯誤碼');
check(!/後台|dashboard/.test(blocked.blockers.join('\n')), '訊息不准叫使用者自己去 CF 後台(#121/D88');
} finally {
// ── 4. 清乾淨(不管上面成功失敗都要跑) ──────────────────────────────
if (testKvId && weCreatedIt && !KEEP) {
const res = await api.cfRaw(`/storage/kv/namespaces/${testKvId}`, { method: 'DELETE' });
console.log(`\n④ 清理:刪除 ${TEST_KV_TITLE}${res.ok ? '✅ 已刪除' : '⚠️ 刪除失敗,請手動刪:' + res.error}`);
} else if (KEEP) {
console.log(`\n④ KEEP=true,保留 ${TEST_KV_TITLE}(記得自己刪)`);
}
}
console.log(`\n${failed === 0 ? '✅ 真帳號驗證全部通過' : `${failed} 項失敗`}`);
process.exit(failed === 0 ? 0 : 1);