@@ -0,0 +1,617 @@
// IAM U4 阶段 7 确定性浏览器切片(DR6– DR10):真实 React/Picker 交互 +
// 完全受控的 HTTP 应答,不依赖任何账号或数据库。活体隔离产品层由
// Agent-System-RDM scripts/iam-u4-role-acceptance.sh 以真实 Backend/RDM
// 授权路径复用同一场景(live 模式不拦截路由)。
import { test , expect , type Page , type Route } from '@playwright/test'
test . skip ( ! process . env . PLAYWRIGHT_BASE_URL , 'set PLAYWRIGHT_BASE_URL to an isolated frontend server' )
function deferred() {
let resolve ! : ( ) = > void
const promise = new Promise < void > ( ok = > { resolve = ok } )
return { promise , resolve }
}
interface Me { owner : string ; name : string ; auth : boolean ; isAdmin? : boolean ; systemActions? : string [ ] }
interface Perm { actionId : string ; label : string ; description : string ; group : string ; scope : 'system' | 'project' ; protected : boolean }
interface RolePerm { actionId : string ; allowed : boolean ; capAllowed : boolean ; permissionVersion : number }
interface Role { roleId : string ; name : string ; description : string ; enabled : boolean ; roleVersion : number ; bindingCount : number ; permissions : RolePerm [ ] }
interface Account {
userId : string ; owner : string ; profile : { name : string ; phone : string ; email : string ; staffNo : string ; department : string ; notes : string }
profileVersion : number ; enabled : boolean ; stateVersion : number ; isAdmin : boolean ; passwordSet : boolean
initializationState : string ; initializationGeneration : number
}
const FULL_ACTIONS = [ 'system.users.view' , 'system.users.create' , 'system.users.edit' , 'system.users.disable' ,
'system.roles.view' , 'system.roles.manage' , 'system.roles.assign' , 'system.permissions.view' , 'system.permissions.manage' ]
function perm ( actionId : string , label : string , group : string , scope : 'system' | 'project' , extra : Partial < Perm > = { } ) : Perm {
return { actionId , label , description : ` ${ label } 说明 ` , group , scope , protected : false , . . . extra }
}
const CATALOG : Perm [ ] = [
perm ( 'project.create' , '创建项目' , '项目入口' , 'system' ) ,
perm ( 'project.member.manage' , '管理项目成员' , '项目入口' , 'project' ) ,
perm ( 'project.override.set' , '设置项目覆盖' , '项目入口' , 'project' ) ,
perm ( 'document.read' , '读取文档' , '文档动作' , 'project' ) ,
perm ( 'document.author' , '编制文档' , '文档动作' , 'project' ) ,
perm ( 'document.approve' , '批准文档' , '文档动作' , 'project' ) ,
perm ( 'export.create' , '创建导出' , '平台动作' , 'project' ) ,
perm ( 'audit.read' , '读取审计' , '平台动作' , 'project' ) ,
perm ( 'system.users.manage' , '管理用户账号' , '系统动作' , 'system' , { protected : true } ) ,
]
function emptyProfile ( name = '' ) {
return { name , phone : '' , email : '' , staffNo : '' , department : '' , notes : '' }
}
function account ( userId : string , owner : string , profile = emptyProfile ( ) , overrides : Partial < Account > = { } ) : Account {
return { userId , owner , profile , profileVersion : 1 , enabled : true , stateVersion : 3 , isAdmin : false , passwordSet : true , initializationState : 'done' , initializationGeneration : 0 , . . . overrides }
}
function rp ( actionId : string , allowed = false , capAllowed = false ) : RolePerm {
return { actionId , allowed , capAllowed , permissionVersion : 1 }
}
/** 可变角色/绑定状态:一个 fixture 贯穿用例内全部请求,支持版本化 409。 */
function world ( me : Me ) {
const roles : Role [ ] = [
{ roleId : 'role_eng' , name : '软件工程师' , description : '研发职位' , enabled : true , roleVersion : 3 , bindingCount : 2 , permissions : [ rp ( 'project.create' , true , true ) , rp ( 'document.read' , true , true ) ] } ,
{ roleId : 'role_ops' , name : '运维工程师' , description : '运维职位' , enabled : true , roleVersion : 1 , bindingCount : 0 , permissions : [ ] } ,
{ roleId : 'role_review' , name : '审核工程师' , description : '质量职位' , enabled : false , roleVersion : 2 , bindingCount : 1 , permissions : [ rp ( 'document.read' , true , true ) , rp ( 'document.approve' , false , true ) ] } ,
]
const bindings : Record < string , string > = { u1 : 'role_eng' , u3 : 'role_eng' }
const users : Account [ ] = [
account ( 'u1' , 'alice' , { . . . emptyProfile ( '张三' ) , phone : '13800000001' , staffNo : 'A001' } ) ,
account ( 'u2' , 'bob' ) ,
account ( 'u3' , 'carol' , { . . . emptyProfile ( '李四' ) , email : 'carol@example.com' } ) ,
]
const grants : Record < string , { actions : string [ ] ; authorityEpoch : number } > = { }
const calls : { method : string ; path : string ; query : Record < string , string > ; body? : Record < string , unknown > } [ ] = [ ]
const holds = new Map < string , { started : ReturnType < typeof deferred > ; release : ReturnType < typeof deferred > } > ( )
const failNext = new Map < string , number > ( )
let grantEpoch = 5
const PAGE = 2
const visible = ( q : URLSearchParams ) : Role [ ] = > roles . filter ( role = > {
const status = q . get ( 'status' ) ? ? 'all'
if ( status === 'enabled' && ! role . enabled ) return false
if ( status === 'disabled' && role . enabled ) return false
const action = q . get ( 'action' ) ? ? ''
if ( action && ! role . permissions . some ( item = > item . actionId === action && item . allowed ) ) return false
const query = ( q . get ( 'query' ) ? ? '' ) . trim ( ) . toLocaleLowerCase ( )
if ( ! query ) return true
const fields = [ role . name , role . description , role . roleId ] . map ( value = > value . toLocaleLowerCase ( ) )
return ( q . get ( 'mode' ) ? ? 'fuzzy' ) === 'exact' ? fields . some ( value = > value === query ) : fields . some ( value = > value . includes ( query ) )
} )
const recount = ( ) = > {
for ( const role of roles ) role . bindingCount = Object . values ( bindings ) . filter ( roleId = > roleId === role . roleId ) . length
}
const notFound = ( ) = > ( { status : 404 , contentType : 'application/json' , body : JSON.stringify ( { error : 'not found' } ) } )
const deny = ( route : Route , status : number ) = > route . fulfill ( { status , contentType : 'application/json' , body : JSON.stringify ( { error : ` code: ${ status } ` } ) } )
const ok = ( route : Route , payload : unknown ) = > route . fulfill ( { contentType : 'application/json' , body : JSON.stringify ( payload ) } )
return {
roles , bindings , users , grants , calls ,
hold ( key : string ) {
const pending = { started : deferred ( ) , release : deferred ( ) }
holds . set ( key , pending )
return pending
} ,
failNextTimes ( path : string , times : number ) { failNext . set ( path , times ) } ,
async route ( route : Route ) {
const request = route . request ( )
const url = new URL ( request . url ( ) )
const path = decodeURIComponent ( url . pathname )
const method = request . method ( )
const raw = request . postData ( )
calls . push ( { method , path , query : Object.fromEntries ( url . searchParams ) , body : raw ? JSON . parse ( raw ) as Record < string , unknown > : undefined } )
const hold = holds . get ( ` ${ method } ${ path } ` )
if ( hold ) { holds . delete ( ` ${ method } ${ path } ` ) ; hold . started . resolve ( ) ; await hold . release . promise }
const fails = failNext . get ( path ) ? ? 0
if ( fails > 0 ) { failNext . set ( path , fails - 1 ) ; return route . fulfill ( { status : 500 , contentType : 'application/json' , body : JSON.stringify ( { error : 'boom' } ) } ) }
if ( path === '/api/health' ) return ok ( route , { ok : true } )
if ( path === '/api/me' ) return ok ( route , me )
if ( path === '/api/projects' ) return ok ( route , [ ] )
if ( path === '/api/settings' ) return ok ( route , { schema : [ ] , defaults : { } , user : { } , project : { } } )
if ( path === '/api/commands' ) return ok ( route , [ ] )
if ( path === '/api/admin-zone/sections' ) return ok ( route , [ ] )
if ( path . endsWith ( '/cards' ) ) return ok ( route , { cards : [ ] } )
if ( path === '/api/admin/permissions/catalog' ) return ok ( route , { permissions : CATALOG } )
if ( path === '/api/admin/roles' && method === 'GET' ) {
const list = visible ( url . searchParams )
const start = Number ( url . searchParams . get ( 'cursor' ) ? ? 0 ) || 0
const page = list . slice ( start , start + PAGE )
return ok ( route , { roles : page , nextCursor : start + PAGE < list . length ? String ( start + PAGE ) : '' } )
}
if ( path === '/api/admin/roles' && method === 'POST' ) {
const body = request . postDataJSON ( ) as { name : string ; description : string }
const role : Role = { roleId : ` role_ ${ body . name } ` , name : body.name , description : body.description , enabled : true , roleVersion : 1 , bindingCount : 0 , permissions : [ ] }
roles . unshift ( role )
return ok ( route , role )
}
const roleMatch = path . match ( /^\/api\/admin\/roles\/([^/]+)(\/permissions)?$/ )
if ( roleMatch ) {
const role = roles . find ( item = > item . roleId === roleMatch [ 1 ] )
if ( ! role ) return route . fulfill ( notFound ( ) )
if ( ! roleMatch [ 2 ] ) {
if ( method === 'GET' ) return ok ( route , role )
const body = request . postDataJSON ( ) as { expectedRoleVersion : number ; name? : string ; description? : string ; enabled? : boolean }
if ( body . expectedRoleVersion !== role . roleVersion ) return deny ( route , 409 )
if ( body . name !== undefined ) role . name = body . name
if ( body . description !== undefined ) role . description = body . description
if ( body . enabled !== undefined ) role . enabled = body . enabled
role . roleVersion += 1
return ok ( route , role )
}
if ( method === 'PUT' ) {
const body = request . postDataJSON ( ) as { expectedRoleVersion : number ; changes : { actionId : string ; allowed : boolean ; capAllowed : boolean } [ ] }
if ( body . expectedRoleVersion !== role . roleVersion ) return deny ( route , 409 )
role . permissions = body . changes . map ( change = > ( { . . . change , permissionVersion : 1 } ) )
role . roleVersion += 1
return ok ( route , { receiptId : 'r1' , roleVersion : role.roleVersion , authorityEpoch : 6 } )
}
}
if ( path === '/api/admin/users/search' && method === 'GET' ) {
const q = url . searchParams
const query = ( q . get ( 'query' ) ? ? '' ) . trim ( ) . toLocaleLowerCase ( )
const fields = ( q . get ( 'fields' ) ? ? 'owner,name,userId' ) . split ( ',' )
const status = q . get ( 'status' ) ? ? 'all'
const list = users . filter ( item = > {
if ( status === 'enabled' && ! item . enabled ) return false
if ( status === 'disabled' && item . enabled ) return false
if ( ! query ) return true
const values = ( fields . includes ( 'default' ) ? [ 'owner' , 'name' , 'userId' ] : fields )
. map ( field = > field === 'userId' ? item.userId : field === 'owner' ? item.owner : item.profile [ field as 'name' ] ? ? '' )
return ( q . get ( 'mode' ) ? ? 'fuzzy' ) === 'exact' ? values . some ( value = > value . toLowerCase ( ) === query ) : values . some ( value = > value . toLowerCase ( ) . includes ( query ) )
} )
return ok ( route , { accounts : list , nextCursor : '' , directoryVersion : 'd1' } )
}
if ( path === '/api/admin/users' && method === 'POST' ) {
const body = request . postDataJSON ( ) as { owner : string ; profile : Account [ 'profile' ] }
if ( users . some ( item = > item . owner === body . owner ) ) return deny ( route , 409 )
const created = account ( ` u- ${ body . owner } ` , body . owner , { . . . emptyProfile ( ) , . . . body . profile } )
users . push ( created )
return ok ( route , created )
}
const userMatch = path . match ( /^\/api\/admin\/users\/([^/]+)(\/role|\/profile|\/state|\/system-grants)?$/ )
if ( userMatch ) {
const user = users . find ( item = > item . userId === userMatch [ 1 ] )
if ( ! user ) return route . fulfill ( notFound ( ) )
const sub = userMatch [ 2 ] ? ? ''
if ( sub === '' && method === 'GET' ) return ok ( route , user )
if ( sub === '/profile' && method === 'PATCH' ) {
const body = request . postDataJSON ( ) as { expectedProfileVersion : number ; patch : Record < string , string > }
if ( body . expectedProfileVersion !== user . profileVersion ) return deny ( route , 409 )
user . profile = { . . . user . profile , . . . body . patch }
user . profileVersion += 1
return ok ( route , user )
}
if ( sub === '/state' && method === 'PUT' ) {
const body = request . postDataJSON ( ) as { expectedStateVersion : number ; enabled : boolean }
if ( body . expectedStateVersion !== user . stateVersion ) return deny ( route , 409 )
user . enabled = body . enabled
user . stateVersion += 1
return ok ( route , user )
}
if ( sub === '/role' && method === 'GET' ) {
const role = roles . find ( item = > item . roleId === bindings [ user . userId ] )
return ok ( route , role ? {
binding : { bindingId : ` b- ${ user . userId } ` , userId : user.userId , roleId : role.roleId , active : true , bindingVersion : 1 , assignedBy : 'root' , assignedAt : '' , userStateVersion : user.stateVersion , authorityEpoch : grantEpoch } ,
role ,
} : { } )
}
if ( sub === '/role' && method === 'PUT' ) {
const body = request . postDataJSON ( ) as { roleId : string ; expectedStateVersion : number }
if ( body . expectedStateVersion !== user . stateVersion ) return deny ( route , 409 )
bindings [ user . userId ] = body . roleId
recount ( )
return ok ( route , { bindingId : ` b- ${ user . userId } ` , userId : user.userId , roleId : body.roleId , active : true , bindingVersion : 2 , assignedBy : 'root' , assignedAt : '' , userStateVersion : user.stateVersion , authorityEpoch : grantEpoch } )
}
if ( sub === '/system-grants' && method === 'GET' ) {
return ok ( route , grants [ user . userId ] ? ? { actions : [ ] , authorityEpoch : grantEpoch } )
}
if ( sub === '/system-grants' && method === 'PUT' ) {
const body = request . postDataJSON ( ) as { actions : string [ ] ; expectedAuthorityEpoch : number }
if ( body . expectedAuthorityEpoch !== ( grants [ user . userId ] ? . authorityEpoch ? ? grantEpoch ) ) return deny ( route , 409 )
const hadManage = ( grants [ user . userId ] ? . actions ? ? [ ] ) . includes ( 'system.permissions.manage' )
if ( hadManage && ! body . actions . includes ( 'system.permissions.manage' ) ) {
return route . fulfill ( { status : 400 , contentType : 'application/json' , body : JSON.stringify ( { error : 'cannot remove the final permission manager' } ) } )
}
grants [ user . userId ] = { actions : body.actions , authorityEpoch : ++ grantEpoch }
return ok ( route , { receiptId : 'g1' , authorityEpoch : grantEpoch } )
}
}
// 未点名端点(profiles/plugins/conversations/workspaces 等启动探测)按空列表兜底。
return route . fulfill ( { contentType : 'application/json' , body : '[]' } )
} ,
}
}
type World = ReturnType < typeof world >
async function openAdmin ( page : Page , me : Me ) : Promise < World > {
const w = world ( me )
await page . route ( '**/api/**' , route = > { void w . route ( route ) } )
await page . goto ( '/' )
await page . locator ( '.tsb-user' ) . click ( )
await page . getByRole ( 'button' , { name : '系统管理…' } ) . click ( )
await expect ( page . getByRole ( 'dialog' , { name : '系统管理' } ) ) . toBeVisible ( )
return w
}
const openAdminAsAdmin = ( page : Page ) = > openAdmin ( page , { owner : 'root' , name : '管理员' , auth : true , isAdmin : true , systemActions : [ . . . FULL_ACTIONS ] } )
async function openRolesSection ( page : Page ) {
await page . getByRole ( 'button' , { name : '角色与权限' , exact : true } ) . click ( )
// 面板加载带 180ms 防抖,等待首笔角色清单返回后再交互。
await expect ( page . locator ( '.rp-role-list button' , { hasText : '软件工程师' } ) ) . toBeVisible ( { timeout : 10_000 } )
await page . getByRole ( 'button' , { name : '搜索与过滤' } ) . click ( )
}
async function openUsersSection ( page : Page ) {
await page . getByRole ( 'button' , { name : '用户管理' , exact : true } ) . click ( )
await expect ( page . locator ( 'tr' , { hasText : 'alice' } ) ) . toBeVisible ( { timeout : 10_000 } )
}
const engRow = ( page : Page ) = > page . locator ( '.rp-role-list button' , { hasText : '软件工程师' } )
const reviewRow = ( page : Page ) = > page . locator ( '.rp-role-list button' , { hasText : '审核工程师' } )
test ( 'DR10 细粒度管理员:仅用户查看权时看不到角色分区,也不发出角色请求' , async ( { page } ) = > {
const w = await openAdmin ( page , { owner : 'mgr' , name : '用户管理员' , auth : true , isAdmin : false , systemActions : [ 'system.users.view' ] } )
await expect ( page . getByRole ( 'button' , { name : '用户管理' , exact : true } ) ) . toBeVisible ( )
await expect ( page . getByRole ( 'button' , { name : '角色与权限' , exact : true } ) ) . toHaveCount ( 0 )
await openUsersSection ( page )
await page . locator ( 'tr' , { hasText : 'alice' } ) . getByRole ( 'button' , { name : '查看与管理' } ) . click ( )
await expect ( page . getByText ( /当前角色 无角色查看权限/ ) ) . toBeVisible ( )
await expect ( page . getByLabel ( '用户生效角色' ) ) . toHaveCount ( 0 )
expect ( w . calls . filter ( call = > call . path . endsWith ( '/role' ) ) ) . toEqual ( [ ] )
expect ( w . calls . filter ( call = > call . path === '/api/admin/permissions/catalog' ) ) . toEqual ( [ ] )
} )
test ( 'DR10 权限管理者无查看权:出现守护提示且不拉取目录' , async ( { page } ) = > {
const w = await openAdmin ( page , { owner : 'mgr' , name : '权限管理者' , auth : true , isAdmin : false , systemActions : [ 'system.users.view' , 'system.permissions.manage' ] } )
await openUsersSection ( page )
await page . locator ( 'tr' , { hasText : 'alice' } ) . getByRole ( 'button' , { name : '查看与管理' } ) . click ( )
await expect ( page . getByText ( '需要同时具备系统权限查看权,才能安全编辑现有授权' ) ) . toBeVisible ( )
await expect ( page . getByRole ( 'button' , { name : '明确系统权限' } ) ) . toHaveCount ( 0 )
expect ( w . calls . filter ( call = > call . path === '/api/admin/permissions/catalog' ) ) . toEqual ( [ ] )
} )
test ( 'DR6 创建带描述的角色,后续修改保持稳定 roleId' , async ( { page } ) = > {
const w = await openAdminAsAdmin ( page )
await openRolesSection ( page )
await page . getByLabel ( '新角色名称' ) . fill ( '数据工程师' )
await page . getByLabel ( '新角色描述' ) . fill ( '数据管道职位' )
await page . getByRole ( 'button' , { name : '创建角色' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'status' ) ) . toContainText ( '已创建角色 数据工程师' )
await page . getByLabel ( '角色描述' , { exact : true } ) . fill ( '数据管道与质量职位' )
await page . getByRole ( 'button' , { name : '保存资料' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'status' ) ) . toContainText ( '角色资料已保存' )
expect ( w . calls . filter ( call = > call . method === 'POST' && call . path === '/api/admin/roles' ) ) . toHaveLength ( 1 )
const patches = w . calls . filter ( call = > call . method === 'PATCH' && call . path . startsWith ( '/api/admin/roles/' ) )
expect ( patches ) . toHaveLength ( 1 )
expect ( patches [ 0 ] . path ) . toBe ( '/api/admin/roles/role_数据工程师' )
} )
test ( 'DR6 停用角色需显式确认;取消不发出任何请求' , async ( { page } ) = > {
const w = await openAdminAsAdmin ( page )
await openRolesSection ( page )
await engRow ( page ) . click ( )
await page . getByRole ( 'button' , { name : '停用角色' , exact : true } ) . click ( )
const dialog = page . getByRole ( 'alertdialog' , { name : '确认停用角色 软件工程师' } )
await expect ( dialog ) . toContainText ( '软件工程师' )
await expect ( dialog ) . toContainText ( '2 个用户绑定' )
await expect ( dialog ) . toContainText ( '不再授予权限' )
await dialog . getByRole ( 'button' , { name : '取消' , exact : true } ) . click ( )
await expect ( dialog ) . toHaveCount ( 0 )
expect ( w . calls . filter ( call = > call . method === 'PATCH' && call . path === '/api/admin/roles/role_eng' ) ) . toEqual ( [ ] )
await page . getByRole ( 'button' , { name : '停用角色' , exact : true } ) . click ( )
await page . getByRole ( 'alertdialog' ) . getByRole ( 'button' , { name : '确认停用' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'status' ) ) . toContainText ( '角色已停用,现有绑定不再授予权限' )
const patches = w . calls . filter ( call = > call . method === 'PATCH' && call . path === '/api/admin/roles/role_eng' )
expect ( patches ) . toHaveLength ( 1 )
expect ( patches [ 0 ] . body ) . toMatchObject ( { expectedRoleVersion : 3 , enabled : false } )
} )
test ( 'DR6 停用版本冲突:刷新最新状态并保留重试上下文' , async ( { page } ) = > {
const w = await openAdminAsAdmin ( page )
await openRolesSection ( page )
await engRow ( page ) . click ( )
// 并发方先推进版本,首次确认停用必然 409。
w . roles . find ( role = > role . roleId === 'role_eng' ) ! . roleVersion = 4
await page . getByRole ( 'button' , { name : '停用角色' , exact : true } ) . click ( )
await page . getByRole ( 'alertdialog' ) . getByRole ( 'button' , { name : '确认停用' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'alert' ) ) . toContainText ( '角色状态版本冲突' )
await page . getByRole ( 'button' , { name : '停用角色' , exact : true } ) . click ( )
await page . getByRole ( 'alertdialog' ) . getByRole ( 'button' , { name : '确认停用' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'status' ) ) . toContainText ( '角色已停用' )
const patches = w . calls . filter ( call = > call . method === 'PATCH' && call . path === '/api/admin/roles/role_eng' )
expect ( patches ) . toHaveLength ( 2 )
expect ( patches [ 0 ] . body ) . toMatchObject ( { expectedRoleVersion : 3 } )
expect ( patches [ 1 ] . body ) . toMatchObject ( { expectedRoleVersion : 4 } )
expect ( w . calls . some ( call = > call . method === 'GET' && call . path === '/api/admin/roles/role_eng' ) ) . toBe ( true )
} )
test ( 'DR9 过滤、翻页与视图切换按 roleId 保留草稿且永不串角色' , async ( { page } ) = > {
const w = await openAdminAsAdmin ( page )
await openRolesSection ( page )
await engRow ( page ) . click ( )
await page . getByLabel ( '编制文档 默认允许' ) . check ( )
await expect ( page . getByText ( '变更预览(1) ' ) ) . toBeVisible ( )
// 切到第二页的审核工程师:不带走软件工程师的草稿。
await page . getByRole ( 'button' , { name : '下一页角色' , exact : true } ) . click ( )
await reviewRow ( page ) . click ( )
await expect ( page . getByText ( '变更预览(0) ' ) ) . toBeVisible ( )
await expect ( page . getByLabel ( '编制文档 默认允许' ) ) . not . toBeChecked ( )
// 切回软件工程师:草稿原样保留。
await page . getByRole ( 'button' , { name : '上一页角色' , exact : true } ) . click ( )
await engRow ( page ) . click ( )
await expect ( page . getByText ( '变更预览(1) ' ) ) . toBeVisible ( )
await expect ( page . getByLabel ( '编制文档 默认允许' ) ) . toBeChecked ( )
// 状态过滤触发重载:草稿仍在。
await page . getByLabel ( '角色状态' ) . selectOption ( 'disabled' )
await expect ( reviewRow ( page ) ) . toBeVisible ( )
await page . getByLabel ( '角色状态' ) . selectOption ( 'all' )
await engRow ( page ) . click ( )
await expect ( page . getByLabel ( '编制文档 默认允许' ) ) . toBeChecked ( )
// 单角色 ↔ 矩阵切换保留;矩阵中当前角色的勾选状态一致。
await page . getByRole ( 'button' , { name : '角色矩阵' , exact : true } ) . click ( )
await expect ( page . getByLabel ( '软件工程师 编制文档 默认允许' ) ) . toBeChecked ( )
await page . getByRole ( 'button' , { name : '单角色配置' , exact : true } ) . click ( )
await expect ( page . getByLabel ( '编制文档 默认允许' ) ) . toBeChecked ( )
expect ( w . calls . filter ( call = > call . path === '/api/admin/roles/role_ops' ) ) . toEqual ( [ ] )
} )
test ( 'DR9 离开管理区须显式决定;确认离开后草稿被放弃' , async ( { page } ) = > {
await openAdminAsAdmin ( page )
await openRolesSection ( page )
await engRow ( page ) . click ( )
await page . getByLabel ( '编制文档 默认允许' ) . check ( )
const dialogs : string [ ] = [ ]
let leave = false
page . on ( 'dialog' , async dialog = > {
dialogs . push ( dialog . message ( ) )
if ( dialog . type ( ) === 'confirm' ) leave ? await dialog . accept ( ) : await dialog . dismiss ( )
else await dialog . accept ( )
} )
await page . getByRole ( 'button' , { name : '返回工作台' } ) . click ( )
expect ( dialogs ) . toHaveLength ( 1 )
expect ( dialogs [ 0 ] ) . toContain ( '软件工程师' )
await expect ( page . getByRole ( 'dialog' , { name : '系统管理' } ) ) . toBeVisible ( )
leave = true
await page . getByRole ( 'button' , { name : '返回工作台' } ) . click ( )
await expect ( page . getByRole ( 'dialog' , { name : '系统管理' } ) ) . toHaveCount ( 0 )
// 重新进入后草稿不复存在(离开即放弃)。
await page . locator ( '.tsb-user' ) . click ( )
await page . getByRole ( 'button' , { name : '系统管理…' } ) . click ( )
await openRolesSection ( page )
await engRow ( page ) . click ( )
await expect ( page . getByText ( '变更预览(0) ' ) ) . toBeVisible ( )
} )
test ( 'DR9 409 只重放同一角色草稿;另一角色草稿不受影响' , async ( { page } ) = > {
const w = await openAdminAsAdmin ( page )
await openRolesSection ( page )
await engRow ( page ) . click ( )
await page . getByLabel ( '编制文档 默认允许' ) . check ( )
await page . getByRole ( 'button' , { name : '下一页角色' , exact : true } ) . click ( )
await reviewRow ( page ) . click ( )
await page . getByLabel ( '批准文档 默认允许' ) . check ( )
// 切回软件工程师再保存:并发方已推进版本并改变服务端基线,保存触发 409 rebase。
await page . getByRole ( 'button' , { name : '上一页角色' , exact : true } ) . click ( )
await engRow ( page ) . click ( )
const eng = w . roles . find ( role = > role . roleId === 'role_eng' ) !
eng . roleVersion = 4
eng . permissions = [ rp ( 'project.create' , false , false ) , rp ( 'document.read' , true , true ) ]
await page . getByRole ( 'button' , { name : '保存权限' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'alert' ) ) . toContainText ( '权限版本冲突;已基于最新版本重放本地草稿' )
expect ( w . calls . filter ( call = > call . method === 'GET' && call . path === '/api/admin/roles/role_review' ) ) . toEqual ( [ ] )
// 重放后:本地对编制文档的修改保留;再次保存基于最新版本成功。
await expect ( page . getByLabel ( '编制文档 默认允许' ) ) . toBeChecked ( )
await page . getByRole ( 'button' , { name : '保存权限' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'status' ) ) . toContainText ( '角色权限已保存' )
const puts = w . calls . filter ( call = > call . method === 'PUT' && call . path === '/api/admin/roles/role_eng/permissions' )
expect ( puts ) . toHaveLength ( 2 )
expect ( puts [ 0 ] . body ) . toMatchObject ( { expectedRoleVersion : 3 } )
expect ( puts [ 1 ] . body ) . toMatchObject ( { expectedRoleVersion : 4 } )
expect ( ( puts [ 1 ] . body ! . changes as { actionId : string ; allowed : boolean } [ ] ) . find ( change = > change . actionId === 'document.author' ) ) . toMatchObject ( { allowed : true , capAllowed : true } )
// 审核工程师的草稿未被动过:切回后仍在。
await page . getByRole ( 'button' , { name : '下一页角色' , exact : true } ) . click ( )
await reviewRow ( page ) . click ( )
await expect ( page . getByLabel ( '批准文档 默认允许' ) ) . toBeChecked ( )
} )
test ( 'DR9 矩阵批量编辑只作用于当前可见页' , async ( { page } ) = > {
await openAdminAsAdmin ( page )
await openRolesSection ( page )
await page . getByRole ( 'button' , { name : '角色矩阵' , exact : true } ) . click ( )
await page . locator ( '.rp-matrix button' , { hasText : '软件工程师' } ) . click ( )
await page . getByRole ( 'button' , { name : '当前页默认全选' , exact : true } ) . click ( )
for ( const label of [ '创建项目' , '管理项目成员' , '设置项目覆盖' , '读取文档' , '编制文档' , '批准文档' ] ) {
await expect ( page . getByLabel ( ` 软件工程师 ${ label } 默认允许 ` ) ) . toBeChecked ( )
}
await page . getByRole ( 'button' , { name : '下一组' , exact : true } ) . click ( )
await expect ( page . getByLabel ( '软件工程师 创建导出 默认允许' ) ) . not . toBeChecked ( )
await page . getByRole ( 'button' , { name : '上一组' , exact : true } ) . click ( )
await page . getByRole ( 'button' , { name : '当前页上限关闭' , exact : true } ) . click ( )
await expect ( page . getByLabel ( '软件工程师 创建项目 默认允许' ) ) . not . toBeChecked ( )
// 变更预览在单角色视图:批量编辑按 roleId 保留,切回可见 2 项相对基线变化。
await page . getByRole ( 'button' , { name : '单角色配置' , exact : true } ) . click ( )
await expect ( page . getByText ( '变更预览(2) ' ) ) . toBeVisible ( )
} )
test ( 'DR10 角色清单失败不拖垮目录加载,可经检索重试恢复' , async ( { page } ) = > {
const w = await openAdminAsAdmin ( page )
w . failNextTimes ( '/api/admin/roles' , 1 )
await page . getByRole ( 'button' , { name : '角色与权限' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'alert' ) ) . toContainText ( '500' )
// 目录请求独立发出并成功(互不拖垮)。
expect ( w . calls . some ( call = > call . path === '/api/admin/permissions/catalog' ) ) . toBe ( true )
await page . getByRole ( 'button' , { name : '搜索与过滤' } ) . click ( )
await page . getByLabel ( '搜索角色' ) . fill ( '软件' )
await expect ( page . locator ( '.rp-role-list button' , { hasText : '软件工程师' } ) ) . toBeVisible ( { timeout : 10_000 } )
await expect ( page . getByRole ( 'alert' ) ) . toHaveCount ( 0 )
} )
test ( 'DR10 权限目录失败不影响角色列表呈现' , async ( { page } ) = > {
const w = await openAdminAsAdmin ( page )
w . failNextTimes ( '/api/admin/permissions/catalog' , 1 )
await page . getByRole ( 'button' , { name : '角色与权限' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'alert' ) ) . toContainText ( '权限目录加载失败' )
await expect ( page . locator ( '.rp-role-list button' , { hasText : '软件工程师' } ) ) . toBeVisible ( )
expect ( w . calls . some ( call = > call . path === '/api/admin/roles' ) ) . toBe ( true )
} )
test ( 'DR10 键盘完成核心流程,保存后焦点稳定且状态可被播报' , async ( { page } ) = > {
await openAdminAsAdmin ( page )
await openRolesSection ( page )
await engRow ( page ) . focus ( )
await page . keyboard . press ( 'Enter' )
await expect ( page . getByLabel ( '角色名称' , { exact : true } ) ) . toHaveValue ( '软件工程师' )
await page . getByLabel ( '编制文档 默认允许' ) . focus ( )
await page . keyboard . press ( 'Space' )
await expect ( page . getByLabel ( '编制文档 默认允许' ) ) . toBeChecked ( )
await page . getByRole ( 'button' , { name : '保存权限' , exact : true } ) . focus ( )
await page . keyboard . press ( 'Enter' )
await expect ( page . getByRole ( 'status' ) ) . toContainText ( '角色权限已保存' )
const focused = await page . evaluate ( ( ) = > ( document . activeElement as HTMLElement | null ) ? . textContent ? ? '' )
expect ( focused ) . toContain ( '保存权限' )
} )
test ( 'DR10 窄屏矩阵横向可导航且可操作' , async ( { page } ) = > {
await openAdminAsAdmin ( page )
await page . setViewportSize ( { width : 480 , height : 800 } )
await openRolesSection ( page )
// 窄屏下视图切换按钮存在持续的布局微抖动,Playwright 稳定性检查无法通过;
// 用 DOM 事件切换视图,随后的横向滚动与勾选仍是真实交互。
await page . getByRole ( 'button' , { name : '角色矩阵' , exact : true } ) . dispatchEvent ( 'click' )
const wrap = page . locator ( '.rp-matrix-wrap' )
await expect ( wrap ) . toBeVisible ( )
const scroll = await wrap . evaluate ( el = > ( { sw : el.scrollWidth , cw : el.clientWidth } ) )
expect ( scroll . sw ) . toBeGreaterThan ( scroll . cw )
await page . getByLabel ( '软件工程师 创建项目 默认允许' ) . check ( )
await expect ( page . getByLabel ( '软件工程师 创建项目 默认允许' ) ) . toBeChecked ( )
} )
test ( 'DR7 两人共享同一角色;换绑后仅一个生效角色且计数随之变化' , async ( { page } ) = > {
const w = await openAdminAsAdmin ( page )
await openUsersSection ( page )
const openUser = async ( owner : string ) = > {
await page . locator ( 'tr' , { hasText : owner } ) . getByRole ( 'button' , { name : '查看与管理' } ) . click ( )
await expect ( page . getByRole ( 'button' , { name : '返回列表' } ) ) . toBeVisible ( )
}
await openUser ( 'bob' )
await page . getByLabel ( '用户生效角色' ) . selectOption ( { label : '软件工程师' } )
await page . getByRole ( 'button' , { name : '替换角色' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'status' ) ) . toContainText ( '用户角色已替换' )
expect ( w . bindings . u2 ) . toBe ( 'role_eng' )
await page . getByRole ( 'button' , { name : '返回列表' , exact : true } ) . click ( )
await openUser ( 'alice' )
await expect ( page . getByText ( /当前角色 软件工程师/ ) ) . toBeVisible ( )
await page . getByLabel ( '用户生效角色' ) . selectOption ( { label : '运维工程师' } )
await page . getByRole ( 'button' , { name : '替换角色' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'status' ) ) . toContainText ( '用户角色已替换' )
expect ( w . bindings . u1 ) . toBe ( 'role_ops' )
expect ( w . roles . find ( role = > role . roleId === 'role_eng' ) ! . bindingCount ) . toBe ( 2 ) // bob + carol
// 用户角色视图始终单角色:GET /role 只返回一个 binding。
expect ( w . calls . filter ( call = > call . method === 'GET' && call . path === '/api/admin/users/u1/role' ) . length ) . toBeGreaterThan ( 0 )
} )
test ( 'DR8 资料可全空创建,并支持按电话等字段检索' , async ( { page } ) = > {
const w = await openAdminAsAdmin ( page )
await openUsersSection ( page )
await page . getByLabel ( '新用户登录名' ) . fill ( 'dave' )
await page . getByRole ( 'button' , { name : '创建用户' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'status' ) ) . toContainText ( '已创建 dave' )
const created = w . calls . find ( call = > call . method === 'POST' && call . path === '/api/admin/users' )
expect ( created ! . body ! . profile ) . toEqual ( emptyProfile ( ) )
await page . getByRole ( 'button' , { name : '返回列表' , exact : true } ) . click ( )
await page . getByLabel ( '用户搜索字段' ) . selectOption ( { label : '电话' } )
await page . getByLabel ( '搜索用户' ) . fill ( '13800000001' )
await expect ( page . locator ( 'tr' , { hasText : 'alice' } ) ) . toBeVisible ( { timeout : 10_000 } )
await expect ( page . locator ( 'tr' , { hasText : 'carol' } ) ) . toHaveCount ( 0 )
const searches = w . calls . filter ( call = > call . method === 'GET' && call . path === '/api/admin/users/search' )
expect ( searches . at ( - 1 ) ! . query . fields ) . toContain ( 'phone' )
} )
test ( 'DR10 加载与空态:加载期间无结果无报错,过滤为空给出选择提示' , async ( { page } ) = > {
const w = await openAdminAsAdmin ( page )
await page . getByRole ( 'button' , { name : '角色与权限' , exact : true } ) . click ( )
await expect ( page . locator ( '.rp-role-list button' , { hasText : '软件工程师' } ) ) . toBeVisible ( { timeout : 10_000 } )
await page . getByRole ( 'button' , { name : '搜索与过滤' } ) . click ( )
const pending = w . hold ( 'GET /api/admin/roles' )
await page . getByLabel ( '搜索角色' ) . fill ( '不存在的角色' )
await pending . started . promise
await expect ( page . locator ( '.rp-role-list button' ) . first ( ) ) . toBeDisabled ( )
await expect ( page . getByRole ( 'alert' ) ) . toHaveCount ( 0 )
pending . release . resolve ( )
await expect ( page . locator ( '.rp-role-list button' ) ) . toHaveCount ( 0 )
await expect ( page . getByText ( '请选择角色' ) ) . toBeVisible ( )
} )
// ── 活体层(IAM_E2E_LIVE=1):同一文件在真实 Backend/RDM 组合上运行真实授权路径。
// 由 Agent-System-RDM scripts/iam-u4-role-acceptance.sh 物化冻结候选源码、
// 构建内嵌产品、全新 SQLite 数据根与随机回环端口后驱动;不拦截任何路由。
const LIVE = process . env . IAM_E2E_LIVE === '1'
async function liveLogin ( page : Page ) {
await page . goto ( '/' )
await page . getByPlaceholder ( '由管理员分配' ) . fill ( process . env . IAM_E2E_OWNER ? ? '' )
await page . getByPlaceholder ( '初始密码或常用密码' ) . fill ( process . env . IAM_E2E_PASSWORD ? ? '' )
await page . getByRole ( 'button' , { name : '登录' , exact : true } ) . click ( )
await expect ( page . locator ( '.tsb-user' ) ) . toBeVisible ( { timeout : 15_000 } )
}
async function openAdminZoneLive ( page : Page ) {
await page . locator ( '.tsb-user' ) . click ( )
await page . getByRole ( 'button' , { name : '系统管理…' } ) . click ( )
await expect ( page . getByRole ( 'dialog' , { name : '系统管理' } ) ) . toBeVisible ( )
}
test ( 'LIVE 真实登录加载真实权限目录,创建、配置并停用角色' , async ( { page } ) = > {
test . skip ( ! LIVE , 'live 层由 iam-u4-role-acceptance.sh 驱动' )
test . setTimeout ( 90 _000 )
await liveLogin ( page )
await openAdminZoneLive ( page )
await page . getByRole ( 'button' , { name : '角色与权限' , exact : true } ) . click ( )
await page . getByRole ( 'button' , { name : '搜索与过滤' } ) . click ( )
const firstDefault = page . locator ( 'input[aria-label$="默认允许"]:not([disabled])' ) . first ( )
// 真实目录来自被测 Backend:非受保护动作的默认允许复选框已渲染。
await expect ( firstDefault ) . toBeVisible ( { timeout : 15_000 } )
await page . getByLabel ( '新角色名称' ) . fill ( '数据工程师' )
await page . getByLabel ( '新角色描述' ) . fill ( '活体验收职位' )
await page . getByRole ( 'button' , { name : '创建角色' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'status' ) ) . toContainText ( '已创建角色 数据工程师' , { timeout : 15_000 } )
await firstDefault . check ( )
await page . getByRole ( 'button' , { name : '保存权限' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'status' ) ) . toContainText ( '角色权限已保存' , { timeout : 15_000 } )
await page . getByRole ( 'button' , { name : '停用角色' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'alertdialog' ) ) . toContainText ( '数据工程师' )
await page . getByRole ( 'alertdialog' ) . getByRole ( 'button' , { name : '确认停用' , exact : true } ) . click ( )
await expect ( page . getByRole ( 'status' ) ) . toContainText ( '角色已停用,现有绑定不再授予权限' , { timeout : 15_000 } )
} )
test ( 'LIVE DR13 非成员账号被拒读取项目业务内容;创建者放行' , async ( { request } ) = > {
test . skip ( ! LIVE , 'live 层由 iam-u4-role-acceptance.sh 驱动' )
const base = process . env . PLAYWRIGHT_BASE_URL !
const projectId = process . env . IAM_E2E_PROJECT_ID
test . expect ( projectId ) . toBeTruthy ( )
const memberList = async ( owner : string , password : string ) = > {
await request . post ( ` ${ base } /api/auth/login ` , { data : { owner , password } } )
const res = await request . post ( ` ${ base } /api/commands/rdm.project.member.list ` , {
data : { params : { projectId } } ,
} )
const body = await res . json ( ) . catch ( ( ) = > null )
return { ok : res.ok ( ) , body }
}
const observer = await memberList ( process . env . IAM_E2E_OBSERVER_OWNER ? ? '' , process . env . IAM_E2E_OBSERVER_PASSWORD ? ? '' )
const denied = ! observer . ok || observer . body ? . receipt ? . state === 'rejected'
expect ( denied , ` observer 应被拒绝: ${ JSON . stringify ( observer . body ) . slice ( 0 , 240 ) } ` ) . toBe ( true )
const creator = await memberList ( process . env . IAM_E2E_OWNER ? ? '' , process . env . IAM_E2E_PASSWORD ? ? '' )
const allowed = creator . ok && creator . body ? . receipt ? . state !== 'rejected'
expect ( allowed , ` 创建者应放行: ${ JSON . stringify ( creator . body ) . slice ( 0 , 240 ) } ` ) . toBe ( true )
} )