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.
Files changed (155) hide show
  1. package/.oxlintrc.json +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +631 -0
  4. package/bin/cli.js +20 -0
  5. package/bin/validate-cwd.js +41 -0
  6. package/dist/config/__tests__/config.test.d.ts +2 -0
  7. package/dist/config/__tests__/config.test.js +262 -0
  8. package/dist/config/__tests__/credentials.test.d.ts +2 -0
  9. package/dist/config/__tests__/credentials.test.js +360 -0
  10. package/dist/config/config.d.ts +33 -0
  11. package/dist/config/config.js +185 -0
  12. package/dist/config/credentials.d.ts +75 -0
  13. package/dist/config/credentials.js +259 -0
  14. package/dist/config/server-selection.d.ts +40 -0
  15. package/dist/config/server-selection.js +130 -0
  16. package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
  17. package/dist/connection/__tests__/firebase-auth.test.js +96 -0
  18. package/dist/connection/__tests__/hmac.test.d.ts +2 -0
  19. package/dist/connection/__tests__/hmac.test.js +372 -0
  20. package/dist/connection/auth.d.ts +13 -0
  21. package/dist/connection/auth.js +91 -0
  22. package/dist/connection/firebase-auth.d.ts +40 -0
  23. package/dist/connection/firebase-auth.js +429 -0
  24. package/dist/connection/hmac.d.ts +24 -0
  25. package/dist/connection/hmac.js +109 -0
  26. package/dist/i18n/index.d.ts +25 -0
  27. package/dist/i18n/index.js +101 -0
  28. package/dist/i18n/locales/en.json +313 -0
  29. package/dist/i18n/locales/es.json +302 -0
  30. package/dist/i18n/locales/fr.json +302 -0
  31. package/dist/i18n/locales/id.json +302 -0
  32. package/dist/i18n/locales/ja.json +302 -0
  33. package/dist/i18n/locales/ko.json +302 -0
  34. package/dist/i18n/locales/locales/en.json +309 -0
  35. package/dist/i18n/locales/locales/es.json +302 -0
  36. package/dist/i18n/locales/locales/fr.json +302 -0
  37. package/dist/i18n/locales/locales/id.json +302 -0
  38. package/dist/i18n/locales/locales/ja.json +302 -0
  39. package/dist/i18n/locales/locales/ko.json +302 -0
  40. package/dist/i18n/locales/locales/pt.json +302 -0
  41. package/dist/i18n/locales/locales/zh-Hans.json +302 -0
  42. package/dist/i18n/locales/pt.json +302 -0
  43. package/dist/i18n/locales/zh-Hans.json +302 -0
  44. package/dist/index.d.ts +25 -0
  45. package/dist/index.js +493 -0
  46. package/dist/proxy/ProxyClient.d.ts +125 -0
  47. package/dist/proxy/ProxyClient.js +781 -0
  48. package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
  49. package/dist/proxy/ProxySocketWrapper.js +98 -0
  50. package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
  51. package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
  52. package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
  53. package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
  54. package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
  55. package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
  56. package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
  57. package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
  58. package/dist/proxy/chunking.d.ts +53 -0
  59. package/dist/proxy/chunking.js +127 -0
  60. package/dist/proxy/handshake-validation.d.ts +21 -0
  61. package/dist/proxy/handshake-validation.js +49 -0
  62. package/dist/rpc/__tests__/router.test.d.ts +2 -0
  63. package/dist/rpc/__tests__/router.test.js +262 -0
  64. package/dist/rpc/router.d.ts +37 -0
  65. package/dist/rpc/router.js +132 -0
  66. package/dist/services/BrowserProxyService.d.ts +13 -0
  67. package/dist/services/BrowserProxyService.js +139 -0
  68. package/dist/services/FilesystemService.d.ts +99 -0
  69. package/dist/services/FilesystemService.js +742 -0
  70. package/dist/services/GitService.d.ts +243 -0
  71. package/dist/services/GitService.js +1439 -0
  72. package/dist/services/SearchService.d.ts +93 -0
  73. package/dist/services/SearchService.js +670 -0
  74. package/dist/services/TerminalService.d.ts +62 -0
  75. package/dist/services/TerminalService.js +337 -0
  76. package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
  77. package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
  78. package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
  79. package/dist/services/__tests__/FilesystemService.test.js +609 -0
  80. package/dist/services/__tests__/GitService.test.d.ts +2 -0
  81. package/dist/services/__tests__/GitService.test.js +953 -0
  82. package/dist/services/__tests__/SearchService.test.d.ts +2 -0
  83. package/dist/services/__tests__/SearchService.test.js +384 -0
  84. package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
  85. package/dist/services/__tests__/TerminalService.test.js +513 -0
  86. package/dist/setup/wizard.d.ts +10 -0
  87. package/dist/setup/wizard.js +172 -0
  88. package/dist/types.d.ts +196 -0
  89. package/dist/types.js +44 -0
  90. package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
  91. package/dist/utils/__tests__/gitignore.test.js +127 -0
  92. package/dist/utils/gitignore.d.ts +24 -0
  93. package/dist/utils/gitignore.js +77 -0
  94. package/dist/utils/logger.d.ts +96 -0
  95. package/dist/utils/logger.js +456 -0
  96. package/dist/utils/project-dir.d.ts +51 -0
  97. package/dist/utils/project-dir.js +191 -0
  98. package/dist/utils/ripgrep.d.ts +34 -0
  99. package/dist/utils/ripgrep.js +148 -0
  100. package/dist/utils/tool-detection.d.ts +17 -0
  101. package/dist/utils/tool-detection.js +126 -0
  102. package/dist/watcher/FileWatcher.d.ts +10 -0
  103. package/dist/watcher/FileWatcher.js +42 -0
  104. package/package.json +70 -0
  105. package/src/config/__tests__/config.test.ts +318 -0
  106. package/src/config/__tests__/credentials.test.ts +494 -0
  107. package/src/config/config.ts +206 -0
  108. package/src/config/credentials.ts +302 -0
  109. package/src/config/server-selection.ts +150 -0
  110. package/src/connection/__tests__/firebase-auth.test.ts +121 -0
  111. package/src/connection/__tests__/hmac.test.ts +509 -0
  112. package/src/connection/auth.ts +140 -0
  113. package/src/connection/firebase-auth.ts +504 -0
  114. package/src/connection/hmac.ts +139 -0
  115. package/src/i18n/index.ts +119 -0
  116. package/src/i18n/locales/en.json +313 -0
  117. package/src/i18n/locales/es.json +302 -0
  118. package/src/i18n/locales/fr.json +302 -0
  119. package/src/i18n/locales/id.json +302 -0
  120. package/src/i18n/locales/ja.json +302 -0
  121. package/src/i18n/locales/ko.json +302 -0
  122. package/src/i18n/locales/pt.json +302 -0
  123. package/src/i18n/locales/zh-Hans.json +302 -0
  124. package/src/index.ts +542 -0
  125. package/src/proxy/ProxyClient.ts +968 -0
  126. package/src/proxy/ProxySocketWrapper.ts +113 -0
  127. package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
  128. package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
  129. package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
  130. package/src/proxy/chunking.ts +162 -0
  131. package/src/proxy/handshake-validation.ts +64 -0
  132. package/src/rpc/__tests__/router.test.ts +400 -0
  133. package/src/rpc/router.ts +183 -0
  134. package/src/services/BrowserProxyService.ts +179 -0
  135. package/src/services/FilesystemService.ts +841 -0
  136. package/src/services/GitService.ts +1639 -0
  137. package/src/services/SearchService.ts +809 -0
  138. package/src/services/TerminalService.ts +413 -0
  139. package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
  140. package/src/services/__tests__/FilesystemService.test.ts +1002 -0
  141. package/src/services/__tests__/GitService.test.ts +1552 -0
  142. package/src/services/__tests__/SearchService.test.ts +484 -0
  143. package/src/services/__tests__/TerminalService.test.ts +702 -0
  144. package/src/setup/wizard.ts +242 -0
  145. package/src/types/fossil-delta.d.ts +4 -0
  146. package/src/types.ts +287 -0
  147. package/src/utils/__tests__/gitignore.test.ts +174 -0
  148. package/src/utils/gitignore.ts +91 -0
  149. package/src/utils/logger.ts +578 -0
  150. package/src/utils/project-dir.ts +218 -0
  151. package/src/utils/ripgrep.ts +180 -0
  152. package/src/utils/tool-detection.ts +141 -0
  153. package/src/watcher/FileWatcher.ts +53 -0
  154. package/tsconfig.json +24 -0
  155. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=handshake-validation.test.d.ts.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