@openwop/openwop-conformance 1.0.0 → 1.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.
- package/CHANGELOG.md +17 -0
- package/README.md +31 -6
- package/api/grpc/openwop.proto +251 -0
- package/api/openapi.yaml +109 -3
- package/coverage.md +48 -9
- package/fixtures/conformance-configurable-schema.json +39 -0
- package/fixtures/conformance-subworkflow-parent.json +1 -1
- package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
- package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
- package/fixtures.md +21 -0
- package/package.json +3 -1
- package/schemas/README.md +4 -0
- package/schemas/audit-verify-result.schema.json +90 -0
- package/schemas/capabilities.schema.json +293 -1
- package/schemas/node-pack-manifest.schema.json +4 -4
- package/schemas/pack-lockfile.schema.json +92 -0
- package/schemas/registry-version-manifest.schema.json +145 -0
- package/schemas/run-event-payloads.schema.json +2 -2
- package/schemas/security-advisory.schema.json +109 -0
- package/src/lib/a2a-fake-peer.ts +143 -56
- package/src/lib/behavior-gate.ts +68 -0
- package/src/lib/env.ts +10 -0
- package/src/lib/grpc-framing.test.ts +96 -0
- package/src/lib/grpc-framing.ts +76 -0
- package/src/lib/oidc-issuer.test.ts +328 -0
- package/src/lib/oidc-issuer.ts +241 -0
- package/src/lib/otel-collector-grpc.test.ts +191 -0
- package/src/lib/otel-collector.test.ts +303 -0
- package/src/lib/otel-collector.ts +318 -14
- package/src/lib/otlp-protobuf.test.ts +461 -0
- package/src/lib/otlp-protobuf.ts +529 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
- package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
- package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
- package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
- package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
- package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
- package/src/scenarios/agentMessageReducer.test.ts +1 -0
- package/src/scenarios/agentMetadata.test.ts +1 -0
- package/src/scenarios/agentPackExport.test.ts +1 -0
- package/src/scenarios/agentPackInstall.test.ts +1 -0
- package/src/scenarios/agentPackProvenance.test.ts +1 -0
- package/src/scenarios/audit-log-integrity.test.ts +3 -6
- package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
- package/src/scenarios/auth-mtls.test.ts +274 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
- package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
- package/src/scenarios/bulk-cancel.test.ts +111 -0
- package/src/scenarios/configurable-schema.test.ts +48 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
- package/src/scenarios/conversationLifecycle.test.ts +1 -0
- package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
- package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
- package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
- package/src/scenarios/discovery.test.ts +183 -0
- package/src/scenarios/http-client-ssrf.test.ts +71 -0
- package/src/scenarios/idempotency.test.ts +6 -0
- package/src/scenarios/idempotencyRetry.test.ts +3 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +198 -34
- package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
- package/src/scenarios/metric-emission.test.ts +113 -0
- package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
- package/src/scenarios/orchestratorDispatch.test.ts +1 -0
- package/src/scenarios/orchestratorTermination.test.ts +1 -0
- package/src/scenarios/otel-emission-grpc.test.ts +98 -0
- package/src/scenarios/pause-resume.test.ts +119 -0
- package/src/scenarios/production-backpressure.test.ts +342 -0
- package/src/scenarios/production-retention-expiry.test.ts +164 -0
- package/src/scenarios/registry-public.test.ts +131 -0
- package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
- package/src/scenarios/replay-retention-expiry.test.ts +178 -0
- package/src/scenarios/restart-during-run.test.ts +177 -0
- package/src/scenarios/spec-corpus-validity.test.ts +54 -26
- package/src/scenarios/staleClaim.test.ts +3 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
- package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
- package/src/scenarios/webhook-negative.test.ts +90 -0
- package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
- package/src/setup.ts +25 -1
- package/vitest.config.ts +5 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic OIDC issuer for conformance scenarios.
|
|
3
|
+
*
|
|
4
|
+
* Implements the harness specified in RFC 0010 §E. Mints signed JWTs
|
|
5
|
+
* (RS256 or ES256) and exposes the JWKS + OIDC discovery document a
|
|
6
|
+
* trusting host fetches to verify them. Hermetic — uses only node:crypto
|
|
7
|
+
* stdlib; no npm dependencies.
|
|
8
|
+
*
|
|
9
|
+
* Scope: this harness is a wire-shape probe. It is NOT a real OIDC
|
|
10
|
+
* provider — there is no authorization endpoint, no userinfo endpoint,
|
|
11
|
+
* no refresh-token machinery. The conformance suite uses it to mint
|
|
12
|
+
* tokens with controlled claims (valid sub, wrong aud, expired exp,
|
|
13
|
+
* unknown kid, etc.) and assert the host's validation behavior.
|
|
14
|
+
*
|
|
15
|
+
* @see RFCS/0010-auth-profile-conformance.md §E
|
|
16
|
+
* @see spec/v1/auth-profiles.md §`openwop-auth-oidc-user-bearer`
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
createSign,
|
|
21
|
+
generateKeyPairSync,
|
|
22
|
+
type KeyObject,
|
|
23
|
+
} from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
export type JwsAlgorithm = 'RS256' | 'ES256';
|
|
26
|
+
|
|
27
|
+
export interface SyntheticOIDCIssuerOptions {
|
|
28
|
+
/** Base issuer URL. Caller is responsible for binding an HTTP server
|
|
29
|
+
* at this URL if end-to-end host validation is required. The harness
|
|
30
|
+
* itself does not bind any port. */
|
|
31
|
+
readonly issuer: string;
|
|
32
|
+
/** Default audience used by `mint()` when claims don't supply one. */
|
|
33
|
+
readonly audience: string;
|
|
34
|
+
/** JWS algorithm. Default RS256 (widest interop). Accepts `string` so
|
|
35
|
+
* conformance tests can exercise the runtime rejection path for
|
|
36
|
+
* unsupported algorithms; the constructor validates at runtime. */
|
|
37
|
+
readonly algorithm?: JwsAlgorithm | (string & {});
|
|
38
|
+
/** Initial key id published in JWKS. Default `openwop-conformance-key-1`. */
|
|
39
|
+
readonly keyId?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MintOptions {
|
|
43
|
+
/** Override the JWT lifetime. Default 300 seconds. Set < 0 to mint
|
|
44
|
+
* an already-expired token. */
|
|
45
|
+
readonly expiresInSeconds?: number;
|
|
46
|
+
/** Override the `kid` placed in the JWT header. Defaults to the
|
|
47
|
+
* issuer's current `keyId`. Setting to a value not published in the
|
|
48
|
+
* issuer's JWKS produces a token that signature-verifies internally
|
|
49
|
+
* but is rejected by hosts because the kid cannot be resolved. */
|
|
50
|
+
readonly keyId?: string;
|
|
51
|
+
/** Override the JWS algorithm header (`alg`). Defaults to the
|
|
52
|
+
* issuer's algorithm. Setting to a value not in the host's
|
|
53
|
+
* `supportedAlgorithms` produces an algorithm-rejected token. */
|
|
54
|
+
readonly algorithm?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MintedToken {
|
|
58
|
+
/** Compact-serialized JWT (`<header>.<payload>.<signature>`). */
|
|
59
|
+
readonly token: string;
|
|
60
|
+
/** The fully-resolved claim set that was signed. */
|
|
61
|
+
readonly claims: Readonly<Record<string, unknown>>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SyntheticOIDCIssuer {
|
|
65
|
+
readonly issuer: string;
|
|
66
|
+
readonly audience: string;
|
|
67
|
+
readonly algorithm: JwsAlgorithm;
|
|
68
|
+
/** Current key id (used by default in `mint()` and published in JWKS). */
|
|
69
|
+
readonly keyId: string;
|
|
70
|
+
/** JWKS document the host fetches at `$issuer/.well-known/jwks.json`. */
|
|
71
|
+
readonly jwksJson: string;
|
|
72
|
+
/** OIDC discovery document the host fetches at
|
|
73
|
+
* `$issuer/.well-known/openid-configuration`. */
|
|
74
|
+
readonly discoveryJson: string;
|
|
75
|
+
|
|
76
|
+
/** Mint a signed JWT with the supplied claims. Claims not supplied
|
|
77
|
+
* are filled with defaults: `iss` (this issuer), `aud` (this
|
|
78
|
+
* audience), `iat` (now), `exp` (now + 300s). Pass `iss`/`aud`/`exp`
|
|
79
|
+
* explicitly to override. */
|
|
80
|
+
mint(claims: Readonly<Record<string, unknown>>, opts?: MintOptions): MintedToken;
|
|
81
|
+
|
|
82
|
+
/** Replace the current keypair with a freshly-generated one and
|
|
83
|
+
* advance the `keyId`. Tokens minted before rotation are no longer
|
|
84
|
+
* verifiable against the published JWKS — the underlying key is
|
|
85
|
+
* discarded. Use to model post-rotation revocation in tests. */
|
|
86
|
+
rotateKey(): void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface KeyMaterial {
|
|
90
|
+
readonly keyId: string;
|
|
91
|
+
readonly publicKey: KeyObject;
|
|
92
|
+
readonly privateKey: KeyObject;
|
|
93
|
+
/** Cached JWK form of the public key (with `kid`/`alg`/`use` mixed in). */
|
|
94
|
+
readonly publicJwk: Record<string, unknown>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function base64UrlEncode(input: Buffer | string): string {
|
|
98
|
+
const buf = typeof input === 'string' ? Buffer.from(input, 'utf8') : input;
|
|
99
|
+
return buf
|
|
100
|
+
.toString('base64')
|
|
101
|
+
.replace(/\+/g, '-')
|
|
102
|
+
.replace(/\//g, '_')
|
|
103
|
+
.replace(/=+$/, '');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isJwsAlgorithm(x: string): x is JwsAlgorithm {
|
|
107
|
+
return x === 'RS256' || x === 'ES256';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function generateKeyMaterial(algorithm: JwsAlgorithm, keyId: string): KeyMaterial {
|
|
111
|
+
const { publicKey, privateKey } =
|
|
112
|
+
algorithm === 'RS256'
|
|
113
|
+
? generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
114
|
+
: generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
|
115
|
+
|
|
116
|
+
// node:crypto exports public keys directly to JWK form (Node ≥ 16).
|
|
117
|
+
// The return type is `JsonWebKey` from the global DOM lib; we widen
|
|
118
|
+
// to a structural record so we can spread + mix in OIDC-flavored
|
|
119
|
+
// fields (`kid`, `alg`, `use`) without further assertions.
|
|
120
|
+
const baseJwk: Record<string, unknown> = publicKey.export({ format: 'jwk' });
|
|
121
|
+
const publicJwk: Record<string, unknown> = {
|
|
122
|
+
...baseJwk,
|
|
123
|
+
alg: algorithm,
|
|
124
|
+
use: 'sig',
|
|
125
|
+
kid: keyId,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return { keyId, publicKey, privateKey, publicJwk };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function signCompact(
|
|
132
|
+
algorithm: JwsAlgorithm,
|
|
133
|
+
privateKey: KeyObject,
|
|
134
|
+
signingInput: string,
|
|
135
|
+
): string {
|
|
136
|
+
// Node's createSign uses the algorithm name to pick the digest:
|
|
137
|
+
// RSA-SHA256 → RSASSA-PKCS1-v1_5 with SHA-256 (RS256)
|
|
138
|
+
// SHA256 → ECDSA with SHA-256 (ES256)
|
|
139
|
+
// For ES256, JWS REQUIRES the IEEE P1363 (R||S) signature format,
|
|
140
|
+
// not the default DER. Node ≥ 17 supports `dsaEncoding: 'ieee-p1363'`
|
|
141
|
+
// on createSign to produce P1363 directly.
|
|
142
|
+
const digest = algorithm === 'RS256' ? 'RSA-SHA256' : 'SHA256';
|
|
143
|
+
const signer = createSign(digest);
|
|
144
|
+
signer.update(signingInput);
|
|
145
|
+
signer.end();
|
|
146
|
+
const signature = signer.sign(
|
|
147
|
+
algorithm === 'RS256'
|
|
148
|
+
? privateKey
|
|
149
|
+
: { key: privateKey, dsaEncoding: 'ieee-p1363' },
|
|
150
|
+
);
|
|
151
|
+
return base64UrlEncode(signature);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createSyntheticOIDCIssuer(
|
|
155
|
+
opts: SyntheticOIDCIssuerOptions,
|
|
156
|
+
): SyntheticOIDCIssuer {
|
|
157
|
+
const requested = opts.algorithm ?? 'RS256';
|
|
158
|
+
if (!isJwsAlgorithm(requested)) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`[oidc-issuer] unsupported algorithm: ${String(requested)} (only RS256 and ES256 are supported)`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
const algorithm: JwsAlgorithm = requested;
|
|
164
|
+
|
|
165
|
+
if (!opts.issuer || !opts.audience) {
|
|
166
|
+
throw new Error('[oidc-issuer] issuer and audience are required');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let rotationCounter = 1;
|
|
170
|
+
let material = generateKeyMaterial(
|
|
171
|
+
algorithm,
|
|
172
|
+
opts.keyId ?? `openwop-conformance-key-${rotationCounter}`,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
get issuer() {
|
|
177
|
+
return opts.issuer;
|
|
178
|
+
},
|
|
179
|
+
get audience() {
|
|
180
|
+
return opts.audience;
|
|
181
|
+
},
|
|
182
|
+
get algorithm() {
|
|
183
|
+
return algorithm;
|
|
184
|
+
},
|
|
185
|
+
get keyId() {
|
|
186
|
+
return material.keyId;
|
|
187
|
+
},
|
|
188
|
+
get jwksJson() {
|
|
189
|
+
return JSON.stringify({ keys: [material.publicJwk] });
|
|
190
|
+
},
|
|
191
|
+
get discoveryJson() {
|
|
192
|
+
return JSON.stringify({
|
|
193
|
+
issuer: opts.issuer,
|
|
194
|
+
jwks_uri: `${opts.issuer.replace(/\/$/, '')}/.well-known/jwks.json`,
|
|
195
|
+
response_types_supported: ['id_token'],
|
|
196
|
+
subject_types_supported: ['public'],
|
|
197
|
+
id_token_signing_alg_values_supported: [algorithm],
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
mint(
|
|
202
|
+
claims: Readonly<Record<string, unknown>>,
|
|
203
|
+
mintOpts: MintOptions = {},
|
|
204
|
+
): MintedToken {
|
|
205
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
206
|
+
const expiresInSeconds = mintOpts.expiresInSeconds ?? 300;
|
|
207
|
+
|
|
208
|
+
const resolvedClaims: Record<string, unknown> = {
|
|
209
|
+
iss: opts.issuer,
|
|
210
|
+
aud: opts.audience,
|
|
211
|
+
iat: nowSeconds,
|
|
212
|
+
exp: nowSeconds + expiresInSeconds,
|
|
213
|
+
...claims, // caller's claims win on collision (sub, aud override, etc.)
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const header = {
|
|
217
|
+
alg: mintOpts.algorithm ?? algorithm,
|
|
218
|
+
typ: 'JWT',
|
|
219
|
+
kid: mintOpts.keyId ?? material.keyId,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const headerB64 = base64UrlEncode(JSON.stringify(header));
|
|
223
|
+
const payloadB64 = base64UrlEncode(JSON.stringify(resolvedClaims));
|
|
224
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
225
|
+
const signatureB64 = signCompact(algorithm, material.privateKey, signingInput);
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
token: `${signingInput}.${signatureB64}`,
|
|
229
|
+
claims: resolvedClaims,
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
rotateKey(): void {
|
|
234
|
+
rotationCounter += 1;
|
|
235
|
+
material = generateKeyMaterial(
|
|
236
|
+
algorithm,
|
|
237
|
+
`openwop-conformance-key-${rotationCounter}`,
|
|
238
|
+
);
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
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
|
+
});
|