@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/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@appliqation/automation-sdk",
3
- "version": "2.5.1",
4
- "description": "Appliqation Automation SDK with API key authentication, custom run titles, and framework-specific reporters",
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-basic && npx playwright test",
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": ">=16.0.0"
92
+ "node": ">=18.0.0"
114
93
  },
115
94
  "repository": {
116
95
  "type": "git",
@@ -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 { DEFAULT_APPLIQATION_BASE_URL } = require('./constants');
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
- // Validatewarn instead of throw
58
+ // H2fail-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 (!validation.valid) {
55
- // Log warnings but don't throw
56
- for (const warning of validation.warnings) {
57
- logger.warn(warning);
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
- // Determine SSL enforcement based on environment
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
- logger.warn('SSL certificate verification is disabled in production environment!');
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
- autoTagName: resolved.options?.autoTagName ||
88
- resolved.autoTagName ||
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 — returns warnings instead of throwing.
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 {{ valid: boolean, warnings: string[] }}
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
- warnings.push('[Appliqation] No configuration provided');
376
- return { valid: false, warnings };
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
- if (!config.projectKey) {
384
- // Not an error will be auto-discovered
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
- warnings.push(`[Appliqation] Invalid baseUrl format: "${config.baseUrl}"`);
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
- return { valid: warnings.length === 0, warnings };
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
  };
@@ -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);