sentryApplicationDetails.spec.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  1. import {OrganizationFixture} from 'sentry-fixture/organization';
  2. import {RouterContextFixture} from 'sentry-fixture/routerContextFixture';
  3. import {RouterFixture} from 'sentry-fixture/routerFixture';
  4. import {SentryAppFixture} from 'sentry-fixture/sentryApp';
  5. import {SentryAppTokenFixture} from 'sentry-fixture/sentryAppToken';
  6. import {
  7. render,
  8. renderGlobalModal,
  9. screen,
  10. userEvent,
  11. waitFor,
  12. } from 'sentry-test/reactTestingLibrary';
  13. import selectEvent from 'sentry-test/selectEvent';
  14. import SentryApplicationDetails from 'sentry/views/settings/organizationDeveloperSettings/sentryApplicationDetails';
  15. describe('Sentry Application Details', function () {
  16. let org;
  17. let sentryApp;
  18. let token;
  19. let createAppRequest;
  20. let editAppRequest;
  21. const maskedValue = '************oken';
  22. const router = RouterFixture();
  23. beforeEach(() => {
  24. MockApiClient.clearMockResponses();
  25. org = OrganizationFixture({features: ['sentry-app-logo-upload']});
  26. });
  27. describe('Creating a new public Sentry App', () => {
  28. function renderComponent() {
  29. return render(
  30. <SentryApplicationDetails
  31. router={router}
  32. location={router.location}
  33. routes={router.routes}
  34. routeParams={{}}
  35. route={{path: 'new-public/'}}
  36. params={{}}
  37. />,
  38. {context: RouterContextFixture([{organization: org}])}
  39. );
  40. }
  41. beforeEach(() => {
  42. createAppRequest = MockApiClient.addMockResponse({
  43. url: '/sentry-apps/',
  44. method: 'POST',
  45. body: [],
  46. });
  47. });
  48. it('has inputs for redirectUrl and verifyInstall', () => {
  49. renderComponent();
  50. expect(
  51. screen.getByRole('checkbox', {name: 'Verify Installation'})
  52. ).toBeInTheDocument();
  53. expect(screen.getByRole('textbox', {name: 'Redirect URL'})).toBeInTheDocument();
  54. });
  55. it('shows empty scopes and no credentials', function () {
  56. renderComponent();
  57. expect(screen.getByText('Permissions')).toBeInTheDocument();
  58. // new app starts off with no scopes selected
  59. expect(screen.getByRole('checkbox', {name: 'issue'})).not.toBeChecked();
  60. expect(screen.getByRole('checkbox', {name: 'error'})).not.toBeChecked();
  61. expect(screen.getByRole('checkbox', {name: 'comment'})).not.toBeChecked();
  62. });
  63. it('does not show logo upload fields', function () {
  64. renderComponent();
  65. expect(screen.queryByText('Logo')).not.toBeInTheDocument();
  66. expect(screen.queryByText('Small Icon')).not.toBeInTheDocument();
  67. });
  68. it('saves', async function () {
  69. renderComponent();
  70. await userEvent.type(screen.getByRole('textbox', {name: 'Name'}), 'Test App');
  71. await userEvent.type(screen.getByRole('textbox', {name: 'Author'}), 'Sentry');
  72. await userEvent.type(
  73. screen.getByRole('textbox', {name: 'Webhook URL'}),
  74. 'https://webhook.com'
  75. );
  76. await userEvent.type(
  77. screen.getByRole('textbox', {name: 'Redirect URL'}),
  78. 'https://webhook.com/setup'
  79. );
  80. await userEvent.click(screen.getByRole('textbox', {name: 'Schema'}));
  81. await userEvent.paste('{}');
  82. await userEvent.click(screen.getByRole('checkbox', {name: 'Alert Rule Action'}));
  83. await selectEvent.select(screen.getByRole('textbox', {name: 'Member'}), 'Admin');
  84. await selectEvent.select(
  85. screen.getByRole('textbox', {name: 'Issue & Event'}),
  86. 'Admin'
  87. );
  88. await userEvent.click(screen.getByRole('checkbox', {name: 'issue'}));
  89. await userEvent.click(screen.getByRole('button', {name: 'Save Changes'}));
  90. const data = {
  91. name: 'Test App',
  92. author: 'Sentry',
  93. organization: org.slug,
  94. redirectUrl: 'https://webhook.com/setup',
  95. webhookUrl: 'https://webhook.com',
  96. scopes: expect.arrayContaining([
  97. 'member:read',
  98. 'member:admin',
  99. 'event:read',
  100. 'event:admin',
  101. ]),
  102. events: ['issue'],
  103. isInternal: false,
  104. verifyInstall: true,
  105. isAlertable: true,
  106. allowedOrigins: [],
  107. schema: {},
  108. };
  109. expect(createAppRequest).toHaveBeenCalledWith(
  110. '/sentry-apps/',
  111. expect.objectContaining({
  112. data,
  113. method: 'POST',
  114. })
  115. );
  116. });
  117. });
  118. describe('Creating a new internal Sentry App', () => {
  119. function renderComponent() {
  120. return render(
  121. <SentryApplicationDetails
  122. router={router}
  123. location={router.location}
  124. routes={router.routes}
  125. routeParams={{}}
  126. route={{path: 'new-internal/'}}
  127. params={{}}
  128. />,
  129. {context: RouterContextFixture([{organization: org}])}
  130. );
  131. }
  132. it('does not show logo upload fields', function () {
  133. renderComponent();
  134. expect(screen.queryByText('Logo')).not.toBeInTheDocument();
  135. expect(screen.queryByText('Small Icon')).not.toBeInTheDocument();
  136. });
  137. it('no inputs for redirectUrl and verifyInstall', () => {
  138. renderComponent();
  139. expect(
  140. screen.queryByRole('checkbox', {name: 'Verify Installation'})
  141. ).not.toBeInTheDocument();
  142. expect(
  143. screen.queryByRole('textbox', {name: 'Redirect URL'})
  144. ).not.toBeInTheDocument();
  145. });
  146. });
  147. describe('Renders public app', function () {
  148. function renderComponent() {
  149. return render(
  150. <SentryApplicationDetails
  151. router={router}
  152. location={router.location}
  153. routes={router.routes}
  154. routeParams={{}}
  155. route={router.routes[0]}
  156. params={{appSlug: sentryApp.slug}}
  157. />,
  158. {
  159. context: RouterContextFixture([{organization: org}]),
  160. }
  161. );
  162. }
  163. beforeEach(() => {
  164. sentryApp = SentryAppFixture();
  165. sentryApp.events = ['issue'];
  166. MockApiClient.addMockResponse({
  167. url: `/sentry-apps/${sentryApp.slug}/`,
  168. body: sentryApp,
  169. });
  170. MockApiClient.addMockResponse({
  171. url: `/sentry-apps/${sentryApp.slug}/api-tokens/`,
  172. body: [],
  173. });
  174. });
  175. it('shows logo upload fields', function () {
  176. renderComponent();
  177. expect(screen.getByText('Logo')).toBeInTheDocument();
  178. expect(screen.getByText('Small Icon')).toBeInTheDocument();
  179. });
  180. it('has inputs for redirectUrl and verifyInstall', () => {
  181. renderComponent();
  182. expect(
  183. screen.getByRole('checkbox', {name: 'Verify Installation'})
  184. ).toBeInTheDocument();
  185. expect(screen.getByRole('textbox', {name: 'Redirect URL'})).toBeInTheDocument();
  186. });
  187. it('shows application data', async function () {
  188. renderComponent();
  189. await selectEvent.openMenu(screen.getByRole('textbox', {name: 'Project'}));
  190. expect(screen.getByRole('menuitemradio', {name: 'Read'})).toBeChecked();
  191. });
  192. it('renders clientId and clientSecret for public apps', function () {
  193. renderComponent();
  194. expect(screen.getByRole('textbox', {name: 'Client ID'})).toBeInTheDocument();
  195. expect(screen.getByRole('textbox', {name: 'Client Secret'})).toBeInTheDocument();
  196. });
  197. });
  198. describe('Renders for internal apps', () => {
  199. function renderComponent() {
  200. return render(
  201. <SentryApplicationDetails
  202. router={router}
  203. location={router.location}
  204. routes={router.routes}
  205. routeParams={{}}
  206. route={router.routes[0]}
  207. params={{appSlug: sentryApp.slug}}
  208. />,
  209. {
  210. context: RouterContextFixture([{organization: org}]),
  211. }
  212. );
  213. }
  214. beforeEach(() => {
  215. sentryApp = SentryAppFixture({
  216. status: 'internal',
  217. });
  218. token = SentryAppTokenFixture();
  219. sentryApp.events = ['issue'];
  220. MockApiClient.addMockResponse({
  221. url: `/sentry-apps/${sentryApp.slug}/`,
  222. body: sentryApp,
  223. });
  224. MockApiClient.addMockResponse({
  225. url: `/sentry-apps/${sentryApp.slug}/api-tokens/`,
  226. body: [token],
  227. });
  228. });
  229. it('no inputs for redirectUrl and verifyInstall', () => {
  230. renderComponent();
  231. expect(
  232. screen.queryByRole('checkbox', {name: 'Verify Installation'})
  233. ).not.toBeInTheDocument();
  234. expect(
  235. screen.queryByRole('textbox', {name: 'Redirect URL'})
  236. ).not.toBeInTheDocument();
  237. });
  238. it('shows logo upload fields', function () {
  239. renderComponent();
  240. expect(screen.getByText('Logo')).toBeInTheDocument();
  241. expect(screen.getByText('Small Icon')).toBeInTheDocument();
  242. });
  243. it('has tokens', function () {
  244. renderComponent();
  245. expect(screen.getByText('Tokens')).toBeInTheDocument();
  246. expect(screen.getByLabelText('Token preview')).toHaveTextContent('oken');
  247. });
  248. it('shows just clientSecret', function () {
  249. renderComponent();
  250. expect(screen.queryByRole('textbox', {name: 'Client ID'})).not.toBeInTheDocument();
  251. expect(screen.getByRole('textbox', {name: 'Client Secret'})).toBeInTheDocument();
  252. });
  253. });
  254. describe('Renders masked values', () => {
  255. function renderComponent() {
  256. return render(
  257. <SentryApplicationDetails
  258. router={router}
  259. location={router.location}
  260. routes={router.routes}
  261. routeParams={{}}
  262. route={router.routes[0]}
  263. params={{appSlug: sentryApp.slug}}
  264. />,
  265. {
  266. context: RouterContextFixture([{organization: org}]),
  267. }
  268. );
  269. }
  270. beforeEach(() => {
  271. sentryApp = SentryAppFixture({
  272. status: 'internal',
  273. clientSecret: maskedValue,
  274. });
  275. token = SentryAppTokenFixture({token: maskedValue, refreshToken: maskedValue});
  276. sentryApp.events = ['issue'];
  277. MockApiClient.addMockResponse({
  278. url: `/sentry-apps/${sentryApp.slug}/`,
  279. body: sentryApp,
  280. });
  281. MockApiClient.addMockResponse({
  282. url: `/sentry-apps/${sentryApp.slug}/api-tokens/`,
  283. body: [token],
  284. });
  285. });
  286. it('shows masked tokens', function () {
  287. renderComponent();
  288. expect(screen.getByLabelText('Token preview')).toHaveTextContent(maskedValue);
  289. });
  290. it('shows masked clientSecret', function () {
  291. renderComponent();
  292. expect(screen.getByRole('textbox', {name: 'Client Secret'})).toHaveValue(
  293. maskedValue
  294. );
  295. });
  296. });
  297. describe('Editing internal app tokens', () => {
  298. function renderComponent() {
  299. return render(
  300. <SentryApplicationDetails
  301. router={router}
  302. location={router.location}
  303. routes={router.routes}
  304. routeParams={{}}
  305. route={router.routes[0]}
  306. params={{appSlug: sentryApp.slug}}
  307. />,
  308. {
  309. context: RouterContextFixture([{organization: org}]),
  310. }
  311. );
  312. }
  313. beforeEach(() => {
  314. sentryApp = SentryAppFixture({
  315. status: 'internal',
  316. isAlertable: true,
  317. });
  318. token = SentryAppTokenFixture();
  319. sentryApp.events = ['issue'];
  320. MockApiClient.addMockResponse({
  321. url: `/sentry-apps/${sentryApp.slug}/`,
  322. body: sentryApp,
  323. });
  324. MockApiClient.addMockResponse({
  325. url: `/sentry-apps/${sentryApp.slug}/api-tokens/`,
  326. body: [token],
  327. });
  328. });
  329. it('adding token to list', async function () {
  330. MockApiClient.addMockResponse({
  331. url: `/sentry-apps/${sentryApp.slug}/api-tokens/`,
  332. method: 'POST',
  333. body: [
  334. SentryAppTokenFixture({
  335. token: '392847329',
  336. dateCreated: '2018-03-02T18:30:26Z',
  337. id: '234',
  338. }),
  339. ],
  340. });
  341. renderComponent();
  342. expect(screen.queryByLabelText('Generated token')).not.toBeInTheDocument();
  343. expect(screen.getAllByLabelText('Token preview')).toHaveLength(1);
  344. await userEvent.click(screen.getByRole('button', {name: 'New Token'}));
  345. await waitFor(() => {
  346. expect(screen.getAllByLabelText('Token preview')).toHaveLength(1);
  347. });
  348. await waitFor(() => {
  349. expect(screen.getAllByLabelText('Generated token')).toHaveLength(1);
  350. });
  351. });
  352. it('removing token from list', async function () {
  353. MockApiClient.addMockResponse({
  354. url: `/sentry-apps/${sentryApp.slug}/api-tokens/${token.id}/`,
  355. method: 'DELETE',
  356. body: {},
  357. });
  358. renderComponent();
  359. renderGlobalModal();
  360. await userEvent.click(screen.getByRole('button', {name: 'Remove'}));
  361. // Confirm modal
  362. await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
  363. expect(await screen.findByText('No tokens created yet.')).toBeInTheDocument();
  364. });
  365. it('removing webhookURL unsets isAlertable and changes webhookDisabled to true', async () => {
  366. renderComponent();
  367. expect(screen.getByRole('checkbox', {name: 'Alert Rule Action'})).toBeChecked();
  368. await userEvent.clear(screen.getByRole('textbox', {name: 'Webhook URL'}));
  369. expect(screen.getByRole('checkbox', {name: 'Alert Rule Action'})).not.toBeChecked();
  370. });
  371. });
  372. describe('Editing an existing public Sentry App', () => {
  373. function renderComponent() {
  374. return render(
  375. <SentryApplicationDetails
  376. router={router}
  377. location={router.location}
  378. routes={router.routes}
  379. routeParams={{}}
  380. route={router.routes[0]}
  381. params={{appSlug: sentryApp.slug}}
  382. />,
  383. {
  384. context: RouterContextFixture([{organization: org}]),
  385. }
  386. );
  387. }
  388. beforeEach(() => {
  389. sentryApp = SentryAppFixture();
  390. sentryApp.events = ['issue'];
  391. sentryApp.scopes = ['project:read', 'event:read'];
  392. editAppRequest = MockApiClient.addMockResponse({
  393. url: `/sentry-apps/${sentryApp.slug}/`,
  394. method: 'PUT',
  395. body: [],
  396. });
  397. MockApiClient.addMockResponse({
  398. url: `/sentry-apps/${sentryApp.slug}/`,
  399. body: sentryApp,
  400. });
  401. MockApiClient.addMockResponse({
  402. url: `/sentry-apps/${sentryApp.slug}/api-tokens/`,
  403. body: [],
  404. });
  405. });
  406. it('updates app with correct data', async function () {
  407. renderComponent();
  408. await userEvent.clear(screen.getByRole('textbox', {name: 'Redirect URL'}));
  409. await userEvent.type(
  410. screen.getByRole('textbox', {name: 'Redirect URL'}),
  411. 'https://hello.com/'
  412. );
  413. await userEvent.click(screen.getByRole('textbox', {name: 'Schema'}));
  414. await userEvent.paste('{}');
  415. await userEvent.click(screen.getByRole('checkbox', {name: 'issue'}));
  416. await userEvent.click(screen.getByRole('button', {name: 'Save Changes'}));
  417. expect(editAppRequest).toHaveBeenCalledWith(
  418. `/sentry-apps/${sentryApp.slug}/`,
  419. expect.objectContaining({
  420. data: expect.objectContaining({
  421. redirectUrl: 'https://hello.com/',
  422. events: [],
  423. }),
  424. method: 'PUT',
  425. })
  426. );
  427. });
  428. it('submits with no-access for event subscription when permission is revoked', async () => {
  429. renderComponent();
  430. await userEvent.click(screen.getByRole('checkbox', {name: 'issue'}));
  431. await userEvent.click(screen.getByRole('textbox', {name: 'Schema'}));
  432. await userEvent.paste('{}');
  433. await selectEvent.select(
  434. screen.getByRole('textbox', {name: 'Issue & Event'}),
  435. 'No Access'
  436. );
  437. await userEvent.click(screen.getByRole('button', {name: 'Save Changes'}));
  438. expect(editAppRequest).toHaveBeenCalledWith(
  439. `/sentry-apps/${sentryApp.slug}/`,
  440. expect.objectContaining({
  441. data: expect.objectContaining({
  442. events: [],
  443. }),
  444. method: 'PUT',
  445. })
  446. );
  447. });
  448. });
  449. describe('Editing an existing public Sentry App with a scope error', () => {
  450. function renderComponent() {
  451. render(
  452. <SentryApplicationDetails
  453. router={router}
  454. location={router.location}
  455. routes={router.routes}
  456. routeParams={{}}
  457. route={router.routes[0]}
  458. params={{appSlug: sentryApp.slug}}
  459. />,
  460. {
  461. context: RouterContextFixture([{organization: org}]),
  462. }
  463. );
  464. }
  465. beforeEach(() => {
  466. sentryApp = SentryAppFixture();
  467. editAppRequest = MockApiClient.addMockResponse({
  468. url: `/sentry-apps/${sentryApp.slug}/`,
  469. method: 'PUT',
  470. statusCode: 400,
  471. body: {
  472. scopes: [
  473. "Requested permission of member:write exceeds requester's permission. Please contact an administrator to make the requested change.",
  474. "Requested permission of member:admin exceeds requester's permission. Please contact an administrator to make the requested change.",
  475. ],
  476. },
  477. });
  478. MockApiClient.addMockResponse({
  479. url: `/sentry-apps/${sentryApp.slug}/`,
  480. body: sentryApp,
  481. });
  482. MockApiClient.addMockResponse({
  483. url: `/sentry-apps/${sentryApp.slug}/api-tokens/`,
  484. body: [],
  485. });
  486. });
  487. it('renders the error', async () => {
  488. renderComponent();
  489. await userEvent.click(screen.getByRole('button', {name: 'Save Changes'}));
  490. expect(
  491. await screen.findByText(
  492. "Requested permission of member:admin exceeds requester's permission. Please contact an administrator to make the requested change."
  493. )
  494. ).toBeInTheDocument();
  495. });
  496. it('handles client secret rotation', async function () {
  497. sentryApp = SentryAppFixture();
  498. sentryApp.clientSecret = null;
  499. MockApiClient.addMockResponse({
  500. url: `/sentry-apps/${sentryApp.slug}/`,
  501. body: sentryApp,
  502. });
  503. const rotateSecretApiCall = MockApiClient.addMockResponse({
  504. method: 'POST',
  505. url: `/sentry-apps/${sentryApp.slug}/rotate-secret/`,
  506. body: {
  507. clientSecret: 'newSecret!',
  508. },
  509. });
  510. render(
  511. <SentryApplicationDetails
  512. router={router}
  513. location={router.location}
  514. routes={router.routes}
  515. route={router.routes[0]}
  516. routeParams={{}}
  517. params={{appSlug: sentryApp.slug}}
  518. />
  519. );
  520. renderGlobalModal();
  521. expect(screen.getByText('hidden')).toBeInTheDocument();
  522. expect(
  523. screen.getByRole('button', {name: 'Rotate client secret'})
  524. ).toBeInTheDocument();
  525. await userEvent.click(screen.getByRole('button', {name: 'Rotate client secret'}));
  526. // Confirm modal
  527. await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
  528. expect(
  529. screen.getByText('This will be the only time your client secret is visible!')
  530. ).toBeInTheDocument();
  531. expect(screen.getByText('Your new Client Secret')).toBeInTheDocument();
  532. expect(screen.getByLabelText<HTMLInputElement>('new-client-secret')).toHaveValue(
  533. 'newSecret!'
  534. );
  535. expect(rotateSecretApiCall).toHaveBeenCalledTimes(1);
  536. });
  537. });
  538. });