Files
Arcrun/kbdb/tests/credential-legacy-migration.test.ts
T
uncle6me-web 046ceba29c fix(kbdb): credential 目錄自癒搬遷(D38 收尾)——修 youlin 20/20 全敗事故
根因(2026-08-07 youlin 測試實例):7ba7855 把 credential 讀寫端從舊表
credentials(0002)改走 KBDB entries(entry_type='credential'),但舊表
資料的搬遷 migration(0006)要人手動觸發部署才會跑。實查 youlin 的 D1:
credentials 表有 1 筆(yuga3bse/kbdb_internal_token),entries 對應筆數
為 0——新讀取端上線、舊資料還沒搬,20 次 workflow 全部找不到 credential。

leo 追加硬要求:credential 資料住在用戶自己的 CF 帳號,換讀取路徑=每個
既有實例都要遷移,但用戶不准做任何手動步驟——搬遷必須內建在既有更新流程
裡、天然無感。

解法(kbdb/src/actions/credential-legacy-migration.ts):把「搬」變成
「讀」的副作用而非獨立步驟。KBDB worker(D38 唯一允許碰 SQL 的牆內)在
每次查詢某租戶的 credential 目錄前,先確認舊表資料是否已搬進 entries
——沒有就搬(per-owner scoped、NOT EXISTS 冪等),有就是零成本的
sqlite_master 短路檢查。呼叫時機掛在 GET /entries?entry_type=credential
(cypher-executor 熱路徑本來就會打的端點),故只要更新 KBDB worker,
下一次任何人跑 workflow 該租戶就自動搬好,不需要用戶或安裝器多做任何事。
刻意不執行退場(DROP TABLE)——多個實例搬遷時間點不同,舊表留著才能讓
「已搬」與「還沒搬」的實例同時安全運作;退場留給之後獨立的清理步驟。

kbdb/tests/credential-legacy-migration.test.ts:反向驗證重建 2026-08-07
事故的確切前置狀態(真 SQLite + 0001/0002/0005 migration 原檔),證明補丁
加入前 entries.length 回 0(事故重現),加入後回 1(修好);另驗冪等
(連呼叫三次不重複搬)、多租戶互不干擾、舊表已清理時的終態安全。

cypher-executor/tests/credentials.test.ts:補齊 7ba7855 留下的刻意紅燈
(原 placeholder 五項清單),涵蓋租戶隔離的讀寫、真刪除(非 deprecated
標記)、零原生 SQL 原始碼掃描、密文本體不落 KBDB。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 22:51:31 +08:00

