@openwop/openwop-conformance 1.0.0 → 1.1.1

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 (86) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +31 -6
  3. package/api/grpc/openwop.proto +251 -0
  4. package/api/openapi.yaml +109 -3
  5. package/coverage.md +48 -9
  6. package/fixtures/conformance-configurable-schema.json +39 -0
  7. package/fixtures/conformance-subworkflow-parent.json +1 -1
  8. package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
  9. package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
  10. package/fixtures.md +21 -0
  11. package/package.json +3 -1
  12. package/schemas/README.md +4 -0
  13. package/schemas/audit-verify-result.schema.json +90 -0
  14. package/schemas/capabilities.schema.json +342 -1
  15. package/schemas/node-pack-manifest.schema.json +4 -4
  16. package/schemas/pack-lockfile.schema.json +92 -0
  17. package/schemas/registry-version-manifest.schema.json +145 -0
  18. package/schemas/run-event-payloads.schema.json +20 -4
  19. package/schemas/run-event.schema.json +2 -1
  20. package/schemas/security-advisory.schema.json +109 -0
  21. package/src/lib/a2a-fake-peer.ts +143 -56
  22. package/src/lib/behavior-gate.ts +107 -0
  23. package/src/lib/env.ts +37 -0
  24. package/src/lib/grpc-framing.test.ts +96 -0
  25. package/src/lib/grpc-framing.ts +76 -0
  26. package/src/lib/oidc-issuer.test.ts +328 -0
  27. package/src/lib/oidc-issuer.ts +241 -0
  28. package/src/lib/otel-collector-grpc.test.ts +191 -0
  29. package/src/lib/otel-collector.test.ts +303 -0
  30. package/src/lib/otel-collector.ts +318 -14
  31. package/src/lib/otlp-protobuf.test.ts +461 -0
  32. package/src/lib/otlp-protobuf.ts +529 -0
  33. package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
  34. package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
  35. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
  36. package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
  37. package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
  38. package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
  39. package/src/scenarios/agentMessageReducer.test.ts +1 -0
  40. package/src/scenarios/agentMetadata.test.ts +1 -0
  41. package/src/scenarios/agentPackExport.test.ts +1 -0
  42. package/src/scenarios/agentPackInstall.test.ts +1 -0
  43. package/src/scenarios/agentPackProvenance.test.ts +1 -0
  44. package/src/scenarios/audit-log-integrity.test.ts +3 -6
  45. package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
  46. package/src/scenarios/auth-mtls.test.ts +274 -0
  47. package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
  48. package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
  49. package/src/scenarios/bulk-cancel.test.ts +111 -0
  50. package/src/scenarios/configurable-schema.test.ts +48 -0
  51. package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
  52. package/src/scenarios/conversationLifecycle.test.ts +1 -0
  53. package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
  54. package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
  55. package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
  56. package/src/scenarios/discovery.test.ts +183 -0
  57. package/src/scenarios/http-client-ssrf.test.ts +71 -0
  58. package/src/scenarios/idempotency.test.ts +6 -0
  59. package/src/scenarios/idempotencyRetry.test.ts +3 -0
  60. package/src/scenarios/mcp-tool-roundtrip.test.ts +205 -34
  61. package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
  62. package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
  63. package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
  64. package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
  65. package/src/scenarios/metric-emission.test.ts +113 -0
  66. package/src/scenarios/multi-region-idempotency.test.ts +39 -4
  67. package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
  68. package/src/scenarios/orchestratorDispatch.test.ts +1 -0
  69. package/src/scenarios/orchestratorTermination.test.ts +1 -0
  70. package/src/scenarios/otel-emission-grpc.test.ts +98 -0
  71. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
  72. package/src/scenarios/pause-resume.test.ts +119 -0
  73. package/src/scenarios/production-backpressure.test.ts +342 -0
  74. package/src/scenarios/production-retention-expiry.test.ts +164 -0
  75. package/src/scenarios/registry-public.test.ts +222 -0
  76. package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
  77. package/src/scenarios/replay-retention-expiry.test.ts +178 -0
  78. package/src/scenarios/restart-during-run.test.ts +177 -0
  79. package/src/scenarios/spec-corpus-validity.test.ts +59 -26
  80. package/src/scenarios/staleClaim.test.ts +3 -0
  81. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
  82. package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
  83. package/src/scenarios/webhook-negative.test.ts +90 -0
  84. package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
  85. package/src/setup.ts +25 -1
  86. package/vitest.config.ts +5 -1
