@lobehub/lobehub 2.0.0-next.141 → 2.0.0-next.142
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/CHANGELOG.md +25 -0
- package/Dockerfile +2 -0
- package/apps/desktop/src/main/controllers/__tests__/McpInstallCtr.test.ts +286 -0
- package/apps/desktop/src/main/controllers/__tests__/NotificationCtr.test.ts +347 -0
- package/apps/desktop/src/main/controllers/__tests__/RemoteServerConfigCtr.test.ts +645 -0
- package/apps/desktop/src/main/controllers/__tests__/RemoteServerSyncCtr.test.ts +372 -0
- package/apps/desktop/src/main/controllers/__tests__/SystemCtr.test.ts +276 -0
- package/apps/desktop/src/main/controllers/__tests__/UploadFileCtr.test.ts +171 -0
- package/apps/desktop/src/main/core/browser/__tests__/Browser.test.ts +573 -0
- package/apps/desktop/src/main/core/browser/__tests__/BrowserManager.test.ts +415 -0
- package/apps/desktop/src/main/core/infrastructure/__tests__/I18nManager.test.ts +353 -0
- package/apps/desktop/src/main/core/infrastructure/__tests__/IoCContainer.test.ts +156 -0
- package/apps/desktop/src/main/core/infrastructure/__tests__/ProtocolManager.test.ts +348 -0
- package/apps/desktop/src/main/core/infrastructure/__tests__/StaticFileServerManager.test.ts +481 -0
- package/apps/desktop/src/main/core/infrastructure/__tests__/StoreManager.test.ts +164 -0
- package/apps/desktop/src/main/core/infrastructure/__tests__/UpdaterManager.test.ts +513 -0
- package/changelog/v1.json +9 -0
- package/docs/self-hosting/environment-variables/model-provider.mdx +31 -0
- package/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx +30 -0
- package/package.json +1 -1
- package/packages/model-runtime/src/core/openaiCompatibleFactory/index.ts +6 -3
- package/src/config/modelProviders/vertexai.ts +1 -1
- package/src/envs/llm.ts +4 -0
- package/src/server/modules/ModelRuntime/index.ts +4 -4
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import { DataSyncConfig } from '@lobechat/electron-client-ipc';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import type { App } from '@/core/App';
|
|
5
|
+
|
|
6
|
+
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
|
|
7
|
+
|
|
8
|
+
// Mock logger
|
|
9
|
+
vi.mock('@/utils/logger', () => ({
|
|
10
|
+
createLogger: () => ({
|
|
11
|
+
debug: vi.fn(),
|
|
12
|
+
error: vi.fn(),
|
|
13
|
+
info: vi.fn(),
|
|
14
|
+
warn: vi.fn(),
|
|
15
|
+
}),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Mock electron
|
|
19
|
+
vi.mock('electron', () => ({
|
|
20
|
+
safeStorage: {
|
|
21
|
+
decryptString: vi.fn((buffer: Buffer) => buffer.toString()),
|
|
22
|
+
encryptString: vi.fn((str: string) => Buffer.from(str)),
|
|
23
|
+
isEncryptionAvailable: vi.fn(() => true),
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// Mock @/const/env
|
|
28
|
+
vi.mock('@/const/env', () => ({
|
|
29
|
+
OFFICIAL_CLOUD_SERVER: 'https://cloud.lobehub.com',
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Mock storeManager
|
|
33
|
+
const mockStoreManager = {
|
|
34
|
+
delete: vi.fn(),
|
|
35
|
+
get: vi.fn(),
|
|
36
|
+
set: vi.fn(),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const mockApp = {
|
|
40
|
+
storeManager: mockStoreManager,
|
|
41
|
+
} as unknown as App;
|
|
42
|
+
|
|
43
|
+
describe('RemoteServerConfigCtr', () => {
|
|
44
|
+
let controller: RemoteServerConfigCtr;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
mockStoreManager.get.mockReturnValue({
|
|
49
|
+
active: false,
|
|
50
|
+
storageMode: 'local',
|
|
51
|
+
});
|
|
52
|
+
controller = new RemoteServerConfigCtr(mockApp);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('getRemoteServerConfig', () => {
|
|
56
|
+
it('should return stored configuration', async () => {
|
|
57
|
+
const config: DataSyncConfig = {
|
|
58
|
+
active: true,
|
|
59
|
+
remoteServerUrl: 'https://my-server.com',
|
|
60
|
+
storageMode: 'selfHost',
|
|
61
|
+
};
|
|
62
|
+
mockStoreManager.get.mockReturnValue(config);
|
|
63
|
+
|
|
64
|
+
const result = await controller.getRemoteServerConfig();
|
|
65
|
+
|
|
66
|
+
expect(result).toEqual(config);
|
|
67
|
+
expect(mockStoreManager.get).toHaveBeenCalledWith('dataSyncConfig');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('setRemoteServerConfig', () => {
|
|
72
|
+
it('should update configuration', async () => {
|
|
73
|
+
const prevConfig: DataSyncConfig = {
|
|
74
|
+
active: false,
|
|
75
|
+
storageMode: 'local',
|
|
76
|
+
};
|
|
77
|
+
mockStoreManager.get.mockReturnValue(prevConfig);
|
|
78
|
+
|
|
79
|
+
const newConfig: Partial<DataSyncConfig> = {
|
|
80
|
+
active: true,
|
|
81
|
+
remoteServerUrl: 'https://my-server.com',
|
|
82
|
+
storageMode: 'selfHost',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const result = await controller.setRemoteServerConfig(newConfig);
|
|
86
|
+
|
|
87
|
+
expect(result).toBe(true);
|
|
88
|
+
expect(mockStoreManager.set).toHaveBeenCalledWith('dataSyncConfig', {
|
|
89
|
+
...prevConfig,
|
|
90
|
+
...newConfig,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('clearRemoteServerConfig', () => {
|
|
96
|
+
it('should clear configuration and tokens', async () => {
|
|
97
|
+
const result = await controller.clearRemoteServerConfig();
|
|
98
|
+
|
|
99
|
+
expect(result).toBe(true);
|
|
100
|
+
expect(mockStoreManager.set).toHaveBeenCalledWith('dataSyncConfig', { storageMode: 'local' });
|
|
101
|
+
expect(mockStoreManager.delete).toHaveBeenCalledWith('encryptedTokens');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('saveTokens', () => {
|
|
106
|
+
it('should save encrypted tokens with expiration', async () => {
|
|
107
|
+
const { safeStorage } = await import('electron');
|
|
108
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
109
|
+
|
|
110
|
+
await controller.saveTokens('access-token', 'refresh-token', 3600);
|
|
111
|
+
|
|
112
|
+
expect(safeStorage.encryptString).toHaveBeenCalledWith('access-token');
|
|
113
|
+
expect(safeStorage.encryptString).toHaveBeenCalledWith('refresh-token');
|
|
114
|
+
expect(mockStoreManager.set).toHaveBeenCalledWith(
|
|
115
|
+
'encryptedTokens',
|
|
116
|
+
expect.objectContaining({
|
|
117
|
+
accessToken: expect.any(String),
|
|
118
|
+
expiresAt: expect.any(Number),
|
|
119
|
+
refreshToken: expect.any(String),
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should save tokens without expiration', async () => {
|
|
125
|
+
const { safeStorage } = await import('electron');
|
|
126
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
127
|
+
|
|
128
|
+
await controller.saveTokens('access-token', 'refresh-token');
|
|
129
|
+
|
|
130
|
+
expect(mockStoreManager.set).toHaveBeenCalledWith(
|
|
131
|
+
'encryptedTokens',
|
|
132
|
+
expect.objectContaining({
|
|
133
|
+
accessToken: expect.any(String),
|
|
134
|
+
expiresAt: undefined,
|
|
135
|
+
refreshToken: expect.any(String),
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should save unencrypted tokens when encryption is not available', async () => {
|
|
141
|
+
const { safeStorage } = await import('electron');
|
|
142
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(false);
|
|
143
|
+
|
|
144
|
+
await controller.saveTokens('access-token', 'refresh-token', 3600);
|
|
145
|
+
|
|
146
|
+
expect(safeStorage.encryptString).not.toHaveBeenCalled();
|
|
147
|
+
expect(mockStoreManager.set).toHaveBeenCalledWith(
|
|
148
|
+
'encryptedTokens',
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
accessToken: 'access-token',
|
|
151
|
+
refreshToken: 'refresh-token',
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('getAccessToken', () => {
|
|
158
|
+
it('should return decrypted access token', async () => {
|
|
159
|
+
const { safeStorage } = await import('electron');
|
|
160
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
161
|
+
|
|
162
|
+
// First save a token
|
|
163
|
+
await controller.saveTokens('test-access-token', 'test-refresh-token');
|
|
164
|
+
|
|
165
|
+
const result = await controller.getAccessToken();
|
|
166
|
+
|
|
167
|
+
expect(result).toBe('test-access-token');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should load token from store if not in memory', async () => {
|
|
171
|
+
const { safeStorage } = await import('electron');
|
|
172
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
173
|
+
vi.mocked(safeStorage.decryptString).mockReturnValue('stored-access-token');
|
|
174
|
+
|
|
175
|
+
mockStoreManager.get.mockImplementation((key) => {
|
|
176
|
+
if (key === 'encryptedTokens') {
|
|
177
|
+
return {
|
|
178
|
+
accessToken: Buffer.from('stored-access-token').toString('base64'),
|
|
179
|
+
refreshToken: Buffer.from('stored-refresh-token').toString('base64'),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return { active: false, storageMode: 'local' };
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Create new controller to test loading from store
|
|
186
|
+
const newController = new RemoteServerConfigCtr(mockApp);
|
|
187
|
+
const result = await newController.getAccessToken();
|
|
188
|
+
|
|
189
|
+
expect(result).toBe('stored-access-token');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should return null when no token exists', async () => {
|
|
193
|
+
mockStoreManager.get.mockImplementation((key) => {
|
|
194
|
+
if (key === 'encryptedTokens') {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
return { active: false, storageMode: 'local' };
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const newController = new RemoteServerConfigCtr(mockApp);
|
|
201
|
+
const result = await newController.getAccessToken();
|
|
202
|
+
|
|
203
|
+
expect(result).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should return raw token when encryption is not available', async () => {
|
|
207
|
+
const { safeStorage } = await import('electron');
|
|
208
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(false);
|
|
209
|
+
|
|
210
|
+
await controller.saveTokens('raw-access-token', 'raw-refresh-token');
|
|
211
|
+
const result = await controller.getAccessToken();
|
|
212
|
+
|
|
213
|
+
expect(result).toBe('raw-access-token');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should return null on decryption error', async () => {
|
|
217
|
+
const { safeStorage } = await import('electron');
|
|
218
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
219
|
+
vi.mocked(safeStorage.decryptString).mockImplementation(() => {
|
|
220
|
+
throw new Error('Decryption failed');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
mockStoreManager.get.mockImplementation((key) => {
|
|
224
|
+
if (key === 'encryptedTokens') {
|
|
225
|
+
return {
|
|
226
|
+
accessToken: 'invalid-encrypted-token',
|
|
227
|
+
refreshToken: 'invalid-encrypted-token',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return { active: false, storageMode: 'local' };
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const newController = new RemoteServerConfigCtr(mockApp);
|
|
234
|
+
const result = await newController.getAccessToken();
|
|
235
|
+
|
|
236
|
+
expect(result).toBeNull();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('getRefreshToken', () => {
|
|
241
|
+
it('should return decrypted refresh token', async () => {
|
|
242
|
+
const { safeStorage } = await import('electron');
|
|
243
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
244
|
+
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
|
|
245
|
+
buffer.toString(),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
await controller.saveTokens('test-access-token', 'test-refresh-token');
|
|
249
|
+
|
|
250
|
+
const result = await controller.getRefreshToken();
|
|
251
|
+
|
|
252
|
+
expect(result).toBe('test-refresh-token');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should return null when no token exists', async () => {
|
|
256
|
+
mockStoreManager.get.mockImplementation((key) => {
|
|
257
|
+
if (key === 'encryptedTokens') {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return { active: false, storageMode: 'local' };
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const newController = new RemoteServerConfigCtr(mockApp);
|
|
264
|
+
const result = await newController.getRefreshToken();
|
|
265
|
+
|
|
266
|
+
expect(result).toBeNull();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe('clearTokens', () => {
|
|
271
|
+
it('should clear all tokens from memory and store', async () => {
|
|
272
|
+
await controller.saveTokens('access', 'refresh', 3600);
|
|
273
|
+
await controller.clearTokens();
|
|
274
|
+
|
|
275
|
+
expect(mockStoreManager.delete).toHaveBeenCalledWith('encryptedTokens');
|
|
276
|
+
|
|
277
|
+
// Verify tokens are cleared from memory
|
|
278
|
+
const accessToken = await controller.getAccessToken();
|
|
279
|
+
expect(accessToken).toBeNull();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('getTokenExpiresAt', () => {
|
|
284
|
+
it('should return expiration time after saving tokens with expiration', async () => {
|
|
285
|
+
const { safeStorage } = await import('electron');
|
|
286
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
287
|
+
|
|
288
|
+
const beforeSave = Date.now();
|
|
289
|
+
await controller.saveTokens('access', 'refresh', 3600);
|
|
290
|
+
const afterSave = Date.now();
|
|
291
|
+
|
|
292
|
+
const expiresAt = controller.getTokenExpiresAt();
|
|
293
|
+
|
|
294
|
+
expect(expiresAt).toBeDefined();
|
|
295
|
+
expect(expiresAt).toBeGreaterThanOrEqual(beforeSave + 3600 * 1000);
|
|
296
|
+
expect(expiresAt).toBeLessThanOrEqual(afterSave + 3600 * 1000);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should return undefined when no expiration is set', async () => {
|
|
300
|
+
const { safeStorage } = await import('electron');
|
|
301
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
302
|
+
|
|
303
|
+
await controller.saveTokens('access', 'refresh');
|
|
304
|
+
|
|
305
|
+
const expiresAt = controller.getTokenExpiresAt();
|
|
306
|
+
|
|
307
|
+
expect(expiresAt).toBeUndefined();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('isTokenExpiringSoon', () => {
|
|
312
|
+
it('should return false when no expiration is set', () => {
|
|
313
|
+
const result = controller.isTokenExpiringSoon();
|
|
314
|
+
|
|
315
|
+
expect(result).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should return false when token is not expiring soon', async () => {
|
|
319
|
+
const { safeStorage } = await import('electron');
|
|
320
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
321
|
+
|
|
322
|
+
// Token expires in 1 hour
|
|
323
|
+
await controller.saveTokens('access', 'refresh', 3600);
|
|
324
|
+
|
|
325
|
+
// Default buffer is 5 minutes
|
|
326
|
+
const result = controller.isTokenExpiringSoon();
|
|
327
|
+
|
|
328
|
+
expect(result).toBe(false);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should return true when token is within buffer time', async () => {
|
|
332
|
+
const { safeStorage } = await import('electron');
|
|
333
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
334
|
+
|
|
335
|
+
// Token expires in 2 minutes
|
|
336
|
+
await controller.saveTokens('access', 'refresh', 120);
|
|
337
|
+
|
|
338
|
+
// Default buffer is 5 minutes, so token is expiring soon
|
|
339
|
+
const result = controller.isTokenExpiringSoon();
|
|
340
|
+
|
|
341
|
+
expect(result).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('should respect custom buffer time', async () => {
|
|
345
|
+
const { safeStorage } = await import('electron');
|
|
346
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
347
|
+
|
|
348
|
+
// Token expires in 10 minutes
|
|
349
|
+
await controller.saveTokens('access', 'refresh', 600);
|
|
350
|
+
|
|
351
|
+
// With 15 minute buffer, should be expiring soon
|
|
352
|
+
const result = controller.isTokenExpiringSoon(15 * 60 * 1000);
|
|
353
|
+
|
|
354
|
+
expect(result).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe('refreshAccessToken', () => {
|
|
359
|
+
let mockFetch: ReturnType<typeof vi.fn>;
|
|
360
|
+
|
|
361
|
+
beforeEach(() => {
|
|
362
|
+
mockFetch = vi.fn();
|
|
363
|
+
global.fetch = mockFetch;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should return error when remote server is not active', async () => {
|
|
367
|
+
mockStoreManager.get.mockImplementation((key) => {
|
|
368
|
+
if (key === 'dataSyncConfig') {
|
|
369
|
+
return { active: false, storageMode: 'local' };
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const result = await controller.refreshAccessToken();
|
|
375
|
+
|
|
376
|
+
expect(result.success).toBe(false);
|
|
377
|
+
expect(result.error).toContain('not active');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should return error when no refresh token available', async () => {
|
|
381
|
+
mockStoreManager.get.mockImplementation((key) => {
|
|
382
|
+
if (key === 'dataSyncConfig') {
|
|
383
|
+
return {
|
|
384
|
+
active: true,
|
|
385
|
+
remoteServerUrl: 'https://server.com',
|
|
386
|
+
storageMode: 'selfHost',
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
if (key === 'encryptedTokens') {
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const newController = new RemoteServerConfigCtr(mockApp);
|
|
396
|
+
const result = await newController.refreshAccessToken();
|
|
397
|
+
|
|
398
|
+
expect(result.success).toBe(false);
|
|
399
|
+
expect(result.error).toContain('No refresh token');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should refresh token successfully', async () => {
|
|
403
|
+
const { safeStorage } = await import('electron');
|
|
404
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
405
|
+
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
|
|
406
|
+
buffer.toString(),
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
mockStoreManager.get.mockImplementation((key) => {
|
|
410
|
+
if (key === 'dataSyncConfig') {
|
|
411
|
+
return {
|
|
412
|
+
active: true,
|
|
413
|
+
remoteServerUrl: 'https://server.com',
|
|
414
|
+
storageMode: 'selfHost',
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
return null;
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Save initial tokens
|
|
421
|
+
await controller.saveTokens('old-access', 'old-refresh');
|
|
422
|
+
|
|
423
|
+
mockFetch.mockResolvedValue({
|
|
424
|
+
json: () =>
|
|
425
|
+
Promise.resolve({
|
|
426
|
+
access_token: 'new-access-token',
|
|
427
|
+
expires_in: 3600,
|
|
428
|
+
refresh_token: 'new-refresh-token',
|
|
429
|
+
}),
|
|
430
|
+
ok: true,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const result = await controller.refreshAccessToken();
|
|
434
|
+
|
|
435
|
+
expect(result.success).toBe(true);
|
|
436
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
437
|
+
'https://server.com/oidc/token',
|
|
438
|
+
expect.objectContaining({
|
|
439
|
+
body: expect.stringContaining('grant_type=refresh_token'),
|
|
440
|
+
method: 'POST',
|
|
441
|
+
}),
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('should handle refresh failure', async () => {
|
|
446
|
+
const { safeStorage } = await import('electron');
|
|
447
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
448
|
+
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
|
|
449
|
+
buffer.toString(),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
mockStoreManager.get.mockImplementation((key) => {
|
|
453
|
+
if (key === 'dataSyncConfig') {
|
|
454
|
+
return {
|
|
455
|
+
active: true,
|
|
456
|
+
remoteServerUrl: 'https://server.com',
|
|
457
|
+
storageMode: 'selfHost',
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
return null;
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
await controller.saveTokens('old-access', 'old-refresh');
|
|
464
|
+
|
|
465
|
+
mockFetch.mockResolvedValue({
|
|
466
|
+
json: () => Promise.resolve({ error: 'invalid_grant' }),
|
|
467
|
+
ok: false,
|
|
468
|
+
status: 400,
|
|
469
|
+
statusText: 'Bad Request',
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const result = await controller.refreshAccessToken();
|
|
473
|
+
|
|
474
|
+
expect(result.success).toBe(false);
|
|
475
|
+
expect(result.error).toContain('Token refresh failed');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('should handle missing tokens in response', async () => {
|
|
479
|
+
const { safeStorage } = await import('electron');
|
|
480
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
481
|
+
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
|
|
482
|
+
buffer.toString(),
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
mockStoreManager.get.mockImplementation((key) => {
|
|
486
|
+
if (key === 'dataSyncConfig') {
|
|
487
|
+
return {
|
|
488
|
+
active: true,
|
|
489
|
+
remoteServerUrl: 'https://server.com',
|
|
490
|
+
storageMode: 'selfHost',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
await controller.saveTokens('old-access', 'old-refresh');
|
|
497
|
+
|
|
498
|
+
mockFetch.mockResolvedValue({
|
|
499
|
+
json: () => Promise.resolve({}), // Missing tokens
|
|
500
|
+
ok: true,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const result = await controller.refreshAccessToken();
|
|
504
|
+
|
|
505
|
+
expect(result.success).toBe(false);
|
|
506
|
+
expect(result.error).toContain('Missing tokens');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('should handle concurrent refresh requests by returning same result', async () => {
|
|
510
|
+
const { safeStorage } = await import('electron');
|
|
511
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
512
|
+
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
|
|
513
|
+
buffer.toString(),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
mockStoreManager.get.mockImplementation((key) => {
|
|
517
|
+
if (key === 'dataSyncConfig') {
|
|
518
|
+
return {
|
|
519
|
+
active: true,
|
|
520
|
+
remoteServerUrl: 'https://server.com',
|
|
521
|
+
storageMode: 'selfHost',
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
return null;
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
await controller.saveTokens('old-access', 'old-refresh');
|
|
528
|
+
|
|
529
|
+
let resolvePromise: (value: any) => void;
|
|
530
|
+
const delayedResponse = new Promise((resolve) => {
|
|
531
|
+
resolvePromise = resolve;
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
mockFetch.mockReturnValue(delayedResponse);
|
|
535
|
+
|
|
536
|
+
// Start two concurrent refresh requests
|
|
537
|
+
const promise1 = controller.refreshAccessToken();
|
|
538
|
+
const promise2 = controller.refreshAccessToken();
|
|
539
|
+
|
|
540
|
+
// Resolve the fetch
|
|
541
|
+
resolvePromise!({
|
|
542
|
+
json: () =>
|
|
543
|
+
Promise.resolve({
|
|
544
|
+
access_token: 'new-access',
|
|
545
|
+
expires_in: 3600,
|
|
546
|
+
refresh_token: 'new-refresh',
|
|
547
|
+
}),
|
|
548
|
+
ok: true,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const [result1, result2] = await Promise.all([promise1, promise2]);
|
|
552
|
+
|
|
553
|
+
// Both results should be equal (same success)
|
|
554
|
+
expect(result1.success).toBe(true);
|
|
555
|
+
expect(result2.success).toBe(true);
|
|
556
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('should handle network errors', async () => {
|
|
560
|
+
const { safeStorage } = await import('electron');
|
|
561
|
+
vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true);
|
|
562
|
+
vi.mocked(safeStorage.decryptString).mockImplementation((buffer: Buffer) =>
|
|
563
|
+
buffer.toString(),
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
mockStoreManager.get.mockImplementation((key) => {
|
|
567
|
+
if (key === 'dataSyncConfig') {
|
|
568
|
+
return {
|
|
569
|
+
active: true,
|
|
570
|
+
remoteServerUrl: 'https://server.com',
|
|
571
|
+
storageMode: 'selfHost',
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
return null;
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
await controller.saveTokens('old-access', 'old-refresh');
|
|
578
|
+
|
|
579
|
+
mockFetch.mockRejectedValue(new Error('Network error'));
|
|
580
|
+
|
|
581
|
+
const result = await controller.refreshAccessToken();
|
|
582
|
+
|
|
583
|
+
expect(result.success).toBe(false);
|
|
584
|
+
expect(result.error).toContain('Network error');
|
|
585
|
+
});
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
describe('afterAppReady', () => {
|
|
589
|
+
it('should load tokens from store', () => {
|
|
590
|
+
mockStoreManager.get.mockImplementation((key) => {
|
|
591
|
+
if (key === 'encryptedTokens') {
|
|
592
|
+
return {
|
|
593
|
+
accessToken: 'stored-access',
|
|
594
|
+
expiresAt: Date.now() + 3600000,
|
|
595
|
+
refreshToken: 'stored-refresh',
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
return { active: false, storageMode: 'local' };
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
const newController = new RemoteServerConfigCtr(mockApp);
|
|
602
|
+
newController.afterAppReady();
|
|
603
|
+
|
|
604
|
+
// Verify tokens were loaded by checking getTokenExpiresAt
|
|
605
|
+
expect(newController.getTokenExpiresAt()).toBeDefined();
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
describe('getRemoteServerUrl', () => {
|
|
610
|
+
it('should return official cloud server for cloud mode', async () => {
|
|
611
|
+
mockStoreManager.get.mockReturnValue({
|
|
612
|
+
active: true,
|
|
613
|
+
storageMode: 'cloud',
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const result = await controller.getRemoteServerUrl();
|
|
617
|
+
|
|
618
|
+
expect(result).toBe('https://cloud.lobehub.com');
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('should return custom URL for selfHost mode', async () => {
|
|
622
|
+
mockStoreManager.get.mockReturnValue({
|
|
623
|
+
active: true,
|
|
624
|
+
remoteServerUrl: 'https://my-server.com',
|
|
625
|
+
storageMode: 'selfHost',
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
const result = await controller.getRemoteServerUrl();
|
|
629
|
+
|
|
630
|
+
expect(result).toBe('https://my-server.com');
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('should use provided config instead of stored config', async () => {
|
|
634
|
+
const customConfig: DataSyncConfig = {
|
|
635
|
+
active: true,
|
|
636
|
+
remoteServerUrl: 'https://custom-server.com',
|
|
637
|
+
storageMode: 'selfHost',
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const result = await controller.getRemoteServerUrl(customConfig);
|
|
641
|
+
|
|
642
|
+
expect(result).toBe('https://custom-server.com');
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
});
|