Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.
1import {
2 Source,
3 pipe,
4 fromValue,
5 toPromise,
6 take,
7 makeSubject,
8 share,
9 publish,
10 scan,
11 tap,
12 map,
13} from 'wonka';
14
15import {
16 makeOperation,
17 CombinedError,
18 Client,
19 Operation,
20 OperationResult,
21} from '@urql/core';
22
23import { vi, expect, it } from 'vitest';
24import { print } from 'graphql';
25import {
26 queryResponse,
27 queryOperation,
28} from '../../../packages/core/src/test-utils';
29import { authExchange } from './authExchange';
30
31const makeExchangeArgs = () => {
32 const operations: Operation[] = [];
33 const result = vi.fn(
34 (operation: Operation): OperationResult => ({ ...queryResponse, operation })
35 );
36
37 return {
38 operations,
39 result,
40 exchangeArgs: {
41 forward: (op$: Source<Operation>) =>
42 pipe(
43 op$,
44 tap(op => operations.push(op)),
45 map(result),
46 share
47 ),
48 client: new Client({
49 url: '/api',
50 exchanges: [],
51 }),
52 } as any,
53 };
54};
55
56it('adds the auth header correctly', async () => {
57 const { exchangeArgs } = makeExchangeArgs();
58
59 const res = await pipe(
60 fromValue(queryOperation),
61 authExchange(async utils => {
62 const token = 'my-token';
63 return {
64 addAuthToOperation(operation) {
65 return utils.appendHeaders(operation, {
66 Authorization: token,
67 });
68 },
69 didAuthError: () => false,
70 async refreshAuth() {
71 /*noop*/
72 },
73 };
74 })(exchangeArgs),
75 take(1),
76 toPromise
77 );
78
79 expect(res.operation.context.authAttempt).toBe(false);
80 expect(res.operation.context.fetchOptions).toEqual({
81 ...(queryOperation.context.fetchOptions || {}),
82 headers: {
83 Authorization: 'my-token',
84 },
85 });
86});
87
88it('adds the auth header correctly when intialized asynchronously', async () => {
89 const { exchangeArgs } = makeExchangeArgs();
90
91 const res = await pipe(
92 fromValue(queryOperation),
93 authExchange(async utils => {
94 // delayed initial auth
95 await Promise.resolve();
96 const token = 'async-token';
97
98 return {
99 addAuthToOperation(operation) {
100 return utils.appendHeaders(operation, {
101 Authorization: token,
102 });
103 },
104 didAuthError: () => false,
105 async refreshAuth() {
106 /*noop*/
107 },
108 };
109 })(exchangeArgs),
110 take(1),
111 toPromise
112 );
113
114 expect(res.operation.context.authAttempt).toBe(false);
115 expect(res.operation.context.fetchOptions).toEqual({
116 ...(queryOperation.context.fetchOptions || {}),
117 headers: {
118 Authorization: 'async-token',
119 },
120 });
121});
122
123it('supports calls to the mutate() method in refreshAuth()', async () => {
124 const { exchangeArgs } = makeExchangeArgs();
125
126 const willAuthError = vi
127 .fn()
128 .mockReturnValueOnce(true)
129 .mockReturnValue(false);
130
131 const [mutateRes, res] = await pipe(
132 fromValue(queryOperation),
133 authExchange(async utils => {
134 const token = 'async-token';
135
136 return {
137 addAuthToOperation(operation) {
138 return utils.appendHeaders(operation, {
139 Authorization: token,
140 });
141 },
142 willAuthError,
143 didAuthError: () => false,
144 async refreshAuth() {
145 const result = await utils.mutate('mutation { auth }', undefined);
146 expect(print(result.operation.query)).toBe('mutation {\n auth\n}');
147 },
148 };
149 })(exchangeArgs),
150 take(2),
151 scan((acc, res) => [...acc, res], [] as OperationResult[]),
152 toPromise
153 );
154
155 expect(mutateRes.operation.context.fetchOptions).toEqual({
156 headers: {
157 Authorization: 'async-token',
158 },
159 });
160
161 expect(res.operation.context.authAttempt).toBe(true);
162 expect(res.operation.context.fetchOptions).toEqual({
163 method: 'POST',
164 headers: {
165 Authorization: 'async-token',
166 },
167 });
168});
169
170it('adds the same token to subsequent operations', async () => {
171 const { exchangeArgs } = makeExchangeArgs();
172 const { source, next } = makeSubject<any>();
173
174 const result = vi.fn();
175 const auth$ = pipe(
176 source,
177 authExchange(async utils => {
178 const token = 'my-token';
179 return {
180 addAuthToOperation(operation) {
181 return utils.appendHeaders(operation, {
182 Authorization: token,
183 });
184 },
185 didAuthError: () => false,
186 async refreshAuth() {
187 /*noop*/
188 },
189 };
190 })(exchangeArgs),
191 tap(result),
192 take(2),
193 toPromise
194 );
195
196 await new Promise(resolve => setTimeout(resolve));
197
198 next(queryOperation);
199
200 next(
201 makeOperation('query', queryOperation, {
202 ...queryOperation.context,
203 foo: 'bar',
204 })
205 );
206
207 await auth$;
208 expect(result).toHaveBeenCalledTimes(2);
209
210 expect(result.mock.calls[0][0].operation.context.authAttempt).toBe(false);
211 expect(result.mock.calls[0][0].operation.context.fetchOptions).toEqual({
212 ...(queryOperation.context.fetchOptions || {}),
213 headers: {
214 Authorization: 'my-token',
215 },
216 });
217
218 expect(result.mock.calls[1][0].operation.context.authAttempt).toBe(false);
219 expect(result.mock.calls[1][0].operation.context.fetchOptions).toEqual({
220 ...(queryOperation.context.fetchOptions || {}),
221 headers: {
222 Authorization: 'my-token',
223 },
224 });
225});
226
227it('triggers authentication when an operation did error', async () => {
228 const { exchangeArgs, result, operations } = makeExchangeArgs();
229 const { source, next } = makeSubject<any>();
230
231 const didAuthError = vi.fn().mockReturnValueOnce(true);
232
233 pipe(
234 source,
235 authExchange(async utils => {
236 let token = 'initial-token';
237 return {
238 addAuthToOperation(operation) {
239 return utils.appendHeaders(operation, {
240 Authorization: token,
241 });
242 },
243 didAuthError,
244 async refreshAuth() {
245 token = 'final-token';
246 },
247 };
248 })(exchangeArgs),
249 publish
250 );
251
252 await new Promise(resolve => setTimeout(resolve));
253
254 result.mockReturnValueOnce({
255 ...queryResponse,
256 operation: queryOperation,
257 data: undefined,
258 error: new CombinedError({
259 graphQLErrors: [{ message: 'Oops' }],
260 }),
261 });
262
263 next(queryOperation);
264 expect(result).toHaveBeenCalledTimes(1);
265 expect(didAuthError).toHaveBeenCalledTimes(1);
266
267 await new Promise(resolve => setTimeout(resolve));
268
269 expect(result).toHaveBeenCalledTimes(2);
270 expect(operations.length).toBe(2);
271 expect(operations[0]).toHaveProperty(
272 'context.fetchOptions.headers.Authorization',
273 'initial-token'
274 );
275 expect(operations[1]).toHaveProperty(
276 'context.fetchOptions.headers.Authorization',
277 'final-token'
278 );
279});
280
281it('triggers authentication when an operation will error', async () => {
282 const { exchangeArgs, result, operations } = makeExchangeArgs();
283 const { source, next } = makeSubject<any>();
284
285 const willAuthError = vi
286 .fn()
287 .mockReturnValueOnce(true)
288 .mockReturnValue(false);
289
290 pipe(
291 source,
292 authExchange(async utils => {
293 let token = 'initial-token';
294 return {
295 addAuthToOperation(operation) {
296 return utils.appendHeaders(operation, {
297 Authorization: token,
298 });
299 },
300 willAuthError,
301 didAuthError: () => false,
302 async refreshAuth() {
303 token = 'final-token';
304 },
305 };
306 })(exchangeArgs),
307 publish
308 );
309
310 await new Promise(resolve => setTimeout(resolve));
311
312 next(queryOperation);
313 expect(result).toHaveBeenCalledTimes(0);
314 expect(willAuthError).toHaveBeenCalledTimes(1);
315
316 await new Promise(resolve => setTimeout(resolve));
317
318 expect(result).toHaveBeenCalledTimes(1);
319 expect(operations.length).toBe(1);
320 expect(operations[0]).toHaveProperty(
321 'context.fetchOptions.headers.Authorization',
322 'final-token'
323 );
324});
325
326it('calls willAuthError on queued operations', async () => {
327 const { exchangeArgs, result, operations } = makeExchangeArgs();
328 const { source, next } = makeSubject<any>();
329
330 let initialAuthResolve: ((_?: any) => void) | undefined;
331
332 const willAuthError = vi
333 .fn()
334 .mockReturnValueOnce(true)
335 .mockReturnValue(false);
336
337 pipe(
338 source,
339 authExchange(async utils => {
340 await new Promise(resolve => {
341 initialAuthResolve = resolve;
342 });
343
344 let token = 'token';
345 return {
346 willAuthError,
347 didAuthError: () => false,
348 addAuthToOperation(operation) {
349 return utils.appendHeaders(operation, {
350 Authorization: token,
351 });
352 },
353 async refreshAuth() {
354 token = 'final-token';
355 },
356 };
357 })(exchangeArgs),
358 publish
359 );
360
361 await Promise.resolve();
362
363 next({ ...queryOperation, key: 1 });
364 next({ ...queryOperation, key: 2 });
365
366 expect(result).toHaveBeenCalledTimes(0);
367 expect(willAuthError).toHaveBeenCalledTimes(0);
368
369 expect(initialAuthResolve).toBeDefined();
370 initialAuthResolve!();
371
372 await new Promise(resolve => setTimeout(resolve));
373
374 expect(willAuthError).toHaveBeenCalledTimes(2);
375 expect(result).toHaveBeenCalledTimes(2);
376
377 expect(operations.length).toBe(2);
378 expect(operations[0]).toHaveProperty(
379 'context.fetchOptions.headers.Authorization',
380 'final-token'
381 );
382
383 expect(operations[1]).toHaveProperty(
384 'context.fetchOptions.headers.Authorization',
385 'final-token'
386 );
387});
388
389it('does not infinitely retry authentication when an operation did error', async () => {
390 const { exchangeArgs, result, operations } = makeExchangeArgs();
391 const { source, next } = makeSubject<any>();
392
393 const didAuthError = vi.fn().mockReturnValue(true);
394
395 pipe(
396 source,
397 authExchange(async utils => {
398 let token = 'initial-token';
399 return {
400 addAuthToOperation(operation) {
401 return utils.appendHeaders(operation, {
402 Authorization: token,
403 });
404 },
405 didAuthError,
406 async refreshAuth() {
407 token = 'final-token';
408 },
409 };
410 })(exchangeArgs),
411 publish
412 );
413
414 await new Promise(resolve => setTimeout(resolve));
415
416 result.mockImplementation(x => ({
417 ...queryResponse,
418 operation: {
419 ...queryResponse.operation,
420 ...x,
421 },
422 data: undefined,
423 error: new CombinedError({
424 graphQLErrors: [{ message: 'Oops' }],
425 }),
426 }));
427
428 next(queryOperation);
429 expect(result).toHaveBeenCalledTimes(1);
430 expect(didAuthError).toHaveBeenCalledTimes(1);
431
432 await new Promise(resolve => setTimeout(resolve));
433
434 expect(result).toHaveBeenCalledTimes(2);
435 expect(operations.length).toBe(2);
436 expect(operations[0]).toHaveProperty(
437 'context.fetchOptions.headers.Authorization',
438 'initial-token'
439 );
440 expect(operations[1]).toHaveProperty(
441 'context.fetchOptions.headers.Authorization',
442 'final-token'
443 );
444});
445
446it('passes on failing refreshAuth() errors to results', async () => {
447 const { exchangeArgs, result } = makeExchangeArgs();
448
449 const didAuthError = vi.fn().mockReturnValue(true);
450 const willAuthError = vi.fn().mockReturnValue(true);
451
452 const res = await pipe(
453 fromValue(queryOperation),
454 authExchange(async utils => {
455 const token = 'initial-token';
456 return {
457 addAuthToOperation(operation) {
458 return utils.appendHeaders(operation, {
459 Authorization: token,
460 });
461 },
462 didAuthError,
463 willAuthError,
464 async refreshAuth() {
465 throw new Error('test');
466 },
467 };
468 })(exchangeArgs),
469 take(1),
470 toPromise
471 );
472
473 expect(result).toHaveBeenCalledTimes(0);
474 expect(didAuthError).toHaveBeenCalledTimes(0);
475 expect(willAuthError).toHaveBeenCalledTimes(1);
476
477 expect(res.error).toMatchInlineSnapshot('[CombinedError: [Network] test]');
478});
479
480it('passes on errors during initialization', async () => {
481 const { source, next } = makeSubject<any>();
482 const { exchangeArgs, result } = makeExchangeArgs();
483 const init = vi.fn().mockRejectedValue(new Error('oops!'));
484 const output = vi.fn();
485
486 pipe(source, authExchange(init)(exchangeArgs), tap(output), publish);
487
488 expect(result).toHaveBeenCalledTimes(0);
489 expect(output).toHaveBeenCalledTimes(0);
490
491 next(queryOperation);
492 await new Promise(resolve => setTimeout(resolve));
493 expect(result).toHaveBeenCalledTimes(0);
494 expect(output).toHaveBeenCalledTimes(1);
495 expect(init).toHaveBeenCalledTimes(1);
496 expect(output.mock.calls[0][0].error).toMatchInlineSnapshot(
497 '[CombinedError: [Network] oops!]'
498 );
499
500 next(queryOperation);
501 await new Promise(resolve => setTimeout(resolve));
502 expect(result).toHaveBeenCalledTimes(0);
503 expect(output).toHaveBeenCalledTimes(2);
504 expect(init).toHaveBeenCalledTimes(2);
505 expect(output.mock.calls[1][0].error).toMatchInlineSnapshot(
506 '[CombinedError: [Network] oops!]'
507 );
508});