dify/web/app/components/access-rules-editor/__tests__/index.spec.tsx
Wu Tianwei 33edf97f81
feat: RBAC (#37107)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: fatelei <fatelei@gmail.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: 盐粒 Yanli <yanli@dify.ai>
Co-authored-by: Charles Yao <chongbinyao33@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: Jingyi <jingyi.qi@dify.ai>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
Co-authored-by: gigglewang <gigglewang@dify.ai>
Co-authored-by: chariri <w@chariri.moe>
Co-authored-by: Evan <2869018789@qq.com>
Co-authored-by: zyssyz123 <916125788@qq.com>
2026-06-18 16:35:29 +00:00

291 lines
9.9 KiB
TypeScript

import type { AccessPolicyWithBindings, ResourceUserAccessSetting } from '@/models/access-control'
import type { Member } from '@/models/common'
import { fireEvent, render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AccessRulesEditor from '../index'
const mockMembers = vi.hoisted(() => ({
accounts: [] as Member[] | null,
isLoading: false,
}))
vi.mock('@/service/use-common', () => ({
useMembers: vi.fn(() => ({
data: { accounts: mockMembers.accounts },
isLoading: mockMembers.isLoading,
})),
}))
const createRule = (resourceType: 'app' | 'dataset'): AccessPolicyWithBindings => ({
policy: {
id: `${resourceType}-policy-id`,
tenant_id: 'tenant-id',
resource_type: resourceType,
policy_key: `${resourceType}-policy-key`,
name: `${resourceType} policy`,
description: `${resourceType} policy description`,
permission_keys: [],
is_builtin: false,
category: 'global_custom',
created_at: '2026-05-22T00:00:00Z',
updated_at: '2026-05-22T00:00:00Z',
},
roles: [],
accounts: [],
})
const createUserAccessSetting = (): ResourceUserAccessSetting => ({
account: {
account_id: 'account-1',
account_name: 'Evan',
email: 'evan@example.com',
},
roles: [{
id: 'role-1',
type: 'app',
category: 'global_custom',
name: 'Maintainer',
is_builtin: false,
permission_keys: [],
}],
access_policies: [{
id: 'app-policy-id',
tenant_id: 'tenant-id',
resource_type: 'app',
policy_key: 'app-policy-key',
name: 'Manage',
description: 'Can manage this app',
permission_keys: [],
is_builtin: false,
category: 'global_custom',
}],
})
const createDefaultUserAccessSetting = (): ResourceUserAccessSetting => ({
...createUserAccessSetting(),
access_policies: [],
})
const createMember = (overrides: Partial<Member> = {}): Member => ({
id: 'account-2',
name: 'Mia',
email: 'mia@example.com',
avatar: '',
avatar_url: '',
status: 'active',
role: 'normal',
roles: [],
...overrides,
} as Member)
describe('AccessRulesEditor', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.accounts = []
mockMembers.isLoading = false
})
it('should render loading state before empty or row content', () => {
render(
<AccessRulesEditor
rules={[]}
userAccessSettings={[]}
isLoadingRules
isLoadingUserAccessSettings={false}
openScope="specific"
isUpdatingOpenScope={false}
updatingAccountId={null}
/>,
)
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
expect(screen.queryByText('permission.accessRule.noUserAccessSettings')).not.toBeInTheDocument()
})
it('should render empty state when there are no user access settings', () => {
render(
<AccessRulesEditor
rules={[]}
userAccessSettings={[]}
isLoadingRules={false}
isLoadingUserAccessSettings={false}
openScope="specific"
isUpdatingOpenScope={false}
updatingAccountId={null}
/>,
)
expect(screen.getByText('permission.accessRule.noUserAccessSettings')).toBeInTheDocument()
})
it('should disable resource access controls before open scope is available', () => {
render(
<AccessRulesEditor
rules={[]}
userAccessSettings={[]}
isLoadingRules={false}
isLoadingUserAccessSettings={false}
isUpdatingOpenScope={false}
updatingAccountId={null}
/>,
)
expect(screen.getByText('permission.accessRule.resourceOpenScope')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'permission.accessRule.resourceOpenScopeDescription' })).toBeInTheDocument()
const allMembersButton = screen.getByRole('button', { name: /permission\.accessRule\.allPermittedMembers/ })
const specificMembersButton = screen.getByRole('button', { name: /permission\.accessRule\.specificMembersOnly/ })
expect(allMembersButton).toBeDisabled()
expect(specificMembersButton).toBeDisabled()
expect(allMembersButton).toHaveAttribute('aria-pressed', 'false')
expect(specificMembersButton).toHaveAttribute('aria-pressed', 'false')
})
it('should render resource access controls and update account exceptions', () => {
const onOpenScopeChange = vi.fn()
const onUserAccessPoliciesChange = vi.fn()
const onRemoveAccessPolicyMemberBinding = vi.fn()
render(
<AccessRulesEditor
rules={[createRule('app')]}
userAccessSettings={[createUserAccessSetting()]}
isLoadingRules={false}
isLoadingUserAccessSettings={false}
openScope="specific"
isUpdatingOpenScope={false}
updatingAccountId={null}
onOpenScopeChange={onOpenScopeChange}
onUserAccessPoliciesChange={onUserAccessPoliciesChange}
onRemoveAccessPolicyMemberBinding={onRemoveAccessPolicyMemberBinding}
/>,
)
expect(screen.getByText('permission.accessRule.resourceOpenScope')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /permission\.accessRule\.specificMembersOnly/ })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByText('permission.accessRule.individualPermissionSettings')).toBeInTheDocument()
expect(screen.getByText('Evan')).toBeInTheDocument()
expect(screen.getByText('evan@example.com')).toBeInTheDocument()
expect(screen.queryByText('Maintainer')).not.toBeInTheDocument()
expect(screen.getAllByText('Manage').length).toBeGreaterThan(0)
fireEvent.click(screen.getByRole('button', { name: /permission\.accessRule\.allPermittedMembers/ }))
expect(onOpenScopeChange).not.toHaveBeenCalled()
expect(screen.getByText('permission.accessRule.changeOpenScopeTitle')).toBeInTheDocument()
expect(screen.getByText('permission.accessRule.changeOpenScopeDescription')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.change' }))
expect(onOpenScopeChange).toHaveBeenCalledWith('all')
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(onRemoveAccessPolicyMemberBinding).toHaveBeenCalledWith('account-1', 'app-policy-id')
})
it('should render the fixed default option when an account has no exception policy', () => {
render(
<AccessRulesEditor
rules={[createRule('app')]}
userAccessSettings={[createDefaultUserAccessSetting()]}
isLoadingRules={false}
isLoadingUserAccessSettings={false}
openScope="specific"
isUpdatingOpenScope={false}
updatingAccountId={null}
/>,
)
expect(screen.getByText('permission.accessRule.defaultPermission')).toBeInTheDocument()
})
it('should mark maintainer rows and prevent editing them', () => {
const onUserAccessPoliciesChange = vi.fn()
const onRemoveAccessPolicyMemberBinding = vi.fn()
render(
<AccessRulesEditor
rules={[createRule('app')]}
userAccessSettings={[createUserAccessSetting()]}
isLoadingRules={false}
isLoadingUserAccessSettings={false}
openScope="specific"
isUpdatingOpenScope={false}
updatingAccountId={null}
maintainerId="account-1"
onUserAccessPoliciesChange={onUserAccessPoliciesChange}
onRemoveAccessPolicyMemberBinding={onRemoveAccessPolicyMemberBinding}
/>,
)
expect(screen.getByText('permission.accessRule.maintainer')).toBeInTheDocument()
expect(screen.getByLabelText(/permission\.accessRule\.exceptionPermissionFor/)).toBeDisabled()
const removeButton = screen.getByRole('button', { name: 'common.operation.remove' })
expect(removeButton).toBeDisabled()
fireEvent.click(removeButton)
expect(onUserAccessPoliciesChange).not.toHaveBeenCalled()
expect(onRemoveAccessPolicyMemberBinding).not.toHaveBeenCalled()
})
it('should keep open scope unchanged when the confirmation is cancelled', () => {
const onOpenScopeChange = vi.fn()
render(
<AccessRulesEditor
rules={[]}
userAccessSettings={[]}
isLoadingRules={false}
isLoadingUserAccessSettings={false}
openScope="specific"
isUpdatingOpenScope={false}
updatingAccountId={null}
onOpenScopeChange={onOpenScopeChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /permission\.accessRule\.allPermittedMembers/ }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onOpenScopeChange).not.toHaveBeenCalled()
})
it('should add unassigned members with default permission', async () => {
const user = userEvent.setup()
const onAddAccessSubject = vi.fn()
mockMembers.accounts = [
createMember({
id: 'account-1',
name: 'Evan',
email: 'evan@example.com',
}),
createMember(),
]
render(
<AccessRulesEditor
rules={[]}
userAccessSettings={[createUserAccessSetting()]}
isLoadingRules={false}
isLoadingUserAccessSettings={false}
openScope="specific"
isUpdatingOpenScope={false}
updatingAccountId={null}
onAddAccessSubject={onAddAccessSubject}
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.add' }))
const dialog = screen.getByRole('dialog', { name: 'permission.accessRule.addMembersTitle' })
expect(within(dialog).getByText('Evan')).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'common.operation.added' })).toBeDisabled()
expect(within(dialog).queryByRole('button', { name: 'permission.accessRule.addMemberAria:{"name":"Evan"}' })).not.toBeInTheDocument()
expect(within(dialog).getByText('Mia')).toBeInTheDocument()
expect(within(dialog).queryByRole('tablist')).not.toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'permission.accessRule.addMemberAria:{"name":"Mia"}' }))
expect(onAddAccessSubject).toHaveBeenCalledWith('account-2', ['default'])
})
})