Skip to content

Commit 0788a6c

Browse files
Add catalog hints, policy-centric assignment panel, and datasource assignment on create
- PolicyForm: add CatalogHints support with schema/table/column chip picker (TargetCard subcomponent with filterable chips for catalogs with >15 entries) - PolicyCreatePage: require datasource selection upfront; auto-assign policy to chosen datasource after creation; load catalog hints from selected datasource - PolicyAssignmentPanel: invert panel direction — PolicyAssignmentEditPanel is now policy-centric (assigns to datasources) instead of datasource-centric; add DatasourceAssignmentsReadonly for policy edit page - Add useCatalogHints hook to load schema/table/column data from a datasource - proxy: fix SQLite NULL duplicate assignment — NULL != NULL in unique indexes so explicitly check for existing (policy, ds, NULL user) before inserting
1 parent 0f0dd23 commit 0788a6c

11 files changed

Lines changed: 924 additions & 172 deletions

admin-ui/src/components/PolicyAssignmentPanel.test.tsx

Lines changed: 167 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,42 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest'
2-
import { screen, waitFor } from '@testing-library/react'
2+
import { screen, waitFor, fireEvent } from '@testing-library/react'
33
import { renderWithProviders } from '../test/test-utils'
4-
import { makePolicy, makePolicyAssignment } from '../test/factories'
4+
import { makePolicyAssignment, makeDataSource } from '../test/factories'
55
import { makeUser } from '../test/factories'
66

77
vi.mock('../api/policies', () => ({
88
listDatasourcePolicies: vi.fn(),
9-
listPolicies: vi.fn(),
109
assignPolicy: vi.fn(),
1110
removeAssignment: vi.fn(),
1211
}))
1312

13+
vi.mock('../api/datasources', () => ({
14+
listDataSources: vi.fn(),
15+
}))
16+
1417
vi.mock('../api/users', () => ({
1518
listUsers: vi.fn(),
1619
}))
1720

18-
import { listDatasourcePolicies, listPolicies } from '../api/policies'
21+
import { listDatasourcePolicies, assignPolicy } from '../api/policies'
22+
import { listDataSources } from '../api/datasources'
1923
import { listUsers } from '../api/users'
20-
import { PolicyAssignmentsReadonly, PolicyAssignmentPanel } from './PolicyAssignmentPanel'
24+
import {
25+
PolicyAssignmentsReadonly,
26+
PolicyAssignmentEditPanel,
27+
DatasourceAssignmentsReadonly,
28+
} from './PolicyAssignmentPanel'
2129

2230
const mockListDsPolicies = listDatasourcePolicies as ReturnType<typeof vi.fn>
23-
const mockListPolicies = listPolicies as ReturnType<typeof vi.fn>
31+
const mockAssignPolicy = assignPolicy as ReturnType<typeof vi.fn>
32+
const mockListDataSources = listDataSources as ReturnType<typeof vi.fn>
2433
const mockListUsers = listUsers as ReturnType<typeof vi.fn>
2534

2635
beforeEach(() => {
2736
vi.clearAllMocks()
2837
mockListDsPolicies.mockResolvedValue([])
29-
mockListPolicies.mockResolvedValue({ data: [], total: 0, page: 1, page_size: 100 })
38+
mockAssignPolicy.mockResolvedValue({})
39+
mockListDataSources.mockResolvedValue({ data: [], total: 0, page: 1, page_size: 200 })
3040
mockListUsers.mockResolvedValue({ data: [], total: 0, page: 1, page_size: 100 })
3141
})
3242

@@ -68,12 +78,141 @@ describe('PolicyAssignmentsReadonly', () => {
6878
})
6979
})
7080

