1 import { uniqueId } from '@proton/pass/utils/string/unique-id';
3 import { type FetchController, fetchControllerFactory, getRequestID, getUID } from './fetch-controller';
6 public request: Request;
8 public respondWith: (res: Response | PromiseLike<Response>) => void;
10 constructor(_: string, init: { request: Request }) {
11 this.request = init.request;
12 this.respondWith = jest.fn(async (res: Response | PromiseLike<Response>) => {
20 const FetchEvent = MockFetchEvent as unknown as typeof globalThis.FetchEvent;
21 const asyncNextTick = () => new Promise(process.nextTick);
23 describe('fetch controller', () => {
24 describe('getUID', () => {
25 test('should return correct header value if present', () => {
26 const uid = uniqueId();
27 const request = new Request('https://pass.test/', { headers: { 'X-Pm-Uid': uid } });
28 const event = new FetchEvent('fetch', { request });
29 expect(getUID(event)).toEqual(uid);
32 test('should return null if header is not present', () => {
33 const request = new Request('https://pass.test/', {});
34 const event = new FetchEvent('fetch', { request });
35 expect(getUID(event)).toBeNull();
39 describe('getRequestID', () => {
40 test('should return correct header value if present', () => {
41 const requestId = uniqueId();
42 const request = new Request('https://pass.test/', { headers: { 'X-Pass-Worker-RequestID': requestId } });
43 const event = new FetchEvent('fetch', { request });
44 expect(getRequestID(event)).toEqual(requestId);
47 test('should return null if header is not present', () => {
48 const request = new Request('https://pass.test/', {});
49 const event = new FetchEvent('fetch', { request });
50 expect(getRequestID(event)).toBeNull();
54 describe('FetchController', () => {
55 let fetchController: FetchController;
56 beforeEach(() => (fetchController = fetchControllerFactory()));
58 describe('register', () => {
59 test('should setup abort controller and respond with handler response', async () => {
60 const requestId = uniqueId();
61 const headers = { 'X-Pm-Uid': uniqueId(), 'X-Pass-Worker-RequestID': requestId };
62 const request = new Request('https://pass.test/', { headers });
63 const response = new Response(uniqueId(), { status: 200 });
64 const event = new FetchEvent('fetch', { request });
65 const cloneSpy = jest.spyOn(response, 'clone');
67 const handler = jest.fn().mockResolvedValue(response);
68 fetchController.register(handler)(event);
70 expect(fetchController._controllers.get(requestId)).toBeInstanceOf(AbortController);
72 await asyncNextTick();
74 expect(handler).toHaveBeenCalled();
75 expect(cloneSpy).toHaveBeenCalled();
76 expect(event.respondWith).toHaveBeenCalled();
78 const eventResponse = await (event.respondWith as jest.Mock).mock?.lastCall[0];
79 await expect(eventResponse).toMatchResponse(eventResponse);
80 expect(fetchController._controllers.get(requestId)).toBeUndefined();
83 test('should fallback to requestUrl as identifier if no `X-Pass-Worker-RequestID` header', async () => {
84 const headers = { 'X-Pm-Uid': uniqueId() };
85 const request = new Request('https://pass.test/', { headers });
86 const response = new Response(uniqueId(), { status: 200 });
87 const event = new FetchEvent('fetch', { request });
88 const cloneSpy = jest.spyOn(response, 'clone');
90 const handler = jest.fn().mockResolvedValue(response);
92 fetchController.register(handler)(event);
93 expect(fetchController._controllers.get('https://pass.test/')).toBeInstanceOf(AbortController);
95 await asyncNextTick();
97 expect(handler).toHaveBeenCalled();
98 expect(cloneSpy).toHaveBeenCalled();
99 expect(event.respondWith).toHaveBeenCalled();
101 const eventResponse = await (event.respondWith as jest.Mock<any>).mock?.lastCall[0];
102 await expect(eventResponse).toMatchResponse(response);
103 expect(fetchController._controllers.get('https://pass.test/')).toBeUndefined();
106 test('should allow unauthenticated handlers', async () => {
107 const request = new Request('https://pass.test/', {});
108 const response = new Response(uniqueId(), { status: 200 });
109 const event = new FetchEvent('fetch', { request });
110 const cloneSpy = jest.spyOn(response, 'clone');
112 const handler = jest.fn().mockResolvedValue(response);
114 fetchController.register(handler, { unauthenticated: true })(event);
115 expect(fetchController._controllers.get('https://pass.test/')).toBeInstanceOf(AbortController);
117 await asyncNextTick();
119 expect(handler).toHaveBeenCalled();
120 expect(cloneSpy).toHaveBeenCalled();
121 expect(event.respondWith).toHaveBeenCalled();
123 const eventResponse = await (event.respondWith as jest.Mock<any>).mock?.lastCall[0];
124 await expect(eventResponse).toMatchResponse(response);
125 expect(fetchController._controllers.get('https://pass.test/')).toBeUndefined();
128 test('should noop if handler does not return a response', () => {
129 const handler = jest.fn();
131 const requestId = uniqueId();
132 const headers = { 'X-Pm-Uid': uniqueId(), 'X-Pass-Worker-RequestID': requestId };
133 const request = new Request('https://pass.test/', { headers });
134 const response = new Response(uniqueId(), { status: 200 });
135 const event = new FetchEvent('fetch', { request });
136 const cloneSpy = jest.spyOn(response, 'clone');
138 fetchController.register(handler)(event);
140 expect(handler).toHaveBeenCalled();
141 expect(cloneSpy).not.toHaveBeenCalled();
142 expect(event.respondWith).not.toHaveBeenCalled();
143 expect(fetchController._controllers.get(requestId)).toBeUndefined();
146 test('should noop if no UID header', () => {
147 const handler = jest.fn();
149 const request = new Request('https://pass.test/', {});
150 const response = new Response(uniqueId(), { status: 403 });
151 const event = new FetchEvent('fetch', { request });
152 const cloneSpy = jest.spyOn(response, 'clone');
154 fetchController.register(handler)(event);
156 expect(handler).not.toHaveBeenCalled();
157 expect(cloneSpy).not.toHaveBeenCalled();
158 expect(event.respondWith).not.toHaveBeenCalled();
159 expect(fetchController._controllers.get('https://pass.test/')).toBeUndefined();
162 test('should clear abort controller if handler throws', async () => {
163 const handler = jest.fn().mockRejectedValue('TestError');
165 const headers = { 'X-Pm-Uid': uniqueId() };
166 const request = new Request('https://pass.test/', { headers });
167 const response = new Response(uniqueId(), { status: 522 });
168 const event = new FetchEvent('fetch', { request });
169 const cloneSpy = jest.spyOn(response, 'clone');
171 fetchController.register(handler)(event);
173 expect(fetchController._controllers.get('https://pass.test/')).toBeInstanceOf(AbortController);
174 await asyncNextTick();
176 expect(handler).toHaveBeenCalled();
177 expect(cloneSpy).not.toHaveBeenCalled();
178 expect(event.respondWith).toHaveBeenCalled();
179 expect(fetchController._controllers.get('https://pass.test/')).toBeUndefined();
183 describe('fetch', () => {
185 global.fetch = jest.fn().mockResolvedValue(new Response());
188 test('should forward abort signal', async () => {
189 const request = new Request('https://pass.test/', { headers: {} });
190 const abort = new AbortController();
191 await fetchController.fetch(request as Request, abort.signal);
192 const params = (global.fetch as jest.Mock<any>).mock.lastCall;
194 expect(global.fetch).toHaveBeenCalledTimes(1);
195 expect(params[1].signal).toEqual(abort.signal);
198 test('should fetch with modified request without `X-Pass-Worker-RequestID` header', async () => {
199 const headers = { 'X-Pass-Worker-RequestID': uniqueId() };
200 const request = new Request('https://pass.test/', { headers });
201 const abort = new AbortController();
202 await fetchController.fetch(request as Request, abort.signal);
203 const params = (global.fetch as jest.Mock<any>).mock.lastCall;
205 expect(global.fetch).toHaveBeenCalledTimes(1);
206 expect(params[0].headers.get('X-Pass-Worker-RequestID')).toBeNull();
207 expect(params[1].signal).toEqual(abort.signal);
211 describe('abort', () => {
212 test('should abort controller for requestId', () => {
213 const abortCtrl = new AbortController();
214 jest.spyOn(abortCtrl, 'abort');
215 fetchController._controllers.set('test', abortCtrl);
216 fetchController.abort('test');
218 expect(abortCtrl.abort).toHaveBeenCalled();