ceb7638d74
規格:system-dev/docs/3-specs/pending-changes.md「record 要有身分」v7 定稿(leo 2026-08-15 confirm)。 模型一句話(leo):「真身在 pool 的 entry 裡,所有的虛擬表虛擬欄位都是指向這個 entry 的指標。」 - 0007 migration:池上型別化指標欄(src/rel/dst)+一對方向 partial index+啟動常數 (sys_root/sys_belongs/sys_field_of)+templates 鏡射成 sheet/field entry+ 每筆 record 一顆身分 entry(id=原 record_id,引用不失效)+每格一條關係列 (id 由舊儲存格列 id 衍生 ⇒ INSERT OR IGNORE 天然冪等)+拆 entry_values (0006 墊表→搬→拆手法)。純 INSERT、value entries 一列不動(向量索引不失效)。 - record-crud 整份改寫到關係列(#128 指標語意/共用保護/N+1 批次/租戶過濾全數保留, 驗收測試 232→236 綠);library-map 四段縱轉橫 SQL、records triplet-stats 改查關係列。 - entry-crud:機制列隔離(未指定 entry_type 的列表/搜尋不回機制節點);deleteEntry 接手舊 entry_values FK 的不變量(dst 被指著→拒刪)。 - 孤兒偵測重設計(v7 §5 點名):新模型孤兒=指標指向不存在 id 的關係列, LEFT JOIN 斷鏈掃描(承接 2026-06-24 清理事故的 FK 形狀), GET /maintenance/relation-orphans 唯讀巡檢。 - cli deploy.ts:0007 逐句套用+容錯 duplicate column(SQLite 無欄位級 IF NOT EXISTS, 整檔送 /query 會在重跑時假紅)。 - 測試:tree-record-migration.test.ts 驗資料零漏/雙跑冪等/孤兒掃描; 釘死三表的斷言依 confirm 後規格改口(execution-log/credential-legacy 兩處)。 遷移期雙軌(第二刀收):templates 表仍是欄位定義真相源;六種 metadata_json 打包型 與 §7 減法封鎖(拿掉 entry_type/metadata_json 欄)留待第二刀。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
167 lines
10 KiB
TypeScript
167 lines
10 KiB
TypeScript
// credential-legacy-migration.test.ts — 「新讀取端上線、舊資料還沒搬完」自癒補丁的迴歸測試
|
||
// (D38 圍牆修復收尾,總管交辦,2026-08-08;youlin 測試實例 2026-08-07 事故的根因修復)。
|
||
//
|
||
// 測試策略比照既有 execution-log.test.ts / library-map.test.ts:真 SQLite(node: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 原檔
|
||
raw.exec(readFileSync(new URL('../migrations/0007_tree_record_model.sql', import.meta.url), 'utf8')); // kbdb-sql-ok:測試治具套 0007(樹狀 record 模型,v7 定稿)——真 schema 就是遷移後的 schema
|
||
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_token,entries 完全沒有對應列
|
||
// (新 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 已由 0007 拆掉——關係的第二套實作,D92)
|
||
// + credentials(舊表,尚未清理)——沒有第四張表。
|
||
expect(names).toEqual(['credentials', 'entries', '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([]);
|
||
});
|
||
});
|