@openwop/openwop-conformance 1.153.0 → 1.154.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +9 -0
  3. package/dist/cli.js +7 -2
  4. package/package.json +31 -2
  5. package/schemas/CORPUS-STAMP.json +99 -3
  6. package/src/cli.ts +7 -5
  7. package/src/global-setup.ts +13 -0
  8. package/src/lib/corpus-stamp.ts +125 -0
  9. package/src/lib/capabilities-auth-subject-link.test.ts +0 -103
  10. package/src/lib/fork-availability.test.ts +0 -69
  11. package/src/lib/global-setup.test.ts +0 -76
  12. package/src/lib/grpc-framing.test.ts +0 -96
  13. package/src/lib/oidc-issuer.test.ts +0 -328
  14. package/src/lib/otel-collector-grpc.test.ts +0 -191
  15. package/src/lib/otel-collector.test.ts +0 -303
  16. package/src/lib/otlp-protobuf.test.ts +0 -461
  17. package/src/lib/polling.test.ts +0 -80
  18. package/src/lib/requirement-ids.test.ts +0 -83
  19. package/src/lib/requirement-ledger.test.ts +0 -75
  20. package/src/lib/risk-disposition.test.ts +0 -91
  21. package/src/lib/saml-idp.test.ts +0 -127
  22. package/src/lib/spec-coherence-registry.test.ts +0 -155
  23. package/src/lib/webhook-receiver.test.ts +0 -144
  24. package/src/scenarios/artifact-schema-compile-bounded.test.ts +0 -126
  25. package/src/scenarios/artifact-type-legacy-ids.test.ts +0 -124
  26. package/src/scenarios/capability-example-root-layout.test.ts +0 -272
  27. package/src/scenarios/certification-floor-enforcement.test.ts +0 -204
  28. package/src/scenarios/chain-subchain-unsupported-refused.test.ts +0 -70
  29. package/src/scenarios/compensation-profile.test.ts +0 -340
  30. package/src/scenarios/core-manifest-and-extension-registry.test.ts +0 -250
  31. package/src/scenarios/discovery-canonical-family-no-shadow.test.ts +0 -219
  32. package/src/scenarios/edge-condition-truthy-falsy.test.ts +0 -108
  33. package/src/scenarios/effect-identity-composition.test.ts +0 -129
  34. package/src/scenarios/effect-identity-cross-scope.test.ts +0 -82
  35. package/src/scenarios/error-envelope-canonical-shape.test.ts +0 -64
  36. package/src/scenarios/form-content-packs.test.ts +0 -415
  37. package/src/scenarios/multi-region-effect-vocabulary.test.ts +0 -175
  38. package/src/scenarios/normative-example-extraction.test.ts +0 -242
  39. package/src/scenarios/openapi-asyncapi-sdk-parity.test.ts +0 -309
  40. package/src/scenarios/pack-manifest-extensions.test.ts +0 -203
  41. package/src/scenarios/protocol-version-grammar.test.ts +0 -119
  42. package/src/scenarios/registry-declarative-kinds.test.ts +0 -121
  43. package/src/scenarios/rfc-0147-self-audit.test.ts +0 -104
  44. package/src/scenarios/rfc-lifecycle-coherence.test.ts +0 -215
  45. package/src/scenarios/semantic-digest-v2.test.ts +0 -128
  46. package/src/scenarios/spec-corpus-validity.test.ts +0 -1727
  47. package/src/scenarios/spec-section-citations.test.ts +0 -132
  48. package/src/scenarios/tool-result-trust-monotone.test.ts +0 -168
  49. package/src/scenarios/versioned-composition-profiles.test.ts +0 -201
  50. package/src/scenarios/workflow-chain-internal-flag.test.ts +0 -84
  51. package/src/scenarios/workload-identity-profile.test.ts +0 -184
