@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.
@@ -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 };