@onlineapps/conn-orch-validator 3.3.2 → 4.0.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.
@@ -6,6 +6,23 @@ const crypto = require('crypto');
6
6
  const MockMQClient = require('./mocks/MockMQClient');
7
7
  const MockRegistry = require('./mocks/MockRegistry');
8
8
  const { resolveHeaders } = require('./utils/resolveHeaders');
9
+ const { checkCookbookFormatVersion } = require('./utils/cookbookFormat');
10
+ const { getTestNamespace } = require('./utils/testNamespace');
11
+ const { describeStepFailure } = require('./utils/stepFailure');
12
+
13
+ /**
14
+ * The step's stopwatch, spelled out. A single total hid the fact that
15
+ * biz-hello's `count_stub` spent 2264 ms loading sequelize and ~0 ms doing its
16
+ * work; the reader of the log had no way to tell those apart, and neither did
17
+ * `expect.duration.max`.
18
+ *
19
+ * @param {Object} result - a step result carrying the three timings
20
+ * @returns {string} e.g. `2ms handler + 402ms module load = 404ms total`
21
+ */
22
+ function describeStepTiming(result) {
23
+ return `${result.handlerDurationMs}ms handler + ${result.setupDurationMs}ms module load`
24
+ + ` = ${result.duration}ms total`;
25
+ }
9
26
 
10
27
  /**
11
28
  * CookbookTestRunner — executes cookbook tests offline with mocked infrastructure.
@@ -17,7 +34,8 @@ const { resolveHeaders } = require('./utils/resolveHeaders');
17
34
  *
18
35
  * The handler dispatch builds a minimal real ctx:
19
36
  * { logger, tenant_id, workspace_id, correlation_id, db: null, cache: null,
20
- * httpClient: null, secrets: null, stream: null, abortSignal: null }
37
+ * httpClient: null, secrets: null, state: null, stream: null,
38
+ * abortSignal: null }
21
39
  * matching the shape ServiceWrapper.ContextBuilder produces in production
22
40
  * (see api/docs/architecture/biz-service-invocation-model.md §5). Handlers
23
41
  * that need DB access import sequelize directly from their own
@@ -78,8 +96,18 @@ class CookbookTestRunner {
78
96
  ? this.loadCookbook(cookbook)
79
97
  : cookbook;
80
98
 
81
- // Validate cookbook format
82
- this.validateCookbook(cookbookData);
99
+ // Validate cookbook format. When the cookbook came from a file, the file
100
+ // path is appended to whatever the validation rejected — the reader of a
101
+ // boot failure otherwise learns that "a" cookbook is malformed but not
102
+ // which one, and a service can carry a dozen of them.
103
+ try {
104
+ this.validateCookbook(cookbookData);
105
+ } catch (error) {
106
+ if (typeof cookbook === 'string') {
107
+ throw new Error(`${error.message} (cookbook file: ${cookbook})`, { cause: error });
108
+ }
109
+ throw error;
110
+ }
83
111
 
84
112
  // Get test configuration
85
113
  const testConfig = cookbookData.test || { mode: 'production' };
@@ -94,9 +122,14 @@ class CookbookTestRunner {
94
122
  const stepsArray = this.normalizeSteps(cookbookData.steps);
95
123
 
96
124
  // Execute steps
125
+ const cookbookName = cookbookData.description || 'Unnamed';
97
126
  const stepResults = [];
98
127
  for (const step of stepsArray) {
99
128
  const stepResult = await this.executeStep(step, testConfig);
129
+ // Which cookbook this step came from. Without it a failed step in the
130
+ // aggregate is an id with no file behind it, and a service can carry a
131
+ // dozen cookbooks (utils/stepFailure.js).
132
+ stepResult.cookbook = cookbookName;
100
133
  stepResults.push(stepResult);
101
134
 
102
135
  // If step failed and has expect, fail immediately
@@ -137,6 +170,21 @@ class CookbookTestRunner {
137
170
  * @returns {Object} Aggregate results
138
171
  */
139
172
  async runCookbooks(cookbooksDir) {
173
+ // Every run reports ITS OWN run. `this.results` only ever accumulated, and
174
+ // `ValidationOrchestrator` builds one runner in its constructor while
175
+ // `ServiceWrapper._ensureValidationProof` reuses one orchestrator across
176
+ // six startup retries — so a single transient failure on attempt 1 stayed
177
+ // in `failed` for the whole process, and step 4's verdict
178
+ // (`success: result.failed === 0`) could never come back true. Measured on
179
+ // biz-hello 2026-08-29: 7/8 → 15/16 → 23/24, then `Validation FAILED` in
180
+ // 104–221 ms while every step in the same log said PASSED — a healthy
181
+ // service reporting itself broken (automation-gates.md §5).
182
+ //
183
+ // resetResults() had a unit test and no production caller at all
184
+ // (change-discipline.md § "Removing something removes its declaration").
185
+ // This is that caller.
186
+ this.resetResults();
187
+
140
188
  const files = fs.readdirSync(cookbooksDir).filter(f => f.endsWith('.json'));
141
189
 
142
190
  this.logger.info(`Found ${files.length} cookbook(s) in ${cookbooksDir}`);
@@ -171,36 +219,49 @@ class CookbookTestRunner {
171
219
  id: step.id,
172
220
  operation: step.operation,
173
221
  passed: false,
222
+ // Three separate numbers, because they answer different questions.
223
+ // `duration` is the wall clock of the whole step. `setupDurationMs` is
224
+ // what the RUNNER spent getting to the handler — reading operations.json
225
+ // and require()ing the module, a one-off cost per process. Only
226
+ // `handlerDurationMs` is the handler's own runtime, and only that one is
227
+ // what `expect.duration.max` asserts about (see validateExpectations).
174
228
  duration: 0,
229
+ setupDurationMs: 0,
230
+ handlerDurationMs: 0,
175
231
  request: null,
176
232
  response: null,
177
233
  expected: step.expect || null,
178
234
  actual: null,
179
235
  error: null,
236
+ errorStack: null,
180
237
  validationErrors: []
181
238
  };
182
239
 
183
240
  try {
241
+ // The namespace is a safety boundary, so it is resolved before anything
242
+ // else happens — before the operation is even looked up. utils/testNamespace
243
+ // is the single owner of that decision: it reads the TESTING_* env contract
244
+ // (docs/biz/40-cookbooks/test-env-vars.md +
245
+ // docs/biz/60-templates/env-conventions.md — the TESTING_ prefix marks
246
+ // values that legitimately ship to production, because this runner fires at
247
+ // every service startup, prod included) AND it returns a namespace only for
248
+ // the non-production environment classes — 96 CI, 97 TESTING, 98 DEVEL,
249
+ // 99 VALIDATION (api/docs/standards/tenant-allocation.md). Production
250
+ // tenants, 100 LIVE and every customer from 101 up, are refused.
251
+ //
252
+ // That refusal is the runtime half of deploy-contract R8: R8 rejects a
253
+ // per-service override before it ships, this refuses the accident that
254
+ // ships anyway. Reading process.env here instead would route the startup
255
+ // probe straight around the guard — which is precisely how the 2026-08
256
+ // biz-property incident wrote into the live tenant at every restart.
257
+ // Values arrive already validated as integers, so ctx matches the
258
+ // production ContextBuilder for handlers checking Number.isInteger().
259
+ const { tenant_id: validationTenantId, workspace_id: validationWorkspaceId } = getTestNamespace();
260
+
184
261
  // Resolve operation spec from operations.json — always returns
185
262
  // { modulePath, exportName } (v3 handler dispatch is the only mode).
186
263
  const spec = await this.resolveOperation(step.service, step.operation);
187
264
 
188
- // Env-var contract (docs/biz/40-cookbooks/test-env-vars.md +
189
- // docs/biz/60-templates/env-conventions.md): TESTING_* prefix marks
190
- // values that legitimately ship to production because Tier-1 cookbook
191
- // runner fires at every service startup, including in prod.
192
- const rawTenantId = process.env.TESTING_TENANT_ID;
193
- const rawWorkspaceId = process.env.TESTING_WORKSPACE_ID;
194
- if (!rawTenantId || !rawWorkspaceId) {
195
- throw new Error('[CookbookTestRunner] Missing required environment variables TESTING_TENANT_ID and/or TESTING_WORKSPACE_ID');
196
- }
197
- // Coerce numeric env vars to integers so ctx matches production
198
- // ContextBuilder (which receives already-typed values from the MQ envelope).
199
- // Handlers that check `Number.isInteger(ctx.tenant_id)` would otherwise
200
- // reject the string form and every workspace-scoped cookbook would fail.
201
- const validationTenantId = /^\d+$/.test(rawTenantId) ? parseInt(rawTenantId, 10) : rawTenantId;
202
- const validationWorkspaceId = /^\d+$/.test(rawWorkspaceId) ? parseInt(rawWorkspaceId, 10) : rawWorkspaceId;
203
-
204
265
  await this._dispatchViaHandler({
205
266
  step,
206
267
  spec,
@@ -211,14 +272,26 @@ class CookbookTestRunner {
211
272
  startTime
212
273
  });
213
274
 
214
- this.logger.info(`Step ${step.id}: ${result.passed ? 'PASSED' : 'FAILED'} (${result.duration}ms)`);
275
+ if (result.passed) {
276
+ this.logger.info(`Step ${step.id}: PASSED (${describeStepTiming(result)})`);
277
+ } else {
278
+ // A verdict without its reason is a silent gate (automation-gates.md
279
+ // §5). Until 2026-08-29 this line said only "FAILED (1101ms)" and the
280
+ // reason — held in result.validationErrors or result.error — reached
281
+ // nobody, which made a failed biz-hello boot undiagnosable from logs.
282
+ this.logger.error(`Step ${step.id}: FAILED (${describeStepTiming(result)}) — ${describeStepFailure(result)}`);
283
+ }
215
284
 
216
285
  } catch (error) {
217
286
  result.error = error.message;
287
+ result.errorStack = error.stack;
218
288
  result.duration = Date.now() - startTime;
289
+ // Nothing reached the handler, so all of it was setup.
290
+ result.setupDurationMs = result.duration;
291
+ result.handlerDurationMs = 0;
219
292
  result.passed = false;
220
293
 
221
- this.logger.error(`Step ${step.id}: ERROR - ${error.message}`);
294
+ this.logger.error(`Step ${step.id}: ERROR - ${error.message}\n${error.stack}`);
222
295
  }
223
296
 
224
297
  return result;
@@ -252,6 +325,14 @@ class CookbookTestRunner {
252
325
  cache: null,
253
326
  httpClient: null,
254
327
  secrets: null,
328
+ // Persistent Redis state (conn-base-state), the slot production builds
329
+ // in ContextBuilder. Null here, and null is the only honest value:
330
+ // Tier-1 validation runs in ServiceWrapper phase 0.2, before
331
+ // _initializeState() instantiates the connector, so there is nothing to
332
+ // inject — same situation as db and cache. Declaring the slot keeps a
333
+ // handler that reads ctx.state from receiving `undefined`, which is a
334
+ // different failure from the documented `null`.
335
+ state: null,
255
336
  stream: null,
256
337
  abortSignal: null,
257
338
  headers: {
@@ -298,6 +379,12 @@ class CookbookTestRunner {
298
379
  if (timeoutId.unref) timeoutId.unref();
299
380
  });
300
381
 
382
+ // Everything up to here was the runner getting ready: the namespace, the
383
+ // operations.json read, require.resolve and the require() of the handler
384
+ // module. That is a one-off cost per process and it is not the handler's.
385
+ const handlerStart = Date.now();
386
+ result.setupDurationMs = handlerStart - startTime;
387
+
301
388
  let handlerError = null;
302
389
  let handlerOutput = null;
303
390
  try {
@@ -311,7 +398,9 @@ class CookbookTestRunner {
311
398
  clearTimeout(timeoutId);
312
399
  }
313
400
 
314
- result.duration = Date.now() - startTime;
401
+ const finished = Date.now();
402
+ result.handlerDurationMs = finished - handlerStart;
403
+ result.duration = finished - startTime;
315
404
 
316
405
  if (handlerError) {
317
406
  // Synthesize an HTTP-like response so existing expect validators
@@ -327,6 +416,9 @@ class CookbookTestRunner {
327
416
  };
328
417
  result.actual = result.response.data;
329
418
  result.error = { code: errorCode, message: handlerError.message };
419
+ // The stack is the half of a thrown error that says WHERE. Kept on the
420
+ // result so the step's failure line can print it (utils/stepFailure.js).
421
+ result.errorStack = handlerError.stack;
330
422
  } else {
331
423
  result.response = {
332
424
  status: 200,
@@ -368,10 +460,23 @@ class CookbookTestRunner {
368
460
  }
369
461
  }
370
462
 
371
- // Validate duration
463
+ // Validate duration — of the HANDLER.
464
+ //
465
+ // Until 2026-08-29 this compared `result.duration`, the whole step, whose
466
+ // dominant term for any handler with a heavy module is the one-off
467
+ // require(). biz-hello's `count_stub` is a stub returning four constants;
468
+ // its module pulls in sequelize, measured at 2264 ms cold inside the
469
+ // container against a 1000 ms bar, while every other cookbook handler in
470
+ // the same service loaded in 5–25 ms. So the boot verdict of a healthy
471
+ // service depended on filesystem cache warmth — a gate that is not
472
+ // predictable is not a gate (automation-gates.md §1.1).
473
+ //
474
+ // The load cost is not swept away: it is `setupDurationMs`, it is printed
475
+ // on the step's log line, and it stays inside `duration`.
372
476
  if (expect.duration && expect.duration.max) {
373
- if (result.duration > expect.duration.max) {
374
- errors.push(`Duration ${result.duration}ms exceeds max ${expect.duration.max}ms`);
477
+ if (result.handlerDurationMs > expect.duration.max) {
478
+ errors.push(`Handler duration ${result.handlerDurationMs}ms exceeds max ${expect.duration.max}ms`
479
+ + ` (module load ${result.setupDurationMs}ms is the runner's own cost and is not counted)`);
375
480
  }
376
481
  }
377
482
 
@@ -573,7 +678,13 @@ class CookbookTestRunner {
573
678
  }
574
679
 
575
680
  const content = fs.readFileSync(cookbookPath, 'utf8');
576
- return JSON.parse(content);
681
+ try {
682
+ return JSON.parse(content);
683
+ } catch (error) {
684
+ throw new Error(`[CookbookTestRunner] Cookbook file is not valid JSON - Expected a JSON object per `
685
+ + `api/docs/biz/40-cookbooks/format.md. Fix: repair the JSON syntax in ${cookbookPath} (${error.message})`,
686
+ { cause: error });
687
+ }
577
688
  }
578
689
 
579
690
  /**
@@ -605,8 +716,26 @@ class CookbookTestRunner {
605
716
  /**
606
717
  * Validate cookbook format
607
718
  * Supports both V1 (steps as array) and V2 (steps as object) formats
719
+ *
720
+ * The format-version check is FIRST and unconditional. Until 2026-08-27 it
721
+ * lived only in `CookbookTestUtils.validateCookbook`, reachable exclusively
722
+ * through the optional `tests/bootstrap/service-readiness.test.js` wrapper
723
+ * that 3 of 8 biz repos happen to have — so the rule from
724
+ * api/docs/biz/40-cookbooks/format.md § "Required fields" was, in practice,
725
+ * opt-in per repo, and 4 repos ship cookbooks with no `version` field at all.
726
+ * A check a repo can decline is not a check (automation-gates §1.5).
608
727
  */
609
728
  validateCookbook(cookbook) {
729
+ if (!cookbook || typeof cookbook !== 'object' || Array.isArray(cookbook)) {
730
+ throw new Error('[CookbookTestRunner] Cookbook is not a JSON object - Expected an object with "version" and "steps". '
731
+ + 'Fix: check the cookbook file content (api/docs/biz/40-cookbooks/format.md § Basic structure).');
732
+ }
733
+
734
+ const versionProblem = checkCookbookFormatVersion(cookbook.version);
735
+ if (versionProblem) {
736
+ throw new Error(`[CookbookTestRunner] ${versionProblem}`);
737
+ }
738
+
610
739
  if (!cookbook.steps || (typeof cookbook.steps !== 'object' && !Array.isArray(cookbook.steps))) {
611
740
  throw new Error('Cookbook must have steps (array or object)');
612
741
  }
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { MIN_COOKBOOK_FORMAT_VERSION, checkCookbookFormatVersion } = require('./utils/cookbookFormat');
4
+
3
5
  /**
4
6
  * CookbookTestUtils - Utilities for cookbook testing
5
7
  */
@@ -11,7 +13,7 @@ class CookbookTestUtils {
11
13
  return {
12
14
  workflow_id: options.workflow_id || `test-workflow-${Date.now()}`,
13
15
  cookbook: options.cookbook || {
14
- version: '1.0.0',
16
+ version: MIN_COOKBOOK_FORMAT_VERSION,
15
17
  steps: options.steps || [
16
18
  {
17
19
  id: 'step1',
@@ -51,7 +53,7 @@ class CookbookTestUtils {
51
53
  }
52
54
 
53
55
  return {
54
- version: '1.0.0',
56
+ version: MIN_COOKBOOK_FORMAT_VERSION,
55
57
  api_input: options.api_input || { test: 'data' },
56
58
  steps
57
59
  };
@@ -59,6 +61,11 @@ class CookbookTestUtils {
59
61
 
60
62
  /**
61
63
  * Validate cookbook structure (basic validation)
64
+ *
65
+ * The format-version rule is the shared one (`utils/cookbookFormat`), the
66
+ * same rule the Tier-1 `CookbookTestRunner` enforces — one fact, one owner.
67
+ * Presence alone used to be enough here, so a cookbook stuck on an outdated
68
+ * format passed the readiness wrapper unnoticed.
62
69
  */
63
70
  static validateCookbook(cookbook) {
64
71
  const errors = [];
@@ -68,8 +75,9 @@ class CookbookTestUtils {
68
75
  return { valid: false, errors };
69
76
  }
70
77
 
71
- if (!cookbook.version) {
72
- errors.push('Version is required');
78
+ const versionProblem = checkCookbookFormatVersion(cookbook.version);
79
+ if (versionProblem) {
80
+ errors.push(versionProblem);
73
81
  }
74
82
 
75
83
  if (!cookbook.steps || !Array.isArray(cookbook.steps)) {
@@ -21,6 +21,17 @@ const CookbookTestUtils = require('./CookbookTestUtils');
21
21
  * previously spent on `health` fold into `operations` so total stays
22
22
  * at 100.
23
23
  *
24
+ * 2026-08-22: the `url` the probe consumed is gone too. It outlived the
25
+ * probe by five months as an accepted-and-ignored argument echoed into
26
+ * `results.serviceUrl` and printed as a `URL:` line — a report field
27
+ * naming an endpoint that does not exist. The only caller that still
28
+ * passed one was Tier-1's readiness step, and that step is gone
29
+ * (see ValidationOrchestrator: its verdict was a strict function of
30
+ * steps 2 and 3). What is left has exactly one consumer:
31
+ * `helpers/createServiceReadinessTests`, and through it the
32
+ * `tests/bootstrap/` suites of the biz repos — which pass name, version,
33
+ * operations, testCookbook and registry, and never passed a url.
34
+ *
24
35
  * @see /api/docs/architecture/biz-service-invocation-model.md §5.3
25
36
  * @see /api/docs/biz/40-cookbooks/test-runner-flow.md (input probe contract)
26
37
  * @see /api/docs/biz/80-decisions/0005-no-http-in-biz-containers.md
@@ -55,7 +66,6 @@ class ServiceReadinessValidator {
55
66
  */
56
67
  async validateReadiness(service) {
57
68
  const {
58
- url,
59
69
  operations,
60
70
  registry,
61
71
  testCookbook
@@ -64,7 +74,6 @@ class ServiceReadinessValidator {
64
74
  const results = {
65
75
  timestamp: Date.now(),
66
76
  serviceName: service.name,
67
- serviceUrl: url,
68
77
  checks: {},
69
78
  score: 0,
70
79
  maxScore: 100,
@@ -73,14 +82,29 @@ class ServiceReadinessValidator {
73
82
  warnings: []
74
83
  };
75
84
 
85
+ /**
86
+ * Record what a check contributed. `createServiceReadinessTests` prints
87
+ * `checkResult.score` per check; nothing ever assigned it, so every biz
88
+ * bootstrap run printed "0 points" beside checks that had just been
89
+ * awarded their full weight, under a total that contradicted them.
90
+ */
91
+ const award = (name, failureMessage) => {
92
+ const { weight, required } = this.checks[name];
93
+ if (results.checks[name].passed) {
94
+ results.checks[name].score = weight;
95
+ results.score += weight;
96
+ } else {
97
+ results.checks[name].score = 0;
98
+ if (required) {
99
+ results.errors.push(failureMessage);
100
+ }
101
+ }
102
+ };
103
+
76
104
  // 1. Validate operations.json structure
77
105
  if (operations) {
78
106
  results.checks.operations = await this.checkOperationsCompliance(operations);
79
- if (results.checks.operations.passed) {
80
- results.score += this.checks.operations.weight;
81
- } else if (this.checks.operations.required) {
82
- results.errors.push('Operations validation failed');
83
- }
107
+ award('operations', 'Operations validation failed');
84
108
  } else {
85
109
  results.errors.push('No operations.json provided - required for all services');
86
110
  results.checks.operations = { passed: false, error: 'Missing operations.json' };
@@ -101,11 +125,7 @@ class ServiceReadinessValidator {
101
125
  results.checks.cookbook = await this.checkCookbookExecution(
102
126
  testCookbook
103
127
  );
104
- if (results.checks.cookbook.passed) {
105
- results.score += this.checks.cookbook.weight;
106
- } else if (this.checks.cookbook.required) {
107
- results.errors.push('Cookbook validation failed');
108
- }
128
+ award('cookbook', 'Cookbook validation failed');
109
129
  }
110
130
 
111
131
  // 5. Verify registry compatibility
@@ -114,11 +134,7 @@ class ServiceReadinessValidator {
114
134
  service,
115
135
  registry
116
136
  );
117
- if (results.checks.registry.passed) {
118
- results.score += this.checks.registry.weight;
119
- } else if (this.checks.registry.required) {
120
- results.errors.push('Registry compatibility check failed');
121
- }
137
+ award('registry', 'Registry compatibility check failed');
122
138
  }
123
139
 
124
140
  // Calculate readiness
@@ -292,7 +308,8 @@ class ServiceReadinessValidator {
292
308
 
293
309
  report.push('=== Service Readiness Report ===');
294
310
  report.push(`Service: ${results.serviceName}`);
295
- report.push(`URL: ${results.serviceUrl}`);
311
+ // No URL line: biz services expose no endpoint (ADR 0005). Printing one
312
+ // named an address nothing listened on.
296
313
  report.push(`Score: ${results.score}/${results.maxScore}`);
297
314
  report.push(`Status: ${results.ready ? 'READY' : 'NOT READY'}`);
298
315
  report.push('');