spck 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.oxlintrc.json +49 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/bin/cli.js +20 -0
- package/bin/validate-cwd.js +41 -0
- package/dist/config/__tests__/config.test.d.ts +2 -0
- package/dist/config/__tests__/config.test.js +262 -0
- package/dist/config/__tests__/credentials.test.d.ts +2 -0
- package/dist/config/__tests__/credentials.test.js +360 -0
- package/dist/config/config.d.ts +33 -0
- package/dist/config/config.js +185 -0
- package/dist/config/credentials.d.ts +75 -0
- package/dist/config/credentials.js +259 -0
- package/dist/config/server-selection.d.ts +40 -0
- package/dist/config/server-selection.js +130 -0
- package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
- package/dist/connection/__tests__/firebase-auth.test.js +96 -0
- package/dist/connection/__tests__/hmac.test.d.ts +2 -0
- package/dist/connection/__tests__/hmac.test.js +372 -0
- package/dist/connection/auth.d.ts +13 -0
- package/dist/connection/auth.js +91 -0
- package/dist/connection/firebase-auth.d.ts +40 -0
- package/dist/connection/firebase-auth.js +429 -0
- package/dist/connection/hmac.d.ts +24 -0
- package/dist/connection/hmac.js +109 -0
- package/dist/i18n/index.d.ts +25 -0
- package/dist/i18n/index.js +101 -0
- package/dist/i18n/locales/en.json +313 -0
- package/dist/i18n/locales/es.json +302 -0
- package/dist/i18n/locales/fr.json +302 -0
- package/dist/i18n/locales/id.json +302 -0
- package/dist/i18n/locales/ja.json +302 -0
- package/dist/i18n/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/en.json +309 -0
- package/dist/i18n/locales/locales/es.json +302 -0
- package/dist/i18n/locales/locales/fr.json +302 -0
- package/dist/i18n/locales/locales/id.json +302 -0
- package/dist/i18n/locales/locales/ja.json +302 -0
- package/dist/i18n/locales/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/pt.json +302 -0
- package/dist/i18n/locales/locales/zh-Hans.json +302 -0
- package/dist/i18n/locales/pt.json +302 -0
- package/dist/i18n/locales/zh-Hans.json +302 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +493 -0
- package/dist/proxy/ProxyClient.d.ts +125 -0
- package/dist/proxy/ProxyClient.js +781 -0
- package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
- package/dist/proxy/ProxySocketWrapper.js +98 -0
- package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
- package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
- package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
- package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
- package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
- package/dist/proxy/chunking.d.ts +53 -0
- package/dist/proxy/chunking.js +127 -0
- package/dist/proxy/handshake-validation.d.ts +21 -0
- package/dist/proxy/handshake-validation.js +49 -0
- package/dist/rpc/__tests__/router.test.d.ts +2 -0
- package/dist/rpc/__tests__/router.test.js +262 -0
- package/dist/rpc/router.d.ts +37 -0
- package/dist/rpc/router.js +132 -0
- package/dist/services/BrowserProxyService.d.ts +13 -0
- package/dist/services/BrowserProxyService.js +139 -0
- package/dist/services/FilesystemService.d.ts +99 -0
- package/dist/services/FilesystemService.js +742 -0
- package/dist/services/GitService.d.ts +243 -0
- package/dist/services/GitService.js +1439 -0
- package/dist/services/SearchService.d.ts +93 -0
- package/dist/services/SearchService.js +670 -0
- package/dist/services/TerminalService.d.ts +62 -0
- package/dist/services/TerminalService.js +337 -0
- package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
- package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
- package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
- package/dist/services/__tests__/FilesystemService.test.js +609 -0
- package/dist/services/__tests__/GitService.test.d.ts +2 -0
- package/dist/services/__tests__/GitService.test.js +953 -0
- package/dist/services/__tests__/SearchService.test.d.ts +2 -0
- package/dist/services/__tests__/SearchService.test.js +384 -0
- package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
- package/dist/services/__tests__/TerminalService.test.js +513 -0
- package/dist/setup/wizard.d.ts +10 -0
- package/dist/setup/wizard.js +172 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +44 -0
- package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
- package/dist/utils/__tests__/gitignore.test.js +127 -0
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +77 -0
- package/dist/utils/logger.d.ts +96 -0
- package/dist/utils/logger.js +456 -0
- package/dist/utils/project-dir.d.ts +51 -0
- package/dist/utils/project-dir.js +191 -0
- package/dist/utils/ripgrep.d.ts +34 -0
- package/dist/utils/ripgrep.js +148 -0
- package/dist/utils/tool-detection.d.ts +17 -0
- package/dist/utils/tool-detection.js +126 -0
- package/dist/watcher/FileWatcher.d.ts +10 -0
- package/dist/watcher/FileWatcher.js +42 -0
- package/package.json +70 -0
- package/src/config/__tests__/config.test.ts +318 -0
- package/src/config/__tests__/credentials.test.ts +494 -0
- package/src/config/config.ts +206 -0
- package/src/config/credentials.ts +302 -0
- package/src/config/server-selection.ts +150 -0
- package/src/connection/__tests__/firebase-auth.test.ts +121 -0
- package/src/connection/__tests__/hmac.test.ts +509 -0
- package/src/connection/auth.ts +140 -0
- package/src/connection/firebase-auth.ts +504 -0
- package/src/connection/hmac.ts +139 -0
- package/src/i18n/index.ts +119 -0
- package/src/i18n/locales/en.json +313 -0
- package/src/i18n/locales/es.json +302 -0
- package/src/i18n/locales/fr.json +302 -0
- package/src/i18n/locales/id.json +302 -0
- package/src/i18n/locales/ja.json +302 -0
- package/src/i18n/locales/ko.json +302 -0
- package/src/i18n/locales/pt.json +302 -0
- package/src/i18n/locales/zh-Hans.json +302 -0
- package/src/index.ts +542 -0
- package/src/proxy/ProxyClient.ts +968 -0
- package/src/proxy/ProxySocketWrapper.ts +113 -0
- package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
- package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
- package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
- package/src/proxy/chunking.ts +162 -0
- package/src/proxy/handshake-validation.ts +64 -0
- package/src/rpc/__tests__/router.test.ts +400 -0
- package/src/rpc/router.ts +183 -0
- package/src/services/BrowserProxyService.ts +179 -0
- package/src/services/FilesystemService.ts +841 -0
- package/src/services/GitService.ts +1639 -0
- package/src/services/SearchService.ts +809 -0
- package/src/services/TerminalService.ts +413 -0
- package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
- package/src/services/__tests__/FilesystemService.test.ts +1002 -0
- package/src/services/__tests__/GitService.test.ts +1552 -0
- package/src/services/__tests__/SearchService.test.ts +484 -0
- package/src/services/__tests__/TerminalService.test.ts +702 -0
- package/src/setup/wizard.ts +242 -0
- package/src/types/fossil-delta.d.ts +4 -0
- package/src/types.ts +287 -0
- package/src/utils/__tests__/gitignore.test.ts +174 -0
- package/src/utils/gitignore.ts +91 -0
- package/src/utils/logger.ts +578 -0
- package/src/utils/project-dir.ts +218 -0
- package/src/utils/ripgrep.ts +180 -0
- package/src/utils/tool-detection.ts +141 -0
- package/src/watcher/FileWatcher.ts +53 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for ProxySocketWrapper
|
|
4
|
+
*/
|
|
5
|
+
import { ProxySocketWrapper } from '../ProxySocketWrapper.js';
|
|
6
|
+
describe('ProxySocketWrapper', () => {
|
|
7
|
+
let wrapper;
|
|
8
|
+
let mockSendFn;
|
|
9
|
+
const connectionId = 'test-connection-123';
|
|
10
|
+
const userId = 'test-user-456';
|
|
11
|
+
const deviceId = 'test-device-789';
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
mockSendFn = vi.fn();
|
|
14
|
+
wrapper = new ProxySocketWrapper(connectionId, userId, mockSendFn, deviceId);
|
|
15
|
+
});
|
|
16
|
+
describe('constructor', () => {
|
|
17
|
+
it('should initialize with correct connection ID, user ID, and device ID', () => {
|
|
18
|
+
expect(wrapper.id).toBe(connectionId);
|
|
19
|
+
expect(wrapper.data.uid).toBe(userId);
|
|
20
|
+
expect(wrapper.data.deviceId).toBe(deviceId);
|
|
21
|
+
});
|
|
22
|
+
it('should initialize broadcast object', () => {
|
|
23
|
+
expect(wrapper.broadcast).toBeDefined();
|
|
24
|
+
expect(typeof wrapper.broadcast.emit).toBe('function');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('emit()', () => {
|
|
28
|
+
it('should call sendFn with correct parameters', () => {
|
|
29
|
+
const event = 'test-event';
|
|
30
|
+
const data = { message: 'hello' };
|
|
31
|
+
wrapper.emit(event, data);
|
|
32
|
+
expect(mockSendFn).toHaveBeenCalledWith(connectionId, event, data);
|
|
33
|
+
});
|
|
34
|
+
it('should handle emit without data', () => {
|
|
35
|
+
const event = 'test-event';
|
|
36
|
+
wrapper.emit(event);
|
|
37
|
+
expect(mockSendFn).toHaveBeenCalledWith(connectionId, event, {});
|
|
38
|
+
});
|
|
39
|
+
it('should return true', () => {
|
|
40
|
+
const result = wrapper.emit('test-event');
|
|
41
|
+
expect(result).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('broadcast.emit()', () => {
|
|
45
|
+
it('should call regular emit (sends to single client)', () => {
|
|
46
|
+
const event = 'broadcast-event';
|
|
47
|
+
const data = { type: 'notification' };
|
|
48
|
+
wrapper.broadcast.emit(event, data);
|
|
49
|
+
expect(mockSendFn).toHaveBeenCalledWith(connectionId, event, data);
|
|
50
|
+
});
|
|
51
|
+
it('should return true', () => {
|
|
52
|
+
const result = wrapper.broadcast.emit('test-event');
|
|
53
|
+
expect(result).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('on()', () => {
|
|
57
|
+
it('should register event listener', () => {
|
|
58
|
+
const listener = vi.fn();
|
|
59
|
+
wrapper.on('rpc', listener);
|
|
60
|
+
// Trigger the event
|
|
61
|
+
wrapper.triggerEvent('rpc', { test: 'data' });
|
|
62
|
+
expect(listener).toHaveBeenCalledWith({ test: 'data' });
|
|
63
|
+
});
|
|
64
|
+
it('should support multiple listeners for same event', () => {
|
|
65
|
+
const listener1 = vi.fn();
|
|
66
|
+
const listener2 = vi.fn();
|
|
67
|
+
wrapper.on('rpc', listener1);
|
|
68
|
+
wrapper.on('rpc', listener2);
|
|
69
|
+
wrapper.triggerEvent('rpc', { test: 'data' });
|
|
70
|
+
expect(listener1).toHaveBeenCalledWith({ test: 'data' });
|
|
71
|
+
expect(listener2).toHaveBeenCalledWith({ test: 'data' });
|
|
72
|
+
});
|
|
73
|
+
it('should return this for chaining', () => {
|
|
74
|
+
const listener = vi.fn();
|
|
75
|
+
const result = wrapper.on('test', listener);
|
|
76
|
+
expect(result).toBe(wrapper);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('off()', () => {
|
|
80
|
+
it('should remove specific listener', () => {
|
|
81
|
+
const listener1 = vi.fn();
|
|
82
|
+
const listener2 = vi.fn();
|
|
83
|
+
wrapper.on('rpc', listener1);
|
|
84
|
+
wrapper.on('rpc', listener2);
|
|
85
|
+
wrapper.off('rpc', listener1);
|
|
86
|
+
wrapper.triggerEvent('rpc', { test: 'data' });
|
|
87
|
+
expect(listener1).not.toHaveBeenCalled();
|
|
88
|
+
expect(listener2).toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
it('should remove all listeners for event when no listener specified', () => {
|
|
91
|
+
const listener1 = vi.fn();
|
|
92
|
+
const listener2 = vi.fn();
|
|
93
|
+
wrapper.on('rpc', listener1);
|
|
94
|
+
wrapper.on('rpc', listener2);
|
|
95
|
+
wrapper.off('rpc');
|
|
96
|
+
wrapper.triggerEvent('rpc', { test: 'data' });
|
|
97
|
+
expect(listener1).not.toHaveBeenCalled();
|
|
98
|
+
expect(listener2).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
it('should return this for chaining', () => {
|
|
101
|
+
const listener = vi.fn();
|
|
102
|
+
wrapper.on('test', listener);
|
|
103
|
+
const result = wrapper.off('test', listener);
|
|
104
|
+
expect(result).toBe(wrapper);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe('once()', () => {
|
|
108
|
+
it('should trigger listener only once', () => {
|
|
109
|
+
const listener = vi.fn();
|
|
110
|
+
wrapper.once('rpc', listener);
|
|
111
|
+
wrapper.triggerEvent('rpc', { call: 1 });
|
|
112
|
+
wrapper.triggerEvent('rpc', { call: 2 });
|
|
113
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
114
|
+
expect(listener).toHaveBeenCalledWith({ call: 1 });
|
|
115
|
+
});
|
|
116
|
+
it('should return this for chaining', () => {
|
|
117
|
+
const listener = vi.fn();
|
|
118
|
+
const result = wrapper.once('test', listener);
|
|
119
|
+
expect(result).toBe(wrapper);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
describe('removeAllListeners()', () => {
|
|
123
|
+
it('should remove all listeners for specific event', () => {
|
|
124
|
+
const listener1 = vi.fn();
|
|
125
|
+
const listener2 = vi.fn();
|
|
126
|
+
wrapper.on('event1', listener1);
|
|
127
|
+
wrapper.on('event2', listener2);
|
|
128
|
+
wrapper.removeAllListeners('event1');
|
|
129
|
+
wrapper.triggerEvent('event1', {});
|
|
130
|
+
wrapper.triggerEvent('event2', {});
|
|
131
|
+
expect(listener1).not.toHaveBeenCalled();
|
|
132
|
+
expect(listener2).toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
it('should remove all listeners when no event specified', () => {
|
|
135
|
+
const listener1 = vi.fn();
|
|
136
|
+
const listener2 = vi.fn();
|
|
137
|
+
wrapper.on('event1', listener1);
|
|
138
|
+
wrapper.on('event2', listener2);
|
|
139
|
+
wrapper.removeAllListeners();
|
|
140
|
+
wrapper.triggerEvent('event1', {});
|
|
141
|
+
wrapper.triggerEvent('event2', {});
|
|
142
|
+
expect(listener1).not.toHaveBeenCalled();
|
|
143
|
+
expect(listener2).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
it('should return this for chaining', () => {
|
|
146
|
+
const result = wrapper.removeAllListeners();
|
|
147
|
+
expect(result).toBe(wrapper);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
describe('triggerEvent()', () => {
|
|
151
|
+
it('should call all registered listeners with arguments', () => {
|
|
152
|
+
const listener = vi.fn();
|
|
153
|
+
wrapper.on('test', listener);
|
|
154
|
+
wrapper.triggerEvent('test', 'arg1', 'arg2', 'arg3');
|
|
155
|
+
expect(listener).toHaveBeenCalledWith('arg1', 'arg2', 'arg3');
|
|
156
|
+
});
|
|
157
|
+
it('should handle errors in listeners gracefully', () => {
|
|
158
|
+
const errorListener = vi.fn(() => {
|
|
159
|
+
throw new Error('Listener error');
|
|
160
|
+
});
|
|
161
|
+
const goodListener = vi.fn();
|
|
162
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
163
|
+
wrapper.on('test', errorListener);
|
|
164
|
+
wrapper.on('test', goodListener);
|
|
165
|
+
wrapper.triggerEvent('test', {});
|
|
166
|
+
expect(errorListener).toHaveBeenCalled();
|
|
167
|
+
expect(goodListener).toHaveBeenCalled();
|
|
168
|
+
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
169
|
+
consoleErrorSpy.mockRestore();
|
|
170
|
+
});
|
|
171
|
+
it('should do nothing if no listeners registered', () => {
|
|
172
|
+
// Should not throw
|
|
173
|
+
expect(() => {
|
|
174
|
+
wrapper.triggerEvent('nonexistent-event', {});
|
|
175
|
+
}).not.toThrow();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
describe('id getter', () => {
|
|
179
|
+
it('should return connection ID', () => {
|
|
180
|
+
expect(wrapper.id).toBe(connectionId);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
describe('data property', () => {
|
|
184
|
+
it('should have uid property', () => {
|
|
185
|
+
expect(wrapper.data).toHaveProperty('uid');
|
|
186
|
+
expect(wrapper.data.uid).toBe(userId);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
//# sourceMappingURL=ProxySocketWrapper.test.js.map
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
/**
|
|
3
|
+
* Tests for handshake validation - Replay attack prevention
|
|
4
|
+
*/
|
|
5
|
+
import { validateHandshakeTimestamp } from '../handshake-validation.js';
|
|
6
|
+
describe('validateHandshakeTimestamp - Replay Attack Prevention', () => {
|
|
7
|
+
const ONE_MINUTE = 60 * 1000;
|
|
8
|
+
const NOW = 1640000000000; // Fixed timestamp for testing
|
|
9
|
+
describe('Valid timestamps', () => {
|
|
10
|
+
it('should accept message with current timestamp', () => {
|
|
11
|
+
const result = validateHandshakeTimestamp(NOW, { now: NOW });
|
|
12
|
+
expect(result.valid).toBe(true);
|
|
13
|
+
expect(result.error).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
it('should accept message 30 seconds old', () => {
|
|
16
|
+
const timestamp = NOW - 30 * 1000;
|
|
17
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
18
|
+
expect(result.valid).toBe(true);
|
|
19
|
+
expect(result.error).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
it('should accept message exactly 1 minute old (boundary)', () => {
|
|
22
|
+
const timestamp = NOW - ONE_MINUTE;
|
|
23
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
24
|
+
expect(result.valid).toBe(true);
|
|
25
|
+
expect(result.error).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
it('should accept message 59 seconds old', () => {
|
|
28
|
+
const timestamp = NOW - 59 * 1000;
|
|
29
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
30
|
+
expect(result.valid).toBe(true);
|
|
31
|
+
expect(result.error).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
it('should accept message 30 seconds in future (clock skew)', () => {
|
|
34
|
+
const timestamp = NOW + 30 * 1000;
|
|
35
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
36
|
+
expect(result.valid).toBe(true);
|
|
37
|
+
expect(result.error).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
it('should accept message exactly 1 minute in future (clock skew boundary)', () => {
|
|
40
|
+
const timestamp = NOW + ONE_MINUTE;
|
|
41
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
42
|
+
expect(result.valid).toBe(true);
|
|
43
|
+
expect(result.error).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
it('should accept message 59 seconds in future', () => {
|
|
46
|
+
const timestamp = NOW + 59 * 1000;
|
|
47
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
48
|
+
expect(result.valid).toBe(true);
|
|
49
|
+
expect(result.error).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('Invalid timestamps - Too old', () => {
|
|
53
|
+
it('should reject message 2 minutes old', () => {
|
|
54
|
+
const timestamp = NOW - 2 * ONE_MINUTE;
|
|
55
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
56
|
+
expect(result.valid).toBe(false);
|
|
57
|
+
expect(result.error).toContain('too old');
|
|
58
|
+
expect(result.error).toContain('120s'); // 2 minutes
|
|
59
|
+
});
|
|
60
|
+
it('should reject message 5 minutes old', () => {
|
|
61
|
+
const timestamp = NOW - 5 * ONE_MINUTE;
|
|
62
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
63
|
+
expect(result.valid).toBe(false);
|
|
64
|
+
expect(result.error).toContain('too old');
|
|
65
|
+
expect(result.error).toContain('300s'); // 5 minutes
|
|
66
|
+
});
|
|
67
|
+
it('should reject message 1 minute and 1 millisecond old', () => {
|
|
68
|
+
const timestamp = NOW - ONE_MINUTE - 1;
|
|
69
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
70
|
+
expect(result.valid).toBe(false);
|
|
71
|
+
expect(result.error).toContain('too old');
|
|
72
|
+
});
|
|
73
|
+
it('should reject message 61 seconds old', () => {
|
|
74
|
+
const timestamp = NOW - 61 * 1000;
|
|
75
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
76
|
+
expect(result.valid).toBe(false);
|
|
77
|
+
expect(result.error).toContain('too old');
|
|
78
|
+
});
|
|
79
|
+
it('should reject very old message (1 hour)', () => {
|
|
80
|
+
const timestamp = NOW - 60 * ONE_MINUTE;
|
|
81
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
82
|
+
expect(result.valid).toBe(false);
|
|
83
|
+
expect(result.error).toContain('too old');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
describe('Invalid timestamps - Too far in future', () => {
|
|
87
|
+
it('should reject message 2 minutes in future', () => {
|
|
88
|
+
const timestamp = NOW + 2 * ONE_MINUTE;
|
|
89
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
90
|
+
expect(result.valid).toBe(false);
|
|
91
|
+
expect(result.error).toContain('too far in future');
|
|
92
|
+
expect(result.error).toContain('120s'); // 2 minutes
|
|
93
|
+
});
|
|
94
|
+
it('should reject message 5 minutes in future', () => {
|
|
95
|
+
const timestamp = NOW + 5 * ONE_MINUTE;
|
|
96
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
97
|
+
expect(result.valid).toBe(false);
|
|
98
|
+
expect(result.error).toContain('too far in future');
|
|
99
|
+
});
|
|
100
|
+
it('should reject message 1 minute and 1 millisecond in future', () => {
|
|
101
|
+
const timestamp = NOW + ONE_MINUTE + 1;
|
|
102
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
103
|
+
expect(result.valid).toBe(false);
|
|
104
|
+
expect(result.error).toContain('too far in future');
|
|
105
|
+
});
|
|
106
|
+
it('should reject message 61 seconds in future', () => {
|
|
107
|
+
const timestamp = NOW + 61 * 1000;
|
|
108
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
109
|
+
expect(result.valid).toBe(false);
|
|
110
|
+
expect(result.error).toContain('too far in future');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('Invalid timestamp formats', () => {
|
|
114
|
+
it('should reject non-number timestamp', () => {
|
|
115
|
+
const result = validateHandshakeTimestamp('invalid', { now: NOW });
|
|
116
|
+
expect(result.valid).toBe(false);
|
|
117
|
+
expect(result.error).toContain('Invalid timestamp format');
|
|
118
|
+
});
|
|
119
|
+
it('should reject NaN timestamp', () => {
|
|
120
|
+
const result = validateHandshakeTimestamp(NaN, { now: NOW });
|
|
121
|
+
expect(result.valid).toBe(false);
|
|
122
|
+
expect(result.error).toContain('Invalid timestamp format');
|
|
123
|
+
});
|
|
124
|
+
it('should reject Infinity timestamp', () => {
|
|
125
|
+
const result = validateHandshakeTimestamp(Infinity, { now: NOW });
|
|
126
|
+
expect(result.valid).toBe(false);
|
|
127
|
+
expect(result.error).toContain('Invalid timestamp format');
|
|
128
|
+
});
|
|
129
|
+
it('should reject negative timestamp', () => {
|
|
130
|
+
const result = validateHandshakeTimestamp(-1000, { now: NOW });
|
|
131
|
+
expect(result.valid).toBe(false);
|
|
132
|
+
expect(result.error).toContain('Timestamp must be positive');
|
|
133
|
+
});
|
|
134
|
+
it('should reject zero timestamp', () => {
|
|
135
|
+
const result = validateHandshakeTimestamp(0, { now: NOW });
|
|
136
|
+
expect(result.valid).toBe(false);
|
|
137
|
+
expect(result.error).toContain('Timestamp must be positive');
|
|
138
|
+
});
|
|
139
|
+
it('should reject null timestamp', () => {
|
|
140
|
+
const result = validateHandshakeTimestamp(null, { now: NOW });
|
|
141
|
+
expect(result.valid).toBe(false);
|
|
142
|
+
expect(result.error).toContain('Invalid timestamp format');
|
|
143
|
+
});
|
|
144
|
+
it('should reject undefined timestamp', () => {
|
|
145
|
+
const result = validateHandshakeTimestamp(undefined, { now: NOW });
|
|
146
|
+
expect(result.valid).toBe(false);
|
|
147
|
+
expect(result.error).toContain('Invalid timestamp format');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
describe('Custom options', () => {
|
|
151
|
+
it('should accept custom maxAge (5 minutes)', () => {
|
|
152
|
+
const timestamp = NOW - 3 * ONE_MINUTE;
|
|
153
|
+
const result = validateHandshakeTimestamp(timestamp, {
|
|
154
|
+
now: NOW,
|
|
155
|
+
maxAge: 5 * ONE_MINUTE,
|
|
156
|
+
});
|
|
157
|
+
expect(result.valid).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
it('should reject with custom maxAge (30 seconds)', () => {
|
|
160
|
+
const timestamp = NOW - 45 * 1000;
|
|
161
|
+
const result = validateHandshakeTimestamp(timestamp, {
|
|
162
|
+
now: NOW,
|
|
163
|
+
maxAge: 30 * 1000,
|
|
164
|
+
});
|
|
165
|
+
expect(result.valid).toBe(false);
|
|
166
|
+
expect(result.error).toContain('too old');
|
|
167
|
+
});
|
|
168
|
+
it('should accept custom clockSkewTolerance (2 minutes)', () => {
|
|
169
|
+
const timestamp = NOW + 90 * 1000;
|
|
170
|
+
const result = validateHandshakeTimestamp(timestamp, {
|
|
171
|
+
now: NOW,
|
|
172
|
+
clockSkewTolerance: 2 * ONE_MINUTE,
|
|
173
|
+
});
|
|
174
|
+
expect(result.valid).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
it('should reject with custom clockSkewTolerance (30 seconds)', () => {
|
|
177
|
+
const timestamp = NOW + 45 * 1000;
|
|
178
|
+
const result = validateHandshakeTimestamp(timestamp, {
|
|
179
|
+
now: NOW,
|
|
180
|
+
clockSkewTolerance: 30 * 1000,
|
|
181
|
+
});
|
|
182
|
+
expect(result.valid).toBe(false);
|
|
183
|
+
expect(result.error).toContain('too far in future');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe('Replay attack scenarios', () => {
|
|
187
|
+
it('should prevent replay of 2-minute-old captured message', () => {
|
|
188
|
+
// Simulate attacker capturing a message and replaying it 2 minutes later
|
|
189
|
+
const capturedTimestamp = NOW - 2 * ONE_MINUTE;
|
|
190
|
+
const replayTime = NOW;
|
|
191
|
+
const result = validateHandshakeTimestamp(capturedTimestamp, { now: replayTime });
|
|
192
|
+
expect(result.valid).toBe(false);
|
|
193
|
+
expect(result.error).toContain('too old');
|
|
194
|
+
});
|
|
195
|
+
it('should prevent replay of 5-minute-old captured message', () => {
|
|
196
|
+
// Simulate attacker capturing a message and replaying it 5 minutes later
|
|
197
|
+
const capturedTimestamp = NOW - 5 * ONE_MINUTE;
|
|
198
|
+
const replayTime = NOW;
|
|
199
|
+
const result = validateHandshakeTimestamp(capturedTimestamp, { now: replayTime });
|
|
200
|
+
expect(result.valid).toBe(false);
|
|
201
|
+
expect(result.error).toContain('too old');
|
|
202
|
+
});
|
|
203
|
+
it('should allow legitimate message sent 30 seconds ago', () => {
|
|
204
|
+
// Simulate legitimate slow network (30 second delay)
|
|
205
|
+
const sentTimestamp = NOW - 30 * 1000;
|
|
206
|
+
const receiveTime = NOW;
|
|
207
|
+
const result = validateHandshakeTimestamp(sentTimestamp, { now: receiveTime });
|
|
208
|
+
expect(result.valid).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
it('should prevent attacker from using far-future timestamp', () => {
|
|
211
|
+
// Attacker tries to use timestamp far in future to extend validity
|
|
212
|
+
const attackTimestamp = NOW + 10 * ONE_MINUTE;
|
|
213
|
+
const receiveTime = NOW;
|
|
214
|
+
const result = validateHandshakeTimestamp(attackTimestamp, { now: receiveTime });
|
|
215
|
+
expect(result.valid).toBe(false);
|
|
216
|
+
expect(result.error).toContain('too far in future');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
describe('Edge cases and boundaries', () => {
|
|
220
|
+
it('should handle message at exact maxAge boundary', () => {
|
|
221
|
+
const timestamp = NOW - ONE_MINUTE;
|
|
222
|
+
const result = validateHandshakeTimestamp(timestamp, {
|
|
223
|
+
now: NOW,
|
|
224
|
+
maxAge: ONE_MINUTE,
|
|
225
|
+
});
|
|
226
|
+
expect(result.valid).toBe(true);
|
|
227
|
+
});
|
|
228
|
+
it('should handle message at exact clockSkewTolerance boundary', () => {
|
|
229
|
+
const timestamp = NOW + ONE_MINUTE;
|
|
230
|
+
const result = validateHandshakeTimestamp(timestamp, {
|
|
231
|
+
now: NOW,
|
|
232
|
+
clockSkewTolerance: ONE_MINUTE,
|
|
233
|
+
});
|
|
234
|
+
expect(result.valid).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
it('should reject message 1ms past maxAge boundary', () => {
|
|
237
|
+
const timestamp = NOW - ONE_MINUTE - 1;
|
|
238
|
+
const result = validateHandshakeTimestamp(timestamp, {
|
|
239
|
+
now: NOW,
|
|
240
|
+
maxAge: ONE_MINUTE,
|
|
241
|
+
});
|
|
242
|
+
expect(result.valid).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
it('should reject message 1ms past clockSkewTolerance boundary', () => {
|
|
245
|
+
const timestamp = NOW + ONE_MINUTE + 1;
|
|
246
|
+
const result = validateHandshakeTimestamp(timestamp, {
|
|
247
|
+
now: NOW,
|
|
248
|
+
clockSkewTolerance: ONE_MINUTE,
|
|
249
|
+
});
|
|
250
|
+
expect(result.valid).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
it('should handle very recent timestamp (1ms old)', () => {
|
|
253
|
+
const timestamp = NOW - 1;
|
|
254
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
255
|
+
expect(result.valid).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
it('should handle very recent future timestamp (1ms ahead)', () => {
|
|
258
|
+
const timestamp = NOW + 1;
|
|
259
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
260
|
+
expect(result.valid).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
describe('Error messages', () => {
|
|
264
|
+
it('should provide detailed error for old message', () => {
|
|
265
|
+
const timestamp = NOW - 2 * ONE_MINUTE;
|
|
266
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
267
|
+
expect(result.error).toContain('age: 120s');
|
|
268
|
+
expect(result.error).toContain('max: 60s');
|
|
269
|
+
});
|
|
270
|
+
it('should provide detailed error for future message', () => {
|
|
271
|
+
const timestamp = NOW + 2 * ONE_MINUTE;
|
|
272
|
+
const result = validateHandshakeTimestamp(timestamp, { now: NOW });
|
|
273
|
+
expect(result.error).toContain('skew: 120s');
|
|
274
|
+
expect(result.error).toContain('max: 60s');
|
|
275
|
+
});
|
|
276
|
+
it('should provide clear error for invalid format', () => {
|
|
277
|
+
const result = validateHandshakeTimestamp('not-a-number', { now: NOW });
|
|
278
|
+
expect(result.error).toBe('Invalid timestamp format');
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
//# sourceMappingURL=handshake-validation.test.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test case for token refresh race condition
|
|
3
|
+
*
|
|
4
|
+
* Scenario:
|
|
5
|
+
* 1. Network issue causes ping timeout disconnect
|
|
6
|
+
* 2. Socket.IO auto-reconnects with expired token
|
|
7
|
+
* 3. Server detects expired token, emits error, disconnects
|
|
8
|
+
* 4. Client starts async token refresh
|
|
9
|
+
* 5. OLD socket's disconnect event arrives (race condition)
|
|
10
|
+
*
|
|
11
|
+
* Expected: Client should NOT exit, should complete refresh and reconnect
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=token-refresh-race.test.d.ts.map
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test case for token refresh race condition
|
|
3
|
+
*
|
|
4
|
+
* Scenario:
|
|
5
|
+
* 1. Network issue causes ping timeout disconnect
|
|
6
|
+
* 2. Socket.IO auto-reconnects with expired token
|
|
7
|
+
* 3. Server detects expired token, emits error, disconnects
|
|
8
|
+
* 4. Client starts async token refresh
|
|
9
|
+
* 5. OLD socket's disconnect event arrives (race condition)
|
|
10
|
+
*
|
|
11
|
+
* Expected: Client should NOT exit, should complete refresh and reconnect
|
|
12
|
+
*/
|
|
13
|
+
import { EventEmitter } from 'events';
|
|
14
|
+
// Mock Socket.IO socket
|
|
15
|
+
class MockSocket extends EventEmitter {
|
|
16
|
+
constructor(auth) {
|
|
17
|
+
super();
|
|
18
|
+
this.connected = false;
|
|
19
|
+
this.disconnectReason = null;
|
|
20
|
+
this.auth = auth;
|
|
21
|
+
}
|
|
22
|
+
connect() {
|
|
23
|
+
this.connected = true;
|
|
24
|
+
setTimeout(() => this.emit('connect'), 10);
|
|
25
|
+
}
|
|
26
|
+
disconnect() {
|
|
27
|
+
this.connected = false;
|
|
28
|
+
if (this.disconnectReason) {
|
|
29
|
+
setTimeout(() => this.emit('disconnect', this.disconnectReason), 10);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Simulate server disconnect
|
|
33
|
+
simulateServerDisconnect(reason = 'io server disconnect') {
|
|
34
|
+
this.disconnectReason = reason;
|
|
35
|
+
this.connected = false;
|
|
36
|
+
// Emit disconnect event with delay to simulate race condition
|
|
37
|
+
setTimeout(() => this.emit('disconnect', reason), 50);
|
|
38
|
+
}
|
|
39
|
+
emit(event, ...args) {
|
|
40
|
+
return super.emit(event, ...args);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
describe('Token Refresh Race Condition', () => {
|
|
44
|
+
let mockSocket;
|
|
45
|
+
let processExitSpy;
|
|
46
|
+
let consoleLogSpy;
|
|
47
|
+
let consoleErrorSpy;
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
// Spy on process.exit
|
|
50
|
+
processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
|
|
51
|
+
throw new Error('process.exit called');
|
|
52
|
+
}));
|
|
53
|
+
// Spy on console methods
|
|
54
|
+
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
|
|
55
|
+
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
56
|
+
});
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
processExitSpy.mockRestore();
|
|
59
|
+
consoleLogSpy.mockRestore();
|
|
60
|
+
consoleErrorSpy.mockRestore();
|
|
61
|
+
});
|
|
62
|
+
test('should NOT exit when disconnect event arrives during token refresh', async () => {
|
|
63
|
+
// Simulate the race condition scenario
|
|
64
|
+
const events = [];
|
|
65
|
+
mockSocket = new MockSocket({ firebaseToken: 'expired-token' });
|
|
66
|
+
// Step 1: Simulate ping timeout
|
|
67
|
+
events.push('ping timeout');
|
|
68
|
+
mockSocket.emit('disconnect', 'ping timeout');
|
|
69
|
+
// Step 2: Simulate auto-reconnect with expired token
|
|
70
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
71
|
+
events.push('reconnect attempt');
|
|
72
|
+
// Step 3: Server detects expired token, emits error
|
|
73
|
+
events.push('server error: expired_firebase_token');
|
|
74
|
+
mockSocket.emit('error', {
|
|
75
|
+
code: 'expired_firebase_token',
|
|
76
|
+
message: 'Firebase token has expired'
|
|
77
|
+
});
|
|
78
|
+
// Step 4: Start async token refresh (takes time)
|
|
79
|
+
const tokenRefreshPromise = new Promise(resolve => {
|
|
80
|
+
events.push('token refresh started');
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
events.push('token refresh completed');
|
|
83
|
+
resolve();
|
|
84
|
+
}, 100); // Simulates async refresh delay
|
|
85
|
+
});
|
|
86
|
+
// Step 5: Server disconnects (OLD socket disconnect event)
|
|
87
|
+
// This arrives DURING or AFTER token refresh
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
events.push('disconnect event received (io server disconnect)');
|
|
90
|
+
mockSocket.simulateServerDisconnect('io server disconnect');
|
|
91
|
+
}, 30); // Arrives during refresh
|
|
92
|
+
// Wait for token refresh to complete
|
|
93
|
+
await tokenRefreshPromise;
|
|
94
|
+
// Give time for disconnect event to be processed
|
|
95
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
96
|
+
// Verify the sequence of events
|
|
97
|
+
console.log('\nEvent sequence:', events);
|
|
98
|
+
// CRITICAL: process.exit should NOT have been called
|
|
99
|
+
expect(processExitSpy).not.toHaveBeenCalled();
|
|
100
|
+
// Verify events occurred in problematic order
|
|
101
|
+
const disconnectIndex = events.indexOf('disconnect event received (io server disconnect)');
|
|
102
|
+
const refreshCompletedIndex = events.indexOf('token refresh completed');
|
|
103
|
+
// This test verifies the race condition scenario
|
|
104
|
+
expect(disconnectIndex).toBeGreaterThan(-1);
|
|
105
|
+
expect(refreshCompletedIndex).toBeGreaterThan(-1);
|
|
106
|
+
});
|
|
107
|
+
test('should handle disconnect after refresh completes but before new connection', async () => {
|
|
108
|
+
const events = [];
|
|
109
|
+
mockSocket = new MockSocket({ firebaseToken: 'expired-token' });
|
|
110
|
+
// Token refresh completes
|
|
111
|
+
events.push('token refresh completed');
|
|
112
|
+
// New connection starts (but hasn't authenticated yet)
|
|
113
|
+
events.push('new connection starting');
|
|
114
|
+
// OLD socket disconnect event arrives NOW (worst case timing)
|
|
115
|
+
setTimeout(() => {
|
|
116
|
+
events.push('OLD socket disconnect event');
|
|
117
|
+
mockSocket.emit('disconnect', 'io server disconnect');
|
|
118
|
+
}, 10);
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
120
|
+
// Should NOT exit - the disconnect is from the OLD socket
|
|
121
|
+
expect(processExitSpy).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
test('should differentiate between old and new socket disconnect events', async () => {
|
|
124
|
+
// This test verifies we need to track which socket emitted the disconnect
|
|
125
|
+
const oldSocket = new MockSocket({ firebaseToken: 'expired-token' });
|
|
126
|
+
const newSocket = new MockSocket({ firebaseToken: 'fresh-token' });
|
|
127
|
+
const events = [];
|
|
128
|
+
// Setup handlers for both sockets
|
|
129
|
+
oldSocket.on('disconnect', (reason) => {
|
|
130
|
+
events.push(`OLD socket disconnected: ${reason}`);
|
|
131
|
+
// This should be ignored during token refresh
|
|
132
|
+
});
|
|
133
|
+
newSocket.on('disconnect', (reason) => {
|
|
134
|
+
events.push(`NEW socket disconnected: ${reason}`);
|
|
135
|
+
// This should trigger exit
|
|
136
|
+
});
|
|
137
|
+
// Simulate token refresh scenario
|
|
138
|
+
oldSocket.emit('disconnect', 'io server disconnect'); // Should be ignored
|
|
139
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
140
|
+
newSocket.emit('disconnect', 'io server disconnect'); // Should trigger exit
|
|
141
|
+
expect(events).toEqual([
|
|
142
|
+
'OLD socket disconnected: io server disconnect',
|
|
143
|
+
'NEW socket disconnected: io server disconnect'
|
|
144
|
+
]);
|
|
145
|
+
// The implementation needs to distinguish between these
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe('Proposed Server-Side Fix', () => {
|
|
149
|
+
test('server should NOT disconnect on expired token during reconnection', () => {
|
|
150
|
+
// Proposed behavior:
|
|
151
|
+
// 1. Server detects expired token
|
|
152
|
+
// 2. Server emits error event (expired_firebase_token)
|
|
153
|
+
// 3. Server does NOT disconnect
|
|
154
|
+
// 4. Client refreshes token
|
|
155
|
+
// 5. Client updates socket.auth
|
|
156
|
+
// 6. Client re-authenticates without reconnecting
|
|
157
|
+
const proposedServerBehavior = {
|
|
158
|
+
onExpiredTokenDuringReconnect: (socket) => {
|
|
159
|
+
// Current (problematic):
|
|
160
|
+
// socket.emit('error', { code: 'expired_firebase_token' })
|
|
161
|
+
// socket.disconnect(false) ❌ This causes the race condition
|
|
162
|
+
// Proposed:
|
|
163
|
+
socket.emit('error', {
|
|
164
|
+
code: 'expired_firebase_token',
|
|
165
|
+
message: 'Please refresh your token and re-authenticate'
|
|
166
|
+
});
|
|
167
|
+
// Don't disconnect - let client handle it
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
expect(proposedServerBehavior).toBeDefined();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
//# sourceMappingURL=token-refresh-race.test.js.map
|