@unifiedmemory/cli 1.3.12 → 1.3.14
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/.env.example +18 -0
- package/README.md +28 -0
- package/commands/debug.js +67 -0
- package/commands/init.js +17 -8
- package/commands/login.js +138 -101
- package/commands/org.js +25 -177
- package/commands/record.js +14 -11
- package/commands/usage.js +163 -0
- package/index.js +37 -6
- package/lib/clerk-api.js +9 -12
- package/lib/config.js +21 -1
- package/lib/mcp-proxy.js +9 -10
- package/lib/mcp-server.js +139 -34
- package/lib/token-refresh.js +10 -24
- package/lib/token-storage.js +33 -27
- package/lib/token-validation.js +2 -85
- package/package.json +1 -1
- package/tests/unit/mcp-proxy.test.js +9 -6
- package/tests/unit/token-refresh.test.js +18 -44
- package/tests/unit/token-storage.test.js +1 -2
- package/tests/unit/token-validation.test.js +24 -241
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unit tests for lib/token-validation.js
|
|
3
3
|
*
|
|
4
|
-
* Tests token loading
|
|
4
|
+
* Tests token loading and expiration checking.
|
|
5
|
+
* The implementation is simple: load token, refresh if expired.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
8
9
|
|
|
9
|
-
// Mock dependencies
|
|
10
|
+
// Mock dependencies
|
|
10
11
|
vi.mock('../../lib/token-storage.js', () => ({
|
|
11
12
|
getToken: vi.fn(),
|
|
12
|
-
saveToken: vi.fn(),
|
|
13
13
|
}));
|
|
14
14
|
|
|
15
15
|
vi.mock('../../lib/token-refresh.js', () => ({
|
|
@@ -17,28 +17,10 @@ vi.mock('../../lib/token-refresh.js', () => ({
|
|
|
17
17
|
refreshAccessToken: vi.fn(),
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
|
-
vi.mock('../../lib/clerk-api.js', () => ({
|
|
21
|
-
getOrgScopedToken: vi.fn(),
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
vi.mock('../../lib/jwt-utils.js', () => ({
|
|
25
|
-
parseJWT: vi.fn(),
|
|
26
|
-
}));
|
|
27
|
-
|
|
28
|
-
vi.mock('../../lib/config.js', () => ({
|
|
29
|
-
config: {
|
|
30
|
-
clerkDomain: 'clerk.test.com',
|
|
31
|
-
clerkClientId: 'test_client_id',
|
|
32
|
-
clerkClientSecret: 'test_client_secret',
|
|
33
|
-
},
|
|
34
|
-
}));
|
|
35
|
-
|
|
36
20
|
// Import after mocking
|
|
37
21
|
import { loadAndRefreshToken } from '../../lib/token-validation.js';
|
|
38
|
-
import { getToken
|
|
22
|
+
import { getToken } from '../../lib/token-storage.js';
|
|
39
23
|
import { isTokenExpired, refreshAccessToken } from '../../lib/token-refresh.js';
|
|
40
|
-
import { getOrgScopedToken } from '../../lib/clerk-api.js';
|
|
41
|
-
import { parseJWT } from '../../lib/jwt-utils.js';
|
|
42
24
|
|
|
43
25
|
describe('loadAndRefreshToken', () => {
|
|
44
26
|
const futureExp = Math.floor(Date.now() / 1000) + 3600;
|
|
@@ -130,238 +112,39 @@ describe('loadAndRefreshToken', () => {
|
|
|
130
112
|
});
|
|
131
113
|
});
|
|
132
114
|
|
|
133
|
-
describe('
|
|
134
|
-
it('
|
|
135
|
-
const
|
|
136
|
-
idToken: '
|
|
137
|
-
decoded: { exp: futureExp,
|
|
138
|
-
selectedOrg: { id: 'org_456', name: 'Test Org' },
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
vi.mocked(getToken).mockReturnValue(tokenMissingOrgClaims);
|
|
142
|
-
vi.mocked(getOrgScopedToken).mockResolvedValue({
|
|
143
|
-
jwt: 'org_scoped_jwt',
|
|
144
|
-
});
|
|
145
|
-
vi.mocked(parseJWT).mockReturnValue({
|
|
146
|
-
exp: futureExp,
|
|
147
|
-
o: { o_id: 'org_456' },
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
await loadAndRefreshToken();
|
|
151
|
-
|
|
152
|
-
expect(getOrgScopedToken).toHaveBeenCalledWith(
|
|
153
|
-
'sess_123',
|
|
154
|
-
'org_456',
|
|
155
|
-
'token_no_org'
|
|
156
|
-
);
|
|
157
|
-
expect(saveToken).toHaveBeenCalled();
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('recovers using originalSid when decoded.sid is missing', async () => {
|
|
161
|
-
const tokenWithOriginalSid = {
|
|
162
|
-
idToken: 'token_org_scoped',
|
|
163
|
-
decoded: { exp: futureExp }, // No sid - org-scoped token
|
|
164
|
-
selectedOrg: { id: 'org_456', name: 'Test Org' },
|
|
165
|
-
originalSid: 'sess_original',
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
vi.mocked(getToken).mockReturnValue(tokenWithOriginalSid);
|
|
169
|
-
vi.mocked(getOrgScopedToken).mockResolvedValue({
|
|
170
|
-
jwt: 'new_org_scoped_jwt',
|
|
171
|
-
});
|
|
172
|
-
vi.mocked(parseJWT).mockReturnValue({
|
|
173
|
-
exp: futureExp,
|
|
174
|
-
o: { o_id: 'org_456' },
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
await loadAndRefreshToken();
|
|
178
|
-
|
|
179
|
-
expect(getOrgScopedToken).toHaveBeenCalledWith(
|
|
180
|
-
'sess_original',
|
|
181
|
-
'org_456',
|
|
182
|
-
'token_org_scoped'
|
|
183
|
-
);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it('skips recovery when token already has org claims', async () => {
|
|
187
|
-
const tokenWithOrgClaims = {
|
|
188
|
-
idToken: 'complete_token',
|
|
189
|
-
decoded: { exp: futureExp, o: { o_id: 'org_456' } },
|
|
190
|
-
selectedOrg: { id: 'org_456', name: 'Test Org' },
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
vi.mocked(getToken).mockReturnValue(tokenWithOrgClaims);
|
|
194
|
-
|
|
195
|
-
const result = await loadAndRefreshToken();
|
|
196
|
-
|
|
197
|
-
expect(getOrgScopedToken).not.toHaveBeenCalled();
|
|
198
|
-
expect(result.decoded.o).toBeDefined();
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('skips recovery when no selectedOrg (personal account)', async () => {
|
|
202
|
-
const personalToken = {
|
|
203
|
-
idToken: 'personal_token',
|
|
204
|
-
decoded: { exp: futureExp, sid: 'sess_123' },
|
|
205
|
-
// No selectedOrg - personal account
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
vi.mocked(getToken).mockReturnValue(personalToken);
|
|
209
|
-
|
|
210
|
-
await loadAndRefreshToken();
|
|
211
|
-
|
|
212
|
-
expect(getOrgScopedToken).not.toHaveBeenCalled();
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('skips recovery when selectedOrg has no id', async () => {
|
|
216
|
-
const tokenWithEmptyOrg = {
|
|
217
|
-
idToken: 'token_empty_org',
|
|
218
|
-
decoded: { exp: futureExp, sid: 'sess_123' },
|
|
219
|
-
selectedOrg: {}, // Empty org object
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
vi.mocked(getToken).mockReturnValue(tokenWithEmptyOrg);
|
|
223
|
-
|
|
224
|
-
await loadAndRefreshToken();
|
|
225
|
-
|
|
226
|
-
expect(getOrgScopedToken).not.toHaveBeenCalled();
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it('continues gracefully when recovery fails', async () => {
|
|
230
|
-
const brokenToken = {
|
|
231
|
-
idToken: 'broken_token',
|
|
232
|
-
decoded: { exp: futureExp, sid: 'sess_123' },
|
|
233
|
-
selectedOrg: { id: 'org_456', name: 'Test Org' },
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
vi.mocked(getToken).mockReturnValue(brokenToken);
|
|
237
|
-
vi.mocked(getOrgScopedToken).mockRejectedValue(new Error('API error'));
|
|
238
|
-
|
|
239
|
-
// Should not throw, just log warning and return token
|
|
240
|
-
const result = await loadAndRefreshToken();
|
|
241
|
-
|
|
242
|
-
expect(result).toBeDefined();
|
|
243
|
-
expect(result.idToken).toBe('broken_token');
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it('saves updated token after successful recovery', async () => {
|
|
247
|
-
const tokenMissingOrgClaims = {
|
|
248
|
-
idToken: 'token_no_org',
|
|
249
|
-
decoded: { exp: futureExp, sid: 'sess_123' },
|
|
250
|
-
selectedOrg: { id: 'org_456', name: 'Test Org' },
|
|
251
|
-
refresh_token: 'rt_123',
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
vi.mocked(getToken).mockReturnValue(tokenMissingOrgClaims);
|
|
255
|
-
vi.mocked(getOrgScopedToken).mockResolvedValue({
|
|
256
|
-
jwt: 'org_scoped_jwt',
|
|
257
|
-
});
|
|
258
|
-
vi.mocked(parseJWT).mockReturnValue({
|
|
259
|
-
exp: futureExp,
|
|
260
|
-
o: { o_id: 'org_456' },
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
await loadAndRefreshToken();
|
|
264
|
-
|
|
265
|
-
expect(saveToken).toHaveBeenCalledWith(
|
|
266
|
-
expect.objectContaining({
|
|
267
|
-
idToken: 'org_scoped_jwt',
|
|
268
|
-
})
|
|
269
|
-
);
|
|
270
|
-
});
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
describe('OAuth refresh fallback for missing sid', () => {
|
|
274
|
-
const originalFetch = global.fetch;
|
|
275
|
-
|
|
276
|
-
afterEach(() => {
|
|
277
|
-
global.fetch = originalFetch;
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it('attempts OAuth refresh when no sid/originalSid but has refresh_token', async () => {
|
|
281
|
-
const tokenNoSid = {
|
|
282
|
-
idToken: 'token_no_sid',
|
|
283
|
-
decoded: { exp: futureExp }, // No sid
|
|
284
|
-
selectedOrg: { id: 'org_456', name: 'Test Org' },
|
|
115
|
+
describe('valid token handling', () => {
|
|
116
|
+
it('returns token data when not expired', async () => {
|
|
117
|
+
const validToken = {
|
|
118
|
+
idToken: 'valid_token',
|
|
119
|
+
decoded: { exp: futureExp, sub: 'user_123' },
|
|
285
120
|
refresh_token: 'rt_123',
|
|
286
|
-
// No originalSid either
|
|
287
121
|
};
|
|
288
122
|
|
|
289
|
-
vi.mocked(getToken).mockReturnValue(
|
|
290
|
-
|
|
291
|
-
// Mock fetch for OAuth refresh
|
|
292
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
293
|
-
ok: true,
|
|
294
|
-
json: () => Promise.resolve({
|
|
295
|
-
access_token: 'new_at',
|
|
296
|
-
id_token: 'new_idt_with_sid',
|
|
297
|
-
refresh_token: 'new_rt',
|
|
298
|
-
}),
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
// parseJWT returns token with sid after refresh
|
|
302
|
-
vi.mocked(parseJWT)
|
|
303
|
-
.mockReturnValueOnce({ exp: futureExp, sid: 'sess_new' }) // First call: parse refreshed token
|
|
304
|
-
.mockReturnValueOnce({ exp: futureExp, o: { o_id: 'org_456' } }); // Second call: parse org-scoped token
|
|
305
|
-
|
|
306
|
-
vi.mocked(getOrgScopedToken).mockResolvedValue({
|
|
307
|
-
jwt: 'org_scoped_jwt_after_refresh',
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
await loadAndRefreshToken();
|
|
311
|
-
|
|
312
|
-
expect(global.fetch).toHaveBeenCalledWith(
|
|
313
|
-
expect.stringContaining('/oauth/token'),
|
|
314
|
-
expect.any(Object)
|
|
315
|
-
);
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
it('continues without org token when OAuth refresh fails', async () => {
|
|
319
|
-
const tokenNoSid = {
|
|
320
|
-
idToken: 'token_no_sid',
|
|
321
|
-
decoded: { exp: futureExp },
|
|
322
|
-
selectedOrg: { id: 'org_456', name: 'Test Org' },
|
|
323
|
-
refresh_token: 'rt_123',
|
|
324
|
-
};
|
|
123
|
+
vi.mocked(getToken).mockReturnValue(validToken);
|
|
124
|
+
vi.mocked(isTokenExpired).mockReturnValue(false);
|
|
325
125
|
|
|
326
|
-
vi.mocked(getToken).mockReturnValue(tokenNoSid);
|
|
327
|
-
|
|
328
|
-
// Mock fetch to fail
|
|
329
|
-
global.fetch = vi.fn().mockResolvedValue({
|
|
330
|
-
ok: false,
|
|
331
|
-
status: 400,
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
// Should not throw, just return token as-is
|
|
335
126
|
const result = await loadAndRefreshToken();
|
|
336
127
|
|
|
337
|
-
expect(
|
|
338
|
-
expect(result
|
|
128
|
+
expect(refreshAccessToken).not.toHaveBeenCalled();
|
|
129
|
+
expect(result).toEqual(validToken);
|
|
339
130
|
});
|
|
340
131
|
|
|
341
|
-
it('
|
|
342
|
-
const
|
|
343
|
-
idToken: '
|
|
344
|
-
decoded: { exp: futureExp },
|
|
345
|
-
|
|
346
|
-
|
|
132
|
+
it('returns token with org context when present', async () => {
|
|
133
|
+
const orgToken = {
|
|
134
|
+
idToken: 'org_token',
|
|
135
|
+
decoded: { exp: futureExp, sub: 'user_123', org_id: 'org_456' },
|
|
136
|
+
selectedOrgId: 'org_456',
|
|
137
|
+
selectedOrgName: 'Test Org',
|
|
138
|
+
refresh_token: 'rt_123',
|
|
347
139
|
};
|
|
348
140
|
|
|
349
|
-
vi.mocked(getToken).mockReturnValue(
|
|
350
|
-
|
|
351
|
-
// Spy on console.error
|
|
352
|
-
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
141
|
+
vi.mocked(getToken).mockReturnValue(orgToken);
|
|
142
|
+
vi.mocked(isTokenExpired).mockReturnValue(false);
|
|
353
143
|
|
|
354
144
|
const result = await loadAndRefreshToken();
|
|
355
145
|
|
|
356
|
-
|
|
357
|
-
expect(result).
|
|
358
|
-
|
|
359
|
-
// Should have logged warning about missing session ID
|
|
360
|
-
expect(consoleSpy).toHaveBeenCalledWith(
|
|
361
|
-
expect.stringContaining('Could not obtain session ID')
|
|
362
|
-
);
|
|
363
|
-
|
|
364
|
-
consoleSpy.mockRestore();
|
|
146
|
+
expect(result.selectedOrgId).toBe('org_456');
|
|
147
|
+
expect(result.selectedOrgName).toBe('Test Org');
|
|
365
148
|
});
|
|
366
149
|
});
|
|
367
150
|
});
|