@@ -1,96 +0,0 @@
1
- /**
2
- * Unit tests for `grpc-framing.ts` — gRPC HTTP/2 message framing.
3
- *
4
- * @see grpc-framing.ts
5
- */
6
-
7
- import { describe, it, expect } from 'vitest';
8
- import { frameMessage, unframeMessages } from './grpc-framing.js';
9
-
10
- describe('grpc-framing: frameMessage', () => {
11
- it('prepends a 5-byte header to a single payload', () => {
12
- const payload = new Uint8Array([0xab, 0xcd, 0xef]);
13
- const framed = frameMessage(payload);
14
- expect(framed.byteLength).toBe(8);
15
- expect(framed[0]).toBe(0); // identity compression
16
- expect(framed[1]).toBe(0);
17
- expect(framed[2]).toBe(0);
18
- expect(framed[3]).toBe(0);
19
- expect(framed[4]).toBe(3); // length = 3
20
- expect(framed[5]).toBe(0xab);
21
- expect(framed[6]).toBe(0xcd);
22
- expect(framed[7]).toBe(0xef);
23
- });
24
-
25
- it('frames a zero-length payload as a 5-byte header alone', () => {
26
- const framed = frameMessage(new Uint8Array(0));
27
- expect(framed.byteLength).toBe(5);
28
- expect(framed[0]).toBe(0);
29
- expect(framed[4]).toBe(0);
30
- });
31
-
32
- it('encodes lengths > 256 in big-endian order', () => {
33
- const payload = new Uint8Array(300);
34
- const framed = frameMessage(payload);
35
- // length = 300 = 0x0000012C, big-endian: 00 00 01 2C
36
- expect(framed[1]).toBe(0);
37
- expect(framed[2]).toBe(0);
38
- expect(framed[3]).toBe(1);
39
- expect(framed[4]).toBe(0x2c);
40
- });
41
- });
42
-
43
- describe('grpc-framing: unframeMessages', () => {
44
- it('parses a single frame', () => {
45
- const payload = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
46
- const framed = frameMessage(payload);
47
- const messages = unframeMessages(framed);
48
- expect(messages.length).toBe(1);
49
- expect(Array.from(messages[0]!)).toEqual([0x01, 0x02, 0x03, 0x04]);
50
- });
51
-
52
- it('parses multiple concatenated frames', () => {
53
- const a = frameMessage(new Uint8Array([0xaa, 0xab]));
54
- const b = frameMessage(new Uint8Array([0xbb]));
55
- const c = frameMessage(new Uint8Array([0xcc, 0xcd, 0xce, 0xcf]));
56
- const combined = new Uint8Array(a.byteLength + b.byteLength + c.byteLength);
57
- combined.set(a, 0);
58
- combined.set(b, a.byteLength);
59
- combined.set(c, a.byteLength + b.byteLength);
60
- const messages = unframeMessages(combined);
61
- expect(messages.length).toBe(3);
62
- expect(Array.from(messages[0]!)).toEqual([0xaa, 0xab]);
63
- expect(Array.from(messages[1]!)).toEqual([0xbb]);
64
- expect(Array.from(messages[2]!)).toEqual([0xcc, 0xcd, 0xce, 0xcf]);
65
- });
66
-
67
- it('parses an empty buffer as zero frames', () => {
68
- expect(unframeMessages(new Uint8Array(0))).toEqual([]);
69
- });
70
-
71
- it('throws on truncated header', () => {
72
- // Only 3 bytes of a 5-byte header.
73
- const buf = new Uint8Array([0, 0, 0]);
74
- expect(() => unframeMessages(buf)).toThrow(/frame truncated/i);
75
- });
76
-
77
- it('throws on truncated payload', () => {
78
- // 5-byte header declares 10 bytes of payload but only 4 follow.
79
- const buf = new Uint8Array([0, 0, 0, 0, 10, 1, 2, 3, 4]);
80
- expect(() => unframeMessages(buf)).toThrow(/payload truncated/i);
81
- });
82
-
83
- it('throws on unsupported compression flag', () => {
84
- // Flag = 1 → compression negotiated, which we don't implement.
85
- const buf = new Uint8Array([1, 0, 0, 0, 0]);
86
- expect(() => unframeMessages(buf)).toThrow(/compression flag/i);
87
- });
88
-
89
- it('round-trips: frame → unframe yields original payload', () => {
90
- const original = new Uint8Array([0x0a, 0x05, 0x68, 0x65, 0x6c, 0x6c, 0x6f]); // protobuf "hello"
91
- const framed = frameMessage(original);
92
- const unframed = unframeMessages(framed);
93
- expect(unframed.length).toBe(1);
94
- expect(Array.from(unframed[0]!)).toEqual(Array.from(original));
95
- });
96
- });
@@ -1,328 +0,0 @@
1
- /**
2
- * Server-free unit tests for the synthetic OIDC issuer harness.
3
- *
4
- * The harness is real cryptographic code (RS256 + ES256 JWS signing,
5
- * JWKS export, JWT compact serialization). If the signing or encoding
6
- * is wrong, every scenario that uses the harness silently misreports —
7
- * the OIDC validation scenarios soft-skip behavior portions when the
8
- * host doesn't trust the harness, so a malformed token would simply
9
- * cause the host to reject and the test to "pass" via soft-skip path.
10
- *
11
- * These unit tests round-trip every token through `node:crypto.createVerify`
12
- * to confirm the harness output is parseable by an independent verifier.
13
- * Run server-free; doesn't depend on OPENWOP_BASE_URL.
14
- *
15
- * @see conformance/src/lib/oidc-issuer.ts
16
- * @see RFCS/0010-auth-profile-conformance.md §E
17
- */
18
-
19
- import { describe, it, expect } from 'vitest';
20
- import { createPublicKey, createVerify, type JsonWebKey } from 'node:crypto';
21
- import { createSyntheticOIDCIssuer } from './oidc-issuer.js';
22
-
23
- function base64UrlDecode(input: string): Buffer {
24
- const pad = input.length % 4 === 0 ? 0 : 4 - (input.length % 4);
25
- const padded = input + '='.repeat(pad);
26
- const std = padded.replace(/-/g, '+').replace(/_/g, '/');
27
- return Buffer.from(std, 'base64');
28
- }
29
-
30
- function decodeJwt(token: string): {
31
- header: Record<string, unknown>;
32
- payload: Record<string, unknown>;
33
- signature: Buffer;
34
- signingInput: string;
35
- } {
36
- const parts = token.split('.');
37
- if (parts.length !== 3) throw new Error(`malformed JWT: ${parts.length} segments`);
38
- const [h, p, s] = parts;
39
- return {
40
- header: JSON.parse(base64UrlDecode(h).toString('utf8')) as Record<string, unknown>,
41
- payload: JSON.parse(base64UrlDecode(p).toString('utf8')) as Record<string, unknown>,
42
- signature: base64UrlDecode(s),
43
- signingInput: `${h}.${p}`,
44
- };
45
- }
46
-
47
- function verifyToken(
48
- token: string,
49
- jwksJson: string,
50
- algorithm: 'RS256' | 'ES256',
51
- ): boolean {
52
- const decoded = decodeJwt(token);
53
- const jwks = JSON.parse(jwksJson) as { keys: JsonWebKey[] };
54
- const kid = decoded.header.kid;
55
- const key = jwks.keys.find((k) => k.kid === kid);
56
- if (!key) throw new Error(`no JWKS key matches kid=${String(kid)}`);
57
-
58
- const publicKey = createPublicKey({ key, format: 'jwk' });
59
- const digest = algorithm === 'RS256' ? 'RSA-SHA256' : 'SHA256';
60
- const verifier = createVerify(digest);
61
- verifier.update(decoded.signingInput);
62
- verifier.end();
63
- return verifier.verify(
64
- algorithm === 'RS256'
65
- ? publicKey
66
- : { key: publicKey, dsaEncoding: 'ieee-p1363' },
67
- decoded.signature,
68
- );
69
- }
70
-
71
- describe('oidc-issuer: harness construction', () => {
72
- it('requires issuer and audience', () => {
73
- expect(() =>
74
- createSyntheticOIDCIssuer({ issuer: '', audience: 'openwop' }),
75
- ).toThrow(/issuer and audience/);
76
- expect(() =>
77
- createSyntheticOIDCIssuer({ issuer: 'https://x', audience: '' }),
78
- ).toThrow(/issuer and audience/);
79
- });
80
-
81
- it('defaults to RS256 + canonical keyId', () => {
82
- const issuer = createSyntheticOIDCIssuer({
83
- issuer: 'https://harness.example',
84
- audience: 'openwop',
85
- });
86
- expect(issuer.algorithm).toBe('RS256');
87
- expect(issuer.keyId).toBe('openwop-conformance-key-1');
88
- expect(issuer.issuer).toBe('https://harness.example');
89
- expect(issuer.audience).toBe('openwop');
90
- });
91
-
92
- it('rejects unsupported algorithm at runtime (defensive)', () => {
93
- expect(() =>
94
- createSyntheticOIDCIssuer({
95
- issuer: 'https://x',
96
- audience: 'y',
97
- algorithm: 'HS256',
98
- }),
99
- ).toThrow(/unsupported algorithm/);
100
- });
101
- });
102
-
103
- describe('oidc-issuer: JWKS + discovery shape', () => {
104
- it('publishes a well-formed JWKS for RS256', () => {
105
- const issuer = createSyntheticOIDCIssuer({
106
- issuer: 'https://harness.example',
107
- audience: 'openwop',
108
- });
109
- const jwks = JSON.parse(issuer.jwksJson) as { keys: JsonWebKey[] };
110
- expect(Array.isArray(jwks.keys)).toBe(true);
111
- expect(jwks.keys.length).toBe(1);
112
- const key = jwks.keys[0];
113
- expect(key.kty).toBe('RSA');
114
- expect(key.alg).toBe('RS256');
115
- expect(key.use).toBe('sig');
116
- expect(key.kid).toBe(issuer.keyId);
117
- // RSA JWK MUST have n (modulus) and e (exponent).
118
- expect(typeof key.n).toBe('string');
119
- expect(typeof key.e).toBe('string');
120
- });
121
-
122
- it('publishes a well-formed JWKS for ES256', () => {
123
- const issuer = createSyntheticOIDCIssuer({
124
- issuer: 'https://harness.example',
125
- audience: 'openwop',
126
- algorithm: 'ES256',
127
- });
128
- const jwks = JSON.parse(issuer.jwksJson) as { keys: JsonWebKey[] };
129
- const key = jwks.keys[0];
130
- expect(key.kty).toBe('EC');
131
- expect(key.alg).toBe('ES256');
132
- expect(key.crv).toBe('P-256');
133
- expect(typeof key.x).toBe('string');
134
- expect(typeof key.y).toBe('string');
135
- });
136
-
137
- it('publishes OIDC discovery doc with correct shape', () => {
138
- const issuer = createSyntheticOIDCIssuer({
139
- issuer: 'https://harness.example/oauth',
140
- audience: 'openwop',
141
- });
142
- const disco = JSON.parse(issuer.discoveryJson) as {
143
- issuer: string;
144
- jwks_uri: string;
145
- response_types_supported: string[];
146
- subject_types_supported: string[];
147
- id_token_signing_alg_values_supported: string[];
148
- };
149
- expect(disco.issuer).toBe('https://harness.example/oauth');
150
- expect(disco.jwks_uri).toBe('https://harness.example/oauth/.well-known/jwks.json');
151
- expect(disco.id_token_signing_alg_values_supported).toContain('RS256');
152
- });
153
-
154
- it('discovery doc strips trailing slash before appending jwks path', () => {
155
- const issuer = createSyntheticOIDCIssuer({
156
- issuer: 'https://harness.example/',
157
- audience: 'openwop',
158
- });
159
- const disco = JSON.parse(issuer.discoveryJson) as { jwks_uri: string };
160
- expect(disco.jwks_uri).toBe('https://harness.example/.well-known/jwks.json');
161
- });
162
- });
163
-
164
- describe('oidc-issuer: mint defaults', () => {
165
- it('fills iss / aud / iat / exp when not supplied', () => {
166
- const issuer = createSyntheticOIDCIssuer({
167
- issuer: 'https://harness.example',
168
- audience: 'openwop',
169
- });
170
- const before = Math.floor(Date.now() / 1000);
171
- const { claims } = issuer.mint({ sub: 'test-sub' });
172
- const after = Math.floor(Date.now() / 1000);
173
-
174
- expect(claims.iss).toBe('https://harness.example');
175
- expect(claims.aud).toBe('openwop');
176
- expect(claims.sub).toBe('test-sub');
177
- expect(typeof claims.iat).toBe('number');
178
- expect(typeof claims.exp).toBe('number');
179
- expect(claims.iat).toBeGreaterThanOrEqual(before);
180
- expect(claims.iat).toBeLessThanOrEqual(after);
181
- // Default lifetime is 300s.
182
- expect((claims.exp as number) - (claims.iat as number)).toBe(300);
183
- });
184
-
185
- it('caller claims override defaults', () => {
186
- const issuer = createSyntheticOIDCIssuer({
187
- issuer: 'https://harness.example',
188
- audience: 'openwop',
189
- });
190
- const { claims } = issuer.mint({
191
- iss: 'override-issuer',
192
- aud: 'override-audience',
193
- sub: 'test-sub',
194
- });
195
- expect(claims.iss).toBe('override-issuer');
196
- expect(claims.aud).toBe('override-audience');
197
- });
198
-
199
- it('negative expiresInSeconds mints already-expired token', () => {
200
- const issuer = createSyntheticOIDCIssuer({
201
- issuer: 'https://harness.example',
202
- audience: 'openwop',
203
- });
204
- const now = Math.floor(Date.now() / 1000);
205
- const { claims } = issuer.mint(
206
- { sub: 'test-sub' },
207
- { expiresInSeconds: -3600 },
208
- );
209
- expect((claims.exp as number) < now).toBe(true);
210
- });
211
- });
212
-
213
- describe('oidc-issuer: signature round-trip', () => {
214
- it('RS256 token verifies against published JWKS', () => {
215
- const issuer = createSyntheticOIDCIssuer({
216
- issuer: 'https://harness.example',
217
- audience: 'openwop',
218
- algorithm: 'RS256',
219
- });
220
- const { token } = issuer.mint({ sub: 'test-sub' });
221
- const verified = verifyToken(token, issuer.jwksJson, 'RS256');
222
- expect(verified).toBe(true);
223
- });
224
-
225
- it('ES256 token verifies against published JWKS', () => {
226
- const issuer = createSyntheticOIDCIssuer({
227
- issuer: 'https://harness.example',
228
- audience: 'openwop',
229
- algorithm: 'ES256',
230
- });
231
- const { token } = issuer.mint({ sub: 'test-sub' });
232
- const verified = verifyToken(token, issuer.jwksJson, 'ES256');
233
- expect(verified).toBe(true);
234
- });
235
-
236
- it('header alg matches issuer algorithm by default', () => {
237
- const issuer = createSyntheticOIDCIssuer({
238
- issuer: 'https://harness.example',
239
- audience: 'openwop',
240
- algorithm: 'ES256',
241
- });
242
- const { token } = issuer.mint({ sub: 'test-sub' });
243
- const decoded = decodeJwt(token);
244
- expect(decoded.header.alg).toBe('ES256');
245
- });
246
-
247
- it('mint opts.algorithm override appears in header (alg-spoof scenario)', () => {
248
- const issuer = createSyntheticOIDCIssuer({
249
- issuer: 'https://harness.example',
250
- audience: 'openwop',
251
- algorithm: 'RS256',
252
- });
253
- const { token } = issuer.mint(
254
- { sub: 'test-sub' },
255
- { algorithm: 'HS256' },
256
- );
257
- const decoded = decodeJwt(token);
258
- expect(decoded.header.alg).toBe('HS256');
259
- // The signature is still RS256-bytes (the harness doesn't actually
260
- // honor the alg override for the signature itself — that's the spoof:
261
- // the header lies, the bytes don't match). Verification with RS256
262
- // succeeds, which is the test scenario's correct behavior: it lets
263
- // the OAuth2-CC negative-case scenario assert the host rejects
264
- // because the header claims HS256 outside supportedAlgorithms.
265
- const verified = verifyToken(
266
- // Pull alg from header for verification — but the verify path
267
- // is RS256 because that's the actual key. Re-decode and verify
268
- // by extracting the alg-from-issuer rather than alg-from-header.
269
- token,
270
- issuer.jwksJson,
271
- 'RS256',
272
- );
273
- expect(verified).toBe(true);
274
- });
275
-
276
- it('keyId override sets header.kid without changing signing key (unknown-kid scenario)', () => {
277
- const issuer = createSyntheticOIDCIssuer({
278
- issuer: 'https://harness.example',
279
- audience: 'openwop',
280
- });
281
- const { token } = issuer.mint(
282
- { sub: 'test-sub' },
283
- { keyId: 'never-published-kid' },
284
- );
285
- const decoded = decodeJwt(token);
286
- expect(decoded.header.kid).toBe('never-published-kid');
287
- // The JWKS doesn't publish this kid; verifyToken throws.
288
- expect(() => verifyToken(token, issuer.jwksJson, 'RS256')).toThrow(/no JWKS key/);
289
- });
290
- });
291
-
292
- describe('oidc-issuer: key rotation', () => {
293
- it('rotateKey() changes the published keyId', () => {
294
- const issuer = createSyntheticOIDCIssuer({
295
- issuer: 'https://harness.example',
296
- audience: 'openwop',
297
- });
298
- const firstKid = issuer.keyId;
299
- issuer.rotateKey();
300
- expect(issuer.keyId).not.toBe(firstKid);
301
- expect(issuer.keyId).toBe('openwop-conformance-key-2');
302
- });
303
-
304
- it('tokens minted before rotation no longer verify against new JWKS', () => {
305
- const issuer = createSyntheticOIDCIssuer({
306
- issuer: 'https://harness.example',
307
- audience: 'openwop',
308
- });
309
- const beforeRotation = issuer.mint({ sub: 'test-sub' });
310
- issuer.rotateKey();
311
- // The JWKS now publishes a different key. The old token's header
312
- // kid still references the pre-rotation kid, which isn't published.
313
- expect(() =>
314
- verifyToken(beforeRotation.token, issuer.jwksJson, 'RS256'),
315
- ).toThrow(/no JWKS key/);
316
- });
317
-
318
- it('tokens minted after rotation verify against new JWKS', () => {
319
- const issuer = createSyntheticOIDCIssuer({
320
- issuer: 'https://harness.example',
321
- audience: 'openwop',
322
- });
323
- issuer.rotateKey();
324
- const afterRotation = issuer.mint({ sub: 'test-sub' });
325
- const verified = verifyToken(afterRotation.token, issuer.jwksJson, 'RS256');
326
- expect(verified).toBe(true);
327
- });
328
- });
@@ -1,191 +0,0 @@
1
- /**
2
- * End-to-end OTLP/gRPC collector tests — Track 11.
3
- *
4
- * Boots an `OtelCollector` with `startGrpc()`, sends a hand-rolled
5
- * OTLP trace request over h2c HTTP/2 with gRPC framing, and asserts
6
- * the collector captured the span. Validates that the framing
7
- * primitive + protobuf decoder + ingest pipeline compose end-to-end.
8
- *
9
- * @see otel-collector.ts §_handleGrpcStream
10
- * @see grpc-framing.ts
11
- * @see otlp-protobuf.ts
12
- */
13
-
14
- import { afterEach, beforeEach, describe, it, expect } from 'vitest';
15
- import { connect, type ClientHttp2Session } from 'node:http2';
16
- import { OtelCollector } from './otel-collector.js';
17
- import { frameMessage, unframeMessages } from './grpc-framing.js';
18
-
19
- // ─── Minimal OTLP/protobuf encoder ────────────────────────────────────────
20
- // Inlined rather than imported from `otlp-protobuf.test.ts` so this file
21
- // stays self-contained. Same builder shape; smaller surface (just what
22
- // the e2e test needs).
23
-
24
- const WIRE_LEN = 2;
25
- const WIRE_I64 = 1;
26
-
27
- class PbWriter {
28
- private readonly chunks: number[] = [];
29
- bytes(): Uint8Array {
30
- return new Uint8Array(this.chunks);
31
- }
32
- private writeVarint(v: number): void {
33
- let n = v >>> 0;
34
- while (n >= 0x80) {
35
- this.chunks.push((n & 0x7f) | 0x80);
36
- n = n >>> 7;
37
- }
38
- this.chunks.push(n & 0x7f);
39
- }
40
- writeTag(fieldNumber: number, wireType: number): void {
41
- this.writeVarint((fieldNumber << 3) | wireType);
42
- }
43
- writeString(fieldNumber: number, s: string): void {
44
- const enc = new TextEncoder().encode(s);
45
- this.writeTag(fieldNumber, WIRE_LEN);
46
- this.writeVarint(enc.length);
47
- for (const b of enc) this.chunks.push(b);
48
- }
49
- writeBytesHex(fieldNumber: number, hex: string): void {
50
- const bytes = new Uint8Array(hex.length / 2);
51
- for (let i = 0; i < bytes.length; i++) {
52
- bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
53
- }
54
- this.writeTag(fieldNumber, WIRE_LEN);
55
- this.writeVarint(bytes.length);
56
- for (const b of bytes) this.chunks.push(b);
57
- }
58
- writeFixed64(fieldNumber: number, v: bigint): void {
59
- this.writeTag(fieldNumber, WIRE_I64);
60
- let big = v;
61
- for (let i = 0; i < 8; i++) {
62
- this.chunks.push(Number(big & 0xffn));
63
- big = big >> 8n;
64
- }
65
- }
66
- writeMessage(fieldNumber: number, body: Uint8Array): void {
67
- this.writeTag(fieldNumber, WIRE_LEN);
68
- this.writeVarint(body.length);
69
- for (const b of body) this.chunks.push(b);
70
- }
71
- }
72
-
73
- function buildSpan(traceId: string, spanId: string, name: string): Uint8Array {
74
- const w = new PbWriter();
75
- w.writeBytesHex(1, traceId);
76
- w.writeBytesHex(2, spanId);
77
- w.writeString(5, name);
78
- w.writeFixed64(7, BigInt(1700000000) * 1_000_000_000n);
79
- w.writeFixed64(8, BigInt(1700000001) * 1_000_000_000n);
80
- return w.bytes();
81
- }
82
-
83
- function buildExportTrace(spanBytes: Uint8Array): Uint8Array {
84
- // ResourceSpans → ScopeSpans (field 2) → Span (field 2)
85
- const scopeSpans = new PbWriter();
86
- scopeSpans.writeMessage(2, spanBytes);
87
- const resourceSpans = new PbWriter();
88
- resourceSpans.writeMessage(2, scopeSpans.bytes());
89
- const root = new PbWriter();
90
- root.writeMessage(1, resourceSpans.bytes()); // ExportTraceServiceRequest.resource_spans
91
- return root.bytes();
92
- }
93
-
94
- // ─── Test fixture ─────────────────────────────────────────────────────────
95
-
96
- describe('otel-collector OTLP/gRPC: end-to-end capture', () => {
97
- let collector: OtelCollector;
98
-
99
- beforeEach(async () => {
100
- collector = new OtelCollector();
101
- await collector.startGrpc(0);
102
- });
103
-
104
- afterEach(async () => {
105
- await collector.stopGrpc();
106
- });
107
-
108
- it('captures a span sent over gRPC framing', async () => {
109
- const TRACE_ID = '0102030405060708090a0b0c0d0e0f10';
110
- const SPAN_ID = '1112131415161718';
111
- const SPAN_NAME = 'openwop.run';
112
-
113
- const span = buildSpan(TRACE_ID, SPAN_ID, SPAN_NAME);
114
- const exportReq = buildExportTrace(span);
115
- const framed = frameMessage(exportReq);
116
-
117
- const session: ClientHttp2Session = connect(collector.grpcEndpoint());
118
- try {
119
- await new Promise<void>((resolve, reject) => {
120
- const req = session.request({
121
- ':method': 'POST',
122
- ':path': '/opentelemetry.proto.collector.trace.v1.TraceService/Export',
123
- 'content-type': 'application/grpc+proto',
124
- te: 'trailers',
125
- });
126
- let respStatus = '';
127
- let trailerStatus = '';
128
- const chunks: Buffer[] = [];
129
- req.on('response', (headers) => {
130
- respStatus = String(headers[':status'] ?? '');
131
- });
132
- req.on('trailers', (trailers) => {
133
- trailerStatus = String(trailers['grpc-status'] ?? '');
134
- });
135
- req.on('data', (c: Buffer) => chunks.push(c));
136
- req.on('end', () => {
137
- try {
138
- expect(respStatus).toBe('200');
139
- expect(trailerStatus).toBe('0');
140
- // Response body MUST be a 5-byte frame with a zero-length payload.
141
- const respBody = Buffer.concat(chunks);
142
- const unframed = unframeMessages(
143
- new Uint8Array(respBody.buffer, respBody.byteOffset, respBody.byteLength),
144
- );
145
- expect(unframed.length).toBe(1);
146
- expect(unframed[0]!.byteLength).toBe(0);
147
- resolve();
148
- } catch (err) {
149
- reject(err);
150
- }
151
- });
152
- req.on('error', reject);
153
- req.end(Buffer.from(framed));
154
- });
155
- } finally {
156
- session.close();
157
- }
158
-
159
- // Collector captured the span exactly once.
160
- const spans = collector.spans();
161
- expect(spans.length).toBe(1);
162
- expect(spans[0]!.name).toBe(SPAN_NAME);
163
- expect(spans[0]!.traceId).toBe(TRACE_ID);
164
- expect(spans[0]!.spanId).toBe(SPAN_ID);
165
- });
166
-
167
- it('returns INVALID_ARGUMENT trailer for unsupported content-type', async () => {
168
- const session: ClientHttp2Session = connect(collector.grpcEndpoint());
169
- try {
170
- await new Promise<void>((resolve, reject) => {
171
- const req = session.request({
172
- ':method': 'POST',
173
- ':path': '/opentelemetry.proto.collector.trace.v1.TraceService/Export',
174
- 'content-type': 'text/plain',
175
- });
176
- req.on('response', (headers) => {
177
- try {
178
- expect(headers['grpc-status']).toBe('3'); // INVALID_ARGUMENT
179
- resolve();
180
- } catch (err) {
181
- reject(err);
182
- }
183
- });
184
- req.on('error', reject);
185
- req.end();
186
- });
187
- } finally {
188
- session.close();
189
- }
190
- });
191
- });