|
| 1 | +/* |
| 2 | + * Copyright OpenSearch Contributors |
| 3 | + * |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"). |
| 5 | + * You may not use this file except in compliance with the License. |
| 6 | + * A copy of the License is located at |
| 7 | + * |
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * or in the "license" file accompanying this file. This file is distributed |
| 11 | + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either |
| 12 | + * express or implied. See the License for the specific language governing |
| 13 | + * permissions and limitations under the License. |
| 14 | + */ |
| 15 | + |
| 16 | +/** |
| 17 | + * @jest-environment jsdom |
| 18 | + */ |
| 19 | +import '@testing-library/jest-dom'; |
| 20 | +import React from 'react'; |
| 21 | +import { render, screen, within, waitFor } from '@testing-library/react'; |
| 22 | +import userEvent from '@testing-library/user-event'; |
| 23 | +import { ResourceSharingPanel } from '../resource-sharing-panel'; |
| 24 | +import { I18nProvider } from '@osd/i18n/react'; |
| 25 | + |
| 26 | +function renderWithI18n(ui: React.ReactElement) { |
| 27 | + return render(<I18nProvider>{ui}</I18nProvider>); |
| 28 | +} |
| 29 | + |
| 30 | +const toasts = { |
| 31 | + addError: jest.fn(), |
| 32 | + addSuccess: jest.fn(), |
| 33 | + addWarning: jest.fn(), |
| 34 | +}; |
| 35 | + |
| 36 | +const typesPayload = [ |
| 37 | + { |
| 38 | + type: 'org.opensearch.anomaly.AnomalyDetector', |
| 39 | + index: '.opensearch-anomaly-detector', |
| 40 | + action_groups: ['READ', 'WRITE'], |
| 41 | + }, |
| 42 | + { |
| 43 | + type: 'org.opensearch.ml.Forecaster', |
| 44 | + index: '.plugins-ml-forecaster', |
| 45 | + action_groups: ['READ_ONLY'], |
| 46 | + }, |
| 47 | +]; |
| 48 | + |
| 49 | +const rowsPayload = [ |
| 50 | + { |
| 51 | + resource_id: 'det-1', |
| 52 | + resource_type: '.opensearch-anomaly-detector', |
| 53 | + created_by: { user: 'alice', tenant: 'global' }, |
| 54 | + share_with: undefined, |
| 55 | + can_share: true, |
| 56 | + }, |
| 57 | + { |
| 58 | + resource_id: 'det-2', |
| 59 | + resource_type: '.opensearch-anomaly-detector', |
| 60 | + created_by: { user: 'bob' }, |
| 61 | + share_with: { READ: { users: ['charlie'], roles: [], backend_roles: [] } }, |
| 62 | + can_share: false, |
| 63 | + }, |
| 64 | +]; |
| 65 | + |
| 66 | +describe('ResourceSharingPanel', () => { |
| 67 | + beforeEach(() => { |
| 68 | + jest.clearAllMocks(); |
| 69 | + }); |
| 70 | + |
| 71 | + it('shows guidance before a type is selected; loads types once', async () => { |
| 72 | + const api = { |
| 73 | + listTypes: jest.fn().mockResolvedValue({ types: typesPayload }), |
| 74 | + listSharingRecords: jest.fn(), |
| 75 | + getSharingRecord: jest.fn(), |
| 76 | + share: jest.fn(), |
| 77 | + update: jest.fn(), |
| 78 | + }; |
| 79 | + |
| 80 | + renderWithI18n(<ResourceSharingPanel api={api as any} toasts={toasts as any} />); |
| 81 | + |
| 82 | + expect( |
| 83 | + await screen.findByText( |
| 84 | + /Pick a resource type from the dropdown to load accessible resources/i |
| 85 | + ) |
| 86 | + ).toBeInTheDocument(); |
| 87 | + |
| 88 | + expect(api.listTypes).toHaveBeenCalledTimes(1); |
| 89 | + |
| 90 | + // SuperSelect placeholder visible |
| 91 | + expect(screen.getByText('Select a type…')).toBeInTheDocument(); |
| 92 | + }); |
| 93 | + |
| 94 | + it('selecting a type loads records and renders table rows', async () => { |
| 95 | + const api = { |
| 96 | + listTypes: jest.fn().mockResolvedValue({ types: typesPayload }), |
| 97 | + listSharingRecords: jest.fn().mockResolvedValue({ resources: rowsPayload }), |
| 98 | + getSharingRecord: jest.fn(), |
| 99 | + share: jest.fn(), |
| 100 | + update: jest.fn(), |
| 101 | + }; |
| 102 | + |
| 103 | + renderWithI18n(<ResourceSharingPanel api={api as any} toasts={toasts as any} />); |
| 104 | + |
| 105 | + // Open the SuperSelect and pick "Anomaly Detector" |
| 106 | + const selectTrigger = await screen.findByText('Select a type…'); |
| 107 | + await userEvent.click(selectTrigger); // <-- await userEvent |
| 108 | + await userEvent.click(await screen.findByText('Anomaly Detector')); |
| 109 | + |
| 110 | + // Wait for the API to be called AND for React to commit the state update |
| 111 | + await waitFor(() => { |
| 112 | + expect(api.listSharingRecords).toHaveBeenCalledWith('.opensearch-anomaly-detector'); |
| 113 | + }); |
| 114 | + |
| 115 | + // Now assert via actual visible content instead of data-testid |
| 116 | + const table = await screen.findByRole('table'); |
| 117 | + expect(await within(table).findByText('det-1')).toBeInTheDocument(); |
| 118 | + expect(within(table).getByText('det-2')).toBeInTheDocument(); |
| 119 | + |
| 120 | + // Shared-with summary |
| 121 | + expect(within(table).getByText(/Not shared/i)).toBeInTheDocument(); |
| 122 | + expect(within(table).getByText(/1 action-group/i)).toBeInTheDocument(); |
| 123 | + |
| 124 | + const row1 = within(table).getByText('det-1').closest('tr')!; |
| 125 | + const row2 = within(table).getByText('det-2').closest('tr')!; |
| 126 | + expect(within(row1).getByRole('button', { name: /Share/i })).toBeEnabled(); |
| 127 | + expect(within(row2).getByRole('button', { name: /Update Access/i })).toBeDisabled(); |
| 128 | + }); |
| 129 | + |
| 130 | + it('opens Share modal and validates empty state; submits to share()', async () => { |
| 131 | + const api = { |
| 132 | + listTypes: jest.fn().mockResolvedValue({ types: typesPayload }), |
| 133 | + listSharingRecords: jest.fn().mockResolvedValue({ resources: rowsPayload }), |
| 134 | + getSharingRecord: jest.fn(), |
| 135 | + share: jest.fn().mockResolvedValue({ ok: true }), |
| 136 | + update: jest.fn(), |
| 137 | + }; |
| 138 | + |
| 139 | + renderWithI18n(<ResourceSharingPanel api={api as any} toasts={toasts as any} />); |
| 140 | + |
| 141 | + // Select the detector type |
| 142 | + const selectTrigger = await screen.findByText('Select a type…'); |
| 143 | + await userEvent.click(selectTrigger); |
| 144 | + await userEvent.click(await screen.findByText('Anomaly Detector')); |
| 145 | + |
| 146 | + await waitFor(() => { |
| 147 | + expect(api.listSharingRecords).toHaveBeenCalledWith('.opensearch-anomaly-detector'); |
| 148 | + }); |
| 149 | + |
| 150 | + // Open Share modal from row det-1 (not shared) |
| 151 | + await userEvent.click(await screen.findByRole('button', { name: /Share/i })); |
| 152 | + |
| 153 | + // Wait for the modal overlay to exist (EUI portals) |
| 154 | + await waitFor(() => { |
| 155 | + const overlay = document.querySelector('.euiOverlayMask'); |
| 156 | + if (!overlay) throw new Error('Modal overlay not present yet'); |
| 157 | + }); |
| 158 | + |
| 159 | + // Now scope queries strictly to the modal overlay |
| 160 | + const overlay = document.querySelector('.euiOverlayMask') as HTMLElement; |
| 161 | + |
| 162 | + // Header text is "Share Resource" in create mode — optional assertion |
| 163 | + expect(within(overlay).getByText(/Share Resource/i)).toBeInTheDocument(); |
| 164 | + |
| 165 | + // The primary button in the modal footer (initially disabled) |
| 166 | + const modalShareBtn = within(overlay).getByRole('button', { name: /^Share$/ }); |
| 167 | + expect(modalShareBtn).toBeDisabled(); |
| 168 | + |
| 169 | + // Add an action-group |
| 170 | + const addGroup = within(overlay).getByRole('button', { name: /Add action-group/i }); |
| 171 | + await userEvent.click(addGroup); |
| 172 | + |
| 173 | + // Comboboxes inside the modal: [0] action-group, [1] Users |
| 174 | + const combos = within(overlay).getAllByRole('combobox'); |
| 175 | + await userEvent.click(combos[0]); // focus action-group (defaults to first suggestion) |
| 176 | + const usersInput = within(overlay).getByText('Add users…'); |
| 177 | + // type then press Enter to trigger onCreateOption |
| 178 | + await userEvent.type(usersInput, 'dc'); |
| 179 | + await userEvent.tab(); // focus out |
| 180 | + |
| 181 | + await waitFor(() => expect(modalShareBtn).toBeEnabled()); |
| 182 | + |
| 183 | + // Submit |
| 184 | + await userEvent.click(modalShareBtn); |
| 185 | + await waitFor(() => expect(api.share).toHaveBeenCalledTimes(1)); |
| 186 | + await waitFor(() => expect(api.listSharingRecords).toHaveBeenCalledTimes(2)); |
| 187 | + expect(toasts.addSuccess).toHaveBeenCalledWith('Resource shared.'); |
| 188 | + }); |
| 189 | + |
| 190 | + it('opens Update Access modal, computes add/revoke diff and calls update()', async () => { |
| 191 | + const api = { |
| 192 | + listTypes: jest.fn().mockResolvedValue({ types: typesPayload }), |
| 193 | + listSharingRecords: jest.fn().mockResolvedValue({ |
| 194 | + resources: [ |
| 195 | + { |
| 196 | + ...rowsPayload[1], |
| 197 | + // pre-existing share_with so we get "Update Access" |
| 198 | + share_with: { READ: { users: ['charlie'], roles: ['roleA'], backend_roles: [] } }, |
| 199 | + can_share: true, |
| 200 | + }, |
| 201 | + ], |
| 202 | + }), |
| 203 | + getSharingRecord: jest.fn(), |
| 204 | + share: jest.fn(), |
| 205 | + update: jest.fn().mockResolvedValue({ ok: true }), |
| 206 | + }; |
| 207 | + |
| 208 | + renderWithI18n(<ResourceSharingPanel api={api as any} toasts={toasts as any} />); |
| 209 | + |
| 210 | + await userEvent.click(await screen.findByText('Select a type…')); |
| 211 | + await userEvent.click(await screen.findByText('Anomaly Detector')); |
| 212 | + |
| 213 | + await waitFor(() => { |
| 214 | + expect(api.listSharingRecords).toHaveBeenCalledWith('.opensearch-anomaly-detector'); |
| 215 | + }); |
| 216 | + |
| 217 | + // Open update modal on det-2 |
| 218 | + await userEvent.click(await screen.findByRole('button', { name: /Update Access/i })); |
| 219 | + |
| 220 | + // Wait for the modal overlay to exist (EUI portals) |
| 221 | + await waitFor(() => { |
| 222 | + const overlay = document.querySelector('.euiOverlayMask'); |
| 223 | + if (!overlay) throw new Error('Modal overlay not present yet'); |
| 224 | + }); |
| 225 | + |
| 226 | + // Now scope queries strictly to the modal overlay |
| 227 | + const overlay = document.querySelector('.euiOverlayMask') as HTMLElement; |
| 228 | + |
| 229 | + // Header text is "Share Resource" in create mode — optional assertion |
| 230 | + expect(within(overlay).getByRole('button', { name: 'Update Access' })).toBeInTheDocument(); |
| 231 | + |
| 232 | + // The primary button in the modal footer (initially disabled) |
| 233 | + const modalShareBtn = within(overlay).getByRole('button', { name: /^Update Access$/ }); |
| 234 | + expect(modalShareBtn).toBeDisabled(); |
| 235 | + |
| 236 | + // Comboboxes inside the modal: [0] action-group, [1] Users |
| 237 | + // Remove charlie and add erin -> should form add/remove diff |
| 238 | + const combos = within(overlay).getAllByRole('combobox'); |
| 239 | + await userEvent.click(combos[0]); |
| 240 | + |
| 241 | + // Remove 'charlie' by clearing tags then add 'erin' |
| 242 | + // Simple strategy: replace via typing new value and enter; then remove the old pill via Backspace |
| 243 | + const usersInput = within(overlay).getByText('charlie'); |
| 244 | + await userEvent.type(usersInput, '{Backspace}{Backspace}erin{enter}'); // simulate clearing last token and new entry |
| 245 | + await userEvent.tab(); // focus out |
| 246 | + |
| 247 | + const updateBtn = within(overlay).getByRole('button', { name: /Update Access/i }); |
| 248 | + await waitFor(() => expect(updateBtn).toBeEnabled()); |
| 249 | + await userEvent.click(updateBtn); |
| 250 | + |
| 251 | + await waitFor(() => expect(api.update).toHaveBeenCalledTimes(1)); |
| 252 | + const payload = (api.update as jest.Mock).mock.calls[0][0]; |
| 253 | + expect(payload.resource_id).toBe('det-2'); |
| 254 | + expect(payload.resource_type).toBe('.opensearch-anomaly-detector'); |
| 255 | + // Ensure both add and revoke present (diff logic) |
| 256 | + expect(payload.add?.READ?.users).toEqual(['erin']); |
| 257 | + expect(payload.revoke?.READ?.users).toEqual(['charlie']); |
| 258 | + |
| 259 | + expect(toasts.addSuccess).toHaveBeenCalledWith('Access updated.'); |
| 260 | + }); |
| 261 | + |
| 262 | + it('renders friendly error lines when backend returns structured errors', async () => { |
| 263 | + const api = { |
| 264 | + listTypes: jest.fn().mockResolvedValue({ types: typesPayload }), |
| 265 | + listSharingRecords: jest.fn().mockResolvedValue({ resources: rowsPayload }), |
| 266 | + getSharingRecord: jest.fn(), |
| 267 | + share: jest.fn().mockRejectedValue({ |
| 268 | + response: { status: 403, statusText: 'Forbidden' }, |
| 269 | + body: JSON.stringify({ |
| 270 | + statusCode: 403, |
| 271 | + error: 'Forbidden', |
| 272 | + message: 'You are not allowed to share this resource', |
| 273 | + }), |
| 274 | + }), |
| 275 | + update: jest.fn(), |
| 276 | + }; |
| 277 | + |
| 278 | + renderWithI18n(<ResourceSharingPanel api={api as any} toasts={toasts as any} />); |
| 279 | + |
| 280 | + await userEvent.click(await screen.findByText('Select a type…')); |
| 281 | + await userEvent.click(await screen.findByText('Anomaly Detector')); |
| 282 | + |
| 283 | + await waitFor(() => { |
| 284 | + expect(api.listSharingRecords).toHaveBeenCalledWith('.opensearch-anomaly-detector'); |
| 285 | + }); |
| 286 | + |
| 287 | + // Open update modal on det-2 |
| 288 | + await userEvent.click(await screen.findByRole('button', { name: /Share/i })); |
| 289 | + // Wait for the modal overlay to exist (EUI portals) |
| 290 | + await waitFor(() => { |
| 291 | + const overlay = document.querySelector('.euiOverlayMask'); |
| 292 | + if (!overlay) throw new Error('Modal overlay not present yet'); |
| 293 | + }); |
| 294 | + |
| 295 | + // Now scope queries strictly to the modal overlay |
| 296 | + const overlay = document.querySelector('.euiOverlayMask') as HTMLElement; |
| 297 | + |
| 298 | + expect(within(overlay).getByRole('button', { name: 'Share' })).toBeInTheDocument(); |
| 299 | + |
| 300 | + // Add minimal valid recipients |
| 301 | + await userEvent.click(within(overlay).getByRole('button', { name: /Add action-group/i })); |
| 302 | + const usersInput = within(overlay).getByText('Add users…'); |
| 303 | + // type then press Enter to trigger onCreateOption |
| 304 | + await userEvent.type(usersInput, 'dc'); |
| 305 | + await userEvent.tab(); // focus out |
| 306 | + |
| 307 | + await userEvent.click(within(overlay).getByRole('button', { name: /^Share$/ })); |
| 308 | + |
| 309 | + // Expect callout with deduped lines |
| 310 | + expect(await within(overlay).findByText(/Request failed/i)).toBeInTheDocument(); |
| 311 | + expect(within(overlay).getByText(/403 Forbidden/)).toBeInTheDocument(); |
| 312 | + expect( |
| 313 | + within(overlay).getByText(/You are not allowed to share this resource/) |
| 314 | + ).toBeInTheDocument(); |
| 315 | + }); |
| 316 | +}); |
0 commit comments