@onlineapps/conn-orch-validator 7.0.0 → 8.1.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 +2582 -2
- package/README.md +1038 -4
- package/docs/DESIGN.md +3 -1
- package/manifests/biz-service.manifest.json +658 -0
- package/manifests/library.manifest.json +324 -0
- package/package.json +12 -6
- package/src/CookbookTestRunner.js +408 -101
- package/src/CookbookTestUtils.js +7 -8
- package/src/ServiceReadinessValidator.js +10 -35
- package/src/ValidationOrchestrator.js +219 -71
- package/src/cli/biz-ci-gate.js +176 -33
- package/src/cli/oa-lint-scripts.js +221 -0
- package/src/cli/oa-sync-template.js +1020 -0
- package/src/cli/oa-validate.js +474 -0
- package/src/helpers/README.md +2 -1
- package/src/helpers/createServiceReadinessTests.js +60 -4
- package/src/index.js +33 -3
- package/src/lint/scripts/lintScripts.js +298 -0
- package/src/manifest/checks/composeRunnerBlock.js +222 -0
- package/src/manifest/checks/composeShape.js +165 -0
- package/src/manifest/checks/contractBridge.js +181 -0
- package/src/manifest/checks/discoveryOrphan.js +50 -0
- package/src/manifest/checks/docsLintBridge.js +553 -0
- package/src/manifest/checks/fileAbsent.js +35 -0
- package/src/manifest/checks/gitTracked.js +204 -0
- package/src/manifest/checks/index.js +111 -0
- package/src/manifest/checks/libraryContext.js +226 -0
- package/src/manifest/checks/libraryDocs.js +75 -0
- package/src/manifest/checks/libraryPackage.js +272 -0
- package/src/manifest/checks/librarySource.js +274 -0
- package/src/manifest/checks/libraryTests.js +121 -0
- package/src/manifest/checks/libraryWorkspace.js +293 -0
- package/src/manifest/checks/readmeRegion.js +135 -0
- package/src/manifest/checks/scriptHeaders.js +79 -0
- package/src/manifest/checks/serviceConfig.js +390 -0
- package/src/manifest/checks/serviceConnectors.js +81 -0
- package/src/manifest/checks/serviceDb.js +388 -0
- package/src/manifest/checks/serviceFiles.js +754 -0
- package/src/manifest/checks/serviceIdentityRows.js +351 -0
- package/src/manifest/checks/serviceRuntime.js +295 -0
- package/src/manifest/checks/serviceScripts.js +213 -0
- package/src/manifest/deployabilitySignal.js +121 -0
- package/src/manifest/discovery.js +386 -0
- package/src/manifest/loadManifest.js +62 -0
- package/src/manifest/manifestShape.js +446 -0
- package/src/manifest/report.js +245 -0
- package/src/manifest/runManifest.js +449 -0
- package/src/manifest/serviceIdentity.js +140 -0
- package/src/manifest/walk.js +74 -0
- package/src/manifest/workspaceRoot.js +242 -0
- package/src/mocks/MockMQClient.js +13 -30
- package/src/mocks/MockRegistry.js +4 -2
- package/src/mocks/MockStorage.js +4 -2
- package/src/sync/docsRegion.js +463 -0
- package/src/sync/generatedRegion.js +228 -0
- package/src/sync/readmeLocation.js +182 -0
- package/src/sync/readmePointer.js +477 -0
- package/src/sync/serviceTemplate.js +583 -0
- package/src/sync/sharedEnv.js +162 -0
- package/src/sync/uniformFiles.js +474 -0
- package/src/utils/bizCiGateContract.js +131 -7
- package/src/utils/connectorContract.js +97 -7
- package/src/utils/cookbookFormat.js +81 -40
- package/src/utils/deployContract.js +140 -9
- package/src/utils/envContract.js +57 -1
- package/src/utils/handlerRef.js +181 -0
- package/src/utils/installContract.js +287 -41
- package/src/utils/libCompat.js +29 -7
- package/src/utils/migrationOrder.js +163 -0
- package/src/utils/preValidation.js +20 -7
- package/src/utils/setupDatabase.js +194 -13
- package/src/utils/testCoverageContract.js +539 -0
- package/src/utils/testNamespace.js +247 -23
- package/src/utils/throwawaySchema.js +207 -0
- package/src/validators/ServiceStructureValidator.js +2 -1
- package/templates/business-service/.dockerignore +42 -0
- package/templates/business-service/.gitlab-ci.yml +409 -0
- package/templates/business-service/Dockerfile +27 -0
- package/templates/business-service/README.md +213 -0
- package/templates/business-service/config/biz-docs-lint.tree.json +10 -0
- package/templates/business-service/config/env-templates/__SERVICE_NAME__.env +22 -0
- package/templates/business-service/config/env-templates/shared.env +65 -0
- package/templates/business-service/config/service/config.json +14 -0
- package/templates/business-service/config/service/integration-contract.json +12 -0
- package/templates/business-service/config/service/operations.json +41 -0
- package/templates/business-service/docker-compose.production.yml +60 -0
- package/templates/business-service/docker-compose.yml +93 -0
- package/templates/business-service/docs/80-setup/INSTALL.md +123 -0
- package/templates/business-service/docs/80-setup/PLATFORM_MATRIX.md +65 -0
- package/templates/business-service/docs/80-setup/README.md +18 -0
- package/templates/business-service/docs/80-setup/VALIDATION.md +78 -0
- package/templates/business-service/docs/README.md +18 -0
- package/templates/business-service/gitignore +42 -0
- package/templates/business-service/index.js +10 -0
- package/templates/business-service/init.sh +54 -0
- package/templates/business-service/jest.config.js +6 -0
- package/templates/business-service/package.json.template +31 -0
- package/templates/business-service/scripts/verify-deploy-uniform.sh +180 -0
- package/templates/business-service/src/handlers/v3/echo.js +39 -0
- package/templates/business-service/tests/cookbooks/echo.json +36 -0
- package/templates/business-service/tests/unit/handler.test.js +78 -0
- package/src/WorkflowTestRunner.js +0 -402
|
@@ -23,7 +23,23 @@
|
|
|
23
23
|
* rather than guessing: a test that does not know which namespace it owns must
|
|
24
24
|
* not write anywhere at all.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
26
|
+
* Two namespaces come out of it: getTestNamespace() is the one the test owns and
|
|
27
|
+
* writes into, getForeignTestNamespace() the one it must not see rows from when
|
|
28
|
+
* it proves a handler filters on tenant AND workspace. Both are read from the
|
|
29
|
+
* same env and constrained to the same allowed classes.
|
|
30
|
+
*
|
|
31
|
+
* assertAllowedTenant() is the same boundary asked about an id the caller
|
|
32
|
+
* already holds — an operational script is handed a tenant on the command line
|
|
33
|
+
* and must know whether it may touch it at all. It exists because the whitelist
|
|
34
|
+
* had exactly one consumer inside this file, so a script that needed the answer
|
|
35
|
+
* kept its own guard instead: biz-property refused one declared live id and let
|
|
36
|
+
* every customer tenant from 101 up through (api/shared/TODO.md, "Od
|
|
37
|
+
* BIZ-PROPERTY (2026-09-11)"). The list itself is NOT exported — a copy of the
|
|
38
|
+
* boundary is a second boundary — and the refusal names the allowed classes so
|
|
39
|
+
* a caller never has to enumerate them.
|
|
40
|
+
*
|
|
41
|
+
* Enforced by deploy-contract requirement R8 (see utils/deployContract.js), which
|
|
42
|
+
* accepts a call to either namespace helper and nothing else.
|
|
27
43
|
*/
|
|
28
44
|
|
|
29
45
|
/**
|
|
@@ -50,50 +66,214 @@
|
|
|
50
66
|
*/
|
|
51
67
|
const ALLOWED_TENANT_CLASSES = Object.freeze([
|
|
52
68
|
96, // CI
|
|
53
|
-
97, // TESTING
|
|
54
69
|
98, // DEVEL
|
|
55
70
|
99 // VALIDATION
|
|
56
71
|
]);
|
|
57
72
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Where the platform's own namespace values are set — the `setBy` half of every
|
|
75
|
+
* message this module raises for itself.
|
|
76
|
+
*
|
|
77
|
+
* It is a property of the ENV reader, not of the boundary: a script told to
|
|
78
|
+
* correct `config/env-active/shared.env` because it was handed `--to-tenant 101`
|
|
79
|
+
* would be sent to a file that has nothing to do with the value it typed. So the
|
|
80
|
+
* caller says where its own value comes from, and the boundary says the rest.
|
|
81
|
+
*/
|
|
82
|
+
const ENV_SET_BY = 'locally in config/env-active/shared.env, in CI in the pipeline variables';
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Both halves of the caller's origin are required, and each is named when it is
|
|
86
|
+
* missing: they are printed in the refusal, so a wrong one misdirects an
|
|
87
|
+
* operator who is being stopped from touching real data.
|
|
88
|
+
*/
|
|
89
|
+
function requireOriginText(value, key) {
|
|
90
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
91
|
+
throw new Error(`[TestNamespace] Missing required argument - assertAllowedTenant(id, { purpose, setBy }) `
|
|
92
|
+
+ `has no "${key}". Fix: pass a non-empty string - "purpose" is the name the caller knows the id by `
|
|
93
|
+
+ '(a CLI flag such as "--to-tenant", an env key such as "TESTING_TENANT_ID"), "setBy" says where that '
|
|
94
|
+
+ 'name is given a value ("on the command line"); both are printed in the refusal so the operator '
|
|
95
|
+
+ 'knows what to change.');
|
|
64
96
|
}
|
|
65
|
-
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** No value at all: absent, null, or whitespace. One condition, two callers. */
|
|
100
|
+
function isBlank(raw) {
|
|
101
|
+
return raw === undefined || raw === null || String(raw).trim() === '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The one rail from a raw value to an integer namespace id. Every id this module
|
|
106
|
+
* accepts — the env pair and the argument assertAllowedTenant() is handed —
|
|
107
|
+
* comes through here, so "what is a valid id" is stated once.
|
|
108
|
+
*
|
|
109
|
+
* The ABSENCE message is the one thing the env reader keeps for itself: a
|
|
110
|
+
* missing environment variable and an omitted argument are different failures
|
|
111
|
+
* with different fixes, and pointing a script operator at an env file it never
|
|
112
|
+
* touched would misdirect the person being stopped from reaching real data.
|
|
113
|
+
*
|
|
114
|
+
* @param {number|string} raw
|
|
115
|
+
* @param {{purpose: string, setBy: string}} origin
|
|
116
|
+
* @returns {number}
|
|
117
|
+
*/
|
|
118
|
+
function normalizeNamespaceId(raw, { purpose, setBy }) {
|
|
119
|
+
if (isBlank(raw)) {
|
|
120
|
+
throw new Error(`[TestNamespace] Missing namespace id - ${purpose} is required. `
|
|
121
|
+
+ `Fix: set it ${setBy} - see api/docs/standards/tenant-allocation.md § Env variables. `
|
|
122
|
+
+ 'A run that does not know which namespace it owns may not write anywhere at all.');
|
|
123
|
+
}
|
|
124
|
+
// A number or a string holding one, and nothing else. `[99]` stringifies to
|
|
125
|
+
// "99" and would pass the Number() rail below; an id that arrives as an array
|
|
126
|
+
// or an object is a caller defect, and a guard whose whole job is to refuse
|
|
127
|
+
// must not guess past one.
|
|
128
|
+
const value = (typeof raw === 'number' || typeof raw === 'string')
|
|
129
|
+
? Number(String(raw).trim())
|
|
130
|
+
: Number.NaN;
|
|
66
131
|
if (!Number.isInteger(value)) {
|
|
67
|
-
throw new Error(`[TestNamespace] Invalid ${
|
|
68
|
-
+
|
|
132
|
+
throw new Error(`[TestNamespace] Invalid ${purpose}="${raw}" - Expected an integer id. `
|
|
133
|
+
+ `Fix: correct the value ${setBy} - see api/docs/standards/tenant-allocation.md § Env variables.`);
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The refusal — one sentence, one place.
|
|
140
|
+
*
|
|
141
|
+
* It was written inline in getTestNamespace() while the whitelist had a single
|
|
142
|
+
* consumer. A second consumer copying it would be the drift that let 97 survive
|
|
143
|
+
* in the list months after the allocation retired it: the list and the sentence
|
|
144
|
+
* naming it must move together, so they are rendered from the same function.
|
|
145
|
+
*/
|
|
146
|
+
function refuseTenant(value, { purpose, setBy }) {
|
|
147
|
+
return new Error(`[TestNamespace] ${purpose}=${value} is not an allowed `
|
|
148
|
+
+ 'environment class - test and validation runs may never write into production data '
|
|
149
|
+
+ '(100 = LIVE, 101+ = CUSTOMER tenants, both refused). Expected one of 96 (CI), '
|
|
150
|
+
+ `98 (DEVEL), 99 (VALIDATION). Fix: set ${purpose} to one of those classes - `
|
|
151
|
+
+ `${setBy} - and seed that namespace from migrations/ - see `
|
|
152
|
+
+ 'api/docs/standards/tenant-allocation.md § The allocation and § Env variables.');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* May this tenant id be written into at all?
|
|
157
|
+
*
|
|
158
|
+
* The question a script asks about an id it was handed, as opposed to
|
|
159
|
+
* getTestNamespace(), which asks which namespace the platform env gives a test.
|
|
160
|
+
* Same whitelist, same refusal; only the name the value carries differs, and the
|
|
161
|
+
* caller supplies it so the message tells the operator what THEY must change.
|
|
162
|
+
*
|
|
163
|
+
* Named for what it enforces (`ALLOWED_TENANT_CLASSES`) the way getTestNamespace
|
|
164
|
+
* is named for what it returns. `assert`, not `is`: a boolean can be ignored by
|
|
165
|
+
* the caller that most needs it, and the value coming back normalized means the
|
|
166
|
+
* check cannot be done and then bypassed with the unchecked original.
|
|
167
|
+
*
|
|
168
|
+
* @param {number|string} tenantId the id to check — an integer, or a string
|
|
169
|
+
* holding one (what `Number(argv[i])` and a config file respectively hand
|
|
170
|
+
* over); no other shape is coerced.
|
|
171
|
+
* @param {{purpose: string, setBy: string}} options
|
|
172
|
+
* `purpose` — the name the caller knows the id by ("--to-tenant");
|
|
173
|
+
* `setBy` — where that name is given a value ("on the command line").
|
|
174
|
+
* @returns {number} the normalized id, safe to use as the namespace.
|
|
175
|
+
* @throws {Error} when the id is absent, not an integer, or not an allowed
|
|
176
|
+
* environment class.
|
|
177
|
+
*/
|
|
178
|
+
function assertAllowedTenant(tenantId, options) {
|
|
179
|
+
if (options === null || typeof options !== 'object') {
|
|
180
|
+
requireOriginText(undefined, 'purpose');
|
|
181
|
+
}
|
|
182
|
+
const { purpose, setBy } = options;
|
|
183
|
+
requireOriginText(purpose, 'purpose');
|
|
184
|
+
requireOriginText(setBy, 'setBy');
|
|
185
|
+
|
|
186
|
+
const value = normalizeNamespaceId(tenantId, { purpose, setBy });
|
|
187
|
+
if (!ALLOWED_TENANT_CLASSES.includes(value)) {
|
|
188
|
+
throw refuseTenant(value, { purpose, setBy });
|
|
69
189
|
}
|
|
70
190
|
return value;
|
|
71
191
|
}
|
|
72
192
|
|
|
193
|
+
/**
|
|
194
|
+
* The same rail for a WORKSPACE id that did not come from the environment.
|
|
195
|
+
*
|
|
196
|
+
* A Tier-1 cookbook declares the workspace its steps run in
|
|
197
|
+
* (`defaults.workspace_id`, and the per-step override —
|
|
198
|
+
* `.claude/rules/workspace-architecture.md`), so that value reaches ctx without
|
|
199
|
+
* passing `readNamespaceId()`. It goes through this function instead, for one
|
|
200
|
+
* reason: a declared value that is not an id must be REFUSED, naming the key
|
|
201
|
+
* that carries it, and must never slide back to the environment value. A
|
|
202
|
+
* fallback there would hide exactly the misconfiguration the boundary exists to
|
|
203
|
+
* surface (architecture-principles.md §3).
|
|
204
|
+
*
|
|
205
|
+
* There is deliberately NO class allowlist here, and that is not an omission:
|
|
206
|
+
*
|
|
207
|
+
* - `api/docs/standards/tenant-allocation.md` allocates environment classes to
|
|
208
|
+
* TENANTS ("Tenants are environment classes; that is the whole scheme"), and
|
|
209
|
+
* its § Adding a class says a new purpose gets a **workspace inside** the
|
|
210
|
+
* class — so a workspace id is a purpose, with no allocated range to check;
|
|
211
|
+
* - the platform's own default is `TESTING_WORKSPACE_ID=200`, which is not a
|
|
212
|
+
* member of `ALLOWED_TENANT_CLASSES` — applying the tenant list to a
|
|
213
|
+
* workspace would refuse the value the standard itself prescribes;
|
|
214
|
+
* - the boundary the guard has to hold is reachability of production data, and
|
|
215
|
+
* that is held by the tenant, which a cookbook cannot influence at all
|
|
216
|
+
* (`getTestNamespace()` remains the only source of `tenant_id` at Tier-1).
|
|
217
|
+
* Every workspace inside an allowed class is inside the test namespace.
|
|
218
|
+
*
|
|
219
|
+
* @param {number|string} workspaceId the declared id — an integer, or a string
|
|
220
|
+
* holding one; no other shape is coerced.
|
|
221
|
+
* @param {{purpose: string, setBy: string}} options as for assertAllowedTenant:
|
|
222
|
+
* `purpose` is the name the caller knows the id by (here, the cookbook key
|
|
223
|
+
* that carries it), `setBy` where that name is given a value.
|
|
224
|
+
* @returns {number} the normalized id.
|
|
225
|
+
* @throws {Error} when the id is absent or not an integer.
|
|
226
|
+
*/
|
|
227
|
+
function assertWorkspaceId(workspaceId, options) {
|
|
228
|
+
if (options === null || typeof options !== 'object') {
|
|
229
|
+
requireOriginText(undefined, 'purpose');
|
|
230
|
+
}
|
|
231
|
+
const { purpose, setBy } = options;
|
|
232
|
+
requireOriginText(purpose, 'purpose');
|
|
233
|
+
requireOriginText(setBy, 'setBy');
|
|
234
|
+
|
|
235
|
+
return normalizeNamespaceId(workspaceId, { purpose, setBy });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The env half of the boundary. The absence message says what an absent
|
|
240
|
+
* environment variable means and where that variable belongs; the rest of the
|
|
241
|
+
* rail — the integer check and, for the tenant, the whitelist — is the shared
|
|
242
|
+
* one. Six suites in this package assert this sentence verbatim, because it is
|
|
243
|
+
* what a service prints when its namespace is not configured at all.
|
|
244
|
+
*/
|
|
245
|
+
function readNamespaceId(key) {
|
|
246
|
+
const raw = process.env[key];
|
|
247
|
+
if (isBlank(raw)) {
|
|
248
|
+
throw new Error(`[TestNamespace] Missing environment variable - ${key} is required. `
|
|
249
|
+
+ 'Fix: set it locally in config/env-active/shared.env (the platform default is the '
|
|
250
|
+
+ 'non-production namespace); in CI it comes from the pipeline variables, not from '
|
|
251
|
+
+ 'a file in the repository - see api/docs/standards/tenant-allocation.md § Env variables. '
|
|
252
|
+
+ 'An integration test may not pick a namespace itself.');
|
|
253
|
+
}
|
|
254
|
+
return normalizeNamespaceId(raw, { purpose: key, setBy: ENV_SET_BY });
|
|
255
|
+
}
|
|
256
|
+
|
|
73
257
|
/**
|
|
74
258
|
* @returns {{tenant_id: number, workspace_id: number}} ctx-shaped, so it can be
|
|
75
259
|
* spread straight into a handler call.
|
|
76
260
|
*/
|
|
77
261
|
function getTestNamespace() {
|
|
78
|
-
const tenantId = readNamespaceId('TESTING_TENANT_ID');
|
|
79
|
-
|
|
80
262
|
// The runtime half of the protection, and the reason the tenant is decided
|
|
81
263
|
// before anything else is read. R8 rejects a per-service override before it
|
|
82
264
|
// ships; this refuses the accident that ships anyway, so a startup probe can
|
|
83
|
-
// never reach real data even if the configuration is wrong.
|
|
265
|
+
// never reach real data even if the configuration is wrong. The check itself
|
|
266
|
+
// is assertAllowedTenant() — the env is one caller of the boundary, not its
|
|
267
|
+
// owner.
|
|
84
268
|
//
|
|
85
269
|
// The workspace id is deliberately NOT class-checked: tenant-allocation.md
|
|
86
270
|
// allocates environment classes to TENANTS, and calls a workspace "the purpose
|
|
87
271
|
// within the class" without naming any range for it. A restriction here would
|
|
88
272
|
// be one this library invented.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
}
|
|
273
|
+
const tenantId = assertAllowedTenant(readNamespaceId('TESTING_TENANT_ID'), {
|
|
274
|
+
purpose: 'TESTING_TENANT_ID',
|
|
275
|
+
setBy: ENV_SET_BY
|
|
276
|
+
});
|
|
97
277
|
|
|
98
278
|
return {
|
|
99
279
|
tenant_id: tenantId,
|
|
@@ -101,4 +281,48 @@ function getTestNamespace() {
|
|
|
101
281
|
};
|
|
102
282
|
}
|
|
103
283
|
|
|
104
|
-
|
|
284
|
+
/**
|
|
285
|
+
* A namespace an integration test may write into that is NOT its own.
|
|
286
|
+
*
|
|
287
|
+
* The isolation half of the same boundary: a handler filters on tenant_id AND
|
|
288
|
+
* workspace_id, so proving it filters at all takes a second namespace whose rows
|
|
289
|
+
* must stay invisible. Two ways of getting one are banned, and both were in use:
|
|
290
|
+
*
|
|
291
|
+
* a literal — biz-hello asserted against tenant 101, a live CUSTOMER tenant
|
|
292
|
+
* (Meditest), so the isolation test wrote into real data;
|
|
293
|
+
*
|
|
294
|
+
* arithmetic — `TESTING_TENANT_ID + 1` reads as harmless until the platform
|
|
295
|
+
* default 99 makes it 100, the LIVE tenant, which is the accident this module
|
|
296
|
+
* exists to prevent.
|
|
297
|
+
*
|
|
298
|
+
* So the foreign tenant is another member of ALLOWED_TENANT_CLASSES, picked by
|
|
299
|
+
* POSITION in that list and wrapping at its end. Every value it can return is
|
|
300
|
+
* therefore a class the standard allocated to a non-production environment, and
|
|
301
|
+
* adding or retiring a class moves this helper with it — one list, one owner.
|
|
302
|
+
*
|
|
303
|
+
* The workspace is the caller's own: tenant-allocation.md allocates environment
|
|
304
|
+
* classes to TENANTS and names no range for a workspace, so a second workspace
|
|
305
|
+
* would be a value this library invented. The pair already differs, because the
|
|
306
|
+
* tenant does.
|
|
307
|
+
*
|
|
308
|
+
* Deterministic: same environment, same answer, so a fixture seeded for the
|
|
309
|
+
* foreign namespace in one run is the one the next run queries.
|
|
310
|
+
*
|
|
311
|
+
* @returns {{tenant_id: number, workspace_id: number}} ctx-shaped, like
|
|
312
|
+
* getTestNamespace(), and never equal to it.
|
|
313
|
+
*/
|
|
314
|
+
function getForeignTestNamespace() {
|
|
315
|
+
// Reuses getTestNamespace() rather than reading the env a second time: the
|
|
316
|
+
// whitelist refusal, the missing-variable message and the integer check are
|
|
317
|
+
// one rail, and a foreign namespace is never chosen for an own value that is
|
|
318
|
+
// itself refused.
|
|
319
|
+
const own = getTestNamespace();
|
|
320
|
+
const next = (ALLOWED_TENANT_CLASSES.indexOf(own.tenant_id) + 1) % ALLOWED_TENANT_CLASSES.length;
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
tenant_id: ALLOWED_TENANT_CLASSES[next],
|
|
324
|
+
workspace_id: own.workspace_id
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
module.exports = { getTestNamespace, getForeignTestNamespace, assertAllowedTenant, assertWorkspaceId };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one throwaway schema an integration test builds.
|
|
5
|
+
*
|
|
6
|
+
* WHY IT LIVES IN THIS PACKAGE. Every DB-owning biz service has integration
|
|
7
|
+
* suites that need a schema of their own — built from the service's migrations,
|
|
8
|
+
* written into, and dropped at the end. Each repository solved that for itself:
|
|
9
|
+
* biz-meta has `tests/helpers/schemaFixture.js` (three suites use it), and the
|
|
10
|
+
* shape before that was one baseline-only copy per test file. A second
|
|
11
|
+
* implementation of one concern is a defect by default
|
|
12
|
+
* (`change-discipline.md` § One rail per concern), and the concern here is not
|
|
13
|
+
* service-specific at all: the service declares WHAT its database is in
|
|
14
|
+
* `config/service/integration-contract.json`, and HOW a schema gets built from
|
|
15
|
+
* that declaration is uniform (`docs/biz/00-model/uniformity-principle.md`).
|
|
16
|
+
*
|
|
17
|
+
* WHAT IT SHARES WITH THE CI BUILD. Everything except one decision. The
|
|
18
|
+
* migration set and its order (`resolveMigrationPlan`, `migrationOrder.js`), the
|
|
19
|
+
* collation requirement (`requireCollation`), the `CREATE DATABASE … COLLATE`
|
|
20
|
+
* statement (`createDatabaseSql`) and the client invocation (`defaultExec`) all
|
|
21
|
+
* come from `setupDatabase.js`. The one difference is deliberate and is the
|
|
22
|
+
* whole point of the two names:
|
|
23
|
+
*
|
|
24
|
+
* `buildSchema` builds the service's REAL schema in CI, so it
|
|
25
|
+
* refuses a target it did not create — non-empty, or
|
|
26
|
+
* carrying another collation.
|
|
27
|
+
* `createThrowawaySchema` builds a schema the test owns, so it DROPS and
|
|
28
|
+
* recreates it, and refuses only one name: the one
|
|
29
|
+
* the service declares.
|
|
30
|
+
*
|
|
31
|
+
* WHAT IT IS TO DEPLOY-CONTRACT R8. R8 asks an integration test where its
|
|
32
|
+
* namespace comes from, because an integration test writes into a real
|
|
33
|
+
* database. A test that builds its own throwaway targets no shared namespace,
|
|
34
|
+
* and until now it earned its permit by containing the string `CREATE DATABASE`
|
|
35
|
+
* plus a `DB_NAME` redirect — a text heuristic that says nothing about where the
|
|
36
|
+
* statements actually land. Using this helper says it by construction: the
|
|
37
|
+
* schema is created here, every statement is executed against it by name, a
|
|
38
|
+
* `USE` line inside a migration cannot redirect them (§ below), and the declared
|
|
39
|
+
* schema is refused outright. So R8 reads the import instead of the string.
|
|
40
|
+
*
|
|
41
|
+
* THE SCHEMA IS CHOSEN BY THE CALLER, NEVER BY THE FILE. A migration written
|
|
42
|
+
* for the install runner commonly opens with `USE \`oagen_<service>\`;` — the
|
|
43
|
+
* production schema by name. Applied verbatim against a throwaway, that one line
|
|
44
|
+
* sends the whole file into the live schema instead. Those lines are therefore
|
|
45
|
+
* stripped, and a schema select that survives the strip stops the build rather
|
|
46
|
+
* than being applied.
|
|
47
|
+
*
|
|
48
|
+
* @see src/utils/setupDatabase.js — the CI build, and the shared decisions
|
|
49
|
+
* @see src/utils/deployContract.js — R8, which permits a file that imports this
|
|
50
|
+
* @see api/docs/standards/tenant-allocation.md § Enforcement
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
const fs = require('fs');
|
|
54
|
+
const path = require('path');
|
|
55
|
+
|
|
56
|
+
const {
|
|
57
|
+
resolveMigrationPlan,
|
|
58
|
+
requireCollation,
|
|
59
|
+
createDatabaseSql,
|
|
60
|
+
defaultExec
|
|
61
|
+
} = require('./setupDatabase');
|
|
62
|
+
|
|
63
|
+
/** `USE \`x\`;` at the start of a line — the install runner's schema select. */
|
|
64
|
+
const LEADING_SCHEMA_SELECT = /^\s*USE\s+/i;
|
|
65
|
+
/** A schema select that survived the strip: at the start of a line, or after a `;`. */
|
|
66
|
+
const SURVIVING_SCHEMA_SELECT = /(?:^|;)\s*USE\s+/i;
|
|
67
|
+
/** An SQL comment line — prose cannot select a schema. */
|
|
68
|
+
const SQL_COMMENT = /^\s*(?:--|#|\/\*|\*)/;
|
|
69
|
+
|
|
70
|
+
function require_(value, name, fix) {
|
|
71
|
+
if (value === undefined || value === null || value === '') {
|
|
72
|
+
throw new Error(`[ThrowawaySchema] Missing ${name} - Expected a value. Fix: ${fix}`);
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The SQL of one migration, with the install runner's schema select removed.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} file absolute path to the .sql file
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
function sqlWithoutSchemaSelect(file) {
|
|
84
|
+
const stripped = fs.readFileSync(file, 'utf8')
|
|
85
|
+
.split('\n')
|
|
86
|
+
.map((line) => (LEADING_SCHEMA_SELECT.test(line) ? '' : line))
|
|
87
|
+
.join('\n');
|
|
88
|
+
|
|
89
|
+
const selects = stripped.split('\n')
|
|
90
|
+
.some((line) => !SQL_COMMENT.test(line) && SURVIVING_SCHEMA_SELECT.test(line));
|
|
91
|
+
|
|
92
|
+
if (selects) {
|
|
93
|
+
throw new Error(`[ThrowawaySchema] ${path.basename(file)} still selects a schema after the leading `
|
|
94
|
+
+ 'USE lines were stripped - refusing to apply it.\n'
|
|
95
|
+
+ ' A throwaway schema is chosen by the test; a USE inside the file would send its statements '
|
|
96
|
+
+ 'somewhere else, and that somewhere is the live schema in every migration written for the install '
|
|
97
|
+
+ 'runner.\n'
|
|
98
|
+
+ ' Fix: put the schema select on a line of its own, or remove it — the runner selects the '
|
|
99
|
+
+ 'schema for the file.');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return stripped;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build a throwaway schema from the service's declared migrations.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} options
|
|
109
|
+
* @param {string} options.serviceRoot repository root of the service
|
|
110
|
+
* @param {object} options.database the `database` block of the integration contract
|
|
111
|
+
* @param {string} options.schema the throwaway name; never `database.schema`
|
|
112
|
+
* @param {object} options.connection `{host, port, user, password}`
|
|
113
|
+
* @param {Function} [options.exec] the SQL executor; defaults to the mysql/mariadb CLI,
|
|
114
|
+
* the same one the CI build uses. May be async — a service whose test image carries
|
|
115
|
+
* no client injects an executor over its own driver connection.
|
|
116
|
+
* @param {string|null} [options.before] apply only the migrations that precede this
|
|
117
|
+
* file name, which is the state that migration is applied to
|
|
118
|
+
* @param {boolean} [options.seeds] also apply the declared seeds, after the migrations
|
|
119
|
+
* @returns {Promise<{schema: string, migrationsApplied: number, seedsApplied: number,
|
|
120
|
+
* migrations: string[], readMigration: Function, applyMigration: Function, dispose: Function}>}
|
|
121
|
+
*/
|
|
122
|
+
async function createThrowawaySchema({
|
|
123
|
+
serviceRoot, database, schema, connection, exec = defaultExec, before = null, seeds = false
|
|
124
|
+
}) {
|
|
125
|
+
require_(serviceRoot, 'serviceRoot', 'pass the repository root of the service under test.');
|
|
126
|
+
require_(database, 'database', 'pass the "database" block of config/service/integration-contract.json.');
|
|
127
|
+
require_(schema, 'schema', 'pass the throwaway schema name this suite owns.');
|
|
128
|
+
require_(connection?.host, 'connection.host', 'pass DB_HOST.');
|
|
129
|
+
require_(connection?.user, 'connection.user', 'pass DB_USER.');
|
|
130
|
+
|
|
131
|
+
if (schema === database.schema) {
|
|
132
|
+
throw new Error(`[ThrowawaySchema] Refusing to build ${schema} - that is the schema the service `
|
|
133
|
+
+ 'declares, and this build DROPS the schema it is given.\n'
|
|
134
|
+
+ ' Fix: pass a name of this suite\'s own, derived from the declared one '
|
|
135
|
+
+ `(e.g. "${database.schema}_<suite>test").`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const conn = { ...connection, port: connection.port ?? 3306 };
|
|
139
|
+
const collation = requireCollation(database);
|
|
140
|
+
const plan = resolveMigrationPlan(serviceRoot, database);
|
|
141
|
+
|
|
142
|
+
const fileOf = (name) => {
|
|
143
|
+
const found = plan.migrations.find((file) => path.basename(file) === name);
|
|
144
|
+
if (found === undefined) {
|
|
145
|
+
throw new Error(`[ThrowawaySchema] No migration named "${name}" in ${plan.dir}.\n`
|
|
146
|
+
+ ' Fix: name a file the declaration carries; the directory contents are the set, and '
|
|
147
|
+
+ 'nothing here keeps a list of them.');
|
|
148
|
+
}
|
|
149
|
+
return found;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
let toApply = plan.migrations;
|
|
153
|
+
if (before !== null) {
|
|
154
|
+
const stop = plan.migrations.indexOf(fileOf(before));
|
|
155
|
+
if (stop === 0) {
|
|
156
|
+
throw new Error(`[ThrowawaySchema] Nothing is applied before "${before}" - refusing to call an empty `
|
|
157
|
+
+ 'schema built.\n'
|
|
158
|
+
+ ' Fix: name a migration that has predecessors in the declared set.');
|
|
159
|
+
}
|
|
160
|
+
toApply = plan.migrations.slice(0, stop);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const run = async (sql) => {
|
|
164
|
+
const result = await exec({ connection: conn, sql });
|
|
165
|
+
if (result.status !== 0) {
|
|
166
|
+
throw new Error(`[ThrowawaySchema] ${sql} failed:\n${result.stderr}\n`
|
|
167
|
+
+ ` Fix: the account must be allowed to create and drop ${schema} on `
|
|
168
|
+
+ `${conn.host}:${conn.port}; point DB_HOST at the throwaway database, never at a live server.`);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const apply = async (file, kind) => {
|
|
173
|
+
const result = await exec({
|
|
174
|
+
connection: conn, schema, file, sql: sqlWithoutSchemaSelect(file)
|
|
175
|
+
});
|
|
176
|
+
if (result.status !== 0) {
|
|
177
|
+
throw new Error(`[ThrowawaySchema] ${kind} ${path.basename(file)} failed:\n${result.stderr}\n`
|
|
178
|
+
+ ' Fix: the set must apply to an empty schema in file order. Either the SQL is wrong, or '
|
|
179
|
+
+ 'the file is numbered before something it depends on.');
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const dropSql = `DROP DATABASE IF EXISTS \`${schema}\`;`;
|
|
184
|
+
|
|
185
|
+
// Dropped first, not "if absent": a suite that died mid-run left its schema
|
|
186
|
+
// behind, and a build onto those leftovers would prove the migration set
|
|
187
|
+
// against a shape it never produced.
|
|
188
|
+
await run(dropSql);
|
|
189
|
+
await run(createDatabaseSql(schema, collation));
|
|
190
|
+
|
|
191
|
+
for (const file of toApply) await apply(file, 'Migration');
|
|
192
|
+
|
|
193
|
+
const seedFiles = seeds === true ? plan.seeds : [];
|
|
194
|
+
for (const file of seedFiles) await apply(file, 'Seed');
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
schema,
|
|
198
|
+
migrationsApplied: toApply.length,
|
|
199
|
+
seedsApplied: seedFiles.length,
|
|
200
|
+
migrations: [...plan.migrations],
|
|
201
|
+
readMigration: (name) => sqlWithoutSchemaSelect(fileOf(name)),
|
|
202
|
+
applyMigration: async (name) => apply(fileOf(name), 'Migration'),
|
|
203
|
+
dispose: async () => run(dropSql)
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { createThrowawaySchema, sqlWithoutSchemaSelect };
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
const fs = require('fs');
|
|
19
19
|
const path = require('path');
|
|
20
|
+
const { HANDLER_REF_PATTERN } = require('../utils/handlerRef');
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Read a configuration file from the ONE place a service may keep it:
|
|
@@ -704,7 +705,7 @@ class ServiceStructureValidator {
|
|
|
704
705
|
}
|
|
705
706
|
}
|
|
706
707
|
|
|
707
|
-
if (spec.handler &&
|
|
708
|
+
if (spec.handler && !HANDLER_REF_PATTERN.test(spec.handler)) {
|
|
708
709
|
this.errors.push({
|
|
709
710
|
type: 'INVALID_HANDLER_REF',
|
|
710
711
|
path: 'config/service/operations.json',
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# --- oa-dockerignore v1
|
|
2
|
+
# Derived from templates/business-service/gitignore, the declaration .gitignore reads too —
|
|
3
|
+
# what a LOCAL production build must not copy into the image (being ignored by git
|
|
4
|
+
# excludes nothing from COPY . .). Everything above this block is this repository's own.
|
|
5
|
+
node_modules
|
|
6
|
+
**/node_modules
|
|
7
|
+
config/runtime
|
|
8
|
+
config/env-active
|
|
9
|
+
conn-runtime
|
|
10
|
+
**/conn-runtime
|
|
11
|
+
ci
|
|
12
|
+
**/ci
|
|
13
|
+
logs
|
|
14
|
+
**/logs
|
|
15
|
+
*.log
|
|
16
|
+
**/*.log
|
|
17
|
+
npm-debug.log*
|
|
18
|
+
**/npm-debug.log*
|
|
19
|
+
coverage
|
|
20
|
+
**/coverage
|
|
21
|
+
.env
|
|
22
|
+
**/.env
|
|
23
|
+
.env.local
|
|
24
|
+
**/.env.local
|
|
25
|
+
.vscode
|
|
26
|
+
**/.vscode
|
|
27
|
+
.idea
|
|
28
|
+
**/.idea
|
|
29
|
+
*.swp
|
|
30
|
+
**/*.swp
|
|
31
|
+
*.swo
|
|
32
|
+
**/*.swo
|
|
33
|
+
*~
|
|
34
|
+
**/*~
|
|
35
|
+
.DS_Store
|
|
36
|
+
**/.DS_Store
|
|
37
|
+
Thumbs.db
|
|
38
|
+
**/Thumbs.db
|
|
39
|
+
.oa_drive_deps_hash
|
|
40
|
+
**/.oa_drive_deps_hash
|
|
41
|
+
.git
|
|
42
|
+
# --- end oa-dockerignore v1
|