index.spec.tsx 14 KB

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