diff --git a/docs/commands/develop/develop-addpermissions.md b/docs/commands/develop/develop-addpermissions.md index dc88df3..8374de2 100644 --- a/docs/commands/develop/develop-addpermissions.md +++ b/docs/commands/develop/develop-addpermissions.md @@ -2,7 +2,12 @@ ## Overview -The `a365 develop add-permissions` command adds MCP (Model Context Protocol) server API permissions to Azure AD applications. This command is designed for **development scenarios** where you need to configure custom applications (not agent blueprints) to access MCP servers. +The `a365 develop add-permissions` command adds API permissions to Azure AD applications. This command supports: + +- **MCP (Model Context Protocol) server permissions** - Default resource for Agent365 Tools +- **Power Platform API permissions** - For CopilotStudio and other Power Platform services + +This command is designed for **development scenarios** where you need to configure custom applications (not agent blueprints) to access various APIs. ## Usage @@ -15,12 +20,19 @@ a365 develop add-permissions [options] | Option | Alias | Description | Default | |--------|-------|-------------|---------| | `--config` | `-c` | Configuration file path | `a365.config.json` | -| `--manifest` | `-m` | Path to ToolingManifest.json | `/ToolingManifest.json` | +| `--manifest` | `-m` | Path to ToolingManifest.json (for mcp resource) | `/ToolingManifest.json` | | `--app-id` | | Application (client) ID to add permissions to | `clientAppId` from config | -| `--scopes` | | Specific scopes to add (space-separated) | All scopes from ToolingManifest.json | +| `--resource` | `-r` | Target resource API: 'mcp' (default), 'powerplatform' | `mcp` | +| `--resource-id` | | Resource application ID (GUID) to add permissions to. Requires `--scopes` | None | +| `--scopes` | | Specific scopes to add (space-separated). Required when using `--resource` or `--resource-id` | All scopes from ToolingManifest.json (mcp) or required defaults | | `--verbose` | `-v` | Show detailed output | `false` | | `--dry-run` | | Show what would be done without making changes | `false` | +> **Resource and scopes behavior**: +> - The `--resource` and `--resource-id` flags are mutually exclusive. +> - If you specify either `--resource` or `--resource-id`, you must provide `--scopes` explicitly. +> - If you wish to use the scopes specified in the ToolingManifest.json, please omit the resource flags. + ## When to Use This Command ### Development Scenarios @@ -29,7 +41,8 @@ a365 develop add-permissions [options] - Third-party integrations calling MCP servers ### NOT for Agent Blueprints -- Use `a365 setup permissions mcp` for agent blueprint setup +- Use `a365 setup permissions mcp` for agent blueprint MCP setup +- Use `a365 setup permissions copilotstudio` for agent blueprint Power Platform setup ## Understanding the Application ID @@ -50,7 +63,7 @@ The application you're adding permissions to can be the **same application** you ## Prerequisites 1. **Azure CLI Authentication**: `az login` with appropriate permissions -2. **Client Application**: +2. **Client Application**: - Must exist in Azure AD - Must have `Application.ReadWrite.All` permission (to modify app registrations) - Can be configured in `a365.config.json` as `clientAppId` OR provided via `--app-id` @@ -96,6 +109,12 @@ a365 develop add-permissions --app-id 87654321-4321-4321-4321-210987654321 a365 develop add-permissions --scopes McpServers.Mail.All McpServers.Calendar.All ``` +### Add permissions from different resources +```bash +# Add CopilotStudio.Copilots.Invoke permission to the app in config +a365 develop add-permissions --resource powerplatform --scopes CopilotStudio.Copilots.Invoke +``` + ### Combine options with dry-run ```bash # Preview changes to a specific app with specific scopes @@ -106,4 +125,18 @@ a365 develop add-permissions --app-id 12345678-1234-1234-1234-123456789abc --sco ```bash # When no config exists, you must provide --app-id a365 develop add-permissions --app-id 12345678-1234-1234-1234-123456789abc --scopes McpServers.Mail.All -``` \ No newline at end of file +``` + +## Resource Types + +### MCP Resource (Default) +- **Resource Key**: `mcp` (default) +- **Target**: Agent 365 Tools API +- **Scope Source**: ToolingManifest.json or explicit `--scopes` +- **Example Scopes**: `McpServers.Mail.All`, `McpServers.Calendar.All` + +### Power Platform Resource +- **Resource Key**: `powerplatform` +- **Target**: Power Platform API (`8578e004-a5c6-46e7-913e-12f58912df43`) +- **Scope Source**: **Required** explicit `--scopes` (no defaults) +- **Example Scopes**: `CopilotStudio.Copilots.Invoke` \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs index 0ce9e9c..e79ef7e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs @@ -10,7 +10,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands.DevelopSubcommands; /// -/// AddPermissions subcommand - Adds MCP server API permissions to a custom application +/// AddPermissions subcommand - Adds API permissions to a custom application /// internal static class AddPermissionsSubcommand { @@ -22,7 +22,7 @@ public static Command CreateCommand( { var command = new Command( "add-permissions", - "Add MCP server API permissions to a custom application"); + "Add API permissions to a custom application"); var configOption = new Option( ["--config", "-c"], @@ -51,6 +51,22 @@ public static Command CreateCommand( ["--verbose", "-v"], description: "Show detailed output"); + var resourceOption = new Option( + ["--resource", "-r"], + description: "Target resource API: 'mcp' (default), 'powerplatform'. " + + "When specified, --scopes is required.") + { + IsRequired = false + }; + + var resourceIdOption = new Option( + ["--resource-id"], + description: "Resource application ID (GUID) to add permissions for. " + + "When specified, --scopes is required.") + { + IsRequired = false + }; + var dryRunOption = new Option( ["--dry-run"], description: "Show what would be done without executing"); @@ -59,30 +75,34 @@ public static Command CreateCommand( command.AddOption(manifestOption); command.AddOption(appIdOption); command.AddOption(scopesOption); + command.AddOption(resourceOption); + command.AddOption(resourceIdOption); command.AddOption(verboseOption); command.AddOption(dryRunOption); - command.SetHandler(async (config, manifest, appId, scopes, verbose, dryRun) => + command.SetHandler(async (config, manifest, appId, scopes, resource, resourceId, verbose, dryRun) => { try { - logger.LogInformation("Adding MCP server permissions to application..."); + logger.LogInformation("Adding API permissions to application..."); logger.LogInformation(""); // Check if config file exists or if --app-id was provided - var setupConfig = File.Exists(config.FullName) - ? await configService.LoadAsync(config.FullName) + var setupConfig = File.Exists(config.FullName) + ? await configService.LoadAsync(config.FullName) : null; if (setupConfig == null && string.IsNullOrWhiteSpace(appId)) { logger.LogError("Configuration file not found: {ConfigPath}", config.FullName); logger.LogInformation(""); - logger.LogInformation("To add MCP server permissions, you must either:"); + logger.LogInformation("To add API permissions, you must either:"); logger.LogInformation(" 1. Create a config file using: a365 config init"); - logger.LogInformation(" 2. Specify the application ID using: a365 develop addpermissions --app-id "); + logger.LogInformation(" 2. Specify the application ID using: a365 develop add-permissions --app-id "); logger.LogInformation(""); - logger.LogInformation("Example: a365 develop addpermissions --app-id 12345678-1234-1234-1234-123456789abc --scopes McpServers.Mail.All"); + logger.LogInformation("Examples:"); + logger.LogInformation(" a365 develop add-permissions --app-id 12345678-1234-1234-1234-123456789abc --scopes McpServers.Mail.All"); + logger.LogInformation(" a365 develop add-permissions --app-id 12345678-1234-1234-1234-123456789abc --resource powerplatform --scopes CopilotStudio.Copilots.Invoke"); Environment.Exit(1); return; } @@ -103,18 +123,46 @@ public static Command CreateCommand( { logger.LogError("No application ID specified. Use --app-id or ensure ClientAppId is set in config."); logger.LogInformation(""); - logger.LogInformation("Example: a365 develop addpermissions --app-id "); + logger.LogInformation("Example: a365 develop add-permissions --app-id "); Environment.Exit(1); return; } // Determine manifest path - var manifestPath = manifest?.FullName + var manifestPath = manifest?.FullName ?? Path.Combine(setupConfig?.DeploymentProjectPath ?? Environment.CurrentDirectory, McpConstants.ToolingManifestFileName); + var environment = setupConfig?.Environment ?? "prod"; + + // Resolve resource app ID + ResolvedResource resolvedResource; + try + { + resolvedResource = ResourceResolutionHelper.ResolveResource(resourceId, resource, environment); + } + catch (ArgumentException ex) + { + logger.LogError("Resource resolution error: {ErrorMessage}", ex.Message); + logger.LogInformation(""); + logger.LogInformation("Example: a365 develop add-permissions --resource-id 12345678-1234-1234-1234-123456789abc --scopes .default"); + logger.LogInformation("Example: a365 develop add-permissions --resource powerplatform --scopes .default"); + logger.LogInformation("Example: a365 develop add-permissions"); + Environment.Exit(1); + return; + } + + var resourceAppId = resolvedResource.ResourceAppId; + var resourceName = resolvedResource.DisplayName; + + logger.LogInformation("Target resource: {ResourceName} ({ResourceAppId})", resourceName, resourceAppId); + logger.LogInformation(""); + + // Determine if custom resource is being used + bool isCustomResource = !string.IsNullOrWhiteSpace(resource) || !string.IsNullOrWhiteSpace(resourceId); + // Determine which scopes to add string[] requestedScopes; - + if (scopes != null && scopes.Length > 0) { // User provided explicit scopes @@ -122,6 +170,17 @@ public static Command CreateCommand( logger.LogInformation("Using user-specified scopes: {Scopes}", string.Join(", ", requestedScopes)); logger.LogInformation(""); } + else if (isCustomResource) + { + logger.LogError("The --scopes option is required when using --resource or --resource-id."); + logger.LogInformation(""); + logger.LogInformation("Manifest-based scopes are only supported for the default flow."); + logger.LogInformation("Please omit the --resource and --resource-id options if you'd like to use manifest-based scopes."); + logger.LogInformation(""); + logger.LogInformation("Example: a365 develop add-permissions --resource powerplatform --scopes .default"); + Environment.Exit(1); + return; + } else { // Read scopes from ToolingManifest.json @@ -132,7 +191,7 @@ public static Command CreateCommand( logger.LogInformation("Please ensure ToolingManifest.json exists in your project directory"); logger.LogInformation("or specify scopes explicitly with --scopes option."); logger.LogInformation(""); - logger.LogInformation("Example: a365 develop addpermissions --scopes McpServers.Mail.All McpServers.Calendar.All"); + logger.LogInformation("Example: a365 develop add-permissions --scopes McpServers.Mail.All McpServers.Calendar.All"); Environment.Exit(1); return; } @@ -150,23 +209,17 @@ public static Command CreateCommand( return; } - logger.LogInformation("Collected {Count} unique scope(s) from manifest: {Scopes}", + logger.LogInformation("Collected {Count} unique scope(s) from manifest: {Scopes}", requestedScopes.Length, string.Join(", ", requestedScopes)); } - var environment = setupConfig?.Environment ?? "prod"; - var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment); - - logger.LogInformation("Target resource: Agent 365 Tools ({ResourceAppId})", resourceAppId); - logger.LogInformation(""); - // Dry run mode if (dryRun) { - logger.LogInformation("DRY RUN: Add MCP Server Permissions"); + logger.LogInformation("DRY RUN: Add API Permissions"); logger.LogInformation("Would add the following permissions to application {AppId}:", targetAppId); logger.LogInformation(""); - logger.LogInformation("Resource: {ResourceAppId}", resourceAppId); + logger.LogInformation("Resource: {ResourceName} ({ResourceAppId})", resourceName, resourceAppId); logger.LogInformation(" Scopes: {Scopes}", string.Join(", ", requestedScopes)); logger.LogInformation(""); logger.LogInformation("No changes made (dry run mode)"); @@ -181,7 +234,7 @@ public static Command CreateCommand( string tenantId = await TenantDetectionHelper.DetectTenantIdAsync(setupConfig, logger) ?? string.Empty; logger.LogInformation("Processing resource: {ResourceAppId}", resourceAppId); - + bool success; try { @@ -207,7 +260,7 @@ public static Command CreateCommand( logger.LogDebug(" {StackTrace}", ex.StackTrace); success = false; } - + logger.LogInformation(""); // Summary @@ -227,11 +280,12 @@ public static Command CreateCommand( } catch (Exception ex) { - logger.LogError(ex, "Failed to add MCP server permissions: {Message}", ex.Message); + logger.LogError(ex, "Failed to add API permissions: {Message}", ex.Message); Environment.Exit(1); } - }, configOption, manifestOption, appIdOption, scopesOption, verboseOption, dryRunOption); + }, configOption, manifestOption, appIdOption, scopesOption, resourceOption, resourceIdOption, verboseOption, dryRunOption); return command; } + } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs index 4f8396c..46c622c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs @@ -104,14 +104,6 @@ public static Command CreateCommand( try { - // Validate mutual exclusivity of --resource and --resource-id - if (!string.IsNullOrWhiteSpace(resource) && !string.IsNullOrWhiteSpace(resourceId)) - { - logger.LogError("Cannot specify both --resource and --resource-id. Use one or the other."); - Environment.Exit(1); - return; - } - // Determine if custom resource is being used bool isCustomResource = !string.IsNullOrWhiteSpace(resource) || !string.IsNullOrWhiteSpace(resourceId); @@ -143,43 +135,30 @@ public static Command CreateCommand( var environment = setupConfig?.Environment ?? "prod"; // Resolve resource app ID - string resourceAppId; - string resourceDisplayName; - string? resourceUrl = null; - if (!string.IsNullOrWhiteSpace(resourceId)) + ResolvedResource resolvedResource; + try { - // Validate that resource ID is a valid GUID - if (!Guid.TryParse(resourceId, out _)) - { - logger.LogError("Invalid resource application ID: {ResourceId}. Expected a valid GUID.", resourceId); - logger.LogInformation(""); - logger.LogInformation("Example: a365 develop get-token --resource-id 12345678-1234-1234-1234-123456789abc --scopes .default"); - Environment.Exit(1); - return; - } - - // User provided explicit resource ID - resourceAppId = resourceId; - resourceDisplayName = $"Custom Resource ({resourceId})"; - logger.LogInformation("Using custom resource ID: {ResourceId}", resourceId); + resolvedResource = ResourceResolutionHelper.ResolveResource(resourceId, resource, environment); } - else + catch (ArgumentException ex) { - // Resolve resource keyword to GUID (default to "mcp" if null) - var resolved = ResolveResourceApp(resource, environment); - if (resolved == null) - { - logger.LogError("Unknown resource keyword '{Resource}'. Valid options: mcp, powerplatform", resource); - Environment.Exit(1); - return; - } - - resourceAppId = resolved.Value.resourceAppId; - resourceDisplayName = resolved.Value.displayName; - resourceUrl = resolved.Value.url; - logger.LogInformation("Using resource: {DisplayName}", resourceDisplayName); + logger.LogError("Resource resolution error: {ErrorMessage}", ex.Message); + logger.LogInformation(""); + logger.LogInformation("Example: a365 develop get-token --resource-id 12345678-1234-1234-1234-123456789abc --scopes .default"); + logger.LogInformation("Example: a365 develop get-token --resource powerplatform --scopes .default"); + logger.LogInformation("Example: a365 develop get-token"); + Environment.Exit(1); + return; } + var resourceAppId = resolvedResource.ResourceAppId; + var resourceDisplayName = resolvedResource.DisplayName; + var resourceUrl = resolvedResource.Url; + + // Log which resource was selected + logger.LogInformation("Selected resource: {ResourceDisplayName} (App ID: {ResourceAppId})", + resourceDisplayName, resourceAppId); + // Determine which scopes to request string[] requestedScopes; @@ -376,27 +355,6 @@ private static async Task AcquireAndDisplayTokenAsync( } } - /// - /// Resolves a resource keyword to its corresponding resource app info. - /// - /// The resource keyword (e.g., "mcp", "powerplatform"). - /// The environment to use for MCP resource resolution. - /// A tuple containing the resource app ID, display name, and URL, or null if the keyword is unknown. - private static (string resourceAppId, string displayName, string? url)? ResolveResourceApp(string? keyword, string environment) - { - if (string.IsNullOrWhiteSpace(keyword)) - { - keyword = "mcp"; // Default to MCP if no keyword provided - } - - return keyword.ToLowerInvariant() switch - { - "mcp" => (ConfigConstants.GetAgent365ToolsResourceAppId(environment), "Agent 365 Tools (MCP)", ConfigConstants.GetDiscoverEndpointUrl(environment)), - "powerplatform" => (MosConstants.PowerPlatformApiResourceAppId, "Power Platform API", null), - _ => null - }; - } - private static void DisplayResults( List results, string outputFormat, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs index f551d6a..225d979 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorMessages.cs @@ -106,6 +106,19 @@ public static List GetGenericAppServicePlanMitigation() #endregion + #region Resource Resolution Messages + + public const string UnknownResourceKeyword = + "Unknown resource keyword '{0}'. Valid options: mcp, powerplatform"; + + public const string InvalidResourceApplicationId = + "Invalid resource application ID: {0}. Expected a valid GUID."; + + public const string CannotSpecifyBothResourceIdAndKeyword = + "Cannot specify both `--resource-id` and `--resource`. Use one or the other."; + + #endregion + #region Client App Validation Messages public const string ClientAppValidationFailed = diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs new file mode 100644 index 0000000..1d2bcd7 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ResourceResolutionHelper.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; + +namespace Microsoft.Agents.A365.DevTools.Cli.Helpers; + +/// +/// Represents a resolved resource with its application ID, display name, and optional URL. +/// +/// The Azure AD application ID for the resource. +/// A human-readable display name for the resource. +/// An optional URL associated with the resource (e.g., endpoint URL). +public record ResolvedResource(string ResourceAppId, string DisplayName, string? Url); + +/// +/// Provides shared resource keyword resolution logic for CLI subcommands. +/// Maps well-known resource keywords (e.g., "mcp", "powerplatform") to their +/// corresponding Azure AD application IDs, display names, and optional URLs. +/// +public static class ResourceResolutionHelper +{ + /// + /// Resolves a resource keyword to its corresponding resource application info. + /// Defaults to "mcp" when the keyword is null or empty. + /// + /// The resource keyword (e.g., "mcp", "powerplatform"). Defaults to "mcp" when null or empty. + /// The environment to use for environment-aware resource resolution (e.g., "prod"). + /// A containing the app ID, display name, and optional URL, or null if the keyword is unknown. + public static ResolvedResource? ResolveByKeyword(string? keyword, string environment) + { + if (string.IsNullOrWhiteSpace(keyword)) + { + keyword = "mcp"; + } + + return keyword.ToLowerInvariant() switch + { + "mcp" => new ResolvedResource( + ConfigConstants.GetAgent365ToolsResourceAppId(environment), + "Agent 365 Tools (MCP)", + ConfigConstants.GetDiscoverEndpointUrl(environment)), + "powerplatform" => new ResolvedResource( + MosConstants.PowerPlatformApiResourceAppId, + "Power Platform API", + null), + _ => null + }; + } + + /// + /// Wraps a custom resource application ID in a with a generic display name. + /// Used when the caller provides an explicit resource GUID rather than a keyword. + /// + /// The custom resource application ID (GUID). + /// A with a generic display name and no URL. + public static ResolvedResource ResolveByCustomId(string resourceId) + { + return new ResolvedResource(resourceId, "Custom Resource", null); + } + + /// + /// Resolves a resource from either a custom resource ID (GUID) or a keyword. + /// Handles mutual exclusivity validation and GUID validation. + /// + /// The custom resource application ID (GUID), or null. + /// The resource keyword (e.g., "mcp", "powerplatform"), or null. + /// The environment to use for environment-aware resource resolution (e.g., "prod"). + /// A containing the app ID, display name, and optional URL. + /// Thrown when both resourceId and resource are provided, when resourceId is not a valid GUID, or when the resource keyword is unknown. + public static ResolvedResource ResolveResource(string? resourceId, string? resource, string environment) + { + // Validate mutual exclusivity + if (!string.IsNullOrWhiteSpace(resourceId) && !string.IsNullOrWhiteSpace(resource)) + { + throw new ArgumentException(ErrorMessages.CannotSpecifyBothResourceIdAndKeyword, nameof(resource)); + } + + if (!string.IsNullOrWhiteSpace(resourceId)) + { + // Validate that resource ID is a valid GUID + if (!Guid.TryParse(resourceId, out _)) + { + throw new ArgumentException(string.Format(ErrorMessages.InvalidResourceApplicationId, resourceId), nameof(resourceId)); + } + + return ResolveByCustomId(resourceId); + } + + // Resolve resource keyword to GUID (defaults to "mcp" if null) + var resolved = ResolveByKeyword(resource, environment); + if (resolved is null) + { + throw new ArgumentException(string.Format(ErrorMessages.UnknownResourceKeyword, resource), nameof(resource)); + } + + return resolved; + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddPermissionsSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddPermissionsSubcommandTests.cs index c4d770e..bdf2fa7 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddPermissionsSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AddPermissionsSubcommandTests.cs @@ -48,7 +48,6 @@ public void CreateCommand_ShouldHaveDescriptiveMessage() var command = AddPermissionsSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockGraphApiService, _mockBlueprintService); // Assert - command.Description.Should().Contain("MCP"); command.Description.Should().Contain("permission"); } @@ -115,6 +114,31 @@ public void CreateCommand_ShouldHaveVerboseOption() verboseOption.Aliases.Should().Contain("-v"); } + [Fact] + public void CreateCommand_ShouldHaveResourceOption() + { + // Act + var command = AddPermissionsSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockGraphApiService, _mockBlueprintService); + + // Assert + var resourceOption = command.Options.FirstOrDefault(o => o.Name == "resource"); + resourceOption.Should().NotBeNull(); + resourceOption!.Aliases.Should().Contain("--resource"); + resourceOption.Aliases.Should().Contain("-r"); + } + + [Fact] + public void CreateCommand_ShouldHaveResourceIdOption() + { + // Act + var command = AddPermissionsSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockGraphApiService, _mockBlueprintService); + + // Assert + var resourceIdOption = command.Options.FirstOrDefault(o => o.Name == "resource-id"); + resourceIdOption.Should().NotBeNull(); + resourceIdOption!.Aliases.Should().Contain("--resource-id"); + } + [Fact] public void CreateCommand_ShouldHaveDryRunOption() { @@ -134,16 +158,18 @@ public void CreateCommand_ShouldHaveAllRequiredOptions() var command = AddPermissionsSubcommand.CreateCommand(_mockLogger, _mockConfigService, _mockGraphApiService, _mockBlueprintService); // Assert - command.Options.Should().HaveCount(6); + command.Options.Should().HaveCount(8); var optionNames = command.Options.Select(opt => opt.Name).ToList(); - optionNames.Should().Contain(new[] - { - "config", - "manifest", - "app-id", - "scopes", - "verbose", - "dry-run" + optionNames.Should().Contain(new[] + { + "config", + "manifest", + "app-id", + "scopes", + "resource", + "resource-id", + "verbose", + "dry-run" }); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs new file mode 100644 index 0000000..1caf50c --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/ResourceResolutionHelperTests.cs @@ -0,0 +1,372 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Helpers; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Helpers; + +/// +/// Unit tests for ResourceResolutionHelper +/// +public class ResourceResolutionHelperTests +{ + private const string DefaultEnvironment = "prod"; + + #region ResolveByKeyword - MCP keyword tests + + [Fact] + public void ResolveByKeyword_McpKeyword_ReturnsCorrectAppId() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("mcp", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); + } + + [Fact] + public void ResolveByKeyword_McpKeyword_ReturnsCorrectDisplayName() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("mcp", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + } + + [Fact] + public void ResolveByKeyword_McpKeyword_ReturnsDiscoverEndpointUrl() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("mcp", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.Url.Should().Be(ConfigConstants.GetDiscoverEndpointUrl(DefaultEnvironment)); + } + + #endregion + + #region ResolveByKeyword - PowerPlatform keyword tests + + [Fact] + public void ResolveByKeyword_PowerPlatformKeyword_ReturnsCorrectAppId() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("powerplatform", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.ResourceAppId.Should().Be(MosConstants.PowerPlatformApiResourceAppId); + } + + [Fact] + public void ResolveByKeyword_PowerPlatformKeyword_ReturnsCorrectDisplayName() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("powerplatform", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.DisplayName.Should().Be("Power Platform API"); + } + + [Fact] + public void ResolveByKeyword_PowerPlatformKeyword_ReturnsNullUrl() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("powerplatform", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.Url.Should().BeNull(); + } + + #endregion + + #region ResolveByKeyword - Case insensitivity tests + + [Theory] + [InlineData("MCP")] + [InlineData("Mcp")] + [InlineData("mCp")] + public void ResolveByKeyword_McpCaseInsensitive_ReturnsCorrectResult(string keyword) + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword(keyword, DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + result.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); + } + + [Theory] + [InlineData("POWERPLATFORM")] + [InlineData("PowerPlatform")] + [InlineData("Powerplatform")] + public void ResolveByKeyword_PowerPlatformCaseInsensitive_ReturnsCorrectResult(string keyword) + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword(keyword, DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.DisplayName.Should().Be("Power Platform API"); + result.ResourceAppId.Should().Be(MosConstants.PowerPlatformApiResourceAppId); + } + + #endregion + + #region ResolveByKeyword - Null/empty defaults to MCP + + [Fact] + public void ResolveByKeyword_NullKeyword_DefaultsToMcp() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword(null, DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + result.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); + } + + [Fact] + public void ResolveByKeyword_EmptyKeyword_DefaultsToMcp() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + result.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); + } + + [Fact] + public void ResolveByKeyword_WhitespaceKeyword_DefaultsToMcp() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword(" ", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result!.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + result.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); + } + + #endregion + + #region ResolveByKeyword - Unknown keyword + + [Fact] + public void ResolveByKeyword_UnknownKeyword_ReturnsNull() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("unknownresource", DefaultEnvironment); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void ResolveByKeyword_RandomString_ReturnsNull() + { + // Act + var result = ResourceResolutionHelper.ResolveByKeyword("graph", DefaultEnvironment); + + // Assert + result.Should().BeNull(); + } + + #endregion + + #region ResolveByCustomId tests + + [Fact] + public void ResolveByCustomId_ValidGuid_ReturnsResourceWithCorrectAppId() + { + // Arrange + var customId = "12345678-1234-1234-1234-123456789abc"; + + // Act + var result = ResourceResolutionHelper.ResolveByCustomId(customId); + + // Assert + result.Should().NotBeNull(); + result.ResourceAppId.Should().Be(customId); + } + + [Fact] + public void ResolveByCustomId_ValidGuid_ReturnsGenericDisplayName() + { + // Arrange + var customId = "12345678-1234-1234-1234-123456789abc"; + + // Act + var result = ResourceResolutionHelper.ResolveByCustomId(customId); + + // Assert + result.Should().NotBeNull(); + result.DisplayName.Should().Be("Custom Resource"); + } + + [Fact] + public void ResolveByCustomId_ValidGuid_ReturnsNullUrl() + { + // Arrange + var customId = "12345678-1234-1234-1234-123456789abc"; + + // Act + var result = ResourceResolutionHelper.ResolveByCustomId(customId); + + // Assert + result.Should().NotBeNull(); + result.Url.Should().BeNull(); + } + + #endregion + + #region ResolvedResource record tests + + [Fact] + public void ResolvedResource_WithNullUrl_IsValid() + { + // Act + var resource = new ResolvedResource("app-id", "Display Name", null); + + // Assert + resource.ResourceAppId.Should().Be("app-id"); + resource.DisplayName.Should().Be("Display Name"); + resource.Url.Should().BeNull(); + } + + #endregion + + #region ResolveResource tests + + [Fact] + public void ResolveResource_WithValidResourceId_ReturnsCustomResource() + { + // Arrange + var resourceId = "12345678-1234-1234-1234-123456789abc"; + + // Act + var result = ResourceResolutionHelper.ResolveResource(resourceId, null, DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result.ResourceAppId.Should().Be(resourceId); + result.DisplayName.Should().Be("Custom Resource"); + result.Url.Should().BeNull(); + } + + [Fact] + public void ResolveResource_WithInvalidResourceId_ThrowsArgumentException() + { + // Arrange + var invalidResourceId = "not-a-guid"; + + // Act + var act = () => ResourceResolutionHelper.ResolveResource(invalidResourceId, null, DefaultEnvironment); + + // Assert + act.Should().Throw() + .WithMessage("*Invalid resource application ID*"); + } + + [Fact] + public void ResolveResource_WithValidResourceKeyword_ReturnsResource() + { + // Act + var result = ResourceResolutionHelper.ResolveResource(null, "mcp", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); + result.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + } + + [Fact] + public void ResolveResource_WithUnknownResourceKeyword_ThrowsArgumentException() + { + // Act + var act = () => ResourceResolutionHelper.ResolveResource(null, "unknown", DefaultEnvironment); + + // Assert + act.Should().Throw() + .WithMessage("*Unknown resource keyword*"); + } + + [Fact] + public void ResolveResource_WithBothResourceIdAndKeyword_ThrowsArgumentException() + { + // Arrange + var resourceId = "12345678-1234-1234-1234-123456789abc"; + var resourceKeyword = "mcp"; + + // Act + var act = () => ResourceResolutionHelper.ResolveResource(resourceId, resourceKeyword, DefaultEnvironment); + + // Assert + act.Should().Throw() + .WithMessage("*Cannot specify both*"); + } + + [Fact] + public void ResolveResource_WithNeitherResourceIdNorKeyword_DefaultsToMcp() + { + // Act + var result = ResourceResolutionHelper.ResolveResource(null, null, DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); + result.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + } + + [Fact] + public void ResolveResource_WithEmptyResourceKeyword_DefaultsToMcp() + { + // Act + var result = ResourceResolutionHelper.ResolveResource(null, "", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result.ResourceAppId.Should().Be(ConfigConstants.GetAgent365ToolsResourceAppId(DefaultEnvironment)); + result.DisplayName.Should().Be("Agent 365 Tools (MCP)"); + } + + [Fact] + public void ResolveResource_WithPowerPlatformKeyword_ReturnsPowerPlatformResource() + { + // Act + var result = ResourceResolutionHelper.ResolveResource(null, "powerplatform", DefaultEnvironment); + + // Assert + result.Should().NotBeNull(); + result.ResourceAppId.Should().Be(MosConstants.PowerPlatformApiResourceAppId); + result.DisplayName.Should().Be("Power Platform API"); + result.Url.Should().BeNull(); + } + + #endregion + + #region ErrorMessages constant test + + [Fact] + public void ErrorMessages_UnknownResourceKeyword_ContainsExpectedContent() + { + // Assert + ErrorMessages.UnknownResourceKeyword.Should().Contain("mcp"); + ErrorMessages.UnknownResourceKeyword.Should().Contain("powerplatform"); + ErrorMessages.UnknownResourceKeyword.Should().Contain("{0}"); + } + + #endregion +}