vitess.io/vitess@v0.16.2/web/vtadmin/src/api/http.test.ts (about)

     1  /**
     2   * Copyright 2021 The Vitess Authors.
     3   *
     4   * Licensed under the Apache License, Version 2.0 (the "License");
     5   * you may not use this file except in compliance with the License.
     6   * You may obtain a copy of the License at
     7   *
     8   *     http://www.apache.org/licenses/LICENSE-2.0
     9   *
    10   * Unless required by applicable law or agreed to in writing, software
    11   * distributed under the License is distributed on an "AS IS" BASIS,
    12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13   * See the License for the specific language governing permissions and
    14   * limitations under the License.
    15   */
    16  import { rest } from 'msw';
    17  import { setupServer } from 'msw/node';
    18  
    19  import * as api from './http';
    20  import {
    21      HttpFetchError,
    22      HttpResponseNotOkError,
    23      HTTP_RESPONSE_NOT_OK_ERROR,
    24      MalformedHttpResponseError,
    25      MALFORMED_HTTP_RESPONSE_ERROR,
    26  } from '../errors/errorTypes';
    27  import * as errorHandler from '../errors/errorHandler';
    28  
    29  jest.mock('../errors/errorHandler');
    30  
    31  // This test suite uses Mock Service Workers (https://github.com/mswjs/msw)
    32  // to mock HTTP responses from vtadmin-api.
    33  //
    34  // MSW lets us intercept requests at the network level. This decouples the tests from
    35  // whatever particular HTTP fetcher interface we are using, and obviates the need
    36  // to mock `fetch` directly (by using a library like jest-fetch-mock, for example).
    37  //
    38  // MSW gives us full control over the response, including edge cases like errors,
    39  // malformed payloads, and timeouts.
    40  //
    41  // The big downside to mocking or "faking" APIs like vtadmin is that
    42  // we end up re-implementing some (or all) of vtadmin-api in our test environment.
    43  // It is, unfortunately, impossible to completely avoid this kind of duplication
    44  // unless we solely use e2e tests (which have their own trade-offs).
    45  //
    46  // That said, our use of protobufjs to validate and strongly type HTTP responses
    47  // means our fake is more robust than it would be otherwise. Since we are using
    48  // the exact same protos in our fake as in our real vtadmin-api server, we're guaranteed
    49  // to have type parity.
    50  const server = setupServer();
    51  
    52  // mockServerJson configures an HttpOkResponse containing the given `json`
    53  // for all requests made against the given `endpoint`.
    54  const mockServerJson = (endpoint: string, json: object) => {
    55      server.use(rest.get(endpoint, (req, res, ctx) => res(ctx.json(json))));
    56  };
    57  
    58  // Since vtadmin uses process.env variables quite a bit, we need to
    59  // do a bit of a dance to clear them out between test runs.
    60  const ORIGINAL_PROCESS_ENV = process.env;
    61  const TEST_PROCESS_ENV = {
    62      ...process.env,
    63      REACT_APP_VTADMIN_API_ADDRESS: '',
    64  };
    65  
    66  beforeAll(() => {
    67      // TypeScript can get a little cranky with the automatic
    68      // string/boolean type conversions, hence this cast.
    69      process.env = { ...TEST_PROCESS_ENV } as NodeJS.ProcessEnv;
    70  
    71      // Enable API mocking before tests.
    72      server.listen();
    73  });
    74  
    75  afterEach(() => {
    76      // Reset the process.env to clear out any changes made in the tests.
    77      process.env = { ...TEST_PROCESS_ENV } as NodeJS.ProcessEnv;
    78  
    79      jest.restoreAllMocks();
    80  
    81      // Reset any runtime request handlers we may add during the tests.
    82      server.resetHandlers();
    83  });
    84  
    85  afterAll(() => {
    86      process.env = { ...ORIGINAL_PROCESS_ENV };
    87  
    88      // Disable API mocking after the tests are done.
    89      server.close();
    90  });
    91  
    92  describe('api/http', () => {
    93      describe('vtfetch', () => {
    94          it('parses and returns JSON, given an HttpOkResponse response', async () => {
    95              const endpoint = `/api/tablets`;
    96              const response = { ok: true, result: null };
    97              mockServerJson(endpoint, response);
    98  
    99              const result = await api.vtfetch(endpoint);
   100              expect(result).toEqual(response);
   101          });
   102  
   103          it('throws an error if response.ok is false', async () => {
   104              const endpoint = `/api/tablets`;
   105              const response = {
   106                  ok: false,
   107                  error: {
   108                      code: 'oh_no',
   109                      message: 'something went wrong',
   110                  },
   111              };
   112  
   113              // See https://mswjs.io/docs/recipes/mocking-error-responses
   114              server.use(rest.get(endpoint, (req, res, ctx) => res(ctx.status(500), ctx.json(response))));
   115  
   116              expect.assertions(5);
   117  
   118              try {
   119                  await api.fetchTablets();
   120              } catch (error) {
   121                  let e: HttpResponseNotOkError = error as HttpResponseNotOkError;
   122                  /* eslint-disable jest/no-conditional-expect */
   123                  expect(e.name).toEqual(HTTP_RESPONSE_NOT_OK_ERROR);
   124                  expect(e.message).toEqual('[status 500] /api/tablets: oh_no something went wrong');
   125                  expect(e.response).toEqual(response);
   126  
   127                  expect(errorHandler.notify).toHaveBeenCalledTimes(1);
   128                  expect(errorHandler.notify).toHaveBeenCalledWith(e);
   129                  /* eslint-enable jest/no-conditional-expect */
   130              }
   131          });
   132  
   133          it('throws an error on malformed JSON', async () => {
   134              const endpoint = `/api/tablets`;
   135              server.use(
   136                  rest.get(endpoint, (req, res, ctx) =>
   137                      res(ctx.status(504), ctx.body('<html><head><title>504 Gateway Time-out</title></head></html>'))
   138                  )
   139              );
   140  
   141              expect.assertions(4);
   142  
   143              try {
   144                  await api.vtfetch(endpoint);
   145              } catch (error) {
   146                  let e: MalformedHttpResponseError = error as MalformedHttpResponseError;
   147                  /* eslint-disable jest/no-conditional-expect */
   148                  expect(e.name).toEqual(MALFORMED_HTTP_RESPONSE_ERROR);
   149                  expect(e.message).toEqual('[status 504] /api/tablets: Unexpected token < in JSON at position 0');
   150  
   151                  expect(errorHandler.notify).toHaveBeenCalledTimes(1);
   152                  expect(errorHandler.notify).toHaveBeenCalledWith(e);
   153                  /* eslint-enable jest/no-conditional-expect */
   154              }
   155          });
   156  
   157          it('throws an error on malformed response envelopes', async () => {
   158              const endpoint = `/api/tablets`;
   159              mockServerJson(endpoint, { foo: 'bar' });
   160  
   161              expect.assertions(1);
   162  
   163              try {
   164                  await api.vtfetch(endpoint);
   165              } catch (error) {
   166                  let e: MalformedHttpResponseError = error as MalformedHttpResponseError;
   167                  /* eslint-disable jest/no-conditional-expect */
   168                  expect(e.name).toEqual(MALFORMED_HTTP_RESPONSE_ERROR);
   169                  /* eslint-enable jest/no-conditional-expect */
   170              }
   171          });
   172  
   173          describe('credentials', () => {
   174              it('uses the REACT_APP_FETCH_CREDENTIALS env variable if specified', async () => {
   175                  process.env.REACT_APP_FETCH_CREDENTIALS = 'include';
   176  
   177                  jest.spyOn(global, 'fetch');
   178  
   179                  const endpoint = `/api/tablets`;
   180                  const response = { ok: true, result: null };
   181                  mockServerJson(endpoint, response);
   182  
   183                  await api.vtfetch(endpoint);
   184                  expect(global.fetch).toHaveBeenCalledTimes(1);
   185                  expect(global.fetch).toHaveBeenCalledWith(endpoint, {
   186                      credentials: 'include',
   187                  });
   188  
   189                  jest.restoreAllMocks();
   190              });
   191  
   192              it('uses the fetch default `credentials` property by default', async () => {
   193                  jest.spyOn(global, 'fetch');
   194  
   195                  const endpoint = `/api/tablets`;
   196                  const response = { ok: true, result: null };
   197                  mockServerJson(endpoint, response);
   198  
   199                  await api.vtfetch(endpoint);
   200                  expect(global.fetch).toHaveBeenCalledTimes(1);
   201                  expect(global.fetch).toHaveBeenCalledWith(endpoint, {
   202                      credentials: undefined,
   203                  });
   204  
   205                  jest.restoreAllMocks();
   206              });
   207  
   208              it('throws an error if an invalid value used for `credentials`', async () => {
   209                  (process as any).env.REACT_APP_FETCH_CREDENTIALS = 'nope';
   210  
   211                  jest.spyOn(global, 'fetch');
   212  
   213                  const endpoint = `/api/tablets`;
   214                  const response = { ok: true, result: null };
   215                  mockServerJson(endpoint, response);
   216  
   217                  try {
   218                      await api.vtfetch(endpoint);
   219                  } catch (error) {
   220                      let e: HttpFetchError = error as HttpFetchError;
   221                      /* eslint-disable jest/no-conditional-expect */
   222                      expect(e.message).toEqual(
   223                          'Invalid fetch credentials property: nope. Must be undefined or one of omit, same-origin, include'
   224                      );
   225                      expect(global.fetch).toHaveBeenCalledTimes(0);
   226  
   227                      expect(errorHandler.notify).toHaveBeenCalledTimes(1);
   228                      expect(errorHandler.notify).toHaveBeenCalledWith(e);
   229                      /* eslint-enable jest/no-conditional-expect */
   230                  }
   231  
   232                  jest.restoreAllMocks();
   233              });
   234          });
   235  
   236          it('allows GET requests when in read only mode', async () => {
   237              (process as any).env.REACT_APP_READONLY_MODE = 'true';
   238  
   239              const endpoint = `/api/tablets`;
   240              const response = { ok: true, result: null };
   241              mockServerJson(endpoint, response);
   242  
   243              const result1 = await api.vtfetch(endpoint);
   244              expect(result1).toEqual(response);
   245  
   246              const result2 = await api.vtfetch(endpoint, { method: 'get' });
   247              expect(result2).toEqual(response);
   248          });
   249  
   250          it('throws an error when executing a write request in read only mode', async () => {
   251              (process as any).env.REACT_APP_READONLY_MODE = 'true';
   252  
   253              jest.spyOn(global, 'fetch');
   254  
   255              // Endpoint doesn't really matter here since the point is that we don't hit it
   256              const endpoint = `/api/fake`;
   257              const response = { ok: true, result: null };
   258              mockServerJson(endpoint, response);
   259  
   260              const blockedMethods = ['post', 'POST', 'put', 'PUT', 'delete', 'DELETE'];
   261              for (let i = 0; i < blockedMethods.length; i++) {
   262                  const method = blockedMethods[i];
   263                  try {
   264                      await api.vtfetch(endpoint, { method });
   265                  } catch (e: any) {
   266                      /* eslint-disable jest/no-conditional-expect */
   267                      expect(e.message).toEqual(`Cannot execute write request in read-only mode: ${method} ${endpoint}`);
   268                      expect(global.fetch).toHaveBeenCalledTimes(0);
   269  
   270                      expect(errorHandler.notify).toHaveBeenCalledTimes(1);
   271                      expect(errorHandler.notify).toHaveBeenCalledWith(e);
   272                      /* eslint-enable jest/no-conditional-expect */
   273                  }
   274  
   275                  jest.clearAllMocks();
   276              }
   277  
   278              jest.restoreAllMocks();
   279          });
   280      });
   281  
   282      describe('vtfetchEntities', () => {
   283          it('throws an error if result.tablets is not an array', async () => {
   284              const endpoint = '/api/foos';
   285              mockServerJson(endpoint, { ok: true, result: { foos: null } });
   286  
   287              expect.assertions(3);
   288  
   289              try {
   290                  await api.vtfetchEntities({
   291                      endpoint,
   292                      extract: (res) => res.result.foos,
   293                      transform: (e) => null, // doesn't matter
   294                  });
   295              } catch (error) {
   296                  let e: HttpFetchError = error as HttpFetchError;
   297                  /* eslint-disable jest/no-conditional-expect */
   298                  expect(e.message).toMatch('expected entities to be an array, got null');
   299  
   300                  expect(errorHandler.notify).toHaveBeenCalledTimes(1);
   301                  expect(errorHandler.notify).toHaveBeenCalledWith(e);
   302                  /* eslint-enable jest/no-conditional-expect */
   303              }
   304          });
   305      });
   306  });