@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.
- package/README.md +330 -1169
- package/package.json +13 -34
- package/src/AppliqationClient.js +125 -25
- package/src/cli/auth-setup.js +308 -0
- package/src/constants.js +21 -1
- package/src/core/AuthManager.js +5 -0
- package/src/index.d.ts +159 -6
- package/src/login/index.d.ts +77 -0
- package/src/login/index.js +49 -0
- 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/index.js +12 -0
- package/src/utils/logger.js +59 -5
- package/src/utils/setupAuth.js +99 -0
- 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
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appliqation/automation-sdk",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Appliqation Automation SDK
|
|
3
|
+
"version": "2.8.0",
|
|
4
|
+
"description": "Appliqation Automation SDK for Playwright — API-key auth, scope-validated result reporting, portable storageState (setupAuth), and customer-defined login flows (defineLogin) for gated apps",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"appq-auth-setup": "src/cli/auth-setup.js"
|
|
9
|
+
},
|
|
7
10
|
"exports": {
|
|
8
11
|
".": {
|
|
9
12
|
"require": "./src/index.js",
|
|
@@ -29,17 +32,14 @@
|
|
|
29
32
|
"require": "./src/playwright/fixture.js",
|
|
30
33
|
"import": "./src/playwright/fixture.js"
|
|
31
34
|
},
|
|
32
|
-
"./cypress": {
|
|
33
|
-
"require": "./src/reporters/cypress/index.js",
|
|
34
|
-
"import": "./src/reporters/cypress/index.js"
|
|
35
|
-
},
|
|
36
|
-
"./jest": {
|
|
37
|
-
"require": "./src/reporters/jest/index.js",
|
|
38
|
-
"import": "./src/reporters/jest/index.js"
|
|
39
|
-
},
|
|
40
35
|
"./utils": {
|
|
41
36
|
"require": "./src/utils/index.js",
|
|
42
37
|
"import": "./src/utils/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./login": {
|
|
40
|
+
"types": "./src/login/index.d.ts",
|
|
41
|
+
"require": "./src/login/index.js",
|
|
42
|
+
"import": "./src/login/index.js"
|
|
43
43
|
}
|
|
44
44
|
},
|
|
45
45
|
"files": [
|
|
@@ -57,9 +57,7 @@
|
|
|
57
57
|
"lint": "eslint src/",
|
|
58
58
|
"lint:fix": "eslint src/ --fix",
|
|
59
59
|
"example:basic": "node examples/basic-usage.js",
|
|
60
|
-
"example:playwright": "cd examples/playwright
|
|
61
|
-
"example:cypress": "cd examples/cypress-basic && npm test",
|
|
62
|
-
"example:jest": "cd examples/jest-basic && npm test",
|
|
60
|
+
"example:playwright": "cd examples/playwright && npx playwright test",
|
|
63
61
|
"docs": "echo 'Documentation generation coming soon'"
|
|
64
62
|
},
|
|
65
63
|
"keywords": [
|
|
@@ -68,10 +66,6 @@
|
|
|
68
66
|
"automation",
|
|
69
67
|
"test-management",
|
|
70
68
|
"playwright",
|
|
71
|
-
"cypress",
|
|
72
|
-
"jest",
|
|
73
|
-
"selenium",
|
|
74
|
-
"webdriver",
|
|
75
69
|
"api-key",
|
|
76
70
|
"sdk",
|
|
77
71
|
"test-reporting",
|
|
@@ -83,34 +77,19 @@
|
|
|
83
77
|
"license": "MIT",
|
|
84
78
|
"dependencies": {
|
|
85
79
|
"axios": "^1.6.0",
|
|
86
|
-
"dotenv": "^16.3.1",
|
|
87
80
|
"jsonwebtoken": "^9.0.3"
|
|
88
81
|
},
|
|
89
82
|
"devDependencies": {
|
|
90
83
|
"@playwright/test": "^1.40.0",
|
|
91
|
-
"cypress": "^13.0.0",
|
|
92
84
|
"eslint": "^8.57.0",
|
|
93
85
|
"jest": "^29.7.0",
|
|
94
86
|
"playwright": "^1.40.0"
|
|
95
87
|
},
|
|
96
88
|
"peerDependencies": {
|
|
97
|
-
"@playwright/test": ">=1.30.0"
|
|
98
|
-
"cypress": ">=10.0.0",
|
|
99
|
-
"jest": ">=29.0.0"
|
|
100
|
-
},
|
|
101
|
-
"peerDependenciesMeta": {
|
|
102
|
-
"@playwright/test": {
|
|
103
|
-
"optional": true
|
|
104
|
-
},
|
|
105
|
-
"cypress": {
|
|
106
|
-
"optional": true
|
|
107
|
-
},
|
|
108
|
-
"jest": {
|
|
109
|
-
"optional": true
|
|
110
|
-
}
|
|
89
|
+
"@playwright/test": ">=1.30.0"
|
|
111
90
|
},
|
|
112
91
|
"engines": {
|
|
113
|
-
"node": ">=
|
|
92
|
+
"node": ">=18.0.0"
|
|
114
93
|
},
|
|
115
94
|
"repository": {
|
|
116
95
|
"type": "git",
|
package/src/AppliqationClient.js
CHANGED
|
@@ -8,7 +8,13 @@ const ProjectInfoService = require('./services/ProjectInfoService');
|
|
|
8
8
|
const UuidValidator = require('./utils/UuidValidator');
|
|
9
9
|
const PayloadBuilder = require('./utils/PayloadBuilder');
|
|
10
10
|
const logger = require('./utils/logger');
|
|
11
|
-
const {
|
|
11
|
+
const { ConfigurationError } = require('./utils/errors');
|
|
12
|
+
const { DEFAULT_APPLIQATION_BASE_URL, AUTO_TAG_NAME } = require('./constants');
|
|
13
|
+
|
|
14
|
+
// H2 — environment names must match the backend's InputValidator regex
|
|
15
|
+
// (appq_mongo InputValidator). Mismatch causes a 400 mid-run; reject at
|
|
16
|
+
// startup instead so misconfiguration fails immediately and obviously.
|
|
17
|
+
const ENVIRONMENT_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
20
|
* Main Appliqation Automation SDK Client
|
|
@@ -49,22 +55,51 @@ class AppliqationClient {
|
|
|
49
55
|
constructor(config = {}) {
|
|
50
56
|
const resolved = AppliqationClient.resolveConfig(config);
|
|
51
57
|
|
|
52
|
-
//
|
|
58
|
+
// H2 — fail-fast schema validation. Hard errors throw at construction so
|
|
59
|
+
// misconfiguration surfaces immediately instead of after a full test run.
|
|
60
|
+
// Soft warnings (missing apiKey, etc.) still just log so no-op mode is
|
|
61
|
+
// preserved when the user intentionally runs without reporting.
|
|
53
62
|
const validation = this.validateConfig(resolved);
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
if (validation.errors.length > 0) {
|
|
64
|
+
throw new ConfigurationError(
|
|
65
|
+
`Invalid Appliqation SDK configuration:\n - ${validation.errors.join('\n - ')}`,
|
|
66
|
+
{ errors: validation.errors }
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
for (const warning of validation.warnings) {
|
|
70
|
+
logger.warn(warning);
|
|
59
71
|
}
|
|
60
72
|
|
|
61
|
-
//
|
|
73
|
+
// H3 — TLS enforcement. Disabling cert validation is allowed in local
|
|
74
|
+
// dev (self-signed certs) but actively dangerous in CI/production: a
|
|
75
|
+
// MITM attacker can intercept result submissions and alter verdicts.
|
|
76
|
+
// The previous behaviour only warned; we now throw to make the misuse
|
|
77
|
+
// impossible to ship. Customers who genuinely need to bypass TLS in a
|
|
78
|
+
// CI environment must explicitly set APPLIQATION_INSECURE=allow.
|
|
62
79
|
const isProduction = this._isProductionEnvironment();
|
|
63
80
|
let rejectUnauthorized = true;
|
|
64
81
|
|
|
65
82
|
if (resolved.rejectUnauthorized !== undefined) {
|
|
66
83
|
if (isProduction && resolved.rejectUnauthorized === false) {
|
|
67
|
-
|
|
84
|
+
const overrideAllowed = process.env.APPLIQATION_INSECURE === 'allow';
|
|
85
|
+
if (!overrideAllowed) {
|
|
86
|
+
throw new ConfigurationError(
|
|
87
|
+
'rejectUnauthorized=false is not permitted in CI/production. '
|
|
88
|
+
+ 'Disabling TLS verification exposes result submissions to MITM. '
|
|
89
|
+
+ 'Use a properly trusted certificate, or set '
|
|
90
|
+
+ 'APPLIQATION_INSECURE=allow to acknowledge the risk and override.',
|
|
91
|
+
{
|
|
92
|
+
detectedAs: 'production',
|
|
93
|
+
ci: process.env.CI,
|
|
94
|
+
nodeEnv: process.env.NODE_ENV
|
|
95
|
+
}
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
logger.warn(
|
|
99
|
+
'TLS verification is DISABLED via APPLIQATION_INSECURE=allow in a '
|
|
100
|
+
+ 'production-like environment. Result submissions are vulnerable '
|
|
101
|
+
+ 'to interception.'
|
|
102
|
+
);
|
|
68
103
|
}
|
|
69
104
|
rejectUnauthorized = resolved.rejectUnauthorized;
|
|
70
105
|
}
|
|
@@ -84,10 +119,8 @@ class AppliqationClient {
|
|
|
84
119
|
logOrphans: resolved.options?.logOrphans !== false,
|
|
85
120
|
logLevel: resolved.options?.logLevel || 'info',
|
|
86
121
|
autoTag: resolved.options?.autoTag !== false,
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
process.env.APPLIQATION_AUTO_TAG_NAME ||
|
|
90
|
-
'Appq_automated',
|
|
122
|
+
// Fixed, not configurable — see AUTO_TAG_NAME's doc comment.
|
|
123
|
+
autoTagName: AUTO_TAG_NAME,
|
|
91
124
|
autoTagBatchSize: resolved.options?.autoTagBatchSize || 50,
|
|
92
125
|
autoTagRetries: resolved.options?.autoTagRetries || 2
|
|
93
126
|
}
|
|
@@ -351,6 +384,20 @@ class AppliqationClient {
|
|
|
351
384
|
}
|
|
352
385
|
}
|
|
353
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Fetch the UUID membership of a Test Set, for scope validation.
|
|
389
|
+
*/
|
|
390
|
+
async fetchTestSetScope(testSetId) {
|
|
391
|
+
try {
|
|
392
|
+
const response = await this.http.get(`/api/automation/testset/${testSetId}/scope`);
|
|
393
|
+
const uuids = response?.data?.data;
|
|
394
|
+
return new Set(Array.isArray(uuids) ? uuids : []);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
logger.error('Failed to fetch testset scope', { error: error.message, testSetId });
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
354
401
|
validateUuid(uuid) {
|
|
355
402
|
return UuidValidator.validate(uuid);
|
|
356
403
|
}
|
|
@@ -364,36 +411,89 @@ class AppliqationClient {
|
|
|
364
411
|
}
|
|
365
412
|
|
|
366
413
|
/**
|
|
367
|
-
* Validate client configuration
|
|
414
|
+
* Validate client configuration.
|
|
415
|
+
*
|
|
416
|
+
* Hard errors (returned in `errors`) cause the constructor to throw
|
|
417
|
+
* ConfigurationError. They cover misconfiguration that would otherwise
|
|
418
|
+
* fail silently or mid-run: malformed URL, non-positive numerics, invalid
|
|
419
|
+
* environment name format. Soft warnings (returned in `warnings`) are
|
|
420
|
+
* logged but don't block construction — missing apiKey, etc.
|
|
421
|
+
*
|
|
368
422
|
* @private
|
|
369
|
-
* @returns {{
|
|
423
|
+
* @returns {{ errors: string[], warnings: string[] }}
|
|
370
424
|
*/
|
|
371
425
|
validateConfig(config) {
|
|
426
|
+
const errors = [];
|
|
372
427
|
const warnings = [];
|
|
373
428
|
|
|
374
429
|
if (!config) {
|
|
375
|
-
|
|
376
|
-
return {
|
|
430
|
+
errors.push('No configuration object provided');
|
|
431
|
+
return { errors, warnings };
|
|
377
432
|
}
|
|
378
433
|
|
|
434
|
+
// apiKey — warn only (no-op mode is intentional behaviour)
|
|
379
435
|
if (!config.apiKey) {
|
|
380
436
|
warnings.push('[Appliqation] API key required. Set APPLIQATION_API_KEY in .env');
|
|
437
|
+
} else if (typeof config.apiKey !== 'string' || config.apiKey.trim() === '') {
|
|
438
|
+
errors.push(`apiKey must be a non-empty string (got: ${typeof config.apiKey})`);
|
|
381
439
|
}
|
|
382
440
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
logger.debug('projectKey not set — will auto-discover from API key');
|
|
386
|
-
}
|
|
387
|
-
|
|
441
|
+
// baseUrl — hard error on malformed URL (silent failure otherwise:
|
|
442
|
+
// every HTTP call fails with a cryptic axios error mid-run).
|
|
388
443
|
if (config.baseUrl) {
|
|
389
444
|
try {
|
|
390
|
-
new URL(config.baseUrl);
|
|
445
|
+
const url = new URL(config.baseUrl);
|
|
446
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
447
|
+
errors.push(`baseUrl must use http or https (got: "${url.protocol}")`);
|
|
448
|
+
}
|
|
391
449
|
} catch (error) {
|
|
392
|
-
|
|
450
|
+
errors.push(`Invalid baseUrl: "${config.baseUrl}" is not a valid URL`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// environment — hard error on invalid format (backend rejects 400 mid-run)
|
|
455
|
+
if (config.environment !== undefined && config.environment !== null && config.environment !== '') {
|
|
456
|
+
if (typeof config.environment !== 'string') {
|
|
457
|
+
errors.push(`environment must be a string (got: ${typeof config.environment})`);
|
|
458
|
+
} else if (!ENVIRONMENT_NAME_PATTERN.test(config.environment)) {
|
|
459
|
+
errors.push(
|
|
460
|
+
`environment "${config.environment}" contains invalid characters. `
|
|
461
|
+
+ 'Allowed: letters, digits, dot, underscore, hyphen.'
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// options.timeout — must be a positive integer if specified
|
|
467
|
+
if (config.options && config.options.timeout !== undefined) {
|
|
468
|
+
const t = config.options.timeout;
|
|
469
|
+
if (!Number.isInteger(t) || t <= 0) {
|
|
470
|
+
errors.push(`options.timeout must be a positive integer (got: ${t})`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// options.retries — must be a non-negative integer if specified
|
|
475
|
+
if (config.options && config.options.retries !== undefined) {
|
|
476
|
+
const r = config.options.retries;
|
|
477
|
+
if (!Number.isInteger(r) || r < 0) {
|
|
478
|
+
errors.push(`options.retries must be a non-negative integer (got: ${r})`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// options.batchSize / options.autoTagBatchSize — must be positive integers
|
|
483
|
+
for (const field of ['batchSize', 'autoTagBatchSize']) {
|
|
484
|
+
if (config.options && config.options[field] !== undefined) {
|
|
485
|
+
const v = config.options[field];
|
|
486
|
+
if (!Number.isInteger(v) || v <= 0) {
|
|
487
|
+
errors.push(`options.${field} must be a positive integer (got: ${v})`);
|
|
488
|
+
}
|
|
393
489
|
}
|
|
394
490
|
}
|
|
395
491
|
|
|
396
|
-
|
|
492
|
+
if (!config.projectKey) {
|
|
493
|
+
logger.debug('projectKey not set — will auto-discover from API key');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return { errors, warnings };
|
|
397
497
|
}
|
|
398
498
|
|
|
399
499
|
/**
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* appq-auth-setup — populate Playwright storageState for an Appliqation
|
|
4
|
+
* project's role by dynamic-importing the customer's login.ts file
|
|
5
|
+
* and running it with role-specific creds from env vars.
|
|
6
|
+
*
|
|
7
|
+
* npx appq-auth-setup --project-id 126 --role manager
|
|
8
|
+
*
|
|
9
|
+
* Customer setup (one-time, per project):
|
|
10
|
+
* 1. Configure roles in Appliqation Project Settings → Auth Config
|
|
11
|
+
* 2. Write `tests/automan/auth/login.ts` in their repo (default path;
|
|
12
|
+
* configurable in Project Settings):
|
|
13
|
+
*
|
|
14
|
+
* import { defineLogin } from '@appliqation/automation-sdk/login';
|
|
15
|
+
* export default defineLogin(async (page, { username, password }) => {
|
|
16
|
+
* await page.goto('/login');
|
|
17
|
+
* await page.getByLabel('Email').fill(username);
|
|
18
|
+
* await page.getByLabel('Password').fill(password);
|
|
19
|
+
* await page.getByRole('button', { name: 'Sign in' }).click();
|
|
20
|
+
* await page.waitForURL('**\/dashboard');
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* 3. Download the .env template from Project Settings, fill in
|
|
24
|
+
* values, drop into CI secrets
|
|
25
|
+
* 4. Add to CI yaml (one line per role used by their tests):
|
|
26
|
+
* - run: npx appq-auth-setup --project-id 126 --role default
|
|
27
|
+
* - run: npx playwright test
|
|
28
|
+
*
|
|
29
|
+
* What this CLI does:
|
|
30
|
+
* 1. Read APPQ_PROJECT_<id>_<ROLE>_USERNAME / _PASSWORD from env
|
|
31
|
+
* 2. Dynamic-import the customer's login.ts (path discoverable via
|
|
32
|
+
* --login-file flag, env var, or convention)
|
|
33
|
+
* 3. Launch Chromium, run customer's login function with creds +
|
|
34
|
+
* baseURL from APPLIQATION_SUT_BASE_URL env var
|
|
35
|
+
* 4. Save resulting Playwright storageState to the canonical path
|
|
36
|
+
* from setupAuth({ project_id, role })
|
|
37
|
+
*
|
|
38
|
+
* Tests then load via:
|
|
39
|
+
* test.use({ storageState: setupAuth({ project_id, role }) })
|
|
40
|
+
*
|
|
41
|
+
* Usage in CI: run BEFORE `playwright test`. Idempotent — fast on
|
|
42
|
+
* cache hit (~3s for the login flow), faster on no-op.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
const fs = require('fs');
|
|
46
|
+
const path = require('path');
|
|
47
|
+
const {
|
|
48
|
+
authStatePath,
|
|
49
|
+
envVarNames,
|
|
50
|
+
sanitizeRole,
|
|
51
|
+
} = require('../utils/setupAuth');
|
|
52
|
+
|
|
53
|
+
const FATAL_EXIT = 1;
|
|
54
|
+
const DEFAULT_LOGIN_FILE = 'tests/automan/auth/login.ts';
|
|
55
|
+
|
|
56
|
+
function parseArgs(argv) {
|
|
57
|
+
const args = {};
|
|
58
|
+
for (let i = 2; i < argv.length; i++) {
|
|
59
|
+
const arg = argv[i];
|
|
60
|
+
if (!arg.startsWith('--')) continue;
|
|
61
|
+
const key = arg.slice(2);
|
|
62
|
+
const next = argv[i + 1];
|
|
63
|
+
if (next && !next.startsWith('--')) {
|
|
64
|
+
args[key] = next;
|
|
65
|
+
i++;
|
|
66
|
+
} else {
|
|
67
|
+
args[key] = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return args;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fail(message) {
|
|
74
|
+
console.error(`✗ ${message}`);
|
|
75
|
+
process.exit(FATAL_EXIT);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the customer's login.ts path. Priority:
|
|
80
|
+
* 1. --login-file CLI flag (explicit override)
|
|
81
|
+
* 2. APPQ_LOGIN_FILE env var (CI override without changing CLI args)
|
|
82
|
+
* 3. Convention: tests/automan/auth/login.ts relative to CWD
|
|
83
|
+
*
|
|
84
|
+
* Throws clearly if the resolved path doesn't exist — customers who
|
|
85
|
+
* haven't created the file yet need a path-specific error, not a
|
|
86
|
+
* cryptic dynamic-import failure.
|
|
87
|
+
*/
|
|
88
|
+
// H4 — only allow loading login scripts written in JS or TS. Blocks an
|
|
89
|
+
// attacker from convincing the CLI to require() arbitrary file types
|
|
90
|
+
// (.json with prototype pollution, .node native addons, .yaml via a
|
|
91
|
+
// custom hook, etc).
|
|
92
|
+
const ALLOWED_LOGIN_FILE_EXTENSIONS = new Set([
|
|
93
|
+
'.ts', '.tsx', '.mts', '.cts',
|
|
94
|
+
'.js', '.mjs', '.cjs', '.jsx'
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
function resolveLoginFile(args) {
|
|
98
|
+
const candidate = args['login-file']
|
|
99
|
+
|| process.env.APPQ_LOGIN_FILE
|
|
100
|
+
|| DEFAULT_LOGIN_FILE;
|
|
101
|
+
const absolute = path.isAbsolute(candidate)
|
|
102
|
+
? candidate
|
|
103
|
+
: path.resolve(process.cwd(), candidate);
|
|
104
|
+
|
|
105
|
+
// H4 — directory-traversal guard. The CLI loads the resolved file via
|
|
106
|
+
// require() / dynamic import(), so a path outside the project root is
|
|
107
|
+
// an arbitrary-code-execution vector in a compromised CI environment
|
|
108
|
+
// (malicious PR, supply chain). Require the login file to live inside
|
|
109
|
+
// the current working directory tree.
|
|
110
|
+
const projectRoot = process.cwd();
|
|
111
|
+
const rootWithSep = projectRoot.endsWith(path.sep) ? projectRoot : projectRoot + path.sep;
|
|
112
|
+
// Realpath to defeat symlink escapes; fall back to absolute if the
|
|
113
|
+
// file doesn't exist yet so the existence error below still fires
|
|
114
|
+
// with the user's intended path.
|
|
115
|
+
let canonical;
|
|
116
|
+
try {
|
|
117
|
+
canonical = fs.realpathSync(absolute);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
canonical = absolute;
|
|
120
|
+
}
|
|
121
|
+
if (canonical !== projectRoot && !canonical.startsWith(rootWithSep)) {
|
|
122
|
+
fail(
|
|
123
|
+
`Login file must live inside the project directory.\n`
|
|
124
|
+
+ ` Project root: ${projectRoot}\n`
|
|
125
|
+
+ ` Login file: ${canonical}\n`
|
|
126
|
+
+ `\n`
|
|
127
|
+
+ ` Resolving outside the project tree would let any process that\n`
|
|
128
|
+
+ ` controls --login-file or APPQ_LOGIN_FILE load arbitrary code\n`
|
|
129
|
+
+ ` through this CLI. Move the file under the project root.`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// H4 — extension allowlist.
|
|
134
|
+
const ext = path.extname(canonical).toLowerCase();
|
|
135
|
+
if (!ALLOWED_LOGIN_FILE_EXTENSIONS.has(ext)) {
|
|
136
|
+
fail(
|
|
137
|
+
`Login file extension "${ext}" is not allowed.\n`
|
|
138
|
+
+ ` Allowed: ${Array.from(ALLOWED_LOGIN_FILE_EXTENSIONS).join(', ')}\n`
|
|
139
|
+
+ ` Got: ${canonical}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!fs.existsSync(canonical)) {
|
|
144
|
+
fail(
|
|
145
|
+
`Login file not found: ${canonical}\n`
|
|
146
|
+
+ ` Create it (default path: ${DEFAULT_LOGIN_FILE}) and export your login flow as a default export:\n`
|
|
147
|
+
+ `\n`
|
|
148
|
+
+ ` import { defineLogin } from '@appliqation/automation-sdk/login';\n`
|
|
149
|
+
+ ` export default defineLogin(async (page, { username, password, baseURL }) => {\n`
|
|
150
|
+
+ ` await page.goto('/login');\n`
|
|
151
|
+
+ ` // ... your login flow\n`
|
|
152
|
+
+ ` });\n`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return canonical;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Dynamic-import the login file and return the default export. Handles
|
|
160
|
+
* both ESM (`export default ...`) and CommonJS (`module.exports = ...`)
|
|
161
|
+
* — the SDK's defineLogin is identity, so customers writing either
|
|
162
|
+
* style end up with a function we can call.
|
|
163
|
+
*
|
|
164
|
+
* For .ts files, requires the customer's repo to have a TS loader
|
|
165
|
+
* registered (tsx, ts-node, esbuild-node-loader). Most Playwright
|
|
166
|
+
* projects do via @playwright/test which transpiles TS on the fly,
|
|
167
|
+
* but the auth-setup CLI runs OUTSIDE the Playwright runner so we
|
|
168
|
+
* need the loader explicitly. We try `tsx` first (lightweight, the
|
|
169
|
+
* most common choice), then `ts-node`, then fail with a clear hint.
|
|
170
|
+
*/
|
|
171
|
+
async function loadLoginFunction(filePath) {
|
|
172
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
173
|
+
if (ext === '.ts' || ext === '.tsx' || ext === '.mts' || ext === '.cts') {
|
|
174
|
+
let registered = false;
|
|
175
|
+
try {
|
|
176
|
+
require('tsx/cjs');
|
|
177
|
+
registered = true;
|
|
178
|
+
} catch {
|
|
179
|
+
try {
|
|
180
|
+
require('ts-node/register/transpile-only');
|
|
181
|
+
registered = true;
|
|
182
|
+
} catch { /* fall through */ }
|
|
183
|
+
}
|
|
184
|
+
if (!registered) {
|
|
185
|
+
fail(
|
|
186
|
+
`Login file is TypeScript (${path.basename(filePath)}) but no TS loader was found.\n`
|
|
187
|
+
+ ` Install one as a dev dependency:\n`
|
|
188
|
+
+ ` npm install --save-dev tsx\n`
|
|
189
|
+
+ ` Or rename your login file to .js (CommonJS) / .mjs (ESM).`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let mod;
|
|
195
|
+
try {
|
|
196
|
+
mod = require(filePath);
|
|
197
|
+
} catch (requireErr) {
|
|
198
|
+
// ESM fallback — `require` of an ESM file throws ERR_REQUIRE_ESM.
|
|
199
|
+
try {
|
|
200
|
+
mod = await import(filePath);
|
|
201
|
+
} catch (importErr) {
|
|
202
|
+
fail(
|
|
203
|
+
`Failed to load ${filePath}:\n`
|
|
204
|
+
+ ` CommonJS error: ${requireErr.message}\n`
|
|
205
|
+
+ ` ESM error: ${importErr.message}`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const fn = mod && (mod.default || mod);
|
|
211
|
+
if (typeof fn !== 'function') {
|
|
212
|
+
fail(
|
|
213
|
+
`${filePath} did not default-export a function.\n`
|
|
214
|
+
+ ` Expected:\n`
|
|
215
|
+
+ ` export default defineLogin(async (page, ctx) => { ... });\n`
|
|
216
|
+
+ ` Got: ${typeof fn}`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return fn;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function main() {
|
|
223
|
+
const args = parseArgs(process.argv);
|
|
224
|
+
|
|
225
|
+
const project_id = args['project-id']
|
|
226
|
+
|| process.env.APPLIQATION_PROJECT_KEY
|
|
227
|
+
|| null;
|
|
228
|
+
const role = sanitizeRole(args.role || 'default');
|
|
229
|
+
|
|
230
|
+
if (!project_id) {
|
|
231
|
+
fail(
|
|
232
|
+
'Missing --project-id. Pass it as a flag or set APPLIQATION_PROJECT_KEY '
|
|
233
|
+
+ 'in your env (the downloaded .env from your project settings includes it).'
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const baseURL = (process.env.APPLIQATION_SUT_BASE_URL || '').replace(/\/$/, '');
|
|
238
|
+
if (!baseURL) {
|
|
239
|
+
fail(
|
|
240
|
+
'APPLIQATION_SUT_BASE_URL env var is not set. This is your SUT (System Under Test) URL '
|
|
241
|
+
+ '— e.g. https://staging.acme.com — that the login flow runs against. Add it to your .env / CI secrets.'
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const { username: userVar, password: pwdVar } = envVarNames({ project_id, role });
|
|
246
|
+
const username = process.env[userVar];
|
|
247
|
+
const password = process.env[pwdVar];
|
|
248
|
+
if (!username || !password) {
|
|
249
|
+
fail(
|
|
250
|
+
`Missing credential env vars: ${userVar} and/or ${pwdVar}.\n`
|
|
251
|
+
+ ` Download the .env template from your project settings in Appliqation,\n`
|
|
252
|
+
+ ` fill in the values, and add them to your CI secrets.`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const loginFile = resolveLoginFile(args);
|
|
257
|
+
console.log(`→ Loading login flow from ${path.relative(process.cwd(), loginFile)}`);
|
|
258
|
+
const loginFn = await loadLoginFunction(loginFile);
|
|
259
|
+
|
|
260
|
+
// Lazy-require playwright — it's a peer dep, not always installed.
|
|
261
|
+
let chromium;
|
|
262
|
+
try {
|
|
263
|
+
({ chromium } = require('playwright'));
|
|
264
|
+
} catch {
|
|
265
|
+
fail(
|
|
266
|
+
'Playwright is not installed. Run `npm install --save-dev playwright` '
|
|
267
|
+
+ '(or @playwright/test) and try again.'
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const targetPath = authStatePath({ project_id, role });
|
|
272
|
+
// H1 — restrict the auth-state directory to owner-only. Playwright's
|
|
273
|
+
// storageState file contains cookies, localStorage, and sessionStorage
|
|
274
|
+
// — equivalent to a live authenticated browser session. Default
|
|
275
|
+
// mkdir permissions inherit the umask (typically 0755 / world-readable
|
|
276
|
+
// on shared CI runners and developer machines); force 0700 so no
|
|
277
|
+
// other local user can read peer sessions.
|
|
278
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true, mode: 0o700 });
|
|
279
|
+
|
|
280
|
+
console.log(`→ Launching Chromium for login as role "${role}" against ${baseURL}`);
|
|
281
|
+
const browser = await chromium.launch({ headless: true });
|
|
282
|
+
const context = await browser.newContext({
|
|
283
|
+
baseURL,
|
|
284
|
+
ignoreHTTPSErrors: true,
|
|
285
|
+
});
|
|
286
|
+
const page = await context.newPage();
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
await loginFn(page, { username, password, role, baseURL });
|
|
290
|
+
await context.storageState({ path: targetPath });
|
|
291
|
+
// H1 — Playwright writes storageState with umask-inherited
|
|
292
|
+
// permissions (typically 0644). Tighten to 0600 so even other
|
|
293
|
+
// local users on the same machine cannot read it.
|
|
294
|
+
try {
|
|
295
|
+
fs.chmodSync(targetPath, 0o600);
|
|
296
|
+
} catch (chmodErr) {
|
|
297
|
+
console.warn(`⚠️ Could not tighten permissions on ${targetPath}: ${chmodErr.message}`);
|
|
298
|
+
}
|
|
299
|
+
console.log(`✓ Auth state saved to ${targetPath} (mode 0600)`);
|
|
300
|
+
console.log(` Tests using setupAuth({ project_id: ${project_id}, role: '${role}' }) will pick this up.`);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
fail(`Login failed: ${err.message}`);
|
|
303
|
+
} finally {
|
|
304
|
+
await browser.close();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
main().catch((err) => fail(err.message || String(err)));
|
package/src/constants.js
CHANGED
|
@@ -204,6 +204,23 @@ const DEFAULT_OS = 'Unknown';
|
|
|
204
204
|
*/
|
|
205
205
|
const DEFAULT_APPLIQATION_BASE_URL = 'https://appq.appliqation.io';
|
|
206
206
|
|
|
207
|
+
// ============================================================================
|
|
208
|
+
// Auto-Tagging Configuration
|
|
209
|
+
// ============================================================================
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Tag applied to a test case the first time the SDK submits an accepted
|
|
213
|
+
* result for it. Marks the test case as automation-covered.
|
|
214
|
+
*
|
|
215
|
+
* Fixed, not configurable — appq's own backend (AutomationResultManager's
|
|
216
|
+
* autoTagScenario/autoTagTestset) writes this exact literal when it
|
|
217
|
+
* auto-tags at run creation. A configurable client-side name would let the
|
|
218
|
+
* SDK and backend disagree and produce two different tags for the same
|
|
219
|
+
* "automated" signal.
|
|
220
|
+
* @constant {string}
|
|
221
|
+
*/
|
|
222
|
+
const AUTO_TAG_NAME = 'Appq_Auto';
|
|
223
|
+
|
|
207
224
|
// ============================================================================
|
|
208
225
|
// Exports
|
|
209
226
|
// ============================================================================
|
|
@@ -251,5 +268,8 @@ module.exports = {
|
|
|
251
268
|
DEFAULT_OS,
|
|
252
269
|
|
|
253
270
|
// Base URL
|
|
254
|
-
DEFAULT_APPLIQATION_BASE_URL
|
|
271
|
+
DEFAULT_APPLIQATION_BASE_URL,
|
|
272
|
+
|
|
273
|
+
// Auto-Tagging
|
|
274
|
+
AUTO_TAG_NAME
|
|
255
275
|
};
|
package/src/core/AuthManager.js
CHANGED
|
@@ -180,6 +180,11 @@ class AuthManager {
|
|
|
180
180
|
|
|
181
181
|
parseJwtExpiry(token) {
|
|
182
182
|
try {
|
|
183
|
+
// Signature not verified — token is issued by the Appliqation API server
|
|
184
|
+
// we just authenticated against, so origin is trusted. We only decode
|
|
185
|
+
// the payload to read `exp` for refresh scheduling. Verifying the HMAC
|
|
186
|
+
// here would require the server-side signing secret, which the SDK
|
|
187
|
+
// does not (and must not) hold.
|
|
183
188
|
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
|
|
184
189
|
// Convert to milliseconds and subtract 5 minutes for safety margin
|
|
185
190
|
return (payload.exp * 1000) - (5 * 60 * 1000);
|