@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,274 @@
1
+ /**
2
+ * RFC 0010 §F: openwop-auth-mtls profile (opt-in).
3
+ *
4
+ * Verifies that hosts claiming the mTLS profile satisfy
5
+ * `spec/v1/auth-profiles.md` §`openwop-auth-mtls`:
6
+ *
7
+ * 1. `capabilities.auth.profiles[]` includes `openwop-auth-mtls`
8
+ * and `mtls.supported === true`.
9
+ * 2. `mtls.required` (when present) is a boolean; `subjectMapping`
10
+ * (when present) is one of the canonical enum values
11
+ * (cn / san-dns / san-uri).
12
+ * 3. Request with valid client cert + valid bearer → 201 on
13
+ * `POST /v1/runs`.
14
+ * 4. When `mtls.required === true`, a bearer-only request (no
15
+ * client cert) MUST fail with non-2xx OR a transport-layer TLS
16
+ * failure (`auth-profiles.md` lets hosts choose either).
17
+ *
18
+ * Capability shape runs unconditionally when the profile is advertised.
19
+ * Behavior portion is opt-in via `OPENWOP_TEST_MTLS=1` and requires
20
+ * operator-supplied cert paths because cert provisioning is environmental
21
+ * (host's CA, client cert/key files). Follows the
22
+ * `restart-during-run.test.ts` opt-in precedent.
23
+ *
24
+ * Implementation note: this scenario uses `node:https.request` rather
25
+ * than the global `fetch` because conformance has no `undici` dep and
26
+ * Node's fetch doesn't expose a client-cert option without a dispatcher
27
+ * (which requires `undici` as a public package). `node:https` ships
28
+ * with the runtime and supports `{ cert, key, ca }` directly.
29
+ *
30
+ * Operator setup:
31
+ * OPENWOP_TEST_MTLS=1
32
+ * OPENWOP_TEST_MTLS_CLIENT_CERT_PATH=<path to PEM client cert>
33
+ * OPENWOP_TEST_MTLS_CLIENT_KEY_PATH=<path to PEM client key>
34
+ * OPENWOP_TEST_MTLS_CA_PATH=<optional path to CA bundle for server-cert verify>
35
+ * OPENWOP_BASE_URL=https://... (HTTPS required for mTLS)
36
+ *
37
+ * @see RFCS/0010-auth-profile-conformance.md §F
38
+ * @see spec/v1/auth-profiles.md §`openwop-auth-mtls`
39
+ */
40
+
41
+ import { describe, it, expect } from 'vitest';
42
+ import { readFileSync } from 'node:fs';
43
+ import { request as httpsRequest } from 'node:https';
44
+ import { driver } from '../lib/driver.js';
45
+ import { loadEnv } from '../lib/env.js';
46
+ import { behaviorGate } from '../lib/behavior-gate.js';
47
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
48
+
49
+ interface MtlsCaps {
50
+ supported?: boolean;
51
+ required?: boolean;
52
+ subjectMapping?: string;
53
+ }
54
+
55
+ interface AuthCaps {
56
+ profiles?: string[];
57
+ mtls?: MtlsCaps;
58
+ }
59
+
60
+ const PROFILE = 'openwop-auth-mtls';
61
+ const FIXTURE = 'conformance-noop';
62
+ const RUN_BEHAVIOR = process.env.OPENWOP_TEST_MTLS === '1';
63
+
64
+ interface ClientCerts {
65
+ cert: Buffer;
66
+ key: Buffer;
67
+ ca?: Buffer;
68
+ }
69
+
70
+ interface HttpsResponse {
71
+ status: number;
72
+ body: string;
73
+ }
74
+
75
+ async function readAuthCaps(): Promise<AuthCaps | undefined> {
76
+ const disco = await driver.get('/.well-known/openwop');
77
+ return (disco.json as { capabilities?: { auth?: AuthCaps } }).capabilities?.auth;
78
+ }
79
+
80
+ function isProfileAdvertised(auth: AuthCaps | undefined): boolean {
81
+ return (
82
+ Array.isArray(auth?.profiles) &&
83
+ auth.profiles.includes(PROFILE) &&
84
+ auth.mtls?.supported === true
85
+ );
86
+ }
87
+
88
+ function loadClientCerts(): ClientCerts | undefined {
89
+ const certPath = process.env.OPENWOP_TEST_MTLS_CLIENT_CERT_PATH;
90
+ const keyPath = process.env.OPENWOP_TEST_MTLS_CLIENT_KEY_PATH;
91
+ if (!certPath || !keyPath) return undefined;
92
+ try {
93
+ const caPath = process.env.OPENWOP_TEST_MTLS_CA_PATH;
94
+ return {
95
+ cert: readFileSync(certPath),
96
+ key: readFileSync(keyPath),
97
+ ...(caPath ? { ca: readFileSync(caPath) } : {}),
98
+ };
99
+ } catch (err) {
100
+ // eslint-disable-next-line no-console
101
+ console.warn(`[auth-mtls] failed to read client cert/key: ${String(err)}`);
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ function mtlsPost(
107
+ baseUrl: string,
108
+ path: string,
109
+ body: unknown,
110
+ headers: Record<string, string>,
111
+ certs: ClientCerts | undefined,
112
+ ): Promise<HttpsResponse | { error: Error }> {
113
+ return new Promise((resolve) => {
114
+ const url = new URL(baseUrl + path);
115
+ const payload = JSON.stringify(body);
116
+ const req = httpsRequest(
117
+ {
118
+ hostname: url.hostname,
119
+ port: url.port ? Number.parseInt(url.port, 10) : 443,
120
+ path: url.pathname + url.search,
121
+ method: 'POST',
122
+ headers: {
123
+ ...headers,
124
+ 'Content-Type': 'application/json',
125
+ 'Content-Length': Buffer.byteLength(payload).toString(),
126
+ },
127
+ ...(certs ? { cert: certs.cert, key: certs.key } : {}),
128
+ ...(certs?.ca ? { ca: certs.ca } : {}),
129
+ },
130
+ (res) => {
131
+ let chunks = '';
132
+ res.on('data', (c: Buffer | string) => {
133
+ chunks += typeof c === 'string' ? c : c.toString('utf8');
134
+ });
135
+ res.on('end', () => {
136
+ resolve({ status: res.statusCode ?? 0, body: chunks });
137
+ });
138
+ },
139
+ );
140
+ req.on('error', (error) => resolve({ error }));
141
+ req.write(payload);
142
+ req.end();
143
+ });
144
+ }
145
+
146
+ describe('auth-mtls: capability shape', () => {
147
+ it('host claiming mTLS profile advertises required fields', async () => {
148
+ const auth = await readAuthCaps();
149
+
150
+ if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
151
+ return;
152
+ }
153
+
154
+ expect(auth?.profiles?.includes(PROFILE), driver.describe(
155
+ 'auth-profiles.md §`openwop-auth-mtls`',
156
+ 'capabilities.auth.profiles MUST include openwop-auth-mtls when the profile is claimed',
157
+ )).toBe(true);
158
+
159
+ expect(auth?.mtls?.supported, driver.describe(
160
+ 'auth-profiles.md §`openwop-auth-mtls`',
161
+ 'capabilities.auth.mtls.supported MUST be true when the profile is claimed',
162
+ )).toBe(true);
163
+
164
+ if (auth?.mtls?.required !== undefined) {
165
+ expect(
166
+ typeof auth.mtls.required,
167
+ 'mtls.required MUST be boolean when advertised',
168
+ ).toBe('boolean');
169
+ }
170
+
171
+ if (auth?.mtls?.subjectMapping !== undefined) {
172
+ expect(
173
+ ['cn', 'san-dns', 'san-uri'].includes(auth.mtls.subjectMapping),
174
+ driver.describe(
175
+ 'capabilities.schema.json auth.mtls.subjectMapping',
176
+ 'subjectMapping MUST be one of cn / san-dns / san-uri',
177
+ ),
178
+ ).toBe(true);
179
+ }
180
+ });
181
+ });
182
+
183
+ describe.skipIf(!RUN_BEHAVIOR)('auth-mtls: client cert behavior', () => {
184
+ it('valid client cert + valid bearer → 201', async () => {
185
+ const auth = await readAuthCaps();
186
+ if (!isProfileAdvertised(auth)) return;
187
+
188
+ const certs = loadClientCerts();
189
+ if (!certs) {
190
+ // eslint-disable-next-line no-console
191
+ console.warn(
192
+ '[auth-mtls] OPENWOP_TEST_MTLS=1 but cert paths missing; skipping behavior',
193
+ );
194
+ return;
195
+ }
196
+
197
+ if (!isFixtureAdvertised(FIXTURE)) return;
198
+
199
+ const env = loadEnv();
200
+ if (!env.baseUrl.startsWith('https://')) {
201
+ // eslint-disable-next-line no-console
202
+ console.warn(
203
+ `[auth-mtls] OPENWOP_BASE_URL is not HTTPS (got ${env.baseUrl}); mTLS requires HTTPS — skipping`,
204
+ );
205
+ return;
206
+ }
207
+
208
+ const res = await mtlsPost(
209
+ env.baseUrl,
210
+ '/v1/runs',
211
+ { workflowId: FIXTURE },
212
+ { Authorization: `Bearer ${env.apiKey}` },
213
+ certs,
214
+ );
215
+
216
+ if ('error' in res) {
217
+ throw new Error(
218
+ `[auth-mtls] mTLS request failed at transport: ${res.error.message}`,
219
+ );
220
+ }
221
+
222
+ expect(res.status, driver.describe(
223
+ 'auth-profiles.md §`openwop-auth-mtls`',
224
+ 'valid client cert + valid bearer MUST authenticate POST /v1/runs (201)',
225
+ )).toBe(201);
226
+ });
227
+
228
+ it('no client cert against mtls.required: true → non-2xx or TLS failure', async () => {
229
+ const auth = await readAuthCaps();
230
+ if (!isProfileAdvertised(auth)) return;
231
+ if (auth?.mtls?.required !== true) {
232
+ // eslint-disable-next-line no-console
233
+ console.warn(
234
+ '[auth-mtls] host advertises mtls.required: false; skipping no-cert rejection (host may accept bearer-only)',
235
+ );
236
+ return;
237
+ }
238
+
239
+ if (!isFixtureAdvertised(FIXTURE)) return;
240
+
241
+ const env = loadEnv();
242
+ if (!env.baseUrl.startsWith('https://')) {
243
+ // eslint-disable-next-line no-console
244
+ console.warn(
245
+ `[auth-mtls] OPENWOP_BASE_URL is not HTTPS (got ${env.baseUrl}); skipping no-cert rejection`,
246
+ );
247
+ return;
248
+ }
249
+
250
+ // No certs supplied → either 4xx or transport-layer TLS handshake failure.
251
+ const res = await mtlsPost(
252
+ env.baseUrl,
253
+ '/v1/runs',
254
+ { workflowId: FIXTURE },
255
+ { Authorization: `Bearer ${env.apiKey}` },
256
+ undefined,
257
+ );
258
+
259
+ if ('error' in res) {
260
+ // Transport-layer TLS failure is conformant per auth-profiles.md.
261
+ // The handshake rejected the client because no cert was offered.
262
+ expect(true, driver.describe(
263
+ 'auth-profiles.md §`openwop-auth-mtls`',
264
+ 'mtls.required: true MUST reject no-cert requests (TLS handshake failure is conformant)',
265
+ )).toBe(true);
266
+ return;
267
+ }
268
+
269
+ expect(res.status >= 400, driver.describe(
270
+ 'auth-profiles.md §`openwop-auth-mtls`',
271
+ 'mtls.required: true MUST reject no-cert requests at the auth layer (4xx) when not rejected at the TLS layer',
272
+ )).toBe(true);
273
+ });
274
+ });
@@ -0,0 +1,259 @@
1
+ /**
2
+ * RFC 0010 §C: openwop-auth-oauth2-client-credentials profile.
3
+ *
4
+ * Verifies that hosts claiming the OAuth2-CC profile satisfy
5
+ * `spec/v1/auth-profiles.md` §`openwop-auth-oauth2-client-credentials`:
6
+ *
7
+ * 1. `capabilities.auth.profiles[]` includes
8
+ * `openwop-auth-oauth2-client-credentials` and `oauth2.supported`.
9
+ * 2. When advertised, `oauth2.issuer` is a non-empty URI, `audience`
10
+ * is a non-empty string, `supportedAlgorithms` is a non-empty
11
+ * array.
12
+ * 3. Malformed JWT bearer (not three dot-separated segments) returns
13
+ * 401 with the canonical error envelope.
14
+ * 4. Tokens minted by the conformance suite's synthetic OIDC issuer
15
+ * with deliberately-broken claims (wrong aud, expired exp,
16
+ * unsupported alg in header) return 401 when the host has been
17
+ * configured to trust the harness via `OPENWOP_TEST_OAUTH_ISSUER_TRUSTED=true`.
18
+ * 5. Positive token (`OPENWOP_TEST_OAUTH_TOKEN`) returns 201 on
19
+ * `POST /v1/runs` per the canonical run-create contract.
20
+ *
21
+ * Negative cases that require the host to trust the harness soft-skip
22
+ * when the operator hasn't wired up trust. The capability-shape and
23
+ * malformed-JWT assertions run unconditionally when the profile is
24
+ * advertised.
25
+ *
26
+ * @see RFCS/0010-auth-profile-conformance.md §C
27
+ * @see spec/v1/auth-profiles.md §`openwop-auth-oauth2-client-credentials`
28
+ * @see conformance/src/lib/oidc-issuer.ts — synthetic harness
29
+ */
30
+
31
+ import { describe, it, expect } from 'vitest';
32
+ import { driver } from '../lib/driver.js';
33
+ import { behaviorGate } from '../lib/behavior-gate.js';
34
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
35
+ import { createSyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
36
+
37
+ interface OAuth2Caps {
38
+ supported?: boolean;
39
+ issuer?: string;
40
+ audience?: string;
41
+ supportedAlgorithms?: string[];
42
+ }
43
+
44
+ interface AuthCaps {
45
+ profiles?: string[];
46
+ oauth2?: OAuth2Caps;
47
+ }
48
+
49
+ const PROFILE = 'openwop-auth-oauth2-client-credentials';
50
+ const FIXTURE = 'conformance-noop';
51
+
52
+ async function readAuthCaps(): Promise<AuthCaps | undefined> {
53
+ const disco = await driver.get('/.well-known/openwop');
54
+ return (disco.json as { capabilities?: { auth?: AuthCaps } }).capabilities?.auth;
55
+ }
56
+
57
+ function isProfileAdvertised(auth: AuthCaps | undefined): boolean {
58
+ return (
59
+ Array.isArray(auth?.profiles) &&
60
+ auth.profiles.includes(PROFILE) &&
61
+ auth.oauth2?.supported === true
62
+ );
63
+ }
64
+
65
+ describe('auth-oauth2-client-credentials: capability shape', () => {
66
+ it('host claiming OAuth2-CC profile advertises required fields', async () => {
67
+ const auth = await readAuthCaps();
68
+
69
+ if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
70
+ return;
71
+ }
72
+
73
+ expect(auth?.profiles?.includes(PROFILE), driver.describe(
74
+ 'auth-profiles.md §`openwop-auth-oauth2-client-credentials`',
75
+ 'capabilities.auth.profiles MUST include openwop-auth-oauth2-client-credentials when the profile is claimed',
76
+ )).toBe(true);
77
+
78
+ expect(auth?.oauth2?.supported, driver.describe(
79
+ 'auth-profiles.md §`openwop-auth-oauth2-client-credentials`',
80
+ 'capabilities.auth.oauth2.supported MUST be true when the profile is claimed',
81
+ )).toBe(true);
82
+
83
+ if (auth?.oauth2?.issuer !== undefined) {
84
+ expect(
85
+ typeof auth.oauth2.issuer === 'string' && auth.oauth2.issuer.length > 0,
86
+ driver.describe(
87
+ 'capabilities.schema.json auth.oauth2.issuer',
88
+ 'issuer MUST be a non-empty string when advertised',
89
+ ),
90
+ ).toBe(true);
91
+ }
92
+
93
+ if (auth?.oauth2?.audience !== undefined) {
94
+ expect(
95
+ typeof auth.oauth2.audience === 'string' && auth.oauth2.audience.length > 0,
96
+ driver.describe(
97
+ 'capabilities.schema.json auth.oauth2.audience',
98
+ 'audience MUST be a non-empty string when advertised',
99
+ ),
100
+ ).toBe(true);
101
+ }
102
+
103
+ if (auth?.oauth2?.supportedAlgorithms !== undefined) {
104
+ expect(
105
+ Array.isArray(auth.oauth2.supportedAlgorithms) &&
106
+ auth.oauth2.supportedAlgorithms.length > 0,
107
+ driver.describe(
108
+ 'capabilities.schema.json auth.oauth2.supportedAlgorithms',
109
+ 'supportedAlgorithms MUST be a non-empty array when advertised',
110
+ ),
111
+ ).toBe(true);
112
+ }
113
+ });
114
+ });
115
+
116
+ describe('auth-oauth2-client-credentials: malformed JWT rejected', () => {
117
+ it('returns 401 on bearer that is not a valid JWT shape', async () => {
118
+ const auth = await readAuthCaps();
119
+
120
+ if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
121
+ return;
122
+ }
123
+
124
+ const res = await driver.post(
125
+ '/v1/runs',
126
+ { workflowId: FIXTURE },
127
+ {
128
+ authenticated: false,
129
+ headers: { Authorization: 'Bearer not.a.real.jwt' },
130
+ },
131
+ );
132
+
133
+ expect(res.status, driver.describe(
134
+ 'auth.md §3',
135
+ 'malformed JWT bearer MUST return 401 (canonical invalid_token envelope)',
136
+ )).toBe(401);
137
+
138
+ const body = res.json as { error?: unknown; message?: unknown } | undefined;
139
+ expect(typeof body?.error, driver.describe(
140
+ 'auth.md §3 + rest-endpoints.md error envelope',
141
+ 'response body MUST include `error` (machine code) string',
142
+ )).toBe('string');
143
+ });
144
+ });
145
+
146
+ describe('auth-oauth2-client-credentials: harness-minted negative cases', () => {
147
+ it('wrong-audience token returns 401 when host trusts the harness', async () => {
148
+ const auth = await readAuthCaps();
149
+
150
+ if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
151
+ return;
152
+ }
153
+
154
+ if (process.env.OPENWOP_TEST_OAUTH_ISSUER_TRUSTED !== 'true') {
155
+ // eslint-disable-next-line no-console
156
+ console.warn(
157
+ '[auth-oauth2-client-credentials] OPENWOP_TEST_OAUTH_ISSUER_TRUSTED not set; skipping harness-minted negative cases (operator must pre-configure the host to trust the conformance harness)',
158
+ );
159
+ return;
160
+ }
161
+
162
+ const issuerUrl =
163
+ process.env.OPENWOP_TEST_OAUTH_ISSUER_URL ?? 'http://127.0.0.1:0/oauth';
164
+ const audience = auth?.oauth2?.audience ?? 'openwop-conformance';
165
+ const issuer = createSyntheticOIDCIssuer({
166
+ issuer: issuerUrl,
167
+ audience,
168
+ algorithm: 'RS256',
169
+ });
170
+
171
+ // Wrong audience.
172
+ const wrongAud = issuer.mint({ aud: 'wrong-audience', sub: 'conformance-suite' });
173
+ const wrongAudRes = await driver.post(
174
+ '/v1/runs',
175
+ { workflowId: FIXTURE },
176
+ {
177
+ authenticated: false,
178
+ headers: { Authorization: `Bearer ${wrongAud.token}` },
179
+ },
180
+ );
181
+ expect(wrongAudRes.status, driver.describe(
182
+ 'auth-profiles.md §`openwop-auth-oauth2-client-credentials`',
183
+ 'token with wrong aud claim MUST return 401',
184
+ )).toBe(401);
185
+
186
+ // Expired token.
187
+ const expired = issuer.mint(
188
+ { sub: 'conformance-suite' },
189
+ { expiresInSeconds: -3600 },
190
+ );
191
+ const expiredRes = await driver.post(
192
+ '/v1/runs',
193
+ { workflowId: FIXTURE },
194
+ {
195
+ authenticated: false,
196
+ headers: { Authorization: `Bearer ${expired.token}` },
197
+ },
198
+ );
199
+ expect(expiredRes.status, driver.describe(
200
+ 'auth-profiles.md §`openwop-auth-oauth2-client-credentials`',
201
+ 'expired token (exp < now) MUST return 401',
202
+ )).toBe(401);
203
+
204
+ // Algorithm header lies (claims HS256, signature is RS256).
205
+ const algSpoofed = issuer.mint(
206
+ { sub: 'conformance-suite' },
207
+ { algorithm: 'HS256' },
208
+ );
209
+ const algSpoofedRes = await driver.post(
210
+ '/v1/runs',
211
+ { workflowId: FIXTURE },
212
+ {
213
+ authenticated: false,
214
+ headers: { Authorization: `Bearer ${algSpoofed.token}` },
215
+ },
216
+ );
217
+ expect(algSpoofedRes.status, driver.describe(
218
+ 'auth-profiles.md §`openwop-auth-oauth2-client-credentials`',
219
+ 'token with alg outside supportedAlgorithms MUST return 401',
220
+ )).toBe(401);
221
+ });
222
+ });
223
+
224
+ describe('auth-oauth2-client-credentials: positive token', () => {
225
+ it('operator-supplied valid token authenticates POST /v1/runs', async () => {
226
+ const auth = await readAuthCaps();
227
+
228
+ if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
229
+ return;
230
+ }
231
+
232
+ const token = process.env.OPENWOP_TEST_OAUTH_TOKEN;
233
+ if (!token) {
234
+ // eslint-disable-next-line no-console
235
+ console.warn(
236
+ '[auth-oauth2-client-credentials] OPENWOP_TEST_OAUTH_TOKEN not supplied; skipping positive-path assertion',
237
+ );
238
+ return;
239
+ }
240
+
241
+ if (!isFixtureAdvertised(FIXTURE)) {
242
+ return;
243
+ }
244
+
245
+ const res = await driver.post(
246
+ '/v1/runs',
247
+ { workflowId: FIXTURE },
248
+ {
249
+ authenticated: false,
250
+ headers: { Authorization: `Bearer ${token}` },
251
+ },
252
+ );
253
+
254
+ expect(res.status, driver.describe(
255
+ 'auth-profiles.md §`openwop-auth-oauth2-client-credentials`',
256
+ 'valid OAuth2-CC token MUST authenticate POST /v1/runs (201)',
257
+ )).toBe(201);
258
+ });
259
+ });