@skyramp/mcp 0.1.8 → 0.2.0-rc.2

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 (122) hide show
  1. package/build/index.js +4 -2
  2. package/build/playwright/registerPlaywrightTools.js +12 -0
  3. package/build/playwright/traceRecordingPrompt.js +15 -0
  4. package/build/prompts/code-reuse.js +106 -7
  5. package/build/prompts/pom-aware-code-reuse.js +106 -7
  6. package/build/prompts/startTraceCollectionPrompts.js +37 -15
  7. package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -31
  8. package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +40 -1
  9. package/build/prompts/test-maintenance/driftAnalysisSections.js +90 -86
  10. package/build/prompts/test-recommendation/analysisOutputPrompt.js +286 -163
  11. package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -45
  12. package/build/prompts/test-recommendation/diffExecutionPlan.js +246 -117
  13. package/build/prompts/test-recommendation/promptPlan.js +290 -0
  14. package/build/prompts/test-recommendation/promptPlan.test.js +336 -0
  15. package/build/prompts/test-recommendation/recommendationSections.js +4 -3
  16. package/build/prompts/test-recommendation/recommendationShared.js +23 -1
  17. package/build/prompts/test-recommendation/scopeAssessment.js +65 -14
  18. package/build/prompts/test-recommendation/scopeAssessment.test.js +93 -2
  19. package/build/prompts/test-recommendation/test-recommendation-prompt.js +36 -12
  20. package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +316 -1
  21. package/build/prompts/testbot/testbot-prompts.js +73 -13
  22. package/build/prompts/testbot/testbot-prompts.test.js +114 -1
  23. package/build/resources/testbotResource.js +1 -1
  24. package/build/services/ScenarioGenerationService.integration.test.js +158 -0
  25. package/build/services/ScenarioGenerationService.js +47 -4
  26. package/build/services/ScenarioGenerationService.test.js +158 -22
  27. package/build/services/TestExecutionService.js +73 -15
  28. package/build/services/TestExecutionService.test.js +105 -0
  29. package/build/services/TestGenerationService.js +11 -1
  30. package/build/tools/executeSkyrampTestTool.js +1 -10
  31. package/build/tools/generate-tests/generateBatchScenarioRestTool.js +16 -4
  32. package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
  33. package/build/tools/generate-tests/generateUIRestTool.js +2 -0
  34. package/build/tools/test-management/actionsTool.js +152 -63
  35. package/build/tools/test-management/analyzeChangesTool.js +178 -64
  36. package/build/tools/test-management/analyzeChangesTool.test.js +103 -16
  37. package/build/tools/test-management/analyzeTestHealthTool.js +30 -81
  38. package/build/tools/test-management/index.js +1 -0
  39. package/build/tools/test-management/uiAnalyzeChangesTool.js +149 -0
  40. package/build/tools/test-management/uiAnalyzeChangesTool.test.js +100 -0
  41. package/build/tools/trace/resolveSaveStoragePath.js +16 -0
  42. package/build/tools/trace/resolveSaveStoragePath.test.js +17 -0
  43. package/build/tools/trace/resolveSessionPaths.js +39 -0
  44. package/build/tools/trace/resolveSessionPaths.test.js +103 -0
  45. package/build/tools/trace/sessionState.js +14 -0
  46. package/build/tools/trace/sessionState.test.js +17 -0
  47. package/build/tools/trace/startTraceCollectionTool.js +84 -14
  48. package/build/tools/trace/stopTraceCollectionTool.js +9 -2
  49. package/build/types/TestAnalysis.js +50 -0
  50. package/build/types/TestRecommendation.js +6 -58
  51. package/build/types/TestTypes.js +1 -1
  52. package/build/utils/AnalysisStateManager.js +22 -11
  53. package/build/utils/branchDiff.js +11 -2
  54. package/build/utils/docker.test.js +1 -1
  55. package/build/utils/gitStaging.js +52 -3
  56. package/build/utils/gitStaging.test.js +19 -1
  57. package/build/utils/repoScanner.js +18 -10
  58. package/build/utils/repoScanner.test.js +92 -0
  59. package/build/utils/routeParsers.js +180 -25
  60. package/build/utils/routeParsers.test.js +180 -1
  61. package/build/utils/scenarioDrafting.js +220 -17
  62. package/build/utils/scenarioDrafting.test.js +182 -9
  63. package/build/utils/sourceRouteExtractor.js +806 -0
  64. package/build/utils/sourceRouteExtractor.test.js +565 -0
  65. package/build/utils/uiPageEnumerator.js +319 -0
  66. package/build/utils/uiPageEnumerator.test.js +422 -0
  67. package/build/utils/utils.js +27 -0
  68. package/build/utils/versions.js +1 -1
  69. package/build/utils/workspaceAuth.js +33 -4
  70. package/node_modules/playwright/ThirdPartyNotices.txt +6 -6
  71. package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
  72. package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1210 -0
  73. package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
  74. package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
  75. package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
  76. package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +254 -0
  77. package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +304 -0
  78. package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
  79. package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
  80. package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
  81. package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
  82. package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
  83. package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
  84. package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
  85. package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
  86. package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
  87. package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
  88. package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
  89. package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -0
  90. package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.js +150 -0
  91. package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.test.js +470 -0
  92. package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.js +169 -0
  93. package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
  94. package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
  95. package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
  96. package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
  97. package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
  98. package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
  99. package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
  100. package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
  101. package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
  102. package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
  103. package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
  104. package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
  105. package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +146 -0
  106. package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +140 -0
  107. package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
  108. package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
  109. package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
  110. package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
  111. package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
  112. package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
  113. package/node_modules/playwright/package.json +1 -1
  114. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
  115. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
  116. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
  117. package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
  118. package/package.json +3 -3
  119. package/build/services/TestHealthService.js +0 -694
  120. package/build/services/TestHealthService.test.js +0 -241
  121. package/build/types/TestDriftAnalysis.js +0 -1
  122. package/build/types/TestHealth.js +0 -4
