customRepositories.spec.tsx 17 KB

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