PluginDetails.test.tsx 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813
  1. import { getDefaultNormalizer, render, RenderResult, SelectorMatcherOptions, waitFor } from '@testing-library/react';
  2. import userEvent from '@testing-library/user-event';
  3. import React from 'react';
  4. import { Provider } from 'react-redux';
  5. import { MemoryRouter } from 'react-router-dom';
  6. import { PluginErrorCode, PluginSignatureStatus, PluginType, dateTimeFormatTimeAgo } from '@grafana/data';
  7. import { selectors } from '@grafana/e2e-selectors';
  8. import { config } from '@grafana/runtime';
  9. import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
  10. import { configureStore } from 'app/store/configureStore';
  11. import { mockPluginApis, getCatalogPluginMock, getPluginsStateMock, mockUserPermissions } from '../__mocks__';
  12. import * as api from '../api';
  13. import { usePluginConfig } from '../hooks/usePluginConfig';
  14. import { fetchRemotePlugins } from '../state/actions';
  15. import {
  16. CatalogPlugin,
  17. CatalogPluginDetails,
  18. PluginTabIds,
  19. PluginTabLabels,
  20. ReducerState,
  21. RequestStatus,
  22. } from '../types';
  23. import PluginDetailsPage from './PluginDetails';
  24. jest.mock('@grafana/runtime', () => {
  25. const original = jest.requireActual('@grafana/runtime');
  26. const mockedRuntime = { ...original };
  27. mockedRuntime.config.buildInfo.version = 'v8.1.0';
  28. return mockedRuntime;
  29. });
  30. jest.mock('../hooks/usePluginConfig.tsx', () => ({
  31. usePluginConfig: jest.fn(() => ({
  32. value: {
  33. meta: {},
  34. },
  35. })),
  36. }));
  37. jest.mock('../helpers.ts', () => ({
  38. ...jest.requireActual('../helpers.ts'),
  39. updatePanels: jest.fn(),
  40. }));
  41. const renderPluginDetails = (
  42. pluginOverride: Partial<CatalogPlugin>,
  43. {
  44. pageId,
  45. pluginsStateOverride,
  46. }: {
  47. pageId?: PluginTabIds;
  48. pluginsStateOverride?: ReducerState;
  49. } = {}
  50. ): RenderResult => {
  51. const plugin = getCatalogPluginMock(pluginOverride);
  52. const { id } = plugin;
  53. const props = getRouteComponentProps({
  54. match: { params: { pluginId: id }, isExact: true, url: '', path: '' },
  55. queryParams: { page: pageId },
  56. location: {
  57. hash: '',
  58. pathname: `/plugins/${id}`,
  59. search: pageId ? `?page=${pageId}` : '',
  60. state: undefined,
  61. },
  62. });
  63. const store = configureStore({
  64. plugins: pluginsStateOverride || getPluginsStateMock([plugin]),
  65. });
  66. return render(
  67. <MemoryRouter>
  68. <Provider store={store}>
  69. <PluginDetailsPage {...props} />
  70. </Provider>
  71. </MemoryRouter>
  72. );
  73. };
  74. describe('Plugin details page', () => {
  75. const id = 'my-plugin';
  76. const originalWindowLocation = window.location;
  77. let dateNow: any;
  78. beforeAll(() => {
  79. dateNow = jest.spyOn(Date, 'now').mockImplementation(() => 1609470000000); // 2021-01-01 04:00:00
  80. // Enabling / disabling the plugin is currently reloading the page to propagate the changes
  81. Object.defineProperty(window, 'location', {
  82. configurable: true,
  83. value: { reload: jest.fn() },
  84. });
  85. });
  86. afterEach(() => {
  87. jest.clearAllMocks();
  88. config.pluginAdminExternalManageEnabled = false;
  89. config.licenseInfo.enabledFeatures = {};
  90. });
  91. afterAll(() => {
  92. dateNow.mockRestore();
  93. Object.defineProperty(window, 'location', { configurable: true, value: originalWindowLocation });
  94. });
  95. describe('viewed as user with grafana admin permissions', () => {
  96. beforeAll(() => {
  97. mockUserPermissions({
  98. isAdmin: true,
  99. isDataSourceEditor: true,
  100. isOrgAdmin: true,
  101. });
  102. });
  103. // We are doing this very basic test to see if the API fetching and data-munging is working correctly from a high-level.
  104. it('(SMOKE TEST) - should fetch and merge the remote and local plugin API responses correctly ', async () => {
  105. const id = 'smoke-test-plugin';
  106. mockPluginApis({
  107. remote: { slug: id },
  108. local: { id },
  109. });
  110. const props = getRouteComponentProps({
  111. match: { params: { pluginId: id }, isExact: true, url: '', path: '' },
  112. queryParams: {},
  113. location: {
  114. hash: '',
  115. pathname: `/plugins/${id}`,
  116. search: '',
  117. state: undefined,
  118. },
  119. });
  120. const store = configureStore();
  121. const { queryByText } = render(
  122. <MemoryRouter>
  123. <Provider store={store}>
  124. <PluginDetailsPage {...props} />
  125. </Provider>
  126. ,
  127. </MemoryRouter>
  128. );
  129. await waitFor(() => expect(queryByText(/licensed under the apache 2.0 license/i)).toBeInTheDocument());
  130. });
  131. it('should display an overview (plugin readme) by default', async () => {
  132. const { queryByText } = renderPluginDetails({ id });
  133. await waitFor(() => expect(queryByText(/licensed under the apache 2.0 license/i)).toBeInTheDocument());
  134. });
  135. it('should display an app config page by default for installed app plugins', async () => {
  136. const name = 'Akumuli';
  137. // @ts-ignore
  138. usePluginConfig.mockReturnValue({
  139. value: {
  140. meta: {
  141. type: PluginType.app,
  142. enabled: false,
  143. pinned: false,
  144. jsonData: {},
  145. },
  146. configPages: [
  147. {
  148. title: 'Config',
  149. icon: 'cog',
  150. id: 'configPage',
  151. body: function ConfigPage() {
  152. return <div>Custom Config Page!</div>;
  153. },
  154. },
  155. ],
  156. },
  157. });
  158. const { queryByText } = renderPluginDetails({
  159. name,
  160. isInstalled: true,
  161. type: PluginType.app,
  162. });
  163. await waitFor(() => expect(queryByText(/custom config page/i)).toBeInTheDocument());
  164. });
  165. it('should display the number of downloads in the header', async () => {
  166. // depending on what locale you have the Intl.NumberFormat will return a format that contains
  167. // whitespaces. In that case we don't want testing library to remove whitespaces.
  168. const downloads = 24324;
  169. const options: SelectorMatcherOptions = { normalizer: getDefaultNormalizer({ collapseWhitespace: false }) };
  170. const expected = new Intl.NumberFormat().format(downloads);
  171. const { queryByText } = renderPluginDetails({ id, downloads });
  172. await waitFor(() => expect(queryByText(expected, options)).toBeInTheDocument());
  173. });
  174. it('should display the installed version if a plugin is installed', async () => {
  175. const installedVersion = '1.3.443';
  176. const { queryByText } = renderPluginDetails({ id, installedVersion });
  177. await waitFor(() => expect(queryByText(installedVersion)).toBeInTheDocument());
  178. });
  179. it('should display the latest compatible version in the header if a plugin is not installed', async () => {
  180. const details: CatalogPluginDetails = {
  181. links: [],
  182. versions: [
  183. { version: '1.3.0', createdAt: '', isCompatible: false, grafanaDependency: '>=9.0.0' },
  184. { version: '1.2.0', createdAt: '', isCompatible: false, grafanaDependency: '>=8.3.0' },
  185. { version: '1.1.1', createdAt: '', isCompatible: true, grafanaDependency: '>=8.0.0' },
  186. { version: '1.1.0', createdAt: '', isCompatible: true, grafanaDependency: '>=8.0.0' },
  187. { version: '1.0.0', createdAt: '', isCompatible: true, grafanaDependency: '>=7.0.0' },
  188. ],
  189. };
  190. const { queryByText } = renderPluginDetails({ id, details });
  191. await waitFor(() => expect(queryByText('1.1.1')).toBeInTheDocument());
  192. await waitFor(() => expect(queryByText(/>=8.0.0/i)).toBeInTheDocument());
  193. });
  194. it('should display description in the header', async () => {
  195. const description = 'This is my description';
  196. const { queryByText } = renderPluginDetails({ id, description });
  197. await waitFor(() => expect(queryByText(description)).toBeInTheDocument());
  198. });
  199. it('should display a "Signed" badge if the plugin signature is verified', async () => {
  200. const { queryByText } = renderPluginDetails({ id, signature: PluginSignatureStatus.valid });
  201. await waitFor(() => expect(queryByText('Signed')).toBeInTheDocument());
  202. });
  203. it('should display a "Missing signature" badge if the plugin signature is missing', async () => {
  204. const { queryByText } = renderPluginDetails({ id, signature: PluginSignatureStatus.missing });
  205. await waitFor(() => expect(queryByText('Missing signature')).toBeInTheDocument());
  206. });
  207. it('should display a "Modified signature" badge if the plugin signature is modified', async () => {
  208. const { queryByText } = renderPluginDetails({ id, signature: PluginSignatureStatus.modified });
  209. await waitFor(() => expect(queryByText('Modified signature')).toBeInTheDocument());
  210. });
  211. it('should display a "Invalid signature" badge if the plugin signature is invalid', async () => {
  212. const { queryByText } = renderPluginDetails({ id, signature: PluginSignatureStatus.invalid });
  213. await waitFor(() => expect(queryByText('Invalid signature')).toBeInTheDocument());
  214. });
  215. it('should display version history if the plugin is published', async () => {
  216. const versions = [
  217. {
  218. version: '1.2.0',
  219. createdAt: '2018-04-06T20:23:41.000Z',
  220. isCompatible: false,
  221. grafanaDependency: '>=8.3.0',
  222. },
  223. {
  224. version: '1.1.0',
  225. createdAt: '2017-04-06T20:23:41.000Z',
  226. isCompatible: true,
  227. grafanaDependency: '>=8.0.0',
  228. },
  229. {
  230. version: '1.0.0',
  231. createdAt: '2016-04-06T20:23:41.000Z',
  232. isCompatible: true,
  233. grafanaDependency: '>=7.0.0',
  234. },
  235. ];
  236. const { queryByText, getByRole } = renderPluginDetails(
  237. {
  238. id,
  239. details: {
  240. links: [],
  241. versions,
  242. },
  243. },
  244. { pageId: PluginTabIds.VERSIONS }
  245. );
  246. // Check if version information is available
  247. await waitFor(() => expect(queryByText(/version history/i)).toBeInTheDocument());
  248. // Check the column headers
  249. expect(getByRole('columnheader', { name: /version/i })).toBeInTheDocument();
  250. expect(getByRole('columnheader', { name: /last updated/i })).toBeInTheDocument();
  251. // Check the data
  252. for (const version of versions) {
  253. expect(getByRole('cell', { name: new RegExp(version.version, 'i') })).toBeInTheDocument();
  254. expect(
  255. getByRole('cell', { name: new RegExp(dateTimeFormatTimeAgo(version.createdAt), 'i') })
  256. ).toBeInTheDocument();
  257. // Check the latest compatible version
  258. expect(queryByText('1.1.0 (latest compatible version)')).toBeInTheDocument();
  259. }
  260. });
  261. it("should display an install button for a plugin that isn't installed", async () => {
  262. const { queryByRole } = renderPluginDetails({ id, isInstalled: false });
  263. await waitFor(() => expect(queryByRole('button', { name: /^install/i })).toBeInTheDocument());
  264. // Does not display "uninstall" button
  265. expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument();
  266. });
  267. it('should display an uninstall button for an already installed plugin', async () => {
  268. const { queryByRole } = renderPluginDetails({ id, isInstalled: true });
  269. await waitFor(() => expect(queryByRole('button', { name: /uninstall/i })).toBeInTheDocument());
  270. // Does not display "install" button
  271. expect(queryByRole('button', { name: /^install/i })).not.toBeInTheDocument();
  272. });
  273. it('should display update and uninstall buttons for a plugin with update', async () => {
  274. const { queryByRole } = renderPluginDetails({ id, isInstalled: true, hasUpdate: true });
  275. // Displays an "update" button
  276. await waitFor(() => expect(queryByRole('button', { name: /update/i })).toBeInTheDocument());
  277. expect(queryByRole('button', { name: /uninstall/i })).toBeInTheDocument();
  278. // Does not display "install" button
  279. expect(queryByRole('button', { name: /^install/i })).not.toBeInTheDocument();
  280. });
  281. it('should display an install button for enterprise plugins if license is valid', async () => {
  282. config.licenseInfo.enabledFeatures = { 'enterprise.plugins': true };
  283. const { queryByRole } = renderPluginDetails({ id, isInstalled: false, isEnterprise: true });
  284. await waitFor(() => expect(queryByRole('button', { name: /install/i })).toBeInTheDocument());
  285. });
  286. it('should not display install button for enterprise plugins if license is invalid', async () => {
  287. config.licenseInfo.enabledFeatures = {};
  288. const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: true, isEnterprise: true });
  289. await waitFor(() => expect(queryByRole('button', { name: /install/i })).not.toBeInTheDocument());
  290. expect(queryByText(/no valid Grafana Enterprise license detected/i)).toBeInTheDocument();
  291. expect(queryByRole('link', { name: /learn more/i })).toBeInTheDocument();
  292. });
  293. it('should not display install / uninstall buttons for core plugins', async () => {
  294. const { queryByRole } = renderPluginDetails({ id, isInstalled: true, isCore: true });
  295. await waitFor(() => expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument());
  296. await waitFor(() => expect(queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
  297. });
  298. it('should not display install / uninstall buttons for disabled plugins', async () => {
  299. const { queryByRole } = renderPluginDetails({ id, isInstalled: true, isDisabled: true });
  300. await waitFor(() => expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument());
  301. await waitFor(() => expect(queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
  302. });
  303. it('should not display install / uninstall buttons for renderer plugins', async () => {
  304. const { queryByRole } = renderPluginDetails({ id, type: PluginType.renderer });
  305. await waitFor(() => expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument());
  306. await waitFor(() => expect(queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
  307. });
  308. it('should display install link with `config.pluginAdminExternalManageEnabled` set to true', async () => {
  309. config.pluginAdminExternalManageEnabled = true;
  310. const { queryByRole } = renderPluginDetails({ id, isInstalled: false });
  311. await waitFor(() => expect(queryByRole('link', { name: /install via grafana.com/i })).toBeInTheDocument());
  312. });
  313. it('should display uninstall link for an installed plugin with `config.pluginAdminExternalManageEnabled` set to true', async () => {
  314. config.pluginAdminExternalManageEnabled = true;
  315. const { queryByRole } = renderPluginDetails({ id, isInstalled: true });
  316. await waitFor(() => expect(queryByRole('link', { name: /uninstall via grafana.com/i })).toBeInTheDocument());
  317. });
  318. it('should display update and uninstall links for a plugin with an available update and `config.pluginAdminExternalManageEnabled` set to true', async () => {
  319. config.pluginAdminExternalManageEnabled = true;
  320. const { queryByRole } = renderPluginDetails({ id, isInstalled: true, hasUpdate: true });
  321. await waitFor(() => expect(queryByRole('link', { name: /update via grafana.com/i })).toBeInTheDocument());
  322. expect(queryByRole('link', { name: /uninstall via grafana.com/i })).toBeInTheDocument();
  323. });
  324. it('should display alert with information about why the plugin is disabled', async () => {
  325. const { queryByLabelText } = renderPluginDetails({
  326. id,
  327. isInstalled: true,
  328. isDisabled: true,
  329. error: PluginErrorCode.modifiedSignature,
  330. });
  331. await waitFor(() => expect(queryByLabelText(selectors.pages.PluginPage.disabledInfo)).toBeInTheDocument());
  332. });
  333. it('should display grafana dependencies for a plugin if they are available', async () => {
  334. const { queryByText } = renderPluginDetails({
  335. id,
  336. details: {
  337. pluginDependencies: [],
  338. grafanaDependency: '>=8.0.0',
  339. links: [],
  340. },
  341. });
  342. // Wait for the dependencies part to be loaded
  343. await waitFor(() => expect(queryByText(/dependencies:/i)).toBeInTheDocument());
  344. expect(queryByText('Grafana >=8.0.0')).toBeInTheDocument();
  345. });
  346. it('should show a confirm modal when trying to uninstall a plugin', async () => {
  347. // @ts-ignore
  348. api.uninstallPlugin = jest.fn();
  349. const { queryByText, getByRole } = renderPluginDetails({
  350. id,
  351. name: 'Akumuli',
  352. isInstalled: true,
  353. details: {
  354. pluginDependencies: [],
  355. grafanaDependency: '>=8.0.0',
  356. links: [],
  357. versions: [
  358. {
  359. version: '1.0.0',
  360. createdAt: '',
  361. isCompatible: true,
  362. grafanaDependency: '>=8.0.0',
  363. },
  364. ],
  365. },
  366. });
  367. // Wait for the install controls to be loaded
  368. await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
  369. // Open the confirmation modal
  370. await userEvent.click(getByRole('button', { name: /uninstall/i }));
  371. expect(queryByText('Uninstall Akumuli')).toBeInTheDocument();
  372. expect(queryByText('Are you sure you want to uninstall this plugin?')).toBeInTheDocument();
  373. expect(api.uninstallPlugin).toHaveBeenCalledTimes(0);
  374. // Confirm the uninstall
  375. await userEvent.click(getByRole('button', { name: /confirm/i }));
  376. expect(api.uninstallPlugin).toHaveBeenCalledTimes(1);
  377. expect(api.uninstallPlugin).toHaveBeenCalledWith(id);
  378. // Check if the modal disappeared
  379. expect(queryByText('Uninstall Akumuli')).not.toBeInTheDocument();
  380. });
  381. it('should not display the install / uninstall / update buttons if the GCOM api is not available', async () => {
  382. let rendered: RenderResult;
  383. const plugin = getCatalogPluginMock({ id });
  384. const state = getPluginsStateMock([plugin]);
  385. // Mock the store like if the remote plugins request was rejected
  386. const pluginsStateOverride = {
  387. ...state,
  388. requests: {
  389. ...state.requests,
  390. [fetchRemotePlugins.typePrefix]: {
  391. status: RequestStatus.Rejected,
  392. },
  393. },
  394. };
  395. // Does not show an Install button
  396. rendered = renderPluginDetails({ id }, { pluginsStateOverride });
  397. await waitFor(() => expect(rendered.queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
  398. rendered.unmount();
  399. // Does not show a Uninstall button
  400. rendered = renderPluginDetails({ id, isInstalled: true }, { pluginsStateOverride });
  401. await waitFor(() => expect(rendered.queryByRole('button', { name: /(un)?install/i })).not.toBeInTheDocument());
  402. rendered.unmount();
  403. // Does not show an Update button
  404. rendered = renderPluginDetails({ id, isInstalled: true, hasUpdate: true }, { pluginsStateOverride });
  405. await waitFor(() => expect(rendered.queryByRole('button', { name: /update/i })).not.toBeInTheDocument());
  406. // Shows a message to the user
  407. // TODO<Import these texts from a single source of truth instead of having them defined in multiple places>
  408. const message = 'The install controls have been disabled because the Grafana server cannot access grafana.com.';
  409. expect(rendered.getByText(message)).toBeInTheDocument();
  410. });
  411. it('should not display the install / uninstall / update buttons if `pluginAdminEnabled` flag is set to FALSE in the Grafana config', async () => {
  412. let rendered: RenderResult;
  413. // Disable the install controls for the plugins catalog
  414. config.pluginAdminEnabled = false;
  415. // Should not show an "Install" button
  416. rendered = renderPluginDetails({ id, isInstalled: false });
  417. await waitFor(() => expect(rendered.queryByRole('button', { name: /^install/i })).not.toBeInTheDocument());
  418. // Should not show an "Uninstall" button
  419. rendered = renderPluginDetails({ id, isInstalled: true });
  420. await waitFor(() => expect(rendered.queryByRole('button', { name: /^uninstall/i })).not.toBeInTheDocument());
  421. // Should not show an "Update" button
  422. rendered = renderPluginDetails({ id, isInstalled: true, hasUpdate: true });
  423. await waitFor(() => expect(rendered.queryByRole('button', { name: /^update/i })).not.toBeInTheDocument());
  424. });
  425. it('should display a "Create" button as a post installation step for installed data source plugins', async () => {
  426. const name = 'Akumuli';
  427. const { queryByText } = renderPluginDetails({
  428. name,
  429. isInstalled: true,
  430. type: PluginType.datasource,
  431. });
  432. await waitFor(() => queryByText('Uninstall'));
  433. expect(queryByText(`Create a ${name} data source`)).toBeInTheDocument();
  434. });
  435. it('should not display a "Create" button as a post installation step for disabled data source plugins', async () => {
  436. const name = 'Akumuli';
  437. const { queryByText } = renderPluginDetails({
  438. name,
  439. isInstalled: true,
  440. isDisabled: true,
  441. type: PluginType.datasource,
  442. });
  443. await waitFor(() => queryByText('Uninstall'));
  444. expect(queryByText(`Create a ${name} data source`)).toBeNull();
  445. });
  446. it('should not display post installation step for panel plugins', async () => {
  447. const name = 'Akumuli';
  448. const { queryByText } = renderPluginDetails({
  449. name,
  450. isInstalled: true,
  451. type: PluginType.panel,
  452. });
  453. await waitFor(() => queryByText('Uninstall'));
  454. expect(queryByText(`Create a ${name} data source`)).toBeNull();
  455. });
  456. it('should display an enable button for app plugins that are not enabled as a post installation step', async () => {
  457. const name = 'Akumuli';
  458. // @ts-ignore
  459. usePluginConfig.mockReturnValue({
  460. value: {
  461. meta: {
  462. enabled: false,
  463. pinned: false,
  464. jsonData: {},
  465. },
  466. },
  467. });
  468. const { queryByText, queryByRole } = renderPluginDetails({
  469. name,
  470. isInstalled: true,
  471. type: PluginType.app,
  472. });
  473. await waitFor(() => queryByText('Uninstall'));
  474. expect(queryByRole('button', { name: /enable/i })).toBeInTheDocument();
  475. expect(queryByRole('button', { name: /disable/i })).not.toBeInTheDocument();
  476. });
  477. it('should display a disable button for app plugins that are enabled as a post installation step', async () => {
  478. const name = 'Akumuli';
  479. // @ts-ignore
  480. usePluginConfig.mockReturnValue({
  481. value: {
  482. meta: {
  483. enabled: true,
  484. pinned: false,
  485. jsonData: {},
  486. },
  487. },
  488. });
  489. const { queryByText, queryByRole } = renderPluginDetails({
  490. name,
  491. isInstalled: true,
  492. type: PluginType.app,
  493. });
  494. await waitFor(() => queryByText('Uninstall'));
  495. expect(queryByRole('button', { name: /disable/i })).toBeInTheDocument();
  496. expect(queryByRole('button', { name: /enable/i })).not.toBeInTheDocument();
  497. });
  498. it('should be possible to enable an app plugin', async () => {
  499. const id = 'akumuli-datasource';
  500. const name = 'Akumuli';
  501. // @ts-ignore
  502. api.updatePluginSettings = jest.fn();
  503. // @ts-ignore
  504. usePluginConfig.mockReturnValue({
  505. value: {
  506. meta: {
  507. enabled: false,
  508. pinned: false,
  509. jsonData: {},
  510. },
  511. },
  512. });
  513. const { queryByText, getByRole } = renderPluginDetails({
  514. id,
  515. name,
  516. isInstalled: true,
  517. type: PluginType.app,
  518. });
  519. // Wait for the header to be loaded
  520. await waitFor(() => queryByText('Uninstall'));
  521. // Click on "Enable"
  522. await userEvent.click(getByRole('button', { name: /enable/i }));
  523. // Check if the API request was initiated
  524. expect(api.updatePluginSettings).toHaveBeenCalledTimes(1);
  525. expect(api.updatePluginSettings).toHaveBeenCalledWith(id, {
  526. enabled: true,
  527. pinned: true,
  528. jsonData: {},
  529. });
  530. });
  531. it('should be possible to disable an app plugin', async () => {
  532. const id = 'akumuli-datasource';
  533. const name = 'Akumuli';
  534. // @ts-ignore
  535. api.updatePluginSettings = jest.fn();
  536. // @ts-ignore
  537. usePluginConfig.mockReturnValue({
  538. value: {
  539. meta: {
  540. enabled: true,
  541. pinned: true,
  542. jsonData: {},
  543. },
  544. },
  545. });
  546. const { queryByText, getByRole } = renderPluginDetails({
  547. id,
  548. name,
  549. isInstalled: true,
  550. type: PluginType.app,
  551. });
  552. // Wait for the header to be loaded
  553. await waitFor(() => queryByText('Uninstall'));
  554. // Click on "Disable"
  555. await userEvent.click(getByRole('button', { name: /disable/i }));
  556. // Check if the API request was initiated
  557. expect(api.updatePluginSettings).toHaveBeenCalledTimes(1);
  558. expect(api.updatePluginSettings).toHaveBeenCalledWith(id, {
  559. enabled: false,
  560. pinned: false,
  561. jsonData: {},
  562. });
  563. });
  564. it('should not display versions tab for plugins not published to gcom', async () => {
  565. const { queryByText } = renderPluginDetails({
  566. name: 'Akumuli',
  567. isInstalled: true,
  568. type: PluginType.app,
  569. isPublished: false,
  570. });
  571. await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
  572. expect(queryByText(PluginTabLabels.VERSIONS)).toBeNull();
  573. });
  574. it('should not display update for plugins not published to gcom', async () => {
  575. const { queryByText, queryByRole } = renderPluginDetails({
  576. name: 'Akumuli',
  577. isInstalled: true,
  578. hasUpdate: true,
  579. type: PluginType.app,
  580. isPublished: false,
  581. });
  582. await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
  583. expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
  584. });
  585. it('should not display install for plugins not published to gcom', async () => {
  586. const { queryByText, queryByRole } = renderPluginDetails({
  587. name: 'Akumuli',
  588. isInstalled: false,
  589. hasUpdate: false,
  590. type: PluginType.app,
  591. isPublished: false,
  592. });
  593. await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
  594. expect(queryByRole('button', { name: /^install/i })).not.toBeInTheDocument();
  595. });
  596. it('should not display uninstall for plugins not published to gcom', async () => {
  597. const { queryByText, queryByRole } = renderPluginDetails({
  598. name: 'Akumuli',
  599. isInstalled: true,
  600. hasUpdate: false,
  601. type: PluginType.app,
  602. isPublished: false,
  603. });
  604. await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
  605. expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument();
  606. });
  607. });
  608. describe('viewed as user without grafana admin permissions', () => {
  609. beforeAll(() => {
  610. mockUserPermissions({
  611. isAdmin: false,
  612. isDataSourceEditor: false,
  613. isOrgAdmin: false,
  614. });
  615. });
  616. it("should not display an install button for a plugin that isn't installed", async () => {
  617. const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: false });
  618. await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
  619. expect(queryByRole('button', { name: /^install/i })).not.toBeInTheDocument();
  620. });
  621. it('should not display an uninstall button for an already installed plugin', async () => {
  622. const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: true });
  623. await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
  624. expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument();
  625. });
  626. it('should not display update or uninstall buttons for a plugin with update', async () => {
  627. const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: true, hasUpdate: true });
  628. await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
  629. expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
  630. expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument();
  631. });
  632. it('should not display an install button for enterprise plugins if license is valid', async () => {
  633. config.licenseInfo.enabledFeatures = { 'enterprise.plugins': true };
  634. const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: false, isEnterprise: true });
  635. await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
  636. expect(queryByRole('button', { name: /^install/i })).not.toBeInTheDocument();
  637. });
  638. });
  639. describe('viewed as user without data source edit permissions', () => {
  640. beforeAll(() => {
  641. mockUserPermissions({
  642. isAdmin: true,
  643. isDataSourceEditor: false,
  644. isOrgAdmin: true,
  645. });
  646. });
  647. it('should not display the data source post intallation step', async () => {
  648. const name = 'Akumuli';
  649. const { queryByText } = renderPluginDetails({
  650. name,
  651. isInstalled: true,
  652. type: PluginType.app,
  653. });
  654. await waitFor(() => queryByText('Uninstall'));
  655. expect(queryByText(`Create a ${name} data source`)).toBeNull();
  656. });
  657. });
  658. });