@lobehub/lobehub 2.0.0-next.20 → 2.0.0-next.22
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/.github/workflows/claude-auto-testing.yml +73 -0
- package/.github/workflows/claude-translate-comments.yml +67 -0
- package/.github/workflows/release.yml +1 -1
- package/.github/workflows/test.yml +39 -2
- package/CHANGELOG.md +42 -0
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/src/main/controllers/AuthCtr.ts +53 -39
- package/apps/desktop/src/main/controllers/MenuCtr.ts +5 -5
- package/apps/desktop/src/main/controllers/NotificationCtr.ts +29 -29
- package/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts +16 -16
- package/apps/desktop/src/main/controllers/ShortcutCtr.ts +2 -2
- package/apps/desktop/src/main/controllers/TrayMenuCtr.ts +18 -18
- package/apps/desktop/src/main/controllers/UpdaterCtr.ts +4 -4
- package/apps/desktop/src/main/controllers/__tests__/AuthCtr.test.ts +706 -0
- package/apps/desktop/src/main/controllers/__tests__/TrayMenuCtr.test.ts +5 -5
- package/apps/desktop/src/main/controllers/index.ts +4 -4
- package/changelog/v1.json +14 -0
- package/docs/development/database-schema.dbml +2 -1
- package/package.json +2 -2
- package/packages/database/migrations/0042_improve_agent_index.sql +1 -0
- package/packages/database/migrations/meta/0042_snapshot.json +7800 -0
- package/packages/database/migrations/meta/_journal.json +7 -0
- package/packages/database/src/core/migrations.json +8 -0
- package/packages/database/src/models/agent.ts +16 -13
- package/packages/database/src/models/session.ts +20 -9
- package/packages/database/src/models/user.ts +2 -1
- package/packages/database/src/schemas/agent.ts +4 -1
- package/packages/types/src/message/ui/params.ts +1 -1
- package/packages/utils/src/apiKey.test.ts +139 -0
- package/packages/utils/src/client/clipboard.ts +2 -2
- package/packages/utils/src/client/exportFile.ts +10 -10
- package/packages/utils/src/client/parserPlaceholder.ts +18 -18
- package/packages/utils/src/client/topic.ts +10 -10
- package/packages/utils/src/client/xor-obfuscation.ts +11 -11
- package/renovate.json +20 -3
- package/src/app/[variants]/oauth/consent/[uid]/Login.tsx +10 -1
- package/src/server/routers/lambda/message.ts +0 -2
- package/src/server/routers/lambda/user.ts +8 -6
- package/src/services/chat/index.ts +3 -3
- package/src/services/mcp.test.ts +777 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
|
|
2
|
+
import { BrowserWindow, shell } from 'electron';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import type { App } from '@/core/App';
|
|
7
|
+
|
|
8
|
+
import AuthCtr from '../AuthCtr';
|
|
9
|
+
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
|
|
10
|
+
|
|
11
|
+
// Mock logger
|
|
12
|
+
vi.mock('@/utils/logger', () => ({
|
|
13
|
+
createLogger: () => ({
|
|
14
|
+
debug: vi.fn(),
|
|
15
|
+
info: vi.fn(),
|
|
16
|
+
warn: vi.fn(),
|
|
17
|
+
error: vi.fn(),
|
|
18
|
+
}),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// Mock electron
|
|
22
|
+
vi.mock('electron', () => ({
|
|
23
|
+
BrowserWindow: {
|
|
24
|
+
getAllWindows: vi.fn(() => []),
|
|
25
|
+
},
|
|
26
|
+
shell: {
|
|
27
|
+
openExternal: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
},
|
|
29
|
+
safeStorage: {
|
|
30
|
+
isEncryptionAvailable: vi.fn(() => true),
|
|
31
|
+
encryptString: vi.fn((str: string) => Buffer.from(str)),
|
|
32
|
+
decryptString: vi.fn((buffer: Buffer) => buffer.toString()),
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
// Mock electron-is
|
|
37
|
+
vi.mock('electron-is', () => ({
|
|
38
|
+
macOS: vi.fn(() => false),
|
|
39
|
+
windows: vi.fn(() => false),
|
|
40
|
+
linux: vi.fn(() => false),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
// Mock OFFICIAL_CLOUD_SERVER
|
|
44
|
+
vi.mock('@/const/env', () => ({
|
|
45
|
+
OFFICIAL_CLOUD_SERVER: 'https://lobehub-cloud.com',
|
|
46
|
+
isMac: false,
|
|
47
|
+
isWindows: false,
|
|
48
|
+
isLinux: false,
|
|
49
|
+
isDev: false,
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
// Mock crypto
|
|
53
|
+
let randomBytesCounter = 0;
|
|
54
|
+
vi.mock('node:crypto', () => ({
|
|
55
|
+
default: {
|
|
56
|
+
randomBytes: vi.fn((size: number) => {
|
|
57
|
+
randomBytesCounter++;
|
|
58
|
+
return {
|
|
59
|
+
toString: vi.fn(() => `mock-random-${randomBytesCounter}`),
|
|
60
|
+
};
|
|
61
|
+
}),
|
|
62
|
+
subtle: {
|
|
63
|
+
digest: vi.fn(() => Promise.resolve(new ArrayBuffer(32))),
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
// Create mock App and RemoteServerConfigCtr
|
|
69
|
+
const mockRemoteServerConfigCtr = {
|
|
70
|
+
clearTokens: vi.fn().mockResolvedValue(undefined),
|
|
71
|
+
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
|
|
72
|
+
getRemoteServerConfig: vi.fn().mockResolvedValue({ active: true, storageMode: 'cloud' }),
|
|
73
|
+
getRemoteServerUrl: vi.fn().mockImplementation(async (config?: DataSyncConfig) => {
|
|
74
|
+
if (config?.storageMode === 'selfHost') {
|
|
75
|
+
return config.remoteServerUrl || 'https://mock-server.com';
|
|
76
|
+
}
|
|
77
|
+
return 'https://lobehub-cloud.com'; // OFFICIAL_CLOUD_SERVER
|
|
78
|
+
}),
|
|
79
|
+
getTokenExpiresAt: vi.fn().mockReturnValue(Date.now() + 3600000),
|
|
80
|
+
isTokenExpiringSoon: vi.fn().mockReturnValue(false),
|
|
81
|
+
refreshAccessToken: vi.fn().mockResolvedValue({ success: true }),
|
|
82
|
+
saveTokens: vi.fn().mockResolvedValue(undefined),
|
|
83
|
+
setRemoteServerConfig: vi.fn().mockResolvedValue(true),
|
|
84
|
+
} as unknown as RemoteServerConfigCtr;
|
|
85
|
+
|
|
86
|
+
const mockApp = {
|
|
87
|
+
getController: vi.fn((ControllerClass) => {
|
|
88
|
+
if (ControllerClass === RemoteServerConfigCtr) {
|
|
89
|
+
return mockRemoteServerConfigCtr;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}),
|
|
93
|
+
} as unknown as App;
|
|
94
|
+
|
|
95
|
+
describe('AuthCtr', () => {
|
|
96
|
+
let authCtr: AuthCtr;
|
|
97
|
+
let mockFetch: ReturnType<typeof vi.fn>;
|
|
98
|
+
let mockWindow: any;
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
vi.clearAllMocks();
|
|
102
|
+
randomBytesCounter = 0; // Reset counter for each test
|
|
103
|
+
|
|
104
|
+
// Reset shell.openExternal to default successful behavior
|
|
105
|
+
vi.mocked(shell.openExternal).mockResolvedValue(undefined);
|
|
106
|
+
|
|
107
|
+
// Create fresh instance for each test
|
|
108
|
+
authCtr = new AuthCtr(mockApp);
|
|
109
|
+
|
|
110
|
+
// Mock global fetch
|
|
111
|
+
mockFetch = vi.fn();
|
|
112
|
+
global.fetch = mockFetch;
|
|
113
|
+
|
|
114
|
+
// Mock BrowserWindow with send spy
|
|
115
|
+
mockWindow = {
|
|
116
|
+
isDestroyed: vi.fn(() => false),
|
|
117
|
+
webContents: {
|
|
118
|
+
send: vi.fn(),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([mockWindow]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
// Clean up authCtr intervals (using real timers, not fake timers)
|
|
126
|
+
authCtr.cleanup();
|
|
127
|
+
// Clean up any fake timers if used
|
|
128
|
+
vi.clearAllTimers();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('Basic functionality', () => {
|
|
132
|
+
// Use real timers for all tests since setInterval with async doesn't work well with fake timers
|
|
133
|
+
|
|
134
|
+
describe('requestAuthorization', () => {
|
|
135
|
+
it('should generate PKCE parameters and open authorization URL', async () => {
|
|
136
|
+
const config: DataSyncConfig = {
|
|
137
|
+
active: false,
|
|
138
|
+
storageMode: 'cloud',
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
mockFetch.mockResolvedValue({
|
|
142
|
+
status: 404,
|
|
143
|
+
ok: false,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const result = await authCtr.requestAuthorization(config);
|
|
147
|
+
|
|
148
|
+
// Verify success response
|
|
149
|
+
expect(result).toEqual({ success: true });
|
|
150
|
+
|
|
151
|
+
// Verify shell.openExternal was called with correct URL
|
|
152
|
+
expect(shell.openExternal).toHaveBeenCalledWith(
|
|
153
|
+
expect.stringContaining('https://lobehub-cloud.com/oidc/auth'),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Verify URL contains required parameters
|
|
157
|
+
const authUrl = vi.mocked(shell.openExternal).mock.calls[0][0];
|
|
158
|
+
expect(authUrl).toContain('client_id=lobehub-desktop');
|
|
159
|
+
expect(authUrl).toContain('response_type=code');
|
|
160
|
+
expect(authUrl).toContain('code_challenge_method=S256');
|
|
161
|
+
expect(authUrl).toContain('scope=profile%20email%20offline_access');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should start polling after authorization request', async () => {
|
|
165
|
+
const config: DataSyncConfig = {
|
|
166
|
+
active: false,
|
|
167
|
+
storageMode: 'cloud',
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
mockFetch.mockResolvedValue({
|
|
171
|
+
status: 404,
|
|
172
|
+
ok: false,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const result = await authCtr.requestAuthorization(config);
|
|
176
|
+
expect(result.success).toBe(true);
|
|
177
|
+
|
|
178
|
+
// Wait a bit for polling to start
|
|
179
|
+
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
180
|
+
|
|
181
|
+
// Verify fetch was called for polling
|
|
182
|
+
const pollingCalls = mockFetch.mock.calls.filter((call) =>
|
|
183
|
+
(call[0] as string).includes('/oidc/handoff'),
|
|
184
|
+
);
|
|
185
|
+
expect(pollingCalls.length).toBeGreaterThan(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should use self-hosted server URL when storageMode is selfHost', async () => {
|
|
189
|
+
const config: DataSyncConfig = {
|
|
190
|
+
active: false,
|
|
191
|
+
storageMode: 'selfHost',
|
|
192
|
+
remoteServerUrl: 'https://my-custom-server.com',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
mockFetch.mockResolvedValue({
|
|
196
|
+
status: 404,
|
|
197
|
+
ok: false,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await authCtr.requestAuthorization(config);
|
|
201
|
+
|
|
202
|
+
// Verify shell.openExternal was called with custom URL
|
|
203
|
+
expect(shell.openExternal).toHaveBeenCalledWith(
|
|
204
|
+
expect.stringContaining('https://my-custom-server.com/oidc/auth'),
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should handle authorization request error gracefully', async () => {
|
|
209
|
+
const config: DataSyncConfig = {
|
|
210
|
+
active: false,
|
|
211
|
+
storageMode: 'cloud',
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
vi.mocked(shell.openExternal).mockRejectedValue(new Error('Failed to open browser'));
|
|
215
|
+
|
|
216
|
+
const result = await authCtr.requestAuthorization(config);
|
|
217
|
+
|
|
218
|
+
expect(result.success).toBe(false);
|
|
219
|
+
expect(result.error).toContain('Failed to open browser');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe('polling mechanism', () => {
|
|
224
|
+
it('should poll every 3 seconds', async () => {
|
|
225
|
+
const config: DataSyncConfig = {
|
|
226
|
+
active: false,
|
|
227
|
+
storageMode: 'cloud',
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
mockFetch.mockResolvedValue({
|
|
231
|
+
status: 404,
|
|
232
|
+
ok: false,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await authCtr.requestAuthorization(config);
|
|
236
|
+
|
|
237
|
+
// Wait for first poll
|
|
238
|
+
await new Promise((resolve) => setTimeout(resolve, 3100));
|
|
239
|
+
|
|
240
|
+
const firstCallCount = mockFetch.mock.calls.filter((call) =>
|
|
241
|
+
(call[0] as string).includes('/oidc/handoff'),
|
|
242
|
+
).length;
|
|
243
|
+
expect(firstCallCount).toBeGreaterThanOrEqual(1);
|
|
244
|
+
|
|
245
|
+
// Wait for second poll
|
|
246
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
247
|
+
|
|
248
|
+
const secondCallCount = mockFetch.mock.calls.filter((call) =>
|
|
249
|
+
(call[0] as string).includes('/oidc/handoff'),
|
|
250
|
+
).length;
|
|
251
|
+
expect(secondCallCount).toBeGreaterThanOrEqual(2);
|
|
252
|
+
}, 10000);
|
|
253
|
+
|
|
254
|
+
it('should stop polling when credentials are received', async () => {
|
|
255
|
+
const config: DataSyncConfig = {
|
|
256
|
+
active: false,
|
|
257
|
+
storageMode: 'cloud',
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
let pollCount = 0;
|
|
261
|
+
mockFetch.mockImplementation((url: string) => {
|
|
262
|
+
const urlObj = new URL(url);
|
|
263
|
+
|
|
264
|
+
// Return success on third poll
|
|
265
|
+
if (urlObj.pathname.includes('/oidc/handoff')) {
|
|
266
|
+
pollCount++;
|
|
267
|
+
if (pollCount >= 3) {
|
|
268
|
+
return Promise.resolve({
|
|
269
|
+
status: 200,
|
|
270
|
+
ok: true,
|
|
271
|
+
json: () =>
|
|
272
|
+
Promise.resolve({
|
|
273
|
+
success: true,
|
|
274
|
+
data: {
|
|
275
|
+
payload: {
|
|
276
|
+
code: 'mock-auth-code',
|
|
277
|
+
state: 'mock-random-2', // Second randomBytes call is for state
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
}),
|
|
281
|
+
text: () => Promise.resolve('mock response'),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Token exchange endpoint
|
|
287
|
+
if (urlObj.pathname.includes('/oidc/token')) {
|
|
288
|
+
return Promise.resolve({
|
|
289
|
+
status: 200,
|
|
290
|
+
ok: true,
|
|
291
|
+
json: () =>
|
|
292
|
+
Promise.resolve({
|
|
293
|
+
access_token: 'new-access-token',
|
|
294
|
+
refresh_token: 'new-refresh-token',
|
|
295
|
+
expires_in: 3600,
|
|
296
|
+
}),
|
|
297
|
+
text: () => Promise.resolve('mock response'),
|
|
298
|
+
clone: () => ({
|
|
299
|
+
json: () =>
|
|
300
|
+
Promise.resolve({
|
|
301
|
+
access_token: 'new-access-token',
|
|
302
|
+
refresh_token: 'new-refresh-token',
|
|
303
|
+
expires_in: 3600,
|
|
304
|
+
}),
|
|
305
|
+
}),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return Promise.resolve({
|
|
310
|
+
status: 404,
|
|
311
|
+
ok: false,
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await authCtr.requestAuthorization(config);
|
|
316
|
+
|
|
317
|
+
// Wait for polling to complete
|
|
318
|
+
await new Promise((resolve) => setTimeout(resolve, 10000));
|
|
319
|
+
|
|
320
|
+
const pollCountBefore = pollCount;
|
|
321
|
+
|
|
322
|
+
// Wait more time and verify no more polling
|
|
323
|
+
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
324
|
+
expect(pollCount).toBe(pollCountBefore);
|
|
325
|
+
}, 15000);
|
|
326
|
+
|
|
327
|
+
it('should broadcast authorizationSuccessful when credentials are exchanged', async () => {
|
|
328
|
+
const config: DataSyncConfig = {
|
|
329
|
+
active: false,
|
|
330
|
+
storageMode: 'cloud',
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
mockFetch.mockImplementation((url: string) => {
|
|
334
|
+
const urlObj = new URL(url);
|
|
335
|
+
|
|
336
|
+
if (urlObj.pathname.includes('/oidc/handoff')) {
|
|
337
|
+
return Promise.resolve({
|
|
338
|
+
status: 200,
|
|
339
|
+
ok: true,
|
|
340
|
+
json: () =>
|
|
341
|
+
Promise.resolve({
|
|
342
|
+
success: true,
|
|
343
|
+
data: {
|
|
344
|
+
payload: {
|
|
345
|
+
code: 'mock-auth-code',
|
|
346
|
+
state: 'mock-random-2', // Second randomBytes call is for state
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
}),
|
|
350
|
+
text: () => Promise.resolve('mock response'),
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (urlObj.pathname.includes('/oidc/token')) {
|
|
355
|
+
return Promise.resolve({
|
|
356
|
+
status: 200,
|
|
357
|
+
ok: true,
|
|
358
|
+
json: () =>
|
|
359
|
+
Promise.resolve({
|
|
360
|
+
access_token: 'new-access-token',
|
|
361
|
+
refresh_token: 'new-refresh-token',
|
|
362
|
+
expires_in: 3600,
|
|
363
|
+
}),
|
|
364
|
+
text: () => Promise.resolve('mock response'),
|
|
365
|
+
clone: () => ({
|
|
366
|
+
json: () =>
|
|
367
|
+
Promise.resolve({
|
|
368
|
+
access_token: 'new-access-token',
|
|
369
|
+
refresh_token: 'new-refresh-token',
|
|
370
|
+
expires_in: 3600,
|
|
371
|
+
}),
|
|
372
|
+
}),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return Promise.resolve({ status: 404, ok: false });
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
await authCtr.requestAuthorization(config);
|
|
380
|
+
|
|
381
|
+
// Wait for polling to complete and token exchange
|
|
382
|
+
await new Promise((resolve) => setTimeout(resolve, 4000));
|
|
383
|
+
|
|
384
|
+
// Verify authorizationSuccessful was broadcast
|
|
385
|
+
expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationSuccessful');
|
|
386
|
+
}, 6000);
|
|
387
|
+
|
|
388
|
+
it('should validate state parameter and reject mismatched state', async () => {
|
|
389
|
+
const config: DataSyncConfig = {
|
|
390
|
+
active: false,
|
|
391
|
+
storageMode: 'cloud',
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
mockFetch.mockImplementation((url: string) => {
|
|
395
|
+
const urlObj = new URL(url);
|
|
396
|
+
|
|
397
|
+
if (urlObj.pathname.includes('/oidc/handoff')) {
|
|
398
|
+
return Promise.resolve({
|
|
399
|
+
status: 200,
|
|
400
|
+
ok: true,
|
|
401
|
+
json: () =>
|
|
402
|
+
Promise.resolve({
|
|
403
|
+
success: true,
|
|
404
|
+
data: {
|
|
405
|
+
payload: {
|
|
406
|
+
code: 'mock-auth-code',
|
|
407
|
+
state: 'wrong-state', // Mismatched state
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
}),
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return Promise.resolve({ status: 404, ok: false });
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
await authCtr.requestAuthorization(config);
|
|
418
|
+
|
|
419
|
+
// Wait for polling and state validation
|
|
420
|
+
await new Promise((resolve) => setTimeout(resolve, 4000));
|
|
421
|
+
|
|
422
|
+
// Verify authorizationFailed was broadcast with state error
|
|
423
|
+
expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationFailed', {
|
|
424
|
+
error: 'Invalid state parameter',
|
|
425
|
+
});
|
|
426
|
+
}, 6000);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
describe('token refresh', () => {
|
|
430
|
+
it('should start auto-refresh after successful authorization', async () => {
|
|
431
|
+
const config: DataSyncConfig = {
|
|
432
|
+
active: false,
|
|
433
|
+
storageMode: 'cloud',
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
mockFetch.mockImplementation((url: string) => {
|
|
437
|
+
const urlObj = new URL(url);
|
|
438
|
+
|
|
439
|
+
if (urlObj.pathname.includes('/oidc/handoff')) {
|
|
440
|
+
return Promise.resolve({
|
|
441
|
+
status: 200,
|
|
442
|
+
ok: true,
|
|
443
|
+
json: () =>
|
|
444
|
+
Promise.resolve({
|
|
445
|
+
success: true,
|
|
446
|
+
data: {
|
|
447
|
+
payload: {
|
|
448
|
+
code: 'mock-auth-code',
|
|
449
|
+
state: 'mock-random-2', // Second randomBytes call is for state
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
}),
|
|
453
|
+
text: () => Promise.resolve('mock response'),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (urlObj.pathname.includes('/oidc/token')) {
|
|
458
|
+
return Promise.resolve({
|
|
459
|
+
status: 200,
|
|
460
|
+
ok: true,
|
|
461
|
+
json: () =>
|
|
462
|
+
Promise.resolve({
|
|
463
|
+
access_token: 'new-access-token',
|
|
464
|
+
refresh_token: 'new-refresh-token',
|
|
465
|
+
expires_in: 3600,
|
|
466
|
+
}),
|
|
467
|
+
text: () => Promise.resolve('mock response'),
|
|
468
|
+
clone: () => ({
|
|
469
|
+
json: () =>
|
|
470
|
+
Promise.resolve({
|
|
471
|
+
access_token: 'new-access-token',
|
|
472
|
+
refresh_token: 'new-refresh-token',
|
|
473
|
+
expires_in: 3600,
|
|
474
|
+
}),
|
|
475
|
+
}),
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return Promise.resolve({ status: 404, ok: false });
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
await authCtr.requestAuthorization(config);
|
|
483
|
+
|
|
484
|
+
// Wait for polling and token exchange
|
|
485
|
+
await new Promise((resolve) => setTimeout(resolve, 4000));
|
|
486
|
+
|
|
487
|
+
// Verify saveTokens was called
|
|
488
|
+
expect(mockRemoteServerConfigCtr.saveTokens).toHaveBeenCalledWith(
|
|
489
|
+
'new-access-token',
|
|
490
|
+
'new-refresh-token',
|
|
491
|
+
3600,
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
// Verify remote server was set to active
|
|
495
|
+
expect(mockRemoteServerConfigCtr.setRemoteServerConfig).toHaveBeenCalledWith({
|
|
496
|
+
active: true,
|
|
497
|
+
});
|
|
498
|
+
}, 6000);
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
describe('Scenario: Authorization Timeout and Retry', () => {
|
|
503
|
+
// All scenario tests use real timers
|
|
504
|
+
|
|
505
|
+
it('Step 1: User requests authorization but does not complete it within 5 minutes', async () => {
|
|
506
|
+
const config: DataSyncConfig = {
|
|
507
|
+
active: false,
|
|
508
|
+
storageMode: 'cloud',
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
// Mock: User never completes authorization, so polling always returns 404
|
|
512
|
+
mockFetch.mockResolvedValue({
|
|
513
|
+
status: 404,
|
|
514
|
+
ok: false,
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// User clicks "Connect to Cloud" button
|
|
518
|
+
await authCtr.requestAuthorization(config);
|
|
519
|
+
|
|
520
|
+
// Wait for some polling to happen
|
|
521
|
+
await new Promise((resolve) => setTimeout(resolve, 10000));
|
|
522
|
+
|
|
523
|
+
const handoffCallsBeforeTimeout = mockFetch.mock.calls.filter((call) =>
|
|
524
|
+
(call[0] as string).includes('/oidc/handoff'),
|
|
525
|
+
).length;
|
|
526
|
+
expect(handoffCallsBeforeTimeout).toBeGreaterThan(0);
|
|
527
|
+
|
|
528
|
+
// Verify polling is active by checking calls increased
|
|
529
|
+
const callsBefore = handoffCallsBeforeTimeout;
|
|
530
|
+
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
531
|
+
const callsAfter = mockFetch.mock.calls.filter((call) =>
|
|
532
|
+
(call[0] as string).includes('/oidc/handoff'),
|
|
533
|
+
).length;
|
|
534
|
+
expect(callsAfter).toBeGreaterThan(callsBefore);
|
|
535
|
+
}, 15000); // Increase test timeout
|
|
536
|
+
|
|
537
|
+
it('Step 2: User clicks retry button after previous attempt', async () => {
|
|
538
|
+
const config: DataSyncConfig = {
|
|
539
|
+
active: false,
|
|
540
|
+
storageMode: 'cloud',
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
mockFetch.mockResolvedValue({
|
|
544
|
+
status: 404,
|
|
545
|
+
ok: false,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
// First attempt
|
|
549
|
+
await authCtr.requestAuthorization(config);
|
|
550
|
+
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
551
|
+
|
|
552
|
+
// Reset mock to track retry
|
|
553
|
+
mockFetch.mockClear();
|
|
554
|
+
|
|
555
|
+
// User clicks retry button - should start fresh authorization
|
|
556
|
+
await authCtr.requestAuthorization(config);
|
|
557
|
+
|
|
558
|
+
// Verify: New polling started
|
|
559
|
+
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
560
|
+
|
|
561
|
+
const handoffCalls = mockFetch.mock.calls.filter((call) =>
|
|
562
|
+
(call[0] as string).includes('/oidc/handoff'),
|
|
563
|
+
);
|
|
564
|
+
expect(handoffCalls.length).toBeGreaterThan(0);
|
|
565
|
+
}, 10000);
|
|
566
|
+
|
|
567
|
+
it('Step 3: Retry generates new state parameter (not reusing old state)', async () => {
|
|
568
|
+
const config: DataSyncConfig = {
|
|
569
|
+
active: false,
|
|
570
|
+
storageMode: 'cloud',
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const capturedStates: string[] = [];
|
|
574
|
+
|
|
575
|
+
mockFetch.mockImplementation((url: string) => {
|
|
576
|
+
const urlObj = new URL(url);
|
|
577
|
+
const stateParam = urlObj.searchParams.get('id');
|
|
578
|
+
if (stateParam && !capturedStates.includes(stateParam)) {
|
|
579
|
+
capturedStates.push(stateParam);
|
|
580
|
+
}
|
|
581
|
+
return Promise.resolve({ status: 404, ok: false });
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// First authorization attempt
|
|
585
|
+
await authCtr.requestAuthorization(config);
|
|
586
|
+
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
587
|
+
const firstState = capturedStates[0];
|
|
588
|
+
|
|
589
|
+
// Clear for second attempt tracking
|
|
590
|
+
const firstAttemptStates = [...capturedStates];
|
|
591
|
+
capturedStates.length = 0;
|
|
592
|
+
|
|
593
|
+
// Retry - should generate NEW state
|
|
594
|
+
await authCtr.requestAuthorization(config);
|
|
595
|
+
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
596
|
+
const secondState = capturedStates[0];
|
|
597
|
+
|
|
598
|
+
// CRITICAL: States must be different
|
|
599
|
+
expect(firstState).toBeDefined();
|
|
600
|
+
expect(secondState).toBeDefined();
|
|
601
|
+
expect(secondState).not.toBe(firstState);
|
|
602
|
+
expect(firstAttemptStates).not.toContain(secondState);
|
|
603
|
+
}, 10000);
|
|
604
|
+
|
|
605
|
+
it('Step 4: User completes authorization on retry successfully', async () => {
|
|
606
|
+
const config: DataSyncConfig = {
|
|
607
|
+
active: false,
|
|
608
|
+
storageMode: 'cloud',
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
// First attempt - incomplete
|
|
612
|
+
mockFetch.mockResolvedValue({ status: 404, ok: false });
|
|
613
|
+
await authCtr.requestAuthorization(config);
|
|
614
|
+
await new Promise((resolve) => setTimeout(resolve, 3500));
|
|
615
|
+
|
|
616
|
+
// Second attempt - user completes it this time
|
|
617
|
+
mockFetch.mockImplementation((url: string) => {
|
|
618
|
+
const urlObj = new URL(url);
|
|
619
|
+
|
|
620
|
+
// Handoff returns credentials immediately
|
|
621
|
+
if (urlObj.pathname.includes('/oidc/handoff')) {
|
|
622
|
+
return Promise.resolve({
|
|
623
|
+
status: 200,
|
|
624
|
+
ok: true,
|
|
625
|
+
json: () =>
|
|
626
|
+
Promise.resolve({
|
|
627
|
+
success: true,
|
|
628
|
+
data: {
|
|
629
|
+
payload: {
|
|
630
|
+
code: 'authorization-code',
|
|
631
|
+
state: 'mock-random-4', // Matches second request's state (3rd and 4th randomBytes calls)
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
}),
|
|
635
|
+
text: () => Promise.resolve('mock response'),
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Token exchange succeeds
|
|
640
|
+
if (urlObj.pathname.includes('/oidc/token')) {
|
|
641
|
+
return Promise.resolve({
|
|
642
|
+
status: 200,
|
|
643
|
+
ok: true,
|
|
644
|
+
json: () =>
|
|
645
|
+
Promise.resolve({
|
|
646
|
+
access_token: 'access-token',
|
|
647
|
+
refresh_token: 'refresh-token',
|
|
648
|
+
expires_in: 3600,
|
|
649
|
+
}),
|
|
650
|
+
text: () => Promise.resolve('mock response'),
|
|
651
|
+
clone: () => ({
|
|
652
|
+
json: () =>
|
|
653
|
+
Promise.resolve({
|
|
654
|
+
access_token: 'access-token',
|
|
655
|
+
refresh_token: 'refresh-token',
|
|
656
|
+
expires_in: 3600,
|
|
657
|
+
}),
|
|
658
|
+
}),
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return Promise.resolve({ status: 404, ok: false });
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
await authCtr.requestAuthorization(config);
|
|
666
|
+
|
|
667
|
+
// Wait longer for polling and token exchange
|
|
668
|
+
await new Promise((resolve) => setTimeout(resolve, 4000));
|
|
669
|
+
|
|
670
|
+
// Verify: Success message shown
|
|
671
|
+
const successCall = mockWindow.webContents.send.mock.calls.find(
|
|
672
|
+
(call: any[]) => call[0] === 'authorizationSuccessful',
|
|
673
|
+
);
|
|
674
|
+
expect(successCall).toBeDefined();
|
|
675
|
+
|
|
676
|
+
// Verify: Tokens saved
|
|
677
|
+
expect(mockRemoteServerConfigCtr.saveTokens).toHaveBeenCalled();
|
|
678
|
+
}, 12000);
|
|
679
|
+
|
|
680
|
+
it('Edge case: Rapid retry clicks should not create multiple polling intervals', async () => {
|
|
681
|
+
const config: DataSyncConfig = {
|
|
682
|
+
active: false,
|
|
683
|
+
storageMode: 'cloud',
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
mockFetch.mockResolvedValue({ status: 404, ok: false });
|
|
687
|
+
|
|
688
|
+
// User rapidly clicks retry multiple times
|
|
689
|
+
await authCtr.requestAuthorization(config);
|
|
690
|
+
await authCtr.requestAuthorization(config);
|
|
691
|
+
await authCtr.requestAuthorization(config);
|
|
692
|
+
|
|
693
|
+
// Wait for some polling to happen
|
|
694
|
+
await new Promise((resolve) => setTimeout(resolve, 9000));
|
|
695
|
+
|
|
696
|
+
// Count handoff requests
|
|
697
|
+
const handoffCalls = mockFetch.mock.calls.filter((call) =>
|
|
698
|
+
(call[0] as string).includes('/oidc/handoff'),
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
// Should have ~3 calls (one per 3-second interval), not ~9 (3 intervals running)
|
|
702
|
+
// Allow some tolerance for timing
|
|
703
|
+
expect(handoffCalls.length).toBeLessThanOrEqual(5);
|
|
704
|
+
}, 10000);
|
|
705
|
+
});
|
|
706
|
+
});
|