@onlineapps/conn-orch-validator 3.3.1 → 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.
- package/CHANGELOG.md +389 -0
- package/README.md +46 -6
- package/TESTING_STRATEGY.md +1 -1
- package/docs/DESIGN.md +18 -11
- package/package.json +2 -3
- package/src/CookbookTestRunner.js +155 -26
- package/src/CookbookTestUtils.js +12 -4
- package/src/ServiceReadinessValidator.js +35 -18
- package/src/ValidationOrchestrator.js +223 -126
- package/src/cli/biz-ci-gate.js +356 -7
- package/src/helpers/README.md +3 -55
- package/src/helpers/createServiceReadinessTests.js +5 -1
- package/src/index.js +5 -2
- package/src/mocks/MockMQClient.js +100 -15
- package/src/utils/bizCiGateContract.js +110 -21
- package/src/utils/connectorContract.js +96 -0
- package/src/utils/cookbookFormat.js +95 -0
- package/src/utils/deployContract.js +488 -0
- package/src/utils/envContract.js +417 -0
- package/src/utils/installContract.js +142 -0
- package/src/utils/integrationRun.js +297 -0
- package/src/utils/libCompat.js +158 -0
- package/src/utils/preValidation.js +137 -0
- package/src/utils/setupDatabase.js +154 -0
- package/src/utils/stepFailure.js +73 -0
- package/src/utils/testNamespace.js +104 -0
- package/src/validators/ServiceStructureValidator.js +252 -45
- package/src/config.js +0 -32
- package/src/defaults.js +0 -11
- package/src/helpers/createPreValidationTests.js +0 -326
- package/test-mq-flow.js +0 -72
- package/test-orchestrator.js +0 -95
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one schema build for every biz service.
|
|
5
|
+
*
|
|
6
|
+
* The service declares WHAT its database is in integration-contract.json; this
|
|
7
|
+
* decides HOW it gets built, identically for all of them
|
|
8
|
+
* (docs/biz/00-model/uniformity-principle.md).
|
|
9
|
+
*
|
|
10
|
+
* It replaces six per-repo ci-setup-db.js scripts that had each solved the same
|
|
11
|
+
* problem differently — 113, 69, 56, 54, 22 and 20 lines, in three strategies,
|
|
12
|
+
* two of which bypassed migrations entirely with sequelize.sync(). That bypass
|
|
13
|
+
* let a service's CI prove a schema built from models while production ran one
|
|
14
|
+
* built from hand-applied migrations. There is deliberately no option here to
|
|
15
|
+
* build from anything but the migrations: the absence is what makes the bypass
|
|
16
|
+
* impossible rather than merely discouraged.
|
|
17
|
+
*
|
|
18
|
+
* Two invariants, both from ADR 0006:
|
|
19
|
+
*
|
|
20
|
+
* §2 every .sql file in the declared directory is applied, in sorted order.
|
|
21
|
+
* No list lives in this file — the set grows with each schema change, and a
|
|
22
|
+
* list would drift until a missing table surfaced in an unrelated test.
|
|
23
|
+
*
|
|
24
|
+
* §3 foreign key checks stay ON. Deferring them would let a migration
|
|
25
|
+
* reference a table a later file creates and still report success, which is
|
|
26
|
+
* the exact defect class building from empty exists to catch.
|
|
27
|
+
*
|
|
28
|
+
* SQL runs through the mysql/mariadb CLI rather than a driver, because trigger
|
|
29
|
+
* definitions use DELIMITER — a client directive drivers report as a syntax
|
|
30
|
+
* error. The executor is injected so the decisions are testable without a
|
|
31
|
+
* database.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const path = require('path');
|
|
36
|
+
const { spawnSync } = require('child_process');
|
|
37
|
+
|
|
38
|
+
function requireValue(value, name) {
|
|
39
|
+
if (value === undefined || value === null || value === '') {
|
|
40
|
+
throw new Error(`[SetupDatabase] Missing ${name} - Expected a value. `
|
|
41
|
+
+ `Fix: set ${name} in the CI job (or env-active/*.env locally).`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Decide which files to apply and in what order.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} serviceRoot repository root
|
|
50
|
+
* @param {{migrations: string, seeds: string[]}} database the contract declaration
|
|
51
|
+
*/
|
|
52
|
+
function resolveMigrationPlan(serviceRoot, database) {
|
|
53
|
+
// The declaration is a glob-ish path; only the directory part is read, and
|
|
54
|
+
// only its own .sql files — subdirectories such as migrations/superseded/ hold
|
|
55
|
+
// the record of how a baseline was reached and must not be replayed.
|
|
56
|
+
const dir = path.resolve(serviceRoot, path.dirname(database.migrations));
|
|
57
|
+
|
|
58
|
+
if (!fs.existsSync(dir)) {
|
|
59
|
+
throw new Error(`[SetupDatabase] Migrations directory not found: ${dir}\n`
|
|
60
|
+
+ ` Fix: create it, or correct database.migrations in the integration contract.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const migrations = fs.readdirSync(dir, { withFileTypes: true })
|
|
64
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.sql'))
|
|
65
|
+
.map((entry) => entry.name)
|
|
66
|
+
.sort()
|
|
67
|
+
.map((name) => path.join(dir, name));
|
|
68
|
+
|
|
69
|
+
if (migrations.length === 0) {
|
|
70
|
+
throw new Error(`[SetupDatabase] No .sql files in ${dir} - refusing to report a schema as built.\n`
|
|
71
|
+
+ ' Fix: the migration set is the source of truth for the schema; it cannot be empty.');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const seeds = (database.seeds ?? []).map((seed) => {
|
|
75
|
+
const file = path.resolve(serviceRoot, seed);
|
|
76
|
+
if (!fs.existsSync(file)) {
|
|
77
|
+
throw new Error(`[SetupDatabase] Declared seed not found: ${seed}\n`
|
|
78
|
+
+ ' Fix: correct database.seeds in the integration contract, or add the file.');
|
|
79
|
+
}
|
|
80
|
+
return file;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return { dir, migrations, seeds };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Run one statement or file through the CLI client. Injectable for tests. */
|
|
87
|
+
function defaultExec({ connection, schema, sql, file }) {
|
|
88
|
+
const client = ['mariadb', 'mysql'].find((bin) => !spawnSync(bin, ['--version'], { encoding: 'utf8' }).error);
|
|
89
|
+
if (!client) {
|
|
90
|
+
return {
|
|
91
|
+
status: 1,
|
|
92
|
+
stderr: '[SetupDatabase] No mysql/mariadb client on PATH - required because migrations use DELIMITER. '
|
|
93
|
+
+ 'Fix: install mariadb-client in the CI job image.'
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const args = ['-h', connection.host, '-P', String(connection.port), '-u', connection.user,
|
|
98
|
+
'--default-character-set=utf8mb4'];
|
|
99
|
+
// MariaDB Connector/C 3.x requires TLS by default and the CI database is a
|
|
100
|
+
// sidecar with none; the connection never leaves the job's private network.
|
|
101
|
+
if (connection.requireTls !== true) args.push('--skip-ssl');
|
|
102
|
+
if (connection.password) args.push(`-p${connection.password}`);
|
|
103
|
+
if (schema) args.push(schema);
|
|
104
|
+
|
|
105
|
+
const result = spawnSync(client, args, {
|
|
106
|
+
input: sql !== undefined ? sql : fs.readFileSync(file, 'utf8'),
|
|
107
|
+
encoding: 'utf8'
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
status: result.error ? 1 : result.status,
|
|
112
|
+
stderr: result.error ? result.error.message
|
|
113
|
+
: (result.stderr || '').split('\n').filter((l) => !l.includes('Using a password')).join('\n').trim()
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the schema from the declared migrations.
|
|
119
|
+
*
|
|
120
|
+
* @returns {{schema: string, migrationsApplied: number, seedsApplied: number}}
|
|
121
|
+
*/
|
|
122
|
+
function buildSchema({ serviceRoot, database, connection, exec = defaultExec }) {
|
|
123
|
+
requireValue(connection?.host, 'DB_HOST');
|
|
124
|
+
requireValue(connection?.user, 'DB_USER');
|
|
125
|
+
const port = connection.port ?? 3306;
|
|
126
|
+
|
|
127
|
+
const conn = { ...connection, port };
|
|
128
|
+
const plan = resolveMigrationPlan(serviceRoot, database);
|
|
129
|
+
|
|
130
|
+
const created = exec({ connection: conn, sql: `CREATE DATABASE IF NOT EXISTS \`${database.schema}\`;` });
|
|
131
|
+
if (created.status !== 0) {
|
|
132
|
+
throw new Error(`[SetupDatabase] Could not create schema ${database.schema}:\n${created.stderr}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const apply = (file, kind) => {
|
|
136
|
+
const result = exec({ connection: conn, schema: database.schema, file });
|
|
137
|
+
if (result.status !== 0) {
|
|
138
|
+
throw new Error(`[SetupDatabase] ${kind} ${path.basename(file)} failed:\n${result.stderr}\n`
|
|
139
|
+
+ ' Fix: the set must apply to an empty database in file order. Either the SQL is wrong, '
|
|
140
|
+
+ 'or the file is numbered before something it depends on.');
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
for (const file of plan.migrations) apply(file, 'Migration');
|
|
145
|
+
for (const file of plan.seeds) apply(file, 'Seed');
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
schema: database.schema,
|
|
149
|
+
migrationsApplied: plan.migrations.length,
|
|
150
|
+
seedsApplied: plan.seeds.length
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { resolveMigrationPlan, buildSchema };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One owner for the sentence that says WHY a cookbook step failed.
|
|
5
|
+
*
|
|
6
|
+
* Two consumers read it and they must not drift apart:
|
|
7
|
+
* - CookbookTestRunner.executeStep — the per-step log line at boot;
|
|
8
|
+
* - ValidationOrchestrator.runCookbookTests — the step-4 error list that
|
|
9
|
+
* reaches `results.errors`, the wrapper's `Validation failed: …` message
|
|
10
|
+
* and validation.responses.
|
|
11
|
+
*
|
|
12
|
+
* Before 2026-08-29 neither of them said anything: the log line was
|
|
13
|
+
* `Step count_stub: FAILED (1101ms)` and the orchestrator's only error was
|
|
14
|
+
* `1 cookbook test(s) failed`. The reason existed the whole time in
|
|
15
|
+
* `result.validationErrors` / `result.error` and was discarded at both ends,
|
|
16
|
+
* so a failed biz-hello boot could not be diagnosed from its own logs
|
|
17
|
+
* (automation-gates.md §5 — silence is a defect).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Why this step failed, in one line, from whichever field holds it.
|
|
22
|
+
*
|
|
23
|
+
* @param {Object} stepResult - a step result produced by CookbookTestRunner
|
|
24
|
+
* @returns {string} the concrete reason; never an empty string
|
|
25
|
+
*/
|
|
26
|
+
function describeStepFailure(stepResult) {
|
|
27
|
+
if (!stepResult || typeof stepResult !== 'object') {
|
|
28
|
+
throw new Error('[stepFailure] stepResult is required - Expected the step result object produced by CookbookTestRunner');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const parts = [];
|
|
32
|
+
|
|
33
|
+
if (Array.isArray(stepResult.validationErrors) && stepResult.validationErrors.length > 0) {
|
|
34
|
+
parts.push(stepResult.validationErrors.join('; '));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const error = stepResult.error;
|
|
38
|
+
if (typeof error === 'string' && error.length > 0) {
|
|
39
|
+
parts.push(error);
|
|
40
|
+
} else if (error && typeof error === 'object') {
|
|
41
|
+
parts.push(`${error.code || 'HANDLER_ERROR'}: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof stepResult.errorStack === 'string' && stepResult.errorStack.length > 0) {
|
|
45
|
+
parts.push(stepResult.errorStack);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (parts.length === 0) {
|
|
49
|
+
// A step marked failed with nothing recorded is itself a defect; say so
|
|
50
|
+
// rather than printing an empty reason that reads like "no problem".
|
|
51
|
+
return 'no reason recorded — neither validationErrors nor error was set on the step result';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return parts.join(' | ');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The same reason, prefixed with enough context to find the case: which
|
|
59
|
+
* cookbook, which step, which operation. This is what step 4 puts into
|
|
60
|
+
* `results.errors`.
|
|
61
|
+
*
|
|
62
|
+
* @param {Object} stepResult - a step result carrying `cookbook`
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function describeStepFailureWithContext(stepResult) {
|
|
66
|
+
const reason = describeStepFailure(stepResult);
|
|
67
|
+
const cookbook = stepResult.cookbook || 'unknown cookbook';
|
|
68
|
+
const stepId = stepResult.id || stepResult.step_id || 'unnamed step';
|
|
69
|
+
const operation = stepResult.operation ? ` (${stepResult.operation})` : '';
|
|
70
|
+
return `cookbook "${cookbook}" step "${stepId}"${operation}: ${reason}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { describeStepFailure, describeStepFailureWithContext };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The tenant / workspace an integration test is allowed to write into.
|
|
5
|
+
*
|
|
6
|
+
* An integration test reaches a real database, so the namespace it targets is a
|
|
7
|
+
* safety boundary. Two incidents show what happens when that boundary is
|
|
8
|
+
* written per file instead of read from one source:
|
|
9
|
+
*
|
|
10
|
+
* biz-property and biz-converter overrode TESTING_TENANT_ID in their own env
|
|
11
|
+
* template to point at the LIVE tenant, because that is where their cookbook
|
|
12
|
+
* fixtures happened to exist. The Tier-1 runner executes real handler
|
|
13
|
+
* dispatch at every service start, production included — so every restart
|
|
14
|
+
* wrote into real data, and did.
|
|
15
|
+
*
|
|
16
|
+
* 24 of biz-property's 26 integration tests carried their own
|
|
17
|
+
* `const TENANT = 100` and inserted and deleted under it. Nothing could
|
|
18
|
+
* redirect them, because there was nothing to redirect.
|
|
19
|
+
*
|
|
20
|
+
* So the value comes from the platform env (`TESTING_TENANT_ID` /
|
|
21
|
+
* `TESTING_WORKSPACE_ID`, set in `config/env-templates/shared.env`) and this
|
|
22
|
+
* module is the only place that reads it. It throws when the env is absent
|
|
23
|
+
* rather than guessing: a test that does not know which namespace it owns must
|
|
24
|
+
* not write anywhere at all.
|
|
25
|
+
*
|
|
26
|
+
* Enforced by deploy-contract requirement R8 (see utils/deployContract.js).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The environment classes a validation or test run may write into.
|
|
31
|
+
*
|
|
32
|
+
* This is a WHITELIST, and that is the whole point. The previous rule refused
|
|
33
|
+
* the one tenant an env var declared live (`PLATFORM_LIVE_TENANT_ID=100`), which
|
|
34
|
+
* left every customer tenant — 101+, production, real data, 101 = Meditest —
|
|
35
|
+
* outside the guard, and the gap grew with every customer onboarded. Naming what
|
|
36
|
+
* is permitted cannot grow a gap: a class that is not on this list is refused
|
|
37
|
+
* whether or not anyone remembered to declare it.
|
|
38
|
+
*
|
|
39
|
+
* Why the values live in the library and not in the environment: they are a
|
|
40
|
+
* CONTRACT CONSTANT taken from a normative standard, not a deployment knob. The
|
|
41
|
+
* standard says WHAT each tenant id means (an environment class, allocated once,
|
|
42
|
+
* "Adding a class — do not"); this module is the HOW of enforcing it at run time.
|
|
43
|
+
* Architecture principle §2 (No Hardcoded Values) targets configuration of an
|
|
44
|
+
* environment — ports, hosts, timeouts, credentials — which differs per
|
|
45
|
+
* deployment by design. This boundary does not: it is identical in every
|
|
46
|
+
* environment because the norm defines it, and making it env-settable would hand
|
|
47
|
+
* the very misconfiguration this guard exists to stop a switch to turn it off.
|
|
48
|
+
*
|
|
49
|
+
* @see api/docs/standards/tenant-allocation.md § "The allocation"
|
|
50
|
+
*/
|
|
51
|
+
const ALLOWED_TENANT_CLASSES = Object.freeze([
|
|
52
|
+
96, // CI
|
|
53
|
+
97, // TESTING
|
|
54
|
+
98, // DEVEL
|
|
55
|
+
99 // VALIDATION
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
function readNamespaceId(key) {
|
|
59
|
+
const raw = process.env[key];
|
|
60
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
61
|
+
throw new Error(`[TestNamespace] Missing environment variable - ${key} is required. `
|
|
62
|
+
+ 'Fix: set it in config/env-active/shared.env (the platform default is the '
|
|
63
|
+
+ 'non-production namespace). An integration test may not pick a namespace itself.');
|
|
64
|
+
}
|
|
65
|
+
const value = Number(String(raw).trim());
|
|
66
|
+
if (!Number.isInteger(value)) {
|
|
67
|
+
throw new Error(`[TestNamespace] Invalid ${key}="${raw}" - Expected an integer id. `
|
|
68
|
+
+ 'Fix: correct the value in config/env-active/shared.env.');
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @returns {{tenant_id: number, workspace_id: number}} ctx-shaped, so it can be
|
|
75
|
+
* spread straight into a handler call.
|
|
76
|
+
*/
|
|
77
|
+
function getTestNamespace() {
|
|
78
|
+
const tenantId = readNamespaceId('TESTING_TENANT_ID');
|
|
79
|
+
|
|
80
|
+
// The runtime half of the protection, and the reason the tenant is decided
|
|
81
|
+
// before anything else is read. R8 rejects a per-service override before it
|
|
82
|
+
// ships; this refuses the accident that ships anyway, so a startup probe can
|
|
83
|
+
// never reach real data even if the configuration is wrong.
|
|
84
|
+
//
|
|
85
|
+
// The workspace id is deliberately NOT class-checked: tenant-allocation.md
|
|
86
|
+
// allocates environment classes to TENANTS, and calls a workspace "the purpose
|
|
87
|
+
// within the class" without naming any range for it. A restriction here would
|
|
88
|
+
// be one this library invented.
|
|
89
|
+
if (!ALLOWED_TENANT_CLASSES.includes(tenantId)) {
|
|
90
|
+
throw new Error(`[TestNamespace] TESTING_TENANT_ID=${tenantId} is not an allowed `
|
|
91
|
+
+ 'environment class - test and validation runs may never write into production data '
|
|
92
|
+
+ '(100 = LIVE, 101+ = CUSTOMER tenants, both refused). Expected one of 96 (CI), '
|
|
93
|
+
+ '97 (TESTING), 98 (DEVEL), 99 (VALIDATION). Fix: set TESTING_TENANT_ID to one of '
|
|
94
|
+
+ 'those classes in config/env-active/shared.env and seed that namespace from '
|
|
95
|
+
+ 'migrations/ - see api/docs/standards/tenant-allocation.md.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
tenant_id: tenantId,
|
|
100
|
+
workspace_id: readNamespaceId('TESTING_WORKSPACE_ID')
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { getTestNamespace };
|