@@ -0,0 +1,357 @@
/**
* portal-auth P3 測試(design §1/§3.3/§3.4/§5/§6, Gitea #24/#25)
*
* 覆蓋(=tasks.md P3 測試項+#24 驗收 3 的 server-side 證明):
* 1. /portal HTML 殼:200、brand、**零租戶字串/零 X-Arcrun-API-Key/零 Mira 字樣**
* 2. /portal/data/search enforce: server 注入 owner_id+ library; caller 自帶
* owner_id/library 參數被靜默覆蓋(filter 繞不過的機械證明);["*"]=不注 library;
* 空集合=誠實空結果不打 KBDB
* 3. /portal/data/entries/:id 逐筆驗庫:越庫 404、跨租戶 404、不存在 404(同一句,
* 不洩存在性)、NULL library→general fallback
* 4. graph D-4 粗閘:無來源庫權限 403(不打 plugin);["*"]/有權 → 轉發
* 5. workflows D-8:非 admin 403; admin 唯讀 list+最近執行、回應無 webhook_url;
* workflowsVisible 單元(admin/all/off/壞值)
* 6. /portal/session 能力欄位:graph_allowed / workflows_visible
*
* KBDB/ graph-plugin 都打 fetchMock 假 host( wrangler.test.toml KBDB_BASE_URL=
* https://kbdb.test、KBDB_GRAPH_URL=https://graph.test)+disableNetConnect——絕不外連。
*/
import { SELF , env , fetchMock } from 'cloudflare:test' ;
import { beforeAll , afterEach , describe , it , expect } from 'vitest' ;
import { workflowsVisible } from '../src/routes/portal' ;
import { entryLibrary } from '../src/routes/portal-data' ;
import type { Bindings } from '../src/types' ;
const KBDB = 'https://kbdb.test' ;
const GRAPH = 'https://graph.test' ;
const TENANT = 'leo' ; // wrangler.test.toml CONSOLE_TENANT(只在 server 側;下面驗它不出現在前端)
beforeAll ( ( ) = > {
fetchMock . activate ( ) ;
fetchMock . disableNetConnect ( ) ;
} ) ;
afterEach ( ( ) = > fetchMock . assertNoPendingInterceptors ( ) ) ;
function get ( path : string , headers : Record < string , string > = { } ) {
return SELF . fetch ( ` http://localhost ${ path } ` , { headers } ) ;
}
async function seedSession ( token : string , recordId : string ) {
await env . SESSIONS_KV . put ( ` portal_sess: ${ token } ` , JSON . stringify ( { record_id : recordId } ) ) ;
}
function mockGetRecord ( recordId : string , values : Record < string , string > ) {
fetchMock
. get ( KBDB )
. intercept ( { path : ` /records/ ${ recordId } ` , method : 'GET' } )
. reply ( 200 , { success : true , record : { record_id : recordId , template_id : 'tpl_pu' , values } } ) ;
}
function mockLibraryList ( records : { record_id : string ; values : Record < string , string > } [ ] ) {
fetchMock
. get ( KBDB )
. intercept ( { path : ( p : string ) = > p . startsWith ( '/records/by-template/portal_library' ) , method : 'GET' } )
. reply ( 200 , { success : true , records : records.map ( ( r ) = > ( { . . . r , template_id : 'tpl_pl' } ) ) , count : records.length } ) ;
}
function userValues ( overrides : Record < string , string > = { } ) : Record < string , string > {
return {
email : 'user@example.com' ,
display_name : '測試同仁' ,
status : 'active' ,
role : 'user' ,
password_hash : 'pbkdf2-sha256$600000$AA$BB' ,
libraries : '["finance"]' ,
created_at : '2026-07-14T00:00:00.000Z' ,
updated_at : '2026-07-14T00:00:00.000Z' ,
. . . overrides ,
} ;
}
/** 攔 KBDB /entries/search 並回收實際轉發的 query( enforce 的機械證據)。 */
function captureSearch ( reply : unknown = { success : true , entries : [ ] , count : 0 , mode : 'keyword' } ) : { url : ( ) = > string } {
let captured = '' ;
fetchMock
. get ( KBDB )
. intercept ( {
path : ( p : string ) = > {
if ( ! p . startsWith ( '/entries/search?' ) ) return false ;
captured = p ;
return true ;
} ,
method : 'GET' ,
} )
. reply ( 200 , reply as Record < string , unknown > ) ;
return { url : ( ) = > captured } ;
}
// ═══════════════ 1. /portal HTML 殼 ═══════════════
describe ( 'GET /portal( HTML 殼)' , ( ) = > {
it ( '200; brand 出現;**前端零租戶字串、零 X-Arcrun-API-Key、零 Mira**' , async ( ) = > {
const res = await get ( '/portal' ) ;
expect ( res . status ) . toBe ( 200 ) ;
const html = await res . text ( ) ;
expect ( html ) . toContain ( 'Arcrun Portal' ) ; // CONSOLE_BRAND 未設 → Arcrun(引擎共用件不寫死產品名)
expect ( html ) . toContain ( '/portal/data/search' ) ; // 資料只走 enforce 面
// design §3.3 關鍵差異的機械斷言:前端不持租戶字串、不打 /kbdb/*
expect ( html ) . not . toContain ( 'X-Arcrun-API-Key' ) ;
expect ( html ) . not . toMatch ( /['"]leo['"]/ ) ; // 租戶字串值不得出現在頁面
expect ( html ) . not . toContain ( '/kbdb/' ) ; // 不直打 kbdb proxy(那要 API key=租戶字串)
expect ( html ) . not . toContain ( 'Mira' ) ; // 零 Mira 字樣(tasks.md P3)
expect ( html ) . not . toContain ( 'CONSOLE_TENANT' ) ;
} ) ;
} ) ;
// ═══════════════ 2. /portal/data/search enforce ═══════════════
describe ( 'GET /portal/data/search' , ( ) = > {
it ( '未登入 → 401,不碰 KBDB' , async ( ) = > {
const res = await get ( '/portal/data/search?q=hello' ) ;
expect ( res . status ) . toBe ( 401 ) ;
} ) ;
it ( 'server 注入 owner_id+ library; caller 自帶 owner_id/library 被靜默覆蓋(繞不過)' , async ( ) = > {
await seedSession ( 'tok-s1' , 'rec_1' ) ;
mockGetRecord ( 'rec_1' , userValues ( ) ) ; // libraries=["finance"]
const cap = captureSearch ( ) ;
// 攻擊嘗試:自帶 library=hr + owner_id=evil → 應完全被 server 值取代
const res = await get ( '/portal/data/search?q=報告&library=hr&owner_id=evil' , {
Authorization : 'Bearer tok-s1' ,
} ) ;
expect ( res . status ) . toBe ( 200 ) ;
const sent = new URLSearchParams ( cap . url ( ) . split ( '?' ) [ 1 ] ) ;
expect ( sent . get ( 'owner_id' ) ) . toBe ( TENANT ) ; // server 注入的租戶
expect ( sent . get ( 'library' ) ) . toBe ( 'finance' ) ; // server 注入的用戶庫集合
expect ( cap . url ( ) ) . not . toContain ( 'hr' ) ; // caller 的越權參數完全沒被轉發
expect ( cap . url ( ) ) . not . toContain ( 'evil' ) ;
} ) ;
it ( '多庫用戶 → library=逗號集合;mode=semantic 透傳' , async ( ) = > {
await seedSession ( 'tok-s2' , 'rec_2' ) ;
mockGetRecord ( 'rec_2' , userValues ( { libraries : '["general","finance"]' } ) ) ;
const cap = captureSearch ( { success : true , entries : [ ] , count : 0 , mode : 'semantic' } ) ;
const res = await get ( '/portal/data/search?q=q1&mode=semantic' , { Authorization : 'Bearer tok-s2' } ) ;
expect ( res . status ) . toBe ( 200 ) ;
const sent = new URLSearchParams ( cap . url ( ) . split ( '?' ) [ 1 ] ) ;
expect ( sent . get ( 'library' ) ) . toBe ( 'general,finance' ) ;
expect ( sent . get ( 'mode' ) ) . toBe ( 'semantic' ) ;
} ) ;
it ( '["*"](全庫)→ 只注 owner_id、不注 library( design §3.3) ' , async ( ) = > {
await seedSession ( 'tok-s3' , 'rec_3' ) ;
mockGetRecord ( 'rec_3' , userValues ( { libraries : '["*"]' , role : 'admin' } ) ) ;
const cap = captureSearch ( ) ;
const res = await get ( '/portal/data/search?q=q2' , { Authorization : 'Bearer tok-s3' } ) ;
expect ( res . status ) . toBe ( 200 ) ;
const sent = new URLSearchParams ( cap . url ( ) . split ( '?' ) [ 1 ] ) ;
expect ( sent . get ( 'owner_id' ) ) . toBe ( TENANT ) ;
expect ( sent . has ( 'library' ) ) . toBe ( false ) ;
} ) ;
it ( '庫集合為空 → 誠實空結果,不打 KBDB search' , async ( ) = > {
await seedSession ( 'tok-s4' , 'rec_4' ) ;
mockGetRecord ( 'rec_4' , userValues ( { libraries : '[]' } ) ) ;
const res = await get ( '/portal/data/search?q=q3' , { Authorization : 'Bearer tok-s4' } ) ;
expect ( res . status ) . toBe ( 200 ) ;
const data = ( await res . json ( ) ) as { entries : unknown [ ] ; note? : string } ;
expect ( data . entries ) . toEqual ( [ ] ) ;
expect ( data . note ) . toContain ( '尚未被授權' ) ; // 無 pending interceptor=真沒打 KBDB
} ) ;
} ) ;
// ═══════════════ 3. /portal/data/entries/:id 逐筆驗庫 ═══════════════
function mockGetEntry ( id : string , entry : Record < string , unknown > | null ) {
fetchMock
. get ( KBDB )
. intercept ( { path : ` /entries/ ${ id } ` , method : 'GET' } )
. reply ( entry ? 200 : 404 , entry ? { success : true , entry } : { success : false , error : 'not found' } ) ;
}
describe ( 'GET /portal/data/entries/:id(逐筆驗庫)' , ( ) = > {
it ( '越庫 id 直讀(hr entry、用戶只有 finance)→ 404' , async ( ) = > {
await seedSession ( 'tok-e1' , 'rec_1' ) ;
mockGetRecord ( 'rec_1' , userValues ( ) ) ;
mockGetEntry ( 'e_hr' , { id : 'e_hr' , owner_id : TENANT , metadata_json : '{"library":"hr"}' , content : '機密' } ) ;
const res = await get ( '/portal/data/entries/e_hr' , { Authorization : 'Bearer tok-e1' } ) ;
expect ( res . status ) . toBe ( 404 ) ;
const data = ( await res . json ( ) ) as { error : string } ;
expect ( data . error ) . toBe ( '找不到這筆資料' ) ; // 與不存在同一句(不洩存在性)
expect ( JSON . stringify ( data ) ) . not . toContain ( 'hr' ) ; // 不洩庫名
} ) ;
it ( '有權庫(finance)→ 200 回 entry' , async ( ) = > {
await seedSession ( 'tok-e2' , 'rec_1' ) ;
mockGetRecord ( 'rec_1' , userValues ( ) ) ;
mockGetEntry ( 'e_fin' , { id : 'e_fin' , owner_id : TENANT , metadata_json : '{"library":"finance","source":"logseq://x.md"}' , content : '財務' } ) ;
const res = await get ( '/portal/data/entries/e_fin' , { Authorization : 'Bearer tok-e2' } ) ;
expect ( res . status ) . toBe ( 200 ) ;
const data = ( await res . json ( ) ) as { entry : { id : string } } ;
expect ( data . entry . id ) . toBe ( 'e_fin' ) ;
} ) ;
it ( '跨租戶 entry( owner_id 不是本實例租戶)→ 404 同一句' , async ( ) = > {
await seedSession ( 'tok-e3' , 'rec_1' ) ;
mockGetRecord ( 'rec_1' , userValues ( { libraries : '["*"]' } ) ) ; // 就算全庫也擋跨租戶
mockGetEntry ( 'e_other' , { id : 'e_other' , owner_id : 'other-tenant' , metadata_json : '{"library":"finance"}' } ) ;
const res = await get ( '/portal/data/entries/e_other' , { Authorization : 'Bearer tok-e3' } ) ;
expect ( res . status ) . toBe ( 404 ) ;
expect ( ( ( await res . json ( ) ) as { error : string } ) . error ) . toBe ( '找不到這筆資料' ) ;
} ) ;
it ( '不存在的 id → 404 同一句' , async ( ) = > {
await seedSession ( 'tok-e4' , 'rec_1' ) ;
mockGetRecord ( 'rec_1' , userValues ( ) ) ;
mockGetEntry ( 'e_ghost' , null ) ;
const res = await get ( '/portal/data/entries/e_ghost' , { Authorization : 'Bearer tok-e4' } ) ;
expect ( res . status ) . toBe ( 404 ) ;
expect ( ( ( await res . json ( ) ) as { error : string } ) . error ) . toBe ( '找不到這筆資料' ) ;
} ) ;
it ( '未標記 library( NULL metadata)→ 歸 general:有 general 者 200、無者 404' , async ( ) = > {
await seedSession ( 'tok-e5' , 'rec_5' ) ;
mockGetRecord ( 'rec_5' , userValues ( { libraries : '["general"]' } ) ) ;
mockGetEntry ( 'e_old' , { id : 'e_old' , owner_id : TENANT , metadata_json : null , content : '舊資料' } ) ;
const ok = await get ( '/portal/data/entries/e_old' , { Authorization : 'Bearer tok-e5' } ) ;
expect ( ok . status ) . toBe ( 200 ) ;
await seedSession ( 'tok-e6' , 'rec_6' ) ;
mockGetRecord ( 'rec_6' , userValues ( { libraries : '["finance"]' } ) ) ; // 沒 general
mockGetEntry ( 'e_old' , { id : 'e_old' , owner_id : TENANT , metadata_json : null , content : '舊資料' } ) ;
const no = await get ( '/portal/data/entries/e_old' , { Authorization : 'Bearer tok-e6' } ) ;
expect ( no . status ) . toBe ( 404 ) ;
} ) ;
it ( 'entryLibrary 單元:壞 metadata/缺欄位 → general;有 library → 原值' , ( ) = > {
expect ( entryLibrary ( { metadata_json : null } ) ) . toBe ( 'general' ) ;
expect ( entryLibrary ( { metadata_json : 'not-json{{' } ) ) . toBe ( 'general' ) ;
expect ( entryLibrary ( { metadata_json : '{"source":"x"}' } ) ) . toBe ( 'general' ) ;
expect ( entryLibrary ( { metadata_json : '{"library":""}' } ) ) . toBe ( 'general' ) ;
expect ( entryLibrary ( { metadata_json : '{"library":"hr"}' } ) ) . toBe ( 'hr' ) ;
} ) ;
} ) ;
// ═══════════════ 4. graph D-4 粗閘 ═══════════════
describe ( 'GET /portal/data/graph/neighbors/:name( D-4 粗閘)' , ( ) = > {
it ( '無 graph 來源庫權限(來源庫預設 general、用戶只有 finance)→ 403,不打 plugin' , async ( ) = > {
await seedSession ( 'tok-g1' , 'rec_1' ) ;
mockGetRecord ( 'rec_1' , userValues ( ) ) ; // finance only
mockLibraryList ( [ ] ) ; // 沒有任何庫標 graph_source → 來源預設 ['general']
const res = await get ( '/portal/data/graph/neighbors/某節點' , { Authorization : 'Bearer tok-g1' } ) ;
expect ( res . status ) . toBe ( 403 ) ;
// 無 pending interceptor( afterEach 驗)=graph plugin 完全沒被打
} ) ;
it ( '["*"] 全庫 → 放行並轉發 plugin(不需查庫目錄)' , async ( ) = > {
await seedSession ( 'tok-g2' , 'rec_2' ) ;
mockGetRecord ( 'rec_2' , userValues ( { libraries : '["*"]' , role : 'admin' } ) ) ;
fetchMock
. get ( GRAPH )
. intercept ( { path : ( p : string ) = > p . startsWith ( '/graph/neighbors/' ) , method : 'GET' } )
. reply ( 200 , { node : 'n' , edges : [ ] , neighbors : [ ] , edgeCount : 0 , neighborCount : 0 } ) ;
const res = await get ( '/portal/data/graph/neighbors/n' , { Authorization : 'Bearer tok-g2' } ) ;
expect ( res . status ) . toBe ( 200 ) ;
} ) ;
it ( '庫目錄標 finance 為 graph_source → finance 用戶放行' , async ( ) = > {
await seedSession ( 'tok-g3' , 'rec_1' ) ;
mockGetRecord ( 'rec_1' , userValues ( ) ) ; // finance
mockLibraryList ( [
{ record_id : 'lib_fin' , values : { name : 'finance' , status : 'active' , graph_source : 'true' } } ,
] ) ;
fetchMock
. get ( GRAPH )
. intercept ( { path : ( p : string ) = > p . startsWith ( '/graph/neighbors/' ) , method : 'GET' } )
. reply ( 200 , { node : 'n' , edges : [ ] , neighbors : [ ] } ) ;
const res = await get ( '/portal/data/graph/neighbors/n' , { Authorization : 'Bearer tok-g3' } ) ;
expect ( res . status ) . toBe ( 200 ) ;
} ) ;
it ( '停用的 graph_source 庫不算來源(disabled 排除 → 回到預設 general → finance 用戶 403) ' , async ( ) = > {
await seedSession ( 'tok-g4' , 'rec_1' ) ;
mockGetRecord ( 'rec_1' , userValues ( ) ) ;
mockLibraryList ( [
{ record_id : 'lib_fin' , values : { name : 'finance' , status : 'disabled' , graph_source : 'true' } } ,
] ) ;
const res = await get ( '/portal/data/graph/neighbors/n' , { Authorization : 'Bearer tok-g4' } ) ;
expect ( res . status ) . toBe ( 403 ) ;
} ) ;
} ) ;
// ═══════════════ 5. workflows D-8 ═══════════════
describe ( 'GET /portal/data/workflows( D-8: admin 唯讀)' , ( ) = > {
it ( '非 admin(預設 PORTAL_SHOW_WORKFLOWS=admin)→ 403' , async ( ) = > {
await seedSession ( 'tok-w1' , 'rec_1' ) ;
mockGetRecord ( 'rec_1' , userValues ( { role : 'user' } ) ) ;
const res = await get ( '/portal/data/workflows' , { Authorization : 'Bearer tok-w1' } ) ;
expect ( res . status ) . toBe ( 403 ) ;
} ) ;
it ( 'admin → 200 唯讀 list+最近執行;**回應無 webhook_url/ trigger 把手**' , async ( ) = > {
await seedSession ( 'tok-w2' , 'rec_a' ) ;
mockGetRecord ( 'rec_a' , userValues ( { role : 'admin' , libraries : '["*"]' } ) ) ;
await env . WEBHOOKS . put (
` ${ TENANT } :wf:daily_report ` ,
JSON . stringify ( { description : '每日彙整' , created_at : '2026-07-14T00:00:00Z' , cron_expr : '0 9 * * *' } ) ,
) ;
await env . ANALYTICS_KV . put ( 'stats:daily_report:1783500000000' , JSON . stringify ( { verdict : 'success' } ) ) ;
await env . ANALYTICS_KV . put ( 'stats:daily_report:1783400000000' , JSON . stringify ( { verdict : 'failed' } ) ) ;
const res = await get ( '/portal/data/workflows' , { Authorization : 'Bearer tok-w2' } ) ;
expect ( res . status ) . toBe ( 200 ) ;
const data = ( await res . json ( ) ) as {
workflows : { name : string ; description : string ; last_execution : { verdict? : string ; timestamp : string } | null } [ ] ;
read_only : boolean ;
} ;
expect ( data . read_only ) . toBe ( true ) ;
const wf = data . workflows . find ( ( w ) = > w . name === 'daily_report' ) ;
expect ( wf ) . toBeTruthy ( ) ;
expect ( wf ! . description ) . toBe ( '每日彙整' ) ;
expect ( wf ! . last_execution ? . verdict ) . toBe ( 'success' ) ; // 取到「最新」那筆(timestamp 較大者)
expect ( JSON . stringify ( data ) ) . not . toContain ( 'webhook_url' ) ;
expect ( JSON . stringify ( data ) ) . not . toContain ( '/trigger' ) ;
// 清場(KV 是 suite 共用實例,避免污染其他測試)
await env . WEBHOOKS . delete ( ` ${ TENANT } :wf:daily_report ` ) ;
await env . ANALYTICS_KV . delete ( 'stats:daily_report:1783500000000' ) ;
await env . ANALYTICS_KV . delete ( 'stats:daily_report:1783400000000' ) ;
} ) ;
it ( 'workflowsVisible 單元:admin(預設/壞值)/ all / off' , ( ) = > {
const mk = ( v? : string ) = > ( { PORTAL_SHOW_WORKFLOWS : v } ) as unknown as Bindings ;
expect ( workflowsVisible ( mk ( undefined ) , 'admin' ) ) . toBe ( true ) ;
expect ( workflowsVisible ( mk ( undefined ) , 'user' ) ) . toBe ( false ) ;
expect ( workflowsVisible ( mk ( 'all' ) , 'user' ) ) . toBe ( true ) ;
expect ( workflowsVisible ( mk ( 'off' ) , 'admin' ) ) . toBe ( false ) ;
expect ( workflowsVisible ( mk ( 'typo!!' ) , 'user' ) ) . toBe ( false ) ; // 壞值退回 admin-only,不意外全開
expect ( workflowsVisible ( mk ( 'typo!!' ) , 'admin' ) ) . toBe ( true ) ;
} ) ;
} ) ;
// ═══════════════ 6. /portal/session 能力欄位 ═══════════════
describe ( 'GET /portal/session( P3 能力欄位)' , ( ) = > {
it ( '一般 user( finance,無 graph 來源權限)→ graph_allowed=false、workflows_visible=false;仍無租戶字串' , async ( ) = > {
await seedSession ( 'tok-p1' , 'rec_1' ) ;
mockGetRecord ( 'rec_1' , userValues ( ) ) ;
mockLibraryList ( [ ] ) ;
const res = await get ( '/portal/session' , { Authorization : 'Bearer tok-p1' } ) ;
expect ( res . status ) . toBe ( 200 ) ;
const data = ( await res . json ( ) ) as Record < string , unknown > ;
expect ( data . graph_allowed ) . toBe ( false ) ;
expect ( data . workflows_visible ) . toBe ( false ) ;
expect ( 'tenant' in data ) . toBe ( false ) ;
expect ( JSON . stringify ( data ) ) . not . toContain ( '"leo"' ) ;
} ) ;
it ( 'admin ["*"] → graph_allowed=true(免查庫目錄)、workflows_visible=true' , async ( ) = > {
await seedSession ( 'tok-p2' , 'rec_a' ) ;
mockGetRecord ( 'rec_a' , userValues ( { role : 'admin' , libraries : '["*"]' } ) ) ;
const res = await get ( '/portal/session' , { Authorization : 'Bearer tok-p2' } ) ;
expect ( res . status ) . toBe ( 200 ) ;
const data = ( await res . json ( ) ) as Record < string , unknown > ;
expect ( data . graph_allowed ) . toBe ( true ) ;
expect ( data . workflows_visible ) . toBe ( true ) ;
} ) ;
} ) ;