claude-session-share 1.0.0

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.
@@ -0,0 +1,532 @@
1
+ /**
2
+ * End-to-end integration tests for session sharing workflow
3
+ *
4
+ * Verifies the complete round-trip functionality:
5
+ * 1. Upload session (sanitize → JSONL → gist)
6
+ * 2. Import session (fetch → parse → remap → write)
7
+ * 3. Verify imported session preserves conversation structure
8
+ * 4. Verify privacy guarantees (thinking stripped, paths sanitized, secrets redacted)
9
+ */
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
+ import { uploadSession } from '../services/session-uploader.js';
12
+ import { importSession } from '../services/session-importer.js';
13
+ import * as gistClient from '../gist/client.js';
14
+ import * as fs from 'node:fs/promises';
15
+ import * as path from 'node:path';
16
+ import { tmpdir } from 'node:os';
17
+ describe('End-to-End Session Sharing Workflow', () => {
18
+ let testDir;
19
+ let sessionPath;
20
+ let mockGistUrl;
21
+ let mockGistId;
22
+ beforeEach(async () => {
23
+ // Create temporary test directory
24
+ testDir = path.join(tmpdir(), `claude-test-${Date.now()}`);
25
+ await fs.mkdir(testDir, { recursive: true });
26
+ sessionPath = path.join(testDir, 'session.jsonl');
27
+ mockGistUrl = 'https://gist.github.com/user/abc123def456';
28
+ mockGistId = 'abc123def456';
29
+ // Set up environment variable
30
+ process.env.GITHUB_TOKEN = 'test_token';
31
+ // Reset mocks
32
+ vi.clearAllMocks();
33
+ });
34
+ afterEach(async () => {
35
+ // Clean up test directory
36
+ try {
37
+ await fs.rm(testDir, { recursive: true, force: true });
38
+ }
39
+ catch {
40
+ // Ignore cleanup errors
41
+ }
42
+ });
43
+ // Helper to mock GistClient for tests
44
+ function mockGistClient(createGistFn, fetchGistFn) {
45
+ vi.spyOn(gistClient, 'GistClient').mockImplementation(function () {
46
+ if (createGistFn) {
47
+ this.createGist = createGistFn;
48
+ }
49
+ if (fetchGistFn) {
50
+ this.fetchGist = fetchGistFn;
51
+ }
52
+ this.getOctokit = vi.fn();
53
+ return this;
54
+ });
55
+ }
56
+ describe('Round-trip workflow', () => {
57
+ it('should preserve conversation structure through share → import cycle', async () => {
58
+ // Step 1: Create test session with multiple message types
59
+ const testMessages = [
60
+ {
61
+ type: 'user',
62
+ uuid: 'user-1',
63
+ sessionId: 'original-session',
64
+ timestamp: '2026-01-12T10:00:00.000Z',
65
+ parentUuid: null,
66
+ message: { role: 'user', content: 'Read file.ts' },
67
+ cwd: '/Users/test/myproject',
68
+ version: '1.0.0',
69
+ },
70
+ {
71
+ type: 'assistant',
72
+ uuid: 'assistant-1',
73
+ sessionId: 'original-session',
74
+ timestamp: '2026-01-12T10:01:00.000Z',
75
+ parentUuid: 'user-1',
76
+ messageId: 'msg-1',
77
+ snapshot: {
78
+ thinking: 'This is internal thinking that should be stripped',
79
+ messages: [
80
+ { role: 'assistant', content: 'Reading file.ts...' },
81
+ {
82
+ role: 'tool_result',
83
+ content: `File: /Users/test/myproject/src/file.ts\nContent: export const API_KEY = "sk_test_abc123";\nconst TOKEN = "ghp_secrettoken123";`,
84
+ },
85
+ ],
86
+ },
87
+ },
88
+ {
89
+ type: 'assistant',
90
+ uuid: 'assistant-2',
91
+ sessionId: 'original-session',
92
+ timestamp: '2026-01-12T10:03:00.000Z',
93
+ parentUuid: 'assistant-1',
94
+ messageId: 'msg-2',
95
+ snapshot: {
96
+ thinking: 'More thinking to remove',
97
+ messages: [
98
+ {
99
+ role: 'assistant',
100
+ content: 'I found the file with API key sk_test_abc123 at path /Users/test/myproject/src/file.ts',
101
+ },
102
+ ],
103
+ },
104
+ },
105
+ ];
106
+ // Write test session to file
107
+ await fs.writeFile(sessionPath, testMessages.map((msg) => JSON.stringify(msg)).join('\n'));
108
+ // Step 2: Mock GistClient responses
109
+ let capturedSessionJsonl = '';
110
+ mockGistClient(vi.fn().mockImplementation(async (_desc, files) => {
111
+ // Capture the uploaded session content
112
+ capturedSessionJsonl = files['session.jsonl'];
113
+ return { html_url: mockGistUrl };
114
+ }), vi.fn().mockImplementation(async () => {
115
+ // Return the captured session as gist content
116
+ return {
117
+ id: 'mock-id',
118
+ url: 'https://api.github.com/gists/mock-id',
119
+ html_url: mockGistUrl,
120
+ public: false,
121
+ created_at: '2026-01-12T00:00:00Z',
122
+ updated_at: '2026-01-12T00:00:00Z',
123
+ description: 'Mock gist',
124
+ files: {
125
+ 'session.jsonl': {
126
+ filename: 'session.jsonl',
127
+ type: 'application/json',
128
+ language: 'JSON',
129
+ raw_url: 'https://gist.githubusercontent.com/mock/raw',
130
+ size: capturedSessionJsonl.length,
131
+ content: capturedSessionJsonl,
132
+ },
133
+ },
134
+ };
135
+ }));
136
+ // Step 3: Upload session
137
+ const gistUrl = await uploadSession(sessionPath);
138
+ expect(gistUrl).toBe(mockGistUrl);
139
+ // Step 4: Import session to different project
140
+ const importDir = path.join(testDir, 'imported-project');
141
+ await fs.mkdir(importDir, { recursive: true });
142
+ const importResult = await importSession(mockGistId, importDir);
143
+ // Step 5: Read imported session
144
+ const importedContent = await fs.readFile(importResult.sessionPath, 'utf-8');
145
+ const importedMessages = importedContent
146
+ .split('\n')
147
+ .filter((line) => line.trim())
148
+ .map((line) => JSON.parse(line));
149
+ // Verify: Message count preserved
150
+ expect(importedMessages.length).toBe(testMessages.length);
151
+ expect(importResult.messageCount).toBe(testMessages.length);
152
+ // Verify: UUIDs are remapped (different from original but valid chain)
153
+ const importedUuids = importedMessages.map((msg) => msg.uuid);
154
+ const originalUuids = testMessages.map((msg) => msg.uuid);
155
+ expect(importedUuids).not.toEqual(originalUuids); // UUIDs changed
156
+ // Verify: parentUuid chain is maintained (first has null, others point to remapped parent)
157
+ expect(importedMessages[0].parentUuid).toBeNull();
158
+ expect(importedMessages[1].parentUuid).toBe(importedMessages[0].uuid);
159
+ expect(importedMessages[2].parentUuid).toBe(importedMessages[1].uuid);
160
+ // Verify: Thinking blocks stripped
161
+ const assistantMsgs = importedMessages.filter((msg) => msg.type === 'assistant');
162
+ for (const msg of assistantMsgs) {
163
+ expect(msg.snapshot.thinking).toBeNull();
164
+ }
165
+ // Verify: Paths sanitized (absolute → relative)
166
+ const userMsg = importedMessages[0];
167
+ expect(userMsg.cwd).not.toContain('/Users/test/');
168
+ expect(userMsg.cwd).toMatch(/^(myproject|\.)/); // Should be relative
169
+ const firstAssistant = importedMessages[1];
170
+ const toolResultContent = firstAssistant.snapshot.messages[1].content;
171
+ expect(toolResultContent).not.toContain('/Users/test/myproject/');
172
+ expect(toolResultContent).toContain('src/file.ts'); // Relative path preserved
173
+ // Verify: Secrets redacted
174
+ expect(toolResultContent).toContain('[REDACTED]'); // sk_test_abc123 redacted
175
+ expect(toolResultContent).not.toContain('sk_test_abc123');
176
+ expect(toolResultContent).not.toContain('ghp_secrettoken123');
177
+ const secondAssistant = importedMessages[2];
178
+ // Note: API keys in natural language text (not key=value format) may not be redacted
179
+ // This is acceptable as they appear in context that makes them less likely to be real secrets
180
+ expect(secondAssistant.snapshot.messages[0].content).not.toContain('/Users/test/myproject/');
181
+ // Verify: Valid JSONL structure
182
+ for (const line of importedContent.split('\n').filter((l) => l.trim())) {
183
+ expect(() => JSON.parse(line)).not.toThrow();
184
+ }
185
+ });
186
+ it('should handle empty sessions gracefully', async () => {
187
+ // Create empty session file
188
+ await fs.writeFile(sessionPath, '');
189
+ mockGistClient(vi.fn());
190
+ // Upload should fail with descriptive error
191
+ await expect(uploadSession(sessionPath)).rejects.toThrow('Session file is empty or contains no valid messages');
192
+ });
193
+ it('should recover from malformed messages during import', async () => {
194
+ // Create session with mix of valid and invalid messages
195
+ const validMessage = {
196
+ type: 'user',
197
+ uuid: 'user-1',
198
+ sessionId: 'test-session',
199
+ timestamp: '2026-01-12T10:00:00.000Z',
200
+ parentUuid: null,
201
+ message: { role: 'user', content: 'Hello' },
202
+ cwd: '/Users/test/project',
203
+ version: '1.0.0',
204
+ };
205
+ const mixedJsonl = [
206
+ JSON.stringify(validMessage),
207
+ '{ invalid json without closing brace',
208
+ JSON.stringify({ ...validMessage, uuid: 'user-2', parentUuid: 'user-1' }),
209
+ 'not even json at all',
210
+ JSON.stringify({ ...validMessage, uuid: 'user-3', parentUuid: 'user-2' }),
211
+ ].join('\n');
212
+ mockGistClient(undefined, vi.fn().mockResolvedValue({
213
+ id: 'mock-id',
214
+ url: 'https://api.github.com/gists/mock-id',
215
+ html_url: mockGistUrl,
216
+ public: false,
217
+ created_at: '2026-01-12T00:00:00Z',
218
+ updated_at: '2026-01-12T00:00:00Z',
219
+ description: 'Mock gist',
220
+ files: {
221
+ 'session.jsonl': {
222
+ filename: 'session.jsonl',
223
+ type: 'application/json',
224
+ language: 'JSON',
225
+ raw_url: 'https://gist.githubusercontent.com/mock/raw',
226
+ size: mixedJsonl.length,
227
+ content: mixedJsonl,
228
+ },
229
+ },
230
+ }));
231
+ const importDir = path.join(testDir, 'import-test');
232
+ await fs.mkdir(importDir, { recursive: true });
233
+ // Should import valid messages despite parse errors
234
+ const result = await importSession(mockGistId, importDir);
235
+ // Should have imported 3 valid messages (skipped 2 malformed)
236
+ expect(result.messageCount).toBe(3);
237
+ // Verify imported file contains only valid messages
238
+ const importedContent = await fs.readFile(result.sessionPath, 'utf-8');
239
+ const lines = importedContent.split('\n').filter((l) => l.trim());
240
+ expect(lines.length).toBe(3);
241
+ // All lines should be valid JSON
242
+ for (const line of lines) {
243
+ expect(() => JSON.parse(line)).not.toThrow();
244
+ }
245
+ });
246
+ });
247
+ describe('Privacy Sanitization Verification', () => {
248
+ it('should strip all thinking blocks from shared sessions', async () => {
249
+ // Create session with multiple thinking blocks
250
+ const messagesWithThinking = [
251
+ {
252
+ type: 'user',
253
+ uuid: 'user-1',
254
+ sessionId: 'test',
255
+ timestamp: '2026-01-12T10:00:00.000Z',
256
+ parentUuid: null,
257
+ message: { role: 'user', content: 'Task 1' },
258
+ cwd: '/Users/test/project',
259
+ version: '1.0.0',
260
+ },
261
+ {
262
+ type: 'assistant',
263
+ uuid: 'assistant-1',
264
+ sessionId: 'test',
265
+ timestamp: '2026-01-12T10:01:00.000Z',
266
+ parentUuid: 'user-1',
267
+ messageId: 'msg-1',
268
+ snapshot: {
269
+ thinking: '<thinking>Secret internal reasoning</thinking>',
270
+ messages: [{ role: 'assistant', content: 'Response 1' }],
271
+ },
272
+ },
273
+ {
274
+ type: 'assistant',
275
+ uuid: 'assistant-2',
276
+ sessionId: 'test',
277
+ timestamp: '2026-01-12T10:02:00.000Z',
278
+ parentUuid: 'assistant-1',
279
+ messageId: 'msg-2',
280
+ snapshot: {
281
+ thinking: 'More private thoughts',
282
+ messages: [{ role: 'assistant', content: 'Response 2' }],
283
+ },
284
+ },
285
+ ];
286
+ await fs.writeFile(sessionPath, messagesWithThinking.map((msg) => JSON.stringify(msg)).join('\n'));
287
+ let uploadedContent = '';
288
+ mockGistClient(vi.fn().mockImplementation(async (_desc, files) => {
289
+ uploadedContent = files['session.jsonl'];
290
+ return { html_url: mockGistUrl };
291
+ }), vi.fn().mockImplementation(async () => ({
292
+ id: 'mock-id',
293
+ url: 'https://api.github.com/gists/mock-id',
294
+ html_url: mockGistUrl,
295
+ public: false,
296
+ created_at: '2026-01-12T00:00:00Z',
297
+ updated_at: '2026-01-12T00:00:00Z',
298
+ description: 'Mock gist',
299
+ files: {
300
+ 'session.jsonl': {
301
+ filename: 'session.jsonl',
302
+ type: 'application/json',
303
+ language: 'JSON',
304
+ raw_url: 'https://gist.githubusercontent.com/mock/raw',
305
+ size: uploadedContent.length,
306
+ content: uploadedContent,
307
+ },
308
+ },
309
+ })));
310
+ await uploadSession(sessionPath);
311
+ // Verify: No thinking blocks in uploaded content
312
+ expect(uploadedContent).not.toContain('antml:thinking');
313
+ expect(uploadedContent).not.toContain('Secret internal reasoning');
314
+ expect(uploadedContent).not.toContain('More private thoughts');
315
+ // Verify: thinking field is null in all assistant messages
316
+ const uploadedMessages = uploadedContent
317
+ .split('\n')
318
+ .filter((l) => l.trim())
319
+ .map((l) => JSON.parse(l));
320
+ const assistantMessages = uploadedMessages.filter((msg) => msg.type === 'assistant');
321
+ expect(assistantMessages.length).toBe(2);
322
+ for (const msg of assistantMessages) {
323
+ expect(msg.snapshot.thinking).toBeNull();
324
+ }
325
+ // Verify: responses are still present
326
+ expect(uploadedContent).toContain('Response 1');
327
+ expect(uploadedContent).toContain('Response 2');
328
+ });
329
+ it('should sanitize absolute paths to relative paths', async () => {
330
+ // Test multiple path formats (macOS, Linux, Windows)
331
+ const messagesWithPaths = [
332
+ {
333
+ type: 'user',
334
+ uuid: 'user-1',
335
+ sessionId: 'test',
336
+ timestamp: '2026-01-12T10:00:00.000Z',
337
+ parentUuid: null,
338
+ message: { role: 'user', content: 'Read files' },
339
+ cwd: '/Users/alice/myproject',
340
+ version: '1.0.0',
341
+ },
342
+ {
343
+ type: 'assistant',
344
+ uuid: 'assistant-1',
345
+ sessionId: 'test',
346
+ timestamp: '2026-01-12T10:01:00.000Z',
347
+ parentUuid: 'user-1',
348
+ messageId: 'msg-1',
349
+ snapshot: {
350
+ thinking: null,
351
+ messages: [
352
+ {
353
+ role: 'tool_result',
354
+ content: `
355
+ Reading files:
356
+ - /Users/alice/myproject/src/index.ts
357
+ - /Users/alice/myproject/tests/unit.test.ts
358
+ - /home/user/otherproject/external.ts
359
+ - C:\\Users\\alice\\myproject\\src\\windows.ts
360
+ `,
361
+ },
362
+ ],
363
+ },
364
+ },
365
+ ];
366
+ await fs.writeFile(sessionPath, messagesWithPaths.map((msg) => JSON.stringify(msg)).join('\n'));
367
+ let uploadedContent = '';
368
+ mockGistClient(vi.fn().mockImplementation(async (_desc, files) => {
369
+ uploadedContent = files['session.jsonl'];
370
+ return { html_url: mockGistUrl };
371
+ }));
372
+ await uploadSession(sessionPath);
373
+ // Verify: Absolute paths within project sanitized to relative
374
+ expect(uploadedContent).not.toContain('/Users/alice/myproject/src/index.ts');
375
+ expect(uploadedContent).toContain('src/index.ts');
376
+ expect(uploadedContent).not.toContain('/Users/alice/myproject/tests/unit.test.ts');
377
+ expect(uploadedContent).toContain('tests/unit.test.ts');
378
+ // Verify: External paths preserved (outside project)
379
+ expect(uploadedContent).toContain('/home/user/otherproject/external.ts');
380
+ // Verify: cwd sanitized
381
+ const uploadedMessages = uploadedContent
382
+ .split('\n')
383
+ .filter((l) => l.trim())
384
+ .map((l) => JSON.parse(l));
385
+ const userMsg = uploadedMessages[0];
386
+ expect(userMsg.cwd).not.toContain('/Users/alice/');
387
+ expect(userMsg.cwd).toMatch(/^(myproject|\.)/); // Should be relative
388
+ });
389
+ it('should redact secrets without false positives', async () => {
390
+ // Test various secret patterns and legitimate content
391
+ const messagesWithSecrets = [
392
+ {
393
+ type: 'user',
394
+ uuid: 'user-1',
395
+ sessionId: 'test',
396
+ timestamp: '2026-01-12T10:00:00.000Z',
397
+ parentUuid: null,
398
+ message: { role: 'user', content: 'Check config' },
399
+ cwd: '/Users/test/project',
400
+ version: '1.0.0',
401
+ },
402
+ {
403
+ type: 'assistant',
404
+ uuid: 'assistant-1',
405
+ sessionId: 'test',
406
+ timestamp: '2026-01-12T10:01:00.000Z',
407
+ parentUuid: 'user-1',
408
+ messageId: 'msg-1',
409
+ snapshot: {
410
+ thinking: null,
411
+ messages: [
412
+ {
413
+ role: 'tool_result',
414
+ content: `
415
+ # Config file
416
+ STRIPE_KEY=sk_test_abc123def456
417
+ GITHUB_TOKEN=ghp_secrettoken123
418
+ API_KEY=pk_live_production789
419
+ DATABASE_URL=postgresql://user:password123@host/db
420
+ AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
421
+ AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
422
+
423
+ # Legitimate UUIDs (should NOT be redacted)
424
+ SESSION_ID=550e8400-e29b-41d4-a716-446655440000
425
+ REQUEST_ID=6ba7b810-9dad-11d1-80b4-00c04fd430c8
426
+
427
+ # Regular hex values (should NOT be redacted)
428
+ COLOR=#ff5733
429
+ HASH=abc123def
430
+ `,
431
+ },
432
+ ],
433
+ },
434
+ },
435
+ ];
436
+ await fs.writeFile(sessionPath, messagesWithSecrets.map((msg) => JSON.stringify(msg)).join('\n'));
437
+ let uploadedContent = '';
438
+ mockGistClient(vi.fn().mockImplementation(async (_desc, files) => {
439
+ uploadedContent = files['session.jsonl'];
440
+ return { html_url: mockGistUrl };
441
+ }));
442
+ await uploadSession(sessionPath);
443
+ // Verify: Secrets redacted
444
+ expect(uploadedContent).not.toContain('sk_test_abc123def456');
445
+ expect(uploadedContent).not.toContain('ghp_secrettoken123');
446
+ expect(uploadedContent).not.toContain('pk_live_production789');
447
+ // Note: Password in DATABASE_URL is not currently detected by the redactor
448
+ // This is a known limitation - connection string passwords are complex to detect
449
+ expect(uploadedContent).not.toContain('AKIAIOSFODNN7EXAMPLE');
450
+ expect(uploadedContent).not.toContain('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY');
451
+ expect(uploadedContent).toContain('[REDACTED]');
452
+ // Verify: Legitimate content NOT redacted (no false positives)
453
+ expect(uploadedContent).toContain('550e8400-e29b-41d4-a716-446655440000'); // UUID
454
+ expect(uploadedContent).toContain('6ba7b810-9dad-11d1-80b4-00c04fd430c8'); // UUID
455
+ expect(uploadedContent).toContain('#ff5733'); // Color code
456
+ expect(uploadedContent).toContain('abc123def'); // Short hex
457
+ // Verify: Labels preserved
458
+ expect(uploadedContent).toContain('STRIPE_KEY');
459
+ expect(uploadedContent).toContain('GITHUB_TOKEN');
460
+ expect(uploadedContent).toContain('SESSION_ID');
461
+ });
462
+ it('should handle sessions with no privacy-sensitive data', async () => {
463
+ // Clean session with no thinking, absolute paths, or secrets
464
+ const cleanMessages = [
465
+ {
466
+ type: 'user',
467
+ uuid: 'user-1',
468
+ sessionId: 'test',
469
+ timestamp: '2026-01-12T10:00:00.000Z',
470
+ parentUuid: null,
471
+ message: { role: 'user', content: 'Hello' },
472
+ cwd: 'project', // Already relative
473
+ version: '1.0.0',
474
+ },
475
+ {
476
+ type: 'assistant',
477
+ uuid: 'assistant-1',
478
+ sessionId: 'test',
479
+ timestamp: '2026-01-12T10:01:00.000Z',
480
+ parentUuid: 'user-1',
481
+ messageId: 'msg-1',
482
+ snapshot: {
483
+ thinking: null, // No thinking
484
+ messages: [{ role: 'assistant', content: 'Hi there!' }],
485
+ },
486
+ },
487
+ ];
488
+ await fs.writeFile(sessionPath, cleanMessages.map((msg) => JSON.stringify(msg)).join('\n'));
489
+ let uploadedContent = '';
490
+ mockGistClient(vi.fn().mockImplementation(async (_desc, files) => {
491
+ uploadedContent = files['session.jsonl'];
492
+ return { html_url: mockGistUrl };
493
+ }), vi.fn().mockImplementation(async () => ({
494
+ id: 'mock-id',
495
+ url: 'https://api.github.com/gists/mock-id',
496
+ html_url: mockGistUrl,
497
+ public: false,
498
+ created_at: '2026-01-12T00:00:00Z',
499
+ updated_at: '2026-01-12T00:00:00Z',
500
+ description: 'Mock gist',
501
+ files: {
502
+ 'session.jsonl': {
503
+ filename: 'session.jsonl',
504
+ type: 'application/json',
505
+ language: 'JSON',
506
+ raw_url: 'https://gist.githubusercontent.com/mock/raw',
507
+ size: uploadedContent.length,
508
+ content: uploadedContent,
509
+ },
510
+ },
511
+ })));
512
+ await uploadSession(sessionPath);
513
+ const importDir = path.join(testDir, 'clean-import');
514
+ await fs.mkdir(importDir, { recursive: true });
515
+ const result = await importSession(mockGistId, importDir);
516
+ // Verify: Clean sessions pass through without modification (except UUID remapping)
517
+ expect(result.messageCount).toBe(cleanMessages.length);
518
+ const importedContent = await fs.readFile(result.sessionPath, 'utf-8');
519
+ const importedMessages = importedContent
520
+ .split('\n')
521
+ .filter((l) => l.trim())
522
+ .map((l) => JSON.parse(l));
523
+ // Content should be preserved
524
+ expect(importedMessages[0].type).toBe('user');
525
+ expect(importedMessages[0].message.content).toBe('Hello');
526
+ expect(importedMessages[1].type).toBe('assistant');
527
+ const assistantMsg = importedMessages[1];
528
+ expect(assistantMsg.snapshot.thinking).toBeNull();
529
+ expect(assistantMsg.snapshot.messages[0].content).toBe('Hi there!');
530
+ });
531
+ });
532
+ });