@scenarist/core 0.3.1 → 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/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 +31 -15
- 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) {
|
|
@@ -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,
|
|
@@ -139,6 +146,7 @@ export const createResponseSelector = (options = {}) => {
|
|
|
139
146
|
state: currentState,
|
|
140
147
|
params: mockWithParams.params || {},
|
|
141
148
|
};
|
|
149
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- applyTemplates preserves structure; input ScenaristResponse → output ScenaristResponse
|
|
142
150
|
finalResponse = applyTemplates(response, templateData);
|
|
143
151
|
}
|
|
144
152
|
// Apply afterResponse.setState to mutate state for subsequent requests
|
|
@@ -393,15 +401,14 @@ const matchesState = (stateCriteria, testId, stateManager) => {
|
|
|
393
401
|
* Non-string values are converted to strings before matching.
|
|
394
402
|
*/
|
|
395
403
|
const matchesBody = (requestBody, criteriaBody) => {
|
|
396
|
-
// If request has no body, can't match
|
|
397
|
-
if (!requestBody
|
|
404
|
+
// If request has no body, can't match - use type guard for proper narrowing
|
|
405
|
+
if (!isRecord(requestBody)) {
|
|
398
406
|
return false;
|
|
399
407
|
}
|
|
400
|
-
const body = requestBody;
|
|
401
408
|
// Check all required fields exist in request body with matching values
|
|
402
409
|
for (const [key, criteriaValue] of Object.entries(criteriaBody)) {
|
|
403
410
|
// eslint-disable-next-line security/detect-object-injection -- Key from Object.entries iteration
|
|
404
|
-
const requestValue =
|
|
411
|
+
const requestValue = requestBody[key];
|
|
405
412
|
// Convert to string for matching (type coercion like headers/query)
|
|
406
413
|
const stringValue = requestValue == null ? "" : String(requestValue);
|
|
407
414
|
if (!matchesValue(stringValue, criteriaValue)) {
|
|
@@ -461,21 +468,30 @@ const matchesValue = (requestValue, criteriaValue) => {
|
|
|
461
468
|
if (criteriaValue == null) {
|
|
462
469
|
return requestValue === "";
|
|
463
470
|
}
|
|
464
|
-
|
|
465
|
-
if (
|
|
466
|
-
return
|
|
471
|
+
// After ruling out string, RegExp, number, boolean, null - remaining must be object strategy
|
|
472
|
+
if (!isRecord(criteriaValue)) {
|
|
473
|
+
return false;
|
|
467
474
|
}
|
|
468
|
-
if (
|
|
469
|
-
return requestValue
|
|
475
|
+
if (criteriaValue.equals !== undefined) {
|
|
476
|
+
return requestValue === String(criteriaValue.equals);
|
|
470
477
|
}
|
|
471
|
-
if (
|
|
472
|
-
return requestValue.
|
|
478
|
+
if (criteriaValue.contains !== undefined) {
|
|
479
|
+
return requestValue.includes(String(criteriaValue.contains));
|
|
473
480
|
}
|
|
474
|
-
if (
|
|
475
|
-
return requestValue.
|
|
481
|
+
if (criteriaValue.startsWith !== undefined) {
|
|
482
|
+
return requestValue.startsWith(String(criteriaValue.startsWith));
|
|
476
483
|
}
|
|
477
|
-
if (
|
|
478
|
-
return
|
|
484
|
+
if (criteriaValue.endsWith !== undefined) {
|
|
485
|
+
return requestValue.endsWith(String(criteriaValue.endsWith));
|
|
486
|
+
}
|
|
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
|
+
}
|
|
479
495
|
}
|
|
480
496
|
return false;
|
|
481
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