@appliqation/automation-sdk 2.7.0 → 2.8.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/README.md +299 -1273
- package/package.json +5 -34
- package/src/AppliqationClient.js +125 -25
- package/src/cli/auth-setup.js +71 -8
- package/src/constants.js +21 -1
- package/src/core/AuthManager.js +5 -0
- package/src/index.d.ts +102 -6
- package/src/login/index.d.ts +2 -1
- package/src/login/index.js +2 -1
- package/src/playwright/fixture.js +4 -1
- package/src/reporters/playwright/AppliqationReporter.js +159 -11
- package/src/services/OrphanTestService.js +21 -2
- package/src/services/ResultService.js +76 -45
- package/src/services/RunMatrixService.js +21 -1
- package/src/services/ScopeValidator.js +314 -0
- package/src/services/TaggingService.js +5 -5
- package/src/utils/PayloadBuilder.js +112 -21
- package/src/utils/logger.js +59 -5
- package/src/reporters/cypress/CypressReporter.js +0 -434
- package/src/reporters/cypress/UuidExtractor.js +0 -139
- package/src/reporters/cypress/index.js +0 -30
- package/src/reporters/jest/JestReporter.js +0 -408
- package/src/reporters/jest/UuidExtractor.js +0 -174
- package/src/reporters/jest/index.js +0 -28
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
const readline = require('readline');
|
|
2
|
+
const UuidValidator = require('../utils/UuidValidator');
|
|
3
|
+
const UuidExtractor = require('../reporters/playwright/helpers/UuidExtractor');
|
|
4
|
+
const logger = require('../utils/logger');
|
|
5
|
+
|
|
6
|
+
const STRATEGY = {
|
|
7
|
+
CANCEL: 'cancel',
|
|
8
|
+
ADHOC: 'adhoc',
|
|
9
|
+
FILTER: 'filter'
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const VALID_STRATEGIES = Object.values(STRATEGY);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Validates that the UUIDs about to be executed match the user's configured
|
|
16
|
+
* scenarioId or testSetId scope, and applies a resolution strategy when they
|
|
17
|
+
* don't.
|
|
18
|
+
*
|
|
19
|
+
* Closes finding C5 in the SDK audit — without this, the SDK silently
|
|
20
|
+
* submits cross-scenario results to a single-scenario run, corrupting the
|
|
21
|
+
* Appliqation data store.
|
|
22
|
+
*
|
|
23
|
+
* Three resolution strategies, selected via `config.onScopeMismatch`:
|
|
24
|
+
* 'cancel' (default in CI) — fail fast, exit 1, submit nothing
|
|
25
|
+
* 'adhoc' — override config, create an ad-hoc run
|
|
26
|
+
* 'filter' — drop out-of-scope results, submit the rest
|
|
27
|
+
*
|
|
28
|
+
* In a TTY (no `CI=true` and stdin is a TTY), the user is prompted
|
|
29
|
+
* interactively before any test runs. In CI, the configured strategy is
|
|
30
|
+
* used; if unset, 'cancel' is the fail-safe default.
|
|
31
|
+
*/
|
|
32
|
+
class ScopeValidator {
|
|
33
|
+
/**
|
|
34
|
+
* @param {Object} options
|
|
35
|
+
* @param {number} [options.scenarioId]
|
|
36
|
+
* @param {number} [options.testSetId]
|
|
37
|
+
* @param {'cancel'|'adhoc'|'filter'} [options.strategy]
|
|
38
|
+
* @param {Function} [options.fetchTestSetScope] async (testSetId) -> Set<uuid>
|
|
39
|
+
*/
|
|
40
|
+
constructor(options = {}) {
|
|
41
|
+
this.scenarioId = options.scenarioId || null;
|
|
42
|
+
this.testSetId = options.testSetId || null;
|
|
43
|
+
this.strategy = this._resolveStrategy(options.strategy);
|
|
44
|
+
this.fetchTestSetScope = options.fetchTestSetScope || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_resolveStrategy(explicit) {
|
|
48
|
+
const envValue = process.env.APPLIQATION_ON_SCOPE_MISMATCH;
|
|
49
|
+
const raw = (explicit || envValue || STRATEGY.CANCEL).toLowerCase();
|
|
50
|
+
if (!VALID_STRATEGIES.includes(raw)) {
|
|
51
|
+
logger.warn(`Unknown onScopeMismatch strategy "${raw}", falling back to "cancel"`);
|
|
52
|
+
return STRATEGY.CANCEL;
|
|
53
|
+
}
|
|
54
|
+
return raw;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @returns {boolean} True if scenarioId or testSetId is set — i.e., the
|
|
59
|
+
* user has declared a scope and we need to validate against it.
|
|
60
|
+
*/
|
|
61
|
+
isScoped() {
|
|
62
|
+
return Boolean(this.scenarioId || this.testSetId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract every Appliqation UUID from a Playwright suite tree.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} suite - Playwright Suite from onBegin
|
|
69
|
+
* @returns {string[]} Unique UUIDs, in test-discovery order
|
|
70
|
+
*/
|
|
71
|
+
extractUuidsFromSuite(suite) {
|
|
72
|
+
if (!suite || typeof suite.allTests !== 'function') return [];
|
|
73
|
+
|
|
74
|
+
const seen = new Set();
|
|
75
|
+
const ordered = [];
|
|
76
|
+
for (const test of suite.allTests()) {
|
|
77
|
+
const uuid = UuidExtractor.extractFromAnnotations(test.annotations || [])
|
|
78
|
+
|| UuidExtractor.extractFromTest(test);
|
|
79
|
+
if (uuid && !seen.has(uuid)) {
|
|
80
|
+
seen.add(uuid);
|
|
81
|
+
ordered.push(uuid);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return ordered;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validate the set of UUIDs against the configured scope.
|
|
89
|
+
*
|
|
90
|
+
* @param {string[]} uuids
|
|
91
|
+
* @returns {Promise<{
|
|
92
|
+
* hasMismatch: boolean,
|
|
93
|
+
* inScope: string[],
|
|
94
|
+
* outOfScope: Array<{uuid: string, actualScenarioId?: number}>,
|
|
95
|
+
* reason: 'scenario' | 'testset' | null
|
|
96
|
+
* }>}
|
|
97
|
+
*/
|
|
98
|
+
async validate(uuids) {
|
|
99
|
+
if (!this.isScoped()) {
|
|
100
|
+
return { hasMismatch: false, inScope: uuids, outOfScope: [], reason: null };
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(uuids) || uuids.length === 0) {
|
|
103
|
+
return { hasMismatch: false, inScope: [], outOfScope: [], reason: null };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (this.scenarioId) {
|
|
107
|
+
return this._validateScenario(uuids);
|
|
108
|
+
}
|
|
109
|
+
return this._validateTestSet(uuids);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_validateScenario(uuids) {
|
|
113
|
+
const inScope = [];
|
|
114
|
+
const outOfScope = [];
|
|
115
|
+
|
|
116
|
+
for (const uuid of uuids) {
|
|
117
|
+
const nid = UuidValidator.extractNid(uuid);
|
|
118
|
+
if (nid === null) {
|
|
119
|
+
// Malformed UUID — let the regular submission path reject it.
|
|
120
|
+
// Don't fail the scope check on bad data we didn't author.
|
|
121
|
+
inScope.push(uuid);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (nid === Number(this.scenarioId)) {
|
|
125
|
+
inScope.push(uuid);
|
|
126
|
+
} else {
|
|
127
|
+
outOfScope.push({ uuid, actualScenarioId: nid });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
hasMismatch: outOfScope.length > 0,
|
|
133
|
+
inScope,
|
|
134
|
+
outOfScope,
|
|
135
|
+
reason: 'scenario'
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async _validateTestSet(uuids) {
|
|
140
|
+
if (!this.fetchTestSetScope) {
|
|
141
|
+
// No way to fetch testset membership — fail open with a warning
|
|
142
|
+
// rather than blocking the run on a missing dependency.
|
|
143
|
+
logger.warn(
|
|
144
|
+
'testSetId is set but no testset-scope fetcher was provided; '
|
|
145
|
+
+ 'skipping scope validation. Pass `fetchTestSetScope` to enable.'
|
|
146
|
+
);
|
|
147
|
+
return { hasMismatch: false, inScope: uuids, outOfScope: [], reason: null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let scope;
|
|
151
|
+
try {
|
|
152
|
+
scope = await this.fetchTestSetScope(this.testSetId);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
logger.warn('Failed to fetch testset scope; skipping validation', {
|
|
155
|
+
testSetId: this.testSetId,
|
|
156
|
+
error: error.message
|
|
157
|
+
});
|
|
158
|
+
return { hasMismatch: false, inScope: uuids, outOfScope: [], reason: null };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const scopeSet = scope instanceof Set ? scope : new Set(scope || []);
|
|
162
|
+
const inScope = [];
|
|
163
|
+
const outOfScope = [];
|
|
164
|
+
|
|
165
|
+
for (const uuid of uuids) {
|
|
166
|
+
if (scopeSet.has(uuid)) {
|
|
167
|
+
inScope.push(uuid);
|
|
168
|
+
} else {
|
|
169
|
+
outOfScope.push({ uuid });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
hasMismatch: outOfScope.length > 0,
|
|
175
|
+
inScope,
|
|
176
|
+
outOfScope,
|
|
177
|
+
reason: 'testset'
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Resolve a scope mismatch by applying the chosen strategy. In a TTY,
|
|
183
|
+
* the user is prompted interactively before any strategy is applied.
|
|
184
|
+
*
|
|
185
|
+
* @param {Object} validation - result of `validate()`
|
|
186
|
+
* @returns {Promise<{
|
|
187
|
+
* action: 'cancel' | 'adhoc' | 'filter',
|
|
188
|
+
* submitUuids: string[],
|
|
189
|
+
* droppedUuids: string[],
|
|
190
|
+
* overrideToAdhoc: boolean,
|
|
191
|
+
* message: string
|
|
192
|
+
* }>}
|
|
193
|
+
*/
|
|
194
|
+
async resolve(validation) {
|
|
195
|
+
if (!validation.hasMismatch) {
|
|
196
|
+
return {
|
|
197
|
+
action: 'pass',
|
|
198
|
+
submitUuids: validation.inScope,
|
|
199
|
+
droppedUuids: [],
|
|
200
|
+
overrideToAdhoc: false,
|
|
201
|
+
message: 'Scope check passed.'
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const strategy = this._isInteractive()
|
|
206
|
+
? await this._promptInteractive(validation)
|
|
207
|
+
: this.strategy;
|
|
208
|
+
|
|
209
|
+
return this._applyStrategy(strategy, validation);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
_isInteractive() {
|
|
213
|
+
if (process.env.CI === 'true' || process.env.CI === '1') return false;
|
|
214
|
+
return Boolean(process.stdin && process.stdin.isTTY);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async _promptInteractive(validation) {
|
|
218
|
+
const summary = this._formatMismatchSummary(validation);
|
|
219
|
+
// Use process.stdout (not logger) because this is a synchronous user
|
|
220
|
+
// prompt that must appear regardless of log level.
|
|
221
|
+
process.stdout.write('\n');
|
|
222
|
+
process.stdout.write(summary);
|
|
223
|
+
process.stdout.write('\n');
|
|
224
|
+
process.stdout.write('Choose how to proceed:\n');
|
|
225
|
+
process.stdout.write(' [1] cancel — fail fast, submit nothing (safest)\n');
|
|
226
|
+
process.stdout.write(' [2] adhoc — create an ad-hoc run and submit everything\n');
|
|
227
|
+
process.stdout.write(' [3] filter — submit only in-scope results, drop the rest\n');
|
|
228
|
+
|
|
229
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
230
|
+
const ask = (q) => new Promise((res) => rl.question(q, (a) => res(a)));
|
|
231
|
+
try {
|
|
232
|
+
const answer = (await ask('Selection [1/2/3]: ')).trim();
|
|
233
|
+
const map = { '1': STRATEGY.CANCEL, '2': STRATEGY.ADHOC, '3': STRATEGY.FILTER };
|
|
234
|
+
return map[answer] || STRATEGY.CANCEL;
|
|
235
|
+
} finally {
|
|
236
|
+
rl.close();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
_applyStrategy(strategy, validation) {
|
|
241
|
+
const droppedUuids = validation.outOfScope.map((o) => o.uuid);
|
|
242
|
+
const summary = this._formatMismatchSummary(validation);
|
|
243
|
+
|
|
244
|
+
if (strategy === STRATEGY.CANCEL) {
|
|
245
|
+
return {
|
|
246
|
+
action: STRATEGY.CANCEL,
|
|
247
|
+
submitUuids: [],
|
|
248
|
+
droppedUuids: [...validation.inScope, ...droppedUuids],
|
|
249
|
+
overrideToAdhoc: false,
|
|
250
|
+
message:
|
|
251
|
+
`${summary}\n`
|
|
252
|
+
+ 'Strategy: cancel. No results will be submitted to Appliqation.\n'
|
|
253
|
+
+ 'Set onScopeMismatch (or APPLIQATION_ON_SCOPE_MISMATCH) to '
|
|
254
|
+
+ '"adhoc" or "filter" to change this behaviour.'
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (strategy === STRATEGY.ADHOC) {
|
|
259
|
+
return {
|
|
260
|
+
action: STRATEGY.ADHOC,
|
|
261
|
+
submitUuids: [...validation.inScope, ...droppedUuids],
|
|
262
|
+
droppedUuids: [],
|
|
263
|
+
overrideToAdhoc: true,
|
|
264
|
+
message:
|
|
265
|
+
`${summary}\n`
|
|
266
|
+
+ 'Strategy: adhoc. The configured scenarioId/testSetId is being '
|
|
267
|
+
+ 'overridden — an ad-hoc run will be created instead and all '
|
|
268
|
+
+ 'results will be submitted to it.'
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// FILTER
|
|
273
|
+
return {
|
|
274
|
+
action: STRATEGY.FILTER,
|
|
275
|
+
submitUuids: validation.inScope,
|
|
276
|
+
droppedUuids,
|
|
277
|
+
overrideToAdhoc: false,
|
|
278
|
+
message:
|
|
279
|
+
`${summary}\n`
|
|
280
|
+
+ `Strategy: filter. ${validation.inScope.length} in-scope result(s) `
|
|
281
|
+
+ `will be submitted; ${droppedUuids.length} out-of-scope result(s) `
|
|
282
|
+
+ 'will execute locally but their verdicts will NOT be uploaded.'
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
_formatMismatchSummary(validation) {
|
|
287
|
+
const target = validation.reason === 'scenario'
|
|
288
|
+
? `scenarioId ${this.scenarioId}`
|
|
289
|
+
: `testSetId ${this.testSetId}`;
|
|
290
|
+
|
|
291
|
+
const examples = validation.outOfScope.slice(0, 5).map((o) => {
|
|
292
|
+
if (o.actualScenarioId !== undefined) {
|
|
293
|
+
return ` - ${o.uuid} (belongs to scenario ${o.actualScenarioId})`;
|
|
294
|
+
}
|
|
295
|
+
return ` - ${o.uuid} (not in testset)`;
|
|
296
|
+
}).join('\n');
|
|
297
|
+
|
|
298
|
+
const more = validation.outOfScope.length > 5
|
|
299
|
+
? `\n … and ${validation.outOfScope.length - 5} more`
|
|
300
|
+
: '';
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
`[Appliqation] Scope mismatch detected.\n`
|
|
304
|
+
+ `Configured: ${target}\n`
|
|
305
|
+
+ `Out-of-scope test(s): ${validation.outOfScope.length} of ${validation.inScope.length + validation.outOfScope.length}\n`
|
|
306
|
+
+ examples
|
|
307
|
+
+ more
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
ScopeValidator.STRATEGY = STRATEGY;
|
|
313
|
+
|
|
314
|
+
module.exports = ScopeValidator;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const logger = require('../utils/logger');
|
|
2
|
+
const { AUTO_TAG_NAME } = require('../constants');
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Service for auto-tagging test cases after successful runs
|
|
@@ -8,12 +9,11 @@ class TaggingService {
|
|
|
8
9
|
this.http = httpClient;
|
|
9
10
|
this.config = config;
|
|
10
11
|
|
|
11
|
-
//
|
|
12
|
+
// Whether to auto-tag is configurable; the tag name itself is not —
|
|
13
|
+
// see AUTO_TAG_NAME's doc comment.
|
|
12
14
|
this.enabled = this.config?.options?.autoTag !== false &&
|
|
13
15
|
process.env.APPLIQATION_AUTO_TAG_ENABLED !== 'false';
|
|
14
|
-
this.tagName =
|
|
15
|
-
process.env.APPLIQATION_AUTO_TAG_NAME ||
|
|
16
|
-
'Appq_automated';
|
|
16
|
+
this.tagName = AUTO_TAG_NAME;
|
|
17
17
|
this.batchSize = this.config?.options?.autoTagBatchSize || 50;
|
|
18
18
|
this.retries = this.config?.options?.autoTagRetries || 2;
|
|
19
19
|
}
|
|
@@ -42,7 +42,7 @@ class TaggingService {
|
|
|
42
42
|
const unique = Array.from(new Set(uuids.filter(Boolean)));
|
|
43
43
|
|
|
44
44
|
try {
|
|
45
|
-
// Build query string: uuids=123-xxx,124-yyy&tag=
|
|
45
|
+
// Build query string: uuids=123-xxx,124-yyy&tag=Appq_Auto
|
|
46
46
|
const uuidsParam = unique.join(',');
|
|
47
47
|
const url = `/api/automation/testcases/tags/check?uuids=${encodeURIComponent(uuidsParam)}&tag=${encodeURIComponent(tag)}`;
|
|
48
48
|
const response = await this.http.get(url);
|
|
@@ -114,36 +114,19 @@ class PayloadBuilder {
|
|
|
114
114
|
static normalizeStatus(status) {
|
|
115
115
|
if (!status) return 'unknown';
|
|
116
116
|
|
|
117
|
+
// Playwright-only status map. Cypress/Jest/generic variants were
|
|
118
|
+
// removed alongside the Cypress and Jest reporters — D1/D2/D3.
|
|
117
119
|
const statusMap = {
|
|
118
|
-
// Standard statuses
|
|
119
120
|
'passed': 'passed',
|
|
120
121
|
'failed': 'failed',
|
|
121
122
|
'skipped': 'skipped',
|
|
122
123
|
'pending': 'pending',
|
|
123
|
-
|
|
124
|
-
// Playwright variants
|
|
125
124
|
'expected': 'passed',
|
|
126
125
|
'unexpected': 'failed',
|
|
127
126
|
'flaky': 'flaky',
|
|
128
127
|
'timedOut': 'failed',
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// Cypress variants
|
|
132
|
-
'pass': 'passed',
|
|
133
|
-
'fail': 'failed',
|
|
134
|
-
|
|
135
|
-
// Jest variants
|
|
136
|
-
'success': 'passed',
|
|
137
|
-
'error': 'failed',
|
|
138
|
-
'disabled': 'skipped',
|
|
139
|
-
|
|
140
|
-
// Generic variants
|
|
141
|
-
'ok': 'passed',
|
|
142
|
-
'success': 'passed',
|
|
143
|
-
'failure': 'failed',
|
|
144
|
-
'error': 'failed',
|
|
145
|
-
'skip': 'skipped',
|
|
146
|
-
'todo': 'pending'
|
|
128
|
+
// Aborted run, not a skip — see AppliqationReporter.mapStatus.
|
|
129
|
+
'interrupted': 'failed'
|
|
147
130
|
};
|
|
148
131
|
|
|
149
132
|
const normalized = statusMap[status.toLowerCase()];
|
|
@@ -212,6 +195,114 @@ class PayloadBuilder {
|
|
|
212
195
|
};
|
|
213
196
|
}
|
|
214
197
|
|
|
198
|
+
/**
|
|
199
|
+
* S8 — Pre-submission payload validation for the automation result
|
|
200
|
+
* endpoint. Runs AFTER buildAutomationResultPayload has assembled the
|
|
201
|
+
* final wire payload, immediately before the HTTP POST. Enforces the
|
|
202
|
+
* exact shape the backend's InputValidator + AutomationResultManager
|
|
203
|
+
* accept, so shape drift (wrong field name, missing required field,
|
|
204
|
+
* bad type) is caught at the client instead of failing at the server
|
|
205
|
+
* with a generic 400 mid-run.
|
|
206
|
+
*
|
|
207
|
+
* Defence-in-depth on top of H2 (which validates user-supplied CONFIG
|
|
208
|
+
* at startup) — S8 validates the assembled OUTGOING payload.
|
|
209
|
+
*
|
|
210
|
+
* Backend constraints (mirror of appq_mongo InputValidator +
|
|
211
|
+
* AutomationApiController::submitResultBatch):
|
|
212
|
+
* run_id required, matches /^[a-zA-Z0-9_:-]+$/ max 100
|
|
213
|
+
* test_case_uuid required, matches UuidValidator's regex
|
|
214
|
+
* status required, one of {passed, failed, skipped}
|
|
215
|
+
* source 'automation' when SDK-submitted (post C1)
|
|
216
|
+
* browser required, matches /^[a-zA-Z0-9 .\-_(),\/]+$/ max 255
|
|
217
|
+
* environment matches /^[a-zA-Z0-9._-]+$/ max 255
|
|
218
|
+
* duration integer >= 0 when present
|
|
219
|
+
* timestamp positive integer when present
|
|
220
|
+
*
|
|
221
|
+
* @param {Object} payload - Fully assembled result payload
|
|
222
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
223
|
+
*/
|
|
224
|
+
static validateAutomationResultPayload(payload) {
|
|
225
|
+
const errors = [];
|
|
226
|
+
|
|
227
|
+
if (!payload || typeof payload !== 'object') {
|
|
228
|
+
return { valid: false, errors: ['payload must be an object'] };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// run_id
|
|
232
|
+
if (typeof payload.run_id !== 'string' || payload.run_id.length === 0) {
|
|
233
|
+
errors.push('run_id must be a non-empty string');
|
|
234
|
+
} else if (payload.run_id.length > 100) {
|
|
235
|
+
errors.push(`run_id exceeds 100 chars (got ${payload.run_id.length})`);
|
|
236
|
+
} else if (!/^[a-zA-Z0-9_:-]+$/.test(payload.run_id)) {
|
|
237
|
+
errors.push(`run_id contains invalid characters: "${payload.run_id}"`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// test_case_uuid — reuse UuidValidator for the canonical check
|
|
241
|
+
if (!payload.test_case_uuid) {
|
|
242
|
+
errors.push('test_case_uuid is required');
|
|
243
|
+
} else {
|
|
244
|
+
// Require nid-prefixed uuid-v4 shape. Duplicated here to avoid a
|
|
245
|
+
// circular require between PayloadBuilder and UuidValidator.
|
|
246
|
+
const uuidPattern = /^\d+-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
247
|
+
if (!uuidPattern.test(payload.test_case_uuid)) {
|
|
248
|
+
errors.push(`test_case_uuid does not match {nid}-{uuid-v4} shape: "${payload.test_case_uuid}"`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// status
|
|
253
|
+
const ALLOWED_STATUSES = new Set(['passed', 'failed', 'skipped']);
|
|
254
|
+
if (!payload.status) {
|
|
255
|
+
errors.push('status is required');
|
|
256
|
+
} else if (!ALLOWED_STATUSES.has(String(payload.status).toLowerCase())) {
|
|
257
|
+
errors.push(`status must be one of {passed, failed, skipped} (got "${payload.status}")`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// source — must be 'automation' when set (C1 contract)
|
|
261
|
+
if (payload.source !== undefined && payload.source !== 'automation') {
|
|
262
|
+
errors.push(`source must be "automation" if provided (got "${payload.source}")`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// browser
|
|
266
|
+
if (payload.browser !== undefined) {
|
|
267
|
+
if (typeof payload.browser !== 'string' || payload.browser.length === 0) {
|
|
268
|
+
errors.push('browser must be a non-empty string when provided');
|
|
269
|
+
} else if (payload.browser.length > 255) {
|
|
270
|
+
errors.push(`browser exceeds 255 chars (got ${payload.browser.length})`);
|
|
271
|
+
} else if (!/^[a-zA-Z0-9 .\-_(),\/]+$/.test(payload.browser)) {
|
|
272
|
+
errors.push(`browser contains invalid characters: "${payload.browser}"`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// environment
|
|
277
|
+
if (payload.environment !== undefined && payload.environment !== '') {
|
|
278
|
+
if (typeof payload.environment !== 'string') {
|
|
279
|
+
errors.push(`environment must be a string (got ${typeof payload.environment})`);
|
|
280
|
+
} else if (payload.environment.length > 255) {
|
|
281
|
+
errors.push(`environment exceeds 255 chars (got ${payload.environment.length})`);
|
|
282
|
+
} else if (!/^[a-zA-Z0-9._-]+$/.test(payload.environment)) {
|
|
283
|
+
errors.push(`environment contains invalid characters: "${payload.environment}"`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// duration
|
|
288
|
+
if (payload.duration !== undefined && payload.duration !== null) {
|
|
289
|
+
const d = payload.duration;
|
|
290
|
+
if (typeof d !== 'number' || !Number.isFinite(d) || d < 0 || !Number.isInteger(d)) {
|
|
291
|
+
errors.push(`duration must be a non-negative integer (got ${d})`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// timestamp
|
|
296
|
+
if (payload.timestamp !== undefined && payload.timestamp !== null) {
|
|
297
|
+
const t = payload.timestamp;
|
|
298
|
+
if (typeof t !== 'number' || !Number.isFinite(t) || t <= 0 || !Number.isInteger(t)) {
|
|
299
|
+
errors.push(`timestamp must be a positive integer (got ${t})`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return { valid: errors.length === 0, errors };
|
|
304
|
+
}
|
|
305
|
+
|
|
215
306
|
/**
|
|
216
307
|
* Validate run creation options
|
|
217
308
|
* @param {Object} options - Run options
|
package/src/utils/logger.js
CHANGED
|
@@ -5,7 +5,8 @@ const LOG_LEVELS = {
|
|
|
5
5
|
DEBUG: 3
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
-
//
|
|
8
|
+
// Regex-based sanitization catches value-shaped secrets inside string
|
|
9
|
+
// payloads (raw JWTs in error messages, axios request dumps, etc).
|
|
9
10
|
const SENSITIVE_PATTERNS = [
|
|
10
11
|
// API Keys
|
|
11
12
|
{ pattern: /(appq_\w+_[a-f0-9]{32})/gi, replacement: 'appq_***_REDACTED' },
|
|
@@ -30,6 +31,51 @@ const SENSITIVE_PATTERNS = [
|
|
|
30
31
|
{ pattern: /("session"\s*:\s*")([^"]+)(")/gi, replacement: '$1***REDACTED***$3' }
|
|
31
32
|
];
|
|
32
33
|
|
|
34
|
+
// H5 — field-name-based sanitization catches custom-named fields the regex
|
|
35
|
+
// list will miss: `x-secret`, `myAuthToken`, `dbPassword`, `serviceKey`, etc.
|
|
36
|
+
// Applied recursively before the regex pass. Errs on the side of over-
|
|
37
|
+
// redaction: any object key matching this pattern has its value replaced
|
|
38
|
+
// regardless of nesting depth.
|
|
39
|
+
const SENSITIVE_KEY_PATTERN = /token|secret|key|password|credential|auth/i;
|
|
40
|
+
|
|
41
|
+
function redactByFieldName(value, seen) {
|
|
42
|
+
if (value === null || typeof value !== 'object') return value;
|
|
43
|
+
if (!seen) seen = new WeakSet();
|
|
44
|
+
if (seen.has(value)) return '[Circular]';
|
|
45
|
+
seen.add(value);
|
|
46
|
+
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
return value.map((item) => redactByFieldName(item, seen));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const result = {};
|
|
52
|
+
for (const k of Object.keys(value)) {
|
|
53
|
+
const v = value[k];
|
|
54
|
+
if (SENSITIVE_KEY_PATTERN.test(k)) {
|
|
55
|
+
if (v === null || v === undefined) {
|
|
56
|
+
// Preserve null/undefined so consumers can detect "field present
|
|
57
|
+
// but empty" without leaking real values.
|
|
58
|
+
result[k] = v;
|
|
59
|
+
} else if (typeof v === 'object') {
|
|
60
|
+
// Recurse into nested objects/arrays. A sensitive container like
|
|
61
|
+
// `credentials: { user, password }` keeps its structure but its
|
|
62
|
+
// leaf values get redacted by the inner pass, so debug output
|
|
63
|
+
// still tells you which fields existed.
|
|
64
|
+
result[k] = redactByFieldName(v, seen);
|
|
65
|
+
} else if (typeof v === 'string' && /^Bearer\s+/i.test(v)) {
|
|
66
|
+
// Preserve scheme prefix for Authorization-style headers so the
|
|
67
|
+
// log reader can tell what kind of token was redacted.
|
|
68
|
+
result[k] = v.replace(/^(Bearer\s+).*$/i, '$1***REDACTED***');
|
|
69
|
+
} else {
|
|
70
|
+
result[k] = '***REDACTED***';
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
result[k] = redactByFieldName(v, seen);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
33
79
|
class Logger {
|
|
34
80
|
constructor(level = 'INFO') {
|
|
35
81
|
this.level = LOG_LEVELS[level.toUpperCase()] || LOG_LEVELS.INFO;
|
|
@@ -55,17 +101,25 @@ class Logger {
|
|
|
55
101
|
return sanitized;
|
|
56
102
|
}
|
|
57
103
|
|
|
58
|
-
// Handle objects and arrays by
|
|
104
|
+
// Handle objects and arrays: redact by field name first (catches
|
|
105
|
+
// custom-named credential fields), then apply the regex pass to any
|
|
106
|
+
// remaining string values for value-shaped secrets.
|
|
59
107
|
if (typeof data === 'object') {
|
|
60
108
|
try {
|
|
61
|
-
|
|
109
|
+
const redacted = redactByFieldName(data);
|
|
110
|
+
let jsonString = JSON.stringify(redacted);
|
|
62
111
|
SENSITIVE_PATTERNS.forEach(({ pattern, replacement }) => {
|
|
63
112
|
jsonString = jsonString.replace(pattern, replacement);
|
|
64
113
|
});
|
|
65
114
|
return JSON.parse(jsonString);
|
|
66
115
|
} catch (error) {
|
|
67
|
-
// If JSON
|
|
68
|
-
|
|
116
|
+
// If JSON serialization fails, return the field-redacted form
|
|
117
|
+
// rather than the raw data — never leak unfiltered objects.
|
|
118
|
+
try {
|
|
119
|
+
return redactByFieldName(data);
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return data;
|
|
122
|
+
}
|
|
69
123
|
}
|
|
70
124
|
}
|
|
71
125
|
|