Skip to content

Commit 3783bb6

Browse files
committed
Smile
1 parent 4eb7bd1 commit 3783bb6

File tree

3 files changed

+316
-0
lines changed

3 files changed

+316
-0
lines changed

features/mi_capability.sml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# This file describes test cases for MSALs
2+
type: MSAL Test
3+
ver: 1
4+
5+
env:
6+
#IDENTITY_ENDPOINT: http://localhost:5000/test/token_sha256_to_refresh/cp2,cp1
7+
IDENTITY_ENDPOINT: https://smile-test.azurewebsites.net/test/token_sha256_to_refresh/cp2,cp1
8+
# Mimic a Service Fabric setup
9+
IDENTITY_HEADER: foo
10+
IDENTITY_SERVER_THUMBPRINT: bar
11+
12+
# Tests are organized in arrange-act-assert pattern
13+
arrange:
14+
# The format is an orderless mapping of variable names and function calls.
15+
app1:
16+
ManagedIdentityClient:
17+
managed_identity: {"ManagedIdentityIdType": "SystemAssigned", "Id": null}
18+
client_capabilities: ["cp2", "cp1"]
19+
20+
steps:
21+
# Each step is a mapping.
22+
# Each mapping contains an "act" key and an optional "assert" key.
23+
24+
- # Populate the token cache with a token
25+
act:
26+
app1.AcquireTokenForManagedIdentity:
27+
resource: R
28+
assert:
29+
# The return value of the action shall be a mapping containing at least the following content
30+
token_type: Bearer # This implies the token acquisition succeeded
31+
token_source: identity_provider
32+
33+
- # Test the next token request should hit cache
34+
act:
35+
app1.AcquireTokenForManagedIdentity:
36+
resource: R
37+
assert:
38+
# The return value of the action shall be a mapping containing at least the following content
39+
token_type: Bearer # This implies the token acquisition succeeded
40+
token_source: cache
41+
42+
- # Test a token request with claims challenge should send a new request
43+
act:
44+
app1.AcquireTokenForManagedIdentity:
45+
resource: R
46+
claims_challenge: foo
47+
assert:
48+
# The return value of the action shall be a mapping containing at least the following content
49+
token_type: Bearer # This implies the token acquisition succeeded
50+
token_source: identity_provider
51+
# Note: This test case does not explicitly assert the token request parameters sent by MSAL. The test lab is expected to validate them.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# This file describes test cases for MSALs
2+
type: MSAL Test
3+
ver: 1
4+
5+
env:
6+
#IDENTITY_ENDPOINT: http://localhost:5000/test/token_sha256_to_refresh
7+
IDENTITY_ENDPOINT: https://smile-test.azurewebsites.net/test/token_sha256_to_refresh
8+
# Mimic a Service Fabric setup
9+
IDENTITY_HEADER: foo
10+
IDENTITY_SERVER_THUMBPRINT: bar
11+
12+
# Tests are organized in arrange-act-assert pattern
13+
arrange:
14+
# The format is an orderless mapping of variable names and function calls.
15+
app1:
16+
ManagedIdentityClient:
17+
managed_identity: {"ManagedIdentityIdType": "SystemAssigned", "Id": null}
18+
19+
steps:
20+
# Each step is a mapping.
21+
# Each mapping contains an "act" key and an optional "assert" key.
22+
23+
- # Populate the token cache with a token
24+
act:
25+
app1.AcquireTokenForManagedIdentity:
26+
resource: R
27+
assert:
28+
# The return value of the action shall be a mapping containing at least the following content
29+
token_type: Bearer # This implies the token acquisition succeeded
30+
token_source: identity_provider
31+
32+
- # Test the next token request should hit cache
33+
act:
34+
app1.AcquireTokenForManagedIdentity:
35+
resource: R
36+
assert:
37+
# The return value of the action shall be a mapping containing at least the following content
38+
token_type: Bearer # This implies the token acquisition succeeded
39+
token_source: cache
40+
41+
- # Test a token request with claims challenge should send a new request
42+
act:
43+
app1.AcquireTokenForManagedIdentity:
44+
resource: R
45+
claims_challenge: foo
46+
assert:
47+
# The return value of the action shall be a mapping containing at least the following content
48+
token_type: Bearer # This implies the token acquisition succeeded
49+
token_source: identity_provider
50+
# Note: This test case does not explicitly assert the token request parameters sent by MSAL. The test lab is expected to validate them.

smile.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
#!/usr/bin/env python3
2+
"""
3+
MSAL Feature Test Runner
4+
Interprets feature.yml files to create and execute test cases using MSAL.
5+
6+
Initially created by the following prompt:
7+
Write a python implementation that can read content from feature.yml, create variables whose names are defined in the "arrange" mapping's keys, and the variables' value are derived from the "arrange" mapping's value; interpret those value as if they are python snippet using MSAL library.
8+
"""
9+
10+
import os
11+
import sys
12+
import logging
13+
from contextlib import contextmanager
14+
from typing import Dict, Any, List, Optional
15+
16+
import yaml
17+
import msal
18+
import requests
19+
20+
21+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
22+
logger = logging.getLogger(__name__)
23+
24+
class SmileTestRunner:
25+
"""Runs MSAL tests defined in a feature.yml file."""
26+
27+
def __init__(self, feature_file: str):
28+
"""Initialize the test runner with a feature file."""
29+
self.feature_file = feature_file
30+
self.test_spec = None
31+
self.variables = {}
32+
33+
def load_feature(self) -> Dict[str, Any]:
34+
"""Load and validate the feature file."""
35+
try:
36+
with open(self.feature_file, 'r') as f:
37+
self.test_spec = yaml.safe_load(f)
38+
39+
# Basic validation
40+
if not isinstance(self.test_spec, dict):
41+
raise ValueError("Feature file must contain a valid YAML dictionary")
42+
43+
if self.test_spec.get('type') != 'MSAL Test':
44+
raise ValueError("Feature file must have type 'MSAL Test'")
45+
46+
return self.test_spec
47+
except Exception as e:
48+
logger.error(f"Error loading feature file: {str(e)}")
49+
sys.exit(1)
50+
51+
@contextmanager
52+
def setup_environment(self):
53+
"""Set up the environment variables specified in the feature file."""
54+
original_env = os.environ.copy()
55+
56+
try:
57+
# Set environment variables
58+
if 'env' in self.test_spec and isinstance(self.test_spec['env'], dict):
59+
for key, value in self.test_spec['env'].items():
60+
os.environ[key] = str(value)
61+
logger.info(f"Set environment variable {key}={value}")
62+
63+
yield
64+
finally:
65+
# Restore original environment
66+
os.environ.clear()
67+
os.environ.update(original_env)
68+
69+
def arrange(self):
70+
"""Create variables based on the arrange section."""
71+
if 'arrange' not in self.test_spec or not self.test_spec['arrange']:
72+
logger.info("No arrangement specified")
73+
return
74+
75+
arrange_spec = self.test_spec['arrange']
76+
for var_name, value_spec in arrange_spec.items():
77+
logger.info(f"Creating variable '{var_name}'")
78+
self.variables[var_name] = self._create_instance(value_spec)
79+
80+
def _create_instance(self, spec: Dict[str, Any]) -> Any:
81+
"""Create an instance based on the specification."""
82+
if not isinstance(spec, dict) or len(spec) != 1:
83+
raise ValueError(f"Invalid specification format: {spec}")
84+
85+
class_name, params = next(iter(spec.items()))
86+
87+
# Handle different MSAL classes
88+
if class_name == "ManagedIdentityClient":
89+
return msal.ManagedIdentityClient(http_client=requests.Session(), **params)
90+
elif class_name == "PublicClientApplication":
91+
return self._create_public_client_app(params)
92+
elif class_name == "ConfidentialClientApplication":
93+
return self._create_confidential_client_app(params)
94+
else:
95+
raise ValueError(f"Unsupported class: {class_name}")
96+
97+
def _create_public_client_app(self, params: Dict[str, Any]) -> Any:
98+
"""Create a PublicClientApplication instance."""
99+
if not params or 'client_id' not in params:
100+
raise ValueError("PublicClientApplication requires client_id")
101+
102+
client_id = params.get('client_id')
103+
authority = params.get('authority')
104+
logger.info(f"Creating PublicClientApplication with client_id: {client_id}, authority: {authority}")
105+
106+
kwargs = {'client_id': client_id}
107+
if authority:
108+
kwargs['authority'] = authority
109+
110+
return msal.PublicClientApplication(**kwargs)
111+
112+
def _create_confidential_client_app(self, params: Dict[str, Any]) -> Any:
113+
"""Create a ConfidentialClientApplication instance."""
114+
if not params or 'client_id' not in params or 'client_credential' not in params:
115+
raise ValueError("ConfidentialClientApplication requires client_id and client_credential")
116+
117+
client_id = params.get('client_id')
118+
client_credential = params.get('client_credential')
119+
authority = params.get('authority')
120+
logger.info(f"Creating ConfidentialClientApplication with client_id: {client_id}, authority: {authority}")
121+
122+
kwargs = {'client_id': client_id, 'client_credential': client_credential}
123+
if authority:
124+
kwargs['authority'] = authority
125+
126+
return msal.ConfidentialClientApplication(**kwargs)
127+
128+
def execute_steps(self):
129+
"""Execute the test steps, returns number of failures."""
130+
if 'steps' not in self.test_spec or not self.test_spec['steps']:
131+
logger.warning("No test steps found")
132+
return
133+
134+
steps = self.test_spec['steps']
135+
passed = 0
136+
137+
for i, step in enumerate(steps):
138+
logger.info(f"Executing step {i+1}/{len(steps)}")
139+
if 'act' in step:
140+
result = self._execute_action(step['act'])
141+
if 'assert' in step:
142+
if self._validate_assertions(result, step['assert']):
143+
passed += 1
144+
logger.info(f"{passed} of {len(steps)} step(s) passed")
145+
146+
def _execute_action(self, act_spec: Dict[str, Any]) -> Any:
147+
"""Execute an action based on the specification."""
148+
if not isinstance(act_spec, dict) or len(act_spec) != 1:
149+
raise ValueError(f"Invalid action specification: {act_spec}")
150+
151+
action_str, params = next(iter(act_spec.items()))
152+
153+
# Parse the action string (e.g., "app1.AcquireToken")
154+
parts = action_str.split('.')
155+
if len(parts) != 2:
156+
raise ValueError(f"Invalid action format: {action_str}")
157+
158+
var_name = parts[0]
159+
method_name = { # Map the method names in yml to actual method names
160+
"AcquireTokenForManagedIdentity": "acquire_token_for_client",
161+
}.get(parts[1])
162+
163+
if method_name is None:
164+
raise ValueError(f"Unsupported method: {parts[1]}")
165+
166+
if var_name not in self.variables:
167+
raise ValueError(f"Variable '{var_name}' not found")
168+
169+
instance = self.variables[var_name]
170+
if not hasattr(instance, method_name):
171+
raise ValueError(f"Method '{method_name}' not found on {var_name}")
172+
173+
method = getattr(instance, method_name)
174+
175+
# Convert parameters to kwargs
176+
kwargs = params if params else {}
177+
178+
# Execute the method with parameters
179+
logger.info(f"Calling {var_name}.{method_name} with {kwargs}")
180+
return method(**kwargs)
181+
182+
def _validate_assertions(self, result: Any, assertions: Dict[str, Any]) -> bool:
183+
"""Validate the assertions against the result."""
184+
logger.info(f"Validating assertions: {assertions}")
185+
for key, expected_value in assertions.items():
186+
if key not in result:
187+
logger.error(f"Assertion failed: '{key}' not found in result {result}")
188+
return False # Failed
189+
actual_value = result[key]
190+
if actual_value != expected_value:
191+
logger.error(f"Assertion failed: expected {key}='{expected_value}', got '{actual_value}'")
192+
return False # Failed
193+
else:
194+
logger.info(f"Assertion passed: {key}='{actual_value}'")
195+
return True # Passed
196+
197+
def run(self):
198+
"""Run the entire test."""
199+
self.load_feature()
200+
201+
with self.setup_environment():
202+
self.arrange()
203+
self.execute_steps()
204+
205+
206+
def main():
207+
if len(sys.argv) != 2:
208+
print(f"Usage: {sys.argv[0]} <feature_file.yml>")
209+
sys.exit(1)
210+
feature_file = sys.argv[1]
211+
SmileTestRunner(feature_file).run()
212+
213+
214+
if __name__ == "__main__":
215+
main()

0 commit comments

Comments
 (0)