soprano-sdk 0.2.5__tar.gz → 0.2.7__tar.gz
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.
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/PKG-INFO +1 -1
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/pyproject.toml +1 -1
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/authenticators/mfa.py +42 -12
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/core/constants.py +4 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/core/engine.py +15 -2
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/nodes/call_function.py +6 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/tests/test_mfa_scenarios.py +381 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/.github/workflows/test_build_and_publish.yaml +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/.gitignore +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/.python-version +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/CLAUDE.md +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/LICENSE +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/README.md +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/concert_booking/__init__.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/concert_booking/booking_helpers.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/concert_booking/concert_ticket_booking.yaml +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/framework_example.yaml +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/greeting_functions.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/greeting_workflow.yaml +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/main.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/persistence/README.md +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/persistence/conversation_based.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/persistence/entity_based.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/persistence/mongodb_demo.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/return_functions.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/return_workflow.yaml +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/structured_output_example.yaml +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/supervisors/README.md +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/supervisors/crewai_supervisor_ui.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/supervisors/langgraph_supervisor_ui.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/supervisors/tools/__init__.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/supervisors/tools/crewai_tools.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/supervisors/tools/langgraph_tools.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/supervisors/workflow_tools.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/tools/__init__.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/tools/address.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/validator.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/legacy/langgraph_demo.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/legacy/langgraph_selfloop_demo.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/legacy/langgraph_v.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/legacy/main.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/legacy/return_fsm.excalidraw +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/legacy/return_state_machine.png +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/legacy/ui.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/scripts/visualize_workflow.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/scripts/workflow_demo.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/scripts/workflow_demo_ui.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/__init__.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/agents/__init__.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/agents/adaptor.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/agents/factory.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/agents/structured_output.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/authenticators/__init__.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/core/__init__.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/core/rollback_strategies.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/core/state.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/engine.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/nodes/__init__.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/nodes/async_function.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/nodes/base.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/nodes/collect_input.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/nodes/factory.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/routing/__init__.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/routing/router.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/tools.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/utils/__init__.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/utils/function.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/utils/logger.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/utils/template.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/utils/tool.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/utils/tracing.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/validation/__init__.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/validation/schema.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/soprano_sdk/validation/validator.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/tests/debug_jinja2.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/tests/test_agent_factory.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/tests/test_async_function.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/tests/test_collect_input_refactor.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/tests/test_external_values.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/tests/test_inputs_validation.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/tests/test_jinja2_path.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/tests/test_jinja2_standalone.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/tests/test_persistence.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/tests/test_structured_output.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/tests/test_transition_routing.py +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/todo.md +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/uv.lock +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/workflow-visualizer/.eslintrc.cjs +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/workflow-visualizer/.gitignore +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/workflow-visualizer/README.md +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/workflow-visualizer/index.html +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/workflow-visualizer/package-lock.json +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/workflow-visualizer/package.json +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/workflow-visualizer/src/App.jsx +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/workflow-visualizer/src/CustomNode.jsx +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/workflow-visualizer/src/StepDetailsModal.jsx +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/workflow-visualizer/src/WorkflowGraph.jsx +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/workflow-visualizer/src/WorkflowInfoPanel.jsx +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/workflow-visualizer/src/assets/react.svg +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/workflow-visualizer/src/main.jsx +0 -0
- {soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/workflow-visualizer/vite.config.js +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "soprano-sdk"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.7"
|
|
8
8
|
description = "YAML-driven workflow engine with AI agent integration for building conversational SOPs"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -10,6 +10,7 @@ class MFAChallenge(TypedDict):
|
|
|
10
10
|
class MFAState(TypedDict):
|
|
11
11
|
challengeType: Literal['OTP', 'dob']
|
|
12
12
|
post_payload: dict[str, str]
|
|
13
|
+
post_headers: NotRequired[dict[str, str]]
|
|
13
14
|
otpValue: NotRequired[str]
|
|
14
15
|
status: Literal['IN_PROGRESS', 'COMPLETED', 'ERRORED', 'FAILED'] | None
|
|
15
16
|
message: str
|
|
@@ -35,6 +36,10 @@ def enforce_mfa_if_required(state: dict, mfa_config: Optional[MFAConfig] = None)
|
|
|
35
36
|
_mfa : MFAState = state['_mfa']
|
|
36
37
|
if _mfa['status'] == 'COMPLETED':
|
|
37
38
|
return True
|
|
39
|
+
|
|
40
|
+
# Use custom headers if provided, otherwise empty dict
|
|
41
|
+
headers = _mfa.get('post_headers', {})
|
|
42
|
+
|
|
38
43
|
generate_token_response = requests.post(
|
|
39
44
|
build_path(
|
|
40
45
|
base_url=mfa_config.generate_token_base_url,
|
|
@@ -42,7 +47,7 @@ def enforce_mfa_if_required(state: dict, mfa_config: Optional[MFAConfig] = None)
|
|
|
42
47
|
),
|
|
43
48
|
json=_mfa['post_payload'],
|
|
44
49
|
timeout=mfa_config.api_timeout,
|
|
45
|
-
headers=
|
|
50
|
+
headers=headers
|
|
46
51
|
)
|
|
47
52
|
_, error = get_response(generate_token_response)
|
|
48
53
|
|
|
@@ -65,6 +70,9 @@ def mfa_validate_user_input(mfa_config: Optional[MFAConfig] = None, **state: dic
|
|
|
65
70
|
if not state[input_field_name]:
|
|
66
71
|
return False
|
|
67
72
|
|
|
73
|
+
# Use custom headers if provided, otherwise empty dict
|
|
74
|
+
headers = _mfa.get('post_headers', {})
|
|
75
|
+
|
|
68
76
|
post_payload = _mfa['post_payload']
|
|
69
77
|
challenge_field_name = f"{_mfa['challengeType'].lower()}Challenge"
|
|
70
78
|
post_payload.update({challenge_field_name: {"value": state[input_field_name]}})
|
|
@@ -75,7 +83,7 @@ def mfa_validate_user_input(mfa_config: Optional[MFAConfig] = None, **state: dic
|
|
|
75
83
|
),
|
|
76
84
|
json=post_payload,
|
|
77
85
|
timeout=mfa_config.api_timeout,
|
|
78
|
-
headers=
|
|
86
|
+
headers=headers
|
|
79
87
|
)
|
|
80
88
|
_mfa['retry_count'] += 1
|
|
81
89
|
response, error = get_response(validate_token_response)
|
|
@@ -95,7 +103,7 @@ def mfa_validate_user_input(mfa_config: Optional[MFAConfig] = None, **state: dic
|
|
|
95
103
|
),
|
|
96
104
|
json=post_payload,
|
|
97
105
|
timeout=mfa_config.api_timeout,
|
|
98
|
-
headers=
|
|
106
|
+
headers=headers
|
|
99
107
|
)
|
|
100
108
|
if authorize.status_code == 204:
|
|
101
109
|
_mfa['status'] = 'COMPLETED'
|
|
@@ -146,25 +154,47 @@ class MFANodeConfig:
|
|
|
146
154
|
model=model_name,
|
|
147
155
|
initial_message="{{_mfa.message}}",
|
|
148
156
|
instructions="""
|
|
149
|
-
You are an authentication value extractor. Your job is to identify and extract MFA codes from user input.
|
|
157
|
+
You are an authentication value extractor. Your job is to identify and extract MFA codes from user input, or detect if the user wants to cancel the authentication flow.
|
|
150
158
|
|
|
151
159
|
**Task:**
|
|
152
|
-
- Read the user's message
|
|
153
|
-
-
|
|
154
|
-
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
160
|
+
- Read the user's message carefully
|
|
161
|
+
- First, check if the user wants to cancel, stop, or exit the authentication process
|
|
162
|
+
- If they want to cancel, output: MFA_CANCELLED:
|
|
163
|
+
- Otherwise, extract ONLY the OTP/MFA code value and output in the format shown below
|
|
164
|
+
|
|
165
|
+
**Cancellation Detection:**
|
|
166
|
+
If the user expresses any intent to cancel, stop, exit, abort, or quit the authentication process, respond with: MFA_CANCELLED
|
|
167
|
+
|
|
168
|
+
Examples of cancellation phrases:
|
|
169
|
+
* "cancel" → MFA_CANCELLED:
|
|
170
|
+
* "I want to stop" → MFA_CANCELLED:
|
|
171
|
+
* "exit" → MFA_CANCELLED:
|
|
172
|
+
* "nevermind" → MFA_CANCELLED:
|
|
173
|
+
* "I don't want to continue" → MFA_CANCELLED:
|
|
174
|
+
* "stop this" → MFA_CANCELLED:
|
|
175
|
+
* "forget it" → MFA_CANCELLED:
|
|
176
|
+
* "abort" → MFA_CANCELLED:
|
|
177
|
+
* "quit" → MFA_CANCELLED:
|
|
178
|
+
|
|
179
|
+
**OTP Capture Examples:**
|
|
180
|
+
* "1234" → MFA_CAPTURED:1234
|
|
181
|
+
* "2345e" → MFA_CAPTURED:2345e
|
|
182
|
+
* "the code is 567890" → MFA_CAPTURED:567890
|
|
183
|
+
* "my otp is 123456" → MFA_CAPTURED:123456
|
|
159
184
|
|
|
160
185
|
**Output Format:**
|
|
161
|
-
MFA_CAPTURED:<
|
|
186
|
+
- For OTP/MFA codes: MFA_CAPTURED:<otp_value>
|
|
187
|
+
- For cancellation: MFA_CANCELLED:
|
|
162
188
|
|
|
163
189
|
"""),
|
|
164
190
|
transitions=[
|
|
165
191
|
dict(
|
|
166
192
|
pattern="MFA_CAPTURED:",
|
|
167
193
|
next=next_node
|
|
194
|
+
),
|
|
195
|
+
dict(
|
|
196
|
+
pattern="MFA_CANCELLED:",
|
|
197
|
+
next="mfa_cancelled"
|
|
168
198
|
)
|
|
169
199
|
]
|
|
170
200
|
)
|
|
@@ -114,6 +114,10 @@ class MFAConfig(BaseSettings):
|
|
|
114
114
|
default=30,
|
|
115
115
|
description="API request timeout in seconds"
|
|
116
116
|
)
|
|
117
|
+
mfa_cancelled_message: str = Field(
|
|
118
|
+
default="Authentication has been cancelled.",
|
|
119
|
+
description="Message to display when user cancels MFA authentication"
|
|
120
|
+
)
|
|
117
121
|
|
|
118
122
|
model_config = SettingsConfigDict(
|
|
119
123
|
case_sensitive=False,
|
|
@@ -39,8 +39,7 @@ class WorkflowEngine:
|
|
|
39
39
|
self.step_map = {step['id']: step for step in self.steps}
|
|
40
40
|
self.mfa_config = (mfa_config or MFAConfig()) if self.mfa_validator_steps else None
|
|
41
41
|
self.data_fields = self.load_data()
|
|
42
|
-
|
|
43
|
-
self.outcomes = self.config['outcomes']
|
|
42
|
+
self.outcomes = self.load_outcomes()
|
|
44
43
|
self.metadata = self.config.get('metadata', {})
|
|
45
44
|
|
|
46
45
|
self.StateType = create_state_model(self.data_fields)
|
|
@@ -260,6 +259,20 @@ class WorkflowEngine:
|
|
|
260
259
|
)
|
|
261
260
|
return data
|
|
262
261
|
|
|
262
|
+
def load_outcomes(self):
|
|
263
|
+
outcomes: list = self.config['outcomes']
|
|
264
|
+
|
|
265
|
+
if self.mfa_config:
|
|
266
|
+
mfa_cancelled_outcome = {
|
|
267
|
+
'id': 'mfa_cancelled',
|
|
268
|
+
'type': 'failure',
|
|
269
|
+
'message': self.mfa_config.mfa_cancelled_message
|
|
270
|
+
}
|
|
271
|
+
outcomes.append(mfa_cancelled_outcome)
|
|
272
|
+
logger.info(f"Auto-generated 'mfa_cancelled' outcome with message: {self.mfa_config.mfa_cancelled_message}")
|
|
273
|
+
|
|
274
|
+
return outcomes
|
|
275
|
+
|
|
263
276
|
|
|
264
277
|
def load_workflow(yaml_path: str, checkpointer=None, config=None, mfa_config: Optional[MFAConfig] = None) -> Tuple[CompiledStateGraph, WorkflowEngine]:
|
|
265
278
|
"""
|
|
@@ -39,11 +39,17 @@ class CallFunctionStrategy(ActionStrategy):
|
|
|
39
39
|
if 'mfa' in self.step_config:
|
|
40
40
|
state['_mfa'] = state.get('_mfa', {})
|
|
41
41
|
state['_mfa']['post_payload'] = dict(transactionId=str(uuid.uuid4()))
|
|
42
|
+
state['_mfa']['post_headers'] = {}
|
|
42
43
|
state['_mfa_config'] = self.engine_context.mfa_config
|
|
43
44
|
template_loader = self.engine_context.get_config_value("template_loader", Environment())
|
|
44
45
|
for k, v in self.step_config['mfa']['payload'].items():
|
|
45
46
|
state['_mfa']['post_payload'][k] = compile_values(template_loader, state, v)
|
|
46
47
|
|
|
48
|
+
# Process headers if provided
|
|
49
|
+
if 'headers' in self.step_config['mfa']:
|
|
50
|
+
for k, v in self.step_config['mfa']['headers'].items():
|
|
51
|
+
state['_mfa']['post_headers'][k] = compile_values(template_loader, state, v)
|
|
52
|
+
|
|
47
53
|
def execute(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
|
48
54
|
from ..utils.tracing import trace_node_execution
|
|
49
55
|
|
|
@@ -416,3 +416,384 @@ def test_mfa_comprehensive_flow():
|
|
|
416
416
|
|
|
417
417
|
print("\n✅ TEST PASSED: Comprehensive flow validation successful")
|
|
418
418
|
print("=" * 80)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def test_mfa_default_max_attempts():
|
|
422
|
+
"""
|
|
423
|
+
Test Case 8: Verify default max_attempts value
|
|
424
|
+
|
|
425
|
+
Validates that:
|
|
426
|
+
- MFA collector nodes use default max_attempts value of 3 when not specified
|
|
427
|
+
"""
|
|
428
|
+
print("\n" + "=" * 80)
|
|
429
|
+
print("TEST 8: MFA Default Max Attempts")
|
|
430
|
+
print("=" * 80)
|
|
431
|
+
|
|
432
|
+
yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
|
|
433
|
+
graph, engine = load_workflow(yaml_path)
|
|
434
|
+
|
|
435
|
+
print("\nScenario: MFA config does not specify max_attempts")
|
|
436
|
+
print("Expected: Default value of 3 should be used")
|
|
437
|
+
|
|
438
|
+
# Get MFA validate nodes
|
|
439
|
+
mfa_validate_nodes = [s for s in engine.list_steps() if '_mfa_validate' in s]
|
|
440
|
+
print(f"\nMFA validate nodes: {mfa_validate_nodes}")
|
|
441
|
+
|
|
442
|
+
for node_id in mfa_validate_nodes:
|
|
443
|
+
node_info = engine.get_step_info(node_id)
|
|
444
|
+
max_attempts = node_info.get('max_attempts', 'NOT_SET')
|
|
445
|
+
print(f"\n{node_id}")
|
|
446
|
+
print(f" max_attempts: {max_attempts}")
|
|
447
|
+
|
|
448
|
+
assert max_attempts == 3, \
|
|
449
|
+
f"Expected default max_attempts=3, got {max_attempts}"
|
|
450
|
+
print(" ✅ Correctly uses default max_attempts=3")
|
|
451
|
+
|
|
452
|
+
print("\n✅ TEST PASSED: Default max_attempts value verified")
|
|
453
|
+
print("=" * 80)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def test_mfa_custom_max_attempts():
|
|
457
|
+
"""
|
|
458
|
+
Test Case 9: Verify custom max_attempts configuration
|
|
459
|
+
|
|
460
|
+
Validates that:
|
|
461
|
+
- Custom max_attempts value from MFA config is applied to collector node
|
|
462
|
+
"""
|
|
463
|
+
print("\n" + "=" * 80)
|
|
464
|
+
print("TEST 9: MFA Custom Max Attempts")
|
|
465
|
+
print("=" * 80)
|
|
466
|
+
|
|
467
|
+
# Create a temporary YAML with custom max_attempts
|
|
468
|
+
import tempfile
|
|
469
|
+
|
|
470
|
+
yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
|
|
471
|
+
|
|
472
|
+
with open(yaml_path, 'r') as f:
|
|
473
|
+
yaml_content = f.read()
|
|
474
|
+
|
|
475
|
+
# Add max_attempts to first MFA config
|
|
476
|
+
modified_yaml = yaml_content.replace(
|
|
477
|
+
""" mfa:
|
|
478
|
+
model: gpt-4o-mini
|
|
479
|
+
type: REST
|
|
480
|
+
payload:
|
|
481
|
+
transactionType: CONCERT_TICKET_PAYMENT""",
|
|
482
|
+
""" mfa:
|
|
483
|
+
model: gpt-4o-mini
|
|
484
|
+
type: REST
|
|
485
|
+
max_attempts: 5
|
|
486
|
+
payload:
|
|
487
|
+
transactionType: CONCERT_TICKET_PAYMENT"""
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# Write to temporary file
|
|
491
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
|
492
|
+
temp_yaml_path = f.name
|
|
493
|
+
f.write(modified_yaml)
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
graph, engine = load_workflow(temp_yaml_path)
|
|
497
|
+
|
|
498
|
+
print("\nScenario: MFA config specifies max_attempts: 5")
|
|
499
|
+
|
|
500
|
+
# Find the process_payment MFA validate node
|
|
501
|
+
process_payment_validate = 'process_payment_mfa_validate'
|
|
502
|
+
assert process_payment_validate in engine.list_steps(), \
|
|
503
|
+
f"Expected {process_payment_validate} node to exist"
|
|
504
|
+
|
|
505
|
+
node_info = engine.get_step_info(process_payment_validate)
|
|
506
|
+
max_attempts = node_info.get('max_attempts')
|
|
507
|
+
|
|
508
|
+
print(f"\nNode: {process_payment_validate}")
|
|
509
|
+
print(f" max_attempts: {max_attempts}")
|
|
510
|
+
|
|
511
|
+
assert max_attempts == 5, \
|
|
512
|
+
f"Expected custom max_attempts=5, got {max_attempts}"
|
|
513
|
+
print(" ✅ Correctly uses custom max_attempts=5")
|
|
514
|
+
|
|
515
|
+
print("\n✅ TEST PASSED: Custom max_attempts value applied correctly")
|
|
516
|
+
print("=" * 80)
|
|
517
|
+
finally:
|
|
518
|
+
# Clean up temporary file
|
|
519
|
+
os.unlink(temp_yaml_path)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def test_mfa_custom_error_message():
|
|
523
|
+
"""
|
|
524
|
+
Test Case 10: Verify custom on_max_attempts_reached message
|
|
525
|
+
|
|
526
|
+
Validates that:
|
|
527
|
+
- Custom error message from MFA config is applied to collector node
|
|
528
|
+
"""
|
|
529
|
+
print("\n" + "=" * 80)
|
|
530
|
+
print("TEST 10: MFA Custom Error Message")
|
|
531
|
+
print("=" * 80)
|
|
532
|
+
|
|
533
|
+
# Create a temporary YAML with custom error message
|
|
534
|
+
import tempfile
|
|
535
|
+
|
|
536
|
+
yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
|
|
537
|
+
|
|
538
|
+
with open(yaml_path, 'r') as f:
|
|
539
|
+
yaml_content = f.read()
|
|
540
|
+
|
|
541
|
+
custom_error = "You have exceeded the maximum MFA attempts for payment verification. Your transaction has been blocked for security. Please contact support at 1-800-TICKETS."
|
|
542
|
+
|
|
543
|
+
# Add on_max_attempts_reached to first MFA config
|
|
544
|
+
modified_yaml = yaml_content.replace(
|
|
545
|
+
""" mfa:
|
|
546
|
+
model: gpt-4o-mini
|
|
547
|
+
type: REST
|
|
548
|
+
payload:
|
|
549
|
+
transactionType: CONCERT_TICKET_PAYMENT""",
|
|
550
|
+
f""" mfa:
|
|
551
|
+
model: gpt-4o-mini
|
|
552
|
+
type: REST
|
|
553
|
+
on_max_attempts_reached: "{custom_error}"
|
|
554
|
+
payload:
|
|
555
|
+
transactionType: CONCERT_TICKET_PAYMENT"""
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# Write to temporary file
|
|
559
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
|
560
|
+
temp_yaml_path = f.name
|
|
561
|
+
f.write(modified_yaml)
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
graph, engine = load_workflow(temp_yaml_path)
|
|
565
|
+
|
|
566
|
+
print("\nScenario: MFA config specifies custom on_max_attempts_reached message")
|
|
567
|
+
|
|
568
|
+
# Find the process_payment MFA validate node
|
|
569
|
+
process_payment_validate = 'process_payment_mfa_validate'
|
|
570
|
+
assert process_payment_validate in engine.list_steps(), \
|
|
571
|
+
f"Expected {process_payment_validate} node to exist"
|
|
572
|
+
|
|
573
|
+
node_info = engine.get_step_info(process_payment_validate)
|
|
574
|
+
error_message = node_info.get('on_max_attempts_reached')
|
|
575
|
+
|
|
576
|
+
print(f"\nNode: {process_payment_validate}")
|
|
577
|
+
print(f" on_max_attempts_reached: {error_message}")
|
|
578
|
+
|
|
579
|
+
assert error_message == custom_error, \
|
|
580
|
+
f"Expected custom error message, got {error_message}"
|
|
581
|
+
print(" ✅ Correctly uses custom error message")
|
|
582
|
+
|
|
583
|
+
print("\n✅ TEST PASSED: Custom error message applied correctly")
|
|
584
|
+
print("=" * 80)
|
|
585
|
+
finally:
|
|
586
|
+
# Clean up temporary file
|
|
587
|
+
os.unlink(temp_yaml_path)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def test_mfa_custom_max_attempts_and_error():
|
|
591
|
+
"""
|
|
592
|
+
Test Case 11: Verify both custom max_attempts and on_max_attempts_reached together
|
|
593
|
+
|
|
594
|
+
Validates that:
|
|
595
|
+
- Both custom max_attempts and error message can be configured together
|
|
596
|
+
- Both values are correctly applied to the MFA collector node
|
|
597
|
+
"""
|
|
598
|
+
print("\n" + "=" * 80)
|
|
599
|
+
print("TEST 11: MFA Custom Max Attempts and Error Message Together")
|
|
600
|
+
print("=" * 80)
|
|
601
|
+
|
|
602
|
+
# Create a temporary YAML with both custom values
|
|
603
|
+
import tempfile
|
|
604
|
+
|
|
605
|
+
yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
|
|
606
|
+
|
|
607
|
+
with open(yaml_path, 'r') as f:
|
|
608
|
+
yaml_content = f.read()
|
|
609
|
+
|
|
610
|
+
custom_error = "Maximum verification attempts exceeded. Account locked."
|
|
611
|
+
custom_max_attempts = 2
|
|
612
|
+
|
|
613
|
+
# Add both fields to MFA config
|
|
614
|
+
modified_yaml = yaml_content.replace(
|
|
615
|
+
""" mfa:
|
|
616
|
+
model: gpt-4o-mini
|
|
617
|
+
type: REST
|
|
618
|
+
payload:
|
|
619
|
+
transactionType: CONCERT_TICKET_PAYMENT""",
|
|
620
|
+
f""" mfa:
|
|
621
|
+
model: gpt-4o-mini
|
|
622
|
+
type: REST
|
|
623
|
+
max_attempts: {custom_max_attempts}
|
|
624
|
+
on_max_attempts_reached: "{custom_error}"
|
|
625
|
+
payload:
|
|
626
|
+
transactionType: CONCERT_TICKET_PAYMENT"""
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
# Write to temporary file
|
|
630
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
|
631
|
+
temp_yaml_path = f.name
|
|
632
|
+
f.write(modified_yaml)
|
|
633
|
+
|
|
634
|
+
try:
|
|
635
|
+
graph, engine = load_workflow(temp_yaml_path)
|
|
636
|
+
|
|
637
|
+
print(f"\nScenario: MFA config specifies max_attempts: {custom_max_attempts} and custom error")
|
|
638
|
+
|
|
639
|
+
# Find the process_payment MFA validate node
|
|
640
|
+
process_payment_validate = 'process_payment_mfa_validate'
|
|
641
|
+
assert process_payment_validate in engine.list_steps(), \
|
|
642
|
+
f"Expected {process_payment_validate} node to exist"
|
|
643
|
+
|
|
644
|
+
node_info = engine.get_step_info(process_payment_validate)
|
|
645
|
+
max_attempts = node_info.get('max_attempts')
|
|
646
|
+
error_message = node_info.get('on_max_attempts_reached')
|
|
647
|
+
|
|
648
|
+
print(f"\nNode: {process_payment_validate}")
|
|
649
|
+
print(f" max_attempts: {max_attempts}")
|
|
650
|
+
print(f" on_max_attempts_reached: {error_message}")
|
|
651
|
+
|
|
652
|
+
assert max_attempts == custom_max_attempts, \
|
|
653
|
+
f"Expected max_attempts={custom_max_attempts}, got {max_attempts}"
|
|
654
|
+
print(f" ✅ Correctly uses custom max_attempts={custom_max_attempts}")
|
|
655
|
+
|
|
656
|
+
assert error_message == custom_error, \
|
|
657
|
+
f"Expected custom error message, got {error_message}"
|
|
658
|
+
print(" ✅ Correctly uses custom error message")
|
|
659
|
+
|
|
660
|
+
print("\n✅ TEST PASSED: Both custom values applied correctly")
|
|
661
|
+
print("=" * 80)
|
|
662
|
+
finally:
|
|
663
|
+
# Clean up temporary file
|
|
664
|
+
os.unlink(temp_yaml_path)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def test_mfa_custom_headers_with_jinja():
|
|
668
|
+
"""
|
|
669
|
+
Test Case 12: Verify custom headers with Jinja template rendering
|
|
670
|
+
|
|
671
|
+
Validates that:
|
|
672
|
+
- Custom headers can be specified in MFA config
|
|
673
|
+
- Jinja templates in header values are correctly rendered with state data
|
|
674
|
+
- Headers are stored in the MFA state as post_headers
|
|
675
|
+
"""
|
|
676
|
+
print("\n" + "=" * 80)
|
|
677
|
+
print("TEST 12: MFA Custom Headers with Jinja Template Rendering")
|
|
678
|
+
print("=" * 80)
|
|
679
|
+
|
|
680
|
+
# Create a temporary YAML with custom headers
|
|
681
|
+
import tempfile
|
|
682
|
+
|
|
683
|
+
yaml_path = os.path.join(get_examples_dir(), "concert_ticket_booking.yaml")
|
|
684
|
+
|
|
685
|
+
with open(yaml_path, 'r') as f:
|
|
686
|
+
yaml_content = f.read()
|
|
687
|
+
|
|
688
|
+
# Add headers to first MFA config
|
|
689
|
+
modified_yaml = yaml_content.replace(
|
|
690
|
+
""" mfa:
|
|
691
|
+
model: gpt-4o-mini
|
|
692
|
+
type: REST
|
|
693
|
+
payload:
|
|
694
|
+
transactionType: CONCERT_TICKET_PAYMENT
|
|
695
|
+
businessKey:
|
|
696
|
+
customerName: "{{customer_name}}"
|
|
697
|
+
concertName: "{{concert_name}}"
|
|
698
|
+
seatPreference: "{{seat_preference}}"
|
|
699
|
+
ticketQuantity: "{{ticket_quantity}}"
|
|
700
|
+
transitions:""",
|
|
701
|
+
""" mfa:
|
|
702
|
+
model: gpt-4o-mini
|
|
703
|
+
type: REST
|
|
704
|
+
payload:
|
|
705
|
+
transactionType: CONCERT_TICKET_PAYMENT
|
|
706
|
+
businessKey:
|
|
707
|
+
customerName: "{{customer_name}}"
|
|
708
|
+
concertName: "{{concert_name}}"
|
|
709
|
+
seatPreference: "{{seat_preference}}"
|
|
710
|
+
ticketQuantity: "{{ticket_quantity}}"
|
|
711
|
+
headers:
|
|
712
|
+
Authorization: "Bearer {{bearer_token}}"
|
|
713
|
+
X-Customer-Name: "{{customer_name}}"
|
|
714
|
+
X-Concert-Name: "{{concert_name}}"
|
|
715
|
+
X-Request-Id: "payment-{{booking_reference}}"
|
|
716
|
+
transitions:"""
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
# Write to temporary file
|
|
720
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
|
|
721
|
+
temp_yaml_path = f.name
|
|
722
|
+
f.write(modified_yaml)
|
|
723
|
+
|
|
724
|
+
try:
|
|
725
|
+
graph, engine = load_workflow(temp_yaml_path)
|
|
726
|
+
|
|
727
|
+
print("\nScenario: MFA config includes custom headers with Jinja templates")
|
|
728
|
+
|
|
729
|
+
# Initialize state with test data
|
|
730
|
+
state = {
|
|
731
|
+
'bearer_token': 'test-token-12345',
|
|
732
|
+
'customer_name': 'John Doe',
|
|
733
|
+
'concert_name': 'Rock Concert 2026',
|
|
734
|
+
'booking_reference': 'BK-001',
|
|
735
|
+
'seat_preference': 'VIP',
|
|
736
|
+
'ticket_quantity': 2
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
# Find the process_payment step
|
|
740
|
+
process_payment_step = 'process_payment'
|
|
741
|
+
assert process_payment_step in engine.list_steps(), \
|
|
742
|
+
f"Expected {process_payment_step} step to exist"
|
|
743
|
+
|
|
744
|
+
# Check the MFA start node - this is where the MFA config is stored
|
|
745
|
+
process_payment_mfa_start = 'process_payment_mfa_start'
|
|
746
|
+
assert process_payment_mfa_start in engine.list_steps(), \
|
|
747
|
+
f"Expected {process_payment_mfa_start} node to exist"
|
|
748
|
+
|
|
749
|
+
mfa_start_info = engine.get_step_info(process_payment_mfa_start)
|
|
750
|
+
|
|
751
|
+
# Verify headers are defined in MFA config
|
|
752
|
+
assert 'mfa' in mfa_start_info, "MFA start node should have MFA configuration"
|
|
753
|
+
assert 'headers' in mfa_start_info['mfa'], "MFA config should have headers"
|
|
754
|
+
|
|
755
|
+
headers_config = mfa_start_info['mfa']['headers']
|
|
756
|
+
print("\nHeaders defined in MFA config:")
|
|
757
|
+
for key, value in headers_config.items():
|
|
758
|
+
print(f" {key}: {value}")
|
|
759
|
+
|
|
760
|
+
# Verify header templates
|
|
761
|
+
assert headers_config['Authorization'] == "Bearer {{bearer_token}}", \
|
|
762
|
+
"Authorization header should have bearer_token template"
|
|
763
|
+
assert headers_config['X-Customer-Name'] == "{{customer_name}}", \
|
|
764
|
+
"X-Customer-Name header should have customer_name template"
|
|
765
|
+
assert headers_config['X-Concert-Name'] == "{{concert_name}}", \
|
|
766
|
+
"X-Concert-Name header should have concert_name template"
|
|
767
|
+
assert headers_config['X-Request-Id'] == "payment-{{booking_reference}}", \
|
|
768
|
+
"X-Request-Id header should have booking_reference template"
|
|
769
|
+
|
|
770
|
+
print("\n✅ Headers configuration verified")
|
|
771
|
+
|
|
772
|
+
# Test that headers would be rendered correctly (simulation)
|
|
773
|
+
from jinja2 import Environment
|
|
774
|
+
template_loader = Environment()
|
|
775
|
+
|
|
776
|
+
expected_rendered_headers = {
|
|
777
|
+
'Authorization': 'Bearer test-token-12345',
|
|
778
|
+
'X-Customer-Name': 'John Doe',
|
|
779
|
+
'X-Concert-Name': 'Rock Concert 2026',
|
|
780
|
+
'X-Request-Id': 'payment-BK-001'
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
print("\nExpected rendered headers:")
|
|
784
|
+
for key, value in expected_rendered_headers.items():
|
|
785
|
+
print(f" {key}: {value}")
|
|
786
|
+
|
|
787
|
+
# Verify template rendering logic
|
|
788
|
+
for key, template_str in headers_config.items():
|
|
789
|
+
rendered = template_loader.from_string(template_str).render(state)
|
|
790
|
+
expected = expected_rendered_headers[key]
|
|
791
|
+
assert rendered == expected, \
|
|
792
|
+
f"Header {key} rendering mismatch: expected '{expected}', got '{rendered}'"
|
|
793
|
+
print(f" ✅ {key} renders correctly")
|
|
794
|
+
|
|
795
|
+
print("\n✅ TEST PASSED: Custom headers with Jinja templates work correctly")
|
|
796
|
+
print("=" * 80)
|
|
797
|
+
finally:
|
|
798
|
+
# Clean up temporary file
|
|
799
|
+
os.unlink(temp_yaml_path)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{soprano_sdk-0.2.5 → soprano_sdk-0.2.7}/examples/concert_booking/concert_ticket_booking.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|