@onlineapps/conn-orch-validator 3.3.2 → 4.0.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.
@@ -0,0 +1,297 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Proof that the integration suite RAN — not that files exist on disk.
5
+ *
6
+ * The file-count check (`verifyIntegrationMinimum`) answers "is there a suite?".
7
+ * It cannot answer "did it execute?", and the difference is the whole defect:
8
+ * a suite hidden behind an env flag that CI never sets makes the pipeline start
9
+ * mariadb/redis/rabbitmq, pay for `ci:gate:wait` and `ci:gate:setup`, run nothing
10
+ * against them — and jest still exits 0. Measured: a fully skipped run reports
11
+ * `"success": true`, so the runner's exit code proves nothing either. The only
12
+ * usable evidence is the machine-readable report (`jest --json --outputFile`).
13
+ *
14
+ * This module is deliberately free of any dependency on bizCiGateContract: the
15
+ * CLI is the composition root that resolves the contract, the file list and the
16
+ * runner, and hands them in. Everything here is injected (principle 1) and
17
+ * validated at entry (principle 4).
18
+ *
19
+ * @see .claude/rules/automation-gates.md §5 — a mechanism that checks nothing
20
+ * is a false guarantee, of the same severity as a wrong result.
21
+ */
22
+
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+
26
+ /** Every counter the verdict rests on. A report missing one is not a report. */
27
+ const REQUIRED_COUNTERS = {
28
+ totalTests: 'numTotalTests',
29
+ passedTests: 'numPassedTests',
30
+ failedTests: 'numFailedTests',
31
+ pendingTests: 'numPendingTests',
32
+ todoTests: 'numTodoTests',
33
+ totalSuites: 'numTotalTestSuites',
34
+ runtimeErrorSuites: 'numRuntimeErrorTestSuites',
35
+ };
36
+
37
+ const INTEGRATION_RUN_ARTEFACT_RELATIVE_PATH = path.join('ci', 'integration-run.json');
38
+ const DEFAULT_REPORT_DIR_RELATIVE_PATH = path.join('ci', 'integration-run');
39
+
40
+ function parseJestReport(rawText, sourcePath) {
41
+ if (typeof sourcePath !== 'string' || sourcePath === '') {
42
+ throw new Error('[BizCiGate] Missing sourcePath for runner report - Expected the path the report was read from. '
43
+ + 'Fix: pass the report path so a violation can name the failing process.');
44
+ }
45
+
46
+ let parsed;
47
+ try {
48
+ parsed = JSON.parse(rawText);
49
+ } catch (error) {
50
+ throw new Error(`[BizCiGate] Unreadable runner report - ${sourcePath}: ${error.message}. `
51
+ + 'Expected the JSON document written by "jest --json --outputFile". '
52
+ + 'Fix: a truncated report means the runner was killed mid-write — rerun it and check the job for OOM.');
53
+ }
54
+
55
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
56
+ throw new Error(`[BizCiGate] Invalid runner report - ${sourcePath} is not a jest report object. `
57
+ + 'Fix: point the gate at the file produced by "jest --json --outputFile".');
58
+ }
59
+
60
+ const report = { sourcePath };
61
+ for (const [field, counter] of Object.entries(REQUIRED_COUNTERS)) {
62
+ const value = parsed[counter];
63
+ if (!Number.isInteger(value) || value < 0) {
64
+ throw new Error(`[BizCiGate] Invalid runner report - ${sourcePath} has no usable "${counter}" `
65
+ + `(found ${JSON.stringify(value)}). Expected a non-negative integer. `
66
+ + 'Fix: the report must come from jest >= 29 run with --json; do not hand-edit it.');
67
+ }
68
+ report[field] = value;
69
+ }
70
+
71
+ return report;
72
+ }
73
+
74
+ function readJestReport(filePath) {
75
+ const resolvedPath = path.resolve(filePath);
76
+ if (!fs.existsSync(resolvedPath)) {
77
+ throw new Error(`[BizCiGate] Missing runner report - ${resolvedPath} was not written. `
78
+ + 'Expected "jest --json --outputFile" to produce it. '
79
+ + 'Fix: the runner died before writing — read the job log for the crash; a missing report is never a pass.');
80
+ }
81
+
82
+ return parseJestReport(fs.readFileSync(resolvedPath, 'utf8'), resolvedPath);
83
+ }
84
+
85
+ function aggregateJestReports(reports) {
86
+ if (!Array.isArray(reports) || reports.length === 0) {
87
+ throw new Error('[BizCiGate] No runner reports - Expected at least one jest report to aggregate. '
88
+ + 'Fix: an empty batch means no process ran; that is a failure, not a clean zero.');
89
+ }
90
+
91
+ const aggregate = { reportCount: reports.length, sourcePaths: [] };
92
+ for (const field of Object.keys(REQUIRED_COUNTERS)) {
93
+ aggregate[field] = 0;
94
+ }
95
+
96
+ for (const report of reports) {
97
+ aggregate.sourcePaths.push(report.sourcePath);
98
+ for (const field of Object.keys(REQUIRED_COUNTERS)) {
99
+ aggregate[field] += report[field];
100
+ }
101
+ }
102
+
103
+ // Executed = what the runner actually carried out. Pending (a skipped describe
104
+ // or it) and todo are declarations, not execution — counting them is exactly
105
+ // the lie this module exists to remove.
106
+ aggregate.executedTests = aggregate.passedTests + aggregate.failedTests;
107
+ aggregate.notExecutedTests = aggregate.pendingTests + aggregate.todoTests;
108
+
109
+ return aggregate;
110
+ }
111
+
112
+ /**
113
+ * Every violation is reported, not only the first: a job that skipped half its
114
+ * suite AND failed a test must show both, or the second one surfaces only after
115
+ * the first is fixed.
116
+ */
117
+ function evaluateIntegrationRun(aggregate) {
118
+ if (!aggregate || typeof aggregate !== 'object') {
119
+ throw new Error('[BizCiGate] Missing aggregate - Expected the object returned by aggregateJestReports. '
120
+ + 'Fix: aggregate the reports before evaluating them.');
121
+ }
122
+
123
+ const violations = [];
124
+
125
+ if (aggregate.runtimeErrorSuites > 0) {
126
+ violations.push({
127
+ code: 'RUNTIME_ERROR_SUITES',
128
+ message: `[BizCiGate] Integration suite failed to load - ${aggregate.runtimeErrorSuites} suite(s) `
129
+ + 'threw before any test ran. Expected every suite to load against the real sidecars. '
130
+ + 'Fix: read the runner output above; a suite that cannot load fails 0 tests and proves nothing.',
131
+ });
132
+ }
133
+
134
+ if (aggregate.failedTests > 0) {
135
+ violations.push({
136
+ code: 'FAILED_TESTS',
137
+ message: `[BizCiGate] Failing integration test(s) - ${aggregate.failedTests} of `
138
+ + `${aggregate.executedTests} executed test(s) failed. Expected all to pass. `
139
+ + 'Fix: fix the service or the test; never loosen the assertion.',
140
+ });
141
+ }
142
+
143
+ if (aggregate.executedTests === 0) {
144
+ violations.push({
145
+ code: 'NO_TESTS_EXECUTED',
146
+ message: `[BizCiGate] No integration test was executed - ${aggregate.totalTests} test(s) discovered, `
147
+ + `0 executed, ${aggregate.notExecutedTests} skipped. `
148
+ + 'The pipeline started the sidecars and ran nothing against them. '
149
+ + 'Fix: remove the condition that disables the suite, or delete the suite.',
150
+ });
151
+ } else if (aggregate.notExecutedTests > 0) {
152
+ violations.push({
153
+ code: 'NOT_EXECUTED_TESTS',
154
+ message: `[BizCiGate] Skipped integration test(s) - ${aggregate.pendingTests} skipped, `
155
+ + `${aggregate.todoTests} todo, out of ${aggregate.totalTests}. `
156
+ + 'Expected: every integration test runs against the real sidecars. '
157
+ + 'Fix: remove the skip/todo, or delete the test.',
158
+ });
159
+ }
160
+
161
+ return {
162
+ ok: violations.length === 0,
163
+ violations,
164
+ executedTests: aggregate.executedTests,
165
+ notExecutedTests: aggregate.notExecutedTests,
166
+ totalTests: aggregate.totalTests,
167
+ };
168
+ }
169
+
170
+ function assertNonEmptyString(value, name, fix) {
171
+ if (typeof value !== 'string' || value.trim() === '') {
172
+ throw new Error(`[BizCiGate] Missing ${name} - Expected a non-empty path. Fix: ${fix}`);
173
+ }
174
+ }
175
+
176
+ function assertExistingPath(value, name, fix) {
177
+ if (!fs.existsSync(value)) {
178
+ throw new Error(`[BizCiGate] Missing ${name} - ${value} does not exist. Fix: ${fix}`);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * One runner process per integration test file.
184
+ *
185
+ * The service declares WHAT its integration suite is; the library decides HOW it
186
+ * is executed (docs/biz/00-model/uniformity-principle.md). Per-file processes are
187
+ * the uniform HOW because they are the only shape that also fits biz-invoicing,
188
+ * whose suite was OOM-killed in a single process — and they bound the blast radius
189
+ * of one leaking suite for everybody else. No new declaration is needed for it.
190
+ */
191
+ function runIntegrationSuite(options) {
192
+ const { serviceRoot, testFiles, jestBin, jestConfig, reportDir, spawn } = options || {};
193
+
194
+ assertNonEmptyString(serviceRoot, 'serviceRoot', 'pass the resolved service repository root.');
195
+ assertNonEmptyString(jestBin, 'jestBin', 'pass the resolved path to the runner executable.');
196
+ assertNonEmptyString(jestConfig, 'jestConfig', 'pass the resolved path to the runner config.');
197
+ assertNonEmptyString(reportDir, 'reportDir', 'pass the directory the per-process reports are written to.');
198
+
199
+ if (typeof spawn !== 'function') {
200
+ throw new Error('[BizCiGate] Missing spawn - Expected the process runner to be injected. '
201
+ + 'Fix: pass child_process.spawnSync (or an equivalent) from the composition root.');
202
+ }
203
+
204
+ if (!Array.isArray(testFiles) || testFiles.length === 0) {
205
+ throw new Error('[BizCiGate] No integration test files - Expected at least one file to run. '
206
+ + 'Fix: this is the file-count precondition; run verify-integration-minimum first.');
207
+ }
208
+
209
+ assertExistingPath(jestBin, 'runner executable',
210
+ 'install the service dependencies (npm ci) so node_modules/.bin/jest exists.');
211
+ assertExistingPath(jestConfig, 'runner config',
212
+ 'the service must ship jest.config.js at its repository root.');
213
+
214
+ fs.rmSync(reportDir, { recursive: true, force: true });
215
+ fs.mkdirSync(reportDir, { recursive: true });
216
+
217
+ const processes = [];
218
+ for (const [index, testFile] of testFiles.entries()) {
219
+ const relativeTestFile = path.relative(serviceRoot, path.resolve(serviceRoot, testFile));
220
+ const reportPath = path.join(reportDir, `${index}-${path.basename(testFile)}.json`);
221
+ const args = [
222
+ '--config', jestConfig,
223
+ relativeTestFile,
224
+ '--runInBand',
225
+ '--json',
226
+ '--outputFile', reportPath,
227
+ ];
228
+
229
+ const spawnResult = spawn(jestBin, args, { cwd: serviceRoot, stdio: 'inherit', env: process.env });
230
+
231
+ processes.push({
232
+ testFile: relativeTestFile,
233
+ reportPath,
234
+ exitCode: spawnResult && Number.isInteger(spawnResult.status) ? spawnResult.status : null,
235
+ });
236
+ }
237
+
238
+ // Read every report before judging any of them: a missing or truncated report
239
+ // throws here, which is the intended outcome — it can never be read as "0 failures".
240
+ const reports = processes.map((entry) => readJestReport(entry.reportPath));
241
+ const aggregate = aggregateJestReports(reports);
242
+ const evaluation = evaluateIntegrationRun(aggregate);
243
+
244
+ const artefact = {
245
+ generatedAt: new Date().toISOString(),
246
+ serviceRoot,
247
+ processes,
248
+ executedTests: aggregate.executedTests,
249
+ notExecutedTests: aggregate.notExecutedTests,
250
+ totalTests: aggregate.totalTests,
251
+ passedTests: aggregate.passedTests,
252
+ failedTests: aggregate.failedTests,
253
+ pendingTests: aggregate.pendingTests,
254
+ todoTests: aggregate.todoTests,
255
+ totalSuites: aggregate.totalSuites,
256
+ runtimeErrorSuites: aggregate.runtimeErrorSuites,
257
+ verdict: evaluation.ok ? 'pass' : 'fail',
258
+ violations: evaluation.violations,
259
+ };
260
+
261
+ const artefactPath = path.join(serviceRoot, INTEGRATION_RUN_ARTEFACT_RELATIVE_PATH);
262
+ fs.mkdirSync(path.dirname(artefactPath), { recursive: true });
263
+ fs.writeFileSync(artefactPath, JSON.stringify(artefact, null, 2));
264
+
265
+ return { aggregate, evaluation, processes, artefact, artefactPath };
266
+ }
267
+
268
+ /**
269
+ * The run artefact is the only honest source of "how many integration tests
270
+ * executed". Absent means unknown — the caller must say so, never substitute a
271
+ * count of files on disk.
272
+ */
273
+ function readIntegrationRunArtefact(serviceRoot) {
274
+ const artefactPath = path.join(path.resolve(serviceRoot), INTEGRATION_RUN_ARTEFACT_RELATIVE_PATH);
275
+ if (!fs.existsSync(artefactPath)) {
276
+ return null;
277
+ }
278
+
279
+ try {
280
+ return { artefactPath, artefact: JSON.parse(fs.readFileSync(artefactPath, 'utf8')) };
281
+ } catch (error) {
282
+ throw new Error(`[BizCiGate] Unreadable integration run artefact - ${artefactPath}: ${error.message}. `
283
+ + 'Fix: delete it and rerun run-integration; a corrupt artefact must not be summarised as a result.');
284
+ }
285
+ }
286
+
287
+ module.exports = {
288
+ REQUIRED_COUNTERS,
289
+ INTEGRATION_RUN_ARTEFACT_RELATIVE_PATH,
290
+ DEFAULT_REPORT_DIR_RELATIVE_PATH,
291
+ parseJestReport,
292
+ readJestReport,
293
+ aggregateJestReports,
294
+ evaluateIntegrationRun,
295
+ runIntegrationSuite,
296
+ readIntegrationRunArtefact,
297
+ };
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Library compatibility gate — requirement R6.
5
+ *
6
+ * A biz service may only ship if every @onlineapps/* package it pins EXACTLY
7
+ * matches the version the platform declares. Mismatch means the service was
8
+ * built against a different platform contract than the one it will run on.
9
+ *
10
+ * Only packages at least one infra service actually installs are compared: the
11
+ * SSOT lists biz-only packages too, and for those there is no infra version to
12
+ * diverge from, so asking "does this match what infra ships" has no answer.
13
+ * The SSOT carries that set as `infraConsumed`. A bare version map has no such
14
+ * list — a platform-release entry records what was deployed, so everything in
15
+ * it is by definition shipped and must match.
16
+ *
17
+ * Pure module: the rules and the fetch live here, presentation and exit codes
18
+ * live in the CLI.
19
+ *
20
+ * Configuration (GitLab CI variables, NOT service env files — the biz env
21
+ * convention in api/docs/biz/60-templates/env-conventions.md governs
22
+ * config/env-active/*.env and compose, and deliberately does not reach CI job
23
+ * variables, so these carry no DEVEL_/TESTING_ prefix):
24
+ * LIBRARIES_SSOT_URL where the platform SSOT is published
25
+ * LIBRARIES_SSOT_TOKEN optional read token for that endpoint
26
+ *
27
+ * Both are PLATFORM-GLOBAL: one value, set once at GitLab group level, shared
28
+ * by all biz repos and every developer. They have no tenant or workspace
29
+ * dimension, and must not grow one. `workspace_id` scopes tenant DATA at
30
+ * runtime; this pair answers "which platform contract was this code built
31
+ * against", which is a property of the build, identical for every tenant. A
32
+ * per-workspace SSOT would mean two workspaces could run the same service
33
+ * built against different library sets — the exact divergence R6 exists to
34
+ * prevent. Compare TESTING_TENANT_ID / TESTING_WORKSPACE_ID, which are
35
+ * per-service test fixtures and therefore legitimately differ.
36
+ */
37
+
38
+ const fs = require('fs');
39
+
40
+ const EXACT_VERSION = /^\d+\.\d+\.\d+$/;
41
+ const SCOPE = '@onlineapps/';
42
+
43
+ /**
44
+ * Resolve the SSOT into a version map plus its optional infraConsumed list.
45
+ * Accepts the committed wrapped shape and a bare map alike.
46
+ */
47
+ function normalizeLibrarySet(raw) {
48
+ if (!raw || typeof raw !== 'object') {
49
+ throw new Error('[LibCompat] Invalid library set - Expected a JSON object. '
50
+ + 'Fix: point LIBRARIES_SSOT_URL at config/libraries.json or a platform-release libraries entry.');
51
+ }
52
+ if (raw.libraries && typeof raw.libraries === 'object') {
53
+ return {
54
+ versions: raw.libraries,
55
+ infraConsumed: Array.isArray(raw.infraConsumed) ? new Set(raw.infraConsumed) : null
56
+ };
57
+ }
58
+ return { versions: raw, infraConsumed: null };
59
+ }
60
+
61
+ /**
62
+ * Compare a package.json against the platform library set.
63
+ *
64
+ * @param {object} pkg parsed package.json of the biz service
65
+ * @param {object} librarySet committed SSOT shape or bare version map
66
+ * @returns {{ok: boolean, gated: string[], notGated: string[], violations: Array<{package: string, message: string}>}}
67
+ */
68
+ function checkLibCompat(pkg, librarySet) {
69
+ const { versions, infraConsumed } = normalizeLibrarySet(librarySet);
70
+
71
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
72
+ const ours = Object.entries(deps).filter(([name]) => name.startsWith(SCOPE));
73
+
74
+ const gated = infraConsumed ? ours.filter(([name]) => infraConsumed.has(name)) : ours;
75
+ const notGated = infraConsumed
76
+ ? ours.filter(([name]) => !infraConsumed.has(name)).map(([name]) => name)
77
+ : [];
78
+
79
+ const violations = [];
80
+ for (const [name, version] of gated) {
81
+ if (!EXACT_VERSION.test(version)) {
82
+ violations.push({
83
+ package: name,
84
+ message: `${name}: version '${version}' is not exact x.y.z — caret/tilde/range pins are banned, `
85
+ + 'because what resolves today is not what resolved when the image was built.'
86
+ });
87
+ continue;
88
+ }
89
+ if (!(name in versions)) {
90
+ violations.push({
91
+ package: name,
92
+ message: `${name}: not present in the infra library set — the platform does not ship this package version contract.`
93
+ });
94
+ continue;
95
+ }
96
+ if (versions[name] !== version) {
97
+ violations.push({
98
+ package: name,
99
+ message: `${name}: biz pins ${version}, infra ships ${versions[name]} — `
100
+ + 'rebuild the service against the current platform, or deploy the matching infra release first.'
101
+ });
102
+ }
103
+ }
104
+
105
+ return {
106
+ ok: violations.length === 0,
107
+ gated: gated.map(([name]) => name),
108
+ notGated,
109
+ violations
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Load the SSOT from a local path or an https endpoint.
115
+ *
116
+ * Fail-fast when unconfigured: a gate that silently passes because it could not
117
+ * find its reference is worse than no gate, since it reports success.
118
+ *
119
+ * @param {string} [source] path or URL; defaults to LIBRARIES_SSOT_URL
120
+ * @param {object} [env] environment to read (injected for testability)
121
+ */
122
+ async function loadLibrarySet(source, env = process.env) {
123
+ const location = source || env.LIBRARIES_SSOT_URL;
124
+
125
+ if (!location) {
126
+ throw new Error('[LibCompat] Missing environment variable - LIBRARIES_SSOT_URL is required '
127
+ + '(or pass --libraries <path|url>). It points at the platform library SSOT the biz pins are compared against. '
128
+ + 'Fix: set LIBRARIES_SSOT_URL as a GitLab CI variable at group level.');
129
+ }
130
+
131
+ if (/^https?:\/\//.test(location)) {
132
+ const headers = { Accept: 'application/json' };
133
+ if (env.LIBRARIES_SSOT_TOKEN) headers['PRIVATE-TOKEN'] = env.LIBRARIES_SSOT_TOKEN;
134
+
135
+ const response = await fetch(location, { headers });
136
+ if (!response.ok) {
137
+ throw new Error(`[LibCompat] Could not fetch the library SSOT - ${location} returned ${response.status} ${response.statusText}. `
138
+ + 'Fix: check LIBRARIES_SSOT_URL, and that LIBRARIES_SSOT_TOKEN grants read access to the infra repository.');
139
+ }
140
+ const body = await response.text();
141
+ try {
142
+ return JSON.parse(body);
143
+ } catch (err) {
144
+ throw new Error(`[LibCompat] Could not parse the library SSOT fetched from ${location} - ${err.message}`);
145
+ }
146
+ }
147
+
148
+ if (!fs.existsSync(location)) {
149
+ throw new Error(`[LibCompat] Library SSOT not found: ${location}`);
150
+ }
151
+ try {
152
+ return JSON.parse(fs.readFileSync(location, 'utf8'));
153
+ } catch (err) {
154
+ throw new Error(`[LibCompat] Could not parse ${location} - ${err.message}`);
155
+ }
156
+ }
157
+
158
+ module.exports = { checkLibCompat, loadLibrarySet, normalizeLibrarySet };
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pre-validation: run a service's cookbooks offline against mocked
5
+ * infrastructure and write the validation proof the Registry consumes.
6
+ *
7
+ * One implementation for every service. It replaces five copies of
8
+ * scripts/run-pre-validation.js — four identical, plus property's 16-line
9
+ * stand-in that counted .json files and printed OK without running a cookbook
10
+ * or writing a proof, while being wired to the same `npm run test:cookbooks`.
11
+ *
12
+ * Two things the copies carried are deliberately absent:
13
+ * - the legacy HTTP branch (spawn scripts/test-server.js, dispatch over
14
+ * axios). Every service declares schema_version "3.0" with handler
15
+ * operations, so the branch was unreachable; a non-v3 shape now fails fast
16
+ * rather than silently taking the handler path.
17
+ * - a ConfigLoader import. That lives in @onlineapps/service-wrapper (L4) and
18
+ * this package is L3, so the resolved service URL is INJECTED by the
19
+ * caller — architecture-principles.md §7, no reverse dependencies.
20
+ *
21
+ * Pure module: it returns the outcome and never exits. The CLI owns exit codes
22
+ * and presentation, this owns the rules.
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ /**
29
+ * A step error is whatever the handler threw or the transport returned — often
30
+ * an object, not a string. The scripts this replaces interpolated it straight
31
+ * into a template literal, so a real failure was reported as "[object Object]"
32
+ * and the operator learned nothing. Render it so the message survives.
33
+ */
34
+ function describeStepError(error) {
35
+ if (error === undefined || error === null) return 'Validation failed';
36
+ if (typeof error === 'string') return error;
37
+ if (typeof error.message === 'string' && error.message.length > 0) return error.message;
38
+ try {
39
+ return JSON.stringify(error);
40
+ } catch {
41
+ return String(error);
42
+ }
43
+ }
44
+
45
+ const COOKBOOKS_RELATIVE_DIR = path.join('tests', 'cookbooks');
46
+ const PROOF_RELATIVE_PATH = path.join('conn-runtime', 'validation-proof.json');
47
+ const OPERATIONS_RELATIVE_PATH = path.join('config', 'service', 'operations.json');
48
+ const VALIDATOR_NAME = '@onlineapps/conn-orch-validator';
49
+ const COOKBOOK_TIMEOUT_MS = 30000;
50
+
51
+ /**
52
+ * v3 means MQ-only handler dispatch. Anything else is a service that has not
53
+ * been migrated, and it must say so rather than be run through a path its
54
+ * operations were never written for.
55
+ */
56
+ function assertV3Operations(serviceRoot) {
57
+ const operationsPath = path.join(serviceRoot, OPERATIONS_RELATIVE_PATH);
58
+
59
+ if (!fs.existsSync(operationsPath)) {
60
+ throw new Error(`[PreValidation] Missing operations.json - ${operationsPath} is required. `
61
+ + 'Fix: declare the service operations at config/service/operations.json.');
62
+ }
63
+
64
+ const operations = JSON.parse(fs.readFileSync(operationsPath, 'utf8'));
65
+ const declaresV3 = operations.schema_version === '3.0' || operations.schema_version === '3';
66
+ const hasHandlers = operations.operations
67
+ && Object.values(operations.operations).some((operation) => operation.handler);
68
+
69
+ if (!declaresV3 && !hasHandlers) {
70
+ throw new Error('[PreValidation] Unsupported operations.json shape - schema_version "3.0" with '
71
+ + 'handler-based operations is required. Fix: migrate the service to v3 handlers; the legacy '
72
+ + 'HTTP dispatch path no longer exists.');
73
+ }
74
+ }
75
+
76
+ /**
77
+ * @param {object} params
78
+ * @param {string} params.serviceRoot service repository root
79
+ * @param {string} params.serviceUrl resolved by the caller from the service's own config
80
+ * @param {Function} params.RunnerClass CookbookTestRunner (injected so the module stays testable)
81
+ * @param {Function} params.ProofGeneratorClass ValidationProofGenerator
82
+ * @param {{log: Function, error: Function}} params.logger
83
+ * @returns {Promise<{ok: boolean, results: object, failedSteps: Array, proof: object|null, proofPath: string|null}>}
84
+ */
85
+ async function runPreValidation({ serviceRoot, serviceUrl, RunnerClass, ProofGeneratorClass, logger }) {
86
+ const cookbooksDir = path.join(serviceRoot, COOKBOOKS_RELATIVE_DIR);
87
+ if (!fs.existsSync(cookbooksDir)) {
88
+ throw new Error(`[PreValidation] Missing cookbooks directory - ${cookbooksDir} is required. `
89
+ + 'Fix: add cookbook .json files under tests/cookbooks.');
90
+ }
91
+
92
+ assertV3Operations(serviceRoot);
93
+
94
+ const packageJson = JSON.parse(fs.readFileSync(path.join(serviceRoot, 'package.json'), 'utf8'));
95
+
96
+ const runner = new RunnerClass({
97
+ serviceName: packageJson.name,
98
+ serviceUrl,
99
+ servicePath: serviceRoot,
100
+ mockInfrastructure: true,
101
+ timeout: COOKBOOK_TIMEOUT_MS,
102
+ logger
103
+ });
104
+
105
+ const results = await runner.runCookbooks(cookbooksDir);
106
+
107
+ if (results.failed > 0) {
108
+ // No proof on a failed run. A proof is a claim that the cookbooks passed,
109
+ // so writing one here would make the Registry trust a run that did not.
110
+ const failedSteps = results.steps
111
+ .filter((step) => !step.passed)
112
+ .map((step) => ({
113
+ id: step.id || step.operation,
114
+ error: describeStepError(step.error),
115
+ validationErrors: step.validationErrors || []
116
+ }));
117
+
118
+ return { ok: false, results, failedSteps, proof: null, proofPath: null };
119
+ }
120
+
121
+ const proofGenerator = new ProofGeneratorClass({
122
+ serviceName: packageJson.name,
123
+ serviceVersion: packageJson.version,
124
+ validatorName: VALIDATOR_NAME,
125
+ validatorVersion: require('../../package.json').version
126
+ });
127
+
128
+ const proof = proofGenerator.generateProof(results, packageJson.dependencies || {});
129
+
130
+ const proofPath = path.join(serviceRoot, PROOF_RELATIVE_PATH);
131
+ fs.mkdirSync(path.dirname(proofPath), { recursive: true });
132
+ fs.writeFileSync(proofPath, JSON.stringify(proof, null, 2));
133
+
134
+ return { ok: true, results, failedSteps: [], proof, proofPath };
135
+ }
136
+
137
+ module.exports = { runPreValidation, PROOF_RELATIVE_PATH, COOKBOOKS_RELATIVE_DIR };