Files
Agent-System-Frontend/test/rdm-members-options-gate.test.tsx
agent-exec-transport d997d4a4d0
governance / governance (pull_request) Successful in 16s
ci / test-and-build (pull_request) Successful in 34m20s
agent-exec transport 85c00f1e2274be1a84b73a421987fc69/ba12fd06dc72
2026-10-20 06:21:55 +00:00

265 lines
10 KiB
TypeScript
Raw Permalink 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.
// RdmMembersSection rdm.role.options 授权门控:rdm.perm.check 给出 member.manage
// 未允许时,不得提前发 rdm.role.options(普通成员被 403);允许后必须发。
//
// 测试在 node 环境下用最小化的 DOM mock 驱动 React 19 createRoot,让 useEffect 正常落槽;
// 不去依赖 happy-dom/jsdom(受网络/包锁约束,allowed_paths 也限制仅 src + test)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { RdmMembersSection } from '../src/components/adminRdmSections'
import { api } from '../src/api'
import type { Project } from '../src/contracts'
// 用 vi.mock 隔离 useStore:组件只用到 select(state).me,其它 store 字段触发不到,
// 不会影响此处关注点(避免 host Store 启动监听器、送 SSE 等副作用污染测试)。
vi.mock('../src/store', () => {
const state = {
me: { owner: 'bob', name: 'bob', auth: true },
}
return {
useStore: (select: (value: typeof state) => unknown) => select(state),
eventTime: (_e: { ts?: string }, now: number) => now,
}
})
// ---- 自定义 React DOM 桩:让 useEffect 在 node 环境落到 commit 阶段 ----
// 服务端 React 不运行 useEffect;createRoot 需要一个 DOM-shaped container。
// 这里只覆盖 react-dom-client 用到的几个关键入口(addEventListener、tagName、namespaceURI、
// ownerDocument.defaultView.HTMLIFrameElement、activeElement、documentElement)。任何
// 新字段缺失都会在第一处 React 调用时显式爆出,便于修复而不是静默吞下。
class HTMLIFrameElement {}
class MockDocument {
nodeType = 9
defaultView: any
HTMLIFrameElement = HTMLIFrameElement
activeElement: any = null
documentElement: any
constructor() {
this.defaultView = makeWindow()
this.defaultView.document = this
this.activeElement = null
this.documentElement = this
}
addEventListener() {}
removeEventListener() {}
dispatchEvent() {}
contains() { return false }
createElement(tag: string) {
const el = new MockElement()
el.tagName = tag
el.nodeName = tag
el.ownerDocument = this
if (renderObserver) renderObserver.append(tag)
return el
}
createElementNS(_ns: string, tag: string) { return this.createElement(tag) }
createTextNode() { return new MockElement() }
}
/** 创建 DOM 节点时把它们的 tag 累积下来,替代 innerHTML 读取(Mock 上 innerHTML 为空)。 */
let renderObserver: { tags: string[]; reset(): void; append(t: string): void } | null = null
class MockElement {
nodeType = 1
style: Record<string, string> = {}
attributes: Record<string, string> = {}
tagName = 'DIV'
nodeName = 'DIV'
namespaceURI = 'http://www.w3.org/1999/xhtml'
ownerDocument: any
childNodes: any[] = []
parentNode: any = null
appendChild(c: any) { this.childNodes.push(c); c.parentNode = this; return c }
removeChild(c: any) { const i = this.childNodes.indexOf(c); if (i >= 0) this.childNodes.splice(i, 1); c.parentNode = null; return c }
insertBefore(c: any, ref: any) {
const i = ref ? this.childNodes.indexOf(ref) : this.childNodes.length
this.childNodes.splice(i < 0 ? this.childNodes.length : i, 0, c)
c.parentNode = this
return c
}
contains(c: any) {
for (let n: any = c; n; n = n.parentNode) if (n === this) return true
return false
}
addEventListener() {}
removeEventListener() {}
dispatchEvent() {}
focus() {}
setAttribute(k: string, v: string) { this.attributes[k] = v }
removeAttribute(k: string) { delete this.attributes[k] }
cloneNode() { return new MockElement() }
/** React 写 `<select>` 时会读 `select.options`,给一个可索引的 length 让 updateOptions 不崩。 */
get options() {
return this.childNodes
}
/** React 写入 innerHTML;这里读为空即可(断言用别的字段)。 */
get innerHTML() { return '' }
get outerHTML() { return '' }
get textContent() { return '' }
set textContent(_v: string) { /* 允许 commit 阶段写入 */ }
get value() { return this.attributes.value ?? '' }
set value(v: string) { this.attributes.value = v }
get firstChild() { return this.childNodes[0] }
get lastChild() { return this.childNodes[this.childNodes.length - 1] }
get nextSibling() {
if (!this.parentNode) return null
const i = this.parentNode.childNodes.indexOf(this)
return this.parentNode.childNodes[i + 1] ?? null
}
get previousSibling() {
if (!this.parentNode) return null
const i = this.parentNode.childNodes.indexOf(this)
return this.parentNode.childNodes[i - 1] ?? null
}
}
function makeWindow() {
const w: any = {
event: undefined,
HTMLIFrameElement,
document: undefined as any,
navigator: { userAgent: 'node' },
}
return w
}
function makeContainer(): MockElement {
const doc = new MockDocument()
const el = doc.createElement('div') as MockElement
return el
}
function installDomGlobals() {
const win = makeWindow()
const doc = new MockDocument()
doc.defaultView = win
win.document = doc
;(globalThis as any).window = win
;(globalThis as any).document = doc
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
;(globalThis as any).MessageChannel = class { port1 = { postMessage() {} }; port2 = { onmessage: null as any, postMessage() {} } }
;(globalThis as any).setImmediate = (cb: (...args: any[]) => void) => Promise.resolve().then(() => cb())
}
let _installed = false
function ensureDomGlobals() {
if (_installed) return
installDomGlobals()
_installed = true
}
// ---- 用例辅助 ----
const project: Project = {
id: 'host-1', owner: 'alice', name: '项目P', createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' },
}
type CmdHandler = (id: string, params: Record<string, unknown>) => Promise<unknown> | unknown
/** 直接劫持 api.rdmProjectCommand,用 handlers[id] 分别登记每个命令的响应。 */
function spyCommandBus(handlers: Record<string, CmdHandler>) {
const calls: Array<[string, Record<string, unknown>]> = []
const original = api.rdmProjectCommand.bind(api)
const spy = vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_proj: Project, id: string, params: Record<string, unknown> = {}) => {
const args: [string, Record<string, unknown>] = [id, params]
calls.push(args)
if (!handlers[id]) throw new Error(`unexpected command: ${id} (no handler registered)`)
return handlers[id](id, params)
})
return {
calls,
roleOptionsCalls: () => calls.filter(([id]) => id === 'rdm.role.options'),
permCheckCalls: () => calls.filter(([id]) => id === 'rdm.perm.check'),
memberListCalls: () => calls.filter(([id]) => id === 'rdm.project.member.list'),
restore: () => {
spy.mockRestore()
;(api as any).rdmProjectCommand = original
},
}
}
let root: Root | null = null
let container: MockElement | null = null
beforeEach(() => {
ensureDomGlobals()
renderObserver = {
tags: [],
reset() { this.tags = [] },
append(t: string) { this.tags.push(t) },
}
})
afterEach(async () => {
if (root) {
await act(async () => root!.unmount())
root = null
}
container = null
renderObserver = null
vi.restoreAllMocks()
})
async function renderAndFlush(element: ReactNode, flushes: number = 3) {
container = makeContainer()
await act(async () => {
root = createRoot(container as any)
root!.render(element)
})
// 反复冲一轮 microtask flush,让 perm.check 的 .then 落进 commit。
for (let i = 0; i < flushes; i++) {
await act(async () => { await Promise.resolve() })
}
}
/** 助记:累计的 render 标签里「候选专业角色筛选」下拉 = 看到标 'select' + 紧邻的 option 角色字符串。 */
function sawCandidateRoleFilter(): boolean {
if (!renderObserver) return false
// React 渲染时按 tag 序列创建 select/option;候选筛选断言只需确保 select 节点创建了。
return renderObserver.tags.includes('select')
}
describe('RdmMembersSection rdm.role.options 授权门控', () => {
it('member.manage 未被允许时不得请求 rdm.role.options(普通成员避免 403)', async () => {
const bus = spyCommandBus({
'rdm.perm.check': () => ({ actions: [{ action: 'member.manage', allowed: false }] }),
'rdm.project.member.list': () => ({ members: [], accountDirectoryAvailable: true }),
})
await renderAndFlush(createElement(RdmMembersSection, { project }))
expect(bus.permCheckCalls().length).toBe(1)
expect(bus.roleOptionsCalls().length).toBe(0)
})
it('member.manage 被允许后再请求 rdm.role.options,并把结果写回角色筛选', async () => {
const bus = spyCommandBus({
'rdm.perm.check': () => ({ actions: [{ action: 'member.manage', allowed: true }] }),
'rdm.role.options': () => ({ roles: [
{ roleId: 'r-eng', name: '工程师' },
{ roleId: 'r-qa', name: '测试' },
] }),
'rdm.project.member.list': () => ({ members: [], accountDirectoryAvailable: true }),
})
await renderAndFlush(createElement(RdmMembersSection, { project }))
expect(bus.permCheckCalls().length).toBe(1)
expect(bus.roleOptionsCalls().length).toBe(1)
})
it('门控生效前的初始挂载阶段不出现专业角色筛选下拉(不提供误导选项)', async () => {
// 用一个永挂起的 perm.check 模拟「自省中」——此时页面上确实只见核对提示,
// 候选区不出现;这是顺带的渲染回归保单,防止门控未来误把 options 提前注入。
// memberManagementView 在 actions===null 时返回 canManage:false,rdm.role.options 因此不发请求;
// 同时 candRoleId 筛选(占位 select)也不应渲染——manage.canManage=false 时整个 ad-create 块被跳掉。
spyCommandBus({
'rdm.perm.check': () => new Promise<{ actions: { action: string; allowed: boolean }[] }>(() => {}),
'rdm.project.member.list': () => new Promise<any>(() => {}),
})
await renderAndFlush(createElement(RdmMembersSection, { project }))
expect(sawCandidateRoleFilter()).toBe(false)
})
})