@matware/e2e-runner 1.2.1 → 1.3.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/.claude-plugin/marketplace.json +21 -0
- package/.mcp.json +2 -2
- package/.opencode/commands/create-test.md +63 -0
- package/.opencode/commands/run.md +50 -0
- package/.opencode/commands/verify-issue.md +62 -0
- package/.opencode/skills/e2e-testing/SKILL.md +181 -0
- package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
- package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
- package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
- package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
- package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
- package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
- package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/.opencode/skills/e2e-testing/references/variables.md +41 -0
- package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
- package/OPENCODE.md +166 -0
- package/README.md +581 -55
- package/agents/test-creator.md +54 -1
- package/agents/test-improver.md +37 -0
- package/bin/cli.js +408 -16
- package/commands/create-test.md +16 -1
- package/opencode.json +11 -0
- package/package.json +7 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +10 -3
- package/skills/e2e-testing/references/action-types.md +48 -5
- package/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/skills/e2e-testing/references/graphql.md +59 -0
- package/skills/e2e-testing/references/issue-verification.md +59 -0
- package/skills/e2e-testing/references/multi-pool.md +60 -0
- package/skills/e2e-testing/references/network-debugging.md +62 -0
- package/skills/e2e-testing/references/test-json-format.md +4 -0
- package/skills/e2e-testing/references/troubleshooting.md +44 -2
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +324 -2
- package/src/ai-generate.js +58 -8
- package/src/config.js +143 -0
- package/src/dashboard.js +145 -13
- package/src/db.js +130 -2
- package/src/index.js +7 -6
- package/src/learner-sqlite.js +304 -0
- package/src/learner.js +8 -3
- package/src/mcp-tools.js +1121 -43
- package/src/module-resolver.js +37 -0
- package/src/narrate.js +37 -0
- package/src/pool-manager.js +223 -0
- package/src/reporter.js +82 -1
- package/src/runner.js +157 -28
- package/src/sync/auth.js +354 -0
- package/src/sync/client.js +572 -0
- package/src/sync/hub-routes.js +816 -0
- package/src/sync/index.js +68 -0
- package/src/sync/middleware.js +347 -0
- package/src/sync/queue.js +209 -0
- package/src/sync/schema.js +540 -0
- package/src/verify.js +10 -7
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +47 -6
- package/templates/dashboard/js/api.js +60 -0
- package/templates/dashboard/js/init.js +13 -0
- package/templates/dashboard/js/keyboard.js +46 -0
- package/templates/dashboard/js/state.js +40 -0
- package/templates/dashboard/js/toast.js +41 -0
- package/templates/dashboard/js/utils.js +196 -0
- package/templates/dashboard/js/view-live.js +143 -0
- package/templates/dashboard/js/view-runs.js +572 -0
- package/templates/dashboard/js/view-tests.js +294 -0
- package/templates/dashboard/js/view-watch.js +242 -0
- package/templates/dashboard/js/websocket.js +110 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +110 -0
- package/templates/dashboard/styles/view-live.css +74 -0
- package/templates/dashboard/styles/view-runs.css +207 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +165 -99
- package/templates/dashboard.html +1596 -541
- package/templates/sample-test.json +0 -8
- package/templates/dashboard/app.js +0 -1152
- package/templates/dashboard/styles.css +0 -413
package/src/runner.js
CHANGED
|
@@ -7,11 +7,15 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from 'fs';
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import
|
|
10
|
+
import http from 'http';
|
|
11
|
+
import https from 'https';
|
|
12
|
+
import { connectToPool } from './pool.js';
|
|
13
|
+
import { getPoolUrls, selectPool, releasePending } from './pool-manager.js';
|
|
11
14
|
import { executeAction } from './actions.js';
|
|
12
15
|
import { narrateAction } from './narrate.js';
|
|
13
16
|
import { log, colors as C } from './logger.js';
|
|
14
|
-
import { resolveTestData } from './module-resolver.js';
|
|
17
|
+
import { resolveTestData, validateActionTypes } from './module-resolver.js';
|
|
18
|
+
import { ensureProject, getVariables } from './db.js';
|
|
15
19
|
|
|
16
20
|
function sleep(ms) {
|
|
17
21
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -44,6 +48,46 @@ function mergeHooks(configHooks, suiteHooks) {
|
|
|
44
48
|
};
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
/** Replaces {{var.KEY}} and {{env.KEY}} in all string fields of an action object.
|
|
52
|
+
* Skips {{param}} patterns (no dot) — those are module params handled by module-resolver. */
|
|
53
|
+
function resolveVarsInAction(action, vars) {
|
|
54
|
+
const resolved = { ...action };
|
|
55
|
+
for (const key of Object.keys(resolved)) {
|
|
56
|
+
if (typeof resolved[key] !== 'string') continue;
|
|
57
|
+
resolved[key] = resolved[key].replace(/\{\{(var|env)\.([^}]+)\}\}/g, (match, ns, name) => {
|
|
58
|
+
if (ns === 'env') {
|
|
59
|
+
if (process.env[name] !== undefined) return process.env[name];
|
|
60
|
+
throw new Error(`Unresolved variable: {{env.${name}}} — environment variable not set`);
|
|
61
|
+
}
|
|
62
|
+
// ns === 'var'
|
|
63
|
+
if (vars[name] !== undefined) return vars[name];
|
|
64
|
+
throw new Error(`Unresolved variable: {{var.${name}}} — not found in project or suite variables`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return resolved;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Loads merged variables for a test (project scope + suite scope overlay). */
|
|
71
|
+
function loadVarsForTest(config, suiteName) {
|
|
72
|
+
try {
|
|
73
|
+
const cwd = config._cwd || process.cwd();
|
|
74
|
+
const projectName = config.projectName || cwd.split('/').pop() || 'default';
|
|
75
|
+
const projectId = ensureProject(cwd, projectName, config.screenshotsDir, config.testsDir);
|
|
76
|
+
const projectVars = getVariables(projectId, 'project');
|
|
77
|
+
if (!suiteName) return projectVars;
|
|
78
|
+
const suiteVars = getVariables(projectId, suiteName);
|
|
79
|
+
return { ...projectVars, ...suiteVars };
|
|
80
|
+
} catch {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Resolves variables in an array of actions. */
|
|
86
|
+
function resolveVarsInActions(actions, vars) {
|
|
87
|
+
if (!actions || !actions.length) return actions;
|
|
88
|
+
return actions.map(a => resolveVarsInAction(a, vars));
|
|
89
|
+
}
|
|
90
|
+
|
|
47
91
|
/** Executes an array of hook actions on a page */
|
|
48
92
|
async function executeHookActions(page, actions, config) {
|
|
49
93
|
for (const action of actions) {
|
|
@@ -65,24 +109,45 @@ function normalizeTestData(data) {
|
|
|
65
109
|
return { tests: data.tests || [], hooks };
|
|
66
110
|
}
|
|
67
111
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
112
|
+
/** Extracts a value from an object using a dot-path (e.g. "data.token"). */
|
|
113
|
+
function getByPath(obj, dotPath) {
|
|
114
|
+
return dotPath.split('.').reduce((o, key) => o?.[key], obj);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Fetches an auth token by POSTing credentials to a login endpoint. */
|
|
118
|
+
function fetchAuthToken(endpoint, credentials, tokenPath) {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const url = new URL(endpoint);
|
|
121
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
122
|
+
const body = JSON.stringify(credentials);
|
|
123
|
+
|
|
124
|
+
const req = transport.request(url, {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
|
127
|
+
timeout: 15000,
|
|
128
|
+
}, (res) => {
|
|
129
|
+
let data = '';
|
|
130
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
131
|
+
res.on('end', () => {
|
|
132
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
133
|
+
return reject(new Error(`Auth login failed: HTTP ${res.statusCode} from ${endpoint}`));
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const json = JSON.parse(data);
|
|
137
|
+
const token = getByPath(json, tokenPath);
|
|
138
|
+
if (!token) {
|
|
139
|
+
return reject(new Error(`Auth login: token not found at path "${tokenPath}" in response`));
|
|
140
|
+
}
|
|
141
|
+
resolve(token);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
reject(new Error(`Auth login: failed to parse response from ${endpoint}: ${e.message}`));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
req.on('error', (e) => reject(new Error(`Auth login request failed: ${e.message}`)));
|
|
148
|
+
req.on('timeout', () => { req.destroy(); reject(new Error(`Auth login request timed out: ${endpoint}`)); });
|
|
149
|
+
req.end(body);
|
|
150
|
+
});
|
|
86
151
|
}
|
|
87
152
|
|
|
88
153
|
/** Runs a single test end-to-end */
|
|
@@ -104,8 +169,15 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
104
169
|
const pendingBodies = [];
|
|
105
170
|
|
|
106
171
|
try {
|
|
107
|
-
await
|
|
108
|
-
|
|
172
|
+
const chosenPool = await selectPool(getPoolUrls(config));
|
|
173
|
+
result.poolUrl = chosenPool;
|
|
174
|
+
const poolLabel = chosenPool.replace('ws://', '').replace('wss://', '');
|
|
175
|
+
const isMultiPool = getPoolUrls(config).length > 1;
|
|
176
|
+
if (isMultiPool) {
|
|
177
|
+
log('🔗', `${C.cyan}${test.name}${C.reset} ${C.dim}→ ${poolLabel}${C.reset}`);
|
|
178
|
+
}
|
|
179
|
+
progressFn({ event: 'test:pool', name: test.name, poolUrl: chosenPool });
|
|
180
|
+
browser = await connectToPool(chosenPool, config.connectRetries, config.connectRetryDelay);
|
|
109
181
|
// Use incognito context for cookie isolation between concurrent tests
|
|
110
182
|
context = await browser.createBrowserContext();
|
|
111
183
|
page = await context.newPage();
|
|
@@ -115,7 +187,10 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
115
187
|
result.consoleLogs.push({ type: msg.type(), text: msg.text() });
|
|
116
188
|
});
|
|
117
189
|
page.on('requestfailed', (req) => {
|
|
118
|
-
|
|
190
|
+
const url = req.url();
|
|
191
|
+
const ignoreDomains = config.networkIgnoreDomains || [];
|
|
192
|
+
if (ignoreDomains.length > 0 && ignoreDomains.some(d => url.includes(d))) return;
|
|
193
|
+
result.networkErrors.push({ url, error: req.failure()?.errorText });
|
|
119
194
|
});
|
|
120
195
|
|
|
121
196
|
const requestTimings = new Map();
|
|
@@ -149,11 +224,38 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
149
224
|
}
|
|
150
225
|
});
|
|
151
226
|
|
|
227
|
+
// Auto-inject auth token into localStorage (runs BEFORE beforeEach hooks)
|
|
228
|
+
if (config.authToken) {
|
|
229
|
+
const storageKey = config.authStorageKey || 'accessToken';
|
|
230
|
+
await page.goto(config.baseUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
231
|
+
await page.evaluate((key, token) => {
|
|
232
|
+
localStorage.setItem(key, token);
|
|
233
|
+
}, storageKey, config.authToken);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Resolve {{var.X}} and {{env.X}} in test actions and hooks
|
|
237
|
+
const vars = loadVarsForTest(config, config._suiteName);
|
|
238
|
+
if (Object.keys(vars).length > 0 || /\{\{(var|env)\./.test(JSON.stringify(test.actions))) {
|
|
239
|
+
test = { ...test, actions: resolveVarsInActions(test.actions, vars) };
|
|
240
|
+
if (hooks.beforeEach?.length) hooks = { ...hooks, beforeEach: resolveVarsInActions(hooks.beforeEach, vars) };
|
|
241
|
+
if (hooks.afterEach?.length) hooks = { ...hooks, afterEach: resolveVarsInActions(hooks.afterEach, vars) };
|
|
242
|
+
}
|
|
243
|
+
|
|
152
244
|
// Run beforeEach hook
|
|
153
245
|
if (hooks.beforeEach?.length) {
|
|
154
246
|
await executeHookActions(page, hooks.beforeEach, config);
|
|
155
247
|
}
|
|
156
248
|
|
|
249
|
+
// Auto-capture baseline screenshot if test has "expect" (BEFORE actions)
|
|
250
|
+
if (test.expect && page) {
|
|
251
|
+
try {
|
|
252
|
+
const safeName = test.name.replace(/[^a-zA-Z0-9_\-. ]/g, '_');
|
|
253
|
+
const baselinePath = path.join(config.screenshotsDir, `baseline-${safeName}-${Date.now()}.png`);
|
|
254
|
+
await page.screenshot({ path: baselinePath, fullPage: true });
|
|
255
|
+
result.baselineScreenshot = baselinePath;
|
|
256
|
+
} catch { /* page may not be ready */ }
|
|
257
|
+
}
|
|
258
|
+
|
|
157
259
|
for (let i = 0; i < test.actions.length; i++) {
|
|
158
260
|
const action = test.actions[i];
|
|
159
261
|
const maxActionRetries = action.retries ?? config.actionRetries ?? 0;
|
|
@@ -263,6 +365,10 @@ export async function runTest(test, config, hooks = {}, progressFn = () => {}) {
|
|
|
263
365
|
if (browser) {
|
|
264
366
|
try { browser.disconnect(); } catch { /* */ }
|
|
265
367
|
}
|
|
368
|
+
// Release local pending counter so selectPool() knows this slot is free
|
|
369
|
+
if (result.poolUrl) {
|
|
370
|
+
releasePending(result.poolUrl);
|
|
371
|
+
}
|
|
266
372
|
}
|
|
267
373
|
|
|
268
374
|
return result;
|
|
@@ -283,7 +389,8 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
283
389
|
log('🪝', `${C.dim}Running beforeAll hook...${C.reset}`);
|
|
284
390
|
let browser = null;
|
|
285
391
|
try {
|
|
286
|
-
|
|
392
|
+
const hookPool = await selectPool(getPoolUrls(config));
|
|
393
|
+
browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
|
|
287
394
|
const page = await browser.newPage();
|
|
288
395
|
await page.setViewport(config.viewport);
|
|
289
396
|
await executeHookActions(page, hooks.beforeAll, config);
|
|
@@ -296,6 +403,22 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
296
403
|
}
|
|
297
404
|
}
|
|
298
405
|
|
|
406
|
+
// Auto-login: fetch auth token via API if configured and not already provided
|
|
407
|
+
if (config.authLoginEndpoint && !config.authToken && config.authCredentials) {
|
|
408
|
+
log('🔑', `${C.dim}Fetching auth token from ${config.authLoginEndpoint}...${C.reset}`);
|
|
409
|
+
try {
|
|
410
|
+
config.authToken = await fetchAuthToken(
|
|
411
|
+
config.authLoginEndpoint,
|
|
412
|
+
config.authCredentials,
|
|
413
|
+
config.authTokenPath || 'token'
|
|
414
|
+
);
|
|
415
|
+
log('✅', `${C.dim}Auth token acquired (${config.authToken.length} chars)${C.reset}`);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
log('❌', `${C.red}Auth auto-login failed: ${error.message}${C.reset}`);
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
299
422
|
const concurrency = config.concurrency || 3;
|
|
300
423
|
const _runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
301
424
|
const _proj = config.projectName || null;
|
|
@@ -363,7 +486,7 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
363
486
|
activeCount--;
|
|
364
487
|
|
|
365
488
|
const screenshots = result.actions.filter(a => a.result?.screenshot).map(a => a.result.screenshot);
|
|
366
|
-
_progress({ event: 'test:complete', name: test.name, success: result.success, duration: timeDiff(result.startTime, result.endTime), error: result.error, consoleLogs: result.consoleLogs, networkErrors: result.networkErrors, networkLogs: result.networkLogs, errorScreenshot: result.errorScreenshot, screenshots });
|
|
489
|
+
_progress({ event: 'test:complete', name: test.name, success: result.success, duration: timeDiff(result.startTime, result.endTime), error: result.error, consoleLogs: result.consoleLogs, networkErrors: result.networkErrors, networkLogs: result.networkLogs, errorScreenshot: result.errorScreenshot, screenshots, poolUrl: result.poolUrl || null });
|
|
367
490
|
|
|
368
491
|
if (result.success) {
|
|
369
492
|
const flaky = result.attempt > 1 ? ` ${C.yellow}(flaky, passed on attempt ${result.attempt}/${result.maxAttempts})${C.reset}` : '';
|
|
@@ -401,7 +524,8 @@ export async function runTestsParallel(tests, config, suiteHooks = {}) {
|
|
|
401
524
|
log('🪝', `${C.dim}Running afterAll hook...${C.reset}`);
|
|
402
525
|
let browser = null;
|
|
403
526
|
try {
|
|
404
|
-
|
|
527
|
+
const hookPool = await selectPool(getPoolUrls(config));
|
|
528
|
+
browser = await connectToPool(hookPool, config.connectRetries, config.connectRetryDelay);
|
|
405
529
|
const page = await browser.newPage();
|
|
406
530
|
await page.setViewport(config.viewport);
|
|
407
531
|
await executeHookActions(page, hooks.afterAll, config);
|
|
@@ -429,7 +553,9 @@ export function loadTestFile(filePath, modulesDir) {
|
|
|
429
553
|
}
|
|
430
554
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
431
555
|
const normalized = normalizeTestData(data);
|
|
432
|
-
|
|
556
|
+
const resolved = modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
|
|
557
|
+
validateActionTypes(resolved, path.basename(filePath));
|
|
558
|
+
return resolved;
|
|
433
559
|
}
|
|
434
560
|
|
|
435
561
|
/** Loads a test suite by name — returns { tests, hooks } */
|
|
@@ -446,7 +572,9 @@ export function loadTestSuite(suiteName, testsDir, modulesDir) {
|
|
|
446
572
|
|
|
447
573
|
const data = JSON.parse(fs.readFileSync(path.join(testsDir, match), 'utf-8'));
|
|
448
574
|
const normalized = normalizeTestData(data);
|
|
449
|
-
|
|
575
|
+
const resolved = modulesDir ? resolveTestData(normalized, modulesDir) : normalized;
|
|
576
|
+
validateActionTypes(resolved, match);
|
|
577
|
+
return resolved;
|
|
450
578
|
}
|
|
451
579
|
|
|
452
580
|
/** Loads all test suites from the tests directory — returns { tests, hooks } */
|
|
@@ -466,6 +594,7 @@ export function loadAllSuites(testsDir, modulesDir, exclude = []) {
|
|
|
466
594
|
if (modulesDir) {
|
|
467
595
|
({ tests, hooks } = resolveTestData({ tests, hooks }, modulesDir));
|
|
468
596
|
}
|
|
597
|
+
validateActionTypes({ tests, hooks }, file);
|
|
469
598
|
// Tag each test with its own suite's hooks so they're preserved
|
|
470
599
|
for (const t of tests) {
|
|
471
600
|
t._suiteHooks = hooks;
|
package/src/sync/auth.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Authentication Module
|
|
3
|
+
*
|
|
4
|
+
* Provides cryptographic utilities for multi-instance sync:
|
|
5
|
+
* - API Key generation and validation
|
|
6
|
+
* - TOTP (Time-based One-Time Password) RFC 6238
|
|
7
|
+
* - JWT token signing and verification
|
|
8
|
+
* - Request signature generation
|
|
9
|
+
*
|
|
10
|
+
* Zero external dependencies - uses Node.js crypto only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import crypto from 'crypto';
|
|
14
|
+
|
|
15
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
16
|
+
// API KEY MANAGEMENT
|
|
17
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate a secure API key (256-bit random).
|
|
21
|
+
* Format: sk_<base64url encoded 32 bytes>
|
|
22
|
+
*/
|
|
23
|
+
export function generateApiKey() {
|
|
24
|
+
const bytes = crypto.randomBytes(32);
|
|
25
|
+
return 'sk_' + bytes.toString('base64url');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Hash an API key for storage (never store plaintext).
|
|
30
|
+
*/
|
|
31
|
+
export function hashApiKey(apiKey) {
|
|
32
|
+
return crypto.createHash('sha256').update(apiKey).digest('hex');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Verify an API key against its stored hash.
|
|
37
|
+
*/
|
|
38
|
+
export function verifyApiKey(apiKey, storedHash) {
|
|
39
|
+
const hash = hashApiKey(apiKey);
|
|
40
|
+
return crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(storedHash));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
44
|
+
// TOTP (TIME-BASED ONE-TIME PASSWORD)
|
|
45
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate a TOTP secret (20 bytes = 160 bits, per RFC 6238).
|
|
49
|
+
* Returns base32-encoded string for compatibility with authenticator apps.
|
|
50
|
+
*/
|
|
51
|
+
export function generateTotpSecret() {
|
|
52
|
+
const bytes = crypto.randomBytes(20);
|
|
53
|
+
return base32Encode(bytes);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Generate TOTP code for a given secret and time step.
|
|
58
|
+
* @param {string} secret - Base32-encoded secret
|
|
59
|
+
* @param {number} timeStep - Time step (default: current)
|
|
60
|
+
* @returns {string} 6-digit TOTP code
|
|
61
|
+
*/
|
|
62
|
+
export function generateTotpCode(secret, timeStep = null) {
|
|
63
|
+
if (timeStep === null) {
|
|
64
|
+
timeStep = Math.floor(Date.now() / 1000 / 30);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const secretBytes = base32Decode(secret);
|
|
68
|
+
const timeBuffer = Buffer.alloc(8);
|
|
69
|
+
timeBuffer.writeBigInt64BE(BigInt(timeStep));
|
|
70
|
+
|
|
71
|
+
const hmac = crypto.createHmac('sha1', secretBytes);
|
|
72
|
+
hmac.update(timeBuffer);
|
|
73
|
+
const hash = hmac.digest();
|
|
74
|
+
|
|
75
|
+
const offset = hash[hash.length - 1] & 0x0f;
|
|
76
|
+
const code = (
|
|
77
|
+
((hash[offset] & 0x7f) << 24) |
|
|
78
|
+
((hash[offset + 1] & 0xff) << 16) |
|
|
79
|
+
((hash[offset + 2] & 0xff) << 8) |
|
|
80
|
+
(hash[offset + 3] & 0xff)
|
|
81
|
+
) % 1000000;
|
|
82
|
+
|
|
83
|
+
return code.toString().padStart(6, '0');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate a TOTP code with a tolerance window of ±1 step (±30 seconds).
|
|
88
|
+
* @param {string} secret - Base32-encoded secret
|
|
89
|
+
* @param {string} code - 6-digit code to validate
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
export function validateTotp(secret, code) {
|
|
93
|
+
const now = Math.floor(Date.now() / 1000 / 30);
|
|
94
|
+
|
|
95
|
+
for (const offset of [0, -1, 1]) {
|
|
96
|
+
const expected = generateTotpCode(secret, now + offset);
|
|
97
|
+
if (crypto.timingSafeEqual(Buffer.from(code), Buffer.from(expected))) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Generate TOTP URI for authenticator apps (Google Authenticator, etc.).
|
|
107
|
+
*/
|
|
108
|
+
export function generateTotpUri(secret, instanceId, issuer = 'e2e-runner') {
|
|
109
|
+
const encodedIssuer = encodeURIComponent(issuer);
|
|
110
|
+
const encodedLabel = encodeURIComponent(`${issuer}:${instanceId}`);
|
|
111
|
+
return `otpauth://totp/${encodedLabel}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
115
|
+
// JWT (JSON WEB TOKENS)
|
|
116
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Sign a JWT token (HS256).
|
|
120
|
+
* @param {object} payload - Claims to include
|
|
121
|
+
* @param {string} secret - Signing secret (256-bit recommended)
|
|
122
|
+
* @param {number} expiresIn - Expiration in seconds (default: 1 hour)
|
|
123
|
+
* @returns {string} JWT token
|
|
124
|
+
*/
|
|
125
|
+
export function signJwt(payload, secret, expiresIn = 3600) {
|
|
126
|
+
const header = { alg: 'HS256', typ: 'JWT' };
|
|
127
|
+
const now = Math.floor(Date.now() / 1000);
|
|
128
|
+
|
|
129
|
+
const claims = {
|
|
130
|
+
...payload,
|
|
131
|
+
iat: now,
|
|
132
|
+
exp: now + expiresIn,
|
|
133
|
+
jti: crypto.randomBytes(16).toString('hex'),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const b64url = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
|
137
|
+
const unsigned = `${b64url(header)}.${b64url(claims)}`;
|
|
138
|
+
const signature = crypto.createHmac('sha256', secret).update(unsigned).digest('base64url');
|
|
139
|
+
|
|
140
|
+
return `${unsigned}.${signature}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Verify and decode a JWT token.
|
|
145
|
+
* @param {string} token - JWT token
|
|
146
|
+
* @param {string} secret - Signing secret
|
|
147
|
+
* @returns {object} Decoded payload
|
|
148
|
+
* @throws {Error} If token is invalid or expired
|
|
149
|
+
*/
|
|
150
|
+
export function verifyJwt(token, secret) {
|
|
151
|
+
const parts = token.split('.');
|
|
152
|
+
if (parts.length !== 3) {
|
|
153
|
+
throw new Error('Invalid token format');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const [headerB64, payloadB64, signature] = parts;
|
|
157
|
+
const unsigned = `${headerB64}.${payloadB64}`;
|
|
158
|
+
const expectedSig = crypto.createHmac('sha256', secret).update(unsigned).digest('base64url');
|
|
159
|
+
|
|
160
|
+
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSig))) {
|
|
161
|
+
throw new Error('Invalid signature');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
|
165
|
+
|
|
166
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
|
167
|
+
throw new Error('Token expired');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return payload;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Decode JWT without verification (for debugging only).
|
|
175
|
+
*/
|
|
176
|
+
export function decodeJwt(token) {
|
|
177
|
+
const parts = token.split('.');
|
|
178
|
+
if (parts.length !== 3) return null;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
return JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
188
|
+
// REQUEST SIGNING
|
|
189
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Generate a signature for a request payload.
|
|
193
|
+
* Used for additional integrity verification on sensitive operations.
|
|
194
|
+
*/
|
|
195
|
+
export function signRequest(payload, secret) {
|
|
196
|
+
const canonical = JSON.stringify(payload, Object.keys(payload).sort());
|
|
197
|
+
return crypto.createHmac('sha512', secret).update(canonical).digest('hex');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Verify a request signature.
|
|
202
|
+
*/
|
|
203
|
+
export function verifyRequestSignature(payload, signature, secret) {
|
|
204
|
+
const expected = signRequest(payload, secret);
|
|
205
|
+
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
209
|
+
// ENCRYPTION (for storing secrets in DB)
|
|
210
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Encrypt a value using AES-256-GCM.
|
|
214
|
+
* @param {string} plaintext - Value to encrypt
|
|
215
|
+
* @param {string} masterKey - 32-byte hex-encoded master key
|
|
216
|
+
* @returns {string} Encrypted value (iv:ciphertext:tag in hex)
|
|
217
|
+
*/
|
|
218
|
+
export function encrypt(plaintext, masterKey) {
|
|
219
|
+
const key = Buffer.from(masterKey, 'hex');
|
|
220
|
+
if (key.length !== 32) {
|
|
221
|
+
throw new Error('Master key must be 32 bytes (64 hex chars)');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const iv = crypto.randomBytes(12);
|
|
225
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
226
|
+
|
|
227
|
+
const encrypted = Buffer.concat([
|
|
228
|
+
cipher.update(plaintext, 'utf8'),
|
|
229
|
+
cipher.final(),
|
|
230
|
+
]);
|
|
231
|
+
const tag = cipher.getAuthTag();
|
|
232
|
+
|
|
233
|
+
return `${iv.toString('hex')}:${encrypted.toString('hex')}:${tag.toString('hex')}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Decrypt a value encrypted with encrypt().
|
|
238
|
+
* @param {string} ciphertext - Encrypted value (iv:ciphertext:tag)
|
|
239
|
+
* @param {string} masterKey - 32-byte hex-encoded master key
|
|
240
|
+
* @returns {string} Decrypted plaintext
|
|
241
|
+
*/
|
|
242
|
+
export function decrypt(ciphertext, masterKey) {
|
|
243
|
+
const key = Buffer.from(masterKey, 'hex');
|
|
244
|
+
if (key.length !== 32) {
|
|
245
|
+
throw new Error('Master key must be 32 bytes (64 hex chars)');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const [ivHex, encryptedHex, tagHex] = ciphertext.split(':');
|
|
249
|
+
const iv = Buffer.from(ivHex, 'hex');
|
|
250
|
+
const encrypted = Buffer.from(encryptedHex, 'hex');
|
|
251
|
+
const tag = Buffer.from(tagHex, 'hex');
|
|
252
|
+
|
|
253
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
254
|
+
decipher.setAuthTag(tag);
|
|
255
|
+
|
|
256
|
+
return decipher.update(encrypted) + decipher.final('utf8');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Generate a master key for encryption.
|
|
261
|
+
* Store this securely (env var, secrets manager).
|
|
262
|
+
*/
|
|
263
|
+
export function generateMasterKey() {
|
|
264
|
+
return crypto.randomBytes(32).toString('hex');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
268
|
+
// NONCE & TIMESTAMP VALIDATION
|
|
269
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Generate a nonce for request freshness.
|
|
273
|
+
*/
|
|
274
|
+
export function generateNonce() {
|
|
275
|
+
return crypto.randomBytes(16).toString('hex');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Check if a timestamp is within acceptable range (±30 seconds).
|
|
280
|
+
*/
|
|
281
|
+
export function isTimestampValid(timestamp, toleranceMs = 30000) {
|
|
282
|
+
const now = Date.now();
|
|
283
|
+
return Math.abs(now - timestamp) <= toleranceMs;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
287
|
+
// BASE32 ENCODING (for TOTP compatibility)
|
|
288
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
289
|
+
|
|
290
|
+
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
291
|
+
|
|
292
|
+
function base32Encode(buffer) {
|
|
293
|
+
let result = '';
|
|
294
|
+
let bits = 0;
|
|
295
|
+
let value = 0;
|
|
296
|
+
|
|
297
|
+
for (const byte of buffer) {
|
|
298
|
+
value = (value << 8) | byte;
|
|
299
|
+
bits += 8;
|
|
300
|
+
|
|
301
|
+
while (bits >= 5) {
|
|
302
|
+
bits -= 5;
|
|
303
|
+
result += BASE32_ALPHABET[(value >>> bits) & 0x1f];
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (bits > 0) {
|
|
308
|
+
result += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function base32Decode(str) {
|
|
315
|
+
str = str.toUpperCase().replace(/=+$/, '');
|
|
316
|
+
const bytes = [];
|
|
317
|
+
let bits = 0;
|
|
318
|
+
let value = 0;
|
|
319
|
+
|
|
320
|
+
for (const char of str) {
|
|
321
|
+
const idx = BASE32_ALPHABET.indexOf(char);
|
|
322
|
+
if (idx === -1) continue;
|
|
323
|
+
|
|
324
|
+
value = (value << 5) | idx;
|
|
325
|
+
bits += 5;
|
|
326
|
+
|
|
327
|
+
if (bits >= 8) {
|
|
328
|
+
bits -= 8;
|
|
329
|
+
bytes.push((value >>> bits) & 0xff);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return Buffer.from(bytes);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
337
|
+
// INSTANCE ID GENERATION
|
|
338
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Generate a unique instance ID.
|
|
342
|
+
* Format: <prefix>-<random 4 chars>
|
|
343
|
+
*/
|
|
344
|
+
export function generateInstanceId(prefix = 'instance') {
|
|
345
|
+
const suffix = crypto.randomBytes(2).toString('hex');
|
|
346
|
+
return `${prefix}-${suffix}`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Validate instance ID format.
|
|
351
|
+
*/
|
|
352
|
+
export function isValidInstanceId(id) {
|
|
353
|
+
return /^[a-z0-9][a-z0-9-]{2,48}[a-z0-9]$/i.test(id);
|
|
354
|
+
}
|