@@ -2,8 +2,22 @@ import { ScenarioSource } from "../types/RepositoryAnalysis.js";
2
2
  import { TestType } from "../types/TestTypes.js";
3
3
  import { CATEGORY_PRIORITY } from "../types/TestRecommendation.js";
4
4
  import { inferExpectedStatus } from "./httpDefaults.js";
5
- const ACTION_PATTERN = /^(me|merge|archive|search|login|logout|verify|forgot|reset|config|dashboard|webhook|migration|favicon|payment|health|status|ping|metrics|callback|confirm|activate|deactivate|subscribe|unsubscribe|unknown)$/i;
5
+ import { WorkspaceAuthType } from "./workspaceAuth.js";
6
+ // Only tokens that are structurally non-testable (meta, infra, static assets).
7
+ // Semantic tokens like login, search, webhook, payment are removed — the LLM
8
+ // already knows how to handle them and the filter was suppressing valid surfaces.
9
+ const ACTION_PATTERN = /^(me|merge|archive|dashboard|migration|favicon|health|status|ping|metrics)$/i;
6
10
  const ACTION_VERB_HYPHEN = /^(forgot-|reset-|verify-|confirm-|send-|check-|get-|set-|update-|delete-|create-|trigger-|start-|stop-)/i;
11
+ /** Matches paths whose last segment is an execution verb (e.g. /jobs/execute, /actions/run). */
12
+ const EXECUTION_SUFFIX = /\/(execute|run|trigger|process|invoke|dispatch|send|apply)$/i;
13
+ function isExecutionEndpoint(path) {
14
+ return EXECUTION_SUFFIX.test(path);
15
+ }
16
+ /** Extract the execution verb from the path's last segment (e.g. "/jobs/run" → "run"). */
17
+ function extractExecutionVerb(endpointPath) {
18
+ const match = EXECUTION_SUFFIX.exec(endpointPath);
19
+ return match ? match[1].toLowerCase() : "execute";
20
+ }
7
21
  /**
8
22
  * Returns true when `a` starts with `b` at a path-segment boundary.
9
23
  * Plain `startsWith` is not sufficient — "/orders-archive".startsWith("/orders") is true
@@ -128,7 +142,7 @@ export function inferResourceRelationships(endpoints) {
128
142
  }
129
143
  return relationships;
130
144
  }
131
- export function draftScenariosFromEndpoints(endpoints, newEndpoints = []) {
145
+ export function draftScenariosFromEndpoints(endpoints, newEndpoints = [], wsAuthType, options = {}) {
132
146
  const scenarios = [];
133
147
  const resourceGroups = new Map();
134
148
  for (const ep of endpoints) {
@@ -175,7 +189,8 @@ export function draftScenariosFromEndpoints(endpoints, newEndpoints = []) {
175
189
  });
176
190
  }
177
191
  }
178
- scenarios.push(...draftDiffDirectScenarios(newEndpoints, resourceGroups));
192
+ scenarios.push(...draftDiffDirectScenarios(newEndpoints, resourceGroups, wsAuthType));
193
+ scenarios.push(...draftAttackSurfaceExpansionScenarios(endpoints, options.changedEndpoints ?? [], wsAuthType, options.securityRelevantDiff ?? false));
179
194
  return capScenarios(scenarios);
180
195
  }
181
196
  const MAX_TOTAL_SCENARIOS = 30;
@@ -238,7 +253,7 @@ function diffDirectIntegration(method, resource, singular, group) {
238
253
  order: 2, method: "GET", path: group.paramPath,
239
254
  description: `Retrieve created ${singular} and verify fields`,
240
255
  interactionType: "success", expectedStatusCode: 200,
241
- chainsFrom: { sourceStep: 1, sourceField: "id", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" },
256
+ chainsFrom: { sourceStep: 1, sourceField: "<id-field>", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" },
242
257
  });
243
258
  }
244
259
  }
@@ -255,7 +270,7 @@ function diffDirectIntegration(method, resource, singular, group) {
255
270
  order: steps.length + 1, method: "DELETE", path: targetPath,
256
271
  description: `Delete ${singular}`,
257
272
  interactionType: "success", expectedStatusCode: 204,
258
- ...(steps.length > 0 ? { chainsFrom: { sourceStep: 1, sourceField: "id", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" } } : {}),
273
+ ...(steps.length > 0 ? { chainsFrom: { sourceStep: 1, sourceField: "<id-field>", sourceLocation: "body", targetParam: `${singular}_id`, targetLocation: "path" } } : {}),
259
274
  });
260
275
  if (group.paramPath && group.methods.has("GET")) {
261
276
  steps.push({
@@ -349,11 +364,8 @@ function diffDirectBoundaryValues(method, resource, singular, group) {
349
364
  description: `${method} with valid boundary values (e.g. 0, maximum allowed, empty collection) — expect 200`,
350
365
  interactionType: "success",
351
366
  expectedStatusCode: 200,
352
- // Signal the execution plan that a body is required. The agent reads source
353
- // to discover the actual numeric/percentage field names to test at boundaries.
354
- bodyMustInclude: ["numeric_or_percentage_field"],
355
367
  ...(steps.length > 0 && group.paramPath
356
- ? { chainsFrom: { sourceStep: 1, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
368
+ ? { chainsFrom: { sourceStep: 1, sourceField: "<id-field>", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
357
369
  : {}),
358
370
  });
359
371
  return {
@@ -392,7 +404,7 @@ function diffDirectMutationRecalc(method, resource, singular, group) {
392
404
  interactionType: "success",
393
405
  expectedStatusCode: 200,
394
406
  ...(sourceStep > 0 && group.paramPath
395
- ? { chainsFrom: { sourceStep, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
407
+ ? { chainsFrom: { sourceStep, sourceField: "<id-field>", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
396
408
  : {}),
397
409
  });
398
410
  if (group.paramPath && group.methods.has("GET")) {
@@ -404,7 +416,7 @@ function diffDirectMutationRecalc(method, resource, singular, group) {
404
416
  interactionType: "success",
405
417
  expectedStatusCode: 200,
406
418
  ...(sourceStep > 0 && group.paramPath
407
- ? { chainsFrom: { sourceStep, sourceField: "id", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
419
+ ? { chainsFrom: { sourceStep, sourceField: "<id-field>", sourceLocation: "body", targetParam: pathParamName, targetLocation: "path" } }
408
420
  : {}),
409
421
  });
410
422
  }
@@ -421,13 +433,63 @@ function diffDirectMutationRecalc(method, resource, singular, group) {
421
433
  testType: TestType.INTEGRATION,
422
434
  };
423
435
  }
424
- function diffDirectAuthBoundary(method, path, resource) {
436
+ function diffDirectExecutionHappy(method, path, resource) {
437
+ const verb = extractExecutionVerb(path);
438
+ return {
439
+ scenarioName: `${resource}-${method.toLowerCase()}-${verb}-happy`,
440
+ description: `Execution test: ${method} ${path} with valid payload — read source to identify required input fields; assert response reflects expected output (result, status, processed data)`,
441
+ category: "new_endpoint",
442
+ priority: "high",
443
+ steps: [{
444
+ order: 1,
445
+ method,
446
+ path,
447
+ description: `${method} ${path} with valid input payload — expect 200 OK with execution result`,
448
+ interactionType: "success",
449
+ expectedStatusCode: 200,
450
+ }],
451
+ chainingKeys: [],
452
+ requiresAuth: true,
453
+ estimatedComplexity: "moderate",
454
+ source: ScenarioSource.CodeInferred,
455
+ testType: TestType.INTEGRATION,
456
+ };
457
+ }
458
+ function diffDirectExecutionInvalid(method, path, resource) {
459
+ const verb = extractExecutionVerb(path);
460
+ return {
461
+ scenarioName: `${resource}-${method.toLowerCase()}-${verb}-invalid`,
462
+ description: `Validation test: ${method} ${path} with missing or invalid request body — read source to find required fields; assert 422 Unprocessable Entity with meaningful error`,
463
+ category: "new_endpoint",
464
+ priority: "high",
465
+ steps: [{
466
+ order: 1,
467
+ method,
468
+ path,
469
+ description: `${method} ${path} with missing required fields — expect 422 Unprocessable Entity`,
470
+ interactionType: "error",
471
+ expectedStatusCode: 422,
472
+ }],
473
+ chainingKeys: [],
474
+ requiresAuth: true,
475
+ estimatedComplexity: "simple",
476
+ source: ScenarioSource.CodeInferred,
477
+ testType: TestType.CONTRACT,
478
+ };
479
+ }
480
+ function diffDirectAuthBoundary(method, path, resource, wsAuthType) {
481
+ // Bearer and OAuth both use the "Authorization: Bearer <token>" scheme. These setups
482
+ // commonly return 403 (Forbidden) rather than 401 (Unauthorized) when a token is
483
+ // absent, because the framework treats a missing credential as insufficient permission
484
+ // rather than unauthenticated. All other auth types use 401.
485
+ const expectedStatusCode = wsAuthType === WorkspaceAuthType.Bearer || wsAuthType === WorkspaceAuthType.OAuth ? 403 : 401;
486
+ const statusLabel = expectedStatusCode === 403 ? "403 Forbidden" : "401 Unauthorized";
425
487
  return {
426
488
  scenarioName: `${resource}-${method.toLowerCase()}-auth-boundary`,
427
- description: `Auth boundary: ${method} ${path} without authentication returns 401 Unauthorized`,
489
+ description: `Auth boundary: ${method} ${path} without authentication returns ${statusLabel}`,
428
490
  category: "security_boundary",
429
491
  priority: "high",
430
- steps: [{ order: 1, method, path, description: `${method} ${path} without Authorization header — expect 401 Unauthorized`, interactionType: "error", expectedStatusCode: 401 }],
492
+ steps: [{ order: 1, method, path, description: `${method} ${path} without Authorization header — expect ${statusLabel}`, interactionType: "error", expectedStatusCode }],
431
493
  chainingKeys: [],
432
494
  requiresAuth: false,
433
495
  estimatedComplexity: "simple",
@@ -435,6 +497,134 @@ function diffDirectAuthBoundary(method, path, resource) {
435
497
  testType: TestType.CONTRACT,
436
498
  };
437
499
  }
500
+ const DESTRUCTIVE_ACTION_TOKENS = new Set(["delete", "destroy", "remove", "cancel", "archive"]);
501
+ const SOURCE_RESOURCE_SKIP_SEGMENTS = new Set([
502
+ ...SKIP_SEGMENTS,
503
+ "app", "apps", "handler", "handlers", "index", "page", "pages", "route", "routes", "router", "server", "src",
504
+ ]);
505
+ function pathSegments(path) {
506
+ return path.split("/").filter(Boolean);
507
+ }
508
+ function isPathParam(segment) {
509
+ return /^\{[^}]+\}$/.test(segment) ||
510
+ /^:[A-Za-z_][A-Za-z0-9_]*$/.test(segment) ||
511
+ /^\[\.\.\.[^\]]+\]$/.test(segment) ||
512
+ /^\[\[\.\.\.[^\]]+\]\]$/.test(segment) ||
513
+ /^\[[^\].][^\]]*\]$/.test(segment);
514
+ }
515
+ function actionSegmentTokens(segment) {
516
+ return segment
517
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
518
+ .split(/[^a-z0-9]+/i)
519
+ .filter(Boolean)
520
+ .map(token => token.toLowerCase());
521
+ }
522
+ function isDestructiveActionSegment(segment) {
523
+ return actionSegmentTokens(segment).some(token => DESTRUCTIVE_ACTION_TOKENS.has(token));
524
+ }
525
+ function sourceFileResource(sourceFile) {
526
+ if (!sourceFile)
527
+ return undefined;
528
+ const sourceSegments = sourceFile.split(/[\\/]/).filter(Boolean);
529
+ const filename = sourceSegments.at(-1)?.replace(/\.[^.]+$/, "").toLowerCase();
530
+ if (filename && isRealResource(filename) && !SOURCE_RESOURCE_SKIP_SEGMENTS.has(filename))
531
+ return filename;
532
+ return [...sourceSegments]
533
+ .slice(0, -1)
534
+ .reverse()
535
+ .map(segment => segment.replace(/\.[^.]+$/, "").toLowerCase())
536
+ .find(segment => segment && isRealResource(segment) && !SOURCE_RESOURCE_SKIP_SEGMENTS.has(segment));
537
+ }
538
+ function collectionPathForChangedEndpoint(path, sourceFile) {
539
+ const segments = pathSegments(path);
540
+ const sourceResource = sourceFileResource(sourceFile);
541
+ if (segments.length > 0 &&
542
+ isPathParam(segments[0]) &&
543
+ sourceResource &&
544
+ (segments.length === 1 || segments[1] !== sourceResource)) {
545
+ return `/${sourceResource}`;
546
+ }
547
+ if (segments.length >= 2) {
548
+ const last = segments[segments.length - 1];
549
+ if (isPathParam(last)) {
550
+ return `/${segments.slice(0, -1).join("/")}`;
551
+ }
552
+ const paramIdx = segments.findIndex(isPathParam);
553
+ if (paramIdx > 0) {
554
+ return `/${segments.slice(0, paramIdx).join("/")}`;
555
+ }
556
+ }
557
+ return sourceResource ? `/${sourceResource}` : undefined;
558
+ }
559
+ function resourceFromCollectionPath(collectionPath) {
560
+ const segments = pathSegments(collectionPath);
561
+ return segments.filter(s => !SKIP_SEGMENTS.has(s)).at(-1) ?? "resource";
562
+ }
563
+ function actionNameFromPath(path) {
564
+ const action = pathSegments(path).at(-1) ?? "destructive";
565
+ return action.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "").toLowerCase() || "destructive";
566
+ }
567
+ function endpointMethods(endpoint) {
568
+ return endpoint.methods.map(m => (typeof m === "string" ? m : m.method).toUpperCase());
569
+ }
570
+ function endpointSourceFiles(endpoint) {
571
+ return new Set(endpoint.methods
572
+ .filter((m) => typeof m !== "string")
573
+ .map(m => m.sourceFile)
574
+ .filter((sourceFile) => Boolean(sourceFile)));
575
+ }
576
+ function resolveSiblingPath(endpointPath, collectionPath) {
577
+ if (isSegmentPrefix(endpointPath, collectionPath))
578
+ return endpointPath;
579
+ const segments = pathSegments(endpointPath);
580
+ if (segments.length > 0 && isDestructiveActionSegment(segments[0])) {
581
+ return `${collectionPath}/${segments.join("/")}`;
582
+ }
583
+ return endpointPath;
584
+ }
585
+ function isDestructiveSiblingEndpoint(endpoint, changed, collectionPath) {
586
+ const methods = endpointMethods(endpoint);
587
+ if (!methods.includes("POST"))
588
+ return false;
589
+ const endpointSegments = pathSegments(endpoint.path);
590
+ const samePathFamily = isSegmentPrefix(endpoint.path, collectionPath) && endpoint.path !== collectionPath;
591
+ const routerRelativeDestructive = endpointSegments.length > 0 &&
592
+ isDestructiveActionSegment(endpointSegments[0]);
593
+ const sameSourceFile = Boolean(routerRelativeDestructive && changed.sourceFile && endpointSourceFiles(endpoint).has(changed.sourceFile));
594
+ if (!samePathFamily && !sameSourceFile)
595
+ return false;
596
+ const collectionSegments = samePathFamily ? pathSegments(collectionPath) : [];
597
+ const suffix = endpointSegments.slice(collectionSegments.length);
598
+ return suffix.some(segment => isDestructiveActionSegment(segment));
599
+ }
600
+ function draftAttackSurfaceExpansionScenarios(endpoints, changedEndpoints, wsAuthType, securityRelevantDiff) {
601
+ if (!securityRelevantDiff || changedEndpoints.length === 0)
602
+ return [];
603
+ const scenarios = [];
604
+ const seen = new Set();
605
+ for (const changed of changedEndpoints) {
606
+ if (changed.method.toUpperCase() !== "DELETE")
607
+ continue;
608
+ const collectionPath = collectionPathForChangedEndpoint(changed.path, changed.sourceFile);
609
+ if (!collectionPath)
610
+ continue;
611
+ const resource = resourceFromCollectionPath(collectionPath);
612
+ for (const endpoint of endpoints) {
613
+ if (!isDestructiveSiblingEndpoint(endpoint, changed, collectionPath))
614
+ continue;
615
+ const siblingPath = resolveSiblingPath(endpoint.path, collectionPath);
616
+ const scenario = diffDirectAuthBoundary("POST", siblingPath, `${resource}-${actionNameFromPath(siblingPath)}`, wsAuthType);
617
+ scenario.description = `Attack-surface auth boundary: ${siblingPath} is a destructive sibling of changed ${changed.method.toUpperCase()} ${changed.path}; verify it rejects missing authentication`;
618
+ scenario.isAttackSurfaceSecurityBoundary = true;
619
+ scenario.steps[0].description = `POST ${siblingPath} without the new auth/admin guard — expect ${scenario.steps[0].expectedStatusCode}`;
620
+ if (!seen.has(scenario.scenarioName)) {
621
+ seen.add(scenario.scenarioName);
622
+ scenarios.push(scenario);
623
+ }
624
+ }
625
+ }
626
+ return scenarios;
627
+ }
438
628
  /**
439
629
  * Draft a unified scenario for GET endpoints without path params (collection or search).
440
630
  * Rather than hardcoding keyword detection, the description instructs the LLM to read
@@ -504,9 +694,11 @@ function diffDirectGetCollection(path, resource, singular, group) {
504
694
  * (category "security_boundary" → HIGH priority, not CRITICAL) so it
505
695
  * lands in ADDITIONAL deterministically instead of depending on LLM
506
696
  * supplement. GET is excluded (often public); POST is excluded because
507
- * the security-boundary supplement tier covers it.
697
+ * the security-boundary supplement tier covers it. Exception: execution
698
+ * endpoints (/execute, /run, /trigger, etc.) include auth-boundary for
699
+ * all non-GET methods (including POST) since they are high-value targets.
508
700
  */
