diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java new file mode 100644 index 00000000..cd792552 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerHelper.java @@ -0,0 +1,217 @@ +package com.microsoft.aad.msal4j; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.microsoft.aad.msal4j.Shortcuts.TestConfig; +import com.microsoft.aad.msal4j.Shortcuts.TestObject; +import com.microsoft.aad.msal4j.Shortcuts.TestAction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class RunnerHelper { + private static final Logger LOG = LoggerFactory.getLogger(RunnerHelper.class); + + /** + * Create Managed Identity applications from the test configuration. + * This method processes the "arrange" section of the test configuration. + */ + static Map createAppsFromConfig(TestConfig config) { + Map apps = new HashMap<>(); + + for (String appName : config.getAllArrangeObjects()) { + TestObject appObject = config.getArrangeObject(appName); + if ("ManagedIdentityClient".equals(appObject.getType())) { + ManagedIdentityId identityId = createManagedIdentityId(appObject); + List capabilities = extractClientCapabilities(appObject); + IEnvironmentVariables envVars = setEnvironmentVariables(config); + // TODO: other application properties + + ManagedIdentityApplication app = ManagedIdentityApplication.builder(identityId) + .clientCapabilities(capabilities) + .build(); + + ManagedIdentityApplication.setEnvironmentVariables(envVars); + + apps.put(appName, app); + } //TODO: Confidential and public clients + } + + return apps; + } + + /** + * Execute an action and return the result + * This method uses the "act" section of the test configuration. + */ + static IAuthenticationResult executeAction(ManagedIdentityApplication app, TestAction action) throws Exception { + if (action.getMethodName().equals("AcquireTokenForManagedIdentity")) { + LOG.info(String.format("===Executing action: %s", action.getMethodName())); + ManagedIdentityParameters params = buildManagedIdentityParameters(action); + IAuthenticationResult result = app.acquireTokenForManagedIdentity(params).get(); + + LOG.info("---Action result"); + LOG.info(String.format("-Access Token: %s", result.accessToken())); + LOG.info(String.format("-ID Token : %s", result.idToken())); + LOG.info(String.format("-Account : %s", result.account())); + LOG.info(String.format("-Token Source: %s", result.metadata().tokenSource())); + + return result; + } else { + //TODO: other token calls and confidential/public client apps + throw new UnsupportedOperationException("Unsupported action: " + action.getMethodName()); + } + } + + /** + * Validate assertions against a result. + * This method uses the "assert" section of the test configuration. + */ + static void validateAssertions(IAuthenticationResult result, Map assertions) { + assertions.forEach((key, value) -> { + switch (key) { + case "token_source": + LOG.info("===Validating token source"); + validateTokenSource(value.asText(), result); + break; + //TODO: other assertions, such as exceptions checks, token content, etc. + default: + // Optional: Handle unknown assertion types + break; + } + }); + } + + /** + * Create managed identity ID from test object + */ + static ManagedIdentityId createManagedIdentityId(TestObject appObject) { + JsonNode managedIdentityNode = appObject.getProperty("managed_identity"); + String idType = managedIdentityNode.get("ManagedIdentityIdType").asText(); + + switch (idType) { + case "SystemAssigned": + return ManagedIdentityId.systemAssigned(); + case "ClientId": + String clientId = managedIdentityNode.get("Id").asText(); + return ManagedIdentityId.userAssignedClientId(clientId); + case "ObjectId": + String objectId = managedIdentityNode.get("Id").asText(); + return ManagedIdentityId.userAssignedObjectId(objectId); + case "ResourceId": + String resourceId = managedIdentityNode.get("Id").asText(); + return ManagedIdentityId.userAssignedResourceId(resourceId); + default: + throw new IllegalArgumentException("Unsupported ManagedIdentityIdType: " + idType); + } + } + + /** + * Extract client capabilities from test object + */ + static List extractClientCapabilities(TestObject testObject) { + List capabilities = new ArrayList<>(); + JsonNode capabilitiesNode = testObject.getProperty("client_capabilities"); + + if (capabilitiesNode != null && capabilitiesNode.isArray()) { + capabilitiesNode.forEach(node -> capabilities.add(node.asText())); + } + + LOG.info(String.format("---Extracted client capabilities: %s", capabilities)); + + return capabilities; + } + + /** + * Creates provider for mocked environment variables using the test configuration. + * + * @param config The test configuration containing the environment variables + * @return An IEnvironmentVariables implementation with the configured variables + */ + static IEnvironmentVariables setEnvironmentVariables(TestConfig config) { + // Get all environment variables from the config + final Map envVars = config.getAllEnvironmentVariables(); + + LOG.info(String.format("---Configured environment variables: %s", envVars.keySet())); + + return new IEnvironmentVariables() { + @Override + public String getEnvironmentVariable(String envVariable) { + return envVars.get(envVariable); + } + }; + } + + /** + * Build parameters for token acquisition + */ + static ManagedIdentityParameters buildManagedIdentityParameters(TestAction action) { + String resource = action.getParameter("resource").asText(); + + LOG.info(String.format("Building ManagedIdentityParameters with resource: %s", resource)); + + ManagedIdentityParameters.ManagedIdentityParametersBuilder builder = + ManagedIdentityParameters.builder(resource); + + // Add optional claims challenge + if (action.hasParameter("claims_challenge")) { + String validatedClaimsChallenge = Shortcuts.validateAndGetClaimsChallenge(action); + builder.claims(validatedClaimsChallenge); + } + + //TODO: other parameters + + return builder.build(); + } + + /** + * Validate token source assertion, either cache or identity provider + */ + static void validateTokenSource(String expectedSource, IAuthenticationResult result) { + TokenSource expected = "identity_provider".equals(expectedSource) ? + TokenSource.IDENTITY_PROVIDER : TokenSource.CACHE; + LOG.info(String.format("---Expected token source: %s", expected)); + LOG.info(String.format("---Actual token source : %s", result.metadata().tokenSource())); + + assertEquals(expected, result.metadata().tokenSource()); + } + + /** + * Complete workflow to get all test case configs + * + * @param indexEndpoint The URL of the index containing test case URLs + * @return Map of test case names to their JSON configurations + */ + static Map getAllTestCaseConfigs(String indexEndpoint) throws IOException { + // Get list of SML test case URLs + List smlUrls = RunnerJsonHelper.getTestCaseUrlsFromEndpoint(indexEndpoint); + + // Convert SML URLs to JSON URLs + List jsonUrls = Shortcuts.convertSmlUrlsToJsonUrls(smlUrls); + + // Fetch content for each JSON URL + Map testCaseConfigs = new HashMap<>(); + for (String jsonUrl : jsonUrls) { + String testCaseName = extractTestCaseName(jsonUrl); + JsonNode config = RunnerJsonHelper.fetchJsonContent(jsonUrl); + testCaseConfigs.put(testCaseName, config); + } + + return testCaseConfigs; + } + + /** + * Extract test case name from URL + */ + private static String extractTestCaseName(String url) { + String[] parts = url.split("/"); + String fileName = parts[parts.length - 1]; + return fileName.substring(0, fileName.lastIndexOf('.')); + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java new file mode 100644 index 00000000..ebaf181c --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerJsonHelper.java @@ -0,0 +1,185 @@ +package com.microsoft.aad.msal4j; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.microsoft.aad.msal4j.Shortcuts.TestConfig; +import com.microsoft.aad.msal4j.Shortcuts.TestObject; +import com.microsoft.aad.msal4j.Shortcuts.TestStep; +import com.microsoft.aad.msal4j.Shortcuts.TestAction; + +import javax.net.ssl.HttpsURLConnection; + +//TODO: Too specific for the test case used in this proof-of-concept, should be able to reuse the regular JsonHelper class +class RunnerJsonHelper { + private static final ObjectMapper mapper = new ObjectMapper(); + + /** + * Parse test configuration from JSON string + */ + static TestConfig parseTestConfig(String jsonContent) { + try { + return JsonParser.parseConfig(mapper.readTree(jsonContent)); + } catch (Exception e) { + throw new RuntimeException("Failed to parse test configuration: " + e.getMessage(), e); + } + } + + /** + * Helper class for parsing JSON into test configuration + */ + private static class JsonParser { + static TestConfig parseConfig(JsonNode rootNode) { + TestConfig.Builder builder = new TestConfig.Builder() + .type(rootNode.path("type").asText()) + .version(rootNode.path("ver").asInt()); + + parseEnvironment(rootNode.path("env"), builder); + parseArrangement(rootNode.path("arrange"), builder); + parseSteps(rootNode.path("steps"), builder); + + return builder.build(); + } + + private static void parseEnvironment(JsonNode envNode, TestConfig.Builder builder) { + envNode.fields().forEachRemaining(entry -> + builder.addEnvironmentVariable(entry.getKey(), entry.getValue().asText())); + } + + private static void parseArrangement(JsonNode arrangeNode, TestConfig.Builder builder) { + arrangeNode.fields().forEachRemaining(appEntry -> { + String appName = appEntry.getKey(); + JsonNode appNode = appEntry.getValue(); + + appNode.fields().forEachRemaining(classEntry -> { + String classType = classEntry.getKey(); + JsonNode classNode = classEntry.getValue(); + + Map properties = new HashMap<>(); + classNode.fields().forEachRemaining(prop -> + properties.put(prop.getKey(), prop.getValue())); + + builder.addArrangedObject(appName, + new TestObject(appName, classType, properties)); + }); + }); + } + + private static void parseSteps(JsonNode stepsNode, TestConfig.Builder builder) { + for (JsonNode stepNode : stepsNode) { + TestAction action = parseAction(stepNode.path("act")); + Map assertions = parseAssertions(stepNode.path("assert")); + + if (action != null) { + builder.addStep(new TestStep(action, assertions)); + } + } + } + + private static TestAction parseAction(JsonNode actNode) { + if (actNode.isMissingNode()) return null; + + String actorKey = actNode.fieldNames().next(); + String[] actorParts = actorKey.split("\\.", 2); + + Map parameters = new HashMap<>(); + actNode.get(actorKey).fields().forEachRemaining(entry -> + parameters.put(entry.getKey(), entry.getValue())); + + return new TestAction(actorParts[0], actorParts[1], parameters); + } + + private static Map parseAssertions(JsonNode assertNode) { + Map assertions = new HashMap<>(); + + if (!assertNode.isMissingNode()) { + assertNode.fields().forEachRemaining(entry -> + assertions.put(entry.getKey(), entry.getValue())); + } + + return assertions; + } + } + + /** + * Fetches test case URLs from an endpoint containing JSON with a "testcases" array. + * + * @param endpointUrl The URL to fetch test cases from + * @return List of test case URLs + */ + static List getTestCaseUrlsFromEndpoint(String endpointUrl) throws IOException { + List testCaseUrls = new ArrayList<>(); + + URL url = new URL(endpointUrl); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Content-Type", "application/json"); + + int responseCode = connection.getResponseCode(); + if (responseCode == HttpsURLConnection.HTTP_OK) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(connection.getInputStream()))) { + StringBuilder response = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + + ObjectMapper mapper = new ObjectMapper(); + JsonNode rootNode = mapper.readTree(response.toString()); + + if (rootNode.has("testcases") && rootNode.get("testcases").isArray()) { + JsonNode testcasesNode = rootNode.get("testcases"); + for (JsonNode testcase : testcasesNode) { + testCaseUrls.add(testcase.asText()); + } + } else { + throw new IllegalStateException("JSON response does not contain a 'testcases' array"); + } + } + } else { + throw new IOException("HTTP request failed with status code: " + responseCode); + } + + return testCaseUrls; + } + + /** + * Fetches JSON content from a URL + * + * @param jsonUrl The URL to fetch JSON content from + * @return The JSON content parsed as a JsonNode + */ + static JsonNode fetchJsonContent(String jsonUrl) throws IOException { + URL url = new URL(jsonUrl); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Content-Type", "application/json"); + + int responseCode = connection.getResponseCode(); + if (responseCode == HttpsURLConnection.HTTP_OK) { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(connection.getInputStream()))) { + StringBuilder response = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } + + ObjectMapper mapper = new ObjectMapper(); + return mapper.readTree(response.toString()); + } + } else { + throw new IOException("Failed to fetch JSON content. HTTP status: " + responseCode); + } + } +} diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java new file mode 100644 index 00000000..9765f954 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/RunnerTest.java @@ -0,0 +1,59 @@ +package com.microsoft.aad.msal4j; + +import com.fasterxml.jackson.databind.JsonNode; +import com.microsoft.aad.msal4j.Shortcuts.TestConfig; +import com.microsoft.aad.msal4j.Shortcuts.TestStep; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.stream.Stream; + +class RunnerTest { + private static final Logger LOG = LoggerFactory.getLogger(RunnerTest.class); + + /** + * Defines a set of test cases for a single unit test to run. + */ + static Stream managedIdentityTestsProvider() { + return Stream.of( + "mi_capability", + "token_sha256_to_refresh", + "mi_vm_pod" + ); + } + + @ParameterizedTest + @MethodSource("managedIdentityTestsProvider") + void runManagedIdentityTest(String testCaseName) throws Exception { + LOG.info("==========Executing Test Case=========="); + + // Get all test configurations + Map configs = RunnerHelper.getAllTestCaseConfigs("https://smile-test.azurewebsites.net/testcases.json"); + + LOG.info(String.format("---Found test case: %s", configs.get(testCaseName).toString())); + + TestConfig config = RunnerJsonHelper.parseTestConfig(configs.get(testCaseName).toString()); + + // Create applications from the configuration + Map apps = RunnerHelper.createAppsFromConfig(config); + + // For each application, execute all steps + for (ManagedIdentityApplication app : apps.values()) { + app.tokenCache.accessTokens.clear(); // Clear the static token cache for each test run + + // Execute each step in the test configuration + for (TestStep step : config.getSteps()) { + LOG.info("----------Executing step----------"); + + // Execute the action + IAuthenticationResult result = RunnerHelper.executeAction(app, step.getAction()); + + // Validate assertions + RunnerHelper.validateAssertions(result, step.getAssertions()); + } + } + } +} \ No newline at end of file diff --git a/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java new file mode 100644 index 00000000..de607980 --- /dev/null +++ b/msal4j-sdk/src/test/java/com/microsoft/aad/msal4j/Shortcuts.java @@ -0,0 +1,207 @@ +package com.microsoft.aad.msal4j; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.*; +import java.util.stream.Collectors; + +class Shortcuts { + //Various helpers and utilities for simplifying the first proof-of-concept implementation. + //They don't follow any design doc or language conventions, and would not be part of the final implementation. + + //=====The following static classes would ideally extend Azure JSON's JsonSerializable or some equivalent YAML parsing interface, + // but for now they are simple classes representing the structure of a test configuration. + static class TestConfig { + private final String type; + private final int version; + private final Map environment; + private final Map arrangeObjects; + private final List steps; + private static final ObjectMapper mapper = new ObjectMapper(); + + private TestConfig(Shortcuts.TestConfig.Builder builder) { + this.type = builder.type; + this.version = builder.version; + this.environment = Collections.unmodifiableMap(builder.environment); + this.arrangeObjects = Collections.unmodifiableMap(builder.arrangeObjects); + this.steps = Collections.unmodifiableList(builder.steps); + } + + String getType() { + return type; + } + + int getVersion() { + return version; + } + + String getEnvironmentVariable(String name) { + return environment.get(name); + } + + Map getAllEnvironmentVariables() { + return environment; + } + + Shortcuts.TestObject getArrangeObject(String name) { + return arrangeObjects.get(name); + } + + List getAllArrangeObjects() { + return new ArrayList<>(arrangeObjects.keySet()); + } + + List getSteps() { + return steps; + } + + static class Builder { + private String type; + private int version; + private final Map environment = new HashMap<>(); + private final Map arrangeObjects = new HashMap<>(); + private final List steps = new ArrayList<>(); + + Shortcuts.TestConfig.Builder type(String type) { + this.type = type; + return this; + } + + Shortcuts.TestConfig.Builder version(int version) { + this.version = version; + return this; + } + + Shortcuts.TestConfig.Builder addEnvironmentVariable(String name, String value) { + environment.put(name, value); + return this; + } + + Shortcuts.TestConfig.Builder addArrangedObject(String name, Shortcuts.TestObject object) { + arrangeObjects.put(name, object); + return this; + } + + Shortcuts.TestConfig.Builder addStep(Shortcuts.TestStep step) { + steps.add(step); + return this; + } + + Shortcuts.TestConfig build() { + return new Shortcuts.TestConfig(this); + } + } + } + + static class TestObject { + private final String name; + private final String type; + private final Map properties; + + TestObject(String name, String type, Map properties) { + this.name = name; + this.type = type; + this.properties = Collections.unmodifiableMap(new HashMap<>(properties)); + } + + String getName() { + return name; + } + + String getType() { + return type; + } + + JsonNode getProperty(String name) { + return properties.get(name); + } + } + + static class TestStep { + private final Shortcuts.TestAction action; + private final Map assertions; + + TestStep(Shortcuts.TestAction action, Map assertions) { + this.action = action; + this.assertions = Collections.unmodifiableMap(new HashMap<>(assertions)); + } + + Shortcuts.TestAction getAction() { + return action; + } + + JsonNode getAssertion(String key) { + return assertions.get(key); + } + + Map getAssertions() { + return assertions; + } + } + + static class TestAction { + private final String targetObject; + private final String methodName; + private final Map parameters; + + TestAction(String targetObject, String methodName, Map parameters) { + this.targetObject = targetObject; + this.methodName = methodName; + this.parameters = Collections.unmodifiableMap(new HashMap<>(parameters)); + } + + String getTargetObject() { + return targetObject; + } + + String getMethodName() { + return methodName; + } + + JsonNode getParameter(String name) { + return parameters.get(name); + } + + boolean hasParameter(String name) { + return parameters.containsKey(name); + } + + Map getParameters() { + return parameters; + } + } + + //=====The following methods are small fixes for issues in the test configuration JSONs + + //Some URLs in the test configurations are malformed and are missing a slash before "test". + static String fixAzureWebsiteUrls(String jsonString) { + return jsonString.replace( + "https://smile-test.azurewebsites.nettest/token", + "https://smile-test.azurewebsites.net/test/token"); + } + + //Some test configurations use a claims challenge that is not valid JSON. + static String validateAndGetClaimsChallenge(TestAction action) { + if (!action.hasParameter("claims_challenge")) { + return null; + } + + String claimsChallenge = action.getParameter("claims_challenge").asText(); + try { + // Try to parse the claims challenge as JSON + new ObjectMapper().readTree(claimsChallenge); + return claimsChallenge; + } catch (Exception e) { + return TestConfiguration.CLAIMS_CHALLENGE; + } + } + + //The server has a public list of endpoints leading to .sml files, but there are JSON equivalents for those files at endpoints ending with .json + static List convertSmlUrlsToJsonUrls(List smlUrls) { + return smlUrls.stream() + .filter(url -> url.endsWith(".sml")) + .map(url -> url.substring(0, url.length() - 4) + ".json") + .collect(Collectors.toList()); + } +} \ No newline at end of file