index.spec.tsx 17 KB

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