Skip to content

Commit f8cf74e

Browse files
Adds UI unit tests
Signed-off-by: Darshit Chanpura <dchanp@amazon.com>
1 parent a26401f commit f8cf74e

File tree

3 files changed

+392
-1
lines changed

3 files changed

+392
-1
lines changed

public/apps/resource-sharing/_index.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
// stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors
6+
// stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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 { renderApp } from '../resource-access-management-app';
21+
import React from 'react';
22+
23+
function mockCoreStart() {
24+
return {
25+
http: {} as any,
26+
notifications: { toasts: { addError: jest.fn(), addSuccess: jest.fn() } } as any,
27+
} as any;
28+
}
29+
30+
function mockDepsStart(withTopNav = true) {
31+
const TopNavMenu = ({ appName }: any) => <div data-test-subj="top-nav">{appName}</div>;
32+
return withTopNav
33+
? ({ navigation: { ui: { TopNavMenu } } } as any)
34+
: ({ navigation: { ui: {} } } as any);
35+
}
36+
37+
describe('ResourceAccessManagementApp', () => {
38+
it('renders TopNav when present and the page title', () => {
39+
const elem = document.createElement('div');
40+
document.body.appendChild(elem); // because we render a detached div, we need to attach it to the body for the test to work properly
41+
42+
// Render the app into the detached div
43+
44+
const unmount = renderApp(
45+
mockCoreStart(),
46+
mockDepsStart(true),
47+
{ element: elem } as any,
48+
{} as any,
49+
''
50+
);
51+
52+
// Rendered into elem, so query inside it
53+
expect(elem.querySelector('[data-test-subj="top-nav"]')).toBeInTheDocument();
54+
expect(elem.textContent).toContain('Resource Access Management');
55+
56+
unmount();
57+
});
58+
59+
it('omits TopNav when not provided', () => {
60+
const elem = document.createElement('div');
61+
62+
const unmount = renderApp(
63+
mockCoreStart(),
64+
mockDepsStart(false),
65+
{ element: elem } as any,
66+
{} as any,
67+
''
68+
);
69+
70+
expect(elem.querySelector('[data-test-subj="top-nav"]')).toBeNull();
71+
expect(elem.textContent).toContain('Resource Access Management');
72+
73+
unmount();
74+
});
75+
});
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
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

Comments
 (0)