@aifabrix/builder 2.32.2 → 2.33.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/.cursor/rules/project-rules.mdc +8 -0
- package/README.md +36 -8
- package/bin/aifabrix.js +6 -8
- package/integration/hubspot/README.md +8 -7
- package/integration/hubspot/companies.json +2048 -0
- package/integration/hubspot/create-hubspot.js +665 -0
- package/integration/hubspot/{hubspot-deploy-company.json → hubspot-datasource-company.json} +1 -1
- package/integration/hubspot/{hubspot-deploy-contact.json → hubspot-datasource-contact.json} +1 -1
- package/integration/hubspot/{hubspot-deploy-deal.json → hubspot-datasource-deal.json} +1 -1
- package/integration/hubspot/hubspot-deploy.json +832 -81
- package/integration/hubspot/hubspot-system.json +99 -0
- package/integration/hubspot/test-artifacts/wizard-hubspot-credential-real.yaml +20 -0
- package/integration/hubspot/test-artifacts/wizard-hubspot-env-vars.yaml +9 -0
- package/integration/hubspot/test-artifacts/wizard-invalid-add-datasource.yaml +5 -0
- package/integration/hubspot/test-artifacts/wizard-invalid-app-name.yaml +5 -0
- package/integration/hubspot/test-artifacts/wizard-invalid-credential-create.yaml +7 -0
- package/integration/hubspot/test-artifacts/wizard-invalid-credential-select.yaml +7 -0
- package/integration/hubspot/test-artifacts/wizard-invalid-known-platform.yaml +4 -0
- package/integration/hubspot/test-artifacts/wizard-invalid-missing-app.yaml +4 -0
- package/integration/hubspot/test-artifacts/wizard-invalid-missing-source.yaml +2 -0
- package/integration/hubspot/test-artifacts/wizard-invalid-mode.yaml +5 -0
- package/integration/hubspot/test-artifacts/wizard-invalid-openapi-file.yaml +5 -0
- package/integration/hubspot/test-artifacts/wizard-invalid-openapi-url.yaml +4 -0
- package/integration/hubspot/test-artifacts/wizard-invalid-source.yaml +4 -0
- package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-array-test.yaml +5 -0
- package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-key-test.yaml +5 -0
- package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-path-test.yaml +5 -0
- package/integration/hubspot/test-artifacts/wizard-valid-for-dimension-test.yaml +5 -0
- package/integration/hubspot/test-artifacts/wizard-valid-for-rbac-test.yaml +5 -0
- package/integration/hubspot/test-artifacts/wizard-valid-for-rbac-yaml-test.yaml +5 -0
- package/integration/hubspot/test-dataplane-down-helpers.js +246 -0
- package/integration/hubspot/test-dataplane-down-tests.js +419 -0
- package/integration/hubspot/test-dataplane-down.js +157 -0
- package/integration/hubspot/test.js +1517 -0
- package/integration/hubspot/variables.yaml +4 -4
- package/integration/hubspot/wizard-hubspot-e2e.yaml +16 -0
- package/integration/hubspot/wizard-hubspot-platform.yaml +8 -0
- package/lib/api/applications.api.js +1 -0
- package/lib/api/index.js +10 -5
- package/lib/api/types/wizard.types.js +176 -38
- package/lib/api/wizard.api.js +207 -38
- package/lib/app/deploy.js +116 -54
- package/lib/app/display.js +6 -5
- package/lib/app/dockerfile.js +2 -1
- package/lib/app/list.js +78 -37
- package/lib/app/prompts.js +9 -5
- package/lib/app/readme.js +41 -112
- package/lib/app/register.js +44 -9
- package/lib/app/rotate-secret.js +50 -32
- package/lib/cli.js +243 -65
- package/lib/commands/app.js +4 -9
- package/lib/commands/auth-config.js +125 -0
- package/lib/commands/auth-status.js +261 -0
- package/lib/commands/datasource.js +3 -6
- package/lib/commands/login-credentials.js +4 -4
- package/lib/commands/login-device.js +43 -29
- package/lib/commands/login.js +22 -13
- package/lib/commands/wizard-config-normalizer.js +92 -0
- package/lib/commands/wizard-core.js +515 -0
- package/lib/commands/wizard-dataplane.js +122 -0
- package/lib/commands/wizard-headless.js +115 -0
- package/lib/commands/wizard.js +129 -357
- package/lib/core/config.js +46 -0
- package/lib/core/secrets.js +3 -22
- package/lib/core/templates-env.js +1 -1
- package/lib/datasource/deploy.js +34 -23
- package/lib/datasource/list.js +8 -6
- package/lib/deployment/deployer.js +25 -0
- package/lib/deployment/environment.js +10 -13
- package/lib/external-system/delete.js +151 -0
- package/lib/external-system/deploy.js +54 -378
- package/lib/external-system/download-helpers.js +45 -65
- package/lib/external-system/download.js +34 -13
- package/lib/external-system/generator.js +11 -7
- package/lib/external-system/test-auth.js +5 -3
- package/lib/generator/builders.js +3 -1
- package/lib/generator/external-controller-manifest.js +157 -0
- package/lib/generator/external-schema-utils.js +236 -0
- package/lib/generator/external.js +55 -3
- package/lib/generator/index.js +22 -10
- package/lib/generator/wizard-prompts.js +33 -10
- package/lib/generator/wizard.js +69 -86
- package/lib/infrastructure/compose.js +100 -0
- package/lib/infrastructure/helpers.js +139 -0
- package/lib/infrastructure/index.js +52 -311
- package/lib/infrastructure/services.js +168 -0
- package/lib/schema/application-schema.json +24 -5
- package/lib/schema/external-datasource.schema.json +303 -17
- package/lib/schema/external-system.schema.json +1 -1
- package/lib/schema/wizard-config.schema.json +234 -0
- package/lib/utils/api.js +37 -42
- package/lib/utils/app-existence.js +42 -0
- package/lib/utils/app-register-config.js +7 -2
- package/lib/utils/app-register-display.js +2 -1
- package/lib/utils/auth-config-validator.js +92 -0
- package/lib/utils/cli-utils.js +3 -1
- package/lib/utils/command-header.js +43 -0
- package/lib/utils/compose-generator.js +113 -70
- package/lib/utils/controller-url.js +115 -0
- package/lib/utils/dataplane-health.js +115 -0
- package/lib/utils/dataplane-resolver.js +29 -0
- package/lib/utils/dev-config.js +6 -2
- package/lib/utils/env-copy.js +2 -1
- package/lib/utils/env-map.js +2 -1
- package/lib/utils/env-ports.js +2 -1
- package/lib/utils/env-template.js +1 -1
- package/lib/utils/error-formatter.js +149 -28
- package/lib/utils/external-readme.js +125 -0
- package/lib/utils/help-builder.js +190 -0
- package/lib/utils/infra-status.js +13 -3
- package/lib/utils/paths.js +17 -2
- package/lib/utils/port-resolver.js +111 -0
- package/lib/utils/secrets-helpers.js +3 -15
- package/lib/utils/secrets-utils.js +2 -2
- package/lib/utils/token-manager.js +69 -4
- package/lib/utils/variable-transformer.js +7 -2
- package/lib/validation/external-manifest-validator.js +202 -0
- package/lib/validation/validate-display.js +406 -0
- package/lib/validation/validate.js +159 -123
- package/lib/validation/validator.js +38 -4
- package/lib/validation/wizard-config-validator.js +267 -0
- package/package.json +4 -2
- package/templates/applications/README.md.hbs +19 -17
- package/templates/applications/miso-controller/env.template +1 -1
- package/templates/applications/miso-controller/rbac.yaml +7 -7
- package/templates/external-system/README.md.hbs +99 -0
- package/templates/external-system/external-system.json.hbs +1 -1
- package/templates/infra/compose.yaml.hbs +35 -0
- package/templates/python/docker-compose.hbs +26 -0
- package/templates/typescript/docker-compose.hbs +26 -0
|
@@ -0,0 +1,1517 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* HubSpot External System Wizard E2E Test Runner
|
|
4
|
+
*
|
|
5
|
+
* @fileoverview Runs end-to-end wizard tests for HubSpot integration.
|
|
6
|
+
* @author AI Fabrix Team
|
|
7
|
+
* @version 2.0.0
|
|
8
|
+
*/
|
|
9
|
+
/* eslint-disable max-lines */
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs').promises;
|
|
13
|
+
const fsSync = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { execFile } = require('child_process');
|
|
16
|
+
const { promisify } = require('util');
|
|
17
|
+
const yaml = require('js-yaml');
|
|
18
|
+
const chalk = require('chalk');
|
|
19
|
+
const { getDeploymentAuth } = require('../../lib/utils/token-manager');
|
|
20
|
+
const { discoverDataplaneUrl } = require('../../lib/commands/wizard-dataplane');
|
|
21
|
+
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
|
|
24
|
+
const DEFAULT_CONTROLLER_URL = process.env.CONTROLLER_URL || 'http://localhost:3110';
|
|
25
|
+
const DEFAULT_ENVIRONMENT = process.env.ENVIRONMENT || 'miso';
|
|
26
|
+
const DEFAULT_DATAPLANE_URL = process.env.DATAPLANE_URL || '';
|
|
27
|
+
const DEFAULT_OPENAPI_FILE = process.env.HUBSPOT_OPENAPI_FILE ||
|
|
28
|
+
'/workspace/aifabrix-dataplane/data/hubspot/openapi/companies.json';
|
|
29
|
+
const LOCAL_ENV_PATH = path.join(process.cwd(), 'integration', 'hubspot', '.env');
|
|
30
|
+
const DEFAULT_ENV_PATH = process.env.HUBSPOT_ENV_PATH ||
|
|
31
|
+
(fsSync.existsSync(LOCAL_ENV_PATH) ? LOCAL_ENV_PATH : '/workspace/aifabrix-dataplane/data/hubspot/.env');
|
|
32
|
+
|
|
33
|
+
const HUBSPOT_DIR = path.join(process.cwd(), 'integration', 'hubspot');
|
|
34
|
+
const ARTIFACT_DIR = path.join(HUBSPOT_DIR, 'test-artifacts');
|
|
35
|
+
const MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
|
|
36
|
+
const COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Error class for skipping tests
|
|
40
|
+
* @class SkipTestError
|
|
41
|
+
* @extends Error
|
|
42
|
+
*/
|
|
43
|
+
class SkipTestError extends Error {
|
|
44
|
+
/**
|
|
45
|
+
* Creates a SkipTestError
|
|
46
|
+
* @param {string} message - Error message
|
|
47
|
+
*/
|
|
48
|
+
constructor(message) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'SkipTestError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parses command line arguments
|
|
56
|
+
* @function parseArgs
|
|
57
|
+
* @param {string[]} argv - Command line arguments
|
|
58
|
+
* @returns {Object} Parsed arguments object with tests, types, verbose, keepArtifacts, help
|
|
59
|
+
*/
|
|
60
|
+
function parseArgs(argv) {
|
|
61
|
+
const args = {
|
|
62
|
+
tests: [],
|
|
63
|
+
types: [],
|
|
64
|
+
verbose: false,
|
|
65
|
+
keepArtifacts: false,
|
|
66
|
+
help: false
|
|
67
|
+
};
|
|
68
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
69
|
+
const arg = argv[i];
|
|
70
|
+
if (arg === '--test' && argv[i + 1]) {
|
|
71
|
+
args.tests = argv[i + 1].split(',').map(item => item.trim()).filter(Boolean);
|
|
72
|
+
i += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (arg === '--type' && argv[i + 1]) {
|
|
76
|
+
args.types = argv[i + 1].split(',').map(item => item.trim()).filter(Boolean);
|
|
77
|
+
i += 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (arg === '--verbose') {
|
|
81
|
+
args.verbose = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (arg === '--keep-artifacts') {
|
|
85
|
+
args.keepArtifacts = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (arg === '--help' || arg === '-h') {
|
|
89
|
+
args.help = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return args;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Prints usage information
|
|
97
|
+
* @function printUsage
|
|
98
|
+
* @returns {void}
|
|
99
|
+
*/
|
|
100
|
+
function printUsage() {
|
|
101
|
+
// eslint-disable-next-line no-console
|
|
102
|
+
console.log([
|
|
103
|
+
'Usage:',
|
|
104
|
+
' node integration/hubspot/test.js',
|
|
105
|
+
' node integration/hubspot/test.js --test "1.1"',
|
|
106
|
+
' node integration/hubspot/test.js --type positive',
|
|
107
|
+
' node integration/hubspot/test.js --type negative --verbose',
|
|
108
|
+
'',
|
|
109
|
+
'Options:',
|
|
110
|
+
' --test <id[,id]> Run specific test IDs',
|
|
111
|
+
' --type <type[,type]> Filter by type (positive|negative|real-data)',
|
|
112
|
+
' --verbose Verbose command output',
|
|
113
|
+
' --keep-artifacts Keep generated test artifacts',
|
|
114
|
+
' -h, --help Show help'
|
|
115
|
+
].join('\n'));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Logs info message
|
|
120
|
+
* @function logInfo
|
|
121
|
+
* @param {string} message - Message to log
|
|
122
|
+
* @returns {void}
|
|
123
|
+
*/
|
|
124
|
+
function logInfo(message) {
|
|
125
|
+
// eslint-disable-next-line no-console
|
|
126
|
+
console.log(chalk.cyan(message));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Logs success message
|
|
131
|
+
* @function logSuccess
|
|
132
|
+
* @param {string} message - Message to log
|
|
133
|
+
* @returns {void}
|
|
134
|
+
*/
|
|
135
|
+
function logSuccess(message) {
|
|
136
|
+
// eslint-disable-next-line no-console
|
|
137
|
+
console.log(chalk.green(message));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Logs warning message
|
|
142
|
+
* @function logWarn
|
|
143
|
+
* @param {string} message - Message to log
|
|
144
|
+
* @returns {void}
|
|
145
|
+
*/
|
|
146
|
+
function logWarn(message) {
|
|
147
|
+
// eslint-disable-next-line no-console
|
|
148
|
+
console.warn(chalk.yellow(message));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Logs error message
|
|
153
|
+
* @function logError
|
|
154
|
+
* @param {string} message - Message to log
|
|
155
|
+
* @returns {void}
|
|
156
|
+
*/
|
|
157
|
+
function logError(message) {
|
|
158
|
+
// eslint-disable-next-line no-console
|
|
159
|
+
console.error(chalk.red(message));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Ensures directory exists
|
|
164
|
+
* @async
|
|
165
|
+
* @function ensureDir
|
|
166
|
+
* @param {string} dirPath - Directory path
|
|
167
|
+
* @returns {Promise<void>} Resolves when directory is created
|
|
168
|
+
*/
|
|
169
|
+
async function ensureDir(dirPath) {
|
|
170
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Checks if file exists
|
|
175
|
+
* @async
|
|
176
|
+
* @function fileExists
|
|
177
|
+
* @param {string} filePath - File path to check
|
|
178
|
+
* @returns {Promise<boolean>} True if file exists
|
|
179
|
+
*/
|
|
180
|
+
async function fileExists(filePath) {
|
|
181
|
+
try {
|
|
182
|
+
await fs.access(filePath);
|
|
183
|
+
return true;
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Parses environment file content
|
|
191
|
+
* @function parseEnvFile
|
|
192
|
+
* @param {string} content - Environment file content
|
|
193
|
+
* @returns {Object<string, string>} Parsed environment variables object
|
|
194
|
+
*/
|
|
195
|
+
function parseEnvFile(content) {
|
|
196
|
+
const envVars = {};
|
|
197
|
+
const lines = content.split(/\r?\n/);
|
|
198
|
+
for (const line of lines) {
|
|
199
|
+
const trimmed = line.trim();
|
|
200
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const eqIndex = trimmed.indexOf('=');
|
|
204
|
+
if (eqIndex === -1) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
208
|
+
let value = trimmed.slice(eqIndex + 1).trim();
|
|
209
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
|
|
210
|
+
value = value.slice(1, -1);
|
|
211
|
+
}
|
|
212
|
+
envVars[key] = value;
|
|
213
|
+
}
|
|
214
|
+
return envVars;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Stable stringify for comparison
|
|
219
|
+
* @function stableStringify
|
|
220
|
+
* @param {*} value - Value to stringify
|
|
221
|
+
* @returns {string} Stable JSON string
|
|
222
|
+
*/
|
|
223
|
+
function stableStringify(value) {
|
|
224
|
+
if (Array.isArray(value)) {
|
|
225
|
+
return `[${value.map(item => stableStringify(item)).join(',')}]`;
|
|
226
|
+
}
|
|
227
|
+
if (value && typeof value === 'object') {
|
|
228
|
+
const keys = Object.keys(value).sort();
|
|
229
|
+
return `{${keys.map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
|
230
|
+
}
|
|
231
|
+
return JSON.stringify(value);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Normalizes file content for comparisons
|
|
236
|
+
* @function normalizeFileContent
|
|
237
|
+
* @param {string} filePath - File path
|
|
238
|
+
* @param {string} content - File content
|
|
239
|
+
* @returns {string} Normalized content
|
|
240
|
+
*/
|
|
241
|
+
function normalizeFileContent(filePath, content) {
|
|
242
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
243
|
+
if (ext === '.json') {
|
|
244
|
+
return stableStringify(JSON.parse(content));
|
|
245
|
+
}
|
|
246
|
+
if (ext === '.yaml' || ext === '.yml') {
|
|
247
|
+
return stableStringify(yaml.load(content));
|
|
248
|
+
}
|
|
249
|
+
return content.trim();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Loads environment file
|
|
254
|
+
* @async
|
|
255
|
+
* @function loadEnvFile
|
|
256
|
+
* @param {string} envPath - Path to environment file
|
|
257
|
+
* @param {Object} options - Options object
|
|
258
|
+
* @param {boolean} options.verbose - Verbose logging flag
|
|
259
|
+
* @returns {Promise<void>} Resolves when environment file is loaded
|
|
260
|
+
*/
|
|
261
|
+
async function loadEnvFile(envPath, options) {
|
|
262
|
+
const exists = await fileExists(envPath);
|
|
263
|
+
if (!exists) {
|
|
264
|
+
logWarn(`Env file not found: ${envPath}`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const content = await fs.readFile(envPath, 'utf8');
|
|
268
|
+
const parsed = parseEnvFile(content);
|
|
269
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
270
|
+
if (process.env[key] === undefined) {
|
|
271
|
+
process.env[key] = value;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (options.verbose) {
|
|
275
|
+
logInfo(`Loaded env vars from: ${envPath}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Ensure dataplane URL is available for tests
|
|
281
|
+
* @async
|
|
282
|
+
* @function ensureDataplaneUrl
|
|
283
|
+
* @param {Object} context - Test context
|
|
284
|
+
* @param {string} appName - Application name
|
|
285
|
+
* @returns {Promise<string>} Dataplane URL
|
|
286
|
+
*/
|
|
287
|
+
async function ensureDataplaneUrl(context, appName) {
|
|
288
|
+
if (context.dataplaneUrl) {
|
|
289
|
+
return context.dataplaneUrl;
|
|
290
|
+
}
|
|
291
|
+
const authConfig = await getDeploymentAuth(context.controllerUrl, context.environment, appName);
|
|
292
|
+
const dataplaneUrl = await discoverDataplaneUrl(context.controllerUrl, context.environment, authConfig);
|
|
293
|
+
context.dataplaneUrl = dataplaneUrl;
|
|
294
|
+
return dataplaneUrl;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Ensures environment variable is set
|
|
299
|
+
* @function ensureEnvVar
|
|
300
|
+
* @param {string} name - Variable name
|
|
301
|
+
* @param {string} value - Variable value
|
|
302
|
+
* @returns {void}
|
|
303
|
+
*/
|
|
304
|
+
function ensureEnvVar(name, value) {
|
|
305
|
+
if (process.env[name] === undefined && value !== undefined && value !== null && value !== '') {
|
|
306
|
+
process.env[name] = value;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Requires environment variables to be set
|
|
312
|
+
* @function requireEnvVars
|
|
313
|
+
* @param {string[]} names - Array of variable names
|
|
314
|
+
* @returns {void}
|
|
315
|
+
* @throws {SkipTestError} If any required variable is missing
|
|
316
|
+
*/
|
|
317
|
+
function requireEnvVars(names) {
|
|
318
|
+
const missing = names.filter(name => !process.env[name]);
|
|
319
|
+
if (missing.length > 0) {
|
|
320
|
+
throw new SkipTestError(`Missing required env vars: ${missing.join(', ')}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Runs a command
|
|
326
|
+
* @async
|
|
327
|
+
* @function runCommand
|
|
328
|
+
* @param {string} command - Command to run
|
|
329
|
+
* @param {string[]} args - Command arguments
|
|
330
|
+
* @param {Object} options - Options object
|
|
331
|
+
* @param {boolean} options.verbose - Verbose logging flag
|
|
332
|
+
* @returns {Promise<Object>} Command result object with success, stdout, stderr
|
|
333
|
+
*/
|
|
334
|
+
async function runCommand(command, args, options) {
|
|
335
|
+
try {
|
|
336
|
+
const result = await execFileAsync(command, args, {
|
|
337
|
+
cwd: process.cwd(),
|
|
338
|
+
env: { ...process.env },
|
|
339
|
+
maxBuffer: MAX_OUTPUT_BYTES,
|
|
340
|
+
timeout: COMMAND_TIMEOUT_MS
|
|
341
|
+
});
|
|
342
|
+
if (options.verbose) {
|
|
343
|
+
if (result.stdout) {
|
|
344
|
+
logInfo(result.stdout.trim());
|
|
345
|
+
}
|
|
346
|
+
if (result.stderr) {
|
|
347
|
+
logWarn(result.stderr.trim());
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { success: true, stdout: result.stdout || '', stderr: result.stderr || '' };
|
|
351
|
+
} catch (error) {
|
|
352
|
+
const stdout = error.stdout || '';
|
|
353
|
+
const stderr = error.stderr || '';
|
|
354
|
+
if (options.verbose) {
|
|
355
|
+
if (stdout) {
|
|
356
|
+
logInfo(stdout.trim());
|
|
357
|
+
}
|
|
358
|
+
if (stderr) {
|
|
359
|
+
logWarn(stderr.trim());
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { success: false, stdout, stderr, error };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Validates authentication status
|
|
368
|
+
* @async
|
|
369
|
+
* @function validateAuth
|
|
370
|
+
* @param {Object} context - Test context
|
|
371
|
+
* @param {string} context.controllerUrl - Controller URL
|
|
372
|
+
* @param {string} context.environment - Environment name
|
|
373
|
+
* @param {Object} options - Options object
|
|
374
|
+
* @returns {Promise<void>} Resolves when authentication is validated
|
|
375
|
+
* @throws {Error} If authentication fails
|
|
376
|
+
*/
|
|
377
|
+
async function validateAuth(context, options) {
|
|
378
|
+
logInfo('Validating authentication status...');
|
|
379
|
+
const configArgs = [
|
|
380
|
+
'bin/aifabrix.js',
|
|
381
|
+
'auth',
|
|
382
|
+
'config',
|
|
383
|
+
'--set-controller',
|
|
384
|
+
context.controllerUrl,
|
|
385
|
+
'--set-environment',
|
|
386
|
+
context.environment
|
|
387
|
+
];
|
|
388
|
+
await runCommand('node', configArgs, options);
|
|
389
|
+
const args = ['bin/aifabrix.js', 'auth', 'status'];
|
|
390
|
+
const result = await runCommand('node', args, options);
|
|
391
|
+
if (!result.success) {
|
|
392
|
+
throw new Error('Authentication check failed. Run: node bin/aifabrix.js login --controller <url> --method device');
|
|
393
|
+
}
|
|
394
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
395
|
+
if (output.includes('Not authenticated') || output.includes('✗')) {
|
|
396
|
+
throw new Error('Not authenticated. Run: node bin/aifabrix.js login --controller <url> --method device');
|
|
397
|
+
}
|
|
398
|
+
logSuccess('Authentication validated.');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Writes wizard configuration to file
|
|
403
|
+
* @async
|
|
404
|
+
* @function writeWizardConfig
|
|
405
|
+
* @param {string} name - Configuration name
|
|
406
|
+
* @param {Object} config - Configuration object
|
|
407
|
+
* @returns {Promise<string>} Path to written configuration file
|
|
408
|
+
*/
|
|
409
|
+
async function writeWizardConfig(name, config) {
|
|
410
|
+
await ensureDir(ARTIFACT_DIR);
|
|
411
|
+
const configPath = path.join(ARTIFACT_DIR, `${name}.yaml`);
|
|
412
|
+
const serialized = yaml.dump(config, { lineWidth: -1, noRefs: true });
|
|
413
|
+
await fs.writeFile(configPath, serialized, 'utf8');
|
|
414
|
+
return configPath;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Lists files in a directory recursively
|
|
419
|
+
* @async
|
|
420
|
+
* @function listFilesRecursive
|
|
421
|
+
* @param {string} dirPath - Directory path
|
|
422
|
+
* @param {string} [prefix=''] - Prefix for file paths
|
|
423
|
+
* @returns {Promise<string[]>} Array of file paths
|
|
424
|
+
*/
|
|
425
|
+
async function listFilesRecursive(dirPath, prefix = '') {
|
|
426
|
+
const files = [];
|
|
427
|
+
try {
|
|
428
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
429
|
+
for (const entry of entries) {
|
|
430
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
431
|
+
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
432
|
+
if (entry.isDirectory()) {
|
|
433
|
+
const subFiles = await listFilesRecursive(fullPath, relativePath);
|
|
434
|
+
files.push(...subFiles);
|
|
435
|
+
} else {
|
|
436
|
+
files.push(relativePath);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
} catch (error) {
|
|
440
|
+
// Ignore errors, return empty array
|
|
441
|
+
}
|
|
442
|
+
return files;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Checks if application directory exists
|
|
447
|
+
* @async
|
|
448
|
+
* @function checkAppDirectory
|
|
449
|
+
* @param {string} appPath - Application directory path
|
|
450
|
+
* @returns {Promise<string[]>} Directory entries
|
|
451
|
+
* @throws {Error} If directory doesn't exist or can't be read
|
|
452
|
+
*/
|
|
453
|
+
async function checkAppDirectory(appPath) {
|
|
454
|
+
const dirExists = await fileExists(appPath);
|
|
455
|
+
if (!dirExists) {
|
|
456
|
+
throw new Error(`Application directory does not exist: ${appPath}. Wizard may have failed to create files.`);
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
return await fs.readdir(appPath);
|
|
460
|
+
} catch (error) {
|
|
461
|
+
throw new Error(`Failed to read application directory ${appPath}: ${error.message}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Validates required files exist
|
|
467
|
+
* @async
|
|
468
|
+
* @function validateRequiredFiles
|
|
469
|
+
* @param {string} appPath - Application directory path
|
|
470
|
+
* @param {string[]} entries - Directory entries
|
|
471
|
+
* @returns {Promise<void>} Resolves when all required files are validated
|
|
472
|
+
* @throws {Error} If required files are missing
|
|
473
|
+
*/
|
|
474
|
+
async function validateRequiredFiles(appPath, entries) {
|
|
475
|
+
const requiredFiles = ['variables.yaml', 'env.template', 'README.md', 'deploy.sh', 'deploy.ps1'];
|
|
476
|
+
const missingFiles = [];
|
|
477
|
+
for (const fileName of requiredFiles) {
|
|
478
|
+
const filePath = path.join(appPath, fileName);
|
|
479
|
+
const exists = await fileExists(filePath);
|
|
480
|
+
if (!exists) {
|
|
481
|
+
missingFiles.push(fileName);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (missingFiles.length > 0) {
|
|
485
|
+
const allFiles = await listFilesRecursive(appPath);
|
|
486
|
+
throw new Error(
|
|
487
|
+
`Missing required files in ${appPath}: ${missingFiles.join(', ')}\n` +
|
|
488
|
+
'Directory exists: true\n' +
|
|
489
|
+
`Files found in directory: ${entries.length > 0 ? entries.join(', ') : 'none'}\n` +
|
|
490
|
+
`All files (recursive): ${allFiles.length > 0 ? allFiles.join(', ') : 'none'}`
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Validates deploy JSON files exist
|
|
497
|
+
* @function validateDeployFiles
|
|
498
|
+
* @param {string} appPath - Application directory path
|
|
499
|
+
* @param {string[]} entries - Directory entries
|
|
500
|
+
* @returns {string[]} Deploy file names
|
|
501
|
+
* @throws {Error} If no deploy files found
|
|
502
|
+
*/
|
|
503
|
+
function validateDeployFiles(appPath, entries) {
|
|
504
|
+
const deployFiles = entries.filter(file => /-deploy.*\.json$/.test(file));
|
|
505
|
+
if (deployFiles.length === 0) {
|
|
506
|
+
throw new Error(`No deploy JSON files found in: ${appPath}. Found files: ${entries.join(', ')}`);
|
|
507
|
+
}
|
|
508
|
+
return deployFiles;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Validates file contents syntax
|
|
513
|
+
* @async
|
|
514
|
+
* @function validateFileContents
|
|
515
|
+
* @param {string} appPath - Application directory path
|
|
516
|
+
* @param {string[]} deployFiles - Deploy file names
|
|
517
|
+
* @returns {Promise<void>} Resolves when all file contents are validated
|
|
518
|
+
* @throws {Error} If file contents are invalid
|
|
519
|
+
*/
|
|
520
|
+
async function validateFileContents(appPath, deployFiles) {
|
|
521
|
+
try {
|
|
522
|
+
const variablesContent = await fs.readFile(path.join(appPath, 'variables.yaml'), 'utf8');
|
|
523
|
+
yaml.load(variablesContent);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
throw new Error(`Invalid YAML syntax in variables.yaml: ${error.message}`);
|
|
526
|
+
}
|
|
527
|
+
for (const fileName of deployFiles) {
|
|
528
|
+
try {
|
|
529
|
+
const fileContent = await fs.readFile(path.join(appPath, fileName), 'utf8');
|
|
530
|
+
JSON.parse(fileContent);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
throw new Error(`Invalid JSON syntax in ${fileName}: ${error.message}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Validates generated files exist and are valid
|
|
539
|
+
* @async
|
|
540
|
+
* @function validateGeneratedFiles
|
|
541
|
+
* @param {string} appName - Application name
|
|
542
|
+
* @returns {Promise<void>} Resolves when all files are validated
|
|
543
|
+
* @throws {Error} If required files are missing or invalid
|
|
544
|
+
*/
|
|
545
|
+
async function validateGeneratedFiles(appName) {
|
|
546
|
+
const appPath = path.join(process.cwd(), 'integration', appName);
|
|
547
|
+
const entries = await checkAppDirectory(appPath);
|
|
548
|
+
await validateRequiredFiles(appPath, entries);
|
|
549
|
+
const deployFiles = validateDeployFiles(appPath, entries);
|
|
550
|
+
await validateFileContents(appPath, deployFiles);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Captures a snapshot of external system files for comparison
|
|
555
|
+
* @async
|
|
556
|
+
* @function captureExternalSnapshot
|
|
557
|
+
* @param {string} appPath - Application directory path
|
|
558
|
+
* @returns {Promise<Object>} Snapshot of file contents keyed by path
|
|
559
|
+
*/
|
|
560
|
+
async function captureExternalSnapshot(appPath) {
|
|
561
|
+
const variablesPath = path.join(appPath, 'variables.yaml');
|
|
562
|
+
const variablesContent = await fs.readFile(variablesPath, 'utf8');
|
|
563
|
+
const variables = yaml.load(variablesContent);
|
|
564
|
+
|
|
565
|
+
if (!variables || !variables.externalIntegration) {
|
|
566
|
+
throw new Error(`externalIntegration block not found in ${variablesPath}`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const systemFiles = variables.externalIntegration.systems || [];
|
|
570
|
+
const datasourceFiles = variables.externalIntegration.dataSources || [];
|
|
571
|
+
const fileNames = [
|
|
572
|
+
'variables.yaml',
|
|
573
|
+
'env.template',
|
|
574
|
+
'README.md',
|
|
575
|
+
...systemFiles,
|
|
576
|
+
...datasourceFiles
|
|
577
|
+
];
|
|
578
|
+
|
|
579
|
+
const rbacPath = path.join(appPath, 'rbac.yml');
|
|
580
|
+
if (await fileExists(rbacPath)) {
|
|
581
|
+
fileNames.push('rbac.yml');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const snapshot = {};
|
|
585
|
+
for (const fileName of fileNames) {
|
|
586
|
+
const filePath = path.join(appPath, fileName);
|
|
587
|
+
if (!(await fileExists(filePath))) {
|
|
588
|
+
throw new Error(`Expected file not found: ${filePath}`);
|
|
589
|
+
}
|
|
590
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
591
|
+
snapshot[filePath] = normalizeFileContent(filePath, content);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return snapshot;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Compares snapshots and throws on differences
|
|
599
|
+
* @function compareSnapshots
|
|
600
|
+
* @param {Object} before - Snapshot before
|
|
601
|
+
* @param {Object} after - Snapshot after
|
|
602
|
+
* @returns {void}
|
|
603
|
+
*/
|
|
604
|
+
function compareSnapshots(before, after) {
|
|
605
|
+
for (const [filePath, content] of Object.entries(before)) {
|
|
606
|
+
if (!Object.prototype.hasOwnProperty.call(after, filePath)) {
|
|
607
|
+
throw new Error(`File missing after split: ${filePath}`);
|
|
608
|
+
}
|
|
609
|
+
if (after[filePath] !== content) {
|
|
610
|
+
throw new Error(`File content mismatch after split: ${filePath}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Checks if app name is a test app name
|
|
617
|
+
* @function isTestAppName
|
|
618
|
+
* @param {string} appName - Application name
|
|
619
|
+
* @returns {boolean} True if test app name
|
|
620
|
+
*/
|
|
621
|
+
function isTestAppName(appName) {
|
|
622
|
+
return appName.startsWith('hubspot-test-');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Cleans up app artifacts
|
|
627
|
+
* @async
|
|
628
|
+
* @function cleanupAppArtifacts
|
|
629
|
+
* @param {string} appName - Application name
|
|
630
|
+
* @param {Object} options - Options object
|
|
631
|
+
* @param {boolean} options.keepArtifacts - Keep artifacts flag
|
|
632
|
+
* @returns {Promise<void>} Resolves when cleanup is complete
|
|
633
|
+
* @throws {Error} If app name is not a test app name
|
|
634
|
+
*/
|
|
635
|
+
async function cleanupAppArtifacts(appName, options) {
|
|
636
|
+
if (options.keepArtifacts) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (!isTestAppName(appName)) {
|
|
640
|
+
throw new Error(`Refusing to delete non-test app directory: ${appName}`);
|
|
641
|
+
}
|
|
642
|
+
const appPath = path.join(process.cwd(), 'integration', appName);
|
|
643
|
+
await fs.rm(appPath, { recursive: true, force: true });
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Runs wizard command
|
|
648
|
+
* @async
|
|
649
|
+
* @function runWizard
|
|
650
|
+
* @param {string} configPath - Path to wizard config file
|
|
651
|
+
* @param {Object} context - Test context
|
|
652
|
+
* @param {Object} options - Options object
|
|
653
|
+
* @returns {Promise<Object>} Command result object
|
|
654
|
+
*/
|
|
655
|
+
async function runWizard(configPath, context, options) {
|
|
656
|
+
const args = ['bin/aifabrix.js', 'wizard', '--config', configPath];
|
|
657
|
+
return await runCommand('node', args, options);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Builds command arguments for external system commands
|
|
662
|
+
* @function buildExternalCommandArgs
|
|
663
|
+
* @param {string} command - Command name (download, json, split-json)
|
|
664
|
+
* @param {string} appName - Application name
|
|
665
|
+
* @returns {string[]} Command arguments array
|
|
666
|
+
*/
|
|
667
|
+
function buildExternalCommandArgs(command, appName) {
|
|
668
|
+
const args = ['bin/aifabrix.js', command, appName];
|
|
669
|
+
if (command !== 'download') {
|
|
670
|
+
args.push('--type', 'external');
|
|
671
|
+
}
|
|
672
|
+
return args;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Runs a command and handles errors, with special handling for "not found" cases
|
|
677
|
+
* @async
|
|
678
|
+
* @function runCommandWithErrorHandling
|
|
679
|
+
* @param {string} command - Command description for error messages
|
|
680
|
+
* @param {string[]} args - Command arguments
|
|
681
|
+
* @param {Object} options - Options object
|
|
682
|
+
* @param {string} [appName] - Application name (for skip logic)
|
|
683
|
+
* @returns {Promise<Object>} Command result object
|
|
684
|
+
* @throws {Error} If command fails (unless it's a "not found" case)
|
|
685
|
+
*/
|
|
686
|
+
async function runCommandWithErrorHandling(command, args, options, appName) {
|
|
687
|
+
const result = await runCommand('node', args, options);
|
|
688
|
+
if (!result.success) {
|
|
689
|
+
const errorOutput = `${result.stdout}\n${result.stderr}`;
|
|
690
|
+
if (appName && (errorOutput.includes('not found') ||
|
|
691
|
+
(errorOutput.includes('External system') && errorOutput.includes('not found')))) {
|
|
692
|
+
logWarn(`System ${appName} not deployed, skipping download test`);
|
|
693
|
+
return { skipped: true };
|
|
694
|
+
}
|
|
695
|
+
throw new Error(`${command} failed: ${result.stderr || result.stdout}`);
|
|
696
|
+
}
|
|
697
|
+
return result;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Downloads external system and validates split workflow
|
|
702
|
+
* @async
|
|
703
|
+
* @function testDownloadAndSplit
|
|
704
|
+
* @param {string} appName - Application name
|
|
705
|
+
* @param {Object} context - Test context
|
|
706
|
+
* @param {Object} options - Options object
|
|
707
|
+
* @returns {Promise<void>} Resolves when download and split validation succeeds
|
|
708
|
+
*/
|
|
709
|
+
async function testDownloadAndSplit(appName, context, options) {
|
|
710
|
+
logInfo(`Downloading external system ${appName}...`);
|
|
711
|
+
const downloadArgs = buildExternalCommandArgs('download', appName);
|
|
712
|
+
const downloadResult = await runCommandWithErrorHandling('Download', downloadArgs, options, appName);
|
|
713
|
+
if (downloadResult.skipped) {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const appPath = path.join(process.cwd(), 'integration', appName);
|
|
718
|
+
const snapshotBefore = await captureExternalSnapshot(appPath);
|
|
719
|
+
|
|
720
|
+
logInfo('Generating application-schema.json...');
|
|
721
|
+
const jsonArgs = buildExternalCommandArgs('json', appName);
|
|
722
|
+
await runCommandWithErrorHandling('JSON generation', jsonArgs, options);
|
|
723
|
+
|
|
724
|
+
logInfo('Splitting application-schema.json...');
|
|
725
|
+
const splitArgs = buildExternalCommandArgs('split-json', appName);
|
|
726
|
+
await runCommandWithErrorHandling('Split', splitArgs, options);
|
|
727
|
+
|
|
728
|
+
const snapshotAfter = await captureExternalSnapshot(appPath);
|
|
729
|
+
compareSnapshots(snapshotBefore, snapshotAfter);
|
|
730
|
+
logSuccess('Download and split workflow validated.');
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Runs wizard and validates generated files
|
|
735
|
+
* @async
|
|
736
|
+
* @function runWizardAndValidate
|
|
737
|
+
* @param {string} configPath - Path to wizard config file
|
|
738
|
+
* @param {string} appName - Application name
|
|
739
|
+
* @param {Object} context - Test context
|
|
740
|
+
* @param {Object} options - Options object
|
|
741
|
+
* @returns {Promise<void>} Resolves when wizard completes and files are validated
|
|
742
|
+
* @throws {Error} If wizard fails or validation fails
|
|
743
|
+
*/
|
|
744
|
+
async function runWizardAndValidate(configPath, appName, context, options) {
|
|
745
|
+
const result = await runWizard(configPath, context, options);
|
|
746
|
+
if (!result.success) {
|
|
747
|
+
const errorOutput = `${result.stdout}\n${result.stderr}`;
|
|
748
|
+
throw new Error(`Wizard failed for ${appName}:\n${errorOutput}`);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Wait a brief moment to ensure file system operations complete
|
|
752
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
753
|
+
|
|
754
|
+
// Validate files were created
|
|
755
|
+
if (options.verbose) {
|
|
756
|
+
logInfo(`Validating files for ${appName}...`);
|
|
757
|
+
}
|
|
758
|
+
await validateGeneratedFiles(appName);
|
|
759
|
+
|
|
760
|
+
if (options.verbose) {
|
|
761
|
+
const appPath = path.join(process.cwd(), 'integration', appName);
|
|
762
|
+
const entries = await fs.readdir(appPath);
|
|
763
|
+
logSuccess(`Files created successfully: ${entries.join(', ')}`);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
await testDownloadAndSplit(appName, context, options);
|
|
767
|
+
|
|
768
|
+
await cleanupAppArtifacts(appName, options);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* Runs wizard expecting failure
|
|
773
|
+
* @async
|
|
774
|
+
* @function runWizardExpectFailure
|
|
775
|
+
* @param {string} configPath - Path to wizard config file
|
|
776
|
+
* @param {Object} context - Test context
|
|
777
|
+
* @param {Object} options - Options object
|
|
778
|
+
* @param {string} [expectedMessage] - Expected error message
|
|
779
|
+
* @returns {Promise<void>} Resolves when wizard fails as expected
|
|
780
|
+
* @throws {Error} If wizard succeeds or expected message not found
|
|
781
|
+
*/
|
|
782
|
+
async function runWizardExpectFailure(configPath, context, options, expectedMessage = null) {
|
|
783
|
+
const result = await runWizard(configPath, context, options);
|
|
784
|
+
if (result.success) {
|
|
785
|
+
throw new Error('Expected wizard to fail, but it succeeded.');
|
|
786
|
+
}
|
|
787
|
+
if (expectedMessage) {
|
|
788
|
+
const combined = `${result.stdout}\n${result.stderr}`;
|
|
789
|
+
if (!combined.includes(expectedMessage)) {
|
|
790
|
+
throw new Error(`Expected error message not found: ${expectedMessage}`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Checks if test case matches selection criteria
|
|
797
|
+
* @function matchesSelection
|
|
798
|
+
* @param {Object} testCase - Test case object
|
|
799
|
+
* @param {string[]} selections - Selection criteria
|
|
800
|
+
* @returns {boolean} True if matches
|
|
801
|
+
*/
|
|
802
|
+
function matchesSelection(testCase, selections) {
|
|
803
|
+
if (!selections || selections.length === 0) {
|
|
804
|
+
return true;
|
|
805
|
+
}
|
|
806
|
+
const lowerSelections = selections.map(item => item.toLowerCase());
|
|
807
|
+
const idMatch = lowerSelections.includes(testCase.id.toLowerCase());
|
|
808
|
+
const nameMatch = lowerSelections.some(item => testCase.name.toLowerCase().includes(item));
|
|
809
|
+
return idMatch || nameMatch;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Checks if test case matches type criteria
|
|
814
|
+
* @function matchesType
|
|
815
|
+
* @param {Object} testCase - Test case object
|
|
816
|
+
* @param {string[]} types - Type criteria
|
|
817
|
+
* @returns {boolean} True if matches
|
|
818
|
+
*/
|
|
819
|
+
function matchesType(testCase, types) {
|
|
820
|
+
if (!types || types.length === 0) {
|
|
821
|
+
return true;
|
|
822
|
+
}
|
|
823
|
+
return types.map(type => type.toLowerCase()).includes(testCase.type.toLowerCase());
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Builds positive test cases
|
|
828
|
+
* @function buildPositiveTestCases
|
|
829
|
+
* @param {Object} context - Test context
|
|
830
|
+
* @returns {Array<Object>} Array of positive test case objects
|
|
831
|
+
*/
|
|
832
|
+
// eslint-disable-next-line max-lines-per-function, require-jsdoc
|
|
833
|
+
function buildPositiveTestCases(context) {
|
|
834
|
+
return [
|
|
835
|
+
{
|
|
836
|
+
id: '1.1',
|
|
837
|
+
type: 'positive',
|
|
838
|
+
name: 'Complete wizard flow with OpenAPI file',
|
|
839
|
+
run: async(options) => {
|
|
840
|
+
const configPath = path.join(HUBSPOT_DIR, 'wizard-hubspot-e2e.yaml');
|
|
841
|
+
if (!(await fileExists(configPath))) {
|
|
842
|
+
throw new Error(`Missing config file: ${configPath}`);
|
|
843
|
+
}
|
|
844
|
+
if (!(await fileExists(context.openapiFile))) {
|
|
845
|
+
throw new Error(`OpenAPI file not found: ${context.openapiFile}`);
|
|
846
|
+
}
|
|
847
|
+
ensureEnvVar('CONTROLLER_URL', context.controllerUrl);
|
|
848
|
+
// Try to run wizard - if dataplane discovery fails, skip the test
|
|
849
|
+
const result = await runWizard(configPath, context, options);
|
|
850
|
+
if (!result.success) {
|
|
851
|
+
const errorOutput = `${result.stdout}\n${result.stderr}`;
|
|
852
|
+
if (errorOutput.includes('Failed to discover dataplane URL') ||
|
|
853
|
+
errorOutput.includes('Application not found')) {
|
|
854
|
+
throw new SkipTestError('Dataplane service not found in environment. Deploy dataplane service to the controller.');
|
|
855
|
+
}
|
|
856
|
+
throw new Error(`Wizard failed: ${errorOutput}`);
|
|
857
|
+
}
|
|
858
|
+
await validateGeneratedFiles('hubspot-test-e2e');
|
|
859
|
+
await cleanupAppArtifacts('hubspot-test-e2e', options);
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
{
|
|
863
|
+
id: '1.2',
|
|
864
|
+
type: 'positive',
|
|
865
|
+
name: 'Wizard flow with known platform',
|
|
866
|
+
run: async(options) => {
|
|
867
|
+
const configPath = path.join(HUBSPOT_DIR, 'wizard-hubspot-platform.yaml');
|
|
868
|
+
if (!(await fileExists(configPath))) {
|
|
869
|
+
throw new Error(`Missing config file: ${configPath}`);
|
|
870
|
+
}
|
|
871
|
+
try {
|
|
872
|
+
await ensureDataplaneUrl(context, 'hubspot-test-platform');
|
|
873
|
+
} catch (error) {
|
|
874
|
+
throw new SkipTestError(`Dataplane service not found: ${error.message}`);
|
|
875
|
+
}
|
|
876
|
+
await runWizardAndValidate(configPath, 'hubspot-test-platform', context, options);
|
|
877
|
+
}
|
|
878
|
+
},
|
|
879
|
+
{
|
|
880
|
+
id: '1.6',
|
|
881
|
+
type: 'positive',
|
|
882
|
+
name: 'Wizard flow with environment variables',
|
|
883
|
+
run: async(options) => {
|
|
884
|
+
let dataplaneUrl;
|
|
885
|
+
try {
|
|
886
|
+
dataplaneUrl = await ensureDataplaneUrl(context, 'hubspot-test-env-vars');
|
|
887
|
+
} catch (error) {
|
|
888
|
+
throw new SkipTestError(`Dataplane service not found: ${error.message}`);
|
|
889
|
+
}
|
|
890
|
+
ensureEnvVar('CONTROLLER_URL', context.controllerUrl);
|
|
891
|
+
ensureEnvVar('DATAPLANE_URL', dataplaneUrl);
|
|
892
|
+
const configPath = await writeWizardConfig('wizard-hubspot-env-vars', {
|
|
893
|
+
appName: 'hubspot-test-env-vars',
|
|
894
|
+
mode: 'create-system',
|
|
895
|
+
source: {
|
|
896
|
+
type: 'openapi-file',
|
|
897
|
+
filePath: context.openapiFile
|
|
898
|
+
},
|
|
899
|
+
deployment: {
|
|
900
|
+
controller: '${CONTROLLER_URL}',
|
|
901
|
+
dataplane: '${DATAPLANE_URL}',
|
|
902
|
+
environment: context.environment
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
await runWizardAndValidate(configPath, 'hubspot-test-env-vars', context, options);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
];
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Builds real-data test cases
|
|
913
|
+
* @function buildRealDataTestCases
|
|
914
|
+
* @param {Object} context - Test context
|
|
915
|
+
* @returns {Array<Object>} Array of real-data test cases
|
|
916
|
+
*/
|
|
917
|
+
function buildRealDataTestCases(context) {
|
|
918
|
+
return [
|
|
919
|
+
{
|
|
920
|
+
id: '1.3',
|
|
921
|
+
type: 'real-data',
|
|
922
|
+
name: 'Wizard flow with real credential creation',
|
|
923
|
+
run: async(options) => {
|
|
924
|
+
if (!(await fileExists(context.openapiFile))) {
|
|
925
|
+
throw new Error(`OpenAPI file not found: ${context.openapiFile}`);
|
|
926
|
+
}
|
|
927
|
+
requireEnvVars(['HUBSPOT_CLIENT_ID', 'HUBSPOT_CLIENT_SECRET']);
|
|
928
|
+
ensureEnvVar('HUBSPOT_TOKEN_URL', 'https://api.hubapi.com/oauth/v1/token');
|
|
929
|
+
const configPath = await writeWizardConfig('wizard-hubspot-credential-real', {
|
|
930
|
+
appName: 'hubspot-test-credential-real',
|
|
931
|
+
mode: 'create-system',
|
|
932
|
+
source: {
|
|
933
|
+
type: 'openapi-file',
|
|
934
|
+
filePath: context.openapiFile
|
|
935
|
+
},
|
|
936
|
+
credential: {
|
|
937
|
+
action: 'create',
|
|
938
|
+
config: {
|
|
939
|
+
key: 'hubspot-test-cred-real',
|
|
940
|
+
displayName: 'HubSpot Test Credential (Real)',
|
|
941
|
+
type: 'OAUTH2',
|
|
942
|
+
config: {
|
|
943
|
+
tokenUrl: '${HUBSPOT_TOKEN_URL}',
|
|
944
|
+
clientId: '${HUBSPOT_CLIENT_ID}',
|
|
945
|
+
clientSecret: '${HUBSPOT_CLIENT_SECRET}',
|
|
946
|
+
scopes: [
|
|
947
|
+
'crm.objects.companies.read',
|
|
948
|
+
'crm.objects.companies.write',
|
|
949
|
+
'crm.objects.contacts.read',
|
|
950
|
+
'crm.objects.contacts.write'
|
|
951
|
+
]
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
await runWizardAndValidate(configPath, 'hubspot-test-credential-real', context, options);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
];
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Builds negative config validation test cases
|
|
964
|
+
* @function buildNegativeConfigTestCases
|
|
965
|
+
* @param {Object} context - Test context
|
|
966
|
+
* @returns {Array<Object>} Array of negative config test case objects
|
|
967
|
+
*/
|
|
968
|
+
// eslint-disable-next-line max-lines-per-function, require-jsdoc
|
|
969
|
+
function buildNegativeConfigTestCases(context) {
|
|
970
|
+
return [
|
|
971
|
+
{
|
|
972
|
+
id: '2.1',
|
|
973
|
+
type: 'negative',
|
|
974
|
+
name: 'Invalid config missing appName',
|
|
975
|
+
run: async(options) => {
|
|
976
|
+
const configPath = await writeWizardConfig('wizard-invalid-missing-app', {
|
|
977
|
+
mode: 'create-system',
|
|
978
|
+
source: { type: 'known-platform', platform: 'hubspot' }
|
|
979
|
+
});
|
|
980
|
+
await runWizardExpectFailure(configPath, context, options, 'Missing required field: appName');
|
|
981
|
+
}
|
|
982
|
+
},
|
|
983
|
+
{
|
|
984
|
+
id: '2.2',
|
|
985
|
+
type: 'negative',
|
|
986
|
+
name: 'Invalid app name with uppercase',
|
|
987
|
+
run: async(options) => {
|
|
988
|
+
const configPath = await writeWizardConfig('wizard-invalid-app-name', {
|
|
989
|
+
appName: 'HubSpot-Test',
|
|
990
|
+
mode: 'create-system',
|
|
991
|
+
source: { type: 'known-platform', platform: 'hubspot' }
|
|
992
|
+
});
|
|
993
|
+
await runWizardExpectFailure(configPath, context, options, 'must match pattern');
|
|
994
|
+
}
|
|
995
|
+
},
|
|
996
|
+
{
|
|
997
|
+
id: '2.3',
|
|
998
|
+
type: 'negative',
|
|
999
|
+
name: 'Missing source block',
|
|
1000
|
+
run: async(options) => {
|
|
1001
|
+
const configPath = await writeWizardConfig('wizard-invalid-missing-source', {
|
|
1002
|
+
appName: 'hubspot-test-negative-missing-source',
|
|
1003
|
+
mode: 'create-system'
|
|
1004
|
+
});
|
|
1005
|
+
await runWizardExpectFailure(configPath, context, options, 'Missing required field: source');
|
|
1006
|
+
}
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
id: '2.4',
|
|
1010
|
+
type: 'negative',
|
|
1011
|
+
name: 'Invalid source type',
|
|
1012
|
+
run: async(options) => {
|
|
1013
|
+
const configPath = await writeWizardConfig('wizard-invalid-source', {
|
|
1014
|
+
appName: 'hubspot-test-negative-source',
|
|
1015
|
+
mode: 'create-system',
|
|
1016
|
+
source: { type: 'invalid-type' }
|
|
1017
|
+
});
|
|
1018
|
+
await runWizardExpectFailure(configPath, context, options, 'Allowed values');
|
|
1019
|
+
}
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
id: '2.5',
|
|
1023
|
+
type: 'negative',
|
|
1024
|
+
name: 'Invalid mode',
|
|
1025
|
+
run: async(options) => {
|
|
1026
|
+
const configPath = await writeWizardConfig('wizard-invalid-mode', {
|
|
1027
|
+
appName: 'hubspot-test-negative-mode',
|
|
1028
|
+
mode: 'invalid-mode',
|
|
1029
|
+
source: { type: 'known-platform', platform: 'hubspot' }
|
|
1030
|
+
});
|
|
1031
|
+
await runWizardExpectFailure(configPath, context, options, 'Allowed values');
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
id: '2.6',
|
|
1036
|
+
type: 'negative',
|
|
1037
|
+
name: 'Known platform missing platform',
|
|
1038
|
+
run: async(options) => {
|
|
1039
|
+
const configPath = await writeWizardConfig('wizard-invalid-known-platform', {
|
|
1040
|
+
appName: 'hubspot-test-negative-platform',
|
|
1041
|
+
mode: 'create-system',
|
|
1042
|
+
source: { type: 'known-platform' }
|
|
1043
|
+
});
|
|
1044
|
+
await runWizardExpectFailure(configPath, context, options, 'Missing required field: platform');
|
|
1045
|
+
}
|
|
1046
|
+
},
|
|
1047
|
+
{
|
|
1048
|
+
id: '2.7',
|
|
1049
|
+
type: 'negative',
|
|
1050
|
+
name: 'Missing OpenAPI file path',
|
|
1051
|
+
run: async(options) => {
|
|
1052
|
+
const configPath = await writeWizardConfig('wizard-invalid-openapi-file', {
|
|
1053
|
+
appName: 'hubspot-test-negative-openapi',
|
|
1054
|
+
mode: 'create-system',
|
|
1055
|
+
source: { type: 'openapi-file', filePath: '/tmp/does-not-exist.json' }
|
|
1056
|
+
});
|
|
1057
|
+
await runWizardExpectFailure(configPath, context, options, 'OpenAPI file not found');
|
|
1058
|
+
}
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
id: '2.8',
|
|
1062
|
+
type: 'negative',
|
|
1063
|
+
name: 'OpenAPI URL missing url',
|
|
1064
|
+
run: async(options) => {
|
|
1065
|
+
const configPath = await writeWizardConfig('wizard-invalid-openapi-url', {
|
|
1066
|
+
appName: 'hubspot-test-negative-openapi-url',
|
|
1067
|
+
mode: 'create-system',
|
|
1068
|
+
source: { type: 'openapi-url' }
|
|
1069
|
+
});
|
|
1070
|
+
await runWizardExpectFailure(configPath, context, options, 'Missing required field: url');
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
];
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Builds negative credential and datasource test cases
|
|
1078
|
+
* @function buildNegativeCredentialTestCases
|
|
1079
|
+
* @param {Object} context - Test context
|
|
1080
|
+
* @returns {Array<Object>} Array of negative credential test cases
|
|
1081
|
+
*/
|
|
1082
|
+
function buildNegativeCredentialTestCases(context) {
|
|
1083
|
+
return [
|
|
1084
|
+
{
|
|
1085
|
+
id: '2.9',
|
|
1086
|
+
type: 'negative',
|
|
1087
|
+
name: 'Add datasource missing systemIdOrKey',
|
|
1088
|
+
run: async(options) => {
|
|
1089
|
+
const configPath = await writeWizardConfig('wizard-invalid-add-datasource', {
|
|
1090
|
+
appName: 'hubspot-test-negative-add-datasource',
|
|
1091
|
+
mode: 'add-datasource',
|
|
1092
|
+
source: { type: 'known-platform', platform: 'hubspot' }
|
|
1093
|
+
});
|
|
1094
|
+
await runWizardExpectFailure(configPath, context, options, 'systemIdOrKey');
|
|
1095
|
+
}
|
|
1096
|
+
},
|
|
1097
|
+
{
|
|
1098
|
+
id: '2.10',
|
|
1099
|
+
type: 'negative',
|
|
1100
|
+
name: 'Credential select missing credentialIdOrKey',
|
|
1101
|
+
run: async(options) => {
|
|
1102
|
+
const configPath = await writeWizardConfig('wizard-invalid-credential-select', {
|
|
1103
|
+
appName: 'hubspot-test-negative-credential-select',
|
|
1104
|
+
mode: 'create-system',
|
|
1105
|
+
source: { type: 'known-platform', platform: 'hubspot' },
|
|
1106
|
+
credential: { action: 'select' }
|
|
1107
|
+
});
|
|
1108
|
+
await runWizardExpectFailure(configPath, context, options, 'Missing required field: credentialIdOrKey');
|
|
1109
|
+
}
|
|
1110
|
+
},
|
|
1111
|
+
{
|
|
1112
|
+
id: '2.11',
|
|
1113
|
+
type: 'negative',
|
|
1114
|
+
name: 'Credential create missing config',
|
|
1115
|
+
run: async(options) => {
|
|
1116
|
+
const configPath = await writeWizardConfig('wizard-invalid-credential-create', {
|
|
1117
|
+
appName: 'hubspot-test-negative-credential-create',
|
|
1118
|
+
mode: 'create-system',
|
|
1119
|
+
source: { type: 'known-platform', platform: 'hubspot' },
|
|
1120
|
+
credential: { action: 'create' }
|
|
1121
|
+
});
|
|
1122
|
+
await runWizardExpectFailure(configPath, context, options, 'Missing required field: config');
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
];
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Runs validation command and expects failure
|
|
1130
|
+
* @async
|
|
1131
|
+
* @function runValidationExpectFailure
|
|
1132
|
+
* @param {string} appName - Application name
|
|
1133
|
+
* @param {Object} context - Test context
|
|
1134
|
+
* @param {Object} options - Options object
|
|
1135
|
+
* @param {string} expectedMessage - Expected error message
|
|
1136
|
+
* @returns {Promise<void>} Resolves when validation fails as expected
|
|
1137
|
+
* @throws {Error} If validation succeeds or expected message not found
|
|
1138
|
+
*/
|
|
1139
|
+
async function runValidationExpectFailure(appName, context, options, expectedMessage = null) {
|
|
1140
|
+
const validateArgs = [
|
|
1141
|
+
'bin/aifabrix.js',
|
|
1142
|
+
'validate',
|
|
1143
|
+
appName,
|
|
1144
|
+
'--type',
|
|
1145
|
+
'external'
|
|
1146
|
+
];
|
|
1147
|
+
const result = await runCommand('node', validateArgs, options);
|
|
1148
|
+
if (result.success) {
|
|
1149
|
+
throw new Error('Expected validation to fail, but it succeeded.');
|
|
1150
|
+
}
|
|
1151
|
+
if (expectedMessage) {
|
|
1152
|
+
const combined = `${result.stdout}\n${result.stderr}`;
|
|
1153
|
+
if (!combined.includes(expectedMessage)) {
|
|
1154
|
+
throw new Error(`Expected error message not found: ${expectedMessage}\nActual output: ${combined}`);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Creates a system via wizard for negative testing
|
|
1161
|
+
* @async
|
|
1162
|
+
* @function createSystemForNegativeTest
|
|
1163
|
+
* @param {string} appName - Application name
|
|
1164
|
+
* @param {string} configName - Configuration name
|
|
1165
|
+
* @param {Object} context - Test context
|
|
1166
|
+
* @param {Object} options - Options object
|
|
1167
|
+
* @returns {Promise<string>} Application path
|
|
1168
|
+
* @throws {SkipTestError} If wizard fails
|
|
1169
|
+
*/
|
|
1170
|
+
async function createSystemForNegativeTest(appName, configName, context, options) {
|
|
1171
|
+
const configPath = await writeWizardConfig(configName, {
|
|
1172
|
+
appName: appName,
|
|
1173
|
+
mode: 'create-system',
|
|
1174
|
+
source: { type: 'known-platform', platform: 'hubspot' }
|
|
1175
|
+
});
|
|
1176
|
+
const wizardResult = await runWizard(configPath, context, options);
|
|
1177
|
+
if (!wizardResult.success) {
|
|
1178
|
+
throw new SkipTestError(`Wizard failed to create system: ${wizardResult.stderr}`);
|
|
1179
|
+
}
|
|
1180
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
1181
|
+
return path.join(process.cwd(), 'integration', appName);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Corrupts system file with invalid permission referencing non-existent role
|
|
1186
|
+
* @async
|
|
1187
|
+
* @function corruptSystemFileWithInvalidRole
|
|
1188
|
+
* @param {string} appPath - Application path
|
|
1189
|
+
* @returns {Promise<void>} Resolves when file is corrupted
|
|
1190
|
+
*/
|
|
1191
|
+
async function corruptSystemFileWithInvalidRole(appPath) {
|
|
1192
|
+
const systemFiles = await fs.readdir(appPath);
|
|
1193
|
+
const systemFile = systemFiles.find(f => f.includes('-system.json'));
|
|
1194
|
+
if (!systemFile) {
|
|
1195
|
+
throw new Error(`System file not found in ${appPath}`);
|
|
1196
|
+
}
|
|
1197
|
+
const systemFilePath = path.join(appPath, systemFile);
|
|
1198
|
+
const systemContent = await fs.readFile(systemFilePath, 'utf8');
|
|
1199
|
+
const systemJson = JSON.parse(systemContent);
|
|
1200
|
+
if (!systemJson.permissions) {
|
|
1201
|
+
systemJson.permissions = [];
|
|
1202
|
+
}
|
|
1203
|
+
systemJson.permissions.push({
|
|
1204
|
+
name: 'test:invalid',
|
|
1205
|
+
roles: ['non-existent-role'],
|
|
1206
|
+
description: 'Test permission'
|
|
1207
|
+
});
|
|
1208
|
+
await fs.writeFile(systemFilePath, JSON.stringify(systemJson, null, 2), 'utf8');
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* Corrupts rbac.yaml with invalid YAML
|
|
1213
|
+
* @async
|
|
1214
|
+
* @function corruptRbacFile
|
|
1215
|
+
* @param {string} appPath - Application path
|
|
1216
|
+
* @returns {Promise<void>} Resolves when file is corrupted
|
|
1217
|
+
*/
|
|
1218
|
+
async function corruptRbacFile(appPath) {
|
|
1219
|
+
const rbacPath = path.join(appPath, 'rbac.yml');
|
|
1220
|
+
await fs.writeFile(rbacPath, 'invalid: yaml: syntax: [', 'utf8');
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Gets first datasource file path
|
|
1225
|
+
* @async
|
|
1226
|
+
* @function getFirstDatasourcePath
|
|
1227
|
+
* @param {string} appPath - Application path
|
|
1228
|
+
* @returns {Promise<string>} Datasource file path
|
|
1229
|
+
* @throws {Error} If no datasource files found
|
|
1230
|
+
*/
|
|
1231
|
+
async function getFirstDatasourcePath(appPath) {
|
|
1232
|
+
const datasourceFiles = (await fs.readdir(appPath)).filter(f => f.includes('-datasource-'));
|
|
1233
|
+
if (datasourceFiles.length === 0) {
|
|
1234
|
+
throw new Error(`No datasource files found in ${appPath}`);
|
|
1235
|
+
}
|
|
1236
|
+
return path.join(appPath, datasourceFiles[0]);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Corrupts datasource file by removing dimensions
|
|
1241
|
+
* @async
|
|
1242
|
+
* @function corruptDatasourceRemoveDimensions
|
|
1243
|
+
* @param {string} datasourcePath - Datasource file path
|
|
1244
|
+
* @returns {Promise<void>} Resolves when file is corrupted
|
|
1245
|
+
*/
|
|
1246
|
+
async function corruptDatasourceRemoveDimensions(datasourcePath) {
|
|
1247
|
+
const datasourceContent = await fs.readFile(datasourcePath, 'utf8');
|
|
1248
|
+
const datasourceJson = JSON.parse(datasourceContent);
|
|
1249
|
+
if (datasourceJson.fieldMappings) {
|
|
1250
|
+
delete datasourceJson.fieldMappings.dimensions;
|
|
1251
|
+
} else {
|
|
1252
|
+
datasourceJson.fieldMappings = {};
|
|
1253
|
+
}
|
|
1254
|
+
await fs.writeFile(datasourcePath, JSON.stringify(datasourceJson, null, 2), 'utf8');
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Corrupts datasource file with invalid dimension key
|
|
1259
|
+
* @async
|
|
1260
|
+
* @function corruptDatasourceInvalidDimensionKey
|
|
1261
|
+
* @param {string} datasourcePath - Datasource file path
|
|
1262
|
+
* @returns {Promise<void>} Resolves when file is corrupted
|
|
1263
|
+
*/
|
|
1264
|
+
async function corruptDatasourceInvalidDimensionKey(datasourcePath) {
|
|
1265
|
+
const datasourceContent = await fs.readFile(datasourcePath, 'utf8');
|
|
1266
|
+
const datasourceJson = JSON.parse(datasourceContent);
|
|
1267
|
+
if (!datasourceJson.fieldMappings) {
|
|
1268
|
+
datasourceJson.fieldMappings = {};
|
|
1269
|
+
}
|
|
1270
|
+
if (!datasourceJson.fieldMappings.dimensions) {
|
|
1271
|
+
datasourceJson.fieldMappings.dimensions = {};
|
|
1272
|
+
}
|
|
1273
|
+
datasourceJson.fieldMappings.dimensions['invalid-key-with-hyphen'] = 'metadata.id';
|
|
1274
|
+
await fs.writeFile(datasourcePath, JSON.stringify(datasourceJson, null, 2), 'utf8');
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/**
|
|
1278
|
+
* Corrupts datasource file with invalid attribute path
|
|
1279
|
+
* @async
|
|
1280
|
+
* @function corruptDatasourceInvalidAttributePath
|
|
1281
|
+
* @param {string} datasourcePath - Datasource file path
|
|
1282
|
+
* @returns {Promise<void>} Resolves when file is corrupted
|
|
1283
|
+
*/
|
|
1284
|
+
async function corruptDatasourceInvalidAttributePath(datasourcePath) {
|
|
1285
|
+
const datasourceContent = await fs.readFile(datasourcePath, 'utf8');
|
|
1286
|
+
const datasourceJson = JSON.parse(datasourceContent);
|
|
1287
|
+
if (!datasourceJson.fieldMappings) {
|
|
1288
|
+
datasourceJson.fieldMappings = {};
|
|
1289
|
+
}
|
|
1290
|
+
if (!datasourceJson.fieldMappings.dimensions) {
|
|
1291
|
+
datasourceJson.fieldMappings.dimensions = {};
|
|
1292
|
+
}
|
|
1293
|
+
datasourceJson.fieldMappings.dimensions['valid_key'] = 'metadata.id-with-invalid-chars@';
|
|
1294
|
+
await fs.writeFile(datasourcePath, JSON.stringify(datasourceJson, null, 2), 'utf8');
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
/**
|
|
1298
|
+
* Corrupts datasource file with dimensions as array
|
|
1299
|
+
* @async
|
|
1300
|
+
* @function corruptDatasourceDimensionsAsArray
|
|
1301
|
+
* @param {string} datasourcePath - Datasource file path
|
|
1302
|
+
* @returns {Promise<void>} Resolves when file is corrupted
|
|
1303
|
+
*/
|
|
1304
|
+
async function corruptDatasourceDimensionsAsArray(datasourcePath) {
|
|
1305
|
+
const datasourceContent = await fs.readFile(datasourcePath, 'utf8');
|
|
1306
|
+
const datasourceJson = JSON.parse(datasourceContent);
|
|
1307
|
+
if (!datasourceJson.fieldMappings) {
|
|
1308
|
+
datasourceJson.fieldMappings = {};
|
|
1309
|
+
}
|
|
1310
|
+
datasourceJson.fieldMappings.dimensions = ['invalid', 'array', 'format'];
|
|
1311
|
+
await fs.writeFile(datasourcePath, JSON.stringify(datasourceJson, null, 2), 'utf8');
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* Builds negative RBAC validation test cases
|
|
1316
|
+
* @function buildNegativeRbacTestCases
|
|
1317
|
+
* @param {Object} context - Test context
|
|
1318
|
+
* @returns {Array<Object>} Array of negative RBAC test cases
|
|
1319
|
+
*/
|
|
1320
|
+
function buildNegativeRbacTestCases(context) {
|
|
1321
|
+
return [
|
|
1322
|
+
{
|
|
1323
|
+
id: '2.12',
|
|
1324
|
+
type: 'negative',
|
|
1325
|
+
name: 'RBAC missing role referenced in permissions',
|
|
1326
|
+
run: async(options) => {
|
|
1327
|
+
const appName = 'hubspot-test-negative-rbac-missing-role';
|
|
1328
|
+
const appPath = await createSystemForNegativeTest(appName, 'wizard-valid-for-rbac-test', context, options);
|
|
1329
|
+
await corruptSystemFileWithInvalidRole(appPath);
|
|
1330
|
+
await runValidationExpectFailure(appName, context, options, 'references role "non-existent-role" which does not exist');
|
|
1331
|
+
await cleanupAppArtifacts(appName, options);
|
|
1332
|
+
}
|
|
1333
|
+
},
|
|
1334
|
+
{
|
|
1335
|
+
id: '2.13',
|
|
1336
|
+
type: 'negative',
|
|
1337
|
+
name: 'RBAC invalid YAML syntax',
|
|
1338
|
+
run: async(options) => {
|
|
1339
|
+
const appName = 'hubspot-test-negative-rbac-invalid-yaml';
|
|
1340
|
+
const appPath = await createSystemForNegativeTest(appName, 'wizard-valid-for-rbac-yaml-test', context, options);
|
|
1341
|
+
await corruptRbacFile(appPath);
|
|
1342
|
+
await runValidationExpectFailure(appName, context, options, 'schema must be object or boolean');
|
|
1343
|
+
await cleanupAppArtifacts(appName, options);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
];
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Builds negative dimension validation test cases
|
|
1351
|
+
* @function buildNegativeDimensionTestCases
|
|
1352
|
+
* @param {Object} context - Test context
|
|
1353
|
+
* @returns {Array<Object>} Array of negative dimension test cases
|
|
1354
|
+
*/
|
|
1355
|
+
function buildNegativeDimensionTestCases(context) {
|
|
1356
|
+
const createTestCase = (id, name, appName, configName, corruptFn, expectedError) => ({
|
|
1357
|
+
id,
|
|
1358
|
+
type: 'negative',
|
|
1359
|
+
name,
|
|
1360
|
+
run: async(options) => {
|
|
1361
|
+
const appPath = await createSystemForNegativeTest(appName, configName, context, options);
|
|
1362
|
+
const datasourcePath = await getFirstDatasourcePath(appPath);
|
|
1363
|
+
await corruptFn(datasourcePath);
|
|
1364
|
+
await runValidationExpectFailure(appName, context, options, expectedError);
|
|
1365
|
+
await cleanupAppArtifacts(appName, options);
|
|
1366
|
+
}
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
return [
|
|
1370
|
+
createTestCase(
|
|
1371
|
+
'2.14',
|
|
1372
|
+
'Datasource missing dimensions in fieldMappings',
|
|
1373
|
+
'hubspot-test-negative-dimension-missing',
|
|
1374
|
+
'wizard-valid-for-dimension-test',
|
|
1375
|
+
corruptDatasourceRemoveDimensions,
|
|
1376
|
+
'Missing required property "dimensions"'
|
|
1377
|
+
),
|
|
1378
|
+
createTestCase(
|
|
1379
|
+
'2.15',
|
|
1380
|
+
'Datasource invalid dimension key pattern',
|
|
1381
|
+
'hubspot-test-negative-dimension-invalid-key',
|
|
1382
|
+
'wizard-valid-for-dimension-key-test',
|
|
1383
|
+
corruptDatasourceInvalidDimensionKey,
|
|
1384
|
+
'Must be at most 40 characters'
|
|
1385
|
+
),
|
|
1386
|
+
createTestCase(
|
|
1387
|
+
'2.16',
|
|
1388
|
+
'Datasource invalid attribute path pattern',
|
|
1389
|
+
'hubspot-test-negative-dimension-invalid-path',
|
|
1390
|
+
'wizard-valid-for-dimension-path-test',
|
|
1391
|
+
corruptDatasourceInvalidAttributePath,
|
|
1392
|
+
'must match pattern'
|
|
1393
|
+
),
|
|
1394
|
+
createTestCase(
|
|
1395
|
+
'2.17',
|
|
1396
|
+
'Datasource dimensions as array instead of object',
|
|
1397
|
+
'hubspot-test-negative-dimension-array',
|
|
1398
|
+
'wizard-valid-for-dimension-array-test',
|
|
1399
|
+
corruptDatasourceDimensionsAsArray,
|
|
1400
|
+
'Expected object, got undefined'
|
|
1401
|
+
)
|
|
1402
|
+
];
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* Builds negative test cases
|
|
1407
|
+
* @function buildNegativeTestCases
|
|
1408
|
+
* @param {Object} context - Test context
|
|
1409
|
+
* @returns {Array<Object>} Array of negative test cases
|
|
1410
|
+
*/
|
|
1411
|
+
function buildNegativeTestCases(context) {
|
|
1412
|
+
return [
|
|
1413
|
+
...buildNegativeConfigTestCases(context),
|
|
1414
|
+
...buildNegativeCredentialTestCases(context),
|
|
1415
|
+
...buildNegativeRbacTestCases(context),
|
|
1416
|
+
...buildNegativeDimensionTestCases(context)
|
|
1417
|
+
];
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Builds all test cases
|
|
1422
|
+
* @function buildTestCases
|
|
1423
|
+
* @param {Object} context - Test context
|
|
1424
|
+
* @returns {Array<Object>} Array of all test cases
|
|
1425
|
+
*/
|
|
1426
|
+
function buildTestCases(context) {
|
|
1427
|
+
return [
|
|
1428
|
+
...buildPositiveTestCases(context),
|
|
1429
|
+
...buildRealDataTestCases(context),
|
|
1430
|
+
...buildNegativeTestCases(context)
|
|
1431
|
+
];
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Runs a test case
|
|
1436
|
+
* @async
|
|
1437
|
+
* @function runTestCase
|
|
1438
|
+
* @param {Object} testCase - Test case object
|
|
1439
|
+
* @param {Object} context - Test context
|
|
1440
|
+
* @param {Object} options - Options object
|
|
1441
|
+
* @returns {Promise<Object>} Test result object
|
|
1442
|
+
*/
|
|
1443
|
+
async function runTestCase(testCase, context, options) {
|
|
1444
|
+
const start = Date.now();
|
|
1445
|
+
try {
|
|
1446
|
+
await testCase.run(options);
|
|
1447
|
+
const durationMs = Date.now() - start;
|
|
1448
|
+
logSuccess(`PASS ${testCase.id} (${durationMs}ms) - ${testCase.name}`);
|
|
1449
|
+
return { id: testCase.id, status: 'passed' };
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
if (error instanceof SkipTestError) {
|
|
1452
|
+
logWarn(`SKIP ${testCase.id} - ${error.message}`);
|
|
1453
|
+
return { id: testCase.id, status: 'skipped' };
|
|
1454
|
+
}
|
|
1455
|
+
logError(`FAIL ${testCase.id} - ${error.message}`);
|
|
1456
|
+
return { id: testCase.id, status: 'failed', error };
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* Sets up test context and environment variables
|
|
1462
|
+
* @function setupTestContext
|
|
1463
|
+
* @param {Object} context - Test context object
|
|
1464
|
+
* @returns {void}
|
|
1465
|
+
*/
|
|
1466
|
+
function setupTestContext(context) {
|
|
1467
|
+
ensureEnvVar('CONTROLLER_URL', context.controllerUrl);
|
|
1468
|
+
ensureEnvVar('ENVIRONMENT', context.environment);
|
|
1469
|
+
if (context.dataplaneUrl) {
|
|
1470
|
+
ensureEnvVar('DATAPLANE_URL', context.dataplaneUrl);
|
|
1471
|
+
}
|
|
1472
|
+
ensureEnvVar('HUBSPOT_OPENAPI_FILE', context.openapiFile);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* Main function
|
|
1477
|
+
* @async
|
|
1478
|
+
* @function main
|
|
1479
|
+
* @returns {Promise<void>} Resolves when all tests complete
|
|
1480
|
+
*/
|
|
1481
|
+
async function main() {
|
|
1482
|
+
const args = parseArgs(process.argv);
|
|
1483
|
+
if (args.help) {
|
|
1484
|
+
printUsage();
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
await ensureDir(ARTIFACT_DIR);
|
|
1488
|
+
await loadEnvFile(DEFAULT_ENV_PATH, args);
|
|
1489
|
+
const context = {
|
|
1490
|
+
controllerUrl: DEFAULT_CONTROLLER_URL,
|
|
1491
|
+
environment: DEFAULT_ENVIRONMENT,
|
|
1492
|
+
dataplaneUrl: DEFAULT_DATAPLANE_URL,
|
|
1493
|
+
openapiFile: DEFAULT_OPENAPI_FILE
|
|
1494
|
+
};
|
|
1495
|
+
setupTestContext(context);
|
|
1496
|
+
await validateAuth(context, args);
|
|
1497
|
+
const testCases = buildTestCases(context).filter(testCase => (
|
|
1498
|
+
matchesSelection(testCase, args.tests) && matchesType(testCase, args.types)
|
|
1499
|
+
));
|
|
1500
|
+
if (testCases.length === 0) {
|
|
1501
|
+
logWarn('No matching test cases found.');
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
const results = [];
|
|
1505
|
+
for (const testCase of testCases) {
|
|
1506
|
+
results.push(await runTestCase(testCase, context, args));
|
|
1507
|
+
}
|
|
1508
|
+
const failed = results.filter(result => result.status === 'failed');
|
|
1509
|
+
if (failed.length > 0) {
|
|
1510
|
+
process.exitCode = 1;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
main().catch(error => {
|
|
1515
|
+
logError(error.message || 'Unexpected error');
|
|
1516
|
+
process.exitCode = 1;
|
|
1517
|
+
});
|