165 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// credential-legacy-migration.test.ts — 「新讀取端上線、舊資料還沒搬完」自癒補丁的迴歸測試
// (D38 圍牆修復收尾,總管交辦,2026-08-08youlin 測試實例 2026-08-07 事故的根因修復)。
//
// 測試策略比照既有 execution-log.test.ts / library-map.test.ts:真 SQLitenode:sqlite
// 套 migration 原檔,比 mock DB 更硬——驗的是真實 SQL 語意,不是「以為 SQL 長這樣」。
// 本檔對 D1 介面的直接呼叫全是測試灌資料/驗證用(與上述兩份既有測試同一慣例),
// 不是牆外業務程式碼繞過 API,逐行標 kbdb-sql-ok。
//
// ── 這份測試在證明什麼(對應 leo 08-08 追加的三個安全性質)─────────────────
// 1. 反向驗證(禁假綠的核心):先重建 2026-08-07 事故的確切狀態——0002 舊表有資料、
// entries 沒有——直接呼叫 cypher-executor 熱路徑會打的同一個端點(GET /entries?
// entry_type=credential&owner_id=X),**在補丁加入之前這裡本該回空陣列**(就是
// 事故當天「缺少 credential: kbdb_internal_token」的成因)。本檔驗證補丁讓它改回
// 找得到,等於把事故重現一次、再證明修好。
// 2. 冪等:同一個 owner 呼叫兩次、三次,entries 筆數不重複增加。
// 3. 對「已搬過」與「還沒搬」的實例都正確:不同 owner 各自獨立、互不干擾;已無舊表
// (模擬清理步驟做完之後)時查詢仍正常運作、不報錯。
import { describe, it, expect } from 'vitest';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { Hono } from 'hono';
import { entryRoutes } from '../src/routes/entries';
import { migrateLegacyCredentialsForOwner } from '../src/actions/credential-legacy-migration';
import type { Bindings } from '../src/types';
// ── node:sqlite → D1 介面最小 adapter(同 execution-log.test.ts / library-map.test.ts 手法)──
function makeSqliteD1(): D1Database {
const raw = new DatabaseSync(':memory:');
raw.exec(readFileSync(new URL('../migrations/0001_base.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔,比照 execution-log.test.ts
raw.exec(readFileSync(new URL('../migrations/0002_credentials.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
raw.exec(readFileSync(new URL('../migrations/0005_credential_template.sql', import.meta.url), 'utf8')); // kbdb-sql-ok: 測試 adapter 套 migration 原檔
function stmt(sql: string, params: unknown[]) {
const s = {
bind(...args: unknown[]) { return stmt(sql, args); },
async all<T>() { return { results: raw.prepare(sql).all(...params) as T[] }; }, // kbdb-sql-ok: 測試 adapter,比照 execution-log.test.ts
async first<T>() { return (raw.prepare(sql).get(...params) ?? null) as T | null; }, // kbdb-sql-ok: 測試 adapter
async run() { raw.prepare(sql).run(...params); return { success: true }; }, // kbdb-sql-ok: 測試 adapter
};
return s;
}
return { prepare: (sql: string) => stmt(sql, []) } as unknown as D1Database; // kbdb-sql-ok: 測試 adapter 的 D1 介面實作本身
}
function envWith(db: D1Database): Bindings {
return { DB: db, ENVIRONMENT: 'test' } as unknown as Bindings;
}
function app(db: D1Database) {
const a = new Hono<{ Bindings: Bindings }>();
a.route('/entries', entryRoutes);
return { fetch: (path: string, init?: RequestInit) => a.request(path, init, envWith(db)) };
}
describe('credential-legacy-migration — 反向驗證:重現 2026-08-07 youlin 事故並證明修好', () => {
it('事故前置狀態(舊表有資料、entries 沒有)下,GET /entries 一樣能讀到 credential(自癒生效)', async () => {
const db = makeSqliteD1();
// 重建事故現場:舊表寫一筆 kbdb_internal_tokenentries 完全沒有對應列
// (新 code 部署了、migration 沒跑——2026-08-07 youlin 的確切狀態)。
await db
.prepare( // kbdb-sql-ok: 測試重建舊表資料現場,比照 execution-log.test.ts
`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, NULL)`,
)
.bind('yuga3bse', 'kbdb_internal_token', 'kbdb', 'high', 'CRED_KBDB_INTERNAL_TOKEN_DEADBEEF', Math.floor(Date.now() / 1000))
.run();
// 事故當天的確切呼叫形狀:cypher-executor credentials.ts 的 findCredentialEntry /
// getCredentialDirectory 都是打這個端點。
const a = app(db);
const res = await a.fetch('/entries?owner_id=yuga3bse&entry_type=credential&page_name=kbdb_internal_token&limit=1');
const body = (await res.json()) as { success: boolean; entries: Array<{ page_name: string; metadata_json: string }> };
expect(body.success).toBe(true);
expect(body.entries.length).toBe(1); // 補丁加入前這裡是 0——2026-08-07 事故的確切失敗形狀
expect(body.entries[0].page_name).toBe('kbdb_internal_token');
const meta = JSON.parse(body.entries[0].metadata_json) as { secret_ref: string; service: string };
expect(meta.secret_ref).toBe('CRED_KBDB_INTERNAL_TOKEN_DEADBEEF');
expect(meta.service).toBe('kbdb');
});
it('搬移後 KBDB 核心三表結構不變,舊表刻意保留(本檔不清舊表,交由之後的清理步驟)', async () => {
const db = makeSqliteD1();
await db
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
.bind('t1', 'x', null, 'standard', 'CRED_X_AAAA', 1)
.run();
await migrateLegacyCredentialsForOwner(db, 't1');
const tables = await db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`) // kbdb-sql-ok: 測試查詢
.all<{ name: string }>();
const names = (tables.results ?? []).map((t) => t.name).sort();
// entries/templates/entry_values 三張核心表 + credentials(舊表,尚未清理)——沒有第五張表。
expect(names).toEqual(['credentials', 'entries', 'entry_values', 'templates']);
});
});
describe('credential-legacy-migration — 冪等(同一 owner 呼叫多次不重複搬)', () => {
it('連呼叫三次,entries 裡該租戶的 credential 筆數固定為 1', async () => {
const db = makeSqliteD1();
await db
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
.bind('owner-idem', 'telegram_bot_token', 'telegram', 'standard', 'CRED_TELEGRAM_BOT_TOKEN_BEEF', 1000)
.run();
const n1 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
const n2 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
const n3 = await migrateLegacyCredentialsForOwner(db, 'owner-idem');
expect(n1).toBe(1); // 第一次:真的搬了一筆
expect(n2).toBe(0); // 第二次起:NOT EXISTS 擋下,不重複
expect(n3).toBe(0);
const rows = await db
.prepare(`SELECT COUNT(*) AS n FROM entries WHERE entry_type='credential' AND owner_id=?1`) // kbdb-sql-ok: 測試查詢
.bind('owner-idem')
.first<{ n: number }>();
expect(rows?.n).toBe(1);
});
});
describe('credential-legacy-migration — 多租戶互不干擾,且對「已搬過」與「還沒搬」同時安全', () => {
it('兩個 owner 各自的 credential 不互相污染;沒有資料的 owner 查詢回空、不報錯', async () => {
const db = makeSqliteD1();
await db
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
.bind('tenant-a', 'gemini_api_key', 'gemini', 'high', 'CRED_GEMINI_API_KEY_A1', 1)
.run();
await db
.prepare(`INSERT INTO credentials (api_key, name, service, sensitivity, secret_ref, created_at, last_used_at) VALUES (?, ?, ?, ?, ?, ?, NULL)`) // kbdb-sql-ok: 測試寫入
.bind('tenant-b', 'gemini_api_key', 'gemini', 'high', 'CRED_GEMINI_API_KEY_B2', 1)
.run();
await migrateLegacyCredentialsForOwner(db, 'tenant-a');
// tenant-b 完全沒觸發過搬遷(模擬「還沒走到這個租戶的下一次 workflow 執行」)。
const a = app(db);
const resA = await a.fetch('/entries?owner_id=tenant-a&entry_type=credential&page_name=gemini_api_key&limit=1');
const bodyA = (await resA.json()) as { entries: Array<{ metadata_json: string }> };
expect(JSON.parse(bodyA.entries[0].metadata_json).secret_ref).toBe('CRED_GEMINI_API_KEY_A1');
// tenant-b 第一次讀取才觸發自己的搬遷(GET /entries 路由本身會呼叫,不需要呼叫端先知道)。
const resB = await a.fetch('/entries?owner_id=tenant-b&entry_type=credential&page_name=gemini_api_key&limit=1');
const bodyB = (await resB.json()) as { entries: Array<{ metadata_json: string }> };
expect(JSON.parse(bodyB.entries[0].metadata_json).secret_ref).toBe('CRED_GEMINI_API_KEY_B2');
// 沒有任何資料的第三個 owner:不報錯、乾淨回空。
const resC = await a.fetch('/entries?owner_id=tenant-c&entry_type=credential&limit=200');
const bodyC = (await resC.json()) as { success: boolean; entries: unknown[] };
expect(bodyC.success).toBe(true);
expect(bodyC.entries).toEqual([]);
});
it('舊表已被清理(不存在)時查詢照常運作(模擬所有租戶搬完後的最終清理狀態)', async () => {
const db = makeSqliteD1();
await db.prepare(`DROP TABLE credentials`).run(); // kbdb-sql-ok: 測試模擬「清理步驟已執行」的終態,非牆外存取
const n = await migrateLegacyCredentialsForOwner(db, 'anyone');
expect(n).toBe(0); // 短路,不報錯
const a = app(db);
const res = await a.fetch('/entries?owner_id=anyone&entry_type=credential&limit=200');
const body = (await res.json()) as { success: boolean; entries: unknown[] };
expect(body.success).toBe(true);
expect(body.entries).toEqual([]);
});
});