feat(policies): UI fuer ABAC-Richtlinien mit Conditions-Builder — Modul 10/16 des UI-Backlogs

This commit is contained in:
Agent Zero
2026-09-14 23:41:55 +02:00
parent abdf9e7d83
commit d734923636
7 changed files with 1127 additions and 0 deletions
@@ -0,0 +1,254 @@
/**
* Policies settings page tests — ABAC entity policy management UI
* (UI-Backlog module 10/16).
*
* Covers: rendering, entity type tabs, permission gating, policy cards,
* create form with conditions builder, toggle enable/disable, edit flow,
* delete flow.
*/
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { SettingsPoliciesPage } from '@/pages/SettingsPolicies';
import type { Policy } from '@/api/policies';
const createMut = vi.fn().mockResolvedValue({});
const updateMut = vi.fn().mockResolvedValue({});
const deleteMut = vi.fn().mockResolvedValue({});
const makePolicy = (overrides: Partial<Policy> = {}): Policy => ({
id: '11111111-1111-1111-1111-111111111111',
name: 'VIP-Kunden erlauben',
entity_type: 'contact',
principal_type: 'user',
principal_id: '22222222-2222-2222-2222-222222222222',
effect: 'allow',
conditions: {
operator: 'AND',
rules: [{ field: 'status', op: 'eq', value: 'active' }],
},
priority: 10,
tenant_id: 't-1',
enabled: true,
created_at: '2026-09-01T10:00:00Z',
updated_at: null,
...overrides,
});
let mockItems: Policy[] = [];
let mockCanRead = true;
let mockCanWrite = true;
vi.mock('@/api/policies', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/api/policies')>();
return {
...actual,
usePolicies: () => ({
data: { items: mockItems, total: mockItems.length },
isLoading: false,
isError: false,
}),
useCreatePolicy: () => ({ mutate: createMut, isPending: false }),
useUpdatePolicy: () => ({ mutate: updateMut, isPending: false }),
useDeletePolicy: () => ({ mutate: deleteMut, isPending: false }),
};
});
vi.mock('@/api/users', () => ({
useUsers: () => ({
data: {
items: [
{ id: '22222222-2222-2222-2222-222222222222', name: 'Admin User', email: 'admin@test.de' },
],
},
}),
}));
vi.mock('@/api/groups', () => ({
useGroups: () => ({
data: {
items: [{ id: '33333333-3333-3333-3333-333333333333', name: 'Sales' }],
},
}),
}));
vi.mock('@/api/roles', () => ({
useRoles: () => ({
data: {
items: [{ id: '44444444-4444-4444-4444-444444444444', name: 'manager' }],
},
}),
}));
vi.mock('@/hooks/usePermission', () => ({
usePermission: () => ({
hasPermission: (perm: string) =>
(mockCanRead || perm !== 'policies:read') &&
(mockCanWrite || perm !== 'policies:write'),
}),
}));
function renderPage() {
return render(
<MemoryRouter>
<SettingsPoliciesPage />
</MemoryRouter>,
);
}
beforeEach(() => {
vi.clearAllMocks();
mockItems = [];
mockCanRead = true;
mockCanWrite = true;
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
describe('SettingsPoliciesPage', () => {
it('renders page title and entity type tabs', () => {
renderPage();
expect(screen.getByTestId('policies-page')).toBeInTheDocument();
expect(screen.getByTestId('policy-tab-contact')).toBeInTheDocument();
expect(screen.getByTestId('policy-tab-file')).toBeInTheDocument();
expect(screen.getByTestId('policy-tab-task')).toBeInTheDocument();
expect(screen.getByTestId('policy-tab-workflow')).toBeInTheDocument();
});
it('shows empty state when no policies exist', () => {
renderPage();
expect(screen.getByTestId('policies-empty')).toBeInTheDocument();
});
it('shows no-permission card when policies:read is missing', () => {
mockCanRead = false;
renderPage();
expect(screen.getByTestId('policies-no-permission')).toBeInTheDocument();
expect(screen.queryByTestId('policies-empty')).not.toBeInTheDocument();
});
it('renders policy cards with effect badge and conditions summary', () => {
mockItems = [makePolicy()];
renderPage();
const card = screen.getByTestId('policy-card-11111111-1111-1111-1111-111111111111');
expect(card).toBeInTheDocument();
expect(
screen.getByTestId('policy-effect-11111111-1111-1111-1111-111111111111'),
).toHaveTextContent('Erlauben');
expect(
screen.getByTestId('policy-conditions-11111111-1111-1111-1111-111111111111'),
).toHaveTextContent('status eq active');
});
it('shows deny badge and disabled badge for deny policies', () => {
mockItems = [makePolicy({ effect: 'deny', enabled: false })];
renderPage();
expect(
screen.getByTestId('policy-effect-11111111-1111-1111-1111-111111111111'),
).toHaveTextContent('Verweigern');
expect(screen.getByText('Deaktiviert')).toBeInTheDocument();
});
it('hides action buttons without policies:write', () => {
mockItems = [makePolicy()];
mockCanWrite = false;
renderPage();
expect(
screen.queryByTestId('policy-edit-11111111-1111-1111-1111-111111111111'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('policy-create-btn')).not.toBeInTheDocument();
});
it('opens the create form, adds a rule, and submits the payload', async () => {
renderPage();
fireEvent.click(screen.getByTestId('policy-create-btn'));
const nameInput = screen.getByTestId('policy-form-name');
fireEvent.change(nameInput, { target: { value: 'Neue Richtlinie' } });
fireEvent.change(screen.getByTestId('policy-form-principal'), {
target: { value: '22222222-2222-2222-2222-222222222222' },
});
// Add one condition rule (whitelisted field from ABAC_ALLOWED_FIELDS.contact)
fireEvent.click(screen.getByTestId('policy-add-rule'));
const ruleRow = screen.getByTestId('policy-rule-0');
const fieldSelect = ruleRow.querySelector('select');
if (fieldSelect) {
fireEvent.change(fieldSelect, { target: { value: 'status' } });
}
const valueInput = ruleRow.querySelector('input');
if (valueInput) {
fireEvent.change(valueInput, { target: { value: 'active' } });
}
fireEvent.click(screen.getByTestId('policy-form-submit'));
await waitFor(() => expect(createMut).toHaveBeenCalled());
const call = createMut.mock.calls[0][0];
expect(call.name).toBe('Neue Richtlinie');
expect(call.entity_type).toBe('contact');
expect(call.principal_id).toBe('22222222-2222-2222-2222-222222222222');
expect(call.conditions).toEqual({
operator: 'AND',
rules: [{ field: 'status', op: 'eq', value: 'active' }],
});
});
it('submits null conditions when no rules are added', async () => {
renderPage();
fireEvent.click(screen.getByTestId('policy-create-btn'));
fireEvent.change(screen.getByTestId('policy-form-name'), {
target: { value: 'Alles erlauben' },
});
fireEvent.change(screen.getByTestId('policy-form-principal'), {
target: { value: '33333333-3333-3333-3333-333333333333' },
});
fireEvent.click(screen.getByTestId('policy-form-submit'));
await waitFor(() => expect(createMut).toHaveBeenCalled());
expect(createMut.mock.calls[0][0].conditions).toBeNull();
});
it('toggles a policy enabled state via update mutation', async () => {
mockItems = [makePolicy()];
renderPage();
fireEvent.click(screen.getByTestId('policy-toggle-11111111-1111-1111-1111-111111111111'));
await waitFor(() =>
expect(updateMut).toHaveBeenCalledWith({
id: '11111111-1111-1111-1111-111111111111',
data: { enabled: false },
}),
);
});
it('opens the edit form pre-filled with policy values', async () => {
mockItems = [makePolicy()];
renderPage();
fireEvent.click(screen.getByTestId('policy-edit-11111111-1111-1111-1111-111111111111'));
await waitFor(() => {
expect(screen.getByTestId('policy-form-name')).toHaveValue('VIP-Kunden erlauben');
});
// number inputs report numeric values
expect(screen.getByTestId('policy-form-priority')).toHaveValue(10);
});
it('deletes a policy after confirm', async () => {
mockItems = [makePolicy()];
renderPage();
fireEvent.click(screen.getByTestId('policy-delete-11111111-1111-1111-1111-111111111111'));
await waitFor(() =>
expect(deleteMut).toHaveBeenCalledWith({
id: '11111111-1111-1111-1111-111111111111',
entityType: 'contact',
}),
);
});
it('switches entity type tab and shows the tab as selected', () => {
renderPage();
const tab = screen.getByTestId('policy-tab-file');
fireEvent.click(tab);
expect(tab).toHaveAttribute('aria-selected', 'true');
});
});