71-
// ===== PolicyAssignmentPanel =====
81+
// ===== PolicyAssignmentEditPanel =====
82+
83+
describe('PolicyAssignmentEditPanel', () => {
84+
it('shows "No assignments yet" when assignments prop is empty', () => {
85+
renderWithProviders(
86+
<PolicyAssignmentEditPanel
87+
policyId="p-1"
88+
assignments={[]}
89+
onAssignmentChange={vi.fn()}
90+
/>,
91+
{ authenticated: true },
92+
)
93+
expect(screen.getByText('No assignments yet.')).toBeInTheDocument()
94+
})
95+
96+
it('renders assignments table with datasource link and Remove button', () => {
97+
const a = makePolicyAssignment({
98+
id: 'a-1',
99+
data_source_id: 'ds-99',
100+
datasource_name: 'staging-db',
101+
username: 'alice',
102+
priority: 50,
103+
})
104+
renderWithProviders(
105+
<PolicyAssignmentEditPanel
106+
policyId="p-1"
107+
assignments={[a]}
108+
onAssignmentChange={vi.fn()}
109+
/>,
110+
{ authenticated: true },
111+
)
112+
const link = screen.getByRole('link', { name: 'staging-db' })
113+
expect(link).toHaveAttribute('href', '/datasources/ds-99/edit')
114+
expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument()
115+
})
116+
117+
it('shows the Add Assignment form with datasource, user, and priority fields', () => {
118+
renderWithProviders(
119+
<PolicyAssignmentEditPanel
120+
policyId="p-1"
121+
assignments={[]}
122+
onAssignmentChange={vi.fn()}
123+
/>,
124+
{ authenticated: true },
125+
)
126+
expect(screen.getByRole('button', { name: /assign policy/i })).toBeInTheDocument()
127+
expect(screen.getByRole('spinbutton')).toBeInTheDocument() // priority number input
128+
})
129+
130+
it('populates datasource dropdown from listDataSources', async () => {
131+
const ds = makeDataSource({ id: 'ds-1', name: 'my-db', is_active: true })
132+
mockListDataSources.mockResolvedValue({ data: [ds], total: 1, page: 1, page_size: 200 })
133+
renderWithProviders(
134+
<PolicyAssignmentEditPanel
135+
policyId="p-1"
136+
assignments={[]}
137+
onAssignmentChange={vi.fn()}
138+
/>,
139+
{ authenticated: true },
140+
)
141+
await waitFor(() => expect(screen.getByText('my-db')).toBeInTheDocument())
142+
})
143+
144+
it('populates user dropdown from listUsers', async () => {
145+
const user = makeUser({ id: 'u-1', username: 'bob' })
146+
mockListUsers.mockResolvedValue({ data: [user], total: 1, page: 1, page_size: 100 })
147+
renderWithProviders(
148+
<PolicyAssignmentEditPanel
149+
policyId="p-1"
150+
assignments={[]}
151+
onAssignmentChange={vi.fn()}
152+
/>,
153+
{ authenticated: true },
154+
)
155+
await waitFor(() => expect(screen.getByText('bob')).toBeInTheDocument())
156+
})
157+
158+
it('renders a Remove button for each assignment', () => {
159+
const assignments = [
160+
makePolicyAssignment({ id: 'a-1', datasource_name: 'db1' }),
161+
makePolicyAssignment({ id: 'a-2', datasource_name: 'db2' }),
162+
]
163+
renderWithProviders(
164+
<PolicyAssignmentEditPanel
165+
policyId="p-1"
166+
assignments={assignments}
167+
onAssignmentChange={vi.fn()}
168+
/>,
169+
{ authenticated: true },
170+
)
171+
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(2)
172+
})
72173

73-
describe('PolicyAssignmentPanel', () => {
174+
it('shows error message when duplicate assignment returns 409', async () => {
175+
const ds = makeDataSource({ id: 'ds-1', name: 'prod-db', is_active: true })
176+
mockListDataSources.mockResolvedValue({ data: [ds], total: 1, page: 1, page_size: 200 })
177+
mockAssignPolicy.mockRejectedValue({
178+
response: { data: { error: 'This policy is already assigned to this datasource for all users' } },
179+
})
180+
181+
const { container } = renderWithProviders(
182+
<PolicyAssignmentEditPanel
183+
policyId="p-1"
184+
assignments={[]}
185+
onAssignmentChange={vi.fn()}
186+
/>,
187+
{ authenticated: true },
188+
)
189+
190+
await waitFor(() => expect(screen.getByText('prod-db')).toBeInTheDocument())
191+
192+
const dsSelect = container.querySelectorAll('select')[0]
193+
fireEvent.change(dsSelect, { target: { value: 'ds-1' } })
194+
195+
await waitFor(() =>
196+
expect(screen.getByRole('button', { name: /assign policy/i })).not.toBeDisabled(),
197+
)
198+
screen.getByRole('button', { name: /assign policy/i }).click()
199+
200+
await waitFor(() =>
201+
expect(
202+
screen.getByText(/already assigned to this datasource for all users/i),
203+
).toBeInTheDocument(),
204+
)
205+
})
206+
})
207+
208+
// ===== DatasourceAssignmentsReadonly =====
209+
210+
describe('DatasourceAssignmentsReadonly', () => {
74211
it('shows "No policies assigned yet" when empty', async () => {
75212
mockListDsPolicies.mockResolvedValue([])
76-
renderWithProviders(<PolicyAssignmentPanel datasourceId="ds-1" />, { authenticated: true })
213+
renderWithProviders(<DatasourceAssignmentsReadonly datasourceId="ds-1" />, {
214+
authenticated: true,
215+
})
77216
await waitFor(() =>
78217
expect(screen.getByText('No policies assigned yet.')).toBeInTheDocument(),
79218
)
@@ -86,41 +225,32 @@ describe('PolicyAssignmentPanel', () => {
86225
priority: 100,
87226
})
88227
mockListDsPolicies.mockResolvedValue([a])
89-
renderWithProviders(<PolicyAssignmentPanel datasourceId="ds-1" />, { authenticated: true })
228+
renderWithProviders(<DatasourceAssignmentsReadonly datasourceId="ds-1" />, {
229+
authenticated: true,
230+
})
90231
await waitFor(() => expect(screen.getByText('row-filter')).toBeInTheDocument())
91232
const link = screen.getByRole('link', { name: 'row-filter' })
92233
expect(link).toHaveAttribute('href', '/policies/p-99/edit')
93234
})
94235

95-
it('shows the add assignment form with policy, user, and priority fields', async () => {
96-
renderWithProviders(<PolicyAssignmentPanel datasourceId="ds-1" />, { authenticated: true })
236+
it('shows the "Manage assignments from the policy edit page" note', async () => {
237+
renderWithProviders(<DatasourceAssignmentsReadonly datasourceId="ds-1" />, {
238+
authenticated: true,
239+
})
97240
await waitFor(() =>
98-
expect(screen.getByRole('button', { name: /assign policy/i })).toBeInTheDocument(),
241+
expect(
242+
screen.getByText(/manage assignments from the policy edit page/i),
243+
).toBeInTheDocument(),
99244
)
100-
expect(screen.getByRole('spinbutton')).toBeInTheDocument() // priority number input
101-
})
102-
103-
it('populates policy dropdown from listPolicies', async () => {
104-
const policy = makePolicy({ id: 'p-1', name: 'deny-cols', policy_type: 'column_deny' })
105-
mockListPolicies.mockResolvedValue({ data: [policy], total: 1, page: 1, page_size: 100 })
106-
renderWithProviders(<PolicyAssignmentPanel datasourceId="ds-1" />, { authenticated: true })
107-
await waitFor(() => expect(screen.getByText(/deny-cols.*column_deny/)).toBeInTheDocument())
108245
})
109246

110-
it('populates user dropdown from listUsers', async () => {
111-
const user = makeUser({ id: 'u-1', username: 'bob' })
112-
mockListUsers.mockResolvedValue({ data: [user], total: 1, page: 1, page_size: 100 })
113-
renderWithProviders(<PolicyAssignmentPanel datasourceId="ds-1" />, { authenticated: true })
114-
await waitFor(() => expect(screen.getByText('bob')).toBeInTheDocument())
115-
})
116-
117-
it('renders a Remove button for each assignment', async () => {
118-
const assignments = [
119-
makePolicyAssignment({ id: 'a-1', policy_name: 'p1' }),
120-
makePolicyAssignment({ id: 'a-2', policy_name: 'p2' }),
121-
]
122-
mockListDsPolicies.mockResolvedValue(assignments)
123-
renderWithProviders(<PolicyAssignmentPanel datasourceId="ds-1" />, { authenticated: true })
124-
await waitFor(() => expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(2))
247+
it('does not render a Remove button', async () => {
248+
const a = makePolicyAssignment({ policy_name: 'deny-cols' })
249+
mockListDsPolicies.mockResolvedValue([a])
250+
renderWithProviders(<DatasourceAssignmentsReadonly datasourceId="ds-1" />, {
251+
authenticated: true,
252+
})
253+
await waitFor(() => expect(screen.getByText('deny-cols')).toBeInTheDocument())
254+
expect(screen.queryByRole('button', { name: /remove/i })).toBeNull()
125255
})
126256
})

0 commit comments

Comments
 (0)