@fluxpointstudios/orynq-sdk-process-trace 0.1.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,860 @@
1
+ /**
2
+ * @summary Tests for bundle operations in the process-trace package.
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach } from 'vitest';
6
+ import {
7
+ createBundle,
8
+ extractPublicView,
9
+ verifyBundle,
10
+ isPublicSpan,
11
+ isPublicEvent,
12
+ filterPublicEvents,
13
+ signBundle,
14
+ verifyBundleSignature,
15
+ countEventsByVisibility,
16
+ countSpansByVisibility,
17
+ getBundleSpanEvents,
18
+ createTrace,
19
+ addSpan,
20
+ addEvent,
21
+ closeSpan,
22
+ finalizeTrace,
23
+ } from '../index.js';
24
+ import type {
25
+ TraceBundle,
26
+ TraceRun,
27
+ TraceSpan,
28
+ TraceEvent,
29
+ SignatureProvider,
30
+ CommandEvent,
31
+ OutputEvent,
32
+ } from '../index.js';
33
+
34
+ // -----------------------------------------------------------------------------
35
+ // Test Fixtures
36
+ // -----------------------------------------------------------------------------
37
+
38
+ async function createTestBundle(options: {
39
+ publicSpans?: number;
40
+ privateSpans?: number;
41
+ secretSpans?: number;
42
+ eventsPerSpan?: number;
43
+ }): Promise<TraceBundle> {
44
+ const {
45
+ publicSpans = 1,
46
+ privateSpans = 0,
47
+ secretSpans = 0,
48
+ eventsPerSpan = 2,
49
+ } = options;
50
+
51
+ const run = await createTrace({ agentId: 'test-agent' });
52
+
53
+ for (let i = 0; i < publicSpans; i++) {
54
+ const span = addSpan(run, { name: `public-${i}`, visibility: 'public' });
55
+ for (let j = 0; j < eventsPerSpan; j++) {
56
+ await addEvent(run, span.id, {
57
+ kind: 'command',
58
+ command: `public-cmd-${i}-${j}`,
59
+ visibility: 'public',
60
+ });
61
+ }
62
+ await closeSpan(run, span.id);
63
+ }
64
+
65
+ for (let i = 0; i < privateSpans; i++) {
66
+ const span = addSpan(run, { name: `private-${i}`, visibility: 'private' });
67
+ for (let j = 0; j < eventsPerSpan; j++) {
68
+ await addEvent(run, span.id, {
69
+ kind: 'command',
70
+ command: `private-cmd-${i}-${j}`,
71
+ visibility: 'private',
72
+ });
73
+ }
74
+ await closeSpan(run, span.id);
75
+ }
76
+
77
+ for (let i = 0; i < secretSpans; i++) {
78
+ const span = addSpan(run, { name: `secret-${i}`, visibility: 'secret' });
79
+ for (let j = 0; j < eventsPerSpan; j++) {
80
+ await addEvent(run, span.id, {
81
+ kind: 'command',
82
+ command: `secret-cmd-${i}-${j}`,
83
+ visibility: 'secret',
84
+ });
85
+ }
86
+ await closeSpan(run, span.id);
87
+ }
88
+
89
+ return finalizeTrace(run);
90
+ }
91
+
92
+ function createMockSignatureProvider(): SignatureProvider {
93
+ return {
94
+ signerId: 'test-signer',
95
+ sign: async (data: Uint8Array): Promise<Uint8Array> => {
96
+ // Simple mock signature: just hash the data (not cryptographically secure)
97
+ const hash = new Uint8Array(64);
98
+ for (let i = 0; i < data.length && i < 64; i++) {
99
+ hash[i] = data[i]!;
100
+ }
101
+ return hash;
102
+ },
103
+ verify: async (
104
+ data: Uint8Array,
105
+ signature: Uint8Array,
106
+ signerId: string
107
+ ): Promise<boolean> => {
108
+ if (signerId !== 'test-signer') return false;
109
+ // Check that signature matches what we would have produced
110
+ for (let i = 0; i < data.length && i < 64; i++) {
111
+ if (signature[i] !== data[i]) return false;
112
+ }
113
+ return true;
114
+ },
115
+ };
116
+ }
117
+
118
+ // -----------------------------------------------------------------------------
119
+ // createBundle Tests
120
+ // -----------------------------------------------------------------------------
121
+
122
+ describe('createBundle', () => {
123
+ it('requires finalized run', async () => {
124
+ const run = await createTrace({ agentId: 'test-agent' });
125
+ addSpan(run, { name: 'build' });
126
+ // Don't finalize
127
+
128
+ await expect(createBundle(run)).rejects.toThrow(/non-finalized/i);
129
+ });
130
+
131
+ it('requires completed/failed/cancelled status', async () => {
132
+ const run = await createTrace({ agentId: 'test-agent' });
133
+ const span = addSpan(run, { name: 'build' });
134
+ await closeSpan(run, span.id);
135
+ // Manually set rootHash but keep status as running
136
+ run.rootHash = 'a'.repeat(64);
137
+
138
+ await expect(createBundle(run)).rejects.toThrow(/running/i);
139
+ });
140
+
141
+ it('creates bundle with correct structure', async () => {
142
+ const bundle = await createTestBundle({ publicSpans: 1 });
143
+
144
+ expect(bundle).toHaveProperty('formatVersion');
145
+ expect(bundle).toHaveProperty('publicView');
146
+ expect(bundle).toHaveProperty('privateRun');
147
+ expect(bundle).toHaveProperty('merkleRoot');
148
+ expect(bundle).toHaveProperty('rootHash');
149
+ expect(bundle.formatVersion).toBe('1.0');
150
+ });
151
+
152
+ it('includes all spans in privateRun', async () => {
153
+ const bundle = await createTestBundle({
154
+ publicSpans: 2,
155
+ privateSpans: 3,
156
+ });
157
+
158
+ expect(bundle.privateRun.spans).toHaveLength(5);
159
+ });
160
+
161
+ it('includes all events in privateRun', async () => {
162
+ const bundle = await createTestBundle({
163
+ publicSpans: 2,
164
+ eventsPerSpan: 3,
165
+ });
166
+
167
+ expect(bundle.privateRun.events).toHaveLength(6);
168
+ });
169
+ });
170
+
171
+ // -----------------------------------------------------------------------------
172
+ // extractPublicView Tests
173
+ // -----------------------------------------------------------------------------
174
+
175
+ describe('extractPublicView', () => {
176
+ it('filters by visibility', async () => {
177
+ const bundle = await createTestBundle({
178
+ publicSpans: 2,
179
+ privateSpans: 2,
180
+ secretSpans: 1,
181
+ });
182
+
183
+ const publicView = extractPublicView(bundle);
184
+
185
+ expect(publicView.publicSpans).toHaveLength(2);
186
+ expect(publicView.redactedSpanHashes).toHaveLength(3);
187
+ });
188
+
189
+ it('returns same publicView from bundle', async () => {
190
+ const bundle = await createTestBundle({ publicSpans: 1 });
191
+
192
+ const publicView = extractPublicView(bundle);
193
+
194
+ expect(publicView).toBe(bundle.publicView);
195
+ });
196
+
197
+ it('includes only public events in public spans', async () => {
198
+ const run = await createTrace({ agentId: 'test-agent' });
199
+ const span = addSpan(run, { name: 'build', visibility: 'public' });
200
+
201
+ await addEvent(run, span.id, {
202
+ kind: 'command',
203
+ command: 'public-cmd',
204
+ visibility: 'public',
205
+ });
206
+ await addEvent(run, span.id, {
207
+ kind: 'output',
208
+ stream: 'stdout',
209
+ content: 'private-output',
210
+ visibility: 'private',
211
+ });
212
+ await closeSpan(run, span.id);
213
+
214
+ const bundle = await finalizeTrace(run);
215
+ const publicView = extractPublicView(bundle);
216
+
217
+ expect(publicView.publicSpans[0]?.events).toHaveLength(1);
218
+ expect((publicView.publicSpans[0]?.events[0] as CommandEvent)?.command).toBe('public-cmd');
219
+ });
220
+
221
+ it('contains cryptographic commitments', async () => {
222
+ const bundle = await createTestBundle({ publicSpans: 1 });
223
+
224
+ const publicView = extractPublicView(bundle);
225
+
226
+ expect(publicView.rootHash).toHaveLength(64);
227
+ expect(publicView.merkleRoot).toHaveLength(64);
228
+ });
229
+
230
+ it('contains run metadata', async () => {
231
+ const bundle = await createTestBundle({ publicSpans: 1, eventsPerSpan: 3 });
232
+
233
+ const publicView = extractPublicView(bundle);
234
+
235
+ expect(publicView.runId).toBeDefined();
236
+ expect(publicView.agentId).toBe('test-agent');
237
+ expect(publicView.schemaVersion).toBe('1.0');
238
+ expect(publicView.totalEvents).toBe(3);
239
+ expect(publicView.totalSpans).toBe(1);
240
+ });
241
+ });
242
+
243
+ // -----------------------------------------------------------------------------
244
+ // verifyBundle Tests
245
+ // -----------------------------------------------------------------------------
246
+
247
+ describe('verifyBundle', () => {
248
+ it('valid bundle passes all checks', async () => {
249
+ const bundle = await createTestBundle({
250
+ publicSpans: 2,
251
+ privateSpans: 1,
252
+ eventsPerSpan: 3,
253
+ });
254
+
255
+ const result = await verifyBundle(bundle);
256
+
257
+ expect(result.valid).toBe(true);
258
+ expect(result.errors).toHaveLength(0);
259
+ expect(result.checks.rollingHashValid).toBe(true);
260
+ expect(result.checks.rootHashValid).toBe(true);
261
+ expect(result.checks.merkleRootValid).toBe(true);
262
+ expect(result.checks.spanHashesValid).toBe(true);
263
+ expect(result.checks.eventHashesValid).toBe(true);
264
+ expect(result.checks.sequenceValid).toBe(true);
265
+ });
266
+
267
+ it('tampered event hash fails', async () => {
268
+ const bundle = await createTestBundle({ publicSpans: 1, eventsPerSpan: 2 });
269
+
270
+ // Tamper with an event hash
271
+ bundle.privateRun.events[0]!.hash = 'tampered' + bundle.privateRun.events[0]!.hash!.slice(8);
272
+
273
+ const result = await verifyBundle(bundle);
274
+
275
+ expect(result.valid).toBe(false);
276
+ expect(result.checks.eventHashesValid).toBe(false);
277
+ expect(result.errors.some((e) => e.includes('Event hash mismatch'))).toBe(true);
278
+ });
279
+
280
+ it('tampered rolling hash fails', async () => {
281
+ const bundle = await createTestBundle({ publicSpans: 1, eventsPerSpan: 2 });
282
+
283
+ // Tamper with rolling hash
284
+ bundle.privateRun.rollingHash = 'tampered' + bundle.privateRun.rollingHash.slice(8);
285
+
286
+ const result = await verifyBundle(bundle);
287
+
288
+ expect(result.valid).toBe(false);
289
+ expect(result.checks.rollingHashValid).toBe(false);
290
+ expect(result.errors.some((e) => e.includes('Rolling hash mismatch'))).toBe(true);
291
+ });
292
+
293
+ it('tampered span hash fails', async () => {
294
+ const bundle = await createTestBundle({ publicSpans: 1, eventsPerSpan: 2 });
295
+
296
+ // Tamper with span hash
297
+ bundle.privateRun.spans[0]!.hash = 'tampered' + bundle.privateRun.spans[0]!.hash!.slice(8);
298
+
299
+ const result = await verifyBundle(bundle);
300
+
301
+ expect(result.valid).toBe(false);
302
+ expect(result.checks.spanHashesValid).toBe(false);
303
+ });
304
+
305
+ it('tampered root hash fails', async () => {
306
+ const bundle = await createTestBundle({ publicSpans: 1 });
307
+
308
+ // Tamper with root hash
309
+ bundle.rootHash = 'tampered' + bundle.rootHash.slice(8);
310
+
311
+ const result = await verifyBundle(bundle);
312
+
313
+ expect(result.valid).toBe(false);
314
+ expect(result.checks.rootHashValid).toBe(false);
315
+ });
316
+
317
+ it('tampered merkle root fails', async () => {
318
+ const bundle = await createTestBundle({ publicSpans: 1 });
319
+
320
+ // Tamper with merkle root
321
+ bundle.merkleRoot = 'tampered' + bundle.merkleRoot.slice(8);
322
+
323
+ const result = await verifyBundle(bundle);
324
+
325
+ expect(result.valid).toBe(false);
326
+ expect(result.checks.merkleRootValid).toBe(false);
327
+ });
328
+
329
+ it('invalid event sequence fails', async () => {
330
+ const bundle = await createTestBundle({ publicSpans: 1, eventsPerSpan: 3 });
331
+
332
+ // Break sequence by modifying seq
333
+ bundle.privateRun.events[1]!.seq = 5; // Should be 1
334
+
335
+ const result = await verifyBundle(bundle);
336
+
337
+ expect(result.valid).toBe(false);
338
+ expect(result.checks.sequenceValid).toBe(false);
339
+ expect(result.errors.some((e) => e.includes('sequence gap'))).toBe(true);
340
+ });
341
+
342
+ it('invalid span sequence fails', async () => {
343
+ const bundle = await createTestBundle({ publicSpans: 3 });
344
+
345
+ // Break sequence by modifying spanSeq
346
+ bundle.privateRun.spans[1]!.spanSeq = 5; // Should be 1
347
+
348
+ const result = await verifyBundle(bundle);
349
+
350
+ expect(result.valid).toBe(false);
351
+ expect(result.checks.sequenceValid).toBe(false);
352
+ expect(result.errors.some((e) => e.includes('Span sequence gap'))).toBe(true);
353
+ });
354
+
355
+ it('warns when no public spans exist', async () => {
356
+ const bundle = await createTestBundle({
357
+ publicSpans: 0,
358
+ privateSpans: 2,
359
+ });
360
+
361
+ const result = await verifyBundle(bundle);
362
+
363
+ expect(result.valid).toBe(true);
364
+ expect(result.warnings.some((w) => w.includes('No public spans'))).toBe(true);
365
+ });
366
+ });
367
+
368
+ // -----------------------------------------------------------------------------
369
+ // Public View Content Tests
370
+ // -----------------------------------------------------------------------------
371
+
372
+ describe('public view content', () => {
373
+ it('contains only public spans', async () => {
374
+ const bundle = await createTestBundle({
375
+ publicSpans: 3,
376
+ privateSpans: 2,
377
+ secretSpans: 1,
378
+ });
379
+
380
+ const publicView = bundle.publicView;
381
+
382
+ expect(publicView.publicSpans).toHaveLength(3);
383
+ for (const span of publicView.publicSpans) {
384
+ expect(span.visibility).toBe('public');
385
+ }
386
+ });
387
+
388
+ it('contains only public events in public spans', async () => {
389
+ const run = await createTrace({ agentId: 'test-agent' });
390
+ const span = addSpan(run, { name: 'build', visibility: 'public' });
391
+
392
+ await addEvent(run, span.id, {
393
+ kind: 'command',
394
+ command: 'public',
395
+ visibility: 'public',
396
+ });
397
+ await addEvent(run, span.id, {
398
+ kind: 'output',
399
+ stream: 'stdout',
400
+ content: 'private',
401
+ visibility: 'private',
402
+ });
403
+ await addEvent(run, span.id, {
404
+ kind: 'command',
405
+ command: 'secret',
406
+ visibility: 'secret',
407
+ });
408
+ await closeSpan(run, span.id);
409
+
410
+ const bundle = await finalizeTrace(run);
411
+
412
+ expect(bundle.publicView.publicSpans[0]?.events).toHaveLength(1);
413
+ expect((bundle.publicView.publicSpans[0]?.events[0] as CommandEvent)?.command).toBe('public');
414
+ });
415
+
416
+ it('redacted hashes for non-public spans', async () => {
417
+ const bundle = await createTestBundle({
418
+ publicSpans: 1,
419
+ privateSpans: 2,
420
+ secretSpans: 1,
421
+ });
422
+
423
+ const publicView = bundle.publicView;
424
+
425
+ expect(publicView.redactedSpanHashes).toHaveLength(3);
426
+ for (const redacted of publicView.redactedSpanHashes) {
427
+ expect(redacted.spanId).toBeDefined();
428
+ expect(redacted.hash).toHaveLength(64);
429
+ }
430
+ });
431
+
432
+ it('public spans are sorted by spanSeq', async () => {
433
+ const bundle = await createTestBundle({
434
+ publicSpans: 5,
435
+ });
436
+
437
+ const publicView = bundle.publicView;
438
+ const spanSeqs = publicView.publicSpans.map((s) => s.spanSeq);
439
+
440
+ for (let i = 1; i < spanSeqs.length; i++) {
441
+ expect(spanSeqs[i]).toBeGreaterThan(spanSeqs[i - 1]!);
442
+ }
443
+ });
444
+ });
445
+
446
+ // -----------------------------------------------------------------------------
447
+ // Visibility Helper Tests
448
+ // -----------------------------------------------------------------------------
449
+
450
+ describe('isPublicSpan', () => {
451
+ it('returns true for public span', () => {
452
+ const span: TraceSpan = {
453
+ id: 'test',
454
+ spanSeq: 0,
455
+ name: 'test',
456
+ status: 'completed',
457
+ visibility: 'public',
458
+ startedAt: new Date().toISOString(),
459
+ eventIds: [],
460
+ childSpanIds: [],
461
+ };
462
+
463
+ expect(isPublicSpan(span)).toBe(true);
464
+ });
465
+
466
+ it('returns false for private span', () => {
467
+ const span: TraceSpan = {
468
+ id: 'test',
469
+ spanSeq: 0,
470
+ name: 'test',
471
+ status: 'completed',
472
+ visibility: 'private',
473
+ startedAt: new Date().toISOString(),
474
+ eventIds: [],
475
+ childSpanIds: [],
476
+ };
477
+
478
+ expect(isPublicSpan(span)).toBe(false);
479
+ });
480
+
481
+ it('returns false for secret span', () => {
482
+ const span: TraceSpan = {
483
+ id: 'test',
484
+ spanSeq: 0,
485
+ name: 'test',
486
+ status: 'completed',
487
+ visibility: 'secret',
488
+ startedAt: new Date().toISOString(),
489
+ eventIds: [],
490
+ childSpanIds: [],
491
+ };
492
+
493
+ expect(isPublicSpan(span)).toBe(false);
494
+ });
495
+ });
496
+
497
+ describe('isPublicEvent', () => {
498
+ it('returns true for public event', () => {
499
+ const event: TraceEvent = {
500
+ kind: 'command',
501
+ id: 'test',
502
+ seq: 0,
503
+ timestamp: new Date().toISOString(),
504
+ visibility: 'public',
505
+ command: 'npm install',
506
+ };
507
+
508
+ expect(isPublicEvent(event)).toBe(true);
509
+ });
510
+
511
+ it('returns false for private event', () => {
512
+ const event: TraceEvent = {
513
+ kind: 'command',
514
+ id: 'test',
515
+ seq: 0,
516
+ timestamp: new Date().toISOString(),
517
+ visibility: 'private',
518
+ command: 'npm install',
519
+ };
520
+
521
+ expect(isPublicEvent(event)).toBe(false);
522
+ });
523
+
524
+ it('returns false for secret event', () => {
525
+ const event: TraceEvent = {
526
+ kind: 'command',
527
+ id: 'test',
528
+ seq: 0,
529
+ timestamp: new Date().toISOString(),
530
+ visibility: 'secret',
531
+ command: 'npm install',
532
+ };
533
+
534
+ expect(isPublicEvent(event)).toBe(false);
535
+ });
536
+ });
537
+
538
+ describe('filterPublicEvents', () => {
539
+ it('returns only public events', () => {
540
+ const events: TraceEvent[] = [
541
+ {
542
+ kind: 'command',
543
+ id: '1',
544
+ seq: 0,
545
+ timestamp: new Date().toISOString(),
546
+ visibility: 'public',
547
+ command: 'public',
548
+ },
549
+ {
550
+ kind: 'command',
551
+ id: '2',
552
+ seq: 1,
553
+ timestamp: new Date().toISOString(),
554
+ visibility: 'private',
555
+ command: 'private',
556
+ },
557
+ {
558
+ kind: 'command',
559
+ id: '3',
560
+ seq: 2,
561
+ timestamp: new Date().toISOString(),
562
+ visibility: 'public',
563
+ command: 'public2',
564
+ },
565
+ ];
566
+
567
+ const filtered = filterPublicEvents(events);
568
+
569
+ expect(filtered).toHaveLength(2);
570
+ expect(filtered.every((e) => e.visibility === 'public')).toBe(true);
571
+ });
572
+
573
+ it('returns empty array when no public events', () => {
574
+ const events: TraceEvent[] = [
575
+ {
576
+ kind: 'command',
577
+ id: '1',
578
+ seq: 0,
579
+ timestamp: new Date().toISOString(),
580
+ visibility: 'private',
581
+ command: 'private',
582
+ },
583
+ ];
584
+
585
+ const filtered = filterPublicEvents(events);
586
+
587
+ expect(filtered).toHaveLength(0);
588
+ });
589
+
590
+ it('returns all events when all are public', () => {
591
+ const events: TraceEvent[] = [
592
+ {
593
+ kind: 'command',
594
+ id: '1',
595
+ seq: 0,
596
+ timestamp: new Date().toISOString(),
597
+ visibility: 'public',
598
+ command: 'public1',
599
+ },
600
+ {
601
+ kind: 'command',
602
+ id: '2',
603
+ seq: 1,
604
+ timestamp: new Date().toISOString(),
605
+ visibility: 'public',
606
+ command: 'public2',
607
+ },
608
+ ];
609
+
610
+ const filtered = filterPublicEvents(events);
611
+
612
+ expect(filtered).toHaveLength(2);
613
+ });
614
+ });
615
+
616
+ // -----------------------------------------------------------------------------
617
+ // Bundle Signing Tests
618
+ // -----------------------------------------------------------------------------
619
+
620
+ describe('signBundle', () => {
621
+ it('adds signature to bundle', async () => {
622
+ const bundle = await createTestBundle({ publicSpans: 1 });
623
+ const provider = createMockSignatureProvider();
624
+
625
+ const signedBundle = await signBundle(bundle, provider);
626
+
627
+ expect(signedBundle.signature).toBeDefined();
628
+ expect(signedBundle.signerId).toBe('test-signer');
629
+ });
630
+
631
+ it('returns new bundle (does not mutate original)', async () => {
632
+ const bundle = await createTestBundle({ publicSpans: 1 });
633
+ const provider = createMockSignatureProvider();
634
+
635
+ const signedBundle = await signBundle(bundle, provider);
636
+
637
+ expect(signedBundle).not.toBe(bundle);
638
+ expect(bundle.signature).toBeUndefined();
639
+ expect(signedBundle.signature).toBeDefined();
640
+ });
641
+
642
+ it('signature is hex encoded', async () => {
643
+ const bundle = await createTestBundle({ publicSpans: 1 });
644
+ const provider = createMockSignatureProvider();
645
+
646
+ const signedBundle = await signBundle(bundle, provider);
647
+
648
+ expect(signedBundle.signature).toMatch(/^[a-f0-9]+$/);
649
+ });
650
+ });
651
+
652
+ describe('verifyBundleSignature', () => {
653
+ it('returns true for valid signature', async () => {
654
+ const bundle = await createTestBundle({ publicSpans: 1 });
655
+ const provider = createMockSignatureProvider();
656
+ const signedBundle = await signBundle(bundle, provider);
657
+
658
+ const isValid = await verifyBundleSignature(signedBundle, provider);
659
+
660
+ expect(isValid).toBe(true);
661
+ });
662
+
663
+ it('returns false for missing signature', async () => {
664
+ const bundle = await createTestBundle({ publicSpans: 1 });
665
+ const provider = createMockSignatureProvider();
666
+
667
+ const isValid = await verifyBundleSignature(bundle, provider);
668
+
669
+ expect(isValid).toBe(false);
670
+ });
671
+
672
+ it('returns false for wrong signer', async () => {
673
+ const bundle = await createTestBundle({ publicSpans: 1 });
674
+ const provider = createMockSignatureProvider();
675
+ const signedBundle = await signBundle(bundle, provider);
676
+
677
+ // Change signer ID
678
+ signedBundle.signerId = 'wrong-signer';
679
+
680
+ const isValid = await verifyBundleSignature(signedBundle, provider);
681
+
682
+ expect(isValid).toBe(false);
683
+ });
684
+
685
+ it('returns false for tampered signature', async () => {
686
+ const bundle = await createTestBundle({ publicSpans: 1 });
687
+ const provider = createMockSignatureProvider();
688
+ const signedBundle = await signBundle(bundle, provider);
689
+
690
+ // Tamper with signature
691
+ signedBundle.signature = '00' + signedBundle.signature!.slice(2);
692
+
693
+ const isValid = await verifyBundleSignature(signedBundle, provider);
694
+
695
+ expect(isValid).toBe(false);
696
+ });
697
+ });
698
+
699
+ // -----------------------------------------------------------------------------
700
+ // Utility Function Tests
701
+ // -----------------------------------------------------------------------------
702
+
703
+ describe('countEventsByVisibility', () => {
704
+ it('counts events correctly', async () => {
705
+ const run = await createTrace({ agentId: 'test-agent' });
706
+ const span = addSpan(run, { name: 'build' });
707
+
708
+ await addEvent(run, span.id, { kind: 'command', command: '1', visibility: 'public' });
709
+ await addEvent(run, span.id, { kind: 'command', command: '2', visibility: 'public' });
710
+ await addEvent(run, span.id, { kind: 'command', command: '3', visibility: 'private' });
711
+ await addEvent(run, span.id, { kind: 'command', command: '4', visibility: 'secret' });
712
+
713
+ const counts = countEventsByVisibility(run);
714
+
715
+ expect(counts.public).toBe(2);
716
+ expect(counts.private).toBe(1);
717
+ expect(counts.secret).toBe(1);
718
+ });
719
+
720
+ it('returns zeros for empty run', async () => {
721
+ const run = await createTrace({ agentId: 'test-agent' });
722
+
723
+ const counts = countEventsByVisibility(run);
724
+
725
+ expect(counts.public).toBe(0);
726
+ expect(counts.private).toBe(0);
727
+ expect(counts.secret).toBe(0);
728
+ });
729
+ });
730
+
731
+ describe('countSpansByVisibility', () => {
732
+ it('counts spans correctly', async () => {
733
+ const run = await createTrace({ agentId: 'test-agent' });
734
+
735
+ addSpan(run, { name: 'public1', visibility: 'public' });
736
+ addSpan(run, { name: 'public2', visibility: 'public' });
737
+ addSpan(run, { name: 'private1', visibility: 'private' });
738
+ addSpan(run, { name: 'secret1', visibility: 'secret' });
739
+ addSpan(run, { name: 'secret2', visibility: 'secret' });
740
+
741
+ const counts = countSpansByVisibility(run);
742
+
743
+ expect(counts.public).toBe(2);
744
+ expect(counts.private).toBe(1);
745
+ expect(counts.secret).toBe(2);
746
+ });
747
+
748
+ it('returns zeros for empty run', async () => {
749
+ const run = await createTrace({ agentId: 'test-agent' });
750
+
751
+ const counts = countSpansByVisibility(run);
752
+
753
+ expect(counts.public).toBe(0);
754
+ expect(counts.private).toBe(0);
755
+ expect(counts.secret).toBe(0);
756
+ });
757
+ });
758
+
759
+ describe('getBundleSpanEvents', () => {
760
+ it('returns events for span sorted by seq', async () => {
761
+ const run = await createTrace({ agentId: 'test-agent' });
762
+ const span = addSpan(run, { name: 'build' });
763
+
764
+ const e1 = await addEvent(run, span.id, { kind: 'command', command: 'first', visibility: 'public' });
765
+ const e2 = await addEvent(run, span.id, { kind: 'output', stream: 'stdout', content: 'second', visibility: 'private' });
766
+ const e3 = await addEvent(run, span.id, { kind: 'command', command: 'third', visibility: 'public' });
767
+
768
+ const events = getBundleSpanEvents(span, run.events);
769
+
770
+ expect(events).toHaveLength(3);
771
+ expect(events[0]).toBe(e1);
772
+ expect(events[1]).toBe(e2);
773
+ expect(events[2]).toBe(e3);
774
+ });
775
+
776
+ it('returns empty array for span with no events', async () => {
777
+ const run = await createTrace({ agentId: 'test-agent' });
778
+ const span = addSpan(run, { name: 'empty' });
779
+
780
+ const events = getBundleSpanEvents(span, run.events);
781
+
782
+ expect(events).toHaveLength(0);
783
+ });
784
+
785
+ it('ignores events not in span', async () => {
786
+ const run = await createTrace({ agentId: 'test-agent' });
787
+ const span1 = addSpan(run, { name: 'span1' });
788
+ const span2 = addSpan(run, { name: 'span2' });
789
+
790
+ await addEvent(run, span1.id, { kind: 'command', command: 'span1-event', visibility: 'public' });
791
+ await addEvent(run, span2.id, { kind: 'command', command: 'span2-event', visibility: 'public' });
792
+
793
+ const events = getBundleSpanEvents(span1, run.events);
794
+
795
+ expect(events).toHaveLength(1);
796
+ expect((events[0] as CommandEvent)?.command).toBe('span1-event');
797
+ });
798
+ });
799
+
800
+ // -----------------------------------------------------------------------------
801
+ // Edge Cases
802
+ // -----------------------------------------------------------------------------
803
+
804
+ describe('edge cases', () => {
805
+ it('handles bundle with no spans', async () => {
806
+ const run = await createTrace({ agentId: 'test-agent' });
807
+ const bundle = await finalizeTrace(run);
808
+
809
+ const result = await verifyBundle(bundle);
810
+
811
+ expect(result.valid).toBe(true);
812
+ expect(bundle.publicView.publicSpans).toHaveLength(0);
813
+ expect(bundle.publicView.redactedSpanHashes).toHaveLength(0);
814
+ });
815
+
816
+ it('handles bundle with only secret spans', async () => {
817
+ const bundle = await createTestBundle({
818
+ publicSpans: 0,
819
+ privateSpans: 0,
820
+ secretSpans: 3,
821
+ });
822
+
823
+ const result = await verifyBundle(bundle);
824
+
825
+ expect(result.valid).toBe(true);
826
+ expect(bundle.publicView.publicSpans).toHaveLength(0);
827
+ expect(bundle.publicView.redactedSpanHashes).toHaveLength(3);
828
+ });
829
+
830
+ it('handles bundle with only public spans', async () => {
831
+ const bundle = await createTestBundle({
832
+ publicSpans: 5,
833
+ privateSpans: 0,
834
+ secretSpans: 0,
835
+ });
836
+
837
+ const result = await verifyBundle(bundle);
838
+
839
+ expect(result.valid).toBe(true);
840
+ expect(bundle.publicView.publicSpans).toHaveLength(5);
841
+ expect(bundle.publicView.redactedSpanHashes).toHaveLength(0);
842
+ });
843
+
844
+ it('handles mixed visibility events within public span', async () => {
845
+ const run = await createTrace({ agentId: 'test-agent' });
846
+ const span = addSpan(run, { name: 'mixed', visibility: 'public' });
847
+
848
+ await addEvent(run, span.id, { kind: 'command', command: 'public', visibility: 'public' });
849
+ await addEvent(run, span.id, { kind: 'output', stream: 'stdout', content: 'private', visibility: 'private' });
850
+ await addEvent(run, span.id, { kind: 'command', command: 'secret', visibility: 'secret' });
851
+ await closeSpan(run, span.id);
852
+
853
+ const bundle = await finalizeTrace(run);
854
+ const result = await verifyBundle(bundle);
855
+
856
+ expect(result.valid).toBe(true);
857
+ expect(bundle.publicView.publicSpans[0]?.events).toHaveLength(1);
858
+ expect(bundle.privateRun.events).toHaveLength(3);
859
+ });
860
+ });