509
- export function draftDiffDirectScenarios(newEndpoints, resourceGroups) {
701
+ export function draftDiffDirectScenarios(newEndpoints, resourceGroups, wsAuthType) {
510
702
  if (newEndpoints.length === 0)
511
703
  return [];
512
704
  const scenarios = [];
@@ -591,6 +783,17 @@ export function draftDiffDirectScenarios(newEndpoints, resourceGroups) {
591
783
  }
592
784
  };
593
785
  for (const method of methods) {
786
+ // Execution endpoints get their own focused templates instead of CRUD lifecycle ones.
787
+ // Caller-traced execution endpoints (Bug 5 scenario) are handled by the LLM prompt;
788
+ // this fires only when a new /execute-style endpoint appears directly in the diff.
789
+ // GET is excluded — execution endpoints use request bodies, which don't apply to GET.
790
+ // MULTI is excluded — it's a sentinel for catch-all handlers, not a real HTTP verb.
791
+ if (isExecutionEndpoint(resolvedPath) && method !== "GET" && method !== "MULTI") {
792
+ add(diffDirectExecutionHappy(method, resolvedPath, resource));
793
+ add(diffDirectExecutionInvalid(method, resolvedPath, resource));
794
+ add(diffDirectAuthBoundary(method, resolvedPath, resource, wsAuthType));
795
+ continue;
796
+ }
594
797
  if (method === "PUT" || method === "PATCH") {
595
798
  // mutation-recalc tests the primary calculation flow
596
799
  add(diffDirectMutationRecalc(method, resource, singular, group));
@@ -618,7 +821,7 @@ export function draftDiffDirectScenarios(newEndpoints, resourceGroups) {
618
821
  add(diffDirectGetCollection(resolvedPath, resource, singular, group));
619
822
  }
620
823
  if (method === "PUT" || method === "PATCH" || method === "DELETE")
621
- add(diffDirectAuthBoundary(method, resolvedPath, resource));
824
+ add(diffDirectAuthBoundary(method, resolvedPath, resource, wsAuthType));
622
825
  }
623
826
  }
624
827
  return scenarios;
@@ -1,5 +1,6 @@
1
1
  // @ts-ignore
2
2
  import { isRealResource, draftScenariosFromEndpoints, draftDiffDirectScenarios, inferResourceRelationships, } from "./scenarioDrafting.js";
3
+ import { WorkspaceAuthType } from "./workspaceAuth.js";
3
4
  describe("isRealResource", () => {
4
5
  it("accepts normal resource names", () => {
5
6
  expect(isRealResource("users")).toBe(true);
@@ -12,14 +13,15 @@ describe("isRealResource", () => {
12
13
  expect(isRealResource("user-profiles")).toBe(true);
13
14
  expect(isRealResource("api-keys")).toBe(true);
14
15
  });
15
- it("rejects action-like single words", () => {
16
- expect(isRealResource("login")).toBe(false);
17
- expect(isRealResource("logout")).toBe(false);
18
- expect(isRealResource("verify")).toBe(false);
16
+ it("rejects infra/monitoring tokens but accepts semantic endpoint names", () => {
19
17
  expect(isRealResource("health")).toBe(false);
20
18
  expect(isRealResource("ping")).toBe(false);
21
19
  expect(isRealResource("dashboard")).toBe(false);
22
- expect(isRealResource("webhook")).toBe(false);
20
+ // login, logout, verify, webhook are real resources (phase 6a)
21
+ expect(isRealResource("login")).toBe(true);
22
+ expect(isRealResource("logout")).toBe(true);
23
+ expect(isRealResource("verify")).toBe(true);
24
+ expect(isRealResource("webhook")).toBe(true);
23
25
  });
24
26
  it("rejects action-verb-hyphenated patterns", () => {
25
27
  expect(isRealResource("forgot-password")).toBe(false);
@@ -31,9 +33,9 @@ describe("isRealResource", () => {
31
33
  expect(isRealResource("delete-cache")).toBe(false);
32
34
  });
33
35
  it("is case-insensitive", () => {
34
- expect(isRealResource("Login")).toBe(false);
35
- expect(isRealResource("VERIFY")).toBe(false);
36
- expect(isRealResource("Forgot-Password")).toBe(false);
36
+ expect(isRealResource("Login")).toBe(true); // login is now a real resource (phase 6a)
37
+ expect(isRealResource("VERIFY")).toBe(true); // verify is now a real resource (phase 6a)
38
+ expect(isRealResource("Forgot-Password")).toBe(false); // ACTION_VERB_HYPHEN still filters this
37
39
  });
38
40
  });
39
41
  describe("draftScenariosFromEndpoints — irregular plural singularization", () => {
@@ -72,6 +74,121 @@ describe("draftScenariosFromEndpoints", () => {
72
74
  expect(draftScenariosFromEndpoints([])).toEqual([]);
73
75
  });
74
76
  });
77
+ describe("draftScenariosFromEndpoints — attack-surface expansion", () => {
78
+ const endpoints = [
79
+ { path: "/{id:uuid}", methods: [{ method: "DELETE", sourceFile: "src/prefect/server/api/flows.py" }] },
80
+ { path: "/bulk_delete", methods: [{ method: "POST", sourceFile: "src/prefect/server/api/flows.py" }] },
81
+ { path: "/{id:uuid}", methods: [{ method: "DELETE", sourceFile: "src/prefect/server/api/deployments.py" }] },
82
+ { path: "/bulk_delete", methods: [{ method: "POST", sourceFile: "src/prefect/server/api/deployments.py" }] },
83
+ ];
84
+ it("drafts auth-boundary scenarios for same-file destructive bulk siblings", () => {
85
+ const scenarios = draftScenariosFromEndpoints(endpoints, [], WorkspaceAuthType.ApiKey, {
86
+ securityRelevantDiff: true,
87
+ changedEndpoints: [
88
+ { method: "DELETE", path: "/{id:uuid}", sourceFile: "src/prefect/server/api/flows.py" },
89
+ { method: "DELETE", path: "/{id:uuid}", sourceFile: "src/prefect/server/api/deployments.py" },
90
+ ],
91
+ });
92
+ const flowBulkDelete = scenarios.find(s => s.scenarioName === "flows-bulk-delete-post-auth-boundary");
93
+ const deploymentBulkDelete = scenarios.find(s => s.scenarioName === "deployments-bulk-delete-post-auth-boundary");
94
+ expect(flowBulkDelete).toBeDefined();
95
+ expect(flowBulkDelete.category).toBe("security_boundary");
96
+ expect(flowBulkDelete.testType).toBe("contract");
97
+ expect(flowBulkDelete.steps[0]).toMatchObject({
98
+ method: "POST",
99
+ path: "/flows/bulk_delete",
100
+ expectedStatusCode: 401,
101
+ });
102
+ expect(flowBulkDelete.description).toContain("destructive sibling");
103
+ expect(deploymentBulkDelete).toBeDefined();
104
+ expect(deploymentBulkDelete.steps[0]).toMatchObject({
105
+ method: "POST",
106
+ path: "/deployments/bulk_delete",
107
+ expectedStatusCode: 401,
108
+ });
109
+ });
110
+ it("does not expand when the diff lacks security signals", () => {
111
+ const scenarios = draftScenariosFromEndpoints(endpoints, [], WorkspaceAuthType.ApiKey, {
112
+ securityRelevantDiff: false,
113
+ changedEndpoints: [{ method: "DELETE", path: "/{id:uuid}", sourceFile: "src/prefect/server/api/flows.py" }],
114
+ });
115
+ expect(scenarios.some(s => s.scenarioName.includes("bulk-delete"))).toBe(false);
116
+ });
117
+ it("uses the source-file resource for router-relative nested destructive endpoints", () => {
118
+ const scenarios = draftScenariosFromEndpoints([
119
+ { path: "/{id:uuid}/schedules/{schedule_id:uuid}", methods: [{ method: "DELETE", sourceFile: "src/prefect/server/api/deployments.py" }] },
120
+ { path: "/bulk_delete", methods: [{ method: "POST", sourceFile: "src/prefect/server/api/deployments.py" }] },
121
+ { path: "/{id:uuid}/schedules/bulk_delete", methods: [{ method: "POST", sourceFile: "src/prefect/server/api/deployments.py" }] },
122
+ ], [], WorkspaceAuthType.ApiKey, {
123
+ securityRelevantDiff: true,
124
+ changedEndpoints: [
125
+ { method: "DELETE", path: "/{id:uuid}/schedules/{schedule_id:uuid}", sourceFile: "src/prefect/server/api/deployments.py" },
126
+ ],
127
+ });
128
+ expect(scenarios.some(s => s.steps.some(step => step.path === "/deployments/bulk_delete"))).toBe(true);
129
+ expect(scenarios.some(s => s.steps.some(step => step.path === "/{id:uuid}/schedules/bulk_delete"))).toBe(false);
130
+ });
131
+ it("preserves Express-style tenant params for attack-surface siblings", () => {
132
+ const scenarios = draftScenariosFromEndpoints([
133
+ { path: "/:tenant_id/deployments/:id", methods: [{ method: "DELETE", sourceFile: "src/routes/deployments.ts" }] },
134
+ { path: "/:tenant_id/deployments/bulk_delete", methods: [{ method: "POST", sourceFile: "src/routes/deployments.ts" }] },
135
+ ], [], WorkspaceAuthType.ApiKey, {
136
+ securityRelevantDiff: true,
137
+ changedEndpoints: [
138
+ { method: "DELETE", path: "/:tenant_id/deployments/:id", sourceFile: "src/routes/deployments.ts" },
139
+ ],
140
+ });
141
+ expect(scenarios.some(s => s.steps.some(step => step.path === "/:tenant_id/deployments/bulk_delete"))).toBe(true);
142
+ });
143
+ it("preserves Next-style tenant params for attack-surface siblings", () => {
144
+ const scenarios = draftScenariosFromEndpoints([
145
+ { path: "/[tenant_id]/deployments/[id]", methods: [{ method: "DELETE", sourceFile: "src/app/api/deployments/route.ts" }] },
146
+ { path: "/[tenant_id]/deployments/bulk_delete", methods: [{ method: "POST", sourceFile: "src/app/api/deployments/route.ts" }] },
147
+ ], [], WorkspaceAuthType.ApiKey, {
148
+ securityRelevantDiff: true,
149
+ changedEndpoints: [
150
+ { method: "DELETE", path: "/[tenant_id]/deployments/[id]", sourceFile: "src/app/api/deployments/route.ts" },
151
+ ],
152
+ });
153
+ expect(scenarios.some(s => s.steps.some(step => step.path === "/[tenant_id]/deployments/bulk_delete"))).toBe(true);
154
+ });
155
+ it("preserves full tenant-scoped paths when the resource segment is explicit", () => {
156
+ const scenarios = draftScenariosFromEndpoints([
157
+ { path: "/{tenant_id}/deployments/{id}", methods: [{ method: "DELETE", sourceFile: "src/prefect/server/api/deployments.py" }] },
158
+ { path: "/{tenant_id}/deployments/bulk_delete", methods: [{ method: "POST", sourceFile: "src/prefect/server/api/deployments.py" }] },
159
+ ], [], WorkspaceAuthType.ApiKey, {
160
+ securityRelevantDiff: true,
161
+ changedEndpoints: [
162
+ { method: "DELETE", path: "/{tenant_id}/deployments/{id}", sourceFile: "src/prefect/server/api/deployments.py" },
163
+ ],
164
+ });
165
+ expect(scenarios.some(s => s.steps.some(step => step.path === "/{tenant_id}/deployments/bulk_delete"))).toBe(true);
166
+ });
167
+ it("does not invent a bulk sibling when no destructive sibling endpoint exists", () => {
168
+ const scenarios = draftScenariosFromEndpoints([{ path: "/{id:uuid}", methods: [{ method: "DELETE", sourceFile: "src/prefect/server/api/flows.py" }] }], [], WorkspaceAuthType.ApiKey, {
169
+ securityRelevantDiff: true,
170
+ changedEndpoints: [{ method: "DELETE", path: "/{id:uuid}", sourceFile: "src/prefect/server/api/flows.py" }],
171
+ });
172
+ expect(scenarios).toEqual([]);
173
+ });
174
+ it("does not expand ordinary changed GET endpoints", () => {
175
+ const scenarios = draftScenariosFromEndpoints(endpoints, [], WorkspaceAuthType.ApiKey, {
176
+ securityRelevantDiff: true,
177
+ changedEndpoints: [{ method: "GET", path: "/{id:uuid}", sourceFile: "src/prefect/server/api/flows.py" }],
178
+ });
179
+ expect(scenarios.some(s => s.scenarioName.includes("bulk-delete"))).toBe(false);
180
+ });
181
+ it("does not promote unrelated destructive routes from the same source file", () => {
182
+ const scenarios = draftScenariosFromEndpoints([
183
+ { path: "/flows/{id:uuid}", methods: [{ method: "DELETE", sourceFile: "src/routes/api.py" }] },
184
+ { path: "/admin/bulk_delete", methods: [{ method: "POST", sourceFile: "src/routes/api.py" }] },
185
+ ], [], WorkspaceAuthType.ApiKey, {
186
+ securityRelevantDiff: true,
187
+ changedEndpoints: [{ method: "DELETE", path: "/flows/{id:uuid}", sourceFile: "src/routes/api.py" }],
188
+ });
189
+ expect(scenarios).toEqual([]);
190
+ });
191
+ });
75
192
  describe("inferResourceRelationships", () => {
76
193
  it("returns empty map for endpoints with no nesting or FK fields", () => {
77
194
  const endpoints = [
@@ -379,7 +496,7 @@ describe("diffDirectMutationRecalc — diverse app types (no hardcoded field nam
379
496
  const chaining = mutationStep.chainsFrom;
380
497
  expect(chaining.targetParam).toBe(expectedParamName);
381
498
  expect(chaining.sourceStep).toBe(1);
382
- expect(chaining.sourceField).toBe("id");
499
+ expect(chaining.sourceField).toBe("<id-field>");
383
500
  }
384
501
  else {
385
502
  expect(mutationStep.chainsFrom).toBeUndefined();
@@ -643,3 +760,59 @@ describe("path collision fix — resourceGroups disambiguation", () => {
643
760
  expect(allPaths.some((p) => p.includes("orders-archive"))).toBe(false);
644
761
  });
645
762
  });
763
+ describe("draftDiffDirectScenarios — execution endpoints", () => {
764
+ it("produces execution templates for /execute-style endpoints (non-GET)", () => {
765
+ const newEndpoints = [{ method: "POST", path: "/api/jobs/execute" }];
766
+ const resourceGroups = new Map([
767
+ ["jobs", { basePath: "/api/jobs", methods: new Set(["GET", "POST"]), paramPath: "/api/jobs/{id}" }],
768
+ ]);
769
+ const result = draftDiffDirectScenarios(newEndpoints, resourceGroups);
770
+ const names = result.map((s) => s.scenarioName);
771
+ expect(names).toContain("jobs-post-execute-happy");
772
+ expect(names).toContain("jobs-post-execute-invalid");
773
+ expect(names).toContain("jobs-post-auth-boundary");
774
+ // Should NOT produce CRUD lifecycle templates
775
+ expect(names.some((n) => n.includes("integration") || n.includes("contract"))).toBe(false);
776
+ });
777
+ it("does not produce execution templates for GET on execution endpoints", () => {
778
+ const newEndpoints = [{ method: "GET", path: "/api/jobs/execute" }];
779
+ const resourceGroups = new Map([
780
+ ["jobs", { basePath: "/api/jobs", methods: new Set(["GET"]), paramPath: "/api/jobs/{id}" }],
781
+ ]);
782
+ const result = draftDiffDirectScenarios(newEndpoints, resourceGroups);
783
+ const names = result.map((s) => s.scenarioName);
784
+ // GET should fall through to normal GET handling, not execution templates
785
+ expect(names.some((n) => n.includes("execute-happy"))).toBe(false);
786
+ expect(names.some((n) => n.includes("execute-invalid"))).toBe(false);
787
+ });
788
+ });
789
+ describe("draftDiffDirectScenarios — auth boundary status codes", () => {
790
+ const newEndpoints = [{ method: "DELETE", path: "/api/orders/{id}" }];
791
+ const resourceGroups = new Map([
792
+ ["orders", { basePath: "/api/orders", methods: new Set(["GET", "POST", "DELETE"]), paramPath: "/api/orders/{id}" }],
793
+ ]);
794
+ it("expects 403 for Bearer auth type", () => {
795
+ const result = draftDiffDirectScenarios(newEndpoints, resourceGroups, WorkspaceAuthType.Bearer);
796
+ const authScenario = result.find((s) => s.scenarioName.includes("auth-boundary"));
797
+ expect(authScenario).toBeDefined();
798
+ expect(authScenario.steps[0].expectedStatusCode).toBe(403);
799
+ });
800
+ it("expects 403 for OAuth auth type (uses Bearer scheme)", () => {
801
+ const result = draftDiffDirectScenarios(newEndpoints, resourceGroups, WorkspaceAuthType.OAuth);
802
+ const authScenario = result.find((s) => s.scenarioName.includes("auth-boundary"));
803
+ expect(authScenario).toBeDefined();
804
+ expect(authScenario.steps[0].expectedStatusCode).toBe(403);
805
+ });
806
+ it("expects 401 for other auth types", () => {
807
+ const result = draftDiffDirectScenarios(newEndpoints, resourceGroups, WorkspaceAuthType.ApiKey);
808
+ const authScenario = result.find((s) => s.scenarioName.includes("auth-boundary"));
809
+ expect(authScenario).toBeDefined();
810
+ expect(authScenario.steps[0].expectedStatusCode).toBe(401);
811
+ });
812
+ it("expects 401 when no auth type specified", () => {
813
+ const result = draftDiffDirectScenarios(newEndpoints, resourceGroups);
814
+ const authScenario = result.find((s) => s.scenarioName.includes("auth-boundary"));
815
+ expect(authScenario).toBeDefined();
816
+ expect(authScenario.steps[0].expectedStatusCode).toBe(401);
817
+ });
818
+ });