portal-auth P2(#24 #25):portal_user 模型+認證 API(不 merge,待總管審)
- lib/portal-auth.ts:PBKDF2-SHA256 600k(WebCrypto deriveBits)、pbkdf2-sha256$… 格式、
常數時間比對(UI session 登入,console-auth 同類先例,非 workflow credential 原語)
- lib/portal-seeds.ts + init-seed:portal_user / portal_library template 冪等種入(零新表)
- routes/portal.ts:login/logout/session/me-password/bootstrap(console owner 閘)/
admin users CRUD + reset-password / 庫目錄 CRUD;帳號資料全寫 {tenant}::portal 子 namespace
(D-2)+ email head entry O(1) 查找(§2.3);session KV 只存 record_id、每請求回讀 record
→ 停用即時生效;絕不回租戶字串;登入節流 5 次/15 分(KV TTL)
- [vars] PORTAL_SESSION_TTL(預設 604800)+ types.ts
- 順修 kbdb record-crud updateRecord grow-slot 漏 owner_id(design §2.2)+回歸測試
驗證:kbdb 20/20、cypher 133/134(1 失敗 stash 複驗=pre-existing)、雙包 tsc 0;
本機雙 worker 端到端 33/33(含搜尋隔離實測:租戶 ns 搜 email/雜湊 0 筆、對照組
leo::portal 搜得到;明碼落地抽查 0 筆)。console-auth 既有行為未動;demo 8787/8788 未碰。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
// portal-auth P2 附帶修復回歸測試(design §2.2):
|
||||
// updateRecord 的「grow 新 slot」路徑建 entry 時,必須帶 record 既有 owner_id。
|
||||
// 修前:createEntry(db, { content, entry_type: 'value' }) → owner_id=NULL 孤兒 entry,
|
||||
// owner-scoped 查詢(searchByTemplate JOIN entries.owner_id / searchEntries owner filter)看不到該 slot 值。
|
||||
// 測法沿 library-filter.test.ts 慣例:fake D1 按 SQL 形狀路由回應+捕捉 INSERT 參數
|
||||
// (不解讀 SQL 語意——真語意由本機 miniflare 端到端驗證,PR 附證據)。
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { updateRecord } from '../src/actions/record-crud';
|
||||
|
||||
interface Captured { sql: string; params: unknown[] }
|
||||
|
||||
/**
|
||||
* 可路由 fake D1:
|
||||
* - entry_values JOIN entries 查詢 → 回既有 slot rows(含 owner_id)
|
||||
* - templates 查詢 → 回 template(slots_json 含既有 + 可 grow 的 slot)
|
||||
* - INSERT INTO entries → 捕捉參數(本測試的斷言目標)
|
||||
* - SELECT * FROM entries WHERE id → 回假 entry(createEntry 的 insert 後回讀)
|
||||
* - 其餘(UPDATE / INSERT entry_values / getRecord SELECT)→ 空殼
|
||||
*/
|
||||
function makeRoutedDB(recordOwnerId: string | null, captured: Captured[]) {
|
||||
const prepare = (sql: string) => {
|
||||
const rec: Captured = { sql, params: [] };
|
||||
captured.push(rec);
|
||||
const stmt = {
|
||||
bind(...args: unknown[]) { rec.params = args; return stmt; },
|
||||
async all<T>() {
|
||||
if (sql.includes('FROM entry_values ev JOIN entries e')) {
|
||||
return {
|
||||
results: [
|
||||
{ slot_name: 'email', entry_id: 'e_existing', template_id: 'tpl_pu', owner_id: recordOwnerId },
|
||||
] as unknown as T[],
|
||||
};
|
||||
}
|
||||
if (sql.includes('FROM entry_values ev JOIN entries e ON ev.entry_id = e.id')) {
|
||||
return { results: [] as T[] };
|
||||
}
|
||||
return { results: [] as T[] };
|
||||
},
|
||||
async first<T>() {
|
||||
if (sql.includes('FROM templates')) {
|
||||
return {
|
||||
id: 'tpl_pu', name: 'portal_user', description: null,
|
||||
slots_json: JSON.stringify(['email', 'status']), created_by: 'system',
|
||||
} as unknown as T;
|
||||
}
|
||||
if (sql.startsWith('SELECT * FROM entries WHERE id')) {
|
||||
return {
|
||||
id: 'e_new', content: 'active', entry_type: 'value', owner_id: recordOwnerId,
|
||||
parent_id: null, page_name: null, refs_json: '[]', tags_json: '[]', task_status: null,
|
||||
content_hash: null, is_embedded: 0, confidence: null, metadata_json: null,
|
||||
created_at: 1, updated_at: 1,
|
||||
} as unknown as T;
|
||||
}
|
||||
return null as unknown as T;
|
||||
},
|
||||
async run() { return { success: true }; },
|
||||
};
|
||||
return stmt;
|
||||
};
|
||||
return { prepare } as unknown as D1Database;
|
||||
}
|
||||
|
||||
describe('updateRecord grow-slot 路徑帶 owner_id(design §2.2 回歸)', () => {
|
||||
it('record 有 owner_id → grow 出的新 entry INSERT 帶同一 owner_id(修前為 null)', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const db = makeRoutedDB('leo::portal', captured);
|
||||
// 'status' slot 在 template 裡但 record 尚無 → 走 grow 路徑
|
||||
await updateRecord(db, 'rec_1', { status: 'active' });
|
||||
|
||||
const insertEntry = captured.find((c) => c.sql.includes('INSERT INTO entries'));
|
||||
expect(insertEntry, 'grow 路徑應 INSERT 新 entry').toBeTruthy();
|
||||
// entry-crud.ts createEntry 的 INSERT 欄位序:id, content, entry_type, owner_id, ...
|
||||
expect(insertEntry!.params[1]).toBe('active'); // content
|
||||
expect(insertEntry!.params[2]).toBe('value'); // entry_type
|
||||
expect(insertEntry!.params[3]).toBe('leo::portal'); // owner_id ← 本修復的斷言核心
|
||||
});
|
||||
|
||||
it('record 本就無 owner_id(全域資料)→ grow 出的 entry owner_id 維持 null(行為不變)', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const db = makeRoutedDB(null, captured);
|
||||
await updateRecord(db, 'rec_1', { status: 'active' });
|
||||
|
||||
const insertEntry = captured.find((c) => c.sql.includes('INSERT INTO entries'));
|
||||
expect(insertEntry).toBeTruthy();
|
||||
expect(insertEntry!.params[3]).toBeNull();
|
||||
});
|
||||
|
||||
it('既有 slot 更新(非 grow)→ 走 UPDATE entries,不 INSERT 新 entry', async () => {
|
||||
const captured: Captured[] = [];
|
||||
const db = makeRoutedDB('leo::portal', captured);
|
||||
await updateRecord(db, 'rec_1', { email: 'new@example.com' });
|
||||
|
||||
expect(captured.some((c) => c.sql.includes('UPDATE entries SET content'))).toBe(true);
|
||||
expect(captured.some((c) => c.sql.includes('INSERT INTO entries'))).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user