api.spec.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import {OrganizationFixture} from 'sentry-fixture/organization';
  2. import type {Client, ResponseMeta} from 'sentry/api';
  3. import {isSimilarOrigin, Request, resolveHostname} from 'sentry/api';
  4. import {PROJECT_MOVED} from 'sentry/constants/apiErrorCodes';
  5. import ConfigStore from './stores/configStore';
  6. import OrganizationStore from './stores/organizationStore';
  7. jest.unmock('sentry/api');
  8. describe('api', function () {
  9. let api: Client;
  10. beforeEach(function () {
  11. api = new MockApiClient();
  12. });
  13. describe('Client', function () {
  14. describe('cancel()', function () {
  15. it('should abort any open XHR requests', function () {
  16. const abort1 = jest.fn();
  17. const abort2 = jest.fn();
  18. const req1 = new Request(new Promise(() => null), {
  19. abort: abort1,
  20. } as any);
  21. const req2 = new Request(new Promise(() => null), {abort: abort2} as any);
  22. api.activeRequests = {
  23. 1: req1,
  24. 2: req2,
  25. };
  26. api.clear();
  27. expect(req1.aborter?.abort).toHaveBeenCalledTimes(1);
  28. expect(req2.aborter?.abort).toHaveBeenCalledTimes(1);
  29. });
  30. });
  31. });
  32. it('does not call success callback if 302 was returned because of a project slug change', function () {
  33. const successCb = jest.fn();
  34. api.activeRequests = {
  35. id: {alive: true, requestPromise: new Promise(() => null), cancel: jest.fn()},
  36. };
  37. api.wrapCallback(
  38. 'id',
  39. successCb
  40. )({
  41. responseJSON: {
  42. detail: {
  43. code: PROJECT_MOVED,
  44. message: '...',
  45. extra: {
  46. slug: 'new-slug',
  47. },
  48. },
  49. },
  50. });
  51. expect(successCb).not.toHaveBeenCalled();
  52. });
  53. it('handles error callback', function () {
  54. jest.spyOn(api, 'wrapCallback').mockImplementation((_id: string, func: any) => func);
  55. const errorCb = jest.fn();
  56. const args = ['test', true, 1] as unknown as [ResponseMeta, string, string];
  57. api.handleRequestError(
  58. {
  59. id: 'test',
  60. path: 'test',
  61. requestOptions: {error: errorCb},
  62. },
  63. ...args
  64. );
  65. expect(errorCb).toHaveBeenCalledWith(...args);
  66. });
  67. it('handles undefined error callback', function () {
  68. expect(() =>
  69. api.handleRequestError(
  70. {
  71. id: 'test',
  72. path: 'test',
  73. requestOptions: {},
  74. },
  75. {} as ResponseMeta,
  76. '',
  77. 'test'
  78. )
  79. ).not.toThrow();
  80. });
  81. });
  82. describe('resolveHostname', function () {
  83. let devUi: boolean | undefined;
  84. let location: Location;
  85. let configstate: ReturnType<typeof ConfigStore.getState>;
  86. const controlPath = '/api/0/broadcasts/';
  87. const regionPath = '/api/0/organizations/slug/issues/';
  88. beforeEach(function () {
  89. configstate = ConfigStore.getState();
  90. location = window.location;
  91. devUi = window.__SENTRY_DEV_UI;
  92. ConfigStore.loadInitialData({
  93. ...configstate,
  94. features: new Set(['system:multi-region']),
  95. links: {
  96. organizationUrl: 'https://acme.sentry.io',
  97. sentryUrl: 'https://sentry.io',
  98. regionUrl: 'https://us.sentry.io',
  99. },
  100. });
  101. });
  102. afterEach(() => {
  103. window.location = location;
  104. window.__SENTRY_DEV_UI = devUi;
  105. ConfigStore.loadInitialData(configstate);
  106. });
  107. it('does nothing without feature', function () {
  108. ConfigStore.loadInitialData({
  109. ...configstate,
  110. // Remove the feature flag
  111. features: new Set(),
  112. });
  113. let result = resolveHostname(controlPath);
  114. expect(result).toBe(controlPath);
  115. // Explicit domains still work.
  116. result = resolveHostname(controlPath, 'https://sentry.io');
  117. expect(result).toBe(`https://sentry.io${controlPath}`);
  118. result = resolveHostname(regionPath, 'https://de.sentry.io');
  119. expect(result).toBe(`https://de.sentry.io${regionPath}`);
  120. });
  121. it('does not override region in _admin', function () {
  122. Object.defineProperty(window, 'location', {
  123. configurable: true,
  124. enumerable: true,
  125. value: new URL('https://sentry.io/_admin/'),
  126. });
  127. // Adds domain to control paths
  128. let result = resolveHostname(controlPath);
  129. expect(result).toBe('https://sentry.io/api/0/broadcasts/');
  130. // Doesn't add domain to region paths
  131. result = resolveHostname(regionPath);
  132. expect(result).toBe(regionPath);
  133. // Explicit domains still work.
  134. result = resolveHostname(controlPath, 'https://sentry.io');
  135. expect(result).toBe(`https://sentry.io${controlPath}`);
  136. result = resolveHostname(regionPath, 'https://de.sentry.io');
  137. expect(result).toBe(`https://de.sentry.io${regionPath}`);
  138. });
  139. it('adds domains when feature enabled', function () {
  140. let result = resolveHostname(regionPath);
  141. expect(result).toBe('https://us.sentry.io/api/0/organizations/slug/issues/');
  142. result = resolveHostname(controlPath);
  143. expect(result).toBe('https://sentry.io/api/0/broadcasts/');
  144. });
  145. it('matches if querystrings are in path', function () {
  146. const result = resolveHostname(
  147. '/api/0/organizations/acme/sentry-app-components/?projectId=123'
  148. );
  149. expect(result).toBe(
  150. 'https://sentry.io/api/0/organizations/acme/sentry-app-components/?projectId=123'
  151. );
  152. });
  153. it('uses paths for region silo in dev-ui', function () {
  154. window.__SENTRY_DEV_UI = true;
  155. let result = resolveHostname(regionPath);
  156. expect(result).toBe('/region/us/api/0/organizations/slug/issues/');
  157. result = resolveHostname(controlPath);
  158. expect(result).toBe('/api/0/broadcasts/');
  159. });
  160. it('removes sentryUrl from dev-ui mode requests', function () {
  161. window.__SENTRY_DEV_UI = true;
  162. let result = resolveHostname(regionPath, 'https://sentry.io');
  163. expect(result).toBe('/api/0/organizations/slug/issues/');
  164. result = resolveHostname(controlPath, 'https://sentry.io');
  165. expect(result).toBe('/api/0/broadcasts/');
  166. });
  167. it('removes sentryUrl from dev-ui mode requests when feature is off', function () {
  168. window.__SENTRY_DEV_UI = true;
  169. // Org does not have the required feature.
  170. OrganizationStore.onUpdate(OrganizationFixture());
  171. let result = resolveHostname(controlPath);
  172. expect(result).toBe(controlPath);
  173. // control silo shaped URLs don't get a host
  174. result = resolveHostname(controlPath, 'https://sentry.io');
  175. expect(result).toBe(controlPath);
  176. result = resolveHostname(regionPath, 'https://de.sentry.io');
  177. expect(result).toBe(`/region/de${regionPath}`);
  178. });
  179. it('preserves host parameters', function () {
  180. const result = resolveHostname(regionPath, 'https://de.sentry.io');
  181. expect(result).toBe('https://de.sentry.io/api/0/organizations/slug/issues/');
  182. });
  183. });
  184. describe('isSimilarOrigin', function () {
  185. test.each([
  186. // Same domain
  187. ['https://sentry.io', 'https://sentry.io', true],
  188. ['https://example.io', 'https://example.io', true],
  189. // Not the same
  190. ['https://example.io', 'https://sentry.io', false],
  191. ['https://sentry.io', 'https://io.sentry', false],
  192. // Sibling domains
  193. ['https://us.sentry.io', 'https://sentry.sentry.io', true],
  194. ['https://us.sentry.io', 'https://acme.sentry.io', true],
  195. ['https://us.sentry.io', 'https://eu.sentry.io', true],
  196. ['https://woof.sentry.io', 'https://woof-org.sentry.io', true],
  197. ['https://woof.sentry.io/issues/1234/', 'https://woof-org.sentry.io', true],
  198. // Subdomain
  199. ['https://sentry.io/api/0/broadcasts/', 'https://woof.sentry.io', true],
  200. ['https://sentry.io/api/0/users/', 'https://sentry.sentry.io', true],
  201. ['https://sentry.io/api/0/users/', 'https://io.sentry.io', true],
  202. // request to subdomain from parent
  203. ['https://us.sentry.io/api/0/users/', 'https://sentry.io', true],
  204. // Not siblings
  205. ['https://sentry.io/api/0/broadcasts/', 'https://sentry.example.io', false],
  206. ['https://acme.sentry.io', 'https://acme.sent.ryio', false],
  207. ['https://woof.example.io', 'https://woof.sentry.io', false],
  208. ['https://woof.sentry.io', 'https://sentry.woof.io', false],
  209. ])('allows sibling domains %s and %s is %s', (target, origin, expected) => {
  210. expect(isSimilarOrigin(target, origin)).toBe(expected);
  211. });
  212. });