@onlineapps/conn-orch-validator 2.0.32 → 2.0.34
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/docs/DESIGN.md +64 -118
- package/package.json +2 -2
- package/src/CookbookTestRunner.js +16 -4
- package/src/ServiceReadinessValidator.js +40 -54
- package/src/ValidationOrchestrator.js +9 -5
- package/src/config.js +1 -1
- package/src/helpers/createServiceReadinessTests.js +1 -1
- package/src/index.js +13 -19
- package/examples/service-wrapper-usage.js +0 -250
- package/examples/three-tier-testing.js +0 -144
- package/src/ServiceTestHarness.js +0 -256
- package/src/ServiceValidator.js +0 -399
- package/src/TestOrchestrator.js +0 -730
package/src/ServiceValidator.js
DELETED
|
@@ -1,399 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const axios = require('axios');
|
|
4
|
-
const Ajv = require('ajv');
|
|
5
|
-
const addFormats = require('ajv-formats');
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* ServiceValidator - Validates service compliance with OpenAPI spec
|
|
9
|
-
*/
|
|
10
|
-
class ServiceValidator {
|
|
11
|
-
constructor(options = {}) {
|
|
12
|
-
this.ajv = new Ajv({ allErrors: true, strict: false });
|
|
13
|
-
addFormats(this.ajv);
|
|
14
|
-
this.timeout = options.timeout || 5000;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Validate service against OpenAPI specification
|
|
19
|
-
*/
|
|
20
|
-
async validateService(serviceUrl, openApiSpec) {
|
|
21
|
-
const results = {
|
|
22
|
-
valid: true,
|
|
23
|
-
errors: [],
|
|
24
|
-
warnings: [],
|
|
25
|
-
testedEndpoints: [],
|
|
26
|
-
coverage: 0
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
if (!openApiSpec.paths) {
|
|
30
|
-
results.valid = false;
|
|
31
|
-
results.errors.push('No paths defined in OpenAPI spec');
|
|
32
|
-
return results;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const totalEndpoints = this.countEndpoints(openApiSpec.paths);
|
|
36
|
-
let testedCount = 0;
|
|
37
|
-
|
|
38
|
-
// Test each endpoint
|
|
39
|
-
for (const [path, pathItem] of Object.entries(openApiSpec.paths)) {
|
|
40
|
-
for (const [method, operation] of Object.entries(pathItem)) {
|
|
41
|
-
if (method === 'parameters' || method.startsWith('x-')) continue;
|
|
42
|
-
|
|
43
|
-
const endpointResult = await this.testEndpoint(
|
|
44
|
-
serviceUrl,
|
|
45
|
-
path,
|
|
46
|
-
method,
|
|
47
|
-
operation
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
results.testedEndpoints.push(endpointResult);
|
|
51
|
-
if (!endpointResult.valid) {
|
|
52
|
-
results.valid = false;
|
|
53
|
-
results.errors.push(...endpointResult.errors);
|
|
54
|
-
}
|
|
55
|
-
if (endpointResult.warnings) {
|
|
56
|
-
results.warnings.push(...endpointResult.warnings);
|
|
57
|
-
}
|
|
58
|
-
testedCount++;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
results.coverage = (testedCount / totalEndpoints) * 100;
|
|
63
|
-
return results;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Test individual endpoint
|
|
68
|
-
*/
|
|
69
|
-
async testEndpoint(serviceUrl, path, method, operation) {
|
|
70
|
-
const result = {
|
|
71
|
-
path,
|
|
72
|
-
method: method.toUpperCase(),
|
|
73
|
-
operationId: operation.operationId,
|
|
74
|
-
valid: true,
|
|
75
|
-
errors: [],
|
|
76
|
-
warnings: [],
|
|
77
|
-
response: null
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
// Build test request
|
|
82
|
-
const testRequest = this.buildTestRequest(path, method, operation);
|
|
83
|
-
const url = `${serviceUrl}${testRequest.path}`;
|
|
84
|
-
|
|
85
|
-
// Execute request
|
|
86
|
-
const response = await axios({
|
|
87
|
-
method: method.toUpperCase(),
|
|
88
|
-
url,
|
|
89
|
-
data: testRequest.body,
|
|
90
|
-
params: testRequest.query,
|
|
91
|
-
headers: testRequest.headers,
|
|
92
|
-
timeout: this.timeout,
|
|
93
|
-
validateStatus: () => true // Don't throw on any status
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
result.response = {
|
|
97
|
-
status: response.status,
|
|
98
|
-
headers: response.headers,
|
|
99
|
-
data: response.data
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
// Validate response
|
|
103
|
-
this.validateResponse(response, operation, result);
|
|
104
|
-
|
|
105
|
-
} catch (error) {
|
|
106
|
-
result.valid = false;
|
|
107
|
-
result.errors.push(`Failed to call endpoint: ${error.message}`);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return result;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Build test request from OpenAPI operation
|
|
115
|
-
*/
|
|
116
|
-
buildTestRequest(path, method, operation) {
|
|
117
|
-
const request = {
|
|
118
|
-
path,
|
|
119
|
-
headers: {
|
|
120
|
-
// Validation context: ALWAYS add x-validation-request header to indicate this is a validation probe.
|
|
121
|
-
// This allows services to return deterministic responses without accessing external resources.
|
|
122
|
-
'x-validation-request': 'true'
|
|
123
|
-
},
|
|
124
|
-
query: {},
|
|
125
|
-
body: null
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
// Process parameters
|
|
129
|
-
if (operation.parameters) {
|
|
130
|
-
operation.parameters.forEach(param => {
|
|
131
|
-
const testValue = this.generateTestValue(param.schema);
|
|
132
|
-
|
|
133
|
-
switch (param.in) {
|
|
134
|
-
case 'path':
|
|
135
|
-
request.path = request.path.replace(`{${param.name}}`, testValue);
|
|
136
|
-
break;
|
|
137
|
-
case 'query':
|
|
138
|
-
if (!param.required && Math.random() > 0.5) break;
|
|
139
|
-
request.query[param.name] = testValue;
|
|
140
|
-
break;
|
|
141
|
-
case 'header':
|
|
142
|
-
if (!param.required && Math.random() > 0.5) break;
|
|
143
|
-
request.headers[param.name] = testValue;
|
|
144
|
-
break;
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Process request body
|
|
150
|
-
if (operation.requestBody && operation.requestBody.content) {
|
|
151
|
-
const contentType = Object.keys(operation.requestBody.content)[0];
|
|
152
|
-
const schema = operation.requestBody.content[contentType].schema;
|
|
153
|
-
request.headers['Content-Type'] = contentType;
|
|
154
|
-
request.body = this.generateTestValue(schema);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
return request;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Generate test value based on schema
|
|
162
|
-
*/
|
|
163
|
-
generateTestValue(schema) {
|
|
164
|
-
if (!schema) return null;
|
|
165
|
-
|
|
166
|
-
switch (schema.type) {
|
|
167
|
-
case 'string':
|
|
168
|
-
if (schema.enum) return schema.enum[0];
|
|
169
|
-
if (schema.format === 'email') return 'test@example.com';
|
|
170
|
-
if (schema.format === 'date') return '2024-01-01';
|
|
171
|
-
if (schema.format === 'date-time') return '2024-01-01T00:00:00Z';
|
|
172
|
-
if (schema.format === 'uuid') return '123e4567-e89b-12d3-a456-426614174000';
|
|
173
|
-
return schema.example || 'test';
|
|
174
|
-
|
|
175
|
-
case 'number':
|
|
176
|
-
case 'integer':
|
|
177
|
-
if (schema.minimum !== undefined) return schema.minimum;
|
|
178
|
-
if (schema.maximum !== undefined) return schema.maximum;
|
|
179
|
-
return schema.example || 1;
|
|
180
|
-
|
|
181
|
-
case 'boolean':
|
|
182
|
-
return schema.example !== undefined ? schema.example : true;
|
|
183
|
-
|
|
184
|
-
case 'array':
|
|
185
|
-
const itemValue = this.generateTestValue(schema.items);
|
|
186
|
-
return [itemValue];
|
|
187
|
-
|
|
188
|
-
case 'object':
|
|
189
|
-
const obj = {};
|
|
190
|
-
if (schema.properties) {
|
|
191
|
-
const entries = Object.entries(schema.properties);
|
|
192
|
-
entries.forEach(([key, prop]) => {
|
|
193
|
-
if (schema.required && schema.required.includes(key)) {
|
|
194
|
-
obj[key] = this.generateTestValue(prop);
|
|
195
|
-
} else if (Math.random() > 0.5) {
|
|
196
|
-
obj[key] = this.generateTestValue(prop);
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
// If schema has properties but nothing was picked (e.g., no required fields and randomness skipped),
|
|
201
|
-
// pick the first property to keep requests deterministic and non-empty.
|
|
202
|
-
if (Object.keys(obj).length === 0 && entries.length > 0) {
|
|
203
|
-
const [firstKey, firstSchema] = entries[0];
|
|
204
|
-
obj[firstKey] = this.generateTestValue(firstSchema);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return obj;
|
|
208
|
-
|
|
209
|
-
default:
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Validate response against OpenAPI schema
|
|
216
|
-
*/
|
|
217
|
-
validateResponse(response, operation, result) {
|
|
218
|
-
if (!operation.responses) {
|
|
219
|
-
result.warnings.push('No responses defined in OpenAPI spec');
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const statusCode = response.status.toString();
|
|
224
|
-
const responseSpec = operation.responses[statusCode] || operation.responses.default;
|
|
225
|
-
|
|
226
|
-
if (!responseSpec) {
|
|
227
|
-
result.valid = false;
|
|
228
|
-
result.errors.push(`Unexpected status code: ${statusCode}`);
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Validate response body
|
|
233
|
-
if (responseSpec.content) {
|
|
234
|
-
const contentType = Object.keys(responseSpec.content)[0];
|
|
235
|
-
const schema = responseSpec.content[contentType].schema;
|
|
236
|
-
|
|
237
|
-
if (schema) {
|
|
238
|
-
const validate = this.ajv.compile(schema);
|
|
239
|
-
if (!validate(response.data)) {
|
|
240
|
-
result.valid = false;
|
|
241
|
-
result.errors.push(`Response validation failed: ${JSON.stringify(validate.errors)}`);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// Validate headers
|
|
247
|
-
if (responseSpec.headers) {
|
|
248
|
-
Object.entries(responseSpec.headers).forEach(([headerName, headerSpec]) => {
|
|
249
|
-
const headerValue = response.headers[headerName.toLowerCase()];
|
|
250
|
-
if (headerSpec.required && !headerValue) {
|
|
251
|
-
result.valid = false;
|
|
252
|
-
result.errors.push(`Required header missing: ${headerName}`);
|
|
253
|
-
}
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Count total endpoints in paths
|
|
260
|
-
*/
|
|
261
|
-
countEndpoints(paths) {
|
|
262
|
-
let count = 0;
|
|
263
|
-
Object.values(paths).forEach(pathItem => {
|
|
264
|
-
Object.keys(pathItem).forEach(method => {
|
|
265
|
-
if (method !== 'parameters' && !method.startsWith('x-')) {
|
|
266
|
-
count++;
|
|
267
|
-
}
|
|
268
|
-
});
|
|
269
|
-
});
|
|
270
|
-
return count;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Validate cookbook structure and operations
|
|
275
|
-
*/
|
|
276
|
-
async validateCookbook(cookbook, serviceRegistry) {
|
|
277
|
-
const results = {
|
|
278
|
-
valid: true,
|
|
279
|
-
errors: [],
|
|
280
|
-
warnings: [],
|
|
281
|
-
validatedSteps: []
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
if (!cookbook.steps || !Array.isArray(cookbook.steps)) {
|
|
285
|
-
results.valid = false;
|
|
286
|
-
results.errors.push('Cookbook must have steps array');
|
|
287
|
-
return results;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Validate each step
|
|
291
|
-
for (const step of cookbook.steps) {
|
|
292
|
-
const stepResult = await this.validateStep(step, serviceRegistry);
|
|
293
|
-
results.validatedSteps.push(stepResult);
|
|
294
|
-
|
|
295
|
-
if (!stepResult.valid) {
|
|
296
|
-
results.valid = false;
|
|
297
|
-
results.errors.push(...stepResult.errors);
|
|
298
|
-
}
|
|
299
|
-
if (stepResult.warnings) {
|
|
300
|
-
results.warnings.push(...stepResult.warnings);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
return results;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Validate individual cookbook step
|
|
309
|
-
*/
|
|
310
|
-
async validateStep(step, serviceRegistry) {
|
|
311
|
-
const result = {
|
|
312
|
-
stepId: step.id,
|
|
313
|
-
valid: true,
|
|
314
|
-
errors: [],
|
|
315
|
-
warnings: []
|
|
316
|
-
};
|
|
317
|
-
|
|
318
|
-
// Basic validation
|
|
319
|
-
if (!step.id) {
|
|
320
|
-
result.valid = false;
|
|
321
|
-
result.errors.push('Step must have an id');
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (!step.type) {
|
|
325
|
-
result.valid = false;
|
|
326
|
-
result.errors.push('Step must have a type');
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Type-specific validation
|
|
330
|
-
switch (step.type) {
|
|
331
|
-
case 'task':
|
|
332
|
-
if (!step.service) {
|
|
333
|
-
result.valid = false;
|
|
334
|
-
result.errors.push('Task step must have a service');
|
|
335
|
-
} else {
|
|
336
|
-
// Check if service exists
|
|
337
|
-
const service = serviceRegistry.getService(step.service);
|
|
338
|
-
if (!service) {
|
|
339
|
-
result.valid = false;
|
|
340
|
-
result.errors.push(`Service not found: ${step.service}`);
|
|
341
|
-
} else if (step.operation) {
|
|
342
|
-
// Validate operation exists in service
|
|
343
|
-
const hasOperation = this.serviceHasOperation(
|
|
344
|
-
service.openapi,
|
|
345
|
-
step.operation
|
|
346
|
-
);
|
|
347
|
-
if (!hasOperation) {
|
|
348
|
-
result.valid = false;
|
|
349
|
-
result.errors.push(`Operation not found: ${step.operation}`);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
break;
|
|
354
|
-
|
|
355
|
-
case 'foreach':
|
|
356
|
-
if (!step.items) {
|
|
357
|
-
result.valid = false;
|
|
358
|
-
result.errors.push('Foreach step must have items');
|
|
359
|
-
}
|
|
360
|
-
if (!step.body) {
|
|
361
|
-
result.valid = false;
|
|
362
|
-
result.errors.push('Foreach step must have body');
|
|
363
|
-
}
|
|
364
|
-
break;
|
|
365
|
-
|
|
366
|
-
case 'switch':
|
|
367
|
-
if (!step.condition) {
|
|
368
|
-
result.valid = false;
|
|
369
|
-
result.errors.push('Switch step must have condition');
|
|
370
|
-
}
|
|
371
|
-
if (!step.cases || !Array.isArray(step.cases)) {
|
|
372
|
-
result.valid = false;
|
|
373
|
-
result.errors.push('Switch step must have cases array');
|
|
374
|
-
}
|
|
375
|
-
break;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
return result;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* Check if service has operation
|
|
383
|
-
*/
|
|
384
|
-
serviceHasOperation(openApiSpec, operationId) {
|
|
385
|
-
if (!openApiSpec || !openApiSpec.paths) return false;
|
|
386
|
-
|
|
387
|
-
for (const pathItem of Object.values(openApiSpec.paths)) {
|
|
388
|
-
for (const operation of Object.values(pathItem)) {
|
|
389
|
-
if (operation.operationId === operationId) {
|
|
390
|
-
return true;
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
return false;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
module.exports = ServiceValidator;
|