@scenarist/core 0.3.0 → 0.3.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/dist/adapters/in-memory-state-manager.d.ts.map +1 -1
- package/dist/adapters/in-memory-state-manager.js +19 -8
- package/dist/domain/deep-equals.d.ts.map +1 -1
- package/dist/domain/deep-equals.js +9 -2
- package/dist/domain/log-events.d.ts +4 -0
- package/dist/domain/log-events.d.ts.map +1 -1
- package/dist/domain/log-events.js +4 -0
- package/dist/domain/path-extraction.d.ts.map +1 -1
- package/dist/domain/path-extraction.js +10 -4
- package/dist/domain/response-selector.d.ts.map +1 -1
- package/dist/domain/response-selector.js +63 -21
- package/dist/domain/scenario-manager.d.ts.map +1 -1
- package/dist/domain/scenario-manager.js +7 -1
- package/dist/domain/template-replacement.d.ts +4 -0
- package/dist/domain/template-replacement.d.ts.map +1 -1
- package/dist/domain/template-replacement.js +30 -4
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"in-memory-state-manager.d.ts","sourceRoot":"","sources":["../../src/adapters/in-memory-state-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAC;
|
|
1
|
+
{"version":3,"file":"in-memory-state-manager.d.ts","sourceRoot":"","sources":["../../src/adapters/in-memory-state-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAcrE;;;;;;GAMG;AACH,qBAAa,oBAAqB,YAAW,YAAY;IACvD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA8C;IAEtE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO;IAUzC,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI;IAyBtD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAI/C,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAI3B,KAAK,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAgB7D,OAAO,CAAC,oBAAoB;IAS5B,OAAO,CAAC,cAAc;IA6CtB,OAAO,CAAC,cAAc;CA8BvB;AAED;;;GAGG;AACH,eAAO,MAAM,0BAA0B,QAAO,YAE7C,CAAC"}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
2
2
|
const isDangerousKey = (key) => DANGEROUS_KEYS.has(key);
|
|
3
|
+
/**
|
|
4
|
+
* Type guard to check if a value is a plain object (Record).
|
|
5
|
+
* Used to properly narrow types after typeof checks.
|
|
6
|
+
*/
|
|
7
|
+
const isRecord = (value) => {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
};
|
|
3
10
|
/**
|
|
4
11
|
* In-memory implementation of StateManager port.
|
|
5
12
|
* Fast, single-process state storage for stateful mocks.
|
|
@@ -80,21 +87,24 @@ export class InMemoryStateManager {
|
|
|
80
87
|
});
|
|
81
88
|
return;
|
|
82
89
|
}
|
|
90
|
+
// Get or create nested object for this path segment
|
|
83
91
|
// eslint-disable-next-line security/detect-object-injection -- Guarded by isDangerousKey and Object.hasOwn
|
|
84
92
|
const existingValue = Object.hasOwn(obj, key) ? obj[key] : undefined;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
93
|
+
// Determine target: use existing record OR create new empty object
|
|
94
|
+
const target = isRecord(existingValue)
|
|
95
|
+
? existingValue
|
|
96
|
+
: {};
|
|
97
|
+
// If we created a new object, assign it to the parent
|
|
98
|
+
if (!isRecord(existingValue)) {
|
|
88
99
|
Object.defineProperty(obj, key, {
|
|
89
|
-
value:
|
|
100
|
+
value: target,
|
|
90
101
|
writable: true,
|
|
91
102
|
enumerable: true,
|
|
92
103
|
configurable: true,
|
|
93
104
|
});
|
|
94
105
|
}
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
this.setNestedValue(nested, path.slice(1), value);
|
|
106
|
+
// Recurse to next path segment
|
|
107
|
+
this.setNestedValue(target, path.slice(1), value);
|
|
98
108
|
}
|
|
99
109
|
getNestedValue(obj, path) {
|
|
100
110
|
const key = path[0];
|
|
@@ -111,7 +121,8 @@ export class InMemoryStateManager {
|
|
|
111
121
|
if (path.length === 1) {
|
|
112
122
|
return value;
|
|
113
123
|
}
|
|
114
|
-
|
|
124
|
+
// Use type guard for proper type narrowing instead of assertion
|
|
125
|
+
if (!isRecord(value)) {
|
|
115
126
|
return undefined;
|
|
116
127
|
}
|
|
117
128
|
return this.getNestedValue(value, path.slice(1));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deep-equals.d.ts","sourceRoot":"","sources":["../../src/domain/deep-equals.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"deep-equals.d.ts","sourceRoot":"","sources":["../../src/domain/deep-equals.ts"],"names":[],"mappings":"AAQA;;;;;;;;;GASG;AACH,eAAO,MAAM,UAAU,GAAI,GAAG,OAAO,EAAE,GAAG,OAAO,KAAG,OA+DnD,CAAC"}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type guard to check if a value is a plain object (Record).
|
|
3
|
+
* Used to properly narrow types after typeof checks.
|
|
4
|
+
*/
|
|
5
|
+
const isRecord = (value) => {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
};
|
|
1
8
|
/**
|
|
2
9
|
* Deep equality comparison for values.
|
|
3
10
|
*
|
|
@@ -42,8 +49,8 @@ export const deepEquals = (a, b) => {
|
|
|
42
49
|
if (Array.isArray(a) !== Array.isArray(b)) {
|
|
43
50
|
return false;
|
|
44
51
|
}
|
|
45
|
-
// Handle objects
|
|
46
|
-
if (
|
|
52
|
+
// Handle objects (use type guard instead of type assertion)
|
|
53
|
+
if (isRecord(a) && isRecord(b)) {
|
|
47
54
|
const aKeys = Object.keys(a);
|
|
48
55
|
const bKeys = Object.keys(b);
|
|
49
56
|
if (aKeys.length !== bKeys.length) {
|
|
@@ -52,6 +52,10 @@ export declare const LogEvents: {
|
|
|
52
52
|
readonly STATE_CAPTURED: "state_captured";
|
|
53
53
|
/** State injected into response */
|
|
54
54
|
readonly STATE_INJECTED: "state_injected";
|
|
55
|
+
/** State set via afterResponse.setState */
|
|
56
|
+
readonly STATE_SET: "state_set";
|
|
57
|
+
/** State response resolved (condition matched or default) */
|
|
58
|
+
readonly STATE_RESPONSE_RESOLVED: "state_response_resolved";
|
|
55
59
|
/** Sequence advanced to next response */
|
|
56
60
|
readonly SEQUENCE_ADVANCED: "sequence_advanced";
|
|
57
61
|
/** Sequence exhausted all responses */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"log-events.d.ts","sourceRoot":"","sources":["../../src/domain/log-events.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa;IACxB,oEAAoE;;IAEpE,yCAAyC;;IAEzC,yCAAyC;;IAEzC,kCAAkC;;IAElC,8BAA8B;;CAEtB,CAAC;AAEX;;GAEG;AACH,eAAO,MAAM,SAAS;IAEpB,uCAAuC;;IAEvC,mDAAmD;;IAEnD,mCAAmC;;IAEnC,kCAAkC;;IAIlC,2CAA2C;;IAE3C,4CAA4C;;IAE5C,sCAAsC;;IAEtC,kCAAkC;;IAIlC,mCAAmC;;IAEnC,mCAAmC;;
|
|
1
|
+
{"version":3,"file":"log-events.d.ts","sourceRoot":"","sources":["../../src/domain/log-events.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,aAAa;IACxB,oEAAoE;;IAEpE,yCAAyC;;IAEzC,yCAAyC;;IAEzC,kCAAkC;;IAElC,8BAA8B;;CAEtB,CAAC;AAEX;;GAEG;AACH,eAAO,MAAM,SAAS;IAEpB,uCAAuC;;IAEvC,mDAAmD;;IAEnD,mCAAmC;;IAEnC,kCAAkC;;IAIlC,2CAA2C;;IAE3C,4CAA4C;;IAE5C,sCAAsC;;IAEtC,kCAAkC;;IAIlC,mCAAmC;;IAEnC,mCAAmC;;IAEnC,2CAA2C;;IAE3C,6DAA6D;;IAI7D,yCAAyC;;IAEzC,uCAAuC;;IAIvC,yCAAyC;;IAEzC,8CAA8C;;IAE9C,qCAAqC;;CAE7B,CAAC;AAEX,MAAM,MAAM,QAAQ,GAAG,CAAC,OAAO,SAAS,CAAC,CAAC,MAAM,OAAO,SAAS,CAAC,CAAC"}
|
|
@@ -55,6 +55,10 @@ export const LogEvents = {
|
|
|
55
55
|
STATE_CAPTURED: "state_captured",
|
|
56
56
|
/** State injected into response */
|
|
57
57
|
STATE_INJECTED: "state_injected",
|
|
58
|
+
/** State set via afterResponse.setState */
|
|
59
|
+
STATE_SET: "state_set",
|
|
60
|
+
/** State response resolved (condition matched or default) */
|
|
61
|
+
STATE_RESPONSE_RESOLVED: "state_response_resolved",
|
|
58
62
|
// Sequence progression
|
|
59
63
|
/** Sequence advanced to next response */
|
|
60
64
|
SEQUENCE_ADVANCED: "sequence_advanced",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"path-extraction.d.ts","sourceRoot":"","sources":["../../src/domain/path-extraction.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"path-extraction.d.ts","sourceRoot":"","sources":["../../src/domain/path-extraction.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAc/D;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,GAC1B,SAAS,kBAAkB,EAC3B,MAAM,MAAM,KACX,OAyBF,CAAC"}
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
2
2
|
const isDangerousKey = (key) => DANGEROUS_KEYS.has(key);
|
|
3
|
+
/**
|
|
4
|
+
* Type guard to check if a value is a plain object (Record).
|
|
5
|
+
* Used to properly narrow types after typeof checks.
|
|
6
|
+
*/
|
|
7
|
+
const isRecord = (value) => {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
};
|
|
3
10
|
/**
|
|
4
11
|
* Extracts a value from HttpRequestContext based on a path expression.
|
|
5
12
|
*
|
|
@@ -55,8 +62,8 @@ const traversePath = (obj, path) => {
|
|
|
55
62
|
if (path.length === 0) {
|
|
56
63
|
return obj;
|
|
57
64
|
}
|
|
58
|
-
// Guard: Can only traverse objects
|
|
59
|
-
if (
|
|
65
|
+
// Guard: Can only traverse objects - use type guard for proper narrowing
|
|
66
|
+
if (!isRecord(obj)) {
|
|
60
67
|
return undefined;
|
|
61
68
|
}
|
|
62
69
|
const key = path[0];
|
|
@@ -68,9 +75,8 @@ const traversePath = (obj, path) => {
|
|
|
68
75
|
if (!Object.hasOwn(obj, key)) {
|
|
69
76
|
return undefined;
|
|
70
77
|
}
|
|
71
|
-
const record = obj;
|
|
72
78
|
// eslint-disable-next-line security/detect-object-injection -- Key validated by Object.hasOwn and isDangerousKey guard
|
|
73
|
-
const value =
|
|
79
|
+
const value = obj[key];
|
|
74
80
|
// Recursively traverse remaining path
|
|
75
81
|
return traversePath(value, path.slice(1));
|
|
76
82
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"response-selector.d.ts","sourceRoot":"","sources":["../../src/domain/response-selector.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,gBAAgB,EAChB,eAAe,EACf,YAAY,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"response-selector.d.ts","sourceRoot":"","sources":["../../src/domain/response-selector.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,gBAAgB,EAChB,eAAe,EACf,YAAY,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AAyB3B;;GAEG;AACH,KAAK,6BAA6B,GAAG;IACnC,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,GACjC,UAAS,6BAAkC,KAC1C,gBAwQF,CAAC"}
|
|
@@ -6,6 +6,13 @@ import { createStateResponseResolver } from "./state-response-resolver.js";
|
|
|
6
6
|
import { deepEquals } from "./deep-equals.js";
|
|
7
7
|
import { noOpLogger } from "../adapters/index.js";
|
|
8
8
|
import { LogCategories, LogEvents } from "./log-events.js";
|
|
9
|
+
/**
|
|
10
|
+
* Type guard to check if a value is a plain object (Record).
|
|
11
|
+
* Used to properly narrow types after typeof checks.
|
|
12
|
+
*/
|
|
13
|
+
const isRecord = (value) => {
|
|
14
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
|
+
};
|
|
9
16
|
const SPECIFICITY_RANGES = {
|
|
10
17
|
MATCH_CRITERIA_BASE: 100,
|
|
11
18
|
SEQUENCE_FALLBACK: 1,
|
|
@@ -72,11 +79,14 @@ export const createResponseSelector = (options = {}) => {
|
|
|
72
79
|
continue;
|
|
73
80
|
}
|
|
74
81
|
// No match criteria = fallback mock (always matches)
|
|
75
|
-
// Log fallback evaluation
|
|
82
|
+
// Log fallback evaluation with response type info for debugging Issue #328
|
|
76
83
|
logger.debug(LogCategories.MATCHING, LogEvents.MOCK_MATCH_EVALUATED, logContext, {
|
|
77
84
|
mockIndex,
|
|
78
85
|
matched: true,
|
|
79
86
|
hasCriteria: false,
|
|
87
|
+
hasSequence: !!mock.sequence,
|
|
88
|
+
hasStateResponse: !!mock.stateResponse,
|
|
89
|
+
hasResponse: !!mock.response,
|
|
80
90
|
});
|
|
81
91
|
// Dynamic response types (sequence, stateResponse) get higher priority than simple responses
|
|
82
92
|
// This ensures they are selected over simple fallback responses
|
|
@@ -105,7 +115,7 @@ export const createResponseSelector = (options = {}) => {
|
|
|
105
115
|
specificity,
|
|
106
116
|
});
|
|
107
117
|
// Select response (single, sequence, or stateResponse)
|
|
108
|
-
const response = selectResponseFromMock(testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager);
|
|
118
|
+
const response = selectResponseFromMock(testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager, logger);
|
|
109
119
|
if (!response) {
|
|
110
120
|
return {
|
|
111
121
|
success: false,
|
|
@@ -136,11 +146,15 @@ export const createResponseSelector = (options = {}) => {
|
|
|
136
146
|
state: currentState,
|
|
137
147
|
params: mockWithParams.params || {},
|
|
138
148
|
};
|
|
149
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- applyTemplates preserves structure; input ScenaristResponse → output ScenaristResponse
|
|
139
150
|
finalResponse = applyTemplates(response, templateData);
|
|
140
151
|
}
|
|
141
152
|
// Apply afterResponse.setState to mutate state for subsequent requests
|
|
142
153
|
if (mock.afterResponse?.setState && stateManager) {
|
|
143
154
|
stateManager.merge(testId, mock.afterResponse.setState);
|
|
155
|
+
logger.debug(LogCategories.STATE, LogEvents.STATE_SET, logContext, {
|
|
156
|
+
setState: mock.afterResponse.setState,
|
|
157
|
+
});
|
|
144
158
|
}
|
|
145
159
|
return { success: true, data: finalResponse };
|
|
146
160
|
}
|
|
@@ -199,9 +213,11 @@ export const createResponseSelector = (options = {}) => {
|
|
|
199
213
|
* @param mock - The mock definition
|
|
200
214
|
* @param sequenceTracker - Optional sequence tracker for Phase 2
|
|
201
215
|
* @param stateManager - Optional state manager for stateResponse
|
|
216
|
+
* @param logger - Logger for debugging
|
|
202
217
|
* @returns ScenaristResponse or null if mock has no response type
|
|
203
218
|
*/
|
|
204
|
-
const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager) => {
|
|
219
|
+
const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTracker, stateManager, logger) => {
|
|
220
|
+
const logContext = { testId, scenarioId };
|
|
205
221
|
// Phase 2: If mock has a sequence, use sequence tracker
|
|
206
222
|
if (mock.sequence) {
|
|
207
223
|
if (!sequenceTracker) {
|
|
@@ -222,7 +238,7 @@ const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTra
|
|
|
222
238
|
}
|
|
223
239
|
// State-aware response: evaluate conditions against current state
|
|
224
240
|
if (mock.stateResponse) {
|
|
225
|
-
return resolveStateResponse(testId, mock.stateResponse, stateManager);
|
|
241
|
+
return resolveStateResponse(testId, mock.stateResponse, stateManager, logger, logContext);
|
|
226
242
|
}
|
|
227
243
|
// Phase 1: Single response
|
|
228
244
|
if (mock.response) {
|
|
@@ -240,18 +256,36 @@ const selectResponseFromMock = (testId, scenarioId, mockIndex, mock, sequenceTra
|
|
|
240
256
|
* @param testId - Test ID for state isolation
|
|
241
257
|
* @param stateResponse - The stateResponse configuration
|
|
242
258
|
* @param stateManager - Optional state manager for state lookup
|
|
259
|
+
* @param logger - Logger for debugging
|
|
260
|
+
* @param logContext - Context for log messages
|
|
243
261
|
* @returns The resolved response (matching condition or default)
|
|
244
262
|
*/
|
|
245
|
-
const resolveStateResponse = (testId, stateResponse, stateManager) => {
|
|
263
|
+
const resolveStateResponse = (testId, stateResponse, stateManager, logger, logContext) => {
|
|
246
264
|
// Without stateManager, always return default
|
|
247
265
|
if (!stateManager) {
|
|
266
|
+
logger.debug(LogCategories.STATE, LogEvents.STATE_RESPONSE_RESOLVED, logContext, {
|
|
267
|
+
result: "default",
|
|
268
|
+
reason: "no_state_manager",
|
|
269
|
+
});
|
|
248
270
|
return stateResponse.default;
|
|
249
271
|
}
|
|
250
272
|
// Get current state for this test
|
|
251
273
|
const currentState = stateManager.getAll(testId);
|
|
252
274
|
// Create resolver and evaluate conditions
|
|
253
275
|
const resolver = createStateResponseResolver();
|
|
254
|
-
|
|
276
|
+
const response = resolver.resolveResponse(stateResponse, currentState);
|
|
277
|
+
// Log which response was selected and why
|
|
278
|
+
const isDefault = response === stateResponse.default;
|
|
279
|
+
const matchedCondition = isDefault
|
|
280
|
+
? null
|
|
281
|
+
: stateResponse.conditions.find((c) => resolver.resolveResponse({ default: stateResponse.default, conditions: [c] }, currentState) !== stateResponse.default);
|
|
282
|
+
logger.debug(LogCategories.STATE, LogEvents.STATE_RESPONSE_RESOLVED, logContext, {
|
|
283
|
+
result: isDefault ? "default" : "condition",
|
|
284
|
+
currentState,
|
|
285
|
+
conditionsCount: stateResponse.conditions.length,
|
|
286
|
+
matchedWhen: matchedCondition?.when ?? null,
|
|
287
|
+
});
|
|
288
|
+
return response;
|
|
255
289
|
};
|
|
256
290
|
/**
|
|
257
291
|
* Calculate specificity score for match criteria.
|
|
@@ -367,15 +401,14 @@ const matchesState = (stateCriteria, testId, stateManager) => {
|
|
|
367
401
|
* Non-string values are converted to strings before matching.
|
|
368
402
|
*/
|
|
369
403
|
const matchesBody = (requestBody, criteriaBody) => {
|
|
370
|
-
// If request has no body, can't match
|
|
371
|
-
if (!requestBody
|
|
404
|
+
// If request has no body, can't match - use type guard for proper narrowing
|
|
405
|
+
if (!isRecord(requestBody)) {
|
|
372
406
|
return false;
|
|
373
407
|
}
|
|
374
|
-
const body = requestBody;
|
|
375
408
|
// Check all required fields exist in request body with matching values
|
|
376
409
|
for (const [key, criteriaValue] of Object.entries(criteriaBody)) {
|
|
377
410
|
// eslint-disable-next-line security/detect-object-injection -- Key from Object.entries iteration
|
|
378
|
-
const requestValue =
|
|
411
|
+
const requestValue = requestBody[key];
|
|
379
412
|
// Convert to string for matching (type coercion like headers/query)
|
|
380
413
|
const stringValue = requestValue == null ? "" : String(requestValue);
|
|
381
414
|
if (!matchesValue(stringValue, criteriaValue)) {
|
|
@@ -435,21 +468,30 @@ const matchesValue = (requestValue, criteriaValue) => {
|
|
|
435
468
|
if (criteriaValue == null) {
|
|
436
469
|
return requestValue === "";
|
|
437
470
|
}
|
|
438
|
-
|
|
439
|
-
if (
|
|
440
|
-
return
|
|
471
|
+
// After ruling out string, RegExp, number, boolean, null - remaining must be object strategy
|
|
472
|
+
if (!isRecord(criteriaValue)) {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
if (criteriaValue.equals !== undefined) {
|
|
476
|
+
return requestValue === String(criteriaValue.equals);
|
|
441
477
|
}
|
|
442
|
-
if (
|
|
443
|
-
return requestValue.includes(String(
|
|
478
|
+
if (criteriaValue.contains !== undefined) {
|
|
479
|
+
return requestValue.includes(String(criteriaValue.contains));
|
|
444
480
|
}
|
|
445
|
-
if (
|
|
446
|
-
return requestValue.startsWith(String(
|
|
481
|
+
if (criteriaValue.startsWith !== undefined) {
|
|
482
|
+
return requestValue.startsWith(String(criteriaValue.startsWith));
|
|
447
483
|
}
|
|
448
|
-
if (
|
|
449
|
-
return requestValue.endsWith(String(
|
|
484
|
+
if (criteriaValue.endsWith !== undefined) {
|
|
485
|
+
return requestValue.endsWith(String(criteriaValue.endsWith));
|
|
450
486
|
}
|
|
451
|
-
if (
|
|
452
|
-
|
|
487
|
+
if (criteriaValue.regex !== undefined && isRecord(criteriaValue.regex)) {
|
|
488
|
+
const regex = criteriaValue.regex;
|
|
489
|
+
if (typeof regex.source === "string") {
|
|
490
|
+
return matchesRegex(requestValue, {
|
|
491
|
+
source: regex.source,
|
|
492
|
+
flags: typeof regex.flags === "string" ? regex.flags : undefined,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
453
495
|
}
|
|
454
496
|
return false;
|
|
455
497
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scenario-manager.d.ts","sourceRoot":"","sources":["../../src/domain/scenario-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,eAAe,EACf,YAAY,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AAwC3B;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,GAAI,6DAMnC;IACD,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,KAAK,EAAE,aAAa,CAAC;IACrB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,KAAG,
|
|
1
|
+
{"version":3,"file":"scenario-manager.d.ts","sourceRoot":"","sources":["../../src/domain/scenario-manager.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,gBAAgB,EAChB,aAAa,EACb,eAAe,EACf,YAAY,EACZ,MAAM,EACP,MAAM,mBAAmB,CAAC;AAwC3B;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,qBAAqB,GAAI,6DAMnC;IACD,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,KAAK,EAAE,aAAa,CAAC;IACrB,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,KAAG,eA+HH,CAAC"}
|
|
@@ -40,7 +40,13 @@ export const createScenarioManager = ({ registry, store, stateManager, sequenceT
|
|
|
40
40
|
const validationResult = ScenaristScenarioSchema.safeParse(definition);
|
|
41
41
|
if (!validationResult.success) {
|
|
42
42
|
const errorMessages = validationResult.error.issues.map((err) => `${err.path.join(".")}: ${err.message}`);
|
|
43
|
-
|
|
43
|
+
// Extract id safely without type assertion - definition failed validation so type is untrusted
|
|
44
|
+
const scenarioId = typeof definition === "object" &&
|
|
45
|
+
definition !== null &&
|
|
46
|
+
"id" in definition &&
|
|
47
|
+
typeof definition.id === "string"
|
|
48
|
+
? definition.id
|
|
49
|
+
: "<unknown>";
|
|
44
50
|
throw createScenarioValidationError(scenarioId, errorMessages);
|
|
45
51
|
}
|
|
46
52
|
const existing = registry.get(definition.id);
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
* Applies templates to a value.
|
|
3
3
|
* Replaces {{state.key}} and {{params.key}} patterns with actual values.
|
|
4
4
|
*
|
|
5
|
+
* Note: This function preserves the structure of the input value.
|
|
6
|
+
* Callers passing typed objects (like ScenaristResponse) can safely
|
|
7
|
+
* cast the return value back to the input type.
|
|
8
|
+
*
|
|
5
9
|
* @param value - Value to apply templates to (string, object, array, or primitive)
|
|
6
10
|
* @param templateData - Object containing state and params for template replacement.
|
|
7
11
|
* Can be flat object (backward compatible) or { state: {...}, params: {...} }
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"template-replacement.d.ts","sourceRoot":"","sources":["../../src/domain/template-replacement.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"template-replacement.d.ts","sourceRoot":"","sources":["../../src/domain/template-replacement.ts"],"names":[],"mappings":"AAgBA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,cAAc,GACzB,OAAO,OAAO,EACd,cAAc,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KACpC,OA+DF,CAAC"}
|
|
@@ -1,7 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type guard to check if a value is a plain object (Record).
|
|
3
|
+
* Used to properly narrow types after typeof checks.
|
|
4
|
+
*/
|
|
5
|
+
const isRecord = (value) => {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Security: Check if a property key could cause prototype pollution.
|
|
10
|
+
* @see https://github.com/citypaul/scenarist/security/code-scanning
|
|
11
|
+
*/
|
|
12
|
+
const isDangerousKey = (key) => {
|
|
13
|
+
return key === "__proto__" || key === "constructor" || key === "prototype";
|
|
14
|
+
};
|
|
1
15
|
/**
|
|
2
16
|
* Applies templates to a value.
|
|
3
17
|
* Replaces {{state.key}} and {{params.key}} patterns with actual values.
|
|
4
18
|
*
|
|
19
|
+
* Note: This function preserves the structure of the input value.
|
|
20
|
+
* Callers passing typed objects (like ScenaristResponse) can safely
|
|
21
|
+
* cast the return value back to the input type.
|
|
22
|
+
*
|
|
5
23
|
* @param value - Value to apply templates to (string, object, array, or primitive)
|
|
6
24
|
* @param templateData - Object containing state and params for template replacement.
|
|
7
25
|
* Can be flat object (backward compatible) or { state: {...}, params: {...} }
|
|
@@ -86,10 +104,18 @@ const resolveTemplatePath = (templateData, prefix, path) => {
|
|
|
86
104
|
if (Array.isArray(current) && segment === "length") {
|
|
87
105
|
return current.length;
|
|
88
106
|
}
|
|
89
|
-
// Traverse object
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
107
|
+
// Traverse object - use type guard for proper narrowing
|
|
108
|
+
if (!isRecord(current)) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
// Security: Prevent prototype pollution attacks
|
|
112
|
+
// @see https://github.com/citypaul/scenarist/security/code-scanning/165
|
|
113
|
+
if (isDangerousKey(segment) || !Object.hasOwn(current, segment)) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
// nosemgrep: javascript.lang.security.audit.prototype-pollution.prototype-pollution-loop
|
|
117
|
+
// eslint-disable-next-line security/detect-object-injection -- Segment validated by isDangerousKey and Object.hasOwn checks above
|
|
118
|
+
current = current[segment];
|
|
93
119
|
// Guard: Return undefined if property doesn't exist
|
|
94
120
|
if (current === undefined) {
|
|
95
121
|
return undefined;
|
package/package.json
CHANGED