index.spec.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. import {initializeOrg} from 'sentry-test/initializeOrg';
  2. import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
  3. import {textWithMarkupMatcher} from 'sentry-test/utils';
  4. import * as indicators from 'sentry/actionCreators/indicator';
  5. import GlobalModal from 'sentry/components/globalModal';
  6. import AppStoreConnectContext from 'sentry/components/projects/appStoreConnectContext';
  7. import {DEBUG_SOURCE_TYPES} from 'sentry/data/debugFileSources';
  8. import ModalStore from 'sentry/stores/modalStore';
  9. import type {
  10. AppStoreConnectCredentialsStatus,
  11. CustomRepo,
  12. CustomRepoAppStoreConnect,
  13. CustomRepoHttp,
  14. } from 'sentry/types/debugFiles';
  15. import {CustomRepoType} from 'sentry/types/debugFiles';
  16. import CustomRepositories from 'sentry/views/settings/projectDebugFiles/sources/customRepositories';
  17. function TestComponent({
  18. organization,
  19. customRepositories,
  20. credetialsStatus,
  21. ...props
  22. }: Omit<React.ComponentProps<typeof CustomRepositories>, 'customRepositories'> & {
  23. credetialsStatus?: AppStoreConnectCredentialsStatus;
  24. customRepositories?:
  25. | [CustomRepoHttp, CustomRepoAppStoreConnect]
  26. | [CustomRepoHttp]
  27. | [CustomRepoAppStoreConnect];
  28. }) {
  29. const appStoreConnectRepo = customRepositories?.find(
  30. customRepository => customRepository.type === CustomRepoType.APP_STORE_CONNECT
  31. );
  32. return (
  33. <AppStoreConnectContext.Provider
  34. value={
  35. appStoreConnectRepo
  36. ? {
  37. [appStoreConnectRepo.id]: {
  38. credentials: credetialsStatus ?? {status: 'valid'},
  39. lastCheckedBuilds: null,
  40. latestBuildNumber: null,
  41. latestBuildVersion: null,
  42. pendingDownloads: 0,
  43. updateAlertMessage: undefined,
  44. },
  45. }
  46. : undefined
  47. }
  48. >
  49. <GlobalModal />
  50. <CustomRepositories
  51. {...props}
  52. organization={organization}
  53. customRepositories={customRepositories ?? []}
  54. />
  55. </AppStoreConnectContext.Provider>
  56. );
  57. }
  58. function getProps(props?: Parameters<typeof initializeOrg>[0]) {
  59. const {organization, router, project, routerContext} = initializeOrg({
  60. router: props?.router,
  61. });
  62. return {
  63. api: new MockApiClient(),
  64. organization,
  65. project,
  66. router,
  67. isLoading: false,
  68. location: router.location,
  69. routerContext,
  70. };
  71. }
  72. describe('Custom Repositories', function () {
  73. const httpRepository: CustomRepo = {
  74. id: '7ebdb871-eb65-0183-8001-ea7df90613a7',
  75. layout: {type: 'native', casing: 'default'},
  76. name: 'New Repo',
  77. password: {'hidden-secret': true},
  78. type: CustomRepoType.HTTP,
  79. url: 'https://msdl.microsoft.com/download/symbols/',
  80. username: 'admin',
  81. };
  82. const appStoreConnectRepository: CustomRepo = {
  83. id: '2192940b704a4e9987a676a0b0dba42c',
  84. appId: '7ebdb871',
  85. appName: 'Release Health',
  86. appconnectIssuer: '7ebdb871-eb65-0183-8001-ea7df90613a7',
  87. appconnectKey: 'XXXXX',
  88. appconnectPrivateKey: {'hidden-secret': true},
  89. bundleId: 'io.sentry.mobile.app',
  90. name: 'Release Health',
  91. type: CustomRepoType.APP_STORE_CONNECT,
  92. };
  93. beforeEach(() => {
  94. ModalStore.reset();
  95. });
  96. beforeAll(async function () {
  97. // TODO: figure out why this transpile is so slow
  98. // transpile the modal upfront so the test runs fast
  99. await import('sentry/components/modals/debugFileCustomRepository');
  100. });
  101. it('renders', async function () {
  102. const props = getProps();
  103. const {rerender} = render(<TestComponent {...props} />, {
  104. context: props.routerContext,
  105. });
  106. // Section title
  107. expect(screen.getByText('Custom Repositories')).toBeInTheDocument();
  108. // Enabled button
  109. expect(screen.getByText('Add Repository').closest('button')).toBeEnabled();
  110. // Content
  111. expect(screen.getByText('No custom repositories configured')).toBeInTheDocument();
  112. // Choose an App Store Connect source
  113. await userEvent.click(screen.getByText('Add Repository'));
  114. await userEvent.click(screen.getByText('App Store Connect'));
  115. // Display modal content
  116. // A single instance of App Store Connect is available on free plans
  117. expect(await screen.findByText('App Store Connect credentials')).toBeInTheDocument();
  118. // Close Modal
  119. await userEvent.click(screen.getByLabelText('Close Modal'));
  120. // Choose another source
  121. await userEvent.click(screen.getByText('Add Repository'));
  122. await userEvent.click(screen.getByText('Amazon S3'));
  123. // Feature disabled warning
  124. expect(
  125. await screen.findByText('This feature is not enabled on your Sentry installation.')
  126. ).toBeInTheDocument();
  127. // Help content
  128. expect(
  129. screen.getByText(
  130. "# Enables the Custom Symbol Sources feature SENTRY_FEATURES['custom-symbol-sources'] = True"
  131. )
  132. ).toBeInTheDocument();
  133. // Close Modal
  134. await userEvent.click(screen.getByLabelText('Close Modal'));
  135. await waitFor(() => {
  136. expect(screen.queryByText('App Store Connect credentials')).not.toBeInTheDocument();
  137. });
  138. // Renders disabled repository list
  139. rerender(
  140. <TestComponent
  141. {...props}
  142. customRepositories={[httpRepository, appStoreConnectRepository]}
  143. />
  144. );
  145. // Content
  146. const actions = screen.queryAllByLabelText('Actions');
  147. expect(actions).toHaveLength(2);
  148. // HTTP Repository
  149. expect(screen.getByText(httpRepository.name)).toBeInTheDocument();
  150. expect(screen.getByText(DEBUG_SOURCE_TYPES.http)).toBeInTheDocument();
  151. expect(actions[0]).toBeDisabled();
  152. // App Store Connect Repository
  153. expect(screen.getByText(appStoreConnectRepository.name)).toBeInTheDocument();
  154. expect(screen.getByText(DEBUG_SOURCE_TYPES.appStoreConnect)).toBeInTheDocument();
  155. expect(actions[1]).toBeEnabled();
  156. // A new App Store Connect instance is not available on free plans
  157. // Choose an App Store Connect source
  158. await userEvent.click(screen.getByText('Add Repository'));
  159. await userEvent.click(screen.getByRole('button', {name: 'App Store Connect'}));
  160. // Feature disabled warning
  161. expect(
  162. await screen.findByText('This feature is not enabled on your Sentry installation.')
  163. ).toBeInTheDocument();
  164. // Help content
  165. expect(
  166. screen.getByText(
  167. "# Enables the App Store Connect Multiple feature SENTRY_FEATURES['app-store-connect-multiple'] = True"
  168. )
  169. ).toBeInTheDocument();
  170. });
  171. it('renders with custom-symbol-sources feature enabled', async function () {
  172. const props = getProps();
  173. const newOrganization = {...props.organization, features: ['custom-symbol-sources']};
  174. const {rerender} = render(
  175. <TestComponent {...props} organization={newOrganization} />,
  176. {context: props.routerContext}
  177. );
  178. // Section title
  179. expect(screen.getByText('Custom Repositories')).toBeInTheDocument();
  180. // Enabled button
  181. expect(screen.getByText('Add Repository').closest('button')).toBeEnabled();
  182. // Content
  183. expect(screen.getByText('No custom repositories configured')).toBeInTheDocument();
  184. // Choose a source
  185. await userEvent.click(screen.getByText('Add Repository'));
  186. await userEvent.click(screen.getByText('Amazon S3'));
  187. // Display modal content
  188. expect(
  189. await screen.findByText(textWithMarkupMatcher('Add Amazon S3 Repository'))
  190. ).toBeInTheDocument();
  191. // Close Modal
  192. await userEvent.click(screen.getByLabelText('Close Modal'));
  193. // Renders enabled repository list
  194. rerender(
  195. <TestComponent
  196. {...props}
  197. organization={newOrganization}
  198. customRepositories={[httpRepository, appStoreConnectRepository]}
  199. />
  200. );
  201. // Content
  202. const actions = screen.queryAllByLabelText('Actions');
  203. expect(actions).toHaveLength(2);
  204. // HTTP Repository
  205. expect(screen.getByText(httpRepository.name)).toBeInTheDocument();
  206. expect(screen.getByText(DEBUG_SOURCE_TYPES.http)).toBeInTheDocument();
  207. expect(actions[0]).toBeEnabled();
  208. // App Store Connect Repository
  209. expect(screen.getByText(appStoreConnectRepository.name)).toBeInTheDocument();
  210. expect(screen.getByText(DEBUG_SOURCE_TYPES.appStoreConnect)).toBeInTheDocument();
  211. expect(actions[1]).toBeEnabled();
  212. });
  213. it('renders with app-store-connect-multiple feature enabled', async function () {
  214. const props = getProps();
  215. const newOrganization = {
  216. ...props.organization,
  217. features: ['app-store-connect-multiple'],
  218. };
  219. render(
  220. <TestComponent
  221. {...props}
  222. organization={newOrganization}
  223. customRepositories={[httpRepository, appStoreConnectRepository]}
  224. />,
  225. {context: props.routerContext}
  226. );
  227. // Section title
  228. expect(screen.getByText('Custom Repositories')).toBeInTheDocument();
  229. // Content
  230. const actions = screen.queryAllByLabelText('Actions');
  231. expect(actions).toHaveLength(2);
  232. // HTTP Repository
  233. expect(screen.getByText(httpRepository.name)).toBeInTheDocument();
  234. expect(screen.getByText(DEBUG_SOURCE_TYPES.http)).toBeInTheDocument();
  235. expect(actions[0]).toBeDisabled();
  236. // App Store Connect Repository
  237. expect(screen.getByText(appStoreConnectRepository.name)).toBeInTheDocument();
  238. expect(screen.getByText(DEBUG_SOURCE_TYPES.appStoreConnect)).toBeInTheDocument();
  239. expect(actions[1]).toBeEnabled();
  240. // Enabled button
  241. expect(screen.getByText('Add Repository').closest('button')).toBeEnabled();
  242. await userEvent.click(screen.getByText('Add Repository'));
  243. await userEvent.click(screen.getByRole('button', {name: 'App Store Connect'}));
  244. // Display modal content
  245. // A new App Store Connect instance is available
  246. expect(await screen.findByText('App Store Connect credentials')).toBeInTheDocument();
  247. // Close Modal
  248. await userEvent.click(screen.getByLabelText('Close Modal'));
  249. });
  250. it('renders with custom-symbol-sources and app-store-connect-multiple features enabled', function () {
  251. const props = getProps();
  252. const newOrganization = {
  253. ...props.organization,
  254. features: ['custom-symbol-sources', 'app-store-connect-multiple'],
  255. };
  256. render(
  257. <TestComponent
  258. {...props}
  259. organization={newOrganization}
  260. customRepositories={[httpRepository, appStoreConnectRepository]}
  261. />,
  262. {context: props.routerContext}
  263. );
  264. // Content
  265. const actions = screen.queryAllByLabelText('Actions');
  266. expect(actions).toHaveLength(2);
  267. // HTTP Repository
  268. expect(screen.getByText(httpRepository.name)).toBeInTheDocument();
  269. expect(screen.getByText(DEBUG_SOURCE_TYPES.http)).toBeInTheDocument();
  270. expect(actions[0]).toBeEnabled();
  271. // App Store Connect Repository
  272. expect(screen.getByText(appStoreConnectRepository.name)).toBeInTheDocument();
  273. expect(screen.getByText(DEBUG_SOURCE_TYPES.appStoreConnect)).toBeInTheDocument();
  274. expect(actions[1]).toBeEnabled();
  275. });
  276. describe('Sync Now button', function () {
  277. const props = getProps();
  278. it('enabled and send requests', async function () {
  279. // Request succeeds
  280. const refreshMockSuccess = MockApiClient.addMockResponse({
  281. url: `/projects/${props.organization.slug}/${props.project.slug}/appstoreconnect/${appStoreConnectRepository.id}/refresh/`,
  282. method: 'POST',
  283. statusCode: 200,
  284. });
  285. jest.spyOn(indicators, 'addSuccessMessage');
  286. const {rerender} = render(
  287. <TestComponent
  288. {...props}
  289. organization={props.organization}
  290. customRepositories={[httpRepository, appStoreConnectRepository]}
  291. />,
  292. {context: props.routerContext}
  293. );
  294. const syncNowButton = screen.getByRole('button', {name: 'Sync Now'});
  295. expect(syncNowButton).toBeEnabled();
  296. await userEvent.click(syncNowButton);
  297. await waitFor(() => expect(refreshMockSuccess).toHaveBeenCalledTimes(1));
  298. expect(indicators.addSuccessMessage).toHaveBeenCalledWith(
  299. 'Repository sync started.'
  300. );
  301. // Request Fails
  302. const refreshMockFail = MockApiClient.addMockResponse({
  303. url: `/projects/${props.organization.slug}/${props.project.slug}/appstoreconnect/${appStoreConnectRepository.id}/refresh/`,
  304. method: 'POST',
  305. statusCode: 429,
  306. });
  307. jest.spyOn(indicators, 'addErrorMessage');
  308. rerender(
  309. <TestComponent
  310. {...props}
  311. organization={props.organization}
  312. customRepositories={[httpRepository, appStoreConnectRepository]}
  313. />
  314. );
  315. await userEvent.click(screen.getByRole('button', {name: 'Sync Now'}));
  316. await waitFor(() => expect(refreshMockFail).toHaveBeenCalledTimes(1));
  317. expect(indicators.addErrorMessage).toHaveBeenCalledWith(
  318. 'Rate limit for refreshing repository exceeded. Try again in a few minutes.'
  319. );
  320. });
  321. it('disabled', async function () {
  322. const refreshMock = MockApiClient.addMockResponse({
  323. url: `/projects/${props.organization.slug}/${props.project.slug}/appstoreconnect/${appStoreConnectRepository.id}/refresh/`,
  324. method: 'POST',
  325. statusCode: 200,
  326. });
  327. render(
  328. <TestComponent
  329. {...props}
  330. organization={props.organization}
  331. customRepositories={[httpRepository, appStoreConnectRepository]}
  332. credetialsStatus={{status: 'invalid', code: 'app-connect-authentication-error'}}
  333. />,
  334. {context: props.routerContext}
  335. );
  336. const syncNowButton = screen.getByRole('button', {name: 'Sync Now'});
  337. expect(syncNowButton).toBeDisabled();
  338. await userEvent.hover(syncNowButton);
  339. expect(
  340. await screen.findByText(
  341. 'Authentication is required before this repository can sync with App Store Connect.'
  342. )
  343. ).toBeInTheDocument();
  344. await userEvent.click(syncNowButton);
  345. await waitFor(() => expect(refreshMock).toHaveBeenCalledTimes(0));
  346. });
  347. it('does not render', function () {
  348. render(
  349. <TestComponent
  350. {...props}
  351. organization={props.organization}
  352. customRepositories={[httpRepository]}
  353. />,
  354. {context: props.routerContext}
  355. );
  356. expect(screen.queryByRole('button', {name: 'Sync Now'})).not.toBeInTheDocument();
  357. });
  358. });
  359. describe('Update saved store', function () {
  360. const props = getProps({
  361. router: {
  362. location: {
  363. pathname: `/settings/org-slug/projects/project-2/debug-symbols/`,
  364. query: {
  365. customRepository: appStoreConnectRepository.id,
  366. },
  367. },
  368. params: {orgId: 'org-slug', projectId: 'project-slug'},
  369. },
  370. });
  371. it('credentials valid for the application', async function () {
  372. jest.useFakeTimers();
  373. // Request succeeds
  374. const updateCredentialsMockSucceeds = MockApiClient.addMockResponse({
  375. url: `/projects/${props.organization.slug}/${props.project.slug}/appstoreconnect/apps/`,
  376. method: 'POST',
  377. statusCode: 200,
  378. body: {
  379. apps: [
  380. {
  381. appId: appStoreConnectRepository.appId,
  382. name: appStoreConnectRepository.appName,
  383. bundleId: appStoreConnectRepository.bundleId,
  384. },
  385. ],
  386. },
  387. });
  388. const updateMockSucceeds = MockApiClient.addMockResponse({
  389. url: `/projects/${props.organization.slug}/${props.project.slug}/appstoreconnect/${appStoreConnectRepository.id}/`,
  390. method: 'POST',
  391. statusCode: 200,
  392. });
  393. jest.spyOn(indicators, 'addSuccessMessage');
  394. render(
  395. <TestComponent
  396. {...props}
  397. organization={props.organization}
  398. customRepositories={[appStoreConnectRepository]}
  399. />,
  400. {context: props.routerContext}
  401. );
  402. // Display modal content
  403. expect(
  404. await screen.findByText('App Store Connect credentials')
  405. ).toBeInTheDocument();
  406. await userEvent.click(screen.getByText('Update'), {delay: null});
  407. await waitFor(() => expect(updateMockSucceeds).toHaveBeenCalledTimes(1));
  408. expect(updateCredentialsMockSucceeds).toHaveBeenCalledTimes(1);
  409. expect(indicators.addSuccessMessage).toHaveBeenCalledWith(
  410. 'Successfully updated custom repository'
  411. );
  412. act(() => jest.runAllTimers());
  413. jest.useRealTimers();
  414. });
  415. it('credentials not authorized for the application', async function () {
  416. // Request fails
  417. const updateCredentialsMockFails = MockApiClient.addMockResponse({
  418. url: `/projects/${props.organization.slug}/${props.project.slug}/appstoreconnect/apps/`,
  419. method: 'POST',
  420. statusCode: 200,
  421. body: {
  422. apps: [
  423. {appId: '8172187', name: 'Release Health', bundleId: 'io.sentry.mobile.app'},
  424. ],
  425. },
  426. });
  427. jest.spyOn(indicators, 'addErrorMessage');
  428. render(
  429. <TestComponent
  430. {...props}
  431. organization={props.organization}
  432. customRepositories={[appStoreConnectRepository]}
  433. />,
  434. {context: props.routerContext}
  435. );
  436. // Display modal content
  437. expect(
  438. await screen.findByText('App Store Connect credentials')
  439. ).toBeInTheDocument();
  440. // Type invalid key
  441. await userEvent.type(
  442. screen.getByPlaceholderText('(Private Key unchanged)'),
  443. 'invalid key{enter}'
  444. );
  445. await userEvent.click(screen.getByText('Update'));
  446. await waitFor(() => expect(updateCredentialsMockFails).toHaveBeenCalledTimes(1));
  447. expect(indicators.addErrorMessage).toHaveBeenCalledWith(
  448. 'Credentials not authorized for this application'
  449. );
  450. });
  451. });
  452. });