Просмотр исходного кода

feat(relocation): Support multiple public key backends (#68400)

Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
Alex Zaslavsky 11 месяцев назад
Родитель
Сommit
474e7061f3

+ 5 - 0
static/app/__mocks__/api.tsx

@@ -34,6 +34,7 @@ interface ResponseType extends ApiNamespace.ResponseMeta {
   body: any;
   callCount: 0;
   headers: Record<string, string>;
+  host: string;
   match: MatchCallable[];
   method: string;
   statusCode: number;
@@ -138,6 +139,7 @@ class Client implements ApiNamespace.Client {
 
     Client.mockResponses.unshift([
       {
+        host: '',
         url: '',
         status: 200,
         statusCode: 200,
@@ -161,6 +163,9 @@ class Client implements ApiNamespace.Client {
 
   static findMockResponse(url: string, options: Readonly<ApiNamespace.RequestOptions>) {
     return Client.mockResponses.find(([response]) => {
+      if (response.host && (options.host || '') !== response.host) {
+        return false;
+      }
       if (url !== response.url) {
         return false;
       }

+ 24 - 9
static/app/views/relocation/getStarted.tsx

@@ -20,17 +20,18 @@ const PROMO_CODE_ERROR_MSG = t(
   'That promotional code has already been claimed, does not have enough remaining uses, is no longer valid, or never existed.'
 );
 
-function GetStarted(props: StepProps) {
+function GetStarted({regionUrl, onChangeRegionUrl, onComplete}: StepProps) {
   const api = useApi();
-  const [regionUrl, setRegionUrl] = useState('');
-  const [orgSlugs, setOrgSlugs] = useState('');
-  const [promoCode, setPromoCode] = useState('');
-  const [showPromoCode, setShowPromoCode] = useState(false);
   const relocationOnboardingContext = useContext(RelocationOnboardingContext);
+  const {orgSlugs, promoCode} = relocationOnboardingContext.data;
+  const [showPromoCode, setShowPromoCode] = useState(
+    !!relocationOnboardingContext.data.promoCode
+  );
   const selectableRegions = ConfigStore.get('relocationConfig')?.selectableRegions || [];
   const regions = ConfigStore.get('regions').filter(region =>
     selectableRegions.includes(region.name)
   );
+  onChangeRegionUrl(relocationOnboardingContext.data.regionUrl);
 
   const handleContinue = async (event: any) => {
     event.preventDefault();
@@ -47,7 +48,7 @@ function GetStarted(props: StepProps) {
       }
     }
     relocationOnboardingContext.setData({orgSlugs, regionUrl, promoCode});
-    props.onComplete();
+    onComplete();
   };
   return (
     <Wrapper data-test-id="get-started">
@@ -71,10 +72,17 @@ function GetStarted(props: StepProps) {
             type="text"
             name="orgs"
             aria-label="org-slugs"
-            onChange={evt => setOrgSlugs(evt.target.value)}
+            onChange={evt => {
+              relocationOnboardingContext.setData({
+                orgSlugs: evt.target.value,
+                regionUrl,
+                promoCode,
+              });
+            }}
             required
             minLength={3}
             placeholder="org-slug-1, org-slug-2, ..."
+            value={orgSlugs}
           />
           <Label>{t('Choose a datacenter location')}</Label>
           <RegionSelect
@@ -83,7 +91,7 @@ function GetStarted(props: StepProps) {
             aria-label="region"
             placeholder="Select Location"
             options={regions.map(r => ({label: r.name, value: r.url}))}
-            onChange={opt => setRegionUrl(opt.value)}
+            onChange={opt => onChangeRegionUrl(opt.value)}
           />
           {regionUrl && (
             <p>{t('This is an important decision and cannot be changed.')}</p>
@@ -108,8 +116,15 @@ function GetStarted(props: StepProps) {
                 type="text"
                 name="promocode"
                 aria-label="promocode"
-                onChange={evt => setPromoCode(evt.target.value)}
+                onChange={evt => {
+                  relocationOnboardingContext.setData({
+                    orgSlugs,
+                    regionUrl,
+                    promoCode: evt.target.value,
+                  });
+                }}
                 placeholder=""
+                value={promoCode}
               />
             </div>
           ) : (

+ 42 - 41
static/app/views/relocation/publicKey.tsx

@@ -1,3 +1,4 @@
+import {useContext} from 'react';
 import {motion} from 'framer-motion';
 
 import LoadingIndicator from 'sentry/components/loadingIndicator';
@@ -8,58 +9,58 @@ import ContinueButton from 'sentry/views/relocation/components/continueButton';
 import RelocationCodeBlock from 'sentry/views/relocation/components/relocationCodeBlock';
 import StepHeading from 'sentry/views/relocation/components/stepHeading';
 import Wrapper from 'sentry/views/relocation/components/wrapper';
+import {RelocationOnboardingContext} from 'sentry/views/relocation/relocationOnboardingContext';
 
 import type {StepProps} from './types';
 
-export function PublicKey({publicKey, onComplete}: StepProps) {
+export function PublicKey({publicKeys, onComplete}: StepProps) {
+  const relocationOnboardingContext = useContext(RelocationOnboardingContext);
+  const {regionUrl} = relocationOnboardingContext.data;
+  const publicKey = publicKeys.get(regionUrl);
   const handleContinue = (event: any) => {
     event.preventDefault();
     onComplete();
   };
 
-  const loaded = (
-    <motion.div
-      transition={testableTransition()}
-      variants={{
-        initial: {y: 30, opacity: 0},
-        animate: {y: 0, opacity: 1},
-        exit: {opacity: 0},
-      }}
-    >
-      <p>
-        {t(
-          "To do so, you'll need to save the following public key to a file accessible from wherever your self-hosted repository is currently installed. You'll need to have this public key file available for the next step."
-        )}
-      </p>
-      <RelocationCodeBlock
-        dark
-        filename="key.pub"
-        icon={<IconFile />}
-        hideCopyButton={false}
-      >
-        {publicKey}
-      </RelocationCodeBlock>
-      <ContinueButton priority="primary" type="submit" onClick={handleContinue} />
-    </motion.div>
-  );
-
-  const unloaded = (
-    <motion.div
-      transition={testableTransition()}
-      variants={{
-        initial: {y: 30, opacity: 0},
-        animate: {y: 0, opacity: 1},
-        exit: {opacity: 0},
-      }}
-    >
-      <LoadingIndicator />
-    </motion.div>
-  );
-
   return (
     <Wrapper data-test-id="public-key">
       <StepHeading step={2}>{t("Save Sentry's public key to your machine")}</StepHeading>
-      {publicKey ? loaded : unloaded}
+      {publicKey ? (
+        <motion.div
+          transition={testableTransition()}
+          variants={{
+            initial: {y: 30, opacity: 0},
+            animate: {y: 0, opacity: 1},
+            exit: {opacity: 0},
+          }}
+        >
+          <p>
+            {t(
+              "To do so, you'll need to save the following public key to a file accessible from wherever your self-hosted repository is currently installed. You'll need to have this public key file available for the next step."
+            )}
+          </p>
+          <RelocationCodeBlock
+            dark
+            filename="key.pub"
+            icon={<IconFile />}
+            hideCopyButton={false}
+          >
+            {publicKey}
+          </RelocationCodeBlock>
+          <ContinueButton priority="primary" type="submit" onClick={handleContinue} />
+        </motion.div>
+      ) : (
+        <motion.div
+          transition={testableTransition()}
+          variants={{
+            initial: {y: 30, opacity: 0},
+            animate: {y: 0, opacity: 1},
+            exit: {opacity: 0},
+          }}
+        >
+          <LoadingIndicator />
+        </motion.div>
+      )}
     </Wrapper>
   );
 }

+ 239 - 171
static/app/views/relocation/relocation.spec.tsx

@@ -14,30 +14,54 @@ import ConfigStore from 'sentry/stores/configStore';
 import Relocation from 'sentry/views/relocation/relocation';
 
 jest.mock('sentry/actionCreators/indicator');
-const fakePublicKey = `-----BEGIN PUBLIC KEY-----
-MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw5Or1zsGE1XJTL4q+1c4
-Ztu8+7SC/exrnEYlWH+LVLI8TVyuGwDTAXrgKHGwaMM5ZnjijP5i8+ph8lfLrybT
-l+2D81qPIqagEtNMDaHqUDm5Tq7I2qvxkJ5YuDLawRUPccKMwWlIDR2Gvfe3efce
-870EicPsExz4uPOkNXGHJZ/FwCQrLo87MXFeqrqj+0Cf+qwCQSCW9qFWe5cj+zqt
-eeJa0qflcHHQzxK4/EKKpl/hkt4zi0aE/PuJgvJz2KB+X3+LzekTy90LzW3VhR4y
-IAxCAaGQJVsg9dhKOORjAf4XK9aXHvy/jUSyT43opj6AgNqXlKEQjb1NBA8qbJJS
-8wIDAQAB
------END PUBLIC KEY-----`;
-const fakeRegionName = 'Narnia';
-const fakeRegionUrl = 'https://example.com';
+
+const fakeOrgSlug = 'test-org';
+const fakePromoCode = 'free-hugs';
+const fakePublicKey = `FAKE-PK-ANY`;
+
+type FakeRegion = {
+  name: string;
+  publicKey: string;
+  url: string;
+};
+
+const fakeRegions: {[key: string]: FakeRegion} = {
+  Earth: {
+    name: 'earth',
+    url: 'https://earth.example.com',
+    publicKey: 'FAKE-PK-EARTH',
+  },
+  Moon: {
+    name: 'moon',
+    url: 'https://moon.example.com',
+    publicKey: 'FAKE-PK-MOON',
+  },
+};
 
 describe('Relocation', function () {
-  let fetchExistingRelocation: jest.Mock;
-  let fetchPublicKey: jest.Mock;
+  let fetchExistingRelocations: jest.Mock;
+  let fetchPublicKeys: jest.Mock;
 
   beforeEach(function () {
-    MockApiClient.asyncDelay = undefined;
     MockApiClient.clearMockResponses();
-    fetchExistingRelocation = MockApiClient.addMockResponse({
+    MockApiClient.asyncDelay = undefined;
+    sessionStorage.clear();
+
+    ConfigStore.set('regions', [
+      {name: fakeRegions.Earth.name, url: fakeRegions.Earth.url},
+      {name: fakeRegions.Moon.name, url: fakeRegions.Moon.url},
+    ]);
+    ConfigStore.set('relocationConfig', {
+      selectableRegions: [fakeRegions.Earth.name, fakeRegions.Moon.name],
+    });
+
+    // For tests that don't care about the difference between our "earth" and "moon" regions, we can
+    // re-use the same mock responses, with the same generic public key for both.
+    fetchExistingRelocations = MockApiClient.addMockResponse({
       url: '/relocations/',
       body: [],
     });
-    fetchPublicKey = MockApiClient.addMockResponse({
+    fetchPublicKeys = MockApiClient.addMockResponse({
       url: '/publickeys/relocations/',
       body: {
         public_key: fakePublicKey,
@@ -55,6 +79,7 @@ describe('Relocation', function () {
   afterEach(function () {
     MockApiClient.clearMockResponses();
     MockApiClient.asyncDelay = undefined;
+    sessionStorage.clear();
   });
 
   function renderPage(step) {
@@ -87,7 +112,7 @@ describe('Relocation', function () {
   describe('Get Started', function () {
     it('renders', async function () {
       await waitForRenderSuccess('get-started');
-      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+      await waitFor(() => expect(fetchPublicKeys).toHaveBeenCalledTimes(2));
 
       expect(
         await screen.findByText('Basic information needed to get started')
@@ -100,7 +125,7 @@ describe('Relocation', function () {
 
     it('redirects to `in-progress` page if user already has active relocation', async function () {
       MockApiClient.clearMockResponses();
-      fetchExistingRelocation = MockApiClient.addMockResponse({
+      fetchExistingRelocations = MockApiClient.addMockResponse({
         url: '/relocations/',
         body: [
           {
@@ -109,7 +134,7 @@ describe('Relocation', function () {
           },
         ],
       });
-      fetchPublicKey = MockApiClient.addMockResponse({
+      fetchPublicKeys = MockApiClient.addMockResponse({
         url: '/publickeys/relocations/',
         body: {
           public_key: fakePublicKey,
@@ -117,65 +142,60 @@ describe('Relocation', function () {
       });
 
       await waitForRenderSuccess('get-started');
-      await waitFor(() => expect(fetchExistingRelocation).toHaveBeenCalled());
-      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+      await waitFor(() => expect(fetchExistingRelocations).toHaveBeenCalledTimes(2));
+      await waitFor(() => expect(fetchPublicKeys).toHaveBeenCalledTimes(2));
 
       expect(browserHistory.push).toHaveBeenCalledWith('/relocation/in-progress/');
     });
 
     it('should prevent user from going to the next step if no org slugs or region are entered', async function () {
       await waitForRenderSuccess('get-started');
-      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+      await waitFor(() => expect(fetchPublicKeys).toHaveBeenCalledTimes(2));
 
-      expect(await screen.getByRole('button', {name: 'Continue'})).toBeDisabled();
+      expect(screen.getByRole('button', {name: 'Continue'})).toBeDisabled();
     });
 
     it('should be allowed to go to next step if org slug is entered, region is selected, and promo code is entered', async function () {
       await waitForRenderSuccess('get-started');
-      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
-      fetchPublicKey = MockApiClient.addMockResponse({
-        url: '/promocodes-external/free-hugs',
+      await waitFor(() => expect(fetchPublicKeys).toHaveBeenCalledTimes(2));
+      const fetchPromoCode = MockApiClient.addMockResponse({
+        url: `/promocodes-external/${fakePromoCode}`,
         method: 'GET',
         statusCode: 200,
       });
 
-      ConfigStore.set('regions', [{name: fakeRegionName, url: fakeRegionUrl}]);
-      ConfigStore.set('relocationConfig', {selectableRegions: [fakeRegionName]});
-      const orgSlugsInput = await screen.getByLabelText('org-slugs');
-      const continueButton = await screen.getByRole('button', {name: 'Continue'});
-      await userEvent.type(orgSlugsInput, 'test-org');
-      await userEvent.type(await screen.getByLabelText('region'), 'Narnia');
-      await userEvent.click(await screen.getByRole('menuitemradio'));
-      expect(continueButton).toBeEnabled();
+      await userEvent.type(screen.getByLabelText('org-slugs'), fakeOrgSlug);
+      await userEvent.type(screen.getByLabelText('region'), fakeRegions.Earth.name);
+      await userEvent.click(screen.getByRole('menuitemradio'));
+      expect(screen.getByRole('button', {name: 'Continue'})).toBeEnabled();
+
       await userEvent.click(screen.getByText('Got a promo code?', {exact: false}));
-      const promoCodeInput = await screen.getByLabelText('promocode');
-      await userEvent.type(promoCodeInput, 'free-hugs');
-      await userEvent.click(continueButton);
-      expect(addErrorMessage).not.toHaveBeenCalledWith();
-      sessionStorage.clear();
+      await userEvent.type(screen.getByLabelText('promocode'), fakePromoCode);
+      await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
+      await waitFor(() => expect(fetchPromoCode).toHaveBeenCalledTimes(1));
+      expect(addErrorMessage).not.toHaveBeenCalled();
     });
 
     it('should not be allowed to go to next step if org slug is entered, region is selected, and promo code is invalid', async function () {
       await waitForRenderSuccess('get-started');
-      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
-      fetchPublicKey = MockApiClient.addMockResponse({
-        url: '/promocodes-external/free-hugs',
+      await waitFor(() => expect(fetchPublicKeys).toHaveBeenCalledTimes(2));
+      const fetchPromoCode = MockApiClient.addMockResponse({
+        url: `/promocodes-external/${fakePromoCode}`,
         method: 'GET',
         statusCode: 403,
       });
 
-      ConfigStore.set('regions', [{name: fakeRegionName, url: fakeRegionUrl}]);
-      ConfigStore.set('relocationConfig', {selectableRegions: [fakeRegionName]});
-      const orgSlugsInput = await screen.getByLabelText('org-slugs');
-      const continueButton = await screen.getByRole('button', {name: 'Continue'});
-      await userEvent.type(orgSlugsInput, 'test-org');
-      await userEvent.type(await screen.getByLabelText('region'), 'Narnia');
-      await userEvent.click(await screen.getByRole('menuitemradio'));
-      expect(continueButton).toBeEnabled();
+      await userEvent.type(screen.getByLabelText('org-slugs'), fakeOrgSlug);
+      await userEvent.type(screen.getByLabelText('region'), fakeRegions.Earth.name);
+      await userEvent.click(screen.getByRole('menuitemradio'));
+      expect(screen.getByRole('button', {name: 'Continue'})).toBeEnabled();
+
       await userEvent.click(screen.getByText('Got a promo code?', {exact: false}));
-      const promoCodeInput = await screen.getByLabelText('promocode');
-      await userEvent.type(promoCodeInput, 'free-hugs');
-      await userEvent.click(continueButton);
+      await userEvent.type(screen.getByLabelText('promocode'), fakePromoCode);
+      expect(screen.getByRole('button', {name: 'Continue'})).toBeEnabled();
+
+      await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
+      await waitFor(() => expect(fetchPromoCode).toHaveBeenCalledTimes(1));
       expect(addErrorMessage).toHaveBeenCalledWith(
         'That promotional code has already been claimed, does not have enough remaining uses, is no longer valid, or never existed.'
       );
@@ -183,11 +203,19 @@ describe('Relocation', function () {
 
     it('should show loading indicator and error message if existing relocation retrieval failed', async function () {
       MockApiClient.clearMockResponses();
-      fetchExistingRelocation = MockApiClient.addMockResponse({
-        url: '/relocations/',
+
+      // Note: only one fails, but that is enough.
+      const failingFetchExistingEarthRelocation = MockApiClient.addMockResponse({
+        host: fakeRegions.Earth.url,
+        url: `/relocations/`,
         statusCode: 400,
       });
-      fetchPublicKey = MockApiClient.addMockResponse({
+      const successfulFetchExistingMoonRelocation = MockApiClient.addMockResponse({
+        host: fakeRegions.Moon.url,
+        url: '/relocations/',
+        body: [],
+      });
+      fetchPublicKeys = MockApiClient.addMockResponse({
         url: '/publickeys/relocations/',
         body: {
           public_key: fakePublicKey,
@@ -195,40 +223,60 @@ describe('Relocation', function () {
       });
 
       await waitForRenderError('get-started');
-      await waitFor(() => expect(fetchExistingRelocation).toHaveBeenCalled());
+      await waitFor(() =>
+        expect(failingFetchExistingEarthRelocation).toHaveBeenCalledTimes(1)
+      );
+      await waitFor(() =>
+        expect(successfulFetchExistingMoonRelocation).toHaveBeenCalledTimes(1)
+      );
 
-      expect(fetchPublicKey).toHaveBeenCalledTimes(1);
-      expect(
-        await screen.queryByRole('button', {name: 'Continue'})
-      ).not.toBeInTheDocument();
-      expect(await screen.queryByLabelText('org-slugs')).not.toBeInTheDocument();
-      expect(await screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
+      expect(fetchPublicKeys).toHaveBeenCalledTimes(2);
+      expect(screen.queryByRole('button', {name: 'Continue'})).not.toBeInTheDocument();
+      expect(screen.queryByLabelText('org-slugs')).not.toBeInTheDocument();
+      expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
 
-      MockApiClient.addMockResponse({
+      const successfulFetchExistingEarthRelocation = MockApiClient.addMockResponse({
+        host: fakeRegions.Earth.url,
         url: '/relocations/',
         body: [],
       });
 
       await userEvent.click(screen.getByRole('button', {name: 'Retry'}));
-      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+      await waitFor(() => expect(fetchPublicKeys).toHaveBeenCalledTimes(2));
       await waitFor(() => expect(screen.getByTestId('get-started')).toBeInTheDocument());
 
-      expect(fetchExistingRelocation).toHaveBeenCalledTimes(1);
-      expect(await screen.queryByLabelText('org-slugs')).toBeInTheDocument();
-      expect(await screen.queryByRole('button', {name: 'Continue'})).toBeInTheDocument();
+      await waitFor(() =>
+        expect(successfulFetchExistingEarthRelocation).toHaveBeenCalledTimes(1)
+      );
+      await waitFor(() =>
+        expect(successfulFetchExistingMoonRelocation).toHaveBeenCalledTimes(2)
+      );
+      expect(screen.queryByLabelText('org-slugs')).toBeInTheDocument();
+      expect(screen.queryByRole('button', {name: 'Continue'})).toBeInTheDocument();
     });
   });
 
   describe('Public Key', function () {
+    beforeEach(function () {
+      sessionStorage.setItem(
+        'relocationOnboarding',
+        JSON.stringify({
+          orgSlugs: fakeOrgSlug,
+          promoCode: fakePromoCode,
+          regionUrl: fakeRegions.Earth.url,
+        })
+      );
+    });
+
     it('should show instructions if key retrieval was successful', async function () {
       await waitForRenderSuccess('public-key');
-      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+      await waitFor(() => expect(fetchPublicKeys).toHaveBeenCalledTimes(2));
 
       expect(
         await screen.findByText("Save Sentry's public key to your machine")
       ).toBeInTheDocument();
-      expect(await screen.getByText('key.pub')).toBeInTheDocument();
-      expect(await screen.getByRole('button', {name: 'Continue'})).toBeInTheDocument();
+      expect(screen.getByText('key.pub')).toBeInTheDocument();
+      expect(screen.getByRole('button', {name: 'Continue'})).toBeInTheDocument();
     });
 
     it('should show loading indicator if key retrieval still in progress', function () {
@@ -242,46 +290,57 @@ describe('Relocation', function () {
 
     it('should show loading indicator and error message if key retrieval failed', async function () {
       MockApiClient.clearMockResponses();
-      fetchExistingRelocation = MockApiClient.addMockResponse({
+      fetchExistingRelocations = MockApiClient.addMockResponse({
         url: '/relocations/',
         body: [],
       });
-      fetchPublicKey = MockApiClient.addMockResponse({
-        url: '/publickeys/relocations/',
+
+      // Note: only one fails, but that is enough.
+      const failingFetchEarthPublicKey = MockApiClient.addMockResponse({
+        host: fakeRegions.Earth.url,
+        url: `/publickeys/relocations/`,
         statusCode: 400,
       });
+      const successfulFetchMoonPublicKey = MockApiClient.addMockResponse({
+        host: fakeRegions.Moon.url,
+        url: '/publickeys/relocations/',
+        body: {
+          public_key: fakeRegions.Moon.publicKey,
+        },
+      });
 
       await waitForRenderError('public-key');
-      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+      await waitFor(() => expect(failingFetchEarthPublicKey).toHaveBeenCalledTimes(1));
+      await waitFor(() => expect(successfulFetchMoonPublicKey).toHaveBeenCalledTimes(1));
 
-      expect(fetchExistingRelocation).toHaveBeenCalledTimes(1);
-      expect(
-        await screen.queryByRole('button', {name: 'Continue'})
-      ).not.toBeInTheDocument();
-      expect(await screen.queryByText('key.pub')).not.toBeInTheDocument();
-      expect(await screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
+      expect(fetchExistingRelocations).toHaveBeenCalledTimes(2);
+      expect(screen.queryByRole('button', {name: 'Continue'})).not.toBeInTheDocument();
+      expect(screen.queryByText('key.pub')).not.toBeInTheDocument();
+      expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
 
-      MockApiClient.addMockResponse({
+      const successfulFetchEarthPublicKey = MockApiClient.addMockResponse({
+        host: fakeRegions.Earth.url,
         url: '/publickeys/relocations/',
         body: {
-          public_key: fakePublicKey,
+          public_key: fakeRegions.Earth.publicKey,
         },
       });
 
       await userEvent.click(screen.getByRole('button', {name: 'Retry'}));
-      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+      await waitFor(() => expect(successfulFetchEarthPublicKey).toHaveBeenCalledTimes(1));
+      await waitFor(() => expect(successfulFetchMoonPublicKey).toHaveBeenCalledTimes(2));
       await waitFor(() => expect(screen.getByTestId('public-key')).toBeInTheDocument());
 
-      expect(fetchExistingRelocation).toHaveBeenCalledTimes(1);
-      expect(await screen.queryByText('key.pub')).toBeInTheDocument();
-      expect(await screen.queryByRole('button', {name: 'Continue'})).toBeInTheDocument();
+      expect(fetchExistingRelocations).toHaveBeenCalledTimes(2);
+      expect(screen.queryByText('key.pub')).toBeInTheDocument();
+      expect(screen.queryByRole('button', {name: 'Continue'})).toBeInTheDocument();
     });
   });
 
   describe('Encrypt Backup', function () {
     it('renders', async function () {
       await waitForRenderSuccess('encrypt-backup');
-      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+      await waitFor(() => expect(fetchPublicKeys).toHaveBeenCalledTimes(2));
 
       expect(
         await screen.findByText(
@@ -292,6 +351,17 @@ describe('Relocation', function () {
   });
 
   describe('Upload Backup', function () {
+    beforeEach(function () {
+      sessionStorage.setItem(
+        'relocationOnboarding',
+        JSON.stringify({
+          orgSlugs: fakeOrgSlug,
+          promoCode: fakePromoCode,
+          regionUrl: fakeRegions.Earth.url,
+        })
+      );
+    });
+
     it('renders', async function () {
       await waitForRenderSuccess('upload-backup');
       expect(
@@ -301,27 +371,29 @@ describe('Relocation', function () {
 
     it('accepts a file upload', async function () {
       await waitForRenderSuccess('upload-backup');
-      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
-      const input = screen.getByLabelText('file-upload');
-      await userEvent.upload(input, relocationFile);
+      await userEvent.upload(
+        screen.getByLabelText('file-upload'),
+        new File(['hello'], 'hello.tar', {type: 'file'})
+      );
       expect(await screen.findByText('hello.tar')).toBeInTheDocument();
       expect(await screen.findByText('Start Relocation')).toBeInTheDocument();
     });
 
     it('accepts a file upload through drag and drop', async function () {
       await waitForRenderSuccess('upload-backup');
-      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
-      const dropzone = screen.getByLabelText('dropzone');
-      fireEvent.drop(dropzone, {dataTransfer: {files: [relocationFile]}});
+      fireEvent.drop(screen.getByLabelText('dropzone'), {
+        dataTransfer: {files: [new File(['hello'], 'hello.tar', {type: 'file'})]},
+      });
       expect(await screen.findByText('hello.tar')).toBeInTheDocument();
       expect(await screen.findByText('Start Relocation')).toBeInTheDocument();
     });
 
     it('correctly removes file and prompts for file upload', async function () {
       await waitForRenderSuccess('upload-backup');
-      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
-      const input = screen.getByLabelText('file-upload');
-      await userEvent.upload(input, relocationFile);
+      await userEvent.upload(
+        screen.getByLabelText('file-upload'),
+        new File(['hello'], 'hello.tar', {type: 'file'})
+      );
       await userEvent.click(screen.getByText('Remove file'));
       expect(screen.queryByText('hello.tar')).not.toBeInTheDocument();
       expect(
@@ -330,23 +402,33 @@ describe('Relocation', function () {
     });
 
     it('fails to starts relocation job if some form data is missing', async function () {
-      const mockapi = MockApiClient.addMockResponse({
+      // Remove `orgSlugs` from session storage; this will act as the "missing form data".
+      sessionStorage.setItem(
+        'relocationOnboarding',
+        JSON.stringify({
+          promoCode: fakePromoCode,
+          regionUrl: fakeRegions.Earth.url,
+        })
+      );
+
+      const postRelocation = MockApiClient.addMockResponse({
         url: `/relocations/`,
         method: 'POST',
       });
       await waitForRenderSuccess('upload-backup');
-      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
-      const input = screen.getByLabelText('file-upload');
-      await userEvent.upload(input, relocationFile);
+      await userEvent.upload(
+        screen.getByLabelText('file-upload'),
+        new File(['hello'], 'hello.tar', {type: 'file'})
+      );
       await userEvent.click(await screen.findByText('Start Relocation'));
-      await waitFor(() => expect(mockapi).not.toHaveBeenCalled());
+      await waitFor(() => expect(postRelocation).not.toHaveBeenCalled());
       expect(addErrorMessage).toHaveBeenCalledWith(
         'An error has occurred while trying to start relocation job. Please contact support for further assistance.'
       );
     });
 
     it('starts relocation job if form data is available from previous steps', async function () {
-      const mockapi = MockApiClient.addMockResponse({
+      const postRelocation = MockApiClient.addMockResponse({
         url: `/relocations/`,
         method: 'POST',
         responseJSON: [
@@ -358,24 +440,21 @@ describe('Relocation', function () {
       });
 
       await waitForRenderSuccess('get-started');
-      ConfigStore.set('regions', [{name: fakeRegionName, url: fakeRegionUrl}]);
-      ConfigStore.set('relocationConfig', {selectableRegions: [fakeRegionName]});
-      const orgSlugsInput = await screen.getByLabelText('org-slugs');
-      const continueButton = await screen.getByRole('button', {name: 'Continue'});
-      await userEvent.type(orgSlugsInput, 'test-org');
-      await userEvent.type(screen.getByLabelText('region'), 'Narnia');
+      await userEvent.type(screen.getByLabelText('org-slugs'), fakeOrgSlug);
+      await userEvent.type(screen.getByLabelText('region'), fakeRegions.Earth.name);
       await userEvent.click(screen.getByRole('menuitemradio'));
-      await userEvent.click(continueButton);
+      await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
 
       await waitForRenderSuccess('upload-backup');
-      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
-      const input = screen.getByLabelText('file-upload');
-      await userEvent.upload(input, relocationFile);
+      await userEvent.upload(
+        screen.getByLabelText('file-upload'),
+        new File(['hello'], 'hello.tar', {type: 'file'})
+      );
       await userEvent.click(await screen.findByText('Start Relocation'));
       await waitFor(() =>
-        expect(mockapi).toHaveBeenCalledWith(
+        expect(postRelocation).toHaveBeenCalledWith(
           '/relocations/',
-          expect.objectContaining({host: fakeRegionUrl, method: 'POST'})
+          expect.objectContaining({host: fakeRegions.Earth.url, method: 'POST'})
         )
       );
       expect(addSuccessMessage).toHaveBeenCalledWith(
@@ -386,109 +465,98 @@ describe('Relocation', function () {
     });
 
     it('throws error if user already has an in-progress relocation job', async function () {
-      const mockapi = MockApiClient.addMockResponse({
+      const postRelocation = MockApiClient.addMockResponse({
         url: `/relocations/`,
         method: 'POST',
         statusCode: 409,
       });
 
       await waitForRenderSuccess('get-started');
-      ConfigStore.set('regions', [{name: fakeRegionName, url: fakeRegionUrl}]);
-      ConfigStore.set('relocationConfig', {selectableRegions: [fakeRegionName]});
-      const orgSlugsInput = screen.getByLabelText('org-slugs');
-      const continueButton = screen.getByRole('button', {name: 'Continue'});
-      await userEvent.type(orgSlugsInput, 'test-org');
-      await userEvent.type(screen.getByLabelText('region'), 'Narnia');
+      await userEvent.type(screen.getByLabelText('org-slugs'), fakeOrgSlug);
+      await userEvent.type(screen.getByLabelText('region'), fakeRegions.Earth.name);
       await userEvent.click(screen.getByRole('menuitemradio'));
-      await userEvent.click(continueButton);
+      await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
 
       await waitForRenderSuccess('upload-backup');
-      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
-      const input = screen.getByLabelText('file-upload');
-      await userEvent.upload(input, relocationFile);
+      await userEvent.upload(
+        screen.getByLabelText('file-upload'),
+        new File(['hello'], 'hello.tar', {type: 'file'})
+      );
       await userEvent.click(await screen.findByText('Start Relocation'));
-      await waitFor(() => expect(mockapi).toHaveBeenCalled());
+      await waitFor(() => expect(postRelocation).toHaveBeenCalledTimes(1));
       expect(addErrorMessage).toHaveBeenCalledWith(
         'You already have an in-progress relocation job.'
       );
     });
 
     it('throws error if daily limit of relocations has been reached', async function () {
-      const mockapi = MockApiClient.addMockResponse({
+      const postRelocation = MockApiClient.addMockResponse({
         url: `/relocations/`,
         method: 'POST',
         statusCode: 429,
       });
 
       await waitForRenderSuccess('get-started');
-      ConfigStore.set('regions', [{name: fakeRegionName, url: fakeRegionUrl}]);
-      ConfigStore.set('relocationConfig', {selectableRegions: [fakeRegionName]});
-      const orgSlugsInput = screen.getByLabelText('org-slugs');
-      const continueButton = screen.getByRole('button', {name: 'Continue'});
-      await userEvent.type(orgSlugsInput, 'test-org');
-      await userEvent.type(screen.getByLabelText('region'), 'Narnia');
+      await userEvent.type(screen.getByLabelText('org-slugs'), fakeOrgSlug);
+      await userEvent.type(screen.getByLabelText('region'), fakeRegions.Earth.name);
       await userEvent.click(screen.getByRole('menuitemradio'));
-      await userEvent.click(continueButton);
+      await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
+
       await waitForRenderSuccess('upload-backup');
-      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
-      const input = screen.getByLabelText('file-upload');
-      await userEvent.upload(input, relocationFile);
+      await userEvent.upload(
+        screen.getByLabelText('file-upload'),
+        new File(['hello'], 'hello.tar', {type: 'file'})
+      );
       await userEvent.click(await screen.findByText('Start Relocation'));
-      await waitFor(() => expect(mockapi).toHaveBeenCalled());
+      await waitFor(() => expect(postRelocation).toHaveBeenCalledTimes(1));
       expect(addErrorMessage).toHaveBeenCalledWith(
         'We have reached the daily limit of relocations - please try again tomorrow, or contact support.'
       );
     });
 
     it('throws error if user session has expired', async function () {
-      const mockapi = MockApiClient.addMockResponse({
+      const postRelocation = MockApiClient.addMockResponse({
         url: `/relocations/`,
         method: 'POST',
         statusCode: 401,
       });
-      ConfigStore.set('regions', [{name: fakeRegionName, url: fakeRegionUrl}]);
-      ConfigStore.set('relocationConfig', {selectableRegions: [fakeRegionName]});
 
       await waitForRenderSuccess('get-started');
-      const orgSlugsInput = screen.getByLabelText('org-slugs');
-      const continueButton = screen.getByRole('button', {name: 'Continue'});
-      await userEvent.type(orgSlugsInput, 'test-org');
-      await userEvent.type(screen.getByLabelText('region'), 'Narnia');
+      await userEvent.type(screen.getByLabelText('org-slugs'), fakeOrgSlug);
+      await userEvent.type(screen.getByLabelText('region'), fakeRegions.Earth.name);
       await userEvent.click(screen.getByRole('menuitemradio'));
-      await userEvent.click(continueButton);
+      await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
 
       await waitForRenderSuccess('upload-backup');
-      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
-      const input = screen.getByLabelText('file-upload');
-      await userEvent.upload(input, relocationFile);
+      await userEvent.upload(
+        screen.getByLabelText('file-upload'),
+        new File(['hello'], 'hello.tar', {type: 'file'})
+      );
       await userEvent.click(await screen.findByText('Start Relocation'));
-      await waitFor(() => expect(mockapi).toHaveBeenCalled());
+      await waitFor(() => expect(postRelocation).toHaveBeenCalledTimes(1));
       expect(addErrorMessage).toHaveBeenCalledWith('Your session has expired.');
     });
 
     it('throws error for 500 error', async function () {
-      const mockapi = MockApiClient.addMockResponse({
+      const postRelocation = MockApiClient.addMockResponse({
         url: `/relocations/`,
         method: 'POST',
         statusCode: 500,
       });
-      ConfigStore.set('regions', [{name: fakeRegionName, url: fakeRegionUrl}]);
-      ConfigStore.set('relocationConfig', {selectableRegions: [fakeRegionName]});
 
       await waitForRenderSuccess('get-started');
-      const orgSlugsInput = screen.getByLabelText('org-slugs');
-      const continueButton = screen.getByRole('button', {name: 'Continue'});
-      await userEvent.type(orgSlugsInput, 'test-org');
-      await userEvent.type(screen.getByLabelText('region'), 'Narnia');
+      await userEvent.type(screen.getByLabelText('org-slugs'), fakeOrgSlug);
+      await userEvent.type(screen.getByLabelText('region'), fakeRegions.Earth.name);
       await userEvent.click(screen.getByRole('menuitemradio'));
-      await userEvent.click(continueButton);
+      await userEvent.click(screen.getByRole('button', {name: 'Continue'}));
 
       await waitForRenderSuccess('upload-backup');
-      const relocationFile = new File(['hello'], 'hello.tar', {type: 'file'});
-      const input = screen.getByLabelText('file-upload');
-      await userEvent.upload(input, relocationFile);
+      await userEvent.upload(
+        screen.getByLabelText('file-upload'),
+        new File(['hello'], 'hello.tar', {type: 'file'})
+      );
       await userEvent.click(await screen.findByText('Start Relocation'));
-      await waitFor(() => expect(mockapi).toHaveBeenCalled());
+      await waitFor(() => expect(postRelocation).toHaveBeenCalledTimes(1));
       expect(addErrorMessage).toHaveBeenCalledWith(
         'An error has occurred while trying to start relocation job. Please contact support for further assistance.'
       );
@@ -498,7 +566,7 @@ describe('Relocation', function () {
   describe('In Progress', function () {
     it('renders', async function () {
       MockApiClient.clearMockResponses();
-      fetchExistingRelocation = MockApiClient.addMockResponse({
+      fetchExistingRelocations = MockApiClient.addMockResponse({
         url: '/relocations/',
         body: [
           {
@@ -507,7 +575,7 @@ describe('Relocation', function () {
           },
         ],
       });
-      fetchPublicKey = MockApiClient.addMockResponse({
+      fetchPublicKeys = MockApiClient.addMockResponse({
         url: '/publickeys/relocations/',
         body: {
           public_key: fakePublicKey,
@@ -522,15 +590,15 @@ describe('Relocation', function () {
 
     it('redirects to `get-started` page if there is no existing relocation', async function () {
       await waitForRenderSuccess('in-progress');
-      await waitFor(() => expect(fetchExistingRelocation).toHaveBeenCalled());
-      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+      await waitFor(() => expect(fetchExistingRelocations).toHaveBeenCalledTimes(2));
+      await waitFor(() => expect(fetchPublicKeys).toHaveBeenCalledTimes(2));
 
       expect(browserHistory.push).toHaveBeenCalledWith('/relocation/get-started/');
     });
 
     it('redirects to `get-started` page if there is no active relocation', async function () {
       MockApiClient.clearMockResponses();
-      fetchExistingRelocation = MockApiClient.addMockResponse({
+      fetchExistingRelocations = MockApiClient.addMockResponse({
         url: '/relocations/',
         body: [
           {
@@ -539,7 +607,7 @@ describe('Relocation', function () {
           },
         ],
       });
-      fetchPublicKey = MockApiClient.addMockResponse({
+      fetchPublicKeys = MockApiClient.addMockResponse({
         url: '/publickeys/relocations/',
         body: {
           public_key: fakePublicKey,
@@ -547,8 +615,8 @@ describe('Relocation', function () {
       });
 
       await waitForRenderSuccess('in-progress');
-      await waitFor(() => expect(fetchExistingRelocation).toHaveBeenCalled());
-      await waitFor(() => expect(fetchPublicKey).toHaveBeenCalled());
+      await waitFor(() => expect(fetchExistingRelocations).toHaveBeenCalledTimes(2));
+      await waitFor(() => expect(fetchPublicKeys).toHaveBeenCalledTimes(2));
 
       expect(browserHistory.push).toHaveBeenCalledWith('/relocation/get-started/');
     });

+ 37 - 25
static/app/views/relocation/relocation.tsx

@@ -87,15 +87,13 @@ function RelocationOnboarding(props: Props) {
   const api = useApi();
   const regions = ConfigStore.get('regions');
 
+  const [regionUrl, setRegionUrl] = useState('');
   const [existingRelocationState, setExistingRelocationState] = useState(
     LoadingState.FETCHING
   );
   const [existingRelocation, setExistingRelocation] = useState('');
-
-  // TODO(getsentry/team-ospo#214): We should use sessionStorage to track this, since it should not
-  // change during a single run through this workflow.
-  const [publicKey, setPublicKey] = useState('');
-  const [publicKeyState, setPublicKeyState] = useState(LoadingState.FETCHING);
+  const [publicKeys, setPublicKeys] = useState(new Map<string, string>());
+  const [publicKeysState, setPublicKeysState] = useState(LoadingState.FETCHING);
 
   const fetchExistingRelocation = useCallback(() => {
     setExistingRelocationState(LoadingState.FETCHING);
@@ -138,24 +136,32 @@ function RelocationOnboarding(props: Props) {
     fetchExistingRelocation();
   }, [fetchExistingRelocation]);
 
-  const fetchPublicKey = useCallback(() => {
-    const endpoint = `/publickeys/relocations/`;
-    setPublicKeyState(LoadingState.FETCHING);
-
-    return api
-      .requestPromise(endpoint)
-      .then(response => {
-        setPublicKey(response.public_key);
-        setPublicKeyState(LoadingState.FETCHED);
+  const fetchPublicKeys = useCallback(() => {
+    setPublicKeysState(LoadingState.FETCHING);
+    return Promise.all(
+      regions.map(region =>
+        api.requestPromise(`/publickeys/relocations/`, {
+          method: 'GET',
+          host: region.url,
+        })
+      )
+    )
+      .then(responses => {
+        setPublicKeys(
+          new Map<string, string>(
+            regions.map((region, index) => [region.url, responses[index].public_key])
+          )
+        );
+        setPublicKeysState(LoadingState.FETCHED);
       })
       .catch(_error => {
-        setPublicKey('');
-        setPublicKeyState(LoadingState.ERROR);
+        setPublicKeys(new Map<string, string>());
+        setPublicKeysState(LoadingState.ERROR);
       });
-  }, [api]);
+  }, [api, regions]);
   useEffect(() => {
-    fetchPublicKey();
-  }, [fetchPublicKey]);
+    fetchPublicKeys();
+  }, [fetchPublicKeys]);
 
   const cornerVariantTimeoutRed = useRef<number | undefined>(undefined);
   useEffect(() => {
@@ -238,7 +244,7 @@ function RelocationOnboarding(props: Props) {
 
   const isLoading =
     existingRelocationState !== LoadingState.FETCHED ||
-    publicKeyState !== LoadingState.FETCHED;
+    publicKeysState !== LoadingState.FETCHED;
   const contentView = isLoading ? (
     <LoadingIndicator />
   ) : (
@@ -250,6 +256,11 @@ function RelocationOnboarding(props: Props) {
             data-test-id={`onboarding-step-${stepObj.id}`}
             existingRelocationUUID={existingRelocation}
             stepIndex={stepIndex}
+            onChangeRegionUrl={(regUrl?) => {
+              if (regUrl) {
+                setRegionUrl(regUrl);
+              }
+            }}
             onComplete={(uuid?) => {
               if (uuid) {
                 setExistingRelocation(uuid);
@@ -258,7 +269,8 @@ function RelocationOnboarding(props: Props) {
                 goNextStep(stepObj);
               }
             }}
-            publicKey={publicKey}
+            publicKeys={publicKeys}
+            regionUrl={regionUrl}
             route={props.route}
             router={props.router}
             location={props.location}
@@ -270,17 +282,17 @@ function RelocationOnboarding(props: Props) {
 
   const hasErr =
     existingRelocationState === LoadingState.ERROR ||
-    publicKeyState === LoadingState.ERROR;
+    publicKeysState === LoadingState.ERROR;
   const errView = hasErr ? (
     <LoadingError
       data-test-id="loading-error"
       message={t('Failed to load information from server - check your connection?')}
       onRetry={() => {
-        if (existingRelocationState) {
+        if (existingRelocationState === LoadingState.ERROR) {
           fetchExistingRelocation();
         }
-        if (publicKeyState) {
-          fetchPublicKey();
+        if (publicKeysState === LoadingState.ERROR) {
+          fetchPublicKeys();
         }
       }}
     />

+ 3 - 1
static/app/views/relocation/types.ts

@@ -6,8 +6,10 @@ export type StepProps = Pick<
 > & {
   active: boolean;
   existingRelocationUUID: string;
+  onChangeRegionUrl: (regionUrl?: string) => void;
   onComplete: (uuid?: string) => void;
-  publicKey: string;
+  publicKeys: Map<string, string>;
+  regionUrl: string;
   stepIndex: number;
 };