index.spec.tsx 17 KB

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