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 });