@@ -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
+ });
@@ -0,0 +1,303 @@
1
+ /**
2
+ * End-to-end unit tests for the OTel collector's HTTP receiver.
3
+ *
4
+ * Boots the collector on an ephemeral port, posts synthesized OTLP
5
+ * payloads (both JSON and protobuf), and asserts the collector
6
+ * correctly captures them. Closes the gap the senior code-review pass
7
+ * flagged as MEDIUM-3 — the protobuf decoder has 18 unit tests, but
8
+ * those don't exercise the collector's HTTP receive wiring
9
+ * (content-type routing, body-size guard, error responses).
10
+ *
11
+ * Server-free (binds to 127.0.0.1 on an ephemeral port; no host required).
12
+ *
13
+ * @see conformance/src/lib/otel-collector.ts
14
+ * @see conformance/src/lib/otlp-protobuf.ts
15
+ */
16
+
17
+ import { afterAll, beforeAll, describe, it, expect } from 'vitest';
18
+ import { OtelCollector } from './otel-collector.js';
19
+
20
+ // ─── Minimal in-test OTLP/protobuf encoder ─────────────────────────────────
21
+ // Hand-rolled so the e2e test doesn't depend on the decoder's own test file
22
+ // re-exporting its writer. ~50 LOC; only encodes the wire-format subset this
23
+ // file actually emits.
24
+
25
+ const WIRE_I64 = 1;
26
+ const WIRE_LEN = 2;
27
+
28
+ function encVarint(out: number[], v: number | bigint): void {
29
+ let x = typeof v === 'bigint' ? v : BigInt(v);
30
+ while (x >= 0x80n) {
31
+ out.push(Number(x & 0x7fn) | 0x80);
32
+ x >>= 7n;
33
+ }
34
+ out.push(Number(x & 0x7fn));
35
+ }
36
+
37
+ function encTag(out: number[], field: number, wire: number): void {
38
+ encVarint(out, (field << 3) | wire);
39
+ }
40
+
41
+ function encString(out: number[], field: number, s: string): void {
42
+ const bytes = new TextEncoder().encode(s);
43
+ encTag(out, field, WIRE_LEN);
44
+ encVarint(out, bytes.length);
45
+ for (const b of bytes) out.push(b);
46
+ }
47
+
48
+ function encBytesHex(out: number[], field: number, hex: string): void {
49
+ const len = hex.length / 2;
50
+ encTag(out, field, WIRE_LEN);
51
+ encVarint(out, len);
52
+ for (let i = 0; i < len; i++) {
53
+ out.push(Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16));
54
+ }
55
+ }
56
+
57
+ function encFixed64(out: number[], field: number, v: bigint): void {
58
+ encTag(out, field, WIRE_I64);
59
+ const buf = new ArrayBuffer(8);
60
+ new DataView(buf).setBigUint64(0, v, true);
61
+ for (let i = 0; i < 8; i++) out.push(new Uint8Array(buf)[i]);
62
+ }
63
+
64
+ function encMessage(out: number[], field: number, body: number[]): void {
65
+ encTag(out, field, WIRE_LEN);
66
+ encVarint(out, body.length);
67
+ for (const b of body) out.push(b);
68
+ }
69
+
70
+ function buildMinimalProtobufExportTrace(spanName: string, traceIdHex: string, runIdAttr: string): Uint8Array {
71
+ // KeyValue { key: "openwop.run_id", value: { stringValue: runIdAttr } }
72
+ const anyValue: number[] = [];
73
+ encString(anyValue, 1, runIdAttr);
74
+ const kv: number[] = [];
75
+ encString(kv, 1, 'openwop.run_id');
76
+ encMessage(kv, 2, anyValue);
77
+
78
+ // Span { trace_id, span_id, name, start, end, attributes }
79
+ const span: number[] = [];
80
+ encBytesHex(span, 1, traceIdHex);
81
+ encBytesHex(span, 2, '0123456789abcdef');
82
+ encString(span, 5, spanName);
83
+ encFixed64(span, 7, 1700000000000000000n);
84
+ encFixed64(span, 8, 1700000000100000000n);
85
+ encMessage(span, 9, kv);
86
+
87
+ // ScopeSpans { spans: [span] }
88
+ const scopeSpans: number[] = [];
89
+ encMessage(scopeSpans, 2, span);
90
+
91
+ // ResourceSpans { scope_spans: [scopeSpans] }
92
+ const resourceSpans: number[] = [];
93
+ encMessage(resourceSpans, 2, scopeSpans);
94
+
95
+ // ExportTraceServiceRequest { resource_spans: [resourceSpans] }
96
+ const req: number[] = [];
97
+ encMessage(req, 1, resourceSpans);
98
+
99
+ return new Uint8Array(req);
100
+ }
101
+
102
+ // ─── Test fixture ──────────────────────────────────────────────────────────
103
+
104
+ let collector: OtelCollector;
105
+ let endpoint: string;
106
+
107
+ beforeAll(async () => {
108
+ collector = new OtelCollector();
109
+ await collector.start(0);
110
+ endpoint = collector.endpoint();
111
+ });
112
+
113
+ afterAll(async () => {
114
+ await collector.stop();
115
+ });
116
+
117
+ // ─── Tests ─────────────────────────────────────────────────────────────────
118
+
119
+ describe('OtelCollector: HTTP receiver wiring', () => {
120
+ it('accepts OTLP/HTTP-protobuf POST on /v1/traces and captures spans', async () => {
121
+ collector.reset();
122
+ const body = buildMinimalProtobufExportTrace(
123
+ 'openwop.run',
124
+ '0123456789abcdef0123456789abcdef',
125
+ 'run-pb-e2e',
126
+ );
127
+
128
+ const res = await fetch(`${endpoint}/v1/traces`, {
129
+ method: 'POST',
130
+ headers: { 'Content-Type': 'application/x-protobuf' },
131
+ body,
132
+ });
133
+
134
+ expect(res.status).toBe(200);
135
+
136
+ const captured = collector.spansWithAttribute('openwop.run_id', 'run-pb-e2e');
137
+ expect(captured.length).toBe(1);
138
+ expect(captured[0].name).toBe('openwop.run');
139
+ expect(captured[0].traceId).toBe('0123456789abcdef0123456789abcdef');
140
+ });
141
+
142
+ it('accepts OTLP/HTTP-JSON POST on /v1/traces and captures spans', async () => {
143
+ collector.reset();
144
+ const payload = {
145
+ resourceSpans: [
146
+ {
147
+ resource: { attributes: [] },
148
+ scopeSpans: [
149
+ {
150
+ spans: [
151
+ {
152
+ traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
153
+ spanId: 'bbbbbbbbbbbbbbbb',
154
+ name: 'openwop.run',
155
+ startTimeUnixNano: '1700000000000000000',
156
+ endTimeUnixNano: '1700000000050000000',
157
+ attributes: [
158
+ { key: 'openwop.run_id', value: { stringValue: 'run-json-e2e' } },
159
+ ],
160
+ },
161
+ ],
162
+ },
163
+ ],
164
+ },
165
+ ],
166
+ };
167
+
168
+ const res = await fetch(`${endpoint}/v1/traces`, {
169
+ method: 'POST',
170
+ headers: { 'Content-Type': 'application/json' },
171
+ body: JSON.stringify(payload),
172
+ });
173
+
174
+ expect(res.status).toBe(200);
175
+
176
+ const captured = collector.spansWithAttribute('openwop.run_id', 'run-json-e2e');
177
+ expect(captured.length).toBe(1);
178
+ expect(captured[0].name).toBe('openwop.run');
179
+ });
180
+
181
+ // Note: the collector also accepts "no Content-Type" as JSON for
182
+ // back-compat with non-spec OTLP clients. fetch() can't reproduce
183
+ // that case — Node automatically sets Content-Type when given a body
184
+ // — so the empty-content-type path is exercised only via direct
185
+ // node:http use (out of scope for this e2e suite).
186
+
187
+ it('returns 415 for an unsupported Content-Type', async () => {
188
+ collector.reset();
189
+ const res = await fetch(`${endpoint}/v1/traces`, {
190
+ method: 'POST',
191
+ headers: { 'Content-Type': 'text/csv' },
192
+ body: 'a,b,c\n1,2,3',
193
+ });
194
+
195
+ expect(res.status).toBe(415);
196
+ const body = (await res.json()) as { error?: string; message?: string };
197
+ expect(body.error).toBe('unsupported_media_type');
198
+ expect(body.message).toContain('text/csv');
199
+ expect(collector.spans().length).toBe(0);
200
+ });
201
+
202
+ it('returns 400 for malformed JSON', async () => {
203
+ collector.reset();
204
+ const res = await fetch(`${endpoint}/v1/traces`, {
205
+ method: 'POST',
206
+ headers: { 'Content-Type': 'application/json' },
207
+ body: '{ this is not valid json',
208
+ });
209
+
210
+ expect(res.status).toBe(400);
211
+ const body = (await res.json()) as { error?: string };
212
+ expect(body.error).toBe('invalid_json');
213
+ });
214
+
215
+ it('returns 400 for malformed protobuf', async () => {
216
+ collector.reset();
217
+ // Garbage bytes — first byte is a tag for field 0 (invalid) which the
218
+ // decoder skips, but the second byte is mid-varint with continuation
219
+ // bit set and no follow-up → readVarint throws on unexpected EOF.
220
+ const res = await fetch(`${endpoint}/v1/traces`, {
221
+ method: 'POST',
222
+ headers: { 'Content-Type': 'application/x-protobuf' },
223
+ body: new Uint8Array([0x0a, 0xff]),
224
+ });
225
+
226
+ expect(res.status).toBe(400);
227
+ const body = (await res.json()) as { error?: string };
228
+ expect(body.error).toBe('invalid_protobuf');
229
+ });
230
+
231
+ it('returns 405 for non-POST methods', async () => {
232
+ collector.reset();
233
+ const res = await fetch(`${endpoint}/v1/traces`, { method: 'GET' });
234
+ expect(res.status).toBe(405);
235
+ });
236
+
237
+ it('returns 413 when body exceeds 16 MiB cap', async () => {
238
+ collector.reset();
239
+ // 16 MiB + 1 byte. Use a fresh ArrayBuffer to avoid TypedArray-cap issues.
240
+ const oversize = new Uint8Array(16 * 1024 * 1024 + 1);
241
+ oversize.fill(0x00);
242
+
243
+ const res = await fetch(`${endpoint}/v1/traces`, {
244
+ method: 'POST',
245
+ headers: { 'Content-Type': 'application/x-protobuf' },
246
+ body: oversize,
247
+ });
248
+
249
+ expect(res.status).toBe(413);
250
+ const body = (await res.json()) as { error?: string };
251
+ expect(body.error).toBe('payload_too_large');
252
+ }, 30_000); // larger timeout — uploading 16 MiB to localhost still costs a few hundred ms
253
+
254
+ it('routes /v1/metrics to the metrics ingest path (JSON)', async () => {
255
+ collector.reset();
256
+ const payload = {
257
+ resourceMetrics: [
258
+ {
259
+ scopeMetrics: [
260
+ {
261
+ metrics: [
262
+ {
263
+ name: 'openwop.queue.depth',
264
+ unit: 'count',
265
+ gauge: {
266
+ dataPoints: [
267
+ {
268
+ asDouble: 3,
269
+ attributes: [],
270
+ },
271
+ ],
272
+ },
273
+ },
274
+ ],
275
+ },
276
+ ],
277
+ },
278
+ ],
279
+ };
280
+ const res = await fetch(`${endpoint}/v1/metrics`, {
281
+ method: 'POST',
282
+ headers: { 'Content-Type': 'application/json' },
283
+ body: JSON.stringify(payload),
284
+ });
285
+ expect(res.status).toBe(200);
286
+ const m = collector.metricByName('openwop.queue.depth');
287
+ expect(m).toBeDefined();
288
+ expect(m?.kind).toBe('gauge');
289
+ expect(m?.dataPoint.value).toBe(3);
290
+ });
291
+
292
+ it('200-OKs unknown paths without ingesting (forward-compat for /v1/logs)', async () => {
293
+ collector.reset();
294
+ const res = await fetch(`${endpoint}/v1/logs`, {
295
+ method: 'POST',
296
+ headers: { 'Content-Type': 'application/json' },
297
+ body: '{}',
298
+ });
299
+ expect(res.status).toBe(200);
300
+ expect(collector.spans().length).toBe(0);
301
+ expect(collector.metrics().length).toBe(0);
302
+ });
303
+ });