@appliqation/automation-sdk 2.5.1 → 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.
@@ -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
- 'interrupted': 'skipped',
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
@@ -6,6 +6,13 @@ const UuidValidator = require('./UuidValidator');
6
6
  const PayloadBuilder = require('./PayloadBuilder');
7
7
  const RunDataNormalizer = require('./RunDataNormalizer');
8
8
  const { mapAppqUuid } = require('./mapAppqUuid');
9
+ const {
10
+ setupAuth,
11
+ authStatePath,
12
+ authStateDir,
13
+ envVarNames,
14
+ sanitizeRole,
15
+ } = require('./setupAuth');
9
16
  const logger = require('./logger');
10
17
 
11
18
  module.exports = {
@@ -13,5 +20,10 @@ module.exports = {
13
20
  PayloadBuilder,
14
21
  RunDataNormalizer,
15
22
  mapAppqUuid,
23
+ setupAuth,
24
+ authStatePath,
25
+ authStateDir,
26
+ envVarNames,
27
+ sanitizeRole,
16
28
  logger
17
29
  };
@@ -5,7 +5,8 @@ const LOG_LEVELS = {
5
5
  DEBUG: 3
6
6
  };
7
7
 
8
- // Sensitive data patterns to sanitize
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 converting to JSON and back
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
- let jsonString = JSON.stringify(data);
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 parsing fails, return original data
68
- return data;
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
 
@@ -0,0 +1,99 @@
1
+ /**
2
+ * setupAuth — portable Playwright storageState resolution.
3
+ *
4
+ * Returns a deterministic file path. The same path resolves to the same
5
+ * file regardless of execution context, so the same generated/authored
6
+ * test script runs unchanged in:
7
+ *
8
+ * - Appliqation's executor (the LoginHelper writes the file at this
9
+ * path during validation prep, using AWS Secrets Manager-backed
10
+ * credentials)
11
+ *
12
+ * - Customer CI / local (the `appq-auth-setup` CLI writes the file at
13
+ * this path, using credentials from env vars per the convention
14
+ * downloaded from Project Settings → "download .env for CI")
15
+ *
16
+ * setupAuth() does NOT perform login itself — that's the CLI's job (CI)
17
+ * or the executor's job (Appliqation cloud). It only computes the path.
18
+ *
19
+ * Usage in a Playwright test file:
20
+ *
21
+ * const { mapAppqUuid, setupAuth } = require('@appliqation/automation-sdk/utils');
22
+ *
23
+ * test.use({ storageState: setupAuth({ project_id: 126, role: 'default' }) });
24
+ *
25
+ * test('...', async ({ page }, testInfo) => {
26
+ * mapAppqUuid(testInfo, '<uuid>');
27
+ * // body — already authenticated
28
+ * });
29
+ */
30
+
31
+ const path = require('path');
32
+ const os = require('os');
33
+
34
+ /**
35
+ * Default base directory for storageState files. Overridable via
36
+ * APPQ_AUTH_STATE_DIR env var (Appliqation executor sets this per-run
37
+ * for tenant isolation; customers normally don't need to).
38
+ */
39
+ function authStateDir() {
40
+ return process.env.APPQ_AUTH_STATE_DIR
41
+ || path.join(os.homedir(), '.appq-auth');
42
+ }
43
+
44
+ /**
45
+ * Compute the canonical storageState path for a given project + role.
46
+ * Pure function — no I/O. Both the SDK CLI and the Appliqation executor
47
+ * call this same function so they always agree on the location.
48
+ */
49
+ function authStatePath({ project_id, role = 'default' }) {
50
+ if (project_id === undefined || project_id === null || project_id === '') {
51
+ throw new Error('authStatePath requires project_id');
52
+ }
53
+ const safeRole = sanitizeRole(role);
54
+ return path.join(authStateDir(), `project-${project_id}-${safeRole}.json`);
55
+ }
56
+
57
+ /**
58
+ * The public entry point. Returns a string suitable for Playwright's
59
+ * `storageState` option (a file path).
60
+ */
61
+ function setupAuth({ project_id, role = 'default' } = {}) {
62
+ if (project_id === undefined || project_id === null || project_id === '') {
63
+ throw new Error(
64
+ 'setupAuth requires project_id. Generated test files include this '
65
+ + 'as a literal; for hand-authored tests, copy from your project URL '
66
+ + 'in Appliqation.'
67
+ );
68
+ }
69
+ return authStatePath({ project_id, role });
70
+ }
71
+
72
+ /**
73
+ * Lowercase + alphanumeric/underscore/hyphen only. Mirrors the role-name
74
+ * validation in the Appliqation project settings UI so paths line up.
75
+ */
76
+ function sanitizeRole(role) {
77
+ return String(role).toLowerCase().replace(/[^a-z0-9_-]/g, '_');
78
+ }
79
+
80
+ /**
81
+ * Env-var name convention for the CLI (and any other consumer) to look
82
+ * up credentials. Matches what the "download .env for CI" feature in
83
+ * Appliqation generates. Exported so the CLI and the SDK never drift.
84
+ */
85
+ function envVarNames({ project_id, role }) {
86
+ const safeRole = sanitizeRole(role).toUpperCase();
87
+ return {
88
+ username: `APPQ_PROJECT_${project_id}_${safeRole}_USERNAME`,
89
+ password: `APPQ_PROJECT_${project_id}_${safeRole}_PASSWORD`,
90
+ };
91
+ }
92
+
93
+ module.exports = {
94
+ setupAuth,
95
+ authStatePath,
96
+ authStateDir,
97
+ envVarNames,
98
+ sanitizeRole,
99
+ };