index.spec.tsx 17 KB

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