@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.
- package/build/index.js +4 -2
- package/build/playwright/registerPlaywrightTools.js +12 -0
- package/build/playwright/traceRecordingPrompt.js +15 -0
- package/build/prompts/code-reuse.js +106 -7
- package/build/prompts/pom-aware-code-reuse.js +106 -7
- package/build/prompts/startTraceCollectionPrompts.js +37 -15
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +26 -31
- package/build/prompts/test-maintenance/drift-analysis-prompt.test.js +40 -1
- package/build/prompts/test-maintenance/driftAnalysisSections.js +90 -86
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +286 -163
- package/build/prompts/test-recommendation/analysisOutputPrompt.test.js +154 -45
- package/build/prompts/test-recommendation/diffExecutionPlan.js +246 -117
- package/build/prompts/test-recommendation/promptPlan.js +290 -0
- package/build/prompts/test-recommendation/promptPlan.test.js +336 -0
- package/build/prompts/test-recommendation/recommendationSections.js +4 -3
- package/build/prompts/test-recommendation/recommendationShared.js +23 -1
- package/build/prompts/test-recommendation/scopeAssessment.js +65 -14
- package/build/prompts/test-recommendation/scopeAssessment.test.js +93 -2
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +36 -12
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +316 -1
- package/build/prompts/testbot/testbot-prompts.js +73 -13
- package/build/prompts/testbot/testbot-prompts.test.js +114 -1
- package/build/resources/testbotResource.js +1 -1
- package/build/services/ScenarioGenerationService.integration.test.js +158 -0
- package/build/services/ScenarioGenerationService.js +47 -4
- package/build/services/ScenarioGenerationService.test.js +158 -22
- package/build/services/TestExecutionService.js +73 -15
- package/build/services/TestExecutionService.test.js +105 -0
- package/build/services/TestGenerationService.js +11 -1
- package/build/tools/executeSkyrampTestTool.js +1 -10
- package/build/tools/generate-tests/generateBatchScenarioRestTool.js +16 -4
- package/build/tools/generate-tests/generateIntegrationRestTool.js +2 -0
- package/build/tools/generate-tests/generateUIRestTool.js +2 -0
- package/build/tools/test-management/actionsTool.js +152 -63
- package/build/tools/test-management/analyzeChangesTool.js +178 -64
- package/build/tools/test-management/analyzeChangesTool.test.js +103 -16
- package/build/tools/test-management/analyzeTestHealthTool.js +30 -81
- package/build/tools/test-management/index.js +1 -0
- package/build/tools/test-management/uiAnalyzeChangesTool.js +149 -0
- package/build/tools/test-management/uiAnalyzeChangesTool.test.js +100 -0
- package/build/tools/trace/resolveSaveStoragePath.js +16 -0
- package/build/tools/trace/resolveSaveStoragePath.test.js +17 -0
- package/build/tools/trace/resolveSessionPaths.js +39 -0
- package/build/tools/trace/resolveSessionPaths.test.js +103 -0
- package/build/tools/trace/sessionState.js +14 -0
- package/build/tools/trace/sessionState.test.js +17 -0
- package/build/tools/trace/startTraceCollectionTool.js +84 -14
- package/build/tools/trace/stopTraceCollectionTool.js +9 -2
- package/build/types/TestAnalysis.js +50 -0
- package/build/types/TestRecommendation.js +6 -58
- package/build/types/TestTypes.js +1 -1
- package/build/utils/AnalysisStateManager.js +22 -11
- package/build/utils/branchDiff.js +11 -2
- package/build/utils/docker.test.js +1 -1
- package/build/utils/gitStaging.js +52 -3
- package/build/utils/gitStaging.test.js +19 -1
- package/build/utils/repoScanner.js +18 -10
- package/build/utils/repoScanner.test.js +92 -0
- package/build/utils/routeParsers.js +180 -25
- package/build/utils/routeParsers.test.js +180 -1
- package/build/utils/scenarioDrafting.js +220 -17
- package/build/utils/scenarioDrafting.test.js +182 -9
- package/build/utils/sourceRouteExtractor.js +806 -0
- package/build/utils/sourceRouteExtractor.test.js +565 -0
- package/build/utils/uiPageEnumerator.js +319 -0
- package/build/utils/uiPageEnumerator.test.js +422 -0
- package/build/utils/utils.js +27 -0
- package/build/utils/versions.js +1 -1
- package/build/utils/workspaceAuth.js +33 -4
- package/node_modules/playwright/ThirdPartyNotices.txt +6 -6
- package/node_modules/playwright/lib/dom-analyzer/analyze.js +111 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.js +1210 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprint.test.js +396 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintCache.test.js +57 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.js +254 -0
- package/node_modules/playwright/lib/dom-analyzer/blueprintDiff.test.js +304 -0
- package/node_modules/playwright/lib/dom-analyzer/crawler.js +384 -0
- package/node_modules/playwright/lib/dom-analyzer/curatedWidgets.js +73 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/dynamicId.test.js +85 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.js +90 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprint.test.js +231 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.fixtures.js +145 -0
- package/node_modules/playwright/lib/dom-analyzer/fingerprintAblation.test.js +41 -0
- package/node_modules/playwright/lib/dom-analyzer/graph.js +36 -0
- package/node_modules/playwright/lib/dom-analyzer/liveFingerprints.js +43 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.js +72 -0
- package/node_modules/playwright/lib/dom-analyzer/logicalNameResolver.test.js +182 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.js +150 -0
- package/node_modules/playwright/lib/dom-analyzer/possibleAssertions.test.js +470 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.js +169 -0
- package/node_modules/playwright/lib/dom-analyzer/sectionGrouper.test.js +269 -0
- package/node_modules/playwright/lib/dom-analyzer/serialization.js +75 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.js +30 -0
- package/node_modules/playwright/lib/dom-analyzer/slug.test.js +84 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.js +127 -0
- package/node_modules/playwright/lib/dom-analyzer/widgetContract.test.js +212 -0
- package/node_modules/playwright/lib/mcp/browser/browserContextFactory.js +3 -1
- package/node_modules/playwright/lib/mcp/browser/config.js +1 -1
- package/node_modules/playwright/lib/mcp/browser/context.js +17 -1
- package/node_modules/playwright/lib/mcp/browser/tab.js +38 -0
- package/node_modules/playwright/lib/mcp/browser/tools/domAnalyzer.js +261 -0
- package/node_modules/playwright/lib/mcp/browser/tools/keyboard.js +3 -3
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.js +146 -0
- package/node_modules/playwright/lib/mcp/browser/tools/pageBlueprint.test.js +140 -0
- package/node_modules/playwright/lib/mcp/browser/tools/sitemap.js +226 -0
- package/node_modules/playwright/lib/mcp/browser/tools/snapshot.js +2 -2
- package/node_modules/playwright/lib/mcp/browser/tools/widgetContract.js +168 -0
- package/node_modules/playwright/lib/mcp/browser/tools.js +6 -0
- package/node_modules/playwright/lib/mcp/skyramp/traceRecordingBackend.js +52 -12
- package/node_modules/playwright/lib/mcp/test/skyRampExport.js +64 -13
- package/node_modules/playwright/package.json +1 -1
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.3.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.4.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.5.tgz +0 -0
- package/node_modules/playwright/skyramp-playwright-1.58.2-skyramp.8.9.6.tgz +0 -0
- package/package.json +3 -3
- package/build/services/TestHealthService.js +0 -694
- package/build/services/TestHealthService.test.js +0 -241
- package/build/types/TestDriftAnalysis.js +0 -1
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
35
|
-
expect(isRealResource("VERIFY")).toBe(
|
|
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
|
+
});
|