index.spec.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. import {browserHistory} from 'react-router';
  2. import selectEvent from 'react-select-event';
  3. import {GroupingConfigsFixture} from 'sentry-fixture/groupingConfigs';
  4. import {LocationFixture} from 'sentry-fixture/locationFixture';
  5. import {OrganizationFixture} from 'sentry-fixture/organization';
  6. import {ProjectFixture} from 'sentry-fixture/project';
  7. import {RouterContextFixture} from 'sentry-fixture/routerContextFixture';
  8. import {RouterFixture} from 'sentry-fixture/routerFixture';
  9. import {
  10. fireEvent,
  11. render,
  12. renderGlobalModal,
  13. screen,
  14. userEvent,
  15. waitFor,
  16. } from 'sentry-test/reactTestingLibrary';
  17. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  18. import {removePageFiltersStorage} from 'sentry/components/organizations/pageFilters/persistence';
  19. import ProjectsStore from 'sentry/stores/projectsStore';
  20. import ProjectContext from 'sentry/views/projects/projectContext';
  21. import ProjectGeneralSettings from 'sentry/views/settings/projectGeneralSettings';
  22. jest.mock('sentry/actionCreators/indicator');
  23. jest.mock('sentry/components/organizations/pageFilters/persistence');
  24. function getField(role, name) {
  25. return screen.getByRole(role, {name});
  26. }
  27. describe('projectGeneralSettings', function () {
  28. const organization = OrganizationFixture();
  29. const project = ProjectFixture({
  30. subjectPrefix: '[my-org]',
  31. resolveAge: 48,
  32. allowedDomains: ['example.com', 'https://example.com'],
  33. scrapeJavaScript: true,
  34. securityToken: 'security-token',
  35. securityTokenHeader: 'x-security-header',
  36. verifySSL: true,
  37. });
  38. const groupingConfigs = GroupingConfigsFixture();
  39. let routerContext;
  40. let putMock;
  41. const router = RouterFixture();
  42. const routerProps = {
  43. location: LocationFixture(),
  44. routes: router.routes,
  45. route: router.routes[0],
  46. router,
  47. routeParams: router.params,
  48. };
  49. beforeEach(function () {
  50. jest.spyOn(window.location, 'assign');
  51. routerContext = RouterContextFixture([
  52. {
  53. router: RouterFixture({
  54. params: {
  55. projectId: project.slug,
  56. },
  57. }),
  58. },
  59. ]);
  60. MockApiClient.clearMockResponses();
  61. MockApiClient.addMockResponse({
  62. url: `/organizations/${organization.slug}/grouping-configs/`,
  63. method: 'GET',
  64. body: groupingConfigs,
  65. });
  66. MockApiClient.addMockResponse({
  67. url: `/projects/${organization.slug}/${project.slug}/`,
  68. method: 'GET',
  69. body: project,
  70. });
  71. MockApiClient.addMockResponse({
  72. url: `/projects/${organization.slug}/${project.slug}/environments/`,
  73. method: 'GET',
  74. body: [],
  75. });
  76. MockApiClient.addMockResponse({
  77. url: `/organizations/${organization.slug}/users/`,
  78. method: 'GET',
  79. body: [],
  80. });
  81. });
  82. afterEach(function () {
  83. MockApiClient.clearMockResponses();
  84. jest.clearAllMocks();
  85. jest.restoreAllMocks();
  86. });
  87. it('renders form fields', function () {
  88. render(
  89. <ProjectGeneralSettings {...routerProps} params={{projectId: project.slug}} />,
  90. {organization}
  91. );
  92. expect(getField('textbox', 'Name')).toHaveValue('Project Name');
  93. expect(getField('textbox', 'Subject Prefix')).toHaveValue('[my-org]');
  94. // Step 19 of the auto resolve slider equates to 48 hours. This is
  95. // different from thee actual field value (which will be 48)
  96. expect(getField('slider', 'Auto Resolve')).toHaveValue('19');
  97. expect(getField('textbox', 'Allowed Domains')).toHaveValue(
  98. 'example.com\nhttps://example.com'
  99. );
  100. expect(getField('checkbox', 'Enable JavaScript source fetching')).toBeChecked();
  101. expect(getField('textbox', 'Security Token')).toHaveValue('security-token');
  102. expect(getField('textbox', 'Security Token Header')).toHaveValue('x-security-header');
  103. expect(getField('checkbox', 'Verify TLS/SSL')).toBeChecked();
  104. });
  105. it('disables scrapeJavaScript when equivalent org setting is false', function () {
  106. const orgWithoutScrapeJavaScript = OrganizationFixture({
  107. scrapeJavaScript: false,
  108. });
  109. render(
  110. <ProjectGeneralSettings {...routerProps} params={{projectId: project.slug}} />,
  111. {
  112. organization: orgWithoutScrapeJavaScript,
  113. }
  114. );
  115. expect(getField('checkbox', 'Enable JavaScript source fetching')).toBeDisabled();
  116. expect(getField('checkbox', 'Enable JavaScript source fetching')).not.toBeChecked();
  117. });
  118. it('project admins can remove project', async function () {
  119. const deleteMock = MockApiClient.addMockResponse({
  120. url: `/projects/${organization.slug}/${project.slug}/`,
  121. method: 'DELETE',
  122. });
  123. render(
  124. <ProjectGeneralSettings {...routerProps} params={{projectId: project.slug}} />,
  125. {organization}
  126. );
  127. await userEvent.click(screen.getByRole('button', {name: 'Remove Project'}));
  128. // Click confirmation button
  129. renderGlobalModal();
  130. await userEvent.click(screen.getByTestId('confirm-button'));
  131. expect(deleteMock).toHaveBeenCalled();
  132. expect(removePageFiltersStorage).toHaveBeenCalledWith('org-slug');
  133. });
  134. it('project admins can transfer project', async function () {
  135. const deleteMock = MockApiClient.addMockResponse({
  136. url: `/projects/${organization.slug}/${project.slug}/transfer/`,
  137. method: 'POST',
  138. });
  139. render(
  140. <ProjectGeneralSettings {...routerProps} params={{projectId: project.slug}} />,
  141. {organization}
  142. );
  143. await userEvent.click(screen.getByRole('button', {name: 'Transfer Project'}));
  144. // Click confirmation button
  145. renderGlobalModal();
  146. await userEvent.type(getField('textbox', 'Organization Owner'), 'billy@sentry.io');
  147. await userEvent.click(screen.getByTestId('confirm-button'));
  148. await waitFor(() =>
  149. expect(deleteMock).toHaveBeenCalledWith(
  150. `/projects/${organization.slug}/${project.slug}/transfer/`,
  151. expect.objectContaining({
  152. method: 'POST',
  153. data: {
  154. email: 'billy@sentry.io',
  155. },
  156. })
  157. )
  158. );
  159. expect(addSuccessMessage).toHaveBeenCalled();
  160. });
  161. it('handles errors on transfer project', async function () {
  162. const deleteMock = MockApiClient.addMockResponse({
  163. url: `/projects/${organization.slug}/${project.slug}/transfer/`,
  164. method: 'POST',
  165. statusCode: 400,
  166. body: {detail: 'An organization owner could not be found'},
  167. });
  168. render(
  169. <ProjectGeneralSettings {...routerProps} params={{projectId: project.slug}} />,
  170. {organization}
  171. );
  172. await userEvent.click(screen.getByRole('button', {name: 'Transfer Project'}));
  173. // Click confirmation button
  174. renderGlobalModal();
  175. await userEvent.type(getField('textbox', 'Organization Owner'), 'billy@sentry.io');
  176. await userEvent.click(screen.getByTestId('confirm-button'));
  177. await waitFor(() => expect(deleteMock).toHaveBeenCalled());
  178. expect(addSuccessMessage).not.toHaveBeenCalled();
  179. expect(addErrorMessage).toHaveBeenCalled();
  180. // Check the error message
  181. const {container} = render((addErrorMessage as jest.Mock).mock.calls[0][0]);
  182. expect(container).toHaveTextContent(
  183. 'Error transferring project-slug. An organization owner could not be found'
  184. );
  185. });
  186. it('displays transfer/remove message for non-admins', function () {
  187. const nonAdminOrg = OrganizationFixture({
  188. access: ['org:read'],
  189. });
  190. const {container} = render(
  191. <ProjectGeneralSettings {...routerProps} params={{projectId: project.slug}} />,
  192. {organization: nonAdminOrg}
  193. );
  194. expect(container).toHaveTextContent(
  195. 'You do not have the required permission to remove this project.'
  196. );
  197. expect(container).toHaveTextContent(
  198. 'You do not have the required permission to transfer this project.'
  199. );
  200. });
  201. it('disables the form for users without write permissions', function () {
  202. const readOnlyOrg = OrganizationFixture({access: ['org:read']});
  203. routerContext.context.organization = readOnlyOrg;
  204. render(
  205. <ProjectGeneralSettings {...routerProps} params={{projectId: project.slug}} />,
  206. {
  207. context: routerContext,
  208. organization: readOnlyOrg,
  209. }
  210. );
  211. // no textboxes are enabled
  212. screen.queryAllByRole('textbox').forEach(textbox => expect(textbox).toBeDisabled());
  213. expect(screen.getByTestId('project-permission-alert')).toBeInTheDocument();
  214. });
  215. it('changing project platform updates ProjectsStore', async function () {
  216. const params = {projectId: project.slug};
  217. ProjectsStore.loadInitialData([project]);
  218. putMock = MockApiClient.addMockResponse({
  219. url: `/projects/${organization.slug}/${project.slug}/`,
  220. method: 'PUT',
  221. body: {
  222. ...project,
  223. platform: 'javascript',
  224. },
  225. });
  226. render(
  227. <ProjectContext projectSlug={project.slug}>
  228. <ProjectGeneralSettings
  229. {...routerProps}
  230. routes={[]}
  231. location={routerContext.context.location}
  232. params={params}
  233. />
  234. </ProjectContext>,
  235. {context: routerContext, organization}
  236. );
  237. const platformSelect = await screen.findByRole('textbox', {name: 'Platform'});
  238. await selectEvent.select(platformSelect, ['React']);
  239. expect(putMock).toHaveBeenCalled();
  240. // updates ProjectsStore
  241. expect(ProjectsStore.getById('2')!.platform).toBe('javascript');
  242. });
  243. it('changing name updates ProjectsStore', async function () {
  244. const params = {projectId: project.slug};
  245. ProjectsStore.loadInitialData([project]);
  246. putMock = MockApiClient.addMockResponse({
  247. url: `/projects/${organization.slug}/${project.slug}/`,
  248. method: 'PUT',
  249. body: {
  250. ...project,
  251. slug: 'new-project',
  252. },
  253. });
  254. render(
  255. <ProjectContext projectSlug={project.slug}>
  256. <ProjectGeneralSettings
  257. {...routerProps}
  258. routes={[]}
  259. location={routerContext.context.location}
  260. params={params}
  261. />
  262. </ProjectContext>,
  263. {context: routerContext, organization}
  264. );
  265. await userEvent.type(
  266. await screen.findByRole('textbox', {name: 'Name'}),
  267. 'New Project'
  268. );
  269. // Slug does not save on blur
  270. expect(putMock).not.toHaveBeenCalled();
  271. // Saves when clicking save
  272. await userEvent.click(screen.getByRole('button', {name: 'Save'}));
  273. // Redirects the user
  274. await waitFor(() => expect(browserHistory.replace).toHaveBeenCalled());
  275. expect(ProjectsStore.getById('2')!.slug).toBe('new-project');
  276. });
  277. describe('Non-"save on blur" Field', function () {
  278. beforeEach(function () {
  279. const params = {projectId: project.slug};
  280. ProjectsStore.loadInitialData([project]);
  281. putMock = MockApiClient.addMockResponse({
  282. url: `/projects/${organization.slug}/${project.slug}/`,
  283. method: 'PUT',
  284. body: {
  285. ...project,
  286. slug: 'new-project',
  287. },
  288. });
  289. render(
  290. <ProjectContext projectSlug={project.slug}>
  291. <ProjectGeneralSettings
  292. {...routerProps}
  293. routes={[]}
  294. location={routerContext.context.location}
  295. params={params}
  296. />
  297. </ProjectContext>,
  298. {context: routerContext, organization}
  299. );
  300. });
  301. it('can cancel unsaved changes for a field', async function () {
  302. expect(screen.queryByRole('button', {name: 'Cancel'})).not.toBeInTheDocument();
  303. const autoResolveSlider = getField('slider', 'Auto Resolve');
  304. expect(autoResolveSlider).toHaveValue('19');
  305. // Change value
  306. fireEvent.change(autoResolveSlider, {target: {value: '12'}});
  307. expect(autoResolveSlider).toHaveValue('12');
  308. // Click cancel
  309. await userEvent.click(screen.getByRole('button', {name: 'Cancel'}));
  310. // Cancel row should disappear
  311. expect(screen.queryByRole('button', {name: 'Cancel'})).not.toBeInTheDocument();
  312. // Value should be reverted
  313. expect(autoResolveSlider).toHaveValue('19');
  314. // PUT should not be called
  315. expect(putMock).not.toHaveBeenCalled();
  316. });
  317. it('saves when value is changed and "Save" clicked', async function () {
  318. expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
  319. const autoResolveSlider = getField('slider', 'Auto Resolve');
  320. expect(autoResolveSlider).toHaveValue('19');
  321. // Change value
  322. fireEvent.change(autoResolveSlider, {target: {value: '12'}});
  323. expect(autoResolveSlider).toHaveValue('12');
  324. // Should not have put mock called yet
  325. expect(putMock).not.toHaveBeenCalled();
  326. // Click "Save"
  327. await userEvent.click(screen.getByRole('button', {name: 'Save'}));
  328. // API endpoint should have been called
  329. await waitFor(() => {
  330. expect(putMock).toHaveBeenCalledWith(
  331. expect.anything(),
  332. expect.objectContaining({
  333. data: {
  334. resolveAge: 12,
  335. },
  336. })
  337. );
  338. });
  339. expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
  340. });
  341. });
  342. });