From 9fefa417831c866749842a1ab4df1888c1ebd330 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Fri, 6 Mar 2026 15:35:27 -0800 Subject: [PATCH 01/11] feat: unify prerequisite validation via IRequirementCheck + IPrerequisiteRunner (#106) Commands now declare prerequisites using IRequirementCheck and fail early with actionable messages before any side effects occur. Phase 1 - pure reorganization (zero behavioral change): - Add AzureAuthRequirementCheck and InfrastructureRequirementCheck adapters - Add IPrerequisiteRunner / PrerequisiteRunner to run checks in order - Route AllSubcommand, BlueprintSubcommand, InfrastructureSubcommand, and DeployCommand through the shared runner instead of ad-hoc validators - Delete dead code: ISubCommand.ValidateAsync, IAzureValidator/AzureValidator - Make AzureAuthValidator.ValidateAuthenticationAsync virtual for testability Phase 2 - minimal early-fail additions: - cleanup azure: auth check before preview display - deploy mcp: explicit early guards for agentBlueprintId and agenticAppId before any Graph/network calls Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/CleanupCommand.cs | 15 +- .../Commands/CreateInstanceCommand.cs | 8 +- .../Commands/DeployCommand.cs | 34 ++- .../Commands/SetupCommand.cs | 12 +- .../SetupSubcommands/AllSubcommand.cs | 63 ++--- .../SetupSubcommands/BlueprintSubcommand.cs | 65 +---- .../InfrastructureSubcommand.cs | 85 +----- .../Program.cs | 18 +- .../Services/AzureAuthValidator.cs | 2 +- .../Services/AzureValidator.cs | 54 ---- .../Services/IPrerequisiteRunner.cs | 30 +++ .../Services/ISubCommand.cs | 22 -- .../Services/PrerequisiteRunner.cs | 46 ++++ .../AzureAuthRequirementCheck.cs | 50 ++++ .../InfrastructureRequirementCheck.cs | 93 +++++++ .../Commands/BlueprintSubcommandTests.cs | 112 +++++--- .../CleanupCommandBotEndpointTests.cs | 25 +- .../Commands/CleanupCommandTests.cs | 68 +++-- .../Commands/CreateInstanceCommandTests.cs | 16 +- .../Commands/DeployCommandTests.cs | 16 +- .../Commands/SetupCommandTests.cs | 68 +++-- .../Commands/SubcommandValidationTests.cs | 110 +++----- .../Services/PrerequisiteRunnerTests.cs | 166 ++++++++++++ .../AzureAuthRequirementCheckTests.cs | 131 ++++++++++ .../InfrastructureRequirementCheckTests.cs | 245 ++++++++++++++++++ 25 files changed, 1095 insertions(+), 459 deletions(-) delete mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureValidator.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/IPrerequisiteRunner.cs delete mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/ISubCommand.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AzureAuthRequirementCheck.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/InfrastructureRequirementCheck.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/PrerequisiteRunnerTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/AzureAuthRequirementCheckTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/InfrastructureRequirementCheckTests.cs diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs index 6def19ba..d1d3ac63 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs @@ -8,6 +8,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Agents.A365.DevTools.Cli.Models; namespace Microsoft.Agents.A365.DevTools.Cli.Commands; @@ -24,7 +25,9 @@ public static Command CreateCommand( CommandExecutor executor, AgentBlueprintService agentBlueprintService, IConfirmationProvider confirmationProvider, - FederatedCredentialService federatedCredentialService) + FederatedCredentialService federatedCredentialService, + IPrerequisiteRunner prerequisiteRunner, + AzureAuthValidator authValidator) { var cleanupCommand = new Command("cleanup", "Clean up ALL resources (blueprint, instance, Azure) - use subcommands for granular cleanup"); @@ -55,7 +58,7 @@ public static Command CreateCommand( // Add subcommands for granular control cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService)); - cleanupCommand.AddCommand(CreateAzureCleanupCommand(logger, configService, executor)); + cleanupCommand.AddCommand(CreateAzureCleanupCommand(logger, configService, executor, prerequisiteRunner, authValidator)); cleanupCommand.AddCommand(CreateInstanceCleanupCommand(logger, configService, executor)); return cleanupCommand; @@ -304,7 +307,9 @@ private static Command CreateBlueprintCleanupCommand( private static Command CreateAzureCleanupCommand( ILogger logger, IConfigService configService, - CommandExecutor executor) + CommandExecutor executor, + IPrerequisiteRunner prerequisiteRunner, + AzureAuthValidator authValidator) { var command = new Command("azure", "Remove Azure resources (App Service, App Service Plan)"); @@ -331,6 +336,10 @@ private static Command CreateAzureCleanupCommand( var config = await LoadConfigAsync(configFile, logger, configService); if (config == null) return; + var authChecks = new List { new AzureAuthRequirementCheck(authValidator) }; + if (!await prerequisiteRunner.RunAsync(authChecks, config, logger, CancellationToken.None)) + return; + logger.LogInformation(""); logger.LogInformation("Azure Cleanup Preview:"); logger.LogInformation("========================="); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs index bb03a1e0..bd44e63b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs @@ -18,7 +18,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands; public class CreateInstanceCommand { public static Command CreateCommand(ILogger logger, IConfigService configService, CommandExecutor executor, - IBotConfigurator botConfigurator, GraphApiService graphApiService, IAzureValidator azureValidator) + IBotConfigurator botConfigurator, GraphApiService graphApiService) { // Command description - deprecated // Old: Create and configure agent user identities with appropriate @@ -75,12 +75,6 @@ public static Command CreateCommand(ILogger logger, IConf var instanceConfig = await LoadConfigAsync(logger, configService, config.FullName); if (instanceConfig == null) Environment.Exit(1); - // Validate Azure CLI authentication, subscription, and environment - if (!await azureValidator.ValidateAllAsync(instanceConfig.SubscriptionId)) - { - logger.LogError("Instance creation cannot proceed without proper Azure CLI authentication and subscription"); - Environment.Exit(1); - } logger.LogInformation(""); // Step 1-3: Identity, Licenses, and MCP Registration diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs index 62cf2a83..be8bb2fd 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs @@ -7,6 +7,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Extensions.Logging; using System.CommandLine; @@ -19,7 +20,8 @@ public static Command CreateCommand( IConfigService configService, CommandExecutor executor, DeploymentService deploymentService, - IAzureValidator azureValidator, + IPrerequisiteRunner prerequisiteRunner, + AzureAuthValidator authValidator, GraphApiService graphApiService, AgentBlueprintService blueprintService) { @@ -53,7 +55,7 @@ public static Command CreateCommand( command.AddOption(restartOption); // Add subcommands - command.AddCommand(CreateAppSubcommand(logger, configService, executor, deploymentService, azureValidator)); + command.AddCommand(CreateAppSubcommand(logger, configService, executor, deploymentService, prerequisiteRunner, authValidator)); command.AddCommand(CreateMcpSubcommand(logger, configService, executor, graphApiService, blueprintService)); // Single handler for the deploy command - runs only the application deployment flow @@ -82,7 +84,7 @@ public static Command CreateCommand( } var validatedConfig = await ValidateDeploymentPrerequisitesAsync( - config.FullName, configService, azureValidator, executor, logger); + config.FullName, configService, prerequisiteRunner, authValidator, executor, logger); if (validatedConfig == null) return; await DeployApplicationAsync(validatedConfig, deploymentService, verbose, inspect, restart, logger); @@ -101,7 +103,8 @@ private static Command CreateAppSubcommand( IConfigService configService, CommandExecutor executor, DeploymentService deploymentService, - IAzureValidator azureValidator) + IPrerequisiteRunner prerequisiteRunner, + AzureAuthValidator authValidator) { var command = new Command("app", "Deploy Microsoft Agent 365 application binaries to the configured Azure App Service"); @@ -157,7 +160,7 @@ private static Command CreateAppSubcommand( } var validatedConfig = await ValidateDeploymentPrerequisitesAsync( - config.FullName, configService, azureValidator, executor, logger); + config.FullName, configService, prerequisiteRunner, authValidator, executor, logger); if (validatedConfig == null) return; await DeployApplicationAsync(validatedConfig, deploymentService, verbose, inspect, restart, logger); @@ -218,6 +221,18 @@ private static Command CreateMcpSubcommand( var updateConfig = await configService.LoadAsync(config.FullName); if (updateConfig == null) Environment.Exit(1); + // Early validation: fail before any network calls + if (string.IsNullOrWhiteSpace(updateConfig.AgentBlueprintId)) + { + logger.LogError("agentBlueprintId is not configured. Run 'a365 setup all' to create the agent blueprint."); + return; + } + if (string.IsNullOrWhiteSpace(updateConfig.AgenticAppId)) + { + logger.LogError("agenticAppId is not configured. Run 'a365 setup all' to complete setup."); + return; + } + // Configure GraphApiService with custom client app ID if available if (!string.IsNullOrWhiteSpace(updateConfig.ClientAppId)) { @@ -246,7 +261,8 @@ private static Command CreateMcpSubcommand( private static async Task ValidateDeploymentPrerequisitesAsync( string configPath, IConfigService configService, - IAzureValidator azureValidator, + IPrerequisiteRunner prerequisiteRunner, + AzureAuthValidator authValidator, CommandExecutor executor, ILogger logger) { @@ -255,7 +271,11 @@ private static Command CreateMcpSubcommand( if (configData == null) return null; // Validate Azure CLI authentication, subscription, and environment - if (!await azureValidator.ValidateAllAsync(configData.SubscriptionId)) + var checks = new List + { + new AzureAuthRequirementCheck(authValidator) + }; + if (!await prerequisiteRunner.RunAsync(checks, configData, logger)) { logger.LogError("Deployment cannot proceed without proper Azure CLI authentication and the correct subscription context"); return null; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 08a913b1..8fd6c909 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -20,7 +20,9 @@ public static Command CreateCommand( CommandExecutor executor, DeploymentService deploymentService, IBotConfigurator botConfigurator, - IAzureValidator azureValidator, + IPrerequisiteRunner prerequisiteRunner, + AzureAuthValidator authValidator, + IAzureEnvironmentValidator environmentValidator, AzureWebAppCreator webAppCreator, PlatformDetector platformDetector, GraphApiService graphApiService, @@ -29,7 +31,7 @@ public static Command CreateCommand( FederatedCredentialService federatedCredentialService, IClientAppValidator clientAppValidator) { - var command = new Command("setup", + var command = new Command("setup", "Set up your Agent 365 environment with granular control over each step\n\n" + "Recommended execution order:\n" + " 0. a365 setup requirements # Check prerequisites (optional)\n" + @@ -46,16 +48,16 @@ public static Command CreateCommand( logger, configService, clientAppValidator)); command.AddCommand(InfrastructureSubcommand.CreateCommand( - logger, configService, azureValidator, webAppCreator, platformDetector, executor)); + logger, configService, prerequisiteRunner, authValidator, webAppCreator, platformDetector, executor)); command.AddCommand(BlueprintSubcommand.CreateCommand( - logger, configService, executor, azureValidator, webAppCreator, platformDetector, botConfigurator, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService)); + logger, configService, executor, prerequisiteRunner, authValidator, webAppCreator, platformDetector, botConfigurator, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService)); command.AddCommand(PermissionsSubcommand.CreateCommand( logger, configService, executor, graphApiService, blueprintService)); command.AddCommand(AllSubcommand.CreateCommand( - logger, configService, executor, botConfigurator, azureValidator, webAppCreator, platformDetector, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService)); + logger, configService, executor, botConfigurator, prerequisiteRunner, authValidator, environmentValidator, webAppCreator, platformDetector, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService)); return command; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs index b67534c2..21cc8623 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -6,6 +6,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Extensions.Logging; using System.CommandLine; @@ -26,7 +27,9 @@ public static Command CreateCommand( IConfigService configService, CommandExecutor executor, IBotConfigurator botConfigurator, - IAzureValidator azureValidator, + IPrerequisiteRunner prerequisiteRunner, + AzureAuthValidator authValidator, + IAzureEnvironmentValidator environmentValidator, AzureWebAppCreator webAppCreator, PlatformDetector platformDetector, GraphApiService graphApiService, @@ -206,57 +209,22 @@ public static Command CreateCommand( // PHASE 1: VALIDATE ALL PREREQUISITES UPFRONT logger.LogDebug("Validating all prerequisites..."); - var allErrors = new List(); - - // Validate Azure CLI authentication first - logger.LogDebug("Validating Azure CLI authentication..."); - if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) + var prereqChecks = new List { - allErrors.Add("Azure CLI authentication failed or subscription not set correctly"); - logger.LogError("Azure CLI authentication validation failed"); - } - else - { - logger.LogDebug("Azure CLI authentication: OK"); - } + new AzureAuthRequirementCheck(authValidator), + new ClientAppRequirementCheck(clientAppValidator) + }; - // Validate Infrastructure prerequisites if (!skipInfrastructure && setupConfig.NeedDeployment) - { - logger.LogDebug("Validating Infrastructure prerequisites..."); - var infraErrors = await InfrastructureSubcommand.ValidateAsync(setupConfig, azureValidator, CancellationToken.None); - if (infraErrors.Count > 0) - { - allErrors.AddRange(infraErrors.Select(e => $"Infrastructure: {e}")); - } - else - { - logger.LogDebug("Infrastructure prerequisites: OK"); - } - } + prereqChecks.Add(new InfrastructureRequirementCheck()); - // Validate Blueprint prerequisites - logger.LogDebug("Validating Blueprint prerequisites..."); - var blueprintErrors = await BlueprintSubcommand.ValidateAsync(setupConfig, azureValidator, clientAppValidator, CancellationToken.None); - if (blueprintErrors.Count > 0) - { - allErrors.AddRange(blueprintErrors.Select(e => $"Blueprint: {e}")); - } - else - { - logger.LogDebug("Blueprint prerequisites: OK"); - } + // Run advisory environment check (warning only, never blocks) + await environmentValidator.ValidateEnvironmentAsync(); - // Stop if any validation failed - if (allErrors.Count > 0) + if (!await prerequisiteRunner.RunAsync(prereqChecks, setupConfig, logger, CancellationToken.None)) { - logger.LogError("Setup cannot proceed due to validation failures:"); - foreach (var error in allErrors) - { - logger.LogError(" - {Error}", error); - } - logger.LogError("Please fix the errors above and try again"); - setupResults.Errors.AddRange(allErrors); + logger.LogError("Setup cannot proceed due to prerequisite validation failures. Please fix the errors above and try again."); + setupResults.Errors.Add("Prerequisite validation failed"); ExceptionHandler.ExitWithCleanup(1); } @@ -304,7 +272,8 @@ public static Command CreateCommand( setupConfig, config, executor, - azureValidator, + prerequisiteRunner, + authValidator, logger, skipInfrastructure, true, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index c1317519..5dbc1981 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -9,6 +9,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; using Microsoft.Extensions.Logging; using Microsoft.Graph; @@ -62,60 +63,12 @@ internal static class BlueprintSubcommand private const int ClientSecretValidationTimeoutSeconds = 10; private const string MicrosoftLoginOAuthTokenEndpoint = "https://login.microsoftonline.com/{0}/oauth2/v2.0/token"; - /// - /// Validates blueprint prerequisites without performing any actions. - /// - public static async Task> ValidateAsync( - Models.Agent365Config config, - IAzureValidator azureValidator, - IClientAppValidator clientAppValidator, - CancellationToken cancellationToken = default) - { - var errors = new List(); - - if (string.IsNullOrWhiteSpace(config.ClientAppId)) - { - errors.Add("clientAppId is required in configuration"); - errors.Add("Please configure a custom client app in your tenant with required permissions"); - errors.Add($"See {ConfigConstants.Agent365CliDocumentationUrl} for setup instructions"); - return errors; - } - - // Validate client app exists and has required permissions - try - { - await clientAppValidator.EnsureValidClientAppAsync( - config.ClientAppId, - config.TenantId, - cancellationToken); - } - catch (ClientAppValidationException ex) - { - // Add issue description and error details - errors.Add(ex.IssueDescription); - errors.AddRange(ex.ErrorDetails); - - // Add mitigation steps if available - if (ex.MitigationSteps.Count > 0) - { - errors.AddRange(ex.MitigationSteps); - } - } - catch (Exception ex) - { - // Catch any unexpected validation errors (Graph API failures, etc.) - errors.Add($"Client app validation failed: {ex.Message}"); - errors.Add("Ensure Azure CLI is authenticated and you have access to the tenant."); - } - - return errors; - } - public static Command CreateCommand( ILogger logger, IConfigService configService, CommandExecutor executor, - IAzureValidator azureValidator, + IPrerequisiteRunner prerequisiteRunner, + AzureAuthValidator authValidator, AzureWebAppCreator webAppCreator, PlatformDetector platformDetector, IBotConfigurator botConfigurator, @@ -303,7 +256,8 @@ await CreateBlueprintImplementationAsync( setupConfig, config, executor, - azureValidator, + prerequisiteRunner, + authValidator, logger, false, false, @@ -367,7 +321,8 @@ public static async Task CreateBlueprintImplementationA Models.Agent365Config setupConfig, FileInfo config, CommandExecutor executor, - IAzureValidator azureValidator, + IPrerequisiteRunner prerequisiteRunner, + AzureAuthValidator authValidator, ILogger logger, bool skipInfrastructure, bool isSetupAll, @@ -401,7 +356,11 @@ public static async Task CreateBlueprintImplementationA logger.LogInformation("==> Creating Agent Blueprint"); // Validate Azure authentication - if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) + var authChecks = new List + { + new AzureAuthRequirementCheck(authValidator) + }; + if (!await prerequisiteRunner.RunAsync(authChecks, setupConfig, logger, cancellationToken)) { return new BlueprintCreationResult { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs index dfe369c2..3856071e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs @@ -6,6 +6,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Extensions.Logging; using System.CommandLine; using System.Text.Json; @@ -25,83 +26,11 @@ public static class InfrastructureSubcommand private const int InitialRetryDelayMs = 500; private const int MaxRetryDelayMs = 5000; // Cap exponential backoff at 5 seconds - /// - /// Validates infrastructure prerequisites without performing any actions. - /// Includes validation of App Service Plan SKU and provides recommendations. - /// - public static Task> ValidateAsync( - Agent365Config config, - IAzureValidator azureValidator, - CancellationToken cancellationToken = default) - { - var errors = new List(); - - if (!config.NeedDeployment) - { - return Task.FromResult(errors); - } - - if (string.IsNullOrWhiteSpace(config.SubscriptionId)) - errors.Add("subscriptionId is required for Azure hosting"); - - if (string.IsNullOrWhiteSpace(config.ResourceGroup)) - errors.Add("resourceGroup is required for Azure hosting"); - - if (string.IsNullOrWhiteSpace(config.AppServicePlanName)) - errors.Add("appServicePlanName is required for Azure hosting"); - - if (string.IsNullOrWhiteSpace(config.WebAppName)) - errors.Add("webAppName is required for Azure hosting"); - - if (string.IsNullOrWhiteSpace(config.Location)) - errors.Add("location is required for Azure hosting"); - - // Validate App Service Plan SKU - var sku = string.IsNullOrWhiteSpace(config.AppServicePlanSku) - ? ConfigConstants.DefaultAppServicePlanSku - : config.AppServicePlanSku; - - if (!IsValidAppServicePlanSku(sku)) - { - errors.Add($"Invalid appServicePlanSku '{sku}'. Valid SKUs: F1 (Free), B1/B2/B3 (Basic), S1/S2/S3 (Standard), P1V2/P2V2/P3V2 (Premium V2), P1V3/P2V3/P3V3 (Premium V3)"); - } - // Note: B1 quota warning is now logged at execution time with actual quota check - - return Task.FromResult(errors); - } - - /// - /// Validates if the provided SKU is a valid App Service Plan SKU. - /// - private static bool IsValidAppServicePlanSku(string sku) - { - if (string.IsNullOrWhiteSpace(sku)) - return false; - - // Common valid SKUs (case-insensitive) - var validSkus = new[] - { - // Free tier - "F1", - // Basic tier - "B1", "B2", "B3", - // Standard tier - "S1", "S2", "S3", - // Premium V2 - "P1V2", "P2V2", "P3V2", - // Premium V3 - "P1V3", "P2V3", "P3V3", - // Isolated (less common) - "I1", "I2", "I3", - "I1V2", "I2V2", "I3V2" - }; - - return validSkus.Contains(sku, StringComparer.OrdinalIgnoreCase); - } public static Command CreateCommand( ILogger logger, IConfigService configService, - IAzureValidator azureValidator, + IPrerequisiteRunner prerequisiteRunner, + AzureAuthValidator authValidator, AzureWebAppCreator webAppCreator, PlatformDetector platformDetector, CommandExecutor executor) @@ -158,8 +87,12 @@ public static Command CreateCommand( var setupConfig = await configService.LoadAsync(config.FullName); if (setupConfig.NeedDeployment) { - // Validate Azure CLI authentication, subscription, and environment - if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) + var checks = new List + { + new AzureAuthRequirementCheck(authValidator), + new InfrastructureRequirementCheck() + }; + if (!await prerequisiteRunner.RunAsync(checks, setupConfig, logger, CancellationToken.None)) { ExceptionHandler.ExitWithCleanup(1); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index fa64b85b..a8972712 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -90,7 +90,9 @@ static async Task Main(string[] args) var configService = serviceProvider.GetRequiredService(); var executor = serviceProvider.GetRequiredService(); var authService = serviceProvider.GetRequiredService(); - var azureValidator = serviceProvider.GetRequiredService(); + var prerequisiteRunner = serviceProvider.GetRequiredService(); + var authValidator = serviceProvider.GetRequiredService(); + var environmentValidator = serviceProvider.GetRequiredService(); var toolingService = serviceProvider.GetRequiredService(); // Get services needed by commands @@ -111,11 +113,11 @@ static async Task Main(string[] args) rootCommand.AddCommand(DevelopCommand.CreateCommand(developLogger, configService, executor, authService, graphApiService, agentBlueprintService, processService)); rootCommand.AddCommand(DevelopMcpCommand.CreateCommand(developLogger, toolingService)); rootCommand.AddCommand(SetupCommand.CreateCommand(setupLogger, configService, executor, - deploymentService, botConfigurator, azureValidator, webAppCreator, platformDetector, graphApiService, agentBlueprintService, blueprintLookupService, federatedCredentialService, clientAppValidator)); + deploymentService, botConfigurator, prerequisiteRunner, authValidator, environmentValidator, webAppCreator, platformDetector, graphApiService, agentBlueprintService, blueprintLookupService, federatedCredentialService, clientAppValidator)); rootCommand.AddCommand(CreateInstanceCommand.CreateCommand(createInstanceLogger, configService, executor, - botConfigurator, graphApiService, azureValidator)); + botConfigurator, graphApiService)); rootCommand.AddCommand(DeployCommand.CreateCommand(deployLogger, configService, executor, - deploymentService, azureValidator, graphApiService, agentBlueprintService)); + deploymentService, prerequisiteRunner, authValidator, graphApiService, agentBlueprintService)); // Register ConfigCommand var configLoggerFactory = serviceProvider.GetRequiredService(); @@ -125,7 +127,7 @@ static async Task Main(string[] args) var confirmationProvider = serviceProvider.GetRequiredService(); rootCommand.AddCommand(ConfigCommand.CreateCommand(configLogger, wizardService: wizardService, clientAppValidator: clientAppValidator)); rootCommand.AddCommand(QueryEntraCommand.CreateCommand(queryEntraLogger, configService, executor, graphApiService, agentBlueprintService)); - rootCommand.AddCommand(CleanupCommand.CreateCommand(cleanupLogger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService)); + rootCommand.AddCommand(CleanupCommand.CreateCommand(cleanupLogger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService, prerequisiteRunner, authValidator)); rootCommand.AddCommand(PublishCommand.CreateCommand(publishLogger, configService, agentPublishService, graphApiService, agentBlueprintService, manifestTemplateService)); // Wrap all command handlers with exception handling @@ -228,12 +230,12 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini return new Agent365ToolingService(configService, authService, logger, environment); }); - // Add Azure validators (individual validators for composition) + // Add Azure validators services.AddSingleton(); services.AddSingleton(); - // Add unified Azure validator - services.AddSingleton(); + // Add prerequisite runner + services.AddSingleton(); // Add multi-platform deployment services services.AddSingleton(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs index c81b6094..49e28eb1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs @@ -26,7 +26,7 @@ public AzureAuthValidator(ILogger logger, CommandExecutor ex /// /// The expected subscription ID to validate against. If null, only checks authentication. /// True if authenticated and subscription matches (if specified), false otherwise. - public async Task ValidateAuthenticationAsync(string? expectedSubscriptionId = null) + public virtual async Task ValidateAuthenticationAsync(string? expectedSubscriptionId = null) { try { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureValidator.cs deleted file mode 100644 index eb6a0b54..00000000 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureValidator.cs +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Extensions.Logging; - -namespace Microsoft.Agents.A365.DevTools.Cli.Services; - -/// -/// Unified Azure validator that orchestrates all Azure-related validations. -/// -public interface IAzureValidator -{ - /// - /// Validates Azure CLI authentication, subscription, and environment. - /// - /// Expected subscription ID - /// True if all validations pass - Task ValidateAllAsync(string subscriptionId); -} - -public class AzureValidator : IAzureValidator -{ - private readonly AzureAuthValidator _authValidator; - private readonly IAzureEnvironmentValidator _environmentValidator; - private readonly ILogger _logger; - - public AzureValidator( - AzureAuthValidator authValidator, - IAzureEnvironmentValidator environmentValidator, - ILogger logger) - { - _authValidator = authValidator; - _environmentValidator = environmentValidator; - _logger = logger; - } - - /// - public async Task ValidateAllAsync(string subscriptionId) - { - _logger.LogDebug("Validating Azure CLI authentication and subscription..."); - - // Authentication validation (critical - stops execution if failed) - if (!await _authValidator.ValidateAuthenticationAsync(subscriptionId)) - { - _logger.LogError("Setup cannot proceed without proper Azure CLI authentication and subscription"); - return false; - } - - // Environment validation (warnings only - doesn't stop execution) - await _environmentValidator.ValidateEnvironmentAsync(); - - return true; - } -} \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IPrerequisiteRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IPrerequisiteRunner.cs new file mode 100644 index 00000000..39532480 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IPrerequisiteRunner.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Runs a set of prerequisite checks before a command executes. +/// Returns false and logs actionable errors if any blocking check fails. +/// Warnings are logged but do not block execution. +/// +public interface IPrerequisiteRunner +{ + /// + /// Runs all checks in order. + /// + /// The prerequisite checks to run. + /// The current Agent 365 configuration. + /// Logger for output. + /// Cancellation token. + /// True if all blocking checks pass; false if any check fails. + Task RunAsync( + IEnumerable checks, + Agent365Config config, + ILogger logger, + CancellationToken cancellationToken = default); +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ISubCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ISubCommand.cs deleted file mode 100644 index bcf4298d..00000000 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ISubCommand.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using Microsoft.Agents.A365.DevTools.Cli.Models; - -namespace Microsoft.Agents.A365.DevTools.Cli.Services; - -/// -/// Interface for subcommands that require validation before execution. -/// Implements separation of validation and execution phases to fail fast on configuration issues. -/// -public interface ISubCommand -{ - /// - /// Validates prerequisites for the subcommand without performing any actions. - /// This should check configuration, authentication, and environment requirements. - /// - /// The Agent365 configuration - /// Cancellation token - /// List of validation errors, empty if validation passes - Task> ValidateAsync(Agent365Config config, CancellationToken cancellationToken = default); -} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs new file mode 100644 index 00000000..c13506d3 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Runs prerequisite checks for a command and logs failures with actionable guidance. +/// +public class PrerequisiteRunner : IPrerequisiteRunner +{ + /// + public async Task RunAsync( + IEnumerable checks, + Agent365Config config, + ILogger logger, + CancellationToken cancellationToken = default) + { + var passed = true; + + foreach (var check in checks) + { + var result = await check.CheckAsync(config, logger, cancellationToken); + + if (!result.Passed) + { + passed = false; + + if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) + logger.LogError("{CheckName}: {ErrorMessage}", check.Name, result.ErrorMessage); + + if (!string.IsNullOrWhiteSpace(result.ResolutionGuidance)) + logger.LogError(" Resolution: {ResolutionGuidance}", result.ResolutionGuidance); + } + else if (result.IsWarning && !string.IsNullOrWhiteSpace(result.Details)) + { + logger.LogWarning("{CheckName}: {Details}", check.Name, result.Details); + } + } + + return passed; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AzureAuthRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AzureAuthRequirementCheck.cs new file mode 100644 index 00000000..3fe62dc2 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AzureAuthRequirementCheck.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; + +/// +/// Validates Azure CLI authentication and active subscription. +/// Delegates entirely to AzureAuthValidator which handles all user-facing logging. +/// +public class AzureAuthRequirementCheck : RequirementCheck +{ + private readonly AzureAuthValidator _authValidator; + + public AzureAuthRequirementCheck(AzureAuthValidator authValidator) + { + _authValidator = authValidator ?? throw new ArgumentNullException(nameof(authValidator)); + } + + /// + public override string Name => "Azure Authentication"; + + /// + public override string Description => "Validates Azure CLI authentication and active subscription"; + + /// + public override string Category => "Azure"; + + /// + public override async Task CheckAsync( + Agent365Config config, + ILogger logger, + CancellationToken cancellationToken = default) + { + // AzureAuthValidator logs all detailed user-facing messages internally. + // This adapter converts the bool result into a RequirementCheckResult. + var authenticated = await _authValidator.ValidateAuthenticationAsync(config.SubscriptionId); + + if (!authenticated) + { + return RequirementCheckResult.Failure( + "Azure CLI authentication failed or the active subscription does not match the configured subscriptionId", + "Run 'az login' to authenticate, then 'az account set --subscription ' to select the correct subscription"); + } + + return RequirementCheckResult.Success(); + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/InfrastructureRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/InfrastructureRequirementCheck.cs new file mode 100644 index 00000000..4c690eb1 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/InfrastructureRequirementCheck.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; + +/// +/// Validates Azure infrastructure configuration fields required for Azure-hosted deployments. +/// Skips all checks when NeedDeployment is false (external messaging endpoint scenario). +/// No external service calls — pure configuration validation. +/// +public class InfrastructureRequirementCheck : RequirementCheck +{ + /// + public override string Name => "Infrastructure Configuration"; + + /// + public override string Description => "Validates Azure infrastructure configuration fields (subscription, resource group, app service plan, web app, location, SKU)"; + + /// + public override string Category => "Configuration"; + + /// + public override Task CheckAsync( + Agent365Config config, + ILogger logger, + CancellationToken cancellationToken = default) + { + if (!config.NeedDeployment) + return Task.FromResult(RequirementCheckResult.Success()); + + var errors = new List(); + + if (string.IsNullOrWhiteSpace(config.SubscriptionId)) + errors.Add("subscriptionId is required for Azure hosting"); + + if (string.IsNullOrWhiteSpace(config.ResourceGroup)) + errors.Add("resourceGroup is required for Azure hosting"); + + if (string.IsNullOrWhiteSpace(config.AppServicePlanName)) + errors.Add("appServicePlanName is required for Azure hosting"); + + if (string.IsNullOrWhiteSpace(config.WebAppName)) + errors.Add("webAppName is required for Azure hosting"); + + if (string.IsNullOrWhiteSpace(config.Location)) + errors.Add("location is required for Azure hosting"); + + var sku = string.IsNullOrWhiteSpace(config.AppServicePlanSku) + ? ConfigConstants.DefaultAppServicePlanSku + : config.AppServicePlanSku; + + if (!IsValidAppServicePlanSku(sku)) + errors.Add($"Invalid appServicePlanSku '{sku}'. Valid SKUs: F1 (Free), B1/B2/B3 (Basic), S1/S2/S3 (Standard), P1V2/P2V2/P3V2 (Premium V2), P1V3/P2V3/P3V3 (Premium V3)"); + + if (errors.Count > 0) + { + return Task.FromResult(RequirementCheckResult.Failure( + string.Join("; ", errors), + "Update the missing or invalid fields in a365.config.json and run again")); + } + + return Task.FromResult(RequirementCheckResult.Success()); + } + + private static bool IsValidAppServicePlanSku(string sku) + { + if (string.IsNullOrWhiteSpace(sku)) + return false; + + var validSkus = new[] + { + // Free tier + "F1", + // Basic tier + "B1", "B2", "B3", + // Standard tier + "S1", "S2", "S3", + // Premium V2 + "P1V2", "P2V2", "P3V2", + // Premium V3 + "P1V3", "P2V3", "P3V3", + // Isolated (less common) + "I1", "I2", "I3", + "I1V2", "I2V2", "I3V2" + }; + + return validSkus.Contains(sku, StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs index 048f45f2..ad36bc92 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs @@ -6,7 +6,9 @@ using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using System.CommandLine; using System.CommandLine.Builder; @@ -26,7 +28,8 @@ public class BlueprintSubcommandTests private readonly ILogger _mockLogger; private readonly IConfigService _mockConfigService; private readonly CommandExecutor _mockExecutor; - private readonly IAzureValidator _mockAzureValidator; + private readonly IPrerequisiteRunner _mockPrerequisiteRunner; + private readonly AzureAuthValidator _mockAuthValidator; private readonly AzureWebAppCreator _mockWebAppCreator; private readonly PlatformDetector _mockPlatformDetector; private readonly IBotConfigurator _mockBotConfigurator; @@ -42,7 +45,8 @@ public BlueprintSubcommandTests() _mockConfigService = Substitute.For(); var mockExecutorLogger = Substitute.For>(); _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); - _mockAzureValidator = Substitute.For(); + _mockPrerequisiteRunner = Substitute.For(); + _mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, _mockExecutor); _mockWebAppCreator = Substitute.ForPartsOf(Substitute.For>()); var mockPlatformDetectorLogger = Substitute.For>(); _mockPlatformDetector = Substitute.ForPartsOf(mockPlatformDetectorLogger); @@ -62,7 +66,8 @@ public void CreateCommand_ShouldHaveCorrectName() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -80,7 +85,8 @@ public void CreateCommand_ShouldHaveDescription() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -99,7 +105,8 @@ public void CreateCommand_ShouldHaveConfigOption() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -120,7 +127,8 @@ public void CreateCommand_ShouldHaveVerboseOption() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -141,7 +149,8 @@ public void CreateCommand_ShouldHaveDryRunOption() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -161,7 +170,8 @@ public void CreateCommand_ShouldHaveSkipRequirementsOption() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -190,7 +200,8 @@ public async Task DryRun_ShouldLoadConfigAndNotExecute() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -205,7 +216,6 @@ public async Task DryRun_ShouldLoadConfigAndNotExecute() // Assert result.Should().Be(0); await _mockConfigService.Received(1).LoadAsync(Arg.Any(), Arg.Any()); - await _mockAzureValidator.DidNotReceiveWithAnyArgs().ValidateAllAsync(default!); } [Fact] @@ -225,7 +235,8 @@ public async Task DryRun_ShouldDisplayBlueprintInformation() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -262,20 +273,25 @@ public async Task CreateBlueprintImplementation_WithMissingDisplayName_ShouldThr var configFile = new FileInfo("test-config.json"); - _mockAzureValidator.ValidateAllAsync(Arg.Any()) + _mockPrerequisiteRunner.RunAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()) .Returns(true); // Note: Since DelegatedConsentService needs to run and will fail with invalid tenant, // the method returns false rather than throwing for missing display name upfront. // The display name check happens after consent, so this test verifies // the method can handle failures gracefully. - + // Act var result = await BlueprintSubcommand.CreateBlueprintImplementationAsync( config, configFile, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockLogger, skipInfrastructure: false, isSetupAll: false, @@ -305,15 +321,20 @@ public async Task CreateBlueprintImplementation_WithAzureValidationFailure_Shoul var configFile = new FileInfo("test-config.json"); - _mockAzureValidator.ValidateAllAsync(Arg.Any()) - .Returns(false); // Validation fails + _mockPrerequisiteRunner.RunAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(false); // Auth check fails // Act var result = await BlueprintSubcommand.CreateBlueprintImplementationAsync( config, configFile, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockLogger, skipInfrastructure: false, isSetupAll: false, @@ -326,7 +347,11 @@ public async Task CreateBlueprintImplementation_WithAzureValidationFailure_Shoul result.Should().NotBeNull(); result.BlueprintCreated.Should().BeFalse(); result.EndpointRegistered.Should().BeFalse(); - await _mockAzureValidator.Received(1).ValidateAllAsync(config.SubscriptionId); + await _mockPrerequisiteRunner.Received(1).RunAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()); } [Fact] @@ -337,7 +362,8 @@ public void CommandDescription_ShouldMentionRequiredPermissions() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -365,7 +391,8 @@ public async Task DryRun_WithCustomConfigPath_ShouldLoadCorrectFile() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -401,7 +428,8 @@ public async Task DryRun_ShouldNotCreateServicePrincipal() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -429,7 +457,8 @@ public void CreateCommand_ShouldHandleAllOptions() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -455,7 +484,8 @@ public async Task DryRun_WithMissingConfig_ShouldHandleGracefully() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -477,7 +507,8 @@ public void CreateCommand_DefaultConfigPath_ShouldBeA365ConfigJson() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -504,7 +535,11 @@ public async Task CreateBlueprintImplementation_ShouldLogProgressMessages() var configFile = new FileInfo("test-config.json"); - _mockAzureValidator.ValidateAllAsync(Arg.Any()) + _mockPrerequisiteRunner.RunAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()) .Returns(false); // Fail fast for this test // Act @@ -512,7 +547,8 @@ public async Task CreateBlueprintImplementation_ShouldLogProgressMessages() config, configFile, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockLogger, skipInfrastructure: false, isSetupAll: false, @@ -543,7 +579,8 @@ public void CommandDescription_ShouldBeInformativeAndActionable() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -571,7 +608,8 @@ public async Task DryRun_WithVerboseFlag_ShouldSucceed() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -605,7 +643,8 @@ public async Task DryRun_ShouldShowWhatWouldBeDone() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -637,7 +676,8 @@ public void CreateCommand_ShouldBeUsableInCommandPipeline() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -1327,7 +1367,8 @@ public void CreateCommand_ShouldHaveUpdateEndpointOption() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -1661,7 +1702,8 @@ public async Task SetHandler_WithClientAppId_ShouldConfigureGraphApiService() _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -1696,7 +1738,8 @@ public async Task SetHandler_WithoutClientAppId_ShouldNotConfigureGraphApiServic _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, @@ -1731,7 +1774,8 @@ public async Task SetHandler_WithWhitespaceClientAppId_ShouldNotConfigureGraphAp _mockLogger, _mockConfigService, _mockExecutor, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, _mockBotConfigurator, diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandBotEndpointTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandBotEndpointTests.cs index 52ca8f69..4a3f72ed 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandBotEndpointTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandBotEndpointTests.cs @@ -2,9 +2,11 @@ // Licensed under the MIT License. using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Agents.A365.DevTools.Cli.Commands; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; using NSubstitute; using Xunit; @@ -21,6 +23,8 @@ public class CleanupCommandBotEndpointTests private readonly FederatedCredentialService _federatedCredentialService; private readonly IMicrosoftGraphTokenProvider _mockTokenProvider; private readonly IConfirmationProvider _mockConfirmationProvider; + private readonly IPrerequisiteRunner _mockPrerequisiteRunner; + private readonly AzureAuthValidator _mockAuthValidator; public CleanupCommandBotEndpointTests() { @@ -76,6 +80,15 @@ public CleanupCommandBotEndpointTests() _mockConfirmationProvider = Substitute.For(); _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(true); _mockConfirmationProvider.ConfirmWithTypedResponseAsync(Arg.Any(), Arg.Any()).Returns(true); + + _mockPrerequisiteRunner = Substitute.For(); + _mockPrerequisiteRunner.RunAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + _mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, _mockExecutor); } [Fact] @@ -102,13 +115,15 @@ public void BotConfigurator_DeleteEndpoint_ShouldBeCalledIndependentlyOfWebApp() AgentBlueprintId = "blueprint-id" }; var command = CleanupCommand.CreateCommand( - _mockLogger, - _mockConfigService, - _mockBotConfigurator, - _mockExecutor, + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, - _federatedCredentialService); + _federatedCredentialService, + _mockPrerequisiteRunner, + _mockAuthValidator); Assert.NotNull(command); Assert.Equal("cleanup", command.Name); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs index cb0d0d2b..e40ec403 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs @@ -4,9 +4,11 @@ using System.CommandLine; using FluentAssertions; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Agents.A365.DevTools.Cli.Commands; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; using NSubstitute; using Xunit; @@ -24,6 +26,8 @@ public class CleanupCommandTests private readonly FederatedCredentialService _federatedCredentialService; private readonly IMicrosoftGraphTokenProvider _mockTokenProvider; private readonly IConfirmationProvider _mockConfirmationProvider; + private readonly IPrerequisiteRunner _mockPrerequisiteRunner; + private readonly AzureAuthValidator _mockAuthValidator; public CleanupCommandTests() { @@ -66,6 +70,14 @@ public CleanupCommandTests() _mockConfirmationProvider = Substitute.For(); _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(true); _mockConfirmationProvider.ConfirmWithTypedResponseAsync(Arg.Any(), Arg.Any()).Returns(true); + _mockPrerequisiteRunner = Substitute.For(); + _mockPrerequisiteRunner.RunAsync( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + _mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, _mockExecutor); } [Fact(Skip = "Test requires interactive confirmation - cleanup commands now enforce user confirmation instead of --force")] @@ -75,7 +87,7 @@ public async Task CleanupAzure_WithValidConfig_ShouldExecuteResourceDeleteComman var config = CreateValidConfig(); _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "azure", "--config", "test.json" }; // Act @@ -104,7 +116,7 @@ public async Task CleanupInstance_WithValidConfig_ShouldReturnSuccess() _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "instance", "--config", "test.json" }; var originalIn = Console.In; @@ -135,7 +147,7 @@ public async Task Cleanup_WithoutSubcommand_ShouldExecuteCompleteCleanup() var config = CreateValidConfig(); _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "--config", "test.json" }; // Act @@ -165,7 +177,7 @@ public async Task CleanupAzure_WithMissingWebAppName_ShouldStillExecuteCommand() var config = CreateConfigWithMissingWebApp(); // Create config without web app name _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "azure", "--config", "test.json" }; // Act @@ -192,7 +204,7 @@ public async Task CleanupCommand_WithInvalidConfigFile_ShouldReturnError() _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(false)); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "azure", "--config", "invalid.json" }; // Act @@ -212,7 +224,7 @@ await _mockExecutor.DidNotReceive().ExecuteAsync( public void CleanupCommand_ShouldHaveCorrectSubcommands() { // Arrange & Act - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); // Assert - Verify command structure (what users see) Assert.Equal("cleanup", command.Name); @@ -231,7 +243,7 @@ public void CleanupCommand_ShouldHaveCorrectSubcommands() public void CleanupCommand_ShouldHaveDefaultHandlerOptions() { // Arrange & Act - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); // Assert - Verify parent command has options for default handler var optionNames = command.Options.Select(opt => opt.Name).ToList(); @@ -244,7 +256,7 @@ public void CleanupCommand_ShouldHaveDefaultHandlerOptions() public void CleanupSubcommands_ShouldHaveRequiredOptions() { // Arrange & Act - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var blueprintCommand = command.Subcommands.First(sc => sc.Name == "blueprint"); // Assert - Verify user-facing options @@ -266,7 +278,7 @@ public async Task CleanupBlueprint_WithValidConfig_ShouldReturnSuccess() _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(true); var stubbedBlueprintService = CreateStubbedBlueprintService(); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, stubbedBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, stubbedBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; // Act @@ -326,7 +338,7 @@ public async Task CleanupBlueprint_WithInstances_DeletesInstancesBeforeBlueprint var command = CleanupCommand.CreateCommand( _mockLogger, _mockConfigService, _mockBotConfigurator, - _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService); + _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; // Act @@ -367,7 +379,7 @@ public async Task CleanupBlueprint_WithNoInstances_ProceedsAsNormal() var command = CleanupCommand.CreateCommand( _mockLogger, _mockConfigService, _mockBotConfigurator, - _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService); + _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; // Act @@ -415,7 +427,7 @@ public async Task CleanupBlueprint_InstanceDeletionFails_WarnsAndContinuesToBlue var command = CleanupCommand.CreateCommand( _mockLogger, _mockConfigService, _mockBotConfigurator, - _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService); + _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; // Act @@ -474,7 +486,7 @@ public async Task CleanupBlueprint_WhenBlueprintDeletionFailsWithInstances_LogsW var command = CleanupCommand.CreateCommand( _mockLogger, _mockConfigService, _mockBotConfigurator, - _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService); + _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; // Act @@ -554,7 +566,7 @@ public async Task Cleanup_WhenUserDeclinesInitialConfirmation_ShouldAbortWithout // User declines the initial "Are you sure?" confirmation _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(false); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "--config", "test.json" }; // Act @@ -582,7 +594,7 @@ public async Task Cleanup_WhenUserConfirmsButDoesNotTypeDelete_ShouldAbortWithou _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(true); _mockConfirmationProvider.ConfirmWithTypedResponseAsync(Arg.Any(), "DELETE").Returns(false); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "--config", "test.json" }; // Act @@ -606,7 +618,7 @@ public async Task Cleanup_ShouldCallConfirmationProviderWithCorrectPrompts() var config = CreateValidConfig(); _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "--config", "test.json" }; // Act @@ -632,7 +644,9 @@ public void CleanupCommand_ShouldAcceptConfirmationProviderParameter() _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, - _federatedCredentialService); + _federatedCredentialService, + _mockPrerequisiteRunner, + _mockAuthValidator); command.Should().NotBeNull(); command.Name.Should().Be("cleanup"); @@ -645,7 +659,7 @@ public void CleanupCommand_ShouldAcceptConfirmationProviderParameter() public void CleanupBlueprint_ShouldHaveEndpointOnlyOption() { // Arrange & Act - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var blueprintCommand = command.Subcommands.First(sc => sc.Name == "blueprint"); // Assert @@ -666,7 +680,7 @@ public async Task CleanupBlueprint_WithEndpointOnly_ShouldOnlyDeleteMessagingEnd _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; // Simulate user confirmation with y @@ -725,7 +739,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndNoBlueprintId_ShouldLogErr }; _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; // Act @@ -763,7 +777,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndNoBotName_ShouldLogInfo() }; _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; // Act @@ -803,7 +817,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndMissingLocation_ShouldNotC }; _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; var originalIn = Console.In; @@ -844,7 +858,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndApiException_ShouldHandleG _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromException(new InvalidOperationException("API connection failed"))); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; var originalIn = Console.In; @@ -898,7 +912,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndWhitespaceBlueprint_Should }; _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; // Act @@ -925,7 +939,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndInvalidInput_ShouldCancelC _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; var originalIn = Console.In; @@ -964,7 +978,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndNoResponse_ShouldCancelCle _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; var originalIn = Console.In; @@ -1003,7 +1017,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndEmptyInput_ShouldCancelCle _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; var originalIn = Console.In; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CreateInstanceCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CreateInstanceCommandTests.cs index e8654510..488806b7 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CreateInstanceCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CreateInstanceCommandTests.cs @@ -21,18 +21,16 @@ public class CreateInstanceCommandTests private readonly CommandExecutor _mockExecutor; private readonly IBotConfigurator _mockBotConfigurator; private readonly GraphApiService _mockGraphApiService; - private readonly IAzureValidator _mockAzureValidator; public CreateInstanceCommandTests() { _mockLogger = Substitute.For>(); - + // Use NullLogger instead of console logger to avoid I/O bottleneck _mockConfigService = Substitute.ForPartsOf(NullLogger.Instance); _mockExecutor = Substitute.ForPartsOf(NullLogger.Instance); _mockBotConfigurator = Substitute.For(); _mockGraphApiService = Substitute.ForPartsOf(NullLogger.Instance, _mockExecutor); - _mockAzureValidator = Substitute.For(); } [Fact] @@ -44,8 +42,7 @@ public void CreateInstanceCommand_Should_Not_Have_Identity_Subcommand_Due_To_Dep _mockConfigService, _mockExecutor, _mockBotConfigurator, - _mockGraphApiService, - _mockAzureValidator); + _mockGraphApiService); // Act var identitySubcommand = command.Subcommands.FirstOrDefault(c => c.Name == "identity"); @@ -63,8 +60,7 @@ public void CreateInstanceCommand_Should_Not_Have_Licenses_Subcommand_Due_To_Dep _mockConfigService, _mockExecutor, _mockBotConfigurator, - _mockGraphApiService, - _mockAzureValidator); + _mockGraphApiService); // Act var licensesSubcommand = command.Subcommands.FirstOrDefault(c => c.Name == "licenses"); @@ -82,8 +78,7 @@ public void CreateInstanceCommand_Should_Have_Handler_For_Complete_Instance_Crea _mockConfigService, _mockExecutor, _mockBotConfigurator, - _mockGraphApiService, - _mockAzureValidator); + _mockGraphApiService); // Act & Assert - Main command should have handler for running all steps Assert.NotNull(command.Handler); @@ -98,8 +93,7 @@ public void CreateInstanceCommand_Should_Log_Deprecation_Error() _mockConfigService, _mockExecutor, _mockBotConfigurator, - _mockGraphApiService, - _mockAzureValidator); + _mockGraphApiService); // Act - Command should be created successfully // Assert - Command structure is valid diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DeployCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DeployCommandTests.cs index 261e075a..11bb555a 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DeployCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DeployCommandTests.cs @@ -9,6 +9,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Commands; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Xunit; @@ -23,7 +24,8 @@ public class DeployCommandTests private readonly ConfigService _mockConfigService; private readonly CommandExecutor _mockExecutor; private readonly DeploymentService _mockDeploymentService; - private readonly IAzureValidator _mockAzureValidator; + private readonly IPrerequisiteRunner _mockPrerequisiteRunner; + private readonly AzureAuthValidator _mockAuthValidator; private readonly GraphApiService _mockGraphApiService; private readonly AgentBlueprintService _mockBlueprintService; @@ -52,7 +54,8 @@ public DeployCommandTests() mockNodeLogger, mockPythonLogger); - _mockAzureValidator = Substitute.For(); + _mockPrerequisiteRunner = Substitute.For(); + _mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, _mockExecutor); _mockGraphApiService = Substitute.ForPartsOf(Substitute.For>(), _mockExecutor); _mockBlueprintService = Substitute.ForPartsOf(Substitute.For>(), _mockGraphApiService); } @@ -66,7 +69,8 @@ public void UpdateCommand_Should_Not_Have_Atg_Subcommand() _mockConfigService, _mockExecutor, _mockDeploymentService, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockGraphApiService, _mockBlueprintService); // Act @@ -85,7 +89,8 @@ public void UpdateCommand_Should_Have_Config_Option_With_Default() _mockConfigService, _mockExecutor, _mockDeploymentService, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockGraphApiService, _mockBlueprintService); // Act @@ -105,7 +110,8 @@ public void UpdateCommand_Should_Have_Verbose_Option() _mockConfigService, _mockExecutor, _mockDeploymentService, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, _mockGraphApiService, _mockBlueprintService); // Act diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs index ee4fcd94..660ba925 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs @@ -5,6 +5,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Commands; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging; using NSubstitute; using System.CommandLine; @@ -24,7 +25,9 @@ public class SetupCommandTests private readonly CommandExecutor _mockExecutor; private readonly DeploymentService _mockDeploymentService; private readonly IBotConfigurator _mockBotConfigurator; - private readonly IAzureValidator _mockAzureValidator; + private readonly IPrerequisiteRunner _mockPrerequisiteRunner; + private readonly AzureAuthValidator _mockAuthValidator; + private readonly IAzureEnvironmentValidator _mockEnvironmentValidator; private readonly AzureWebAppCreator _mockWebAppCreator; private readonly PlatformDetector _mockPlatformDetector; private readonly GraphApiService _mockGraphApiService; @@ -53,7 +56,9 @@ public SetupCommandTests() mockNodeLogger, mockPythonLogger); _mockBotConfigurator = Substitute.For(); - _mockAzureValidator = Substitute.For(); + _mockPrerequisiteRunner = Substitute.For(); + _mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, _mockExecutor); + _mockEnvironmentValidator = Substitute.For(); _mockWebAppCreator = Substitute.ForPartsOf(Substitute.For>()); _mockGraphApiService = Substitute.For(); _mockBlueprintService = Substitute.ForPartsOf(Substitute.For>(), _mockGraphApiService); @@ -85,9 +90,11 @@ public async Task SetupAllCommand_DryRun_ValidConfig_OnlyValidatesConfig() _mockConfigService, _mockExecutor, _mockDeploymentService, - _mockBotConfigurator, - _mockAzureValidator, - _mockWebAppCreator, + _mockBotConfigurator, + _mockPrerequisiteRunner, + _mockAuthValidator, + _mockEnvironmentValidator, + _mockWebAppCreator, _mockPlatformDetector, _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); @@ -102,7 +109,6 @@ public async Task SetupAllCommand_DryRun_ValidConfig_OnlyValidatesConfig() // Dry-run mode does not load config or call Azure/Bot services - it just displays what would be done await _mockConfigService.DidNotReceiveWithAnyArgs().LoadAsync(Arg.Any(), Arg.Any()); - await _mockAzureValidator.DidNotReceiveWithAnyArgs().ValidateAllAsync(default!); await _mockBotConfigurator.DidNotReceiveWithAnyArgs().CreateEndpointWithAgentBlueprintAsync(default!, default!, default!, default!, default!); } @@ -132,9 +138,11 @@ public async Task SetupAllCommand_SkipInfrastructure_SkipsInfrastructureStep() _mockConfigService, _mockExecutor, _mockDeploymentService, - _mockBotConfigurator, - _mockAzureValidator, - _mockWebAppCreator, + _mockBotConfigurator, + _mockPrerequisiteRunner, + _mockAuthValidator, + _mockEnvironmentValidator, + _mockWebAppCreator, _mockPlatformDetector, _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); @@ -160,9 +168,11 @@ public void SetupCommand_HasRequiredSubcommands() _mockConfigService, _mockExecutor, _mockDeploymentService, - _mockBotConfigurator, - _mockAzureValidator, - _mockWebAppCreator, + _mockBotConfigurator, + _mockPrerequisiteRunner, + _mockAuthValidator, + _mockEnvironmentValidator, + _mockWebAppCreator, _mockPlatformDetector, _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); @@ -185,9 +195,11 @@ public void SetupCommand_PermissionsSubcommand_HasMcpAndBotSubcommands() _mockConfigService, _mockExecutor, _mockDeploymentService, - _mockBotConfigurator, - _mockAzureValidator, - _mockWebAppCreator, + _mockBotConfigurator, + _mockPrerequisiteRunner, + _mockAuthValidator, + _mockEnvironmentValidator, + _mockWebAppCreator, _mockPlatformDetector, _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); @@ -213,9 +225,11 @@ public void SetupCommand_ErrorMessages_ShouldBeInformativeAndActionable() _mockConfigService, _mockExecutor, _mockDeploymentService, - _mockBotConfigurator, - _mockAzureValidator, - _mockWebAppCreator, + _mockBotConfigurator, + _mockPrerequisiteRunner, + _mockAuthValidator, + _mockEnvironmentValidator, + _mockWebAppCreator, _mockPlatformDetector, _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); @@ -259,8 +273,10 @@ public async Task InfrastructureSubcommand_DryRun_CompletesSuccessfully() _mockConfigService, _mockExecutor, _mockDeploymentService, - _mockBotConfigurator, - _mockAzureValidator, + _mockBotConfigurator, + _mockPrerequisiteRunner, + _mockAuthValidator, + _mockEnvironmentValidator, _mockWebAppCreator, _mockPlatformDetector, _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); @@ -303,7 +319,9 @@ public async Task BlueprintSubcommand_DryRun_CompletesSuccessfully() _mockExecutor, _mockDeploymentService, _mockBotConfigurator, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, + _mockEnvironmentValidator, _mockWebAppCreator, _mockPlatformDetector, _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); @@ -345,7 +363,9 @@ public async Task RequirementsSubcommand_ValidConfig_CompletesSuccessfully() _mockExecutor, _mockDeploymentService, _mockBotConfigurator, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, + _mockEnvironmentValidator, _mockWebAppCreator, _mockPlatformDetector, _mockGraphApiService, @@ -389,7 +409,9 @@ public async Task RequirementsSubcommand_WithCategoryFilter_RunsFilteredChecks() _mockExecutor, _mockDeploymentService, _mockBotConfigurator, - _mockAzureValidator, + _mockPrerequisiteRunner, + _mockAuthValidator, + _mockEnvironmentValidator, _mockWebAppCreator, _mockPlatformDetector, _mockGraphApiService, diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs index 95077d95..df5f577e 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs @@ -6,8 +6,8 @@ using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Models; -using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Agents.A365.DevTools.Cli.Tests.TestHelpers; using Microsoft.Extensions.Logging; using NSubstitute; @@ -21,21 +21,20 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; /// public class SubcommandValidationTests { - private readonly IAzureValidator _mockAzureValidator; - private readonly IClientAppValidator _mockClientAppValidator; + private readonly ILogger _mockLogger; public SubcommandValidationTests() { - _mockAzureValidator = Substitute.For(); - _mockClientAppValidator = Substitute.For(); + _mockLogger = Substitute.For(); } - #region InfrastructureSubcommand Validation Tests + #region InfrastructureRequirementCheck Validation Tests [Fact] public async Task InfrastructureSubcommand_WithValidConfig_PassesValidation() { // Arrange + var check = new InfrastructureRequirementCheck(); var config = new Agent365Config { NeedDeployment = true, @@ -44,20 +43,21 @@ public async Task InfrastructureSubcommand_WithValidConfig_PassesValidation() AppServicePlanName = "test-plan", WebAppName = "test-webapp", Location = "westus", - AppServicePlanSku = "F1" // Use F1 to avoid B1 quota warning + AppServicePlanSku = "F1" }; // Act - var errors = await InfrastructureSubcommand.ValidateAsync(config, _mockAzureValidator); + var result = await check.CheckAsync(config, _mockLogger); // Assert - errors.Should().BeEmpty(); + result.Passed.Should().BeTrue(); } [Fact] public async Task InfrastructureSubcommand_WithMissingSubscriptionId_FailsValidation() { // Arrange + var check = new InfrastructureRequirementCheck(); var config = new Agent365Config { NeedDeployment = true, @@ -66,21 +66,22 @@ public async Task InfrastructureSubcommand_WithMissingSubscriptionId_FailsValida AppServicePlanName = "test-plan", WebAppName = "test-webapp", Location = "westus", - AppServicePlanSku = "F1" // Use F1 to avoid B1 quota warning + AppServicePlanSku = "F1" }; // Act - var errors = await InfrastructureSubcommand.ValidateAsync(config, _mockAzureValidator); + var result = await check.CheckAsync(config, _mockLogger); // Assert - errors.Should().ContainSingle() - .Which.Should().Contain("subscriptionId"); + result.Passed.Should().BeFalse(); + result.ErrorMessage.Should().Contain("subscriptionId"); } [Fact] public async Task InfrastructureSubcommand_WithMissingResourceGroup_FailsValidation() { // Arrange + var check = new InfrastructureRequirementCheck(); var config = new Agent365Config { NeedDeployment = true, @@ -89,21 +90,22 @@ public async Task InfrastructureSubcommand_WithMissingResourceGroup_FailsValidat AppServicePlanName = "test-plan", WebAppName = "test-webapp", Location = "westus", - AppServicePlanSku = "F1" // Use F1 to avoid B1 quota warning + AppServicePlanSku = "F1" }; // Act - var errors = await InfrastructureSubcommand.ValidateAsync(config, _mockAzureValidator); + var result = await check.CheckAsync(config, _mockLogger); // Assert - errors.Should().ContainSingle() - .Which.Should().Contain("resourceGroup"); + result.Passed.Should().BeFalse(); + result.ErrorMessage.Should().Contain("resourceGroup"); } [Fact] public async Task InfrastructureSubcommand_WithMultipleMissingFields_ReturnsAllErrors() { // Arrange + var check = new InfrastructureRequirementCheck(); var config = new Agent365Config { NeedDeployment = true, @@ -112,23 +114,24 @@ public async Task InfrastructureSubcommand_WithMultipleMissingFields_ReturnsAllE AppServicePlanName = "", WebAppName = "test-webapp", Location = "westus", - AppServicePlanSku = "F1" // Use F1 to avoid B1 quota warning + AppServicePlanSku = "F1" }; // Act - var errors = await InfrastructureSubcommand.ValidateAsync(config, _mockAzureValidator); + var result = await check.CheckAsync(config, _mockLogger); // Assert - errors.Should().HaveCount(3); - errors.Should().Contain(e => e.Contains("subscriptionId")); - errors.Should().Contain(e => e.Contains("resourceGroup")); - errors.Should().Contain(e => e.Contains("appServicePlanName")); + result.Passed.Should().BeFalse(); + result.ErrorMessage.Should().Contain("subscriptionId"); + result.ErrorMessage.Should().Contain("resourceGroup"); + result.ErrorMessage.Should().Contain("appServicePlanName"); } [Fact] public async Task InfrastructureSubcommand_WhenNeedDeploymentFalse_SkipsValidation() { // Arrange + var check = new InfrastructureRequirementCheck(); var config = new Agent365Config { NeedDeployment = false, @@ -140,16 +143,17 @@ public async Task InfrastructureSubcommand_WhenNeedDeploymentFalse_SkipsValidati }; // Act - var errors = await InfrastructureSubcommand.ValidateAsync(config, _mockAzureValidator); + var result = await check.CheckAsync(config, _mockLogger); // Assert - errors.Should().BeEmpty(); + result.Passed.Should().BeTrue(); } [Fact] public async Task InfrastructureSubcommand_WithInvalidSku_FailsValidation() { // Arrange + var check = new InfrastructureRequirementCheck(); var config = new Agent365Config { NeedDeployment = true, @@ -162,17 +166,18 @@ public async Task InfrastructureSubcommand_WithInvalidSku_FailsValidation() }; // Act - var errors = await InfrastructureSubcommand.ValidateAsync(config, _mockAzureValidator); + var result = await check.CheckAsync(config, _mockLogger); // Assert - errors.Should().ContainSingle() - .Which.Should().Contain("Invalid appServicePlanSku"); + result.Passed.Should().BeFalse(); + result.ErrorMessage.Should().Contain("Invalid appServicePlanSku"); } [Fact] public async Task InfrastructureSubcommand_WithB1Sku_PassesValidation() { // Arrange + var check = new InfrastructureRequirementCheck(); var config = new Agent365Config { NeedDeployment = true, @@ -185,10 +190,10 @@ public async Task InfrastructureSubcommand_WithB1Sku_PassesValidation() }; // Act - var errors = await InfrastructureSubcommand.ValidateAsync(config, _mockAzureValidator); + var result = await check.CheckAsync(config, _mockLogger); - // Assert - B1 quota warning is now logged at execution time, not during validation - errors.Should().BeEmpty(); + // Assert + result.Passed.Should().BeTrue(); } [Theory] @@ -201,6 +206,7 @@ public async Task InfrastructureSubcommand_WithB1Sku_PassesValidation() public async Task InfrastructureSubcommand_WithValidSku_PassesValidationOrWarning(string sku) { // Arrange + var check = new InfrastructureRequirementCheck(); var config = new Agent365Config { NeedDeployment = true, @@ -213,48 +219,10 @@ public async Task InfrastructureSubcommand_WithValidSku_PassesValidationOrWarnin }; // Act - var errors = await InfrastructureSubcommand.ValidateAsync(config, _mockAzureValidator); - - // Assert - All valid SKUs pass validation (B1 quota warning is logged at execution time) - errors.Should().BeEmpty(); - } - - #endregion - - #region BlueprintSubcommand Validation Tests - - [Fact] - public async Task BlueprintSubcommand_WithValidConfig_PassesValidation() - { - // Arrange - var config = new Agent365Config - { - ClientAppId = "12345678-1234-1234-1234-123456789012" - }; - - // Act - var errors = await BlueprintSubcommand.ValidateAsync(config, _mockAzureValidator, _mockClientAppValidator); - - // Assert - errors.Should().BeEmpty(); - } - - [Fact] - public async Task BlueprintSubcommand_WithMissingClientAppId_FailsValidation() - { - // Arrange - var config = new Agent365Config - { - ClientAppId = "" - }; - - // Act - var errors = await BlueprintSubcommand.ValidateAsync(config, _mockAzureValidator, _mockClientAppValidator); + var result = await check.CheckAsync(config, _mockLogger); // Assert - errors.Should().HaveCountGreaterThan(0); - errors.Should().Contain(e => e.Contains("clientAppId")); - errors.Should().Contain(e => e.Contains("learn.microsoft.com")); + result.Passed.Should().BeTrue(); } #endregion diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/PrerequisiteRunnerTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/PrerequisiteRunnerTests.cs new file mode 100644 index 00000000..cffdbd31 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/PrerequisiteRunnerTests.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; +using Microsoft.Agents.A365.DevTools.Cli.Tests.TestHelpers; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; + +/// +/// Unit tests for PrerequisiteRunner +/// +public class PrerequisiteRunnerTests +{ + private readonly ILogger _mockLogger; + private readonly Agent365Config _config; + + public PrerequisiteRunnerTests() + { + _mockLogger = Substitute.For(); + _config = new Agent365Config { TenantId = "test-tenant", SubscriptionId = "test-sub" }; + } + + [Fact] + public async Task RunAsync_WithEmptyChecks_ShouldReturnTrue() + { + // Arrange + var runner = new PrerequisiteRunner(); + var checks = new List(); + + // Act + var result = await runner.RunAsync(checks, _config, _mockLogger); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task RunAsync_WithAllPassingChecks_ShouldReturnTrue() + { + // Arrange + var runner = new PrerequisiteRunner(); + var checks = new List + { + new AlwaysPassRequirementCheck(), + new AlwaysPassRequirementCheck() + }; + + // Act + var result = await runner.RunAsync(checks, _config, _mockLogger); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public async Task RunAsync_WithOneFailingCheck_ShouldReturnFalse() + { + // Arrange + var runner = new PrerequisiteRunner(); + var checks = new List + { + new AlwaysPassRequirementCheck(), + new AlwaysFailRequirementCheck() + }; + + // Act + var result = await runner.RunAsync(checks, _config, _mockLogger); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task RunAsync_WithFailingCheck_ShouldLogError() + { + // Arrange + var runner = new PrerequisiteRunner(); + var checks = new List { new AlwaysFailRequirementCheck() }; + + // Act + await runner.RunAsync(checks, _config, _mockLogger); + + // Assert - should log an error for the failing check + _mockLogger.Received().Log( + LogLevel.Error, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task RunAsync_WithMultipleFailingChecks_ShouldReturnFalseAndLogAll() + { + // Arrange + var runner = new PrerequisiteRunner(); + var checks = new List + { + new AlwaysFailRequirementCheck(), + new AlwaysFailRequirementCheck() + }; + + // Act + var result = await runner.RunAsync(checks, _config, _mockLogger); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public async Task RunAsync_WithWarningCheck_ShouldReturnTrueAndLogWarning() + { + // Arrange + var runner = new PrerequisiteRunner(); + var mockCheck = Substitute.For(); + mockCheck.Name.Returns("Warning Check"); + mockCheck.CheckAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(RequirementCheckResult.Warning("This is a warning", "Warning details")); + + var checks = new List { mockCheck }; + + // Act + var result = await runner.RunAsync(checks, _config, _mockLogger); + + // Assert + result.Should().BeTrue("a warning does not block execution"); + _mockLogger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task RunAsync_ChecksAreRunInOrder() + { + // Arrange + var runner = new PrerequisiteRunner(); + var executionOrder = new List(); + + var check1 = Substitute.For(); + check1.Name.Returns("Check1"); + check1.CheckAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => { executionOrder.Add("Check1"); return Task.FromResult(RequirementCheckResult.Success()); }); + + var check2 = Substitute.For(); + check2.Name.Returns("Check2"); + check2.CheckAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => { executionOrder.Add("Check2"); return Task.FromResult(RequirementCheckResult.Success()); }); + + var checks = new List { check1, check2 }; + + // Act + await runner.RunAsync(checks, _config, _mockLogger); + + // Assert + executionOrder.Should().Equal("Check1", "Check2"); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/AzureAuthRequirementCheckTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/AzureAuthRequirementCheckTests.cs new file mode 100644 index 00000000..e803b4e0 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/AzureAuthRequirementCheckTests.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services.Requirements; + +/// +/// Unit tests for AzureAuthRequirementCheck +/// +public class AzureAuthRequirementCheckTests +{ + private readonly AzureAuthValidator _mockAuthValidator; + private readonly ILogger _mockLogger; + + public AzureAuthRequirementCheckTests() + { + var mockExecutor = Substitute.ForPartsOf(NullLogger.Instance); + _mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, mockExecutor); + _mockLogger = Substitute.For(); + } + + [Fact] + public async Task CheckAsync_WhenAuthenticationSucceeds_ShouldReturnSuccess() + { + // Arrange + var check = new AzureAuthRequirementCheck(_mockAuthValidator); + var config = new Agent365Config { SubscriptionId = "test-sub-id" }; + + _mockAuthValidator.ValidateAuthenticationAsync(Arg.Any()) + .Returns(true); + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert + result.Should().NotBeNull(); + result.Passed.Should().BeTrue(); + result.ErrorMessage.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task CheckAsync_WhenAuthenticationFails_ShouldReturnFailure() + { + // Arrange + var check = new AzureAuthRequirementCheck(_mockAuthValidator); + var config = new Agent365Config { SubscriptionId = "test-sub-id" }; + + _mockAuthValidator.ValidateAuthenticationAsync(Arg.Any()) + .Returns(false); + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert + result.Should().NotBeNull(); + result.Passed.Should().BeFalse(); + result.ErrorMessage.Should().Contain("Azure CLI authentication failed"); + result.ResolutionGuidance.Should().Contain("az login"); + } + + [Fact] + public async Task CheckAsync_ShouldPassSubscriptionIdToValidator() + { + // Arrange + var check = new AzureAuthRequirementCheck(_mockAuthValidator); + var config = new Agent365Config { SubscriptionId = "specific-sub-id" }; + + _mockAuthValidator.ValidateAuthenticationAsync(Arg.Any()) + .Returns(true); + + // Act + await check.CheckAsync(config, _mockLogger); + + // Assert + await _mockAuthValidator.Received(1).ValidateAuthenticationAsync("specific-sub-id"); + } + + [Fact] + public async Task CheckAsync_WithEmptySubscriptionId_ShouldPassEmptyStringToValidator() + { + // Arrange + var check = new AzureAuthRequirementCheck(_mockAuthValidator); + var config = new Agent365Config(); + + _mockAuthValidator.ValidateAuthenticationAsync(Arg.Any()) + .Returns(true); + + // Act + await check.CheckAsync(config, _mockLogger); + + // Assert + await _mockAuthValidator.Received(1).ValidateAuthenticationAsync(string.Empty); + } + + [Fact] + public void Metadata_ShouldHaveCorrectName() + { + // Arrange + var check = new AzureAuthRequirementCheck(_mockAuthValidator); + + // Act & Assert + check.Name.Should().Be("Azure Authentication"); + } + + [Fact] + public void Metadata_ShouldHaveCorrectCategory() + { + // Arrange + var check = new AzureAuthRequirementCheck(_mockAuthValidator); + + // Act & Assert + check.Category.Should().Be("Azure"); + } + + [Fact] + public void Constructor_WithNullValidator_ShouldThrowArgumentNullException() + { + // Act & Assert + var act = () => new AzureAuthRequirementCheck(null!); + act.Should().Throw() + .WithParameterName("authValidator"); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/InfrastructureRequirementCheckTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/InfrastructureRequirementCheckTests.cs new file mode 100644 index 00000000..b8055191 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/InfrastructureRequirementCheckTests.cs @@ -0,0 +1,245 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services.Requirements; + +/// +/// Unit tests for InfrastructureRequirementCheck +/// +public class InfrastructureRequirementCheckTests +{ + private readonly ILogger _mockLogger; + + public InfrastructureRequirementCheckTests() + { + _mockLogger = Substitute.For(); + } + + [Fact] + public async Task CheckAsync_WhenNeedDeploymentFalse_ShouldReturnSuccess() + { + // Arrange + var check = new InfrastructureRequirementCheck(); + var config = new Agent365Config + { + NeedDeployment = false, + SubscriptionId = "", + ResourceGroup = "", + AppServicePlanName = "", + WebAppName = "", + Location = "" + }; + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert + result.Passed.Should().BeTrue(); + result.ErrorMessage.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task CheckAsync_WithAllRequiredFields_ShouldReturnSuccess() + { + // Arrange + var check = new InfrastructureRequirementCheck(); + var config = new Agent365Config + { + NeedDeployment = true, + SubscriptionId = "test-sub-id", + ResourceGroup = "test-rg", + AppServicePlanName = "test-plan", + WebAppName = "test-webapp", + Location = "eastus", + AppServicePlanSku = "B1" + }; + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert + result.Passed.Should().BeTrue(); + result.ErrorMessage.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task CheckAsync_WithMissingSubscriptionId_ShouldReturnFailure() + { + // Arrange + var check = new InfrastructureRequirementCheck(); + var config = new Agent365Config + { + NeedDeployment = true, + SubscriptionId = "", + ResourceGroup = "test-rg", + AppServicePlanName = "test-plan", + WebAppName = "test-webapp", + Location = "eastus", + AppServicePlanSku = "B1" + }; + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert + result.Passed.Should().BeFalse(); + result.ErrorMessage.Should().Contain("subscriptionId"); + result.ResolutionGuidance.Should().Contain("a365.config.json"); + } + + [Fact] + public async Task CheckAsync_WithMissingResourceGroup_ShouldReturnFailure() + { + // Arrange + var check = new InfrastructureRequirementCheck(); + var config = new Agent365Config + { + NeedDeployment = true, + SubscriptionId = "test-sub-id", + ResourceGroup = "", + AppServicePlanName = "test-plan", + WebAppName = "test-webapp", + Location = "eastus", + AppServicePlanSku = "B1" + }; + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert + result.Passed.Should().BeFalse(); + result.ErrorMessage.Should().Contain("resourceGroup"); + } + + [Fact] + public async Task CheckAsync_WithMissingAppServicePlanName_ShouldReturnFailure() + { + // Arrange + var check = new InfrastructureRequirementCheck(); + var config = new Agent365Config + { + NeedDeployment = true, + SubscriptionId = "test-sub-id", + ResourceGroup = "test-rg", + AppServicePlanName = "", + WebAppName = "test-webapp", + Location = "eastus", + AppServicePlanSku = "B1" + }; + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert + result.Passed.Should().BeFalse(); + result.ErrorMessage.Should().Contain("appServicePlanName"); + } + + [Fact] + public async Task CheckAsync_WithMultipleMissingFields_ShouldIncludeAllErrorsInMessage() + { + // Arrange + var check = new InfrastructureRequirementCheck(); + var config = new Agent365Config + { + NeedDeployment = true, + SubscriptionId = "", + ResourceGroup = "", + AppServicePlanName = "", + WebAppName = "test-webapp", + Location = "eastus", + AppServicePlanSku = "F1" + }; + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert + result.Passed.Should().BeFalse(); + result.ErrorMessage.Should().Contain("subscriptionId"); + result.ErrorMessage.Should().Contain("resourceGroup"); + result.ErrorMessage.Should().Contain("appServicePlanName"); + } + + [Fact] + public async Task CheckAsync_WithInvalidSku_ShouldReturnFailure() + { + // Arrange + var check = new InfrastructureRequirementCheck(); + var config = new Agent365Config + { + NeedDeployment = true, + SubscriptionId = "test-sub-id", + ResourceGroup = "test-rg", + AppServicePlanName = "test-plan", + WebAppName = "test-webapp", + Location = "eastus", + AppServicePlanSku = "INVALID_SKU" + }; + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert + result.Passed.Should().BeFalse(); + result.ErrorMessage.Should().Contain("Invalid appServicePlanSku"); + result.ErrorMessage.Should().Contain("INVALID_SKU"); + } + + [Theory] + [InlineData("F1")] + [InlineData("B1")] + [InlineData("B2")] + [InlineData("B3")] + [InlineData("S1")] + [InlineData("P1V2")] + [InlineData("P1V3")] + public async Task CheckAsync_WithValidSku_ShouldReturnSuccess(string sku) + { + // Arrange + var check = new InfrastructureRequirementCheck(); + var config = new Agent365Config + { + NeedDeployment = true, + SubscriptionId = "test-sub-id", + ResourceGroup = "test-rg", + AppServicePlanName = "test-plan", + WebAppName = "test-webapp", + Location = "eastus", + AppServicePlanSku = sku + }; + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert + result.Passed.Should().BeTrue(); + } + + [Fact] + public void Metadata_ShouldHaveCorrectName() + { + // Arrange + var check = new InfrastructureRequirementCheck(); + + // Act & Assert + check.Name.Should().Be("Infrastructure Configuration"); + } + + [Fact] + public void Metadata_ShouldHaveCorrectCategory() + { + // Arrange + var check = new InfrastructureRequirementCheck(); + + // Act & Assert + check.Category.Should().Be("Configuration"); + } +} From 2cd8540f56523303d47a75014cfb638710d6f53f Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Fri, 6 Mar 2026 16:08:47 -0800 Subject: [PATCH 02/11] fix: three CLI polish fixes - ConfigFileNotFoundException now extends Agent365Exception so missing config errors surface as clean user messages (no stack trace) on all commands, not just those with local catch blocks. Removes ad-hoc FileNotFoundException catches in CleanupCommand and CreateInstanceCommand. - config init: expand relative/dot deployment paths to absolute before saving so the stored value is portable across directories. Update help text to clarify relative paths are accepted. - config init: drop platform-specific parenthetical from 'Allow public client flows' log message -- the setting is required on all platforms. Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/CleanupCommand.cs | 5 +++-- .../Commands/CreateInstanceCommand.cs | 12 +++--------- .../Exceptions/ConfigFileNotFoundException.cs | 19 +++++++++---------- .../Services/ClientAppValidator.cs | 2 +- .../Services/ConfigurationWizardService.cs | 6 +++--- 5 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs index d1d3ac63..fcde04d6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; @@ -971,9 +972,9 @@ private static void PrintOrphanSummary( logger.LogInformation("Loaded configuration successfully from {ConfigFile}", configPath); return config; } - catch (FileNotFoundException ex) + catch (ConfigFileNotFoundException ex) { - logger.LogError("Configuration file not found: {Message}", ex.Message); + logger.LogError("Configuration file not found: {Message}", ex.IssueDescription); return null; } catch (Exception ex) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs index bd44e63b..4134c95c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs @@ -4,6 +4,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Extensions.Logging; @@ -499,16 +500,9 @@ private static Command CreateLicensesSubcommand( : await configService.LoadAsync(); return config; } - catch (FileNotFoundException ex) + catch (ConfigFileNotFoundException ex) { - logger.LogError("Configuration file not found: {Message}", ex.Message); - logger.LogInformation(""); - logger.LogInformation("To get started:"); - logger.LogInformation(" 1. Copy a365.config.example.json to a365.config.json"); - logger.LogInformation(" 2. Edit a365.config.json with your Azure tenant and subscription details"); - logger.LogInformation(" 3. Run 'a365 setup' to initialize your environment first"); - logger.LogInformation(" 4. Then run 'a365 createinstance' to create agent instances"); - logger.LogInformation(""); + logger.LogError("Configuration file not found: {Message}", ex.IssueDescription); return null; } catch (Exception ex) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ConfigFileNotFoundException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ConfigFileNotFoundException.cs index d15f7906..e23fb618 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ConfigFileNotFoundException.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ConfigFileNotFoundException.cs @@ -1,26 +1,25 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.IO; - namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions; /// /// Exception thrown when the a365.config.json configuration file cannot be found. /// This is a USER ERROR - the file is missing or the command was run from the wrong directory. -/// Derives from FileNotFoundException so existing callers that catch FileNotFoundException -/// continue to work without changes. /// -public class ConfigFileNotFoundException : FileNotFoundException +public class ConfigFileNotFoundException : Agent365Exception { public ConfigFileNotFoundException(string configFilePath) : base( - message: $"Configuration file not found: {configFilePath}. " + - "Make sure you are running this command from your agent project directory. " + - "If you have not created a configuration file yet, run: a365 config init", - fileName: configFilePath) + errorCode: "CONFIG_NOT_FOUND", + issueDescription: $"Configuration file not found: {configFilePath}", + mitigationSteps: + [ + "Make sure you are running this command from your agent project directory.", + "If you have not created a configuration file yet, run: a365 config init" + ]) { } - public int ExitCode => 2; // Configuration error + public override int ExitCode => 2; // Configuration error } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs index bd724030..a62bac7a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs @@ -292,7 +292,7 @@ private async Task EnsurePublicClientFlowsEnabledAsync( return; } - _logger.LogInformation("Enabling 'Allow public client flows' on app registration (required for device code authentication fallback on macOS/Linux)."); + _logger.LogInformation("Enabling 'Allow public client flows' on app registration (required for device code authentication fallback)."); _logger.LogInformation("Run 'a365 setup requirements' at any time to re-verify and auto-fix this setting."); var patchBody = "{\"isFallbackPublicClient\":true}"; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs index a7597662..bc796728 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs @@ -338,8 +338,8 @@ private string PromptForDeploymentPath(Agent365Config? existingConfig) Console.WriteLine("This is used to detect your project type (.NET, Node.js, or Python)"); Console.WriteLine("and as the source directory for Azure App Service deployment."); Console.WriteLine(); - Console.WriteLine(" Use '.' if you are already running this command from your project folder."); - Console.WriteLine(@" Example: /home/user/my-agent or C:\Projects\my-agent"); + Console.WriteLine(" Absolute and relative paths are both accepted and will be resolved to a full path."); + Console.WriteLine(@" Example: /home/user/my-agent or C:\Projects\my-agent or ."); Console.WriteLine("================================================================="); Console.WriteLine(); @@ -371,7 +371,7 @@ private string PromptForDeploymentPath(Agent365Config? existingConfig) } } - return path; + return Path.GetFullPath(path); } private async Task<(string name, string? location)> PromptForResourceGroupAsync(Agent365Config? existingConfig) From 7257a565da3cffb46cb1ef363d176b7a1358cf13 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Fri, 6 Mar 2026 18:37:21 -0800 Subject: [PATCH 03/11] Polish CLI output: reduce noise, fix ordering, add TraceId - Move "Running all setup steps..." to after requirements check output - Remove redundant "Agent 365 Setup" header (user already knows the command) - Change CorrelationId log to LogDebug for setup all and blueprint; surface as TraceId inline on the action line ("Running all setup steps... (TraceId: ...)") so it is always captured in setup.log as [INF] and visible on console - Demote PlatformDetector internal logs to LogDebug; single "Detected project platform: X" line remains as the user-facing output - Add AzureAuthRequirementCheck to GetConfigRequirementChecks so Azure auth appears in requirements output for all setup subcommands - Remove redundant mid-execution auth gate from BlueprintSubcommand that caused duplicate [PASS] Azure Authentication output - Fix RequirementCheck base class: use LogInformation for all check result lines to avoid WARNING:/ERROR: prefix doubling from logger formatter - Collapse verbose requirements summary to single line: "Requirements: X passed, Y warnings, Z failed" - Update tests to match new message text and log level assertions Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/SetupCommand.cs | 2 +- .../SetupSubcommands/AllSubcommand.cs | 99 ++++--------------- .../SetupSubcommands/BlueprintSubcommand.cs | 26 ++--- .../RequirementsSubcommand.cs | 41 +++----- .../Services/PlatformDetector.cs | 8 +- .../Services/Requirements/RequirementCheck.cs | 29 ++---- .../AzureAuthRequirementCheck.cs | 10 +- .../ClientAppRequirementCheck.cs | 2 +- .../FrontierPreviewRequirementCheck.cs | 21 +--- .../LocationRequirementCheck.cs | 2 +- .../PowerShellModulesRequirementCheck.cs | 5 +- .../Commands/BlueprintSubcommandTests.cs | 56 ----------- .../Commands/RequirementsSubcommandTests.cs | 14 ++- .../ClientAppRequirementCheckTests.cs | 1 - .../FrontierPreviewRequirementCheckTests.cs | 22 ++--- 15 files changed, 83 insertions(+), 255 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 8fd6c909..064fc007 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -45,7 +45,7 @@ public static Command CreateCommand( // Add subcommands command.AddCommand(RequirementsSubcommand.CreateCommand( - logger, configService, clientAppValidator)); + logger, configService, authValidator, clientAppValidator)); command.AddCommand(InfrastructureSubcommand.CreateCommand( logger, configService, prerequisiteRunner, authValidator, webAppCreator, platformDetector, executor)); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs index 21cc8623..3de16b5d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -79,7 +79,7 @@ public static Command CreateCommand( { // Generate correlation ID at workflow entry point var correlationId = HttpClientFactory.GenerateCorrelationId(); - logger.LogInformation("Starting setup all (CorrelationId: {CorrelationId})", correlationId); + logger.LogDebug("Starting setup all (CorrelationId: {CorrelationId})", correlationId); if (dryRun) { @@ -113,61 +113,12 @@ public static Command CreateCommand( return; } - logger.LogInformation("Agent 365 Setup"); - logger.LogInformation("Running all setup steps..."); - - if (skipRequirements) - { - logger.LogInformation("NOTE: Skipping requirements validation (--skip-requirements flag used)"); - } - - if (skipInfrastructure) - { - logger.LogInformation("NOTE: Skipping infrastructure creation (--skip-infrastructure flag used)"); - } - - logger.LogInformation(""); var setupResults = new SetupResults(); try { - // PHASE 0a: CHECK SYSTEM REQUIREMENTS before loading config (if not skipped) - // Runs config-independent checks (PowerShell, Frontier Preview) so blockers - // are surfaced before the user is asked to fill in the configuration wizard. - if (!skipRequirements) - { - logger.LogDebug("Validating system prerequisites..."); - - try - { - var systemResult = await RequirementsSubcommand.RunRequirementChecksAsync( - RequirementsSubcommand.GetSystemRequirementChecks(), - new Agent365Config(), - logger, - category: null, - CancellationToken.None); - - if (!systemResult) - { - logger.LogError("Setup cannot proceed due to the failed requirement checks above. Please fix the issues above and then try again."); - ExceptionHandler.ExitWithCleanup(1); - } - } - catch (Exception reqEx) - { - logger.LogError(reqEx, "Requirements check failed with an unexpected error: {Message}", reqEx.Message); - logger.LogError("Setup cannot proceed because system requirement validation failed unexpectedly."); - logger.LogError("If you want to bypass requirement validation, rerun this command with the --skip-requirements flag."); - ExceptionHandler.ExitWithCleanup(1); - } - } - else - { - logger.LogDebug("Skipping requirements validation (--skip-requirements flag used)"); - } - - // PHASE 0b: Load configuration (may trigger interactive wizard) + // Load configuration var setupConfig = await configService.LoadAsync(config.FullName); // Configure GraphApiService with custom client app ID if available @@ -177,21 +128,19 @@ public static Command CreateCommand( graphApiService.CustomClientAppId = setupConfig.ClientAppId; } - // PHASE 0c: CHECK CONFIG-DEPENDENT REQUIREMENTS after the wizard + // Validate all prerequisites in one pass if (!skipRequirements) { - logger.LogDebug("Validating configuration prerequisites..."); + var checks = RequirementsSubcommand.GetRequirementChecks(authValidator, clientAppValidator); + if (!skipInfrastructure && setupConfig.NeedDeployment) + checks.Add(new InfrastructureRequirementCheck()); try { - var configResult = await RequirementsSubcommand.RunRequirementChecksAsync( - RequirementsSubcommand.GetConfigRequirementChecks(clientAppValidator), - setupConfig, - logger, - category: null, - CancellationToken.None); - - if (!configResult) + var requirementsResult = await RequirementsSubcommand.RunRequirementChecksAsync( + checks, setupConfig, logger, category: null, CancellationToken.None); + + if (!requirementsResult) { logger.LogError("Setup cannot proceed due to the failed requirement checks above. Please fix the issues above and then try again."); ExceptionHandler.ExitWithCleanup(1); @@ -200,36 +149,23 @@ public static Command CreateCommand( catch (Exception reqEx) { logger.LogError(reqEx, "Requirements check failed with an unexpected error: {Message}", reqEx.Message); - logger.LogError("Setup cannot proceed because configuration requirement validation failed unexpectedly."); logger.LogError("If you want to bypass requirement validation, rerun this command with the --skip-requirements flag."); ExceptionHandler.ExitWithCleanup(1); } } - // PHASE 1: VALIDATE ALL PREREQUISITES UPFRONT - logger.LogDebug("Validating all prerequisites..."); - - var prereqChecks = new List - { - new AzureAuthRequirementCheck(authValidator), - new ClientAppRequirementCheck(clientAppValidator) - }; - - if (!skipInfrastructure && setupConfig.NeedDeployment) - prereqChecks.Add(new InfrastructureRequirementCheck()); - // Run advisory environment check (warning only, never blocks) await environmentValidator.ValidateEnvironmentAsync(); - if (!await prerequisiteRunner.RunAsync(prereqChecks, setupConfig, logger, CancellationToken.None)) - { - logger.LogError("Setup cannot proceed due to prerequisite validation failures. Please fix the errors above and try again."); - setupResults.Errors.Add("Prerequisite validation failed"); - ExceptionHandler.ExitWithCleanup(1); - } - logger.LogDebug("All validations passed. Starting setup execution..."); + logger.LogInformation("Running all setup steps... (TraceId: {TraceId})", correlationId); + if (skipRequirements) + logger.LogInformation("NOTE: Requirements validation skipped (--skip-requirements flag used)"); + if (skipInfrastructure) + logger.LogInformation("NOTE: Infrastructure creation skipped (--skip-infrastructure flag used)"); + logger.LogInformation(""); + var generatedConfigPath = Path.Combine( config.DirectoryName ?? Environment.CurrentDirectory, "a365.generated.config.json"); @@ -272,7 +208,6 @@ public static Command CreateCommand( setupConfig, config, executor, - prerequisiteRunner, authValidator, logger, skipInfrastructure, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index 5dbc1981..95f83242 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -124,7 +124,7 @@ public static Command CreateCommand( { // Generate correlation ID at workflow entry point var correlationId = HttpClientFactory.GenerateCorrelationId(); - logger.LogInformation("Starting blueprint setup (CorrelationId: {CorrelationId})", correlationId); + logger.LogDebug("Starting blueprint setup (CorrelationId: {CorrelationId})", correlationId); // Validate mutually exclusive options if (!ValidateMutuallyExclusiveOptions( @@ -184,7 +184,7 @@ await UpdateEndpointAsync( try { var requirementsResult = await RequirementsSubcommand.RunRequirementChecksAsync( - RequirementsSubcommand.GetRequirementChecks(clientAppValidator), + RequirementsSubcommand.GetRequirementChecks(authValidator, clientAppValidator), setupConfig, logger, category: null, @@ -219,6 +219,8 @@ await UpdateEndpointAsync( return; } + logger.LogInformation("Starting blueprint setup... (TraceId: {TraceId})", correlationId); + // Handle --endpoint-only flag if (endpointOnly) { @@ -256,7 +258,6 @@ await CreateBlueprintImplementationAsync( setupConfig, config, executor, - prerequisiteRunner, authValidator, logger, false, @@ -321,7 +322,6 @@ public static async Task CreateBlueprintImplementationA Models.Agent365Config setupConfig, FileInfo config, CommandExecutor executor, - IPrerequisiteRunner prerequisiteRunner, AzureAuthValidator authValidator, ILogger logger, bool skipInfrastructure, @@ -355,21 +355,6 @@ public static async Task CreateBlueprintImplementationA logger.LogInformation(""); logger.LogInformation("==> Creating Agent Blueprint"); - // Validate Azure authentication - var authChecks = new List - { - new AzureAuthRequirementCheck(authValidator) - }; - if (!await prerequisiteRunner.RunAsync(authChecks, setupConfig, logger, cancellationToken)) - { - return new BlueprintCreationResult - { - BlueprintCreated = false, - EndpointRegistered = false, - EndpointRegistrationAttempted = false - }; - } - var generatedConfigPath = Path.Combine( config.DirectoryName ?? Environment.CurrentDirectory, "a365.generated.config.json"); @@ -1143,7 +1128,8 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( tenantId, objectId, userObjectId: null, - ct); + ct, + scopes: AuthenticationConstants.RequiredClientAppPermissions); if (isOwner) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs index 104f5187..f1104679 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs @@ -19,6 +19,7 @@ internal static class RequirementsSubcommand public static Command CreateCommand( ILogger logger, IConfigService configService, + AzureAuthValidator authValidator, IClientAppValidator clientAppValidator) { var command = new Command("requirements", @@ -57,7 +58,7 @@ public static Command CreateCommand( { // Load configuration var setupConfig = await configService.LoadAsync(config.FullName); - var requirementChecks = GetRequirementChecks(clientAppValidator); + var requirementChecks = GetRequirementChecks(authValidator, clientAppValidator); await RunRequirementChecksAsync(requirementChecks, setupConfig, logger, category); } catch (Exception ex) @@ -101,14 +102,13 @@ public static async Task RunRequirementChecksAsync( var warningChecks = 0; var failedChecks = 0; + logger.LogInformation("Checking requirements..."); + // Execute all checks (grouped by category but headers not shown) foreach (var categoryGroup in checksByCategory) { foreach (var check in categoryGroup) { - // Add spacing before each check for readability - Console.WriteLine(); - var result = await check.CheckAsync(setupConfig, logger, ct); if (result.Passed) @@ -129,29 +129,9 @@ public static async Task RunRequirementChecksAsync( } } - // Display summary - logger.LogInformation("Requirements Check Summary"); - logger.LogInformation(new string('=', 50)); - logger.LogInformation("Total checks: {Total}", totalChecks); - logger.LogInformation("Passed: {Passed}", passedChecks); - logger.LogInformation("Warning: {Warning}", warningChecks); - logger.LogInformation("Failed: {Failed}", failedChecks); Console.WriteLine(); - - if (failedChecks > 0) - { - logger.LogError("Some requirements failed. Please address the issues above before running setup."); - logger.LogInformation("Use the resolution guidance provided for each failed check."); - } - else if (warningChecks > 0) - { - logger.LogWarning("All automated checks passed, but {WarningCount} requirement(s) require manual verification.", warningChecks); - logger.LogInformation("Please review the warnings above and ensure all requirements are met before running setup."); - } - else - { - logger.LogInformation("All requirements passed! You're ready to run Agent 365 setup."); - } + logger.LogInformation("Requirements: {Passed} passed, {Warning} warnings, {Failed} failed", + passedChecks, warningChecks, failedChecks); return failedChecks == 0; } @@ -160,10 +140,10 @@ public static async Task RunRequirementChecksAsync( /// Gets all available requirement checks. /// Derived from the union of system and config checks to keep a single source of truth. /// - public static List GetRequirementChecks(IClientAppValidator clientAppValidator) + public static List GetRequirementChecks(AzureAuthValidator authValidator, IClientAppValidator clientAppValidator) { return GetSystemRequirementChecks() - .Concat(GetConfigRequirementChecks(clientAppValidator)) + .Concat(GetConfigRequirementChecks(authValidator, clientAppValidator)) .ToList(); } @@ -186,10 +166,13 @@ public static List GetSystemRequirementChecks() /// /// Gets configuration-dependent requirement checks that must run after the configuration is loaded. /// - public static List GetConfigRequirementChecks(IClientAppValidator clientAppValidator) + public static List GetConfigRequirementChecks(AzureAuthValidator authValidator, IClientAppValidator clientAppValidator) { return new List { + // Azure CLI authentication — required before any Azure operation + new AzureAuthRequirementCheck(authValidator), + // Location configuration — required for endpoint registration new LocationRequirementCheck(), diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/PlatformDetector.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/PlatformDetector.cs index 3ce648c5..38473bc2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/PlatformDetector.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/PlatformDetector.cs @@ -29,7 +29,7 @@ public Models.ProjectPlatform Detect(string projectPath) return Models.ProjectPlatform.Unknown; } - _logger.LogInformation("Detecting platform in: {Path}", projectPath); + _logger.LogDebug("Detecting platform in: {Path}", projectPath); // Check for .NET project files var dotnetFiles = Directory.GetFiles(projectPath, "*.csproj", SearchOption.TopDirectoryOnly) @@ -39,7 +39,7 @@ public Models.ProjectPlatform Detect(string projectPath) if (dotnetFiles.Length > 0) { - _logger.LogInformation("Detected .NET project (found {Count} project file(s))", dotnetFiles.Length); + _logger.LogDebug("Detected .NET project (found {Count} project file(s))", dotnetFiles.Length); return Models.ProjectPlatform.DotNet; } @@ -50,7 +50,7 @@ public Models.ProjectPlatform Detect(string projectPath) if (File.Exists(packageJsonPath) || jsFiles || tsFiles) { - _logger.LogInformation("Detected Node.js project"); + _logger.LogDebug("Detected Node.js project"); return Models.ProjectPlatform.NodeJs; } @@ -62,7 +62,7 @@ public Models.ProjectPlatform Detect(string projectPath) if (File.Exists(requirementsPath) || File.Exists(setupPyPath) || File.Exists(pyprojectPath) || pythonFiles.Length > 0) { - _logger.LogInformation("Detected Python project"); + _logger.LogDebug("Detected Python project"); return Models.ProjectPlatform.Python; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs index f3cacf21..d0292f9f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs @@ -23,24 +23,13 @@ public abstract class RequirementCheck : IRequirementCheck /// public abstract Task CheckAsync(Agent365Config config, ILogger logger, CancellationToken cancellationToken = default); - /// - /// Helper method to log check start - /// - protected virtual void LogCheckStart(ILogger logger) - { - logger.LogInformation("Requirement: {Name}", Name); - } - /// /// Helper method to log check success /// protected virtual void LogCheckSuccess(ILogger logger, string? details = null) { - logger.LogInformation("[PASS] {Name}: PASSED", Name); - if (!string.IsNullOrWhiteSpace(details)) - { - logger.LogInformation(" Details: {Details}", details); - } + logger.LogInformation("[PASS] {Name}{Details}", Name, + string.IsNullOrWhiteSpace(details) ? "" : $" ({details})"); } /// @@ -48,11 +37,8 @@ protected virtual void LogCheckSuccess(ILogger logger, string? details = null) /// protected virtual void LogCheckWarning(ILogger logger, string? details = null) { - logger.LogWarning("[WARNING] {Name}: Cannot automatically verify", Name); - if (!string.IsNullOrWhiteSpace(details)) - { - logger.LogWarning(" Details: {Details}", details); - } + logger.LogInformation("[WARN] {Name}{Details}", Name, + string.IsNullOrWhiteSpace(details) ? "" : $" - {details}"); } /// @@ -60,9 +46,9 @@ protected virtual void LogCheckWarning(ILogger logger, string? details = null) /// protected virtual void LogCheckFailure(ILogger logger, string errorMessage, string resolutionGuidance) { - logger.LogError("[FAIL] {Name}: FAILED", Name); - logger.LogError(" Issue: {ErrorMessage}", errorMessage); - logger.LogError(" Resolution: {ResolutionGuidance}", resolutionGuidance); + logger.LogInformation("[FAIL] {Name}", Name); + logger.LogInformation(" Issue: {ErrorMessage}", errorMessage); + logger.LogInformation(" Resolution: {ResolutionGuidance}", resolutionGuidance); } /// @@ -74,7 +60,6 @@ protected async Task ExecuteCheckWithLoggingAsync( Func> checkImplementation, CancellationToken cancellationToken = default) { - LogCheckStart(logger); try { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AzureAuthRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AzureAuthRequirementCheck.cs index 3fe62dc2..ef0e7c96 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AzureAuthRequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AzureAuthRequirementCheck.cs @@ -34,8 +34,14 @@ public override async Task CheckAsync( ILogger logger, CancellationToken cancellationToken = default) { - // AzureAuthValidator logs all detailed user-facing messages internally. - // This adapter converts the bool result into a RequirementCheckResult. + return await ExecuteCheckWithLoggingAsync(config, logger, CheckImplementationAsync, cancellationToken); + } + + private async Task CheckImplementationAsync( + Agent365Config config, + ILogger logger, + CancellationToken cancellationToken) + { var authenticated = await _authValidator.ValidateAuthenticationAsync(config.SubscriptionId); if (!authenticated) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/ClientAppRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/ClientAppRequirementCheck.cs index a90caddb..7d485788 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/ClientAppRequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/ClientAppRequirementCheck.cs @@ -70,7 +70,7 @@ await _clientAppValidator.EnsureValidClientAppAsync( ); return RequirementCheckResult.Success( - details: $"Client app {config.ClientAppId} is properly configured with all required permissions and admin consent." + details: config.ClientAppId ); } catch (ClientAppValidationException ex) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/FrontierPreviewRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/FrontierPreviewRequirementCheck.cs index a887f1e3..df1a2258 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/FrontierPreviewRequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/FrontierPreviewRequirementCheck.cs @@ -24,21 +24,10 @@ public class FrontierPreviewRequirementCheck : RequirementCheck /// public override Task CheckAsync(Agent365Config config, ILogger logger, CancellationToken cancellationToken = default) { - logger.LogInformation("Requirement: {Name}", Name); - - Console.WriteLine(); - logger.LogWarning("While Microsoft Agent 365 is in preview, Frontier Preview Program enrollment is required."); - Console.WriteLine(" - Enrollment cannot be verified automatically."); - Console.WriteLine(" - Please confirm your tenant is enrolled before continuing."); - Console.WriteLine(); - Console.WriteLine("Documentation:"); - Console.WriteLine(" - https://learn.microsoft.com/microsoft-agent-365/developer/"); - Console.WriteLine(" - https://adoption.microsoft.com/copilot/frontier-program/"); - - // Return warning without using base class logging (already logged above) - return Task.FromResult(RequirementCheckResult.Warning( - message: "Cannot automatically verify Frontier Preview Program enrollment", - details: "Tenant must be enrolled in Frontier Preview Program during Agent 365 preview. Check documentation to verify if this requirement still applies." - )); + return ExecuteCheckWithLoggingAsync(config, logger, (_, __, ___) => Task.FromResult( + RequirementCheckResult.Warning( + message: "Cannot automatically verify Frontier Preview Program enrollment", + details: "enrollment cannot be auto-verified. See: https://adoption.microsoft.com/copilot/frontier-program/" + )), cancellationToken); } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/LocationRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/LocationRequirementCheck.cs index 14f73ae6..e810d2c6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/LocationRequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/LocationRequirementCheck.cs @@ -40,7 +40,7 @@ private static Task CheckImplementationAsync(Agent365Con } return Task.FromResult(RequirementCheckResult.Success( - details: $"Location is configured: {config.Location}" + details: config.Location?.Trim() )); } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/PowerShellModulesRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/PowerShellModulesRequirementCheck.cs index 85871072..fb2687be 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/PowerShellModulesRequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/PowerShellModulesRequirementCheck.cs @@ -43,8 +43,6 @@ public override async Task CheckAsync(Agent365Config con /// private async Task CheckImplementationAsync(Agent365Config config, ILogger logger, CancellationToken cancellationToken) { - logger.LogInformation("Checking if PowerShell is available on this system..."); - // Check if PowerShell is available var powerShellAvailable = await CheckPowerShellAvailabilityAsync(logger, cancellationToken); if (!powerShellAvailable) @@ -65,7 +63,6 @@ private async Task CheckImplementationAsync(Agent365Conf ); } - logger.LogInformation("Checking PowerShell modules..."); var missingModules = new List(); var installedModules = new List(); @@ -91,7 +88,7 @@ private async Task CheckImplementationAsync(Agent365Conf if (missingModules.Count == 0) { return RequirementCheckResult.Success( - details: $"All required PowerShell modules are installed: {string.Join(", ", installedModules.Select(m => m.Name))}" + details: string.Join(", ", installedModules.Select(m => m.Name)) ); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs index ad36bc92..445dba95 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs @@ -290,7 +290,6 @@ public async Task CreateBlueprintImplementation_WithMissingDisplayName_ShouldThr config, configFile, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockLogger, skipInfrastructure: false, @@ -306,53 +305,6 @@ public async Task CreateBlueprintImplementation_WithMissingDisplayName_ShouldThr result.EndpointRegistered.Should().BeFalse(); } - [Fact] - public async Task CreateBlueprintImplementation_WithAzureValidationFailure_ShouldReturnFalse() - { - // Arrange - var config = new Agent365Config - { - TenantId = "00000000-0000-0000-0000-000000000000", - ClientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", // Required for validation - SubscriptionId = "test-sub", - AgentBlueprintDisplayName = "Test Blueprint", - Location = "eastus" // Required for endpoint registration; location guard runs before Azure validation - }; - - var configFile = new FileInfo("test-config.json"); - - _mockPrerequisiteRunner.RunAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(false); // Auth check fails - - // Act - var result = await BlueprintSubcommand.CreateBlueprintImplementationAsync( - config, - configFile, - _mockExecutor, - _mockPrerequisiteRunner, - _mockAuthValidator, - _mockLogger, - skipInfrastructure: false, - isSetupAll: false, - _mockConfigService, - _mockBotConfigurator, - _mockPlatformDetector, - _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService); - - // Assert - result.Should().NotBeNull(); - result.BlueprintCreated.Should().BeFalse(); - result.EndpointRegistered.Should().BeFalse(); - await _mockPrerequisiteRunner.Received(1).RunAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any(), - Arg.Any()); - } [Fact] public void CommandDescription_ShouldMentionRequiredPermissions() @@ -535,19 +487,11 @@ public async Task CreateBlueprintImplementation_ShouldLogProgressMessages() var configFile = new FileInfo("test-config.json"); - _mockPrerequisiteRunner.RunAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(false); // Fail fast for this test - // Act var result = await BlueprintSubcommand.CreateBlueprintImplementationAsync( config, configFile, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockLogger, skipInfrastructure: false, diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/RequirementsSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/RequirementsSubcommandTests.cs index 9e02d09f..08afe944 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/RequirementsSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/RequirementsSubcommandTests.cs @@ -9,6 +9,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Agents.A365.DevTools.Cli.Tests.TestHelpers; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Xunit; @@ -161,13 +162,16 @@ public void GetRequirementChecks_ContainsAllExpectedCheckTypes() { // GetRequirementChecks is now derived from GetSystemRequirementChecks + GetConfigRequirementChecks. // This test guards against a check being accidentally added to one sub-list but not propagated. + var mockExecutor = Substitute.ForPartsOf(Substitute.For>()); + var mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, mockExecutor); var mockValidator = Substitute.For(); - var checks = RequirementsSubcommand.GetRequirementChecks(mockValidator); + var checks = RequirementsSubcommand.GetRequirementChecks(mockAuthValidator, mockValidator); - checks.Should().HaveCount(4, "system (2) + config (2) checks"); + checks.Should().HaveCount(5, "system (2) + config (3) checks"); checks.Should().ContainSingle(c => c is FrontierPreviewRequirementCheck); checks.Should().ContainSingle(c => c is PowerShellModulesRequirementCheck); + checks.Should().ContainSingle(c => c is AzureAuthRequirementCheck); checks.Should().ContainSingle(c => c is LocationRequirementCheck); checks.Should().ContainSingle(c => c is ClientAppRequirementCheck); } @@ -176,11 +180,13 @@ public void GetRequirementChecks_ContainsAllExpectedCheckTypes() public void GetRequirementChecks_IsUnionOfSystemAndConfigChecks() { // GetRequirementChecks must exactly equal GetSystemRequirementChecks + GetConfigRequirementChecks. + var mockExecutor = Substitute.ForPartsOf(Substitute.For>()); + var mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, mockExecutor); var mockValidator = Substitute.For(); - var all = RequirementsSubcommand.GetRequirementChecks(mockValidator); + var all = RequirementsSubcommand.GetRequirementChecks(mockAuthValidator, mockValidator); var system = RequirementsSubcommand.GetSystemRequirementChecks(); - var config = RequirementsSubcommand.GetConfigRequirementChecks(mockValidator); + var config = RequirementsSubcommand.GetConfigRequirementChecks(mockAuthValidator, mockValidator); all.Should().HaveCount(system.Count + config.Count); all.Select(c => c.GetType()).Should().StartWith(system.Select(c => c.GetType()), diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/ClientAppRequirementCheckTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/ClientAppRequirementCheckTests.cs index f90ecc32..fa2ba32f 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/ClientAppRequirementCheckTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/ClientAppRequirementCheckTests.cs @@ -94,7 +94,6 @@ public async Task CheckAsync_WithValidClientApp_ShouldReturnSuccess() // Assert result.Should().NotBeNull(); result.Passed.Should().BeTrue("client app is valid"); - result.Details.Should().Contain("properly configured"); result.Details.Should().Contain(config.ClientAppId); result.ErrorMessage.Should().BeNullOrEmpty(); result.ResolutionGuidance.Should().BeNullOrEmpty(); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs index 41d651da..6c83b7b5 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs @@ -37,8 +37,7 @@ public async Task CheckAsync_ShouldReturnWarning_WithDetails() result.Passed.Should().BeTrue("check should pass to allow user to proceed despite warning"); result.IsWarning.Should().BeTrue("check should be flagged as a warning"); result.Details.Should().NotBeNullOrEmpty(); - result.Details.Should().Contain("enrolled"); - result.Details.Should().Contain("preview"); + result.Details.Should().Contain("auto-verified"); result.ErrorMessage.Should().Contain("Cannot automatically verify"); result.ResolutionGuidance.Should().BeNullOrEmpty("warning checks don't have resolution guidance"); } @@ -54,11 +53,11 @@ public async Task CheckAsync_ShouldLogMainWarningMessage() await check.CheckAsync(config, _mockLogger); // Assert - // Verify the logger was called with the main warning message + // Verify the logger was called with the warning output line _mockLogger.Received().Log( - LogLevel.Warning, + LogLevel.Information, Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Frontier Preview Program enrollment is required")), + Arg.Is(o => o.ToString()!.Contains("[WARN] Frontier Preview Program")), Arg.Any(), Arg.Any>()); } @@ -74,11 +73,11 @@ public async Task CheckAsync_ShouldLogRequirementName() await check.CheckAsync(config, _mockLogger); // Assert - // Verify the logger was called with "Requirement:" prefix + // Verify the logger was called with the check name in the output line _mockLogger.Received().Log( LogLevel.Information, Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Requirement:") && o.ToString()!.Contains("Frontier Preview Program")), + Arg.Is(o => o.ToString()!.Contains("Frontier Preview Program")), Arg.Any(), Arg.Any>()); } @@ -94,9 +93,8 @@ public async Task CheckAsync_ShouldIncludePreviewContext() var result = await check.CheckAsync(config, _mockLogger); // Assert - // Verify the result mentions preview context - result.Details.Should().Contain("preview"); - result.Details.Should().Contain("enrolled"); + // Verify the result mentions the auto-verification limitation + result.Details.Should().Contain("auto-verified"); } [Fact] @@ -110,8 +108,8 @@ public async Task CheckAsync_ShouldMentionDocumentationCheck() var result = await check.CheckAsync(config, _mockLogger); // Assert - // Verify the details mention checking documentation - result.Details.Should().Contain("Check documentation"); + // Verify the details include a reference URL + result.Details.Should().Contain("https://adoption.microsoft.com/copilot/frontier-program/"); } [Fact] From 4b7588e89fa908978120c89e834dd43336d88ea7 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Sun, 8 Mar 2026 01:14:36 -0800 Subject: [PATCH 04/11] feat: add fail-early requirement checks to remaining commands Extends fail-early validation to setup infrastructure, setup permissions, setup copilot-studio, cleanup azure, deploy, and publish commands. Each command now runs targeted IRequirementCheck-based pre-flight checks with formatted [PASS]/[FAIL] output before executing destructive or slow operations, surfacing auth and config failures immediately. Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/CleanupCommand.cs | 9 +-- .../Commands/DeployCommand.cs | 31 +++++++-- .../Commands/PublishCommand.cs | 68 +++++++++++-------- .../Commands/SetupCommand.cs | 4 +- .../CopilotStudioSubcommand.cs | 6 +- .../InfrastructureSubcommand.cs | 6 +- .../SetupSubcommands/PermissionsSubcommand.cs | 25 +++++-- .../Program.cs | 4 +- .../CleanupCommandBotEndpointTests.cs | 1 - .../Commands/CleanupCommandTests.cs | 53 +++++++-------- .../Commands/CopilotStudioSubcommandTests.cs | 9 +++ .../Commands/DeployCommandTests.cs | 3 - .../Commands/PermissionsSubcommandTests.cs | 21 ++++++ .../Commands/PublishCommandTests.cs | 1 + 14 files changed, 156 insertions(+), 85 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs index fcde04d6..ee0a551d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs @@ -11,6 +11,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; namespace Microsoft.Agents.A365.DevTools.Cli.Commands; @@ -27,7 +28,6 @@ public static Command CreateCommand( AgentBlueprintService agentBlueprintService, IConfirmationProvider confirmationProvider, FederatedCredentialService federatedCredentialService, - IPrerequisiteRunner prerequisiteRunner, AzureAuthValidator authValidator) { var cleanupCommand = new Command("cleanup", "Clean up ALL resources (blueprint, instance, Azure) - use subcommands for granular cleanup"); @@ -59,7 +59,7 @@ public static Command CreateCommand( // Add subcommands for granular control cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService)); - cleanupCommand.AddCommand(CreateAzureCleanupCommand(logger, configService, executor, prerequisiteRunner, authValidator)); + cleanupCommand.AddCommand(CreateAzureCleanupCommand(logger, configService, executor, authValidator)); cleanupCommand.AddCommand(CreateInstanceCleanupCommand(logger, configService, executor)); return cleanupCommand; @@ -309,7 +309,6 @@ private static Command CreateAzureCleanupCommand( ILogger logger, IConfigService configService, CommandExecutor executor, - IPrerequisiteRunner prerequisiteRunner, AzureAuthValidator authValidator) { var command = new Command("azure", "Remove Azure resources (App Service, App Service Plan)"); @@ -338,7 +337,9 @@ private static Command CreateAzureCleanupCommand( if (config == null) return; var authChecks = new List { new AzureAuthRequirementCheck(authValidator) }; - if (!await prerequisiteRunner.RunAsync(authChecks, config, logger, CancellationToken.None)) + var authOk = await RequirementsSubcommand.RunRequirementChecksAsync( + authChecks, config, logger, category: null, CancellationToken.None); + if (!authOk) return; logger.LogInformation(""); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs index be8bb2fd..5a544004 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Helpers; @@ -20,7 +21,6 @@ public static Command CreateCommand( IConfigService configService, CommandExecutor executor, DeploymentService deploymentService, - IPrerequisiteRunner prerequisiteRunner, AzureAuthValidator authValidator, GraphApiService graphApiService, AgentBlueprintService blueprintService) @@ -55,7 +55,7 @@ public static Command CreateCommand( command.AddOption(restartOption); // Add subcommands - command.AddCommand(CreateAppSubcommand(logger, configService, executor, deploymentService, prerequisiteRunner, authValidator)); + command.AddCommand(CreateAppSubcommand(logger, configService, executor, deploymentService, authValidator)); command.AddCommand(CreateMcpSubcommand(logger, configService, executor, graphApiService, blueprintService)); // Single handler for the deploy command - runs only the application deployment flow @@ -84,7 +84,7 @@ public static Command CreateCommand( } var validatedConfig = await ValidateDeploymentPrerequisitesAsync( - config.FullName, configService, prerequisiteRunner, authValidator, executor, logger); + config.FullName, configService, authValidator, executor, logger); if (validatedConfig == null) return; await DeployApplicationAsync(validatedConfig, deploymentService, verbose, inspect, restart, logger); @@ -103,7 +103,6 @@ private static Command CreateAppSubcommand( IConfigService configService, CommandExecutor executor, DeploymentService deploymentService, - IPrerequisiteRunner prerequisiteRunner, AzureAuthValidator authValidator) { var command = new Command("app", "Deploy Microsoft Agent 365 application binaries to the configured Azure App Service"); @@ -160,7 +159,7 @@ private static Command CreateAppSubcommand( } var validatedConfig = await ValidateDeploymentPrerequisitesAsync( - config.FullName, configService, prerequisiteRunner, authValidator, executor, logger); + config.FullName, configService, authValidator, executor, logger); if (validatedConfig == null) return; await DeployApplicationAsync(validatedConfig, deploymentService, verbose, inspect, restart, logger); @@ -232,6 +231,11 @@ private static Command CreateMcpSubcommand( logger.LogError("agenticAppId is not configured. Run 'a365 setup all' to complete setup."); return; } + if (string.IsNullOrWhiteSpace(updateConfig.TenantId)) + { + logger.LogError("tenantId is not configured. Run 'a365 setup all' to complete setup."); + return; + } // Configure GraphApiService with custom client app ID if available if (!string.IsNullOrWhiteSpace(updateConfig.ClientAppId)) @@ -261,7 +265,6 @@ private static Command CreateMcpSubcommand( private static async Task ValidateDeploymentPrerequisitesAsync( string configPath, IConfigService configService, - IPrerequisiteRunner prerequisiteRunner, AzureAuthValidator authValidator, CommandExecutor executor, ILogger logger) @@ -270,12 +273,26 @@ private static Command CreateMcpSubcommand( var configData = await configService.LoadAsync(configPath); if (configData == null) return null; + // Validate required config fields before any network calls + var missingFields = new List(); + if (string.IsNullOrWhiteSpace(configData.ResourceGroup)) missingFields.Add("resourceGroup"); + if (string.IsNullOrWhiteSpace(configData.WebAppName)) missingFields.Add("webAppName"); + if (string.IsNullOrWhiteSpace(configData.SubscriptionId)) missingFields.Add("subscriptionId"); + if (missingFields.Count > 0) + { + logger.LogError("Missing required configuration fields: {Fields}. Update a365.config.json and retry.", + string.Join(", ", missingFields)); + return null; + } + // Validate Azure CLI authentication, subscription, and environment var checks = new List { new AzureAuthRequirementCheck(authValidator) }; - if (!await prerequisiteRunner.RunAsync(checks, configData, logger)) + var authOk = await RequirementsSubcommand.RunRequirementChecksAsync( + checks, configData, logger, category: null, CancellationToken.None); + if (!authOk) { logger.LogError("Deployment cannot proceed without proper Azure CLI authentication and the correct subscription context"); return null; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs index fce13d0a..7d5c99c1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs @@ -141,6 +141,12 @@ public static Command CreateCommand( return; } + if (string.IsNullOrWhiteSpace(config.ClientAppId)) + { + logger.LogError("clientAppId is not configured. Run 'a365 setup blueprint' first to configure client app authentication."); + return; + } + // Use deploymentProjectPath from config for portability var baseDir = GetProjectDirectory(config, logger); var manifestDir = Path.Combine(baseDir, "manifest"); @@ -191,10 +197,14 @@ public static Command CreateCommand( var mosTitlesBaseUrl = GetMosTitlesUrl(tenantId); logger.LogInformation("Using MOS Titles URL: {Url} (Tenant: {TenantId})", mosTitlesBaseUrl, tenantId ?? "unknown"); - // Warn if tenantId is missing if (string.IsNullOrWhiteSpace(tenantId)) { - logger.LogWarning("tenantId missing in configuration; using default production MOS URL. Graph operations will be skipped."); + if (!skipGraph) + { + logger.LogError("tenantId is not configured. Graph operations require tenantId. Use --skip-graph to publish without Graph operations, or run 'a365 setup all' to complete setup."); + return; + } + logger.LogWarning("tenantId missing in configuration; using default production MOS URL. Graph operations will be skipped (--skip-graph)."); } string updatedManifest = await UpdateManifestFileAsync(logger, agentBlueprintDisplayName, blueprintId, manifestPath); @@ -218,6 +228,33 @@ public static Command CreateCommand( logger.LogDebug("Manifest files written to disk"); + // Verify MOS prerequisites before asking user to edit manifest (fail fast) + logger.LogInformation(""); + logger.LogDebug("Checking MOS prerequisites (service principals and permissions)..."); + try + { + var mosPrereqsConfigured = await PublishHelpers.EnsureMosPrerequisitesAsync( + graphApiService, blueprintService, config, logger); + + if (!mosPrereqsConfigured) + { + logger.LogError("Failed to configure MOS prerequisites. Aborting publish."); + return; + } + logger.LogInformation(""); + } + catch (SetupValidationException ex) + { + logger.LogError("MOS prerequisites configuration failed: {Message}", ex.Message); + logger.LogInformation(""); + logger.LogInformation("To manually create MOS service principals, run:"); + logger.LogInformation(" az ad sp create --id 6ec511af-06dc-4fe2-b493-63a37bc397b1"); + logger.LogInformation(" az ad sp create --id 8578e004-a5c6-46e7-913e-12f58912df43"); + logger.LogInformation(" az ad sp create --id e8be65d6-d430-4289-a665-51bf2a194bda"); + logger.LogInformation(""); + return; + } + // Interactive pause for user customization logger.LogInformation(""); logger.LogInformation("=== MANIFEST UPDATED ==="); @@ -315,33 +352,6 @@ public static Command CreateCommand( } logger.LogInformation("Created archive {ZipPath}", zipPath); - // Ensure MOS prerequisites are configured (service principals + permissions) - try - { - logger.LogInformation(""); - logger.LogDebug("Checking MOS prerequisites (service principals and permissions)..."); - var mosPrereqsConfigured = await PublishHelpers.EnsureMosPrerequisitesAsync( - graphApiService, blueprintService, config, logger); - - if (!mosPrereqsConfigured) - { - logger.LogError("Failed to configure MOS prerequisites. Aborting publish."); - return; - } - logger.LogInformation(""); - } - catch (SetupValidationException ex) - { - logger.LogError("MOS prerequisites configuration failed: {Message}", ex.Message); - logger.LogInformation(""); - logger.LogInformation("To manually create MOS service principals, run:"); - logger.LogInformation(" az ad sp create --id 6ec511af-06dc-4fe2-b493-63a37bc397b1"); - logger.LogInformation(" az ad sp create --id 8578e004-a5c6-46e7-913e-12f58912df43"); - logger.LogInformation(" az ad sp create --id e8be65d6-d430-4289-a665-51bf2a194bda"); - logger.LogInformation(""); - return; - } - // Acquire MOS token using native C# service logger.LogDebug("Acquiring MOS authentication token for environment: {Environment}", mosEnv); var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 064fc007..13c0b6a7 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -48,13 +48,13 @@ public static Command CreateCommand( logger, configService, authValidator, clientAppValidator)); command.AddCommand(InfrastructureSubcommand.CreateCommand( - logger, configService, prerequisiteRunner, authValidator, webAppCreator, platformDetector, executor)); + logger, configService, authValidator, webAppCreator, platformDetector, executor)); command.AddCommand(BlueprintSubcommand.CreateCommand( logger, configService, executor, prerequisiteRunner, authValidator, webAppCreator, platformDetector, botConfigurator, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService)); command.AddCommand(PermissionsSubcommand.CreateCommand( - logger, configService, executor, graphApiService, blueprintService)); + logger, authValidator, configService, executor, graphApiService, blueprintService)); command.AddCommand(AllSubcommand.CreateCommand( logger, configService, executor, botConfigurator, prerequisiteRunner, authValidator, environmentValidator, webAppCreator, platformDetector, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService)); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs index 15539f92..db0b8234 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs @@ -6,6 +6,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Extensions.Logging; using System.CommandLine; @@ -30,6 +31,7 @@ public static Task> ValidateAsync( public static Command CreateCommand( ILogger logger, + AzureAuthValidator authValidator, IConfigService configService, CommandExecutor executor, GraphApiService graphApiService, @@ -78,8 +80,10 @@ public static Command CreateCommand( // which would be a side effect in a mode that is supposed to be non-mutating. if (!dryRun) { + var copilotChecks = new List { new AzureAuthRequirementCheck(authValidator) }; + copilotChecks.AddRange(RequirementsSubcommand.GetSystemRequirementChecks()); var systemChecksOk = await RequirementsSubcommand.RunRequirementChecksAsync( - RequirementsSubcommand.GetSystemRequirementChecks(), setupConfig, logger, category: null, CancellationToken.None); + copilotChecks, setupConfig, logger, category: null, CancellationToken.None); if (!systemChecksOk) { logger.LogError("Setup cannot proceed due to failed requirement checks above. Please fix the issues and retry."); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs index 3856071e..28d25a2b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs @@ -29,7 +29,6 @@ public static class InfrastructureSubcommand public static Command CreateCommand( ILogger logger, IConfigService configService, - IPrerequisiteRunner prerequisiteRunner, AzureAuthValidator authValidator, AzureWebAppCreator webAppCreator, PlatformDetector platformDetector, @@ -92,8 +91,11 @@ public static Command CreateCommand( new AzureAuthRequirementCheck(authValidator), new InfrastructureRequirementCheck() }; - if (!await prerequisiteRunner.RunAsync(checks, setupConfig, logger, CancellationToken.None)) + var checksOk = await RequirementsSubcommand.RunRequirementChecksAsync( + checks, setupConfig, logger, category: null, CancellationToken.None); + if (!checksOk) { + logger.LogError("Setup cannot proceed due to failed requirement checks above. Please fix the issues and retry."); ExceptionHandler.ExitWithCleanup(1); } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs index f1870472..632bac5c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs @@ -6,6 +6,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Extensions.Logging; using System.CommandLine; using System.Threading; @@ -20,6 +21,7 @@ internal static class PermissionsSubcommand { public static Command CreateCommand( ILogger logger, + AzureAuthValidator authValidator, IConfigService configService, CommandExecutor executor, GraphApiService graphApiService, @@ -30,10 +32,10 @@ public static Command CreateCommand( "Minimum required permissions: Global Administrator\n"); // Add subcommands - permissionsCommand.AddCommand(CreateMcpSubcommand(logger, configService, executor, graphApiService, blueprintService)); - permissionsCommand.AddCommand(CreateBotSubcommand(logger, configService, executor, graphApiService, blueprintService)); - permissionsCommand.AddCommand(CreateCustomSubcommand(logger, configService, executor, graphApiService, blueprintService)); - permissionsCommand.AddCommand(CopilotStudioSubcommand.CreateCommand(logger, configService, executor, graphApiService, blueprintService)); + permissionsCommand.AddCommand(CreateMcpSubcommand(logger, authValidator, configService, executor, graphApiService, blueprintService)); + permissionsCommand.AddCommand(CreateBotSubcommand(logger, authValidator, configService, executor, graphApiService, blueprintService)); + permissionsCommand.AddCommand(CreateCustomSubcommand(logger, authValidator, configService, executor, graphApiService, blueprintService)); + permissionsCommand.AddCommand(CopilotStudioSubcommand.CreateCommand(logger, authValidator, configService, executor, graphApiService, blueprintService)); return permissionsCommand; } @@ -43,6 +45,7 @@ public static Command CreateCommand( /// private static Command CreateMcpSubcommand( ILogger logger, + AzureAuthValidator authValidator, IConfigService configService, CommandExecutor executor, GraphApiService graphApiService, @@ -90,8 +93,10 @@ private static Command CreateMcpSubcommand( // which would be a side effect in a mode that is supposed to be non-mutating. if (!dryRun) { + var mcpChecks = new List { new AzureAuthRequirementCheck(authValidator) }; + mcpChecks.AddRange(RequirementsSubcommand.GetSystemRequirementChecks()); var mcpSystemChecksOk = await RequirementsSubcommand.RunRequirementChecksAsync( - RequirementsSubcommand.GetSystemRequirementChecks(), setupConfig, logger, category: null, CancellationToken.None); + mcpChecks, setupConfig, logger, category: null, CancellationToken.None); if (!mcpSystemChecksOk) { logger.LogError("Setup cannot proceed due to failed requirement checks above. Please fix the issues and retry."); @@ -133,6 +138,7 @@ await ConfigureMcpPermissionsAsync( /// private static Command CreateBotSubcommand( ILogger logger, + AzureAuthValidator authValidator, IConfigService configService, CommandExecutor executor, GraphApiService graphApiService, @@ -182,8 +188,10 @@ private static Command CreateBotSubcommand( // which would be a side effect in a mode that is supposed to be non-mutating. if (!dryRun) { + var botChecks = new List { new AzureAuthRequirementCheck(authValidator) }; + botChecks.AddRange(RequirementsSubcommand.GetSystemRequirementChecks()); var botSystemChecksOk = await RequirementsSubcommand.RunRequirementChecksAsync( - RequirementsSubcommand.GetSystemRequirementChecks(), setupConfig, logger, category: null, CancellationToken.None); + botChecks, setupConfig, logger, category: null, CancellationToken.None); if (!botSystemChecksOk) { logger.LogError("Setup cannot proceed due to failed requirement checks above. Please fix the issues and retry."); @@ -222,6 +230,7 @@ await ConfigureBotPermissionsAsync( /// private static Command CreateCustomSubcommand( ILogger logger, + AzureAuthValidator authValidator, IConfigService configService, CommandExecutor executor, GraphApiService graphApiService, @@ -270,8 +279,10 @@ private static Command CreateCustomSubcommand( // which would be a side effect in a mode that is supposed to be non-mutating. if (!dryRun) { + var customChecks = new List { new AzureAuthRequirementCheck(authValidator) }; + customChecks.AddRange(RequirementsSubcommand.GetSystemRequirementChecks()); var customSystemChecksOk = await RequirementsSubcommand.RunRequirementChecksAsync( - RequirementsSubcommand.GetSystemRequirementChecks(), setupConfig, logger, category: null, CancellationToken.None); + customChecks, setupConfig, logger, category: null, CancellationToken.None); if (!customSystemChecksOk) { logger.LogError("Setup cannot proceed due to failed requirement checks above. Please fix the issues and retry."); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index a8972712..1d29d631 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -117,7 +117,7 @@ static async Task Main(string[] args) rootCommand.AddCommand(CreateInstanceCommand.CreateCommand(createInstanceLogger, configService, executor, botConfigurator, graphApiService)); rootCommand.AddCommand(DeployCommand.CreateCommand(deployLogger, configService, executor, - deploymentService, prerequisiteRunner, authValidator, graphApiService, agentBlueprintService)); + deploymentService, authValidator, graphApiService, agentBlueprintService)); // Register ConfigCommand var configLoggerFactory = serviceProvider.GetRequiredService(); @@ -127,7 +127,7 @@ static async Task Main(string[] args) var confirmationProvider = serviceProvider.GetRequiredService(); rootCommand.AddCommand(ConfigCommand.CreateCommand(configLogger, wizardService: wizardService, clientAppValidator: clientAppValidator)); rootCommand.AddCommand(QueryEntraCommand.CreateCommand(queryEntraLogger, configService, executor, graphApiService, agentBlueprintService)); - rootCommand.AddCommand(CleanupCommand.CreateCommand(cleanupLogger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService, prerequisiteRunner, authValidator)); + rootCommand.AddCommand(CleanupCommand.CreateCommand(cleanupLogger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService, authValidator)); rootCommand.AddCommand(PublishCommand.CreateCommand(publishLogger, configService, agentPublishService, graphApiService, agentBlueprintService, manifestTemplateService)); // Wrap all command handlers with exception handling diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandBotEndpointTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandBotEndpointTests.cs index 4a3f72ed..89c188c6 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandBotEndpointTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandBotEndpointTests.cs @@ -122,7 +122,6 @@ public void BotConfigurator_DeleteEndpoint_ShouldBeCalledIndependentlyOfWebApp() _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, - _mockPrerequisiteRunner, _mockAuthValidator); Assert.NotNull(command); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs index e40ec403..5436711b 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs @@ -87,7 +87,7 @@ public async Task CleanupAzure_WithValidConfig_ShouldExecuteResourceDeleteComman var config = CreateValidConfig(); _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "azure", "--config", "test.json" }; // Act @@ -116,7 +116,7 @@ public async Task CleanupInstance_WithValidConfig_ShouldReturnSuccess() _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "instance", "--config", "test.json" }; var originalIn = Console.In; @@ -147,7 +147,7 @@ public async Task Cleanup_WithoutSubcommand_ShouldExecuteCompleteCleanup() var config = CreateValidConfig(); _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "--config", "test.json" }; // Act @@ -177,7 +177,7 @@ public async Task CleanupAzure_WithMissingWebAppName_ShouldStillExecuteCommand() var config = CreateConfigWithMissingWebApp(); // Create config without web app name _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "azure", "--config", "test.json" }; // Act @@ -204,7 +204,7 @@ public async Task CleanupCommand_WithInvalidConfigFile_ShouldReturnError() _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(false)); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "azure", "--config", "invalid.json" }; // Act @@ -224,7 +224,7 @@ await _mockExecutor.DidNotReceive().ExecuteAsync( public void CleanupCommand_ShouldHaveCorrectSubcommands() { // Arrange & Act - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); // Assert - Verify command structure (what users see) Assert.Equal("cleanup", command.Name); @@ -243,7 +243,7 @@ public void CleanupCommand_ShouldHaveCorrectSubcommands() public void CleanupCommand_ShouldHaveDefaultHandlerOptions() { // Arrange & Act - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); // Assert - Verify parent command has options for default handler var optionNames = command.Options.Select(opt => opt.Name).ToList(); @@ -256,7 +256,7 @@ public void CleanupCommand_ShouldHaveDefaultHandlerOptions() public void CleanupSubcommands_ShouldHaveRequiredOptions() { // Arrange & Act - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var blueprintCommand = command.Subcommands.First(sc => sc.Name == "blueprint"); // Assert - Verify user-facing options @@ -278,7 +278,7 @@ public async Task CleanupBlueprint_WithValidConfig_ShouldReturnSuccess() _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(true); var stubbedBlueprintService = CreateStubbedBlueprintService(); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, stubbedBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, stubbedBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; // Act @@ -338,7 +338,7 @@ public async Task CleanupBlueprint_WithInstances_DeletesInstancesBeforeBlueprint var command = CleanupCommand.CreateCommand( _mockLogger, _mockConfigService, _mockBotConfigurator, - _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; // Act @@ -379,7 +379,7 @@ public async Task CleanupBlueprint_WithNoInstances_ProceedsAsNormal() var command = CleanupCommand.CreateCommand( _mockLogger, _mockConfigService, _mockBotConfigurator, - _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; // Act @@ -427,7 +427,7 @@ public async Task CleanupBlueprint_InstanceDeletionFails_WarnsAndContinuesToBlue var command = CleanupCommand.CreateCommand( _mockLogger, _mockConfigService, _mockBotConfigurator, - _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; // Act @@ -486,7 +486,7 @@ public async Task CleanupBlueprint_WhenBlueprintDeletionFailsWithInstances_LogsW var command = CleanupCommand.CreateCommand( _mockLogger, _mockConfigService, _mockBotConfigurator, - _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; // Act @@ -566,7 +566,7 @@ public async Task Cleanup_WhenUserDeclinesInitialConfirmation_ShouldAbortWithout // User declines the initial "Are you sure?" confirmation _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(false); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "--config", "test.json" }; // Act @@ -594,7 +594,7 @@ public async Task Cleanup_WhenUserConfirmsButDoesNotTypeDelete_ShouldAbortWithou _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(true); _mockConfirmationProvider.ConfirmWithTypedResponseAsync(Arg.Any(), "DELETE").Returns(false); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "--config", "test.json" }; // Act @@ -618,7 +618,7 @@ public async Task Cleanup_ShouldCallConfirmationProviderWithCorrectPrompts() var config = CreateValidConfig(); _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "--config", "test.json" }; // Act @@ -645,7 +645,6 @@ public void CleanupCommand_ShouldAcceptConfirmationProviderParameter() _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, - _mockPrerequisiteRunner, _mockAuthValidator); command.Should().NotBeNull(); @@ -659,7 +658,7 @@ public void CleanupCommand_ShouldAcceptConfirmationProviderParameter() public void CleanupBlueprint_ShouldHaveEndpointOnlyOption() { // Arrange & Act - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var blueprintCommand = command.Subcommands.First(sc => sc.Name == "blueprint"); // Assert @@ -680,7 +679,7 @@ public async Task CleanupBlueprint_WithEndpointOnly_ShouldOnlyDeleteMessagingEnd _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; // Simulate user confirmation with y @@ -739,7 +738,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndNoBlueprintId_ShouldLogErr }; _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; // Act @@ -777,7 +776,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndNoBotName_ShouldLogInfo() }; _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; // Act @@ -817,7 +816,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndMissingLocation_ShouldNotC }; _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; var originalIn = Console.In; @@ -858,7 +857,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndApiException_ShouldHandleG _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromException(new InvalidOperationException("API connection failed"))); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; var originalIn = Console.In; @@ -912,7 +911,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndWhitespaceBlueprint_Should }; _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; // Act @@ -939,7 +938,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndInvalidInput_ShouldCancelC _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; var originalIn = Console.In; @@ -978,7 +977,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndNoResponse_ShouldCancelCle _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; var originalIn = Console.In; @@ -1017,7 +1016,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndEmptyInput_ShouldCancelCle _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockPrerequisiteRunner, _mockAuthValidator); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "blueprint", "--endpoint-only", "--config", "test.json" }; var originalIn = Console.In; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CopilotStudioSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CopilotStudioSubcommandTests.cs index 7e37a903..34a1eec8 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CopilotStudioSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CopilotStudioSubcommandTests.cs @@ -6,6 +6,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using System.CommandLine; using Xunit; @@ -19,6 +20,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; public class CopilotStudioSubcommandTests { private readonly ILogger _mockLogger; + private readonly AzureAuthValidator _mockAuthValidator; private readonly IConfigService _mockConfigService; private readonly CommandExecutor _mockExecutor; private readonly GraphApiService _mockGraphApiService; @@ -30,6 +32,7 @@ public CopilotStudioSubcommandTests() _mockConfigService = Substitute.For(); var mockExecutorLogger = Substitute.For>(); _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); + _mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, _mockExecutor); _mockGraphApiService = Substitute.ForPartsOf(); _mockBlueprintService = Substitute.ForPartsOf(Substitute.For>(), _mockGraphApiService); } @@ -42,6 +45,7 @@ public void CreateCommand_ShouldHaveCorrectName() // Act var command = CopilotStudioSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, @@ -58,6 +62,7 @@ public void CreateCommand_ShouldHaveConfigOption() // Act var command = CopilotStudioSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, @@ -76,6 +81,7 @@ public void CreateCommand_ShouldHaveVerboseOption() // Act var command = CopilotStudioSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, @@ -94,6 +100,7 @@ public void CreateCommand_ShouldHaveDryRunOption() // Act var command = CopilotStudioSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, @@ -110,6 +117,7 @@ public void CreateCommand_Description_ShouldMentionPowerPlatformApi() // Act var command = CopilotStudioSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, @@ -129,6 +137,7 @@ public void CreateCommand_Description_ShouldMentionPrerequisites() // Act var command = CopilotStudioSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DeployCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DeployCommandTests.cs index 11bb555a..5cffaaf5 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DeployCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DeployCommandTests.cs @@ -69,7 +69,6 @@ public void UpdateCommand_Should_Not_Have_Atg_Subcommand() _mockConfigService, _mockExecutor, _mockDeploymentService, - _mockPrerequisiteRunner, _mockAuthValidator, _mockGraphApiService, _mockBlueprintService); @@ -89,7 +88,6 @@ public void UpdateCommand_Should_Have_Config_Option_With_Default() _mockConfigService, _mockExecutor, _mockDeploymentService, - _mockPrerequisiteRunner, _mockAuthValidator, _mockGraphApiService, _mockBlueprintService); @@ -110,7 +108,6 @@ public void UpdateCommand_Should_Have_Verbose_Option() _mockConfigService, _mockExecutor, _mockDeploymentService, - _mockPrerequisiteRunner, _mockAuthValidator, _mockGraphApiService, _mockBlueprintService); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs index 0a4b53de..ab58dd7a 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs @@ -6,6 +6,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using System.CommandLine; using Xunit; @@ -19,6 +20,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; public class PermissionsSubcommandTests { private readonly ILogger _mockLogger; + private readonly AzureAuthValidator _mockAuthValidator; private readonly IConfigService _mockConfigService; private readonly CommandExecutor _mockExecutor; private readonly GraphApiService _mockGraphApiService; @@ -30,6 +32,7 @@ public PermissionsSubcommandTests() _mockConfigService = Substitute.For(); var mockExecutorLogger = Substitute.For>(); _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); + _mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, _mockExecutor); _mockGraphApiService = Substitute.ForPartsOf(); _mockBlueprintService = Substitute.ForPartsOf(Substitute.For>(), _mockGraphApiService); } @@ -42,6 +45,7 @@ public void CreateCommand_ShouldHaveMcpSubcommand() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -57,6 +61,7 @@ public void CreateCommand_ShouldHaveBotSubcommand() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -72,6 +77,7 @@ public void CommandDescription_ShouldMentionRequiredPermissions() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -86,6 +92,7 @@ public void CreateCommand_ShouldHaveBothSubcommands() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -104,6 +111,7 @@ public void CreateCommand_ShouldBeUsableInCommandPipeline() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -124,6 +132,7 @@ public void McpSubcommand_ShouldHaveCorrectName() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -140,6 +149,7 @@ public void McpSubcommand_ShouldHaveConfigOption() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -159,6 +169,7 @@ public void McpSubcommand_ShouldHaveVerboseOption() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -178,6 +189,7 @@ public void McpSubcommand_ShouldHaveDryRunOption() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -196,6 +208,7 @@ public void McpSubcommand_DescriptionShouldBeInformativeAndActionable() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -217,6 +230,7 @@ public void BotSubcommand_ShouldHaveCorrectName() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -233,6 +247,7 @@ public void BotSubcommand_ShouldHaveConfigOption() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -252,6 +267,7 @@ public void BotSubcommand_ShouldHaveVerboseOption() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -271,6 +287,7 @@ public void BotSubcommand_ShouldHaveDryRunOption() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -289,6 +306,7 @@ public void BotSubcommand_DescriptionShouldMentionPrerequisites() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -305,6 +323,7 @@ public void BotSubcommand_DescriptionShouldBeInformativeAndActionable() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -501,6 +520,7 @@ public void BotSubcommand_Description_ShouldNotReferenceNonExistentEndpointComma // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); @@ -521,6 +541,7 @@ public void BotSubcommand_Description_ShouldMentionPrerequisites() // Act var command = PermissionsSubcommand.CreateCommand( _mockLogger, + _mockAuthValidator, _mockConfigService, _mockExecutor, _mockGraphApiService, _mockBlueprintService); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs index e9fee923..e90adba4 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs @@ -125,6 +125,7 @@ public async Task PublishCommand_WithDryRun_ShouldReturnExitCode0() AgentBlueprintId = "test-blueprint-id", AgentBlueprintDisplayName = "Test Agent", TenantId = "test-tenant", + ClientAppId = "test-client-app-id", DeploymentProjectPath = tempDir }; _configService.LoadAsync().Returns(config); From b114298e17bf48b1425c4e8e1a48ab8cac784b69 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Sun, 8 Mar 2026 04:00:20 -0700 Subject: [PATCH 05/11] refactor: structured requirement check composition + fix CAE token revocation UX Phase 1 (zero behavioral change): - Add GetBaseChecks() to SetupCommand and CleanupCommand for explicit check composition - Add GetChecks() to each setup subcommand so check lists are co-located with their command - Add RunChecksOrExitAsync() helper to RequirementsSubcommand to eliminate four-line boilerplate - Guard all requirement check calls with if (!dryRun) to avoid spurious network calls - Update RequirementsSubcommandTests to use public API after making internal helpers private Fix CAE token revocation UX: - Add ClientAppValidationException.TokenRevoked() factory for clear re-auth guidance - Detect server-side CAE token revocation in GetClientAppInfoAsync and throw TokenRevoked instead of returning null (which was misreported as "app not found") - Pass suppressErrorLogging: true to all az CLI calls in ClientAppValidator so raw error output no longer leaks to console before the formatted [FAIL] message - Update ClientAppValidatorTests mocks to match suppressErrorLogging parameter Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/CleanupCommand.cs | 24 ++++++--- .../Commands/SetupCommand.cs | 13 +++++ .../SetupSubcommands/AllSubcommand.cs | 42 ++++++++++----- .../SetupSubcommands/BlueprintSubcommand.cs | 37 ++++++++----- .../CopilotStudioSubcommand.cs | 19 +++---- .../SetupSubcommands/PermissionsSubcommand.cs | 53 +++++++++---------- .../RequirementsSubcommand.cs | 23 +++++++- .../ClientAppValidationException.cs | 24 +++++++++ .../Services/ClientAppValidator.cs | 20 +++++-- .../Commands/RequirementsSubcommandTests.cs | 22 ++++---- .../Services/ClientAppValidatorTests.cs | 3 ++ 11 files changed, 196 insertions(+), 84 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs index ee0a551d..d7506e55 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs @@ -20,6 +20,13 @@ public class CleanupCommand private const string AgenticUsersKey = "agentic users"; private const string IdentitySpsKey = "identity SPs"; + /// + /// Returns the base requirement checks for cleanup operations: + /// Azure authentication only. + /// + public static List GetBaseChecks(AzureAuthValidator auth) + => [new AzureAuthRequirementCheck(auth)]; + public static Command CreateCommand( ILogger logger, IConfigService configService, @@ -305,6 +312,12 @@ private static Command CreateBlueprintCleanupCommand( return command; } + /// + /// Returns the requirement checks for the cleanup azure subcommand. + /// + internal static List GetAzureCleanupChecks(AzureAuthValidator auth) + => GetBaseChecks(auth); + private static Command CreateAzureCleanupCommand( ILogger logger, IConfigService configService, @@ -312,7 +325,7 @@ private static Command CreateAzureCleanupCommand( AzureAuthValidator authValidator) { var command = new Command("azure", "Remove Azure resources (App Service, App Service Plan)"); - + var configOption = new Option( new[] { "--config", "-c" }, "Path to configuration file") @@ -332,15 +345,12 @@ private static Command CreateAzureCleanupCommand( try { logger.LogInformation("Starting Azure cleanup..."); - + var config = await LoadConfigAsync(configFile, logger, configService); if (config == null) return; - var authChecks = new List { new AzureAuthRequirementCheck(authValidator) }; - var authOk = await RequirementsSubcommand.RunRequirementChecksAsync( - authChecks, config, logger, category: null, CancellationToken.None); - if (!authOk) - return; + var checks = GetAzureCleanupChecks(authValidator); + await RequirementsSubcommand.RunChecksOrExitAsync(checks, config, logger, CancellationToken.None); logger.LogInformation(""); logger.LogInformation("Azure Cleanup Preview:"); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 13c0b6a7..fbd893bf 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -3,6 +3,8 @@ using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Extensions.Logging; using System.CommandLine; @@ -14,6 +16,17 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands /// public class SetupCommand { + /// + /// Returns the base requirement checks shared by all setup subcommands: + /// Azure authentication, Frontier Preview enrollment, and PowerShell modules. + /// + public static List GetBaseChecks(AzureAuthValidator auth) + => [ + new AzureAuthRequirementCheck(auth), + new FrontierPreviewRequirementCheck(), + new PowerShellModulesRequirementCheck() + ]; + public static Command CreateCommand( ILogger logger, IConfigService configService, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs index 3de16b5d..669e75d9 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -1,11 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Commands; using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Extensions.Logging; using System.CommandLine; @@ -22,6 +24,29 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; /// internal static class AllSubcommand { + /// + /// Returns the requirement checks for setup all. + /// Composes SetupCommand base checks + Location + ClientApp + optional Infrastructure. + /// + public static List GetChecks( + AzureAuthValidator auth, + IClientAppValidator clientAppValidator, + bool includeInfrastructure) + { + var checks = new List(SetupCommand.GetBaseChecks(auth)) + { + new LocationRequirementCheck(), + new ClientAppRequirementCheck(clientAppValidator), + }; + + if (includeInfrastructure) + { + checks.Add(new InfrastructureRequirementCheck()); + } + + return checks; + } + public static Command CreateCommand( ILogger logger, IConfigService configService, @@ -131,22 +156,15 @@ public static Command CreateCommand( // Validate all prerequisites in one pass if (!skipRequirements) { - var checks = RequirementsSubcommand.GetRequirementChecks(authValidator, clientAppValidator); - if (!skipInfrastructure && setupConfig.NeedDeployment) - checks.Add(new InfrastructureRequirementCheck()); + var includeInfra = !skipInfrastructure && setupConfig.NeedDeployment; + var checks = AllSubcommand.GetChecks(authValidator, clientAppValidator, includeInfra); try { - var requirementsResult = await RequirementsSubcommand.RunRequirementChecksAsync( - checks, setupConfig, logger, category: null, CancellationToken.None); - - if (!requirementsResult) - { - logger.LogError("Setup cannot proceed due to the failed requirement checks above. Please fix the issues above and then try again."); - ExceptionHandler.ExitWithCleanup(1); - } + await RequirementsSubcommand.RunChecksOrExitAsync( + checks, setupConfig, logger, CancellationToken.None); } - catch (Exception reqEx) + catch (Exception reqEx) when (reqEx is not OperationCanceledException) { logger.LogError(reqEx, "Requirements check failed with an unexpected error: {Message}", reqEx.Message); logger.LogError("If you want to bypass requirement validation, rerun this command with the --skip-requirements flag."); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index 95f83242..834dff44 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -3,12 +3,14 @@ using Azure.Core; using Azure.Identity; +using Microsoft.Agents.A365.DevTools.Cli.Commands; using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; using Microsoft.Extensions.Logging; @@ -59,6 +61,23 @@ internal static class BlueprintSubcommand { // Client secret validation constants private const int ClientSecretValidationMaxRetries = 2; + + /// + /// Returns the requirement checks for setup blueprint. + /// Composes SetupCommand base checks + Location + ClientApp. + /// + public static List GetChecks( + AzureAuthValidator auth, + IClientAppValidator clientAppValidator) + { + var checks = new List(SetupCommand.GetBaseChecks(auth)) + { + new LocationRequirementCheck(), + new ClientAppRequirementCheck(clientAppValidator), + }; + + return checks; + } private const int ClientSecretValidationRetryDelayMs = 1000; private const int ClientSecretValidationTimeoutSeconds = 10; private const string MicrosoftLoginOAuthTokenEndpoint = "https://login.microsoftonline.com/{0}/oauth2/v2.0/token"; @@ -183,21 +202,11 @@ await UpdateEndpointAsync( { try { - var requirementsResult = await RequirementsSubcommand.RunRequirementChecksAsync( - RequirementsSubcommand.GetRequirementChecks(authValidator, clientAppValidator), - setupConfig, - logger, - category: null, - CancellationToken.None); - - if (!requirementsResult) - { - logger.LogError("Setup cannot proceed due to the failed requirement checks above. Please fix the issues above and then try again."); - logger.LogError("Use the resolution guidance provided for each failed check."); - ExceptionHandler.ExitWithCleanup(1); - } + var checks = BlueprintSubcommand.GetChecks(authValidator, clientAppValidator); + await RequirementsSubcommand.RunChecksOrExitAsync( + checks, setupConfig, logger, CancellationToken.None); } - catch (Exception reqEx) + catch (Exception reqEx) when (reqEx is not OperationCanceledException) { logger.LogError(reqEx, "Requirements check failed with an unexpected error: {Message}", reqEx.Message); logger.LogError("If you want to bypass requirement validation, rerun this command with the --skip-requirements flag."); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs index db0b8234..ffa5c27b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs @@ -1,11 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Commands; using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Extensions.Logging; using System.CommandLine; @@ -18,6 +20,12 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; /// internal static class CopilotStudioSubcommand { + /// + /// Returns the requirement checks for setup permissions copilotstudio. + /// + public static List GetChecks(AzureAuthValidator auth) + => SetupCommand.GetBaseChecks(auth); + /// /// Validates CopilotStudio permissions prerequisites without performing any actions. /// @@ -80,15 +88,8 @@ public static Command CreateCommand( // which would be a side effect in a mode that is supposed to be non-mutating. if (!dryRun) { - var copilotChecks = new List { new AzureAuthRequirementCheck(authValidator) }; - copilotChecks.AddRange(RequirementsSubcommand.GetSystemRequirementChecks()); - var systemChecksOk = await RequirementsSubcommand.RunRequirementChecksAsync( - copilotChecks, setupConfig, logger, category: null, CancellationToken.None); - if (!systemChecksOk) - { - logger.LogError("Setup cannot proceed due to failed requirement checks above. Please fix the issues and retry."); - ExceptionHandler.ExitWithCleanup(1); - } + var copilotChecks = CopilotStudioSubcommand.GetChecks(authValidator); + await RequirementsSubcommand.RunChecksOrExitAsync(copilotChecks, setupConfig, logger, CancellationToken.None); } if (dryRun) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs index 632bac5c..5a84c66b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs @@ -1,11 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Commands; using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Extensions.Logging; using System.CommandLine; @@ -19,6 +21,24 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; /// internal static class PermissionsSubcommand { + /// + /// Returns the requirement checks for setup permissions mcp. + /// + public static List GetMcpChecks(AzureAuthValidator auth) + => SetupCommand.GetBaseChecks(auth); + + /// + /// Returns the requirement checks for setup permissions bot. + /// + public static List GetBotChecks(AzureAuthValidator auth) + => SetupCommand.GetBaseChecks(auth); + + /// + /// Returns the requirement checks for setup permissions custom. + /// + public static List GetCustomChecks(AzureAuthValidator auth) + => SetupCommand.GetBaseChecks(auth); + public static Command CreateCommand( ILogger logger, AzureAuthValidator authValidator, @@ -93,15 +113,8 @@ private static Command CreateMcpSubcommand( // which would be a side effect in a mode that is supposed to be non-mutating. if (!dryRun) { - var mcpChecks = new List { new AzureAuthRequirementCheck(authValidator) }; - mcpChecks.AddRange(RequirementsSubcommand.GetSystemRequirementChecks()); - var mcpSystemChecksOk = await RequirementsSubcommand.RunRequirementChecksAsync( - mcpChecks, setupConfig, logger, category: null, CancellationToken.None); - if (!mcpSystemChecksOk) - { - logger.LogError("Setup cannot proceed due to failed requirement checks above. Please fix the issues and retry."); - ExceptionHandler.ExitWithCleanup(1); - } + var mcpChecks = GetMcpChecks(authValidator); + await RequirementsSubcommand.RunChecksOrExitAsync(mcpChecks, setupConfig, logger, CancellationToken.None); } if (dryRun) @@ -188,15 +201,8 @@ private static Command CreateBotSubcommand( // which would be a side effect in a mode that is supposed to be non-mutating. if (!dryRun) { - var botChecks = new List { new AzureAuthRequirementCheck(authValidator) }; - botChecks.AddRange(RequirementsSubcommand.GetSystemRequirementChecks()); - var botSystemChecksOk = await RequirementsSubcommand.RunRequirementChecksAsync( - botChecks, setupConfig, logger, category: null, CancellationToken.None); - if (!botSystemChecksOk) - { - logger.LogError("Setup cannot proceed due to failed requirement checks above. Please fix the issues and retry."); - ExceptionHandler.ExitWithCleanup(1); - } + var botChecks = GetBotChecks(authValidator); + await RequirementsSubcommand.RunChecksOrExitAsync(botChecks, setupConfig, logger, CancellationToken.None); } if (dryRun) @@ -279,15 +285,8 @@ private static Command CreateCustomSubcommand( // which would be a side effect in a mode that is supposed to be non-mutating. if (!dryRun) { - var customChecks = new List { new AzureAuthRequirementCheck(authValidator) }; - customChecks.AddRange(RequirementsSubcommand.GetSystemRequirementChecks()); - var customSystemChecksOk = await RequirementsSubcommand.RunRequirementChecksAsync( - customChecks, setupConfig, logger, category: null, CancellationToken.None); - if (!customSystemChecksOk) - { - logger.LogError("Setup cannot proceed due to failed requirement checks above. Please fix the issues and retry."); - ExceptionHandler.ExitWithCleanup(1); - } + var customChecks = GetCustomChecks(authValidator); + await RequirementsSubcommand.RunChecksOrExitAsync(customChecks, setupConfig, logger, CancellationToken.None); } if (dryRun) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs index f1104679..788f4e26 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; @@ -136,6 +137,24 @@ public static async Task RunRequirementChecksAsync( return failedChecks == 0; } + /// + /// Runs checks with formatted [PASS]/[FAIL] output and exits if any fail. + /// Use this instead of RunRequirementChecksAsync when failure should abort the command. + /// + public static async Task RunChecksOrExitAsync( + List checks, + Agent365Config config, + ILogger logger, + CancellationToken cancellationToken = default) + { + var passed = await RunRequirementChecksAsync(checks, config, logger, category: null, cancellationToken); + if (!passed) + { + logger.LogError("Operation cannot proceed due to failed requirement checks above. Please fix the issues and retry."); + ExceptionHandler.ExitWithCleanup(1); + } + } + /// /// Gets all available requirement checks. /// Derived from the union of system and config checks to keep a single source of truth. @@ -151,7 +170,7 @@ public static List GetRequirementChecks(AzureAuthValidator au /// Gets system-level requirement checks that do not depend on configuration. /// These can be run before the configuration wizard to surface blockers early. /// - public static List GetSystemRequirementChecks() + private static List GetSystemRequirementChecks() { return new List { @@ -166,7 +185,7 @@ public static List GetSystemRequirementChecks() /// /// Gets configuration-dependent requirement checks that must run after the configuration is loaded. /// - public static List GetConfigRequirementChecks(AzureAuthValidator authValidator, IClientAppValidator clientAppValidator) + private static List GetConfigRequirementChecks(AzureAuthValidator authValidator, IClientAppValidator clientAppValidator) { return new List { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs index e7c1c221..28d58d7a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs @@ -106,6 +106,30 @@ public static ClientAppValidationException MissingAdminConsent(string clientAppI }); } + /// + /// Creates exception for when the Azure token was revoked by a security event (CAE). + /// + public static ClientAppValidationException TokenRevoked(string clientAppId) + { + return new ClientAppValidationException( + issueDescription: "Azure authentication token revoked — re-authentication required", + errorDetails: new List + { + "Your Azure CLI token has been revoked due to a security event (Continuous Access Evaluation).", + "This occurs when a password is changed, MFA is updated, or a conditional access policy fires." + }, + mitigationSteps: new List + { + "Run: az logout", + "Run: az login", + "Then retry the command." + }, + context: new Dictionary + { + ["clientAppId"] = clientAppId + }); + } + /// /// Creates exception for general validation failures with custom details. /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs index a62bac7a..f62eae73 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs @@ -325,6 +325,7 @@ private async Task EnsurePublicClientFlowsEnabledAsync( var tokenResult = await _executor.ExecuteAsync( "az", $"account get-access-token --resource {GraphTokenResource} --query accessToken -o tsv", + suppressErrorLogging: true, cancellationToken: ct); if (!tokenResult.Success || string.IsNullOrWhiteSpace(tokenResult.StandardOutput)) @@ -343,6 +344,7 @@ private async Task EnsurePublicClientFlowsEnabledAsync( var appCheckResult = await _executor.ExecuteAsync( "az", $"rest --method GET --url \"{GraphApiBaseUrl}/applications?$filter=appId eq '{CommandStringHelper.EscapePowerShellString(clientAppId)}'&$select=id,appId,displayName,requiredResourceAccess\" --headers \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(graphToken)}\"", + suppressErrorLogging: true, cancellationToken: ct); if (!appCheckResult.Success) @@ -351,12 +353,13 @@ private async Task EnsurePublicClientFlowsEnabledAsync( if (appCheckResult.StandardError.Contains("TokenCreatedWithOutdatedPolicies", StringComparison.OrdinalIgnoreCase) || appCheckResult.StandardError.Contains("InvalidAuthenticationToken", StringComparison.OrdinalIgnoreCase)) { - _logger.LogWarning("Azure CLI token is stale due to Continuous Access Evaluation. Refreshing token automatically..."); - + _logger.LogDebug("Azure CLI token is stale due to Continuous Access Evaluation. Attempting token refresh..."); + // Force token refresh var refreshResult = await _executor.ExecuteAsync( "az", $"account get-access-token --resource {GraphTokenResource} --query accessToken -o tsv", + suppressErrorLogging: true, cancellationToken: ct); if (refreshResult.Success && !string.IsNullOrWhiteSpace(refreshResult.StandardOutput)) @@ -368,6 +371,7 @@ private async Task EnsurePublicClientFlowsEnabledAsync( var retryResult = await _executor.ExecuteAsync( "az", $"rest --method GET --url \"{GraphApiBaseUrl}/applications?$filter=appId eq '{CommandStringHelper.EscapePowerShellString(clientAppId)}'&$select=id,appId,displayName,requiredResourceAccess\" --headers \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(freshToken)}\"", + suppressErrorLogging: true, cancellationToken: ct); if (retryResult.Success) @@ -376,14 +380,20 @@ private async Task EnsurePublicClientFlowsEnabledAsync( } else { + // Token refresh succeeded but the Graph call still rejected it — the revocation + // is server-side and cannot be silently recovered. Throw explicitly so the + // caller shows "token revoked" rather than "app not found". _logger.LogDebug("App query failed after token refresh: {Error}", retryResult.StandardError); - return null; + throw ClientAppValidationException.TokenRevoked(clientAppId); } } } if (!appCheckResult.Success) { + if (IsCaeError(appCheckResult.StandardError)) + throw ClientAppValidationException.TokenRevoked(clientAppId); + _logger.LogDebug("App query failed: {Error}", appCheckResult.StandardError); return null; } @@ -702,6 +712,10 @@ private async Task ValidateAdminConsentAsync(string clientAppId, string gr #region Helper Types + private static bool IsCaeError(string errorOutput) => + errorOutput.Contains("TokenIssuedBeforeRevocationTimestamp", StringComparison.OrdinalIgnoreCase) || + errorOutput.Contains("TokenCreatedWithOutdatedPolicies", StringComparison.OrdinalIgnoreCase); + private record ClientAppInfo(string ObjectId, string DisplayName, JsonArray? RequiredResourceAccess); #endregion diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/RequirementsSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/RequirementsSubcommandTests.cs index 08afe944..a1acbf48 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/RequirementsSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/RequirementsSubcommandTests.cs @@ -177,22 +177,24 @@ public void GetRequirementChecks_ContainsAllExpectedCheckTypes() } [Fact] - public void GetRequirementChecks_IsUnionOfSystemAndConfigChecks() + public void GetRequirementChecks_SystemChecksRunBeforeConfigChecks() { - // GetRequirementChecks must exactly equal GetSystemRequirementChecks + GetConfigRequirementChecks. + // GetRequirementChecks returns system checks (FrontierPreview, PowerShellModules) + // before config checks (AzureAuth, Location, ClientApp). var mockExecutor = Substitute.ForPartsOf(Substitute.For>()); var mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, mockExecutor); var mockValidator = Substitute.For(); var all = RequirementsSubcommand.GetRequirementChecks(mockAuthValidator, mockValidator); - var system = RequirementsSubcommand.GetSystemRequirementChecks(); - var config = RequirementsSubcommand.GetConfigRequirementChecks(mockAuthValidator, mockValidator); - - all.Should().HaveCount(system.Count + config.Count); - all.Select(c => c.GetType()).Should().StartWith(system.Select(c => c.GetType()), - "system checks run before config checks"); - all.Select(c => c.GetType()).Should().EndWith(config.Select(c => c.GetType()), - "config checks follow system checks"); + + // System checks come first + var types = all.Select(c => c.GetType()).ToList(); + types.IndexOf(typeof(FrontierPreviewRequirementCheck)) + .Should().BeLessThan(types.IndexOf(typeof(AzureAuthRequirementCheck)), + "system checks should run before config checks"); + types.IndexOf(typeof(PowerShellModulesRequirementCheck)) + .Should().BeLessThan(types.IndexOf(typeof(LocationRequirementCheck)), + "system checks should run before config checks"); } #endregion diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs index 20a4f51c..c54bd6e6 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs @@ -271,6 +271,7 @@ public async Task EnsureValidClientAppAsync_WhenAppNotFound_ThrowsClientAppValid _executor.ExecuteAsync( Arg.Is(s => s == "az"), Arg.Is(s => s.Contains("rest --method GET") && s.Contains("/applications")), + suppressErrorLogging: Arg.Any(), cancellationToken: Arg.Any()) .Returns(new CommandResult { ExitCode = 0, StandardOutput = "{\"value\": []}", StandardError = string.Empty }); @@ -324,6 +325,7 @@ private void SetupTokenAcquisition(string token) _executor.ExecuteAsync( Arg.Is(s => s == "az"), Arg.Is(s => s.Contains("account get-access-token")), + suppressErrorLogging: Arg.Any(), cancellationToken: Arg.Any()) .Returns(new CommandResult { ExitCode = 0, StandardOutput = token, StandardError = string.Empty }); } @@ -347,6 +349,7 @@ private void SetupAppExists(string appId, string displayName, string? requiredRe _executor.ExecuteAsync( Arg.Is(s => s == "az"), Arg.Is(s => s.Contains("rest --method GET") && s.Contains("/applications")), + suppressErrorLogging: Arg.Any(), cancellationToken: Arg.Any()) .Returns(new CommandResult { ExitCode = 0, StandardOutput = appJson, StandardError = string.Empty }); } From 82c903353cb95176fc01adf1aecc7414a850bf67 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Sun, 8 Mar 2026 04:17:17 -0700 Subject: [PATCH 06/11] fix: suppress raw subprocess output leaking before structured check results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AzureAuthValidator: add suppressErrorLogging to az account show call to prevent CommandExecutor from printing raw stderr before [FAIL] output. Remove verbose LogError/LogInformation guidance blocks — the validator returns bool only; issue/resolution messaging belongs in the check layer. PowerShellModulesRequirementCheck: downgrade auto-install progress from LogInformation/LogWarning to LogDebug so they don't print before [PASS]. Co-Authored-By: Claude Sonnet 4.6 --- .../Services/AzureAuthValidator.cs | 23 ++++--------------- .../PowerShellModulesRequirementCheck.cs | 8 +++---- 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs index 49e28eb1..abf5283b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs @@ -31,20 +31,11 @@ public virtual async Task ValidateAuthenticationAsync(string? expectedSubs try { // Check Azure CLI authentication by trying to get current account - var result = await _executor.ExecuteAsync("az", "account show --output json", captureOutput: true); - + var result = await _executor.ExecuteAsync("az", "account show --output json", captureOutput: true, suppressErrorLogging: true); + if (!result.Success) { - _logger.LogError("Azure CLI authentication required!"); - _logger.LogInformation(""); - _logger.LogInformation("Please run the following command to log in to Azure:"); - _logger.LogInformation(" az login"); - _logger.LogInformation(""); - _logger.LogInformation("After logging in, run this command again."); - _logger.LogInformation(""); - _logger.LogInformation("For more information about Azure CLI authentication:"); - _logger.LogInformation(" https://docs.microsoft.com/en-us/cli/azure/authenticate-azure-cli"); - _logger.LogInformation(""); + _logger.LogDebug("Azure CLI authentication check failed: {Error}", result.StandardError); return false; } @@ -66,13 +57,7 @@ public virtual async Task ValidateAuthenticationAsync(string? expectedSubs { if (!string.Equals(subscriptionId, expectedSubscriptionId, StringComparison.OrdinalIgnoreCase)) { - _logger.LogError("Azure CLI is using a different subscription than configured"); - _logger.LogError(" Expected: {ExpectedSubscription}", expectedSubscriptionId); - _logger.LogError(" Current: {CurrentSubscription}", subscriptionId); - _logger.LogInformation(""); - _logger.LogInformation("Please switch to the correct subscription:"); - _logger.LogInformation(" az account set --subscription {ExpectedSubscription}", expectedSubscriptionId); - _logger.LogInformation(""); + _logger.LogDebug("Subscription mismatch — expected: {Expected}, current: {Current}", expectedSubscriptionId, subscriptionId); return false; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/PowerShellModulesRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/PowerShellModulesRequirementCheck.cs index fb2687be..3720d340 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/PowerShellModulesRequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/PowerShellModulesRequirementCheck.cs @@ -93,13 +93,13 @@ private async Task CheckImplementationAsync(Agent365Conf } // Attempt auto-install for missing modules - logger.LogInformation("Attempting to auto-install missing PowerShell modules..."); + logger.LogDebug("Attempting to auto-install missing PowerShell modules..."); var autoInstalled = new List(); var stillMissing = new List(); foreach (var module in missingModules) { - logger.LogInformation("Installing {ModuleName}...", module.Name); + logger.LogDebug("Installing {ModuleName}...", module.Name); var installSuccess = await InstallModuleAsync(module.Name, logger, cancellationToken); if (installSuccess) @@ -108,12 +108,12 @@ private async Task CheckImplementationAsync(Agent365Conf if (verified) { autoInstalled.Add(module); - logger.LogInformation("Successfully installed {ModuleName}", module.Name); + logger.LogDebug("Successfully installed {ModuleName}", module.Name); } else { stillMissing.Add(module); - logger.LogWarning("Install succeeded but {ModuleName} not found in module path after install", module.Name); + logger.LogDebug("Install succeeded but {ModuleName} not found in module path after install", module.Name); } } else From 82462e834bf797a638bc6e316fd714d9cc78f87f Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Sun, 8 Mar 2026 04:57:54 -0700 Subject: [PATCH 07/11] feat: add cleanup azure --dry-run, AppService/MOS checks, update docs - Add `--dry-run` flag to `a365 cleanup azure`: previews resources that would be deleted without requiring Azure auth or making any changes - Add `AppServiceAuthRequirementCheck`: validates App Service deployment token before `a365 deploy`, catching AADSTS50173 token revocation early - Add `MosPrerequisitesRequirementCheck`: validates MOS service principals before `a365 publish` proceeds, converting SetupValidationException to structured failure output - Wire new checks into DeployCommand and PublishCommand via RunChecksOrExitAsync, replacing ad-hoc inline validation - Add `GetChecks(AzureAuthValidator)` to InfrastructureSubcommand for explicit check composition - Add `GetAppServiceTokenAsync` to AzureAuthValidator - Update CLI design.md: add Requirements/ to project structure and document the IRequirementCheck prerequisite validation pattern - Update CHANGELOG.md with user-visible additions Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 5 ++ .../Commands/CleanupCommand.cs | 23 ++++-- .../Commands/DeployCommand.cs | 24 +++--- .../Commands/PublishCommand.cs | 37 +++------ .../InfrastructureSubcommand.cs | 27 +++---- .../Services/AzureAuthValidator.cs | 17 ++++ .../AppServiceAuthRequirementCheck.cs | 53 +++++++++++++ .../MosPrerequisitesRequirementCheck.cs | 72 +++++++++++++++++ .../design.md | 78 ++++++++++++++++++- 9 files changed, 280 insertions(+), 56 deletions(-) create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AppServiceAuthRequirementCheck.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/MosPrerequisitesRequirementCheck.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e84f1fe2..64adb4b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- `a365 cleanup azure --dry-run` — preview resources that would be deleted without making any changes or requiring Azure authentication +- `AppServiceAuthRequirementCheck` — validates App Service deployment token before `a365 deploy` begins, catching revoked grants (AADSTS50173) early +- `MosPrerequisitesRequirementCheck` — validates MOS service principals before `a365 publish` proceeds + ### Fixed - macOS/Linux: device code fallback when browser authentication is unavailable (#309) - Linux: MSAL fallback when PowerShell `Connect-MgGraph` fails in non-TTY environments (#309) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs index d7506e55..2ddc1b24 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs @@ -337,20 +337,27 @@ private static Command CreateAzureCleanupCommand( new[] { "--verbose", "-v" }, description: "Enable verbose logging"); + var dryRunOption = new Option("--dry-run", "Show resources that would be deleted without making any changes"); + command.AddOption(configOption); command.AddOption(verboseOption); + command.AddOption(dryRunOption); - command.SetHandler(async (configFile, verbose) => + command.SetHandler(async (configFile, verbose, dryRun) => { try { - logger.LogInformation("Starting Azure cleanup..."); + if (!dryRun) + logger.LogInformation("Starting Azure cleanup..."); var config = await LoadConfigAsync(configFile, logger, configService); if (config == null) return; - var checks = GetAzureCleanupChecks(authValidator); - await RequirementsSubcommand.RunChecksOrExitAsync(checks, config, logger, CancellationToken.None); + if (!dryRun) + { + var checks = GetAzureCleanupChecks(authValidator); + await RequirementsSubcommand.RunChecksOrExitAsync(checks, config, logger, CancellationToken.None); + } logger.LogInformation(""); logger.LogInformation("Azure Cleanup Preview:"); @@ -362,6 +369,12 @@ private static Command CreateAzureCleanupCommand( logger.LogInformation(" Resource Group: {ResourceGroup}", config.ResourceGroup); logger.LogInformation(""); + if (dryRun) + { + logger.LogInformation("DRY RUN: No changes made."); + return; + } + Console.Write("Continue with Azure cleanup? (y/N): "); var response = Console.ReadLine()?.Trim().ToLowerInvariant(); if (response != "y" && response != "yes") @@ -418,7 +431,7 @@ private static Command CreateAzureCleanupCommand( { logger.LogError(ex, "Azure cleanup failed with exception"); } - }, configOption, verboseOption); + }, configOption, verboseOption, dryRunOption); return command; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs index 5a544004..de19bc5a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs @@ -8,6 +8,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Extensions.Logging; using System.CommandLine; @@ -98,6 +99,14 @@ public static Command CreateCommand( return command; } + /// + /// Requirement checks for deploy: Azure CLI auth and App Service token validity. + /// AppServiceAuthRequirementCheck probes the App Service scope explicitly to catch + /// revoked/expired grants before build and upload begin. + /// + public static List GetChecks(AzureAuthValidator auth) + => [new AzureAuthRequirementCheck(auth), new AppServiceAuthRequirementCheck(auth)]; + private static Command CreateAppSubcommand( ILogger logger, IConfigService configService, @@ -285,18 +294,9 @@ private static Command CreateMcpSubcommand( return null; } - // Validate Azure CLI authentication, subscription, and environment - var checks = new List - { - new AzureAuthRequirementCheck(authValidator) - }; - var authOk = await RequirementsSubcommand.RunRequirementChecksAsync( - checks, configData, logger, category: null, CancellationToken.None); - if (!authOk) - { - logger.LogError("Deployment cannot proceed without proper Azure CLI authentication and the correct subscription context"); - return null; - } + // Validate Azure CLI authentication and App Service token scope + await RequirementsSubcommand.RunChecksOrExitAsync( + GetChecks(authValidator), configData, logger, CancellationToken.None); // Validate Azure Web App exists before starting deployment logger.LogInformation("Validating Azure Web App exists..."); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs index 7d5c99c1..a259d5e9 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Helpers; @@ -8,6 +9,8 @@ using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Extensions.Logging; using Microsoft.Identity.Client; using System.CommandLine; @@ -84,6 +87,13 @@ private static string GetProjectDirectory(Agent365Config config, ILogger logger) } } + /// + /// Requirement checks for publish: MOS service principals must exist and be configured. + /// Runs before the interactive manifest editing pause to avoid wasted work. + /// + internal static List GetChecks(GraphApiService graphApiService, AgentBlueprintService blueprintService) + => [new MosPrerequisitesRequirementCheck(graphApiService, blueprintService)]; + public static Command CreateCommand( ILogger logger, IConfigService configService, @@ -229,31 +239,8 @@ public static Command CreateCommand( logger.LogDebug("Manifest files written to disk"); // Verify MOS prerequisites before asking user to edit manifest (fail fast) - logger.LogInformation(""); - logger.LogDebug("Checking MOS prerequisites (service principals and permissions)..."); - try - { - var mosPrereqsConfigured = await PublishHelpers.EnsureMosPrerequisitesAsync( - graphApiService, blueprintService, config, logger); - - if (!mosPrereqsConfigured) - { - logger.LogError("Failed to configure MOS prerequisites. Aborting publish."); - return; - } - logger.LogInformation(""); - } - catch (SetupValidationException ex) - { - logger.LogError("MOS prerequisites configuration failed: {Message}", ex.Message); - logger.LogInformation(""); - logger.LogInformation("To manually create MOS service principals, run:"); - logger.LogInformation(" az ad sp create --id 6ec511af-06dc-4fe2-b493-63a37bc397b1"); - logger.LogInformation(" az ad sp create --id 8578e004-a5c6-46e7-913e-12f58912df43"); - logger.LogInformation(" az ad sp create --id e8be65d6-d430-4289-a665-51bf2a194bda"); - logger.LogInformation(""); - return; - } + await RequirementsSubcommand.RunChecksOrExitAsync( + GetChecks(graphApiService, blueprintService), config, logger, context.GetCancellationToken()); // Interactive pause for user customization logger.LogInformation(""); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs index 28d25a2b..b4de6da1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs @@ -6,6 +6,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; using Microsoft.Extensions.Logging; using System.CommandLine; @@ -25,7 +26,17 @@ public static class InfrastructureSubcommand private const int MaxSdkValidationAttempts = 3; private const int InitialRetryDelayMs = 500; private const int MaxRetryDelayMs = 5000; // Cap exponential backoff at 5 seconds - + + /// + /// Requirement checks for setup infrastructure: Azure auth, Frontier Preview, PowerShell modules, and infrastructure config. + /// + internal static List GetChecks(AzureAuthValidator auth) + { + var checks = SetupCommand.GetBaseChecks(auth); + checks.Add(new InfrastructureRequirementCheck()); + return checks; + } + public static Command CreateCommand( ILogger logger, IConfigService configService, @@ -86,18 +97,8 @@ public static Command CreateCommand( var setupConfig = await configService.LoadAsync(config.FullName); if (setupConfig.NeedDeployment) { - var checks = new List - { - new AzureAuthRequirementCheck(authValidator), - new InfrastructureRequirementCheck() - }; - var checksOk = await RequirementsSubcommand.RunRequirementChecksAsync( - checks, setupConfig, logger, category: null, CancellationToken.None); - if (!checksOk) - { - logger.LogError("Setup cannot proceed due to failed requirement checks above. Please fix the issues and retry."); - ExceptionHandler.ExitWithCleanup(1); - } + await RequirementsSubcommand.RunChecksOrExitAsync( + GetChecks(authValidator), setupConfig, logger, CancellationToken.None); } else { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs index abf5283b..ae714363 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs @@ -77,4 +77,21 @@ public virtual async Task ValidateAuthenticationAsync(string? expectedSubs return false; } } + + /// + /// Probes the Azure App Service token scope to verify deployment credentials are valid. + /// Returns false if the grant is expired or revoked (AADSTS50173 / invalid_grant). + /// + public virtual async Task GetAppServiceTokenAsync(CancellationToken ct = default) + { + var result = await _executor.ExecuteAsync( + "az", + "account get-access-token --resource https://appservice.azure.com", + captureOutput: true, + suppressErrorLogging: true, + cancellationToken: ct); + + _logger.LogDebug("App Service token probe: {Result}", result.Success ? "valid" : "expired or revoked"); + return result.Success; + } } \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AppServiceAuthRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AppServiceAuthRequirementCheck.cs new file mode 100644 index 00000000..ce190d29 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AppServiceAuthRequirementCheck.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; + +/// +/// Validates that the Azure App Service deployment token is valid and not expired or revoked. +/// Probes the App Service token scope explicitly, which is not covered by AzureAuthRequirementCheck. +/// Catches stale/revoked grants (AADSTS50173) before build and upload begin. +/// +public class AppServiceAuthRequirementCheck : RequirementCheck +{ + private readonly AzureAuthValidator _auth; + + public AppServiceAuthRequirementCheck(AzureAuthValidator auth) + { + _auth = auth ?? throw new ArgumentNullException(nameof(auth)); + } + + /// + public override string Name => "App Service Authentication"; + + /// + public override string Description => "Validates that the Azure App Service deployment token is valid and not expired or revoked"; + + /// + public override string Category => "Azure"; + + /// + public override async Task CheckAsync( + Agent365Config config, + ILogger logger, + CancellationToken cancellationToken = default) + { + return await ExecuteCheckWithLoggingAsync(config, logger, CheckImplementationAsync, cancellationToken); + } + + private async Task CheckImplementationAsync( + Agent365Config config, + ILogger logger, + CancellationToken cancellationToken) + { + var success = await _auth.GetAppServiceTokenAsync(cancellationToken); + return success + ? RequirementCheckResult.Success() + : RequirementCheckResult.Failure( + "Azure App Service token is expired or revoked", + "Run: az logout\n az login --tenant "); + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/MosPrerequisitesRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/MosPrerequisitesRequirementCheck.cs new file mode 100644 index 00000000..55f60104 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/MosPrerequisitesRequirementCheck.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; +using Microsoft.Agents.A365.DevTools.Cli.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; + +/// +/// Ensures MOS service principals exist in the tenant, creating and configuring them if absent. +/// Wraps PublishHelpers.EnsureMosPrerequisitesAsync so it runs before the interactive manifest +/// editing pause, preventing wasted work if MOS prerequisites are not configured. +/// +public class MosPrerequisitesRequirementCheck : RequirementCheck +{ + private readonly GraphApiService _graphApiService; + private readonly AgentBlueprintService _blueprintService; + + public MosPrerequisitesRequirementCheck(GraphApiService graphApiService, AgentBlueprintService blueprintService) + { + _graphApiService = graphApiService ?? throw new ArgumentNullException(nameof(graphApiService)); + _blueprintService = blueprintService ?? throw new ArgumentNullException(nameof(blueprintService)); + } + + /// + public override string Name => "MOS Prerequisites"; + + /// + public override string Description => "Ensures MOS service principals exist in tenant, creating and configuring them if absent"; + + /// + public override string Category => "MOS"; + + /// + public override async Task CheckAsync( + Agent365Config config, + ILogger logger, + CancellationToken cancellationToken = default) + { + return await ExecuteCheckWithLoggingAsync(config, logger, CheckImplementationAsync, cancellationToken); + } + + private async Task CheckImplementationAsync( + Agent365Config config, + ILogger logger, + CancellationToken cancellationToken) + { + try + { + var ok = await PublishHelpers.EnsureMosPrerequisitesAsync( + _graphApiService, _blueprintService, config, logger, cancellationToken); + + return ok + ? RequirementCheckResult.Success() + : RequirementCheckResult.Failure( + "MOS service principals not configured", + "Run 'a365 setup all' to configure MOS prerequisites"); + } + catch (SetupValidationException ex) + { + // EnsureMosPrerequisitesAsync throws SetupValidationException for unrecoverable + // failures (e.g., insufficient privileges). Convert to Failure so the check + // framework returns [FAIL] with guidance rather than an unhandled exception. + var resolution = ex.MitigationSteps.Count > 0 + ? string.Join("\n", ex.MitigationSteps) + : "Run 'a365 setup all' to configure MOS prerequisites"; + return RequirementCheckResult.Failure(ex.Message, resolution); + } + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/design.md b/src/Microsoft.Agents.A365.DevTools.Cli/design.md index b2da814a..d2d77778 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/design.md +++ b/src/Microsoft.Agents.A365.DevTools.Cli/design.md @@ -33,7 +33,13 @@ Microsoft.Agents.A365.DevTools.Cli/ │ ├── BotConfigurator.cs # Messaging endpoint registration │ ├── GraphApiService.cs # Graph API interactions │ ├── AuthenticationService.cs # MSAL.NET authentication -│ └── Helpers/ # Service helper utilities +│ ├── AzureAuthValidator.cs # Azure CLI auth + App Service token validation +│ ├── Helpers/ # Service helper utilities +│ └── Requirements/ # Prerequisite validation system +│ ├── IRequirementCheck.cs # Check interface +│ ├── RequirementCheck.cs # Abstract base class with logging wrapper +│ ├── RequirementCheckResult.cs # Success/Warning/Failure result +│ └── RequirementChecks/ # Concrete check implementations ├── Models/ # Data models │ ├── Agent365Config.cs # Unified configuration model │ ├── ProjectPlatform.cs # Platform enumeration @@ -200,6 +206,76 @@ public class SetupCommand : AsyncCommand --- +## Prerequisite Validation Pattern (IRequirementCheck) + +Commands validate prerequisites through a structured check system before performing any mutating work. This produces consistent `[PASS]`/`[FAIL]`/`[WARN]` output and ensures users see actionable errors early. + +### Core Types + +```csharp +// Each check returns a structured result +public class RequirementCheckResult +{ + public RequirementCheckStatus Status { get; } // Success, Warning, Failure + public string? Issue { get; } // What went wrong + public string? Resolution { get; } // How to fix it +} + +// Base class handles the [PASS]/[FAIL] output line +public abstract class RequirementCheck : IRequirementCheck +{ + public abstract string Name { get; } + public abstract string Category { get; } + public abstract Task CheckAsync(Agent365Config, ILogger, CancellationToken); +} +``` + +### Check Composition + +Each command declares its checks via a static `GetChecks()` method, making composition explicit and testable: + +```csharp +// deploy: auth first, then App Service token +public static List GetChecks(AzureAuthValidator auth) + => [new AzureAuthRequirementCheck(auth), new AppServiceAuthRequirementCheck(auth)]; + +// setup infrastructure: base checks + config validation +internal static List GetChecks(AzureAuthValidator auth) +{ + var checks = SetupCommand.GetBaseChecks(auth); // Auth + FrontierPreview + PowerShell + checks.Add(new InfrastructureRequirementCheck()); + return checks; +} +``` + +### Running Checks + +`RequirementsSubcommand.RunChecksOrExitAsync` is the shared runner — prints `[PASS]/[FAIL]/[WARN]` per check and calls `ExceptionHandler.ExitWithCleanup(1)` on any failure: + +```csharp +await RequirementsSubcommand.RunChecksOrExitAsync( + GetChecks(authValidator), config, logger, cancellationToken); +``` + +### Dry-Run Rule + +Commands supporting `--dry-run` skip checks entirely — the `RunChecksOrExitAsync` call is guarded by `if (!dryRun)` so dry runs are always fast and require no Azure credentials. + +### Available Checks + +| Check | Category | Used By | +|-------|----------|---------| +| `AzureAuthRequirementCheck` | Azure | setup all, setup infra, deploy, cleanup azure | +| `AppServiceAuthRequirementCheck` | Azure | deploy | +| `FrontierPreviewRequirementCheck` | Azure | setup all, setup infra | +| `PowerShellModulesRequirementCheck` | Tools | setup all, setup infra | +| `InfrastructureRequirementCheck` | Configuration | setup infra | +| `MosPrerequisitesRequirementCheck` | MOS | publish | +| `LocationRequirementCheck` | Configuration | setup endpoint | +| `ClientAppRequirementCheck` | Configuration | setup blueprint | + +--- + ## Multiplatform Deployment Architecture ### Platform Detection From 2e90bb8593db08e4d02f13efe13fcf2240d1efea Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Sun, 8 Mar 2026 05:29:24 -0700 Subject: [PATCH 08/11] fix: address PR #312 review comments (exit codes, log levels, skip-graph, tests) Exit codes (#7, #8/#9): - Set Environment.ExitCode = 1 in ValidateDeploymentPrerequisitesAsync before each null return so callers exit non-zero on config/Web App validation failure - Replace deploy-mcp guard `return` with ExceptionHandler.ExitWithCleanup(1) for AgentBlueprintId, AgenticAppId, and TenantId missing-config cases Log severity (#15, #16, #17): - LogCheckWarning: LogInformation -> LogWarning - LogCheckFailure: all three LogInformation -> LogError - ExecuteCheckWithLoggingAsync warning path: log ErrorMessage ?? Details so the primary warning message is no longer silently dropped skip-graph regressions (#21, #22): - Guard RunChecksOrExitAsync(MOS checks) behind if (!skipGraph) - Guard clientAppId null check behind !skipGraph in PublishCommand Unused parameter (#14): - Remove IPrerequisiteRunner from BlueprintSubcommand.CreateCommand signature - Update SetupCommand.cs call site and BlueprintSubcommandTests accordingly InfrastructureRequirementCheck (#5, #6): - Add I1/I2/I3/I1V2/I2V2/I3V2 (Isolated) SKUs to validation error message - Wrap CheckAsync with ExecuteCheckWithLoggingAsync so [PASS]/[FAIL] is printed PrerequisiteRunner warning message (#3): - Log ErrorMessage ?? Details, log even when both are empty IsCaeError gap (#18): - Add InvalidAuthenticationToken to IsCaeError in ClientAppValidator Stale comment (#10): - Update ValidateDeploymentPrerequisitesAsync doc to remove "environment" Tests (#19, #20): - Add AppServiceAuthRequirementCheckTests (success, failure, metadata, null guard) - Add MosPrerequisitesRequirementCheckTests (exception->failure, metadata, null guards) - Update FrontierPreviewRequirementCheckTests: [WARN] now at LogWarning not LogInformation Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/DeployCommand.cs | 16 ++- .../Commands/PublishCommand.cs | 10 +- .../Commands/SetupCommand.cs | 2 +- .../SetupSubcommands/BlueprintSubcommand.cs | 1 - .../Services/ClientAppValidator.cs | 3 +- .../Services/PrerequisiteRunner.cs | 6 +- .../Services/Requirements/RequirementCheck.cs | 14 +-- .../InfrastructureRequirementCheck.cs | 10 +- .../Commands/BlueprintSubcommandTests.cs | 32 ----- .../AppServiceAuthRequirementCheckTests.cs | 108 ++++++++++++++++ .../FrontierPreviewRequirementCheckTests.cs | 10 +- .../MosPrerequisitesRequirementCheckTests.cs | 117 ++++++++++++++++++ 12 files changed, 270 insertions(+), 59 deletions(-) create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/AppServiceAuthRequirementCheckTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/MosPrerequisitesRequirementCheckTests.cs diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs index de19bc5a..cfc42f45 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs @@ -233,17 +233,17 @@ private static Command CreateMcpSubcommand( if (string.IsNullOrWhiteSpace(updateConfig.AgentBlueprintId)) { logger.LogError("agentBlueprintId is not configured. Run 'a365 setup all' to create the agent blueprint."); - return; + ExceptionHandler.ExitWithCleanup(1); } if (string.IsNullOrWhiteSpace(updateConfig.AgenticAppId)) { logger.LogError("agenticAppId is not configured. Run 'a365 setup all' to complete setup."); - return; + ExceptionHandler.ExitWithCleanup(1); } if (string.IsNullOrWhiteSpace(updateConfig.TenantId)) { logger.LogError("tenantId is not configured. Run 'a365 setup all' to complete setup."); - return; + ExceptionHandler.ExitWithCleanup(1); } // Configure GraphApiService with custom client app ID if available @@ -269,7 +269,7 @@ private static Command CreateMcpSubcommand( } /// - /// Validates configuration, Azure authentication, and Web App existence + /// Validates configuration, Azure CLI authentication, and Web App existence /// private static async Task ValidateDeploymentPrerequisitesAsync( string configPath, @@ -280,7 +280,11 @@ private static Command CreateMcpSubcommand( { // Load configuration var configData = await configService.LoadAsync(configPath); - if (configData == null) return null; + if (configData == null) + { + Environment.ExitCode = 1; + return null; + } // Validate required config fields before any network calls var missingFields = new List(); @@ -291,6 +295,7 @@ private static Command CreateMcpSubcommand( { logger.LogError("Missing required configuration fields: {Fields}. Update a365.config.json and retry.", string.Join(", ", missingFields)); + Environment.ExitCode = 1; return null; } @@ -315,6 +320,7 @@ await RequirementsSubcommand.RunChecksOrExitAsync( logger.LogInformation(" 2. Or verify your a365.config.json has the correct WebAppName and ResourceGroup"); logger.LogInformation(""); logger.LogError("Deployment cannot proceed without a valid Azure Web App target"); + Environment.ExitCode = 1; return null; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs index a259d5e9..270a8dd8 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs @@ -151,7 +151,7 @@ public static Command CreateCommand( return; } - if (string.IsNullOrWhiteSpace(config.ClientAppId)) + if (!skipGraph && string.IsNullOrWhiteSpace(config.ClientAppId)) { logger.LogError("clientAppId is not configured. Run 'a365 setup blueprint' first to configure client app authentication."); return; @@ -239,8 +239,12 @@ public static Command CreateCommand( logger.LogDebug("Manifest files written to disk"); // Verify MOS prerequisites before asking user to edit manifest (fail fast) - await RequirementsSubcommand.RunChecksOrExitAsync( - GetChecks(graphApiService, blueprintService), config, logger, context.GetCancellationToken()); + // Skip when --skip-graph is set: MOS checks perform Graph operations + if (!skipGraph) + { + await RequirementsSubcommand.RunChecksOrExitAsync( + GetChecks(graphApiService, blueprintService), config, logger, context.GetCancellationToken()); + } // Interactive pause for user customization logger.LogInformation(""); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index fbd893bf..0146d357 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -64,7 +64,7 @@ public static Command CreateCommand( logger, configService, authValidator, webAppCreator, platformDetector, executor)); command.AddCommand(BlueprintSubcommand.CreateCommand( - logger, configService, executor, prerequisiteRunner, authValidator, webAppCreator, platformDetector, botConfigurator, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService)); + logger, configService, executor, authValidator, webAppCreator, platformDetector, botConfigurator, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService)); command.AddCommand(PermissionsSubcommand.CreateCommand( logger, authValidator, configService, executor, graphApiService, blueprintService)); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index 834dff44..bbdfc722 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -86,7 +86,6 @@ public static Command CreateCommand( ILogger logger, IConfigService configService, CommandExecutor executor, - IPrerequisiteRunner prerequisiteRunner, AzureAuthValidator authValidator, AzureWebAppCreator webAppCreator, PlatformDetector platformDetector, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs index f62eae73..f2ae0cc5 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs @@ -714,7 +714,8 @@ private async Task ValidateAdminConsentAsync(string clientAppId, string gr private static bool IsCaeError(string errorOutput) => errorOutput.Contains("TokenIssuedBeforeRevocationTimestamp", StringComparison.OrdinalIgnoreCase) || - errorOutput.Contains("TokenCreatedWithOutdatedPolicies", StringComparison.OrdinalIgnoreCase); + errorOutput.Contains("TokenCreatedWithOutdatedPolicies", StringComparison.OrdinalIgnoreCase) || + errorOutput.Contains("InvalidAuthenticationToken", StringComparison.OrdinalIgnoreCase); private record ClientAppInfo(string ObjectId, string DisplayName, JsonArray? RequiredResourceAccess); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs index c13506d3..490d9549 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs @@ -35,9 +35,11 @@ public async Task RunAsync( if (!string.IsNullOrWhiteSpace(result.ResolutionGuidance)) logger.LogError(" Resolution: {ResolutionGuidance}", result.ResolutionGuidance); } - else if (result.IsWarning && !string.IsNullOrWhiteSpace(result.Details)) + else if (result.IsWarning) { - logger.LogWarning("{CheckName}: {Details}", check.Name, result.Details); + var warningMessage = result.ErrorMessage ?? result.Details; + logger.LogWarning("{CheckName}: {WarningMessage}", check.Name, + string.IsNullOrWhiteSpace(warningMessage) ? "Warning reported with no message" : warningMessage); } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs index d0292f9f..98e8b731 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs @@ -35,10 +35,10 @@ protected virtual void LogCheckSuccess(ILogger logger, string? details = null) /// /// Helper method to log check warning /// - protected virtual void LogCheckWarning(ILogger logger, string? details = null) + protected virtual void LogCheckWarning(ILogger logger, string? message = null) { - logger.LogInformation("[WARN] {Name}{Details}", Name, - string.IsNullOrWhiteSpace(details) ? "" : $" - {details}"); + logger.LogWarning("[WARN] {Name}{Details}", Name, + string.IsNullOrWhiteSpace(message) ? "" : $" - {message}"); } /// @@ -46,9 +46,9 @@ protected virtual void LogCheckWarning(ILogger logger, string? details = null) /// protected virtual void LogCheckFailure(ILogger logger, string errorMessage, string resolutionGuidance) { - logger.LogInformation("[FAIL] {Name}", Name); - logger.LogInformation(" Issue: {ErrorMessage}", errorMessage); - logger.LogInformation(" Resolution: {ResolutionGuidance}", resolutionGuidance); + logger.LogError("[FAIL] {Name}", Name); + logger.LogError(" Issue: {ErrorMessage}", errorMessage); + logger.LogError(" Resolution: {ResolutionGuidance}", resolutionGuidance); } /// @@ -69,7 +69,7 @@ protected async Task ExecuteCheckWithLoggingAsync( { if (result.IsWarning) { - LogCheckWarning(logger, result.Details); + LogCheckWarning(logger, result.ErrorMessage ?? result.Details); } else { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/InfrastructureRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/InfrastructureRequirementCheck.cs index 4c690eb1..960e3a11 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/InfrastructureRequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/InfrastructureRequirementCheck.cs @@ -28,6 +28,14 @@ public override Task CheckAsync( Agent365Config config, ILogger logger, CancellationToken cancellationToken = default) + { + return ExecuteCheckWithLoggingAsync(config, logger, CheckImplementationAsync, cancellationToken); + } + + private static Task CheckImplementationAsync( + Agent365Config config, + ILogger logger, + CancellationToken cancellationToken) { if (!config.NeedDeployment) return Task.FromResult(RequirementCheckResult.Success()); @@ -54,7 +62,7 @@ public override Task CheckAsync( : config.AppServicePlanSku; if (!IsValidAppServicePlanSku(sku)) - errors.Add($"Invalid appServicePlanSku '{sku}'. Valid SKUs: F1 (Free), B1/B2/B3 (Basic), S1/S2/S3 (Standard), P1V2/P2V2/P3V2 (Premium V2), P1V3/P2V3/P3V3 (Premium V3)"); + errors.Add($"Invalid appServicePlanSku '{sku}'. Valid SKUs: F1 (Free), B1/B2/B3 (Basic), S1/S2/S3 (Standard), P1V2/P2V2/P3V2 (Premium V2), P1V3/P2V3/P3V3 (Premium V3), I1/I2/I3/I1V2/I2V2/I3V2 (Isolated)"); if (errors.Count > 0) { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs index 445dba95..5eb137c3 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs @@ -6,7 +6,6 @@ using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; -using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; @@ -28,7 +27,6 @@ public class BlueprintSubcommandTests private readonly ILogger _mockLogger; private readonly IConfigService _mockConfigService; private readonly CommandExecutor _mockExecutor; - private readonly IPrerequisiteRunner _mockPrerequisiteRunner; private readonly AzureAuthValidator _mockAuthValidator; private readonly AzureWebAppCreator _mockWebAppCreator; private readonly PlatformDetector _mockPlatformDetector; @@ -45,7 +43,6 @@ public BlueprintSubcommandTests() _mockConfigService = Substitute.For(); var mockExecutorLogger = Substitute.For>(); _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); - _mockPrerequisiteRunner = Substitute.For(); _mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, _mockExecutor); _mockWebAppCreator = Substitute.ForPartsOf(Substitute.For>()); var mockPlatformDetectorLogger = Substitute.For>(); @@ -66,7 +63,6 @@ public void CreateCommand_ShouldHaveCorrectName() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -85,7 +81,6 @@ public void CreateCommand_ShouldHaveDescription() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -105,7 +100,6 @@ public void CreateCommand_ShouldHaveConfigOption() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -127,7 +121,6 @@ public void CreateCommand_ShouldHaveVerboseOption() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -149,7 +142,6 @@ public void CreateCommand_ShouldHaveDryRunOption() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -170,7 +162,6 @@ public void CreateCommand_ShouldHaveSkipRequirementsOption() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -200,7 +191,6 @@ public async Task DryRun_ShouldLoadConfigAndNotExecute() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -235,7 +225,6 @@ public async Task DryRun_ShouldDisplayBlueprintInformation() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -273,13 +262,6 @@ public async Task CreateBlueprintImplementation_WithMissingDisplayName_ShouldThr var configFile = new FileInfo("test-config.json"); - _mockPrerequisiteRunner.RunAsync( - Arg.Any>(), - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(true); - // Note: Since DelegatedConsentService needs to run and will fail with invalid tenant, // the method returns false rather than throwing for missing display name upfront. // The display name check happens after consent, so this test verifies @@ -314,7 +296,6 @@ public void CommandDescription_ShouldMentionRequiredPermissions() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -343,7 +324,6 @@ public async Task DryRun_WithCustomConfigPath_ShouldLoadCorrectFile() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -380,7 +360,6 @@ public async Task DryRun_ShouldNotCreateServicePrincipal() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -409,7 +388,6 @@ public void CreateCommand_ShouldHandleAllOptions() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -436,7 +414,6 @@ public async Task DryRun_WithMissingConfig_ShouldHandleGracefully() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -459,7 +436,6 @@ public void CreateCommand_DefaultConfigPath_ShouldBeA365ConfigJson() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -523,7 +499,6 @@ public void CommandDescription_ShouldBeInformativeAndActionable() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -552,7 +527,6 @@ public async Task DryRun_WithVerboseFlag_ShouldSucceed() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -587,7 +561,6 @@ public async Task DryRun_ShouldShowWhatWouldBeDone() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -620,7 +593,6 @@ public void CreateCommand_ShouldBeUsableInCommandPipeline() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -1311,7 +1283,6 @@ public void CreateCommand_ShouldHaveUpdateEndpointOption() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -1646,7 +1617,6 @@ public async Task SetHandler_WithClientAppId_ShouldConfigureGraphApiService() _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -1682,7 +1652,6 @@ public async Task SetHandler_WithoutClientAppId_ShouldNotConfigureGraphApiServic _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, @@ -1718,7 +1687,6 @@ public async Task SetHandler_WithWhitespaceClientAppId_ShouldNotConfigureGraphAp _mockLogger, _mockConfigService, _mockExecutor, - _mockPrerequisiteRunner, _mockAuthValidator, _mockWebAppCreator, _mockPlatformDetector, diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/AppServiceAuthRequirementCheckTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/AppServiceAuthRequirementCheckTests.cs new file mode 100644 index 00000000..445f3bd9 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/AppServiceAuthRequirementCheckTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services.Requirements; + +/// +/// Unit tests for AppServiceAuthRequirementCheck +/// +public class AppServiceAuthRequirementCheckTests +{ + private readonly AzureAuthValidator _mockAuthValidator; + private readonly ILogger _mockLogger; + + public AppServiceAuthRequirementCheckTests() + { + var mockExecutor = Substitute.ForPartsOf(NullLogger.Instance); + _mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, mockExecutor); + _mockLogger = Substitute.For(); + } + + [Fact] + public async Task CheckAsync_WhenTokenAcquisitionSucceeds_ShouldReturnSuccess() + { + // Arrange + var check = new AppServiceAuthRequirementCheck(_mockAuthValidator); + var config = new Agent365Config { SubscriptionId = "test-sub-id" }; + + _mockAuthValidator.GetAppServiceTokenAsync(Arg.Any()) + .Returns(true); + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert + result.Should().NotBeNull(); + result.Passed.Should().BeTrue(); + result.IsWarning.Should().BeFalse(); + result.ErrorMessage.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task CheckAsync_WhenTokenAcquisitionFails_ShouldReturnFailure() + { + // Arrange + var check = new AppServiceAuthRequirementCheck(_mockAuthValidator); + var config = new Agent365Config { SubscriptionId = "test-sub-id" }; + + _mockAuthValidator.GetAppServiceTokenAsync(Arg.Any()) + .Returns(false); + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert + result.Should().NotBeNull(); + result.Passed.Should().BeFalse(); + result.ErrorMessage.Should().Contain("App Service token is expired or revoked"); + result.ResolutionGuidance.Should().Contain("az logout"); + } + + [Fact] + public async Task CheckAsync_ShouldCallGetAppServiceTokenAsync() + { + // Arrange + var check = new AppServiceAuthRequirementCheck(_mockAuthValidator); + var config = new Agent365Config(); + + _mockAuthValidator.GetAppServiceTokenAsync(Arg.Any()) + .Returns(true); + + // Act + await check.CheckAsync(config, _mockLogger); + + // Assert + await _mockAuthValidator.Received(1).GetAppServiceTokenAsync(Arg.Any()); + } + + [Fact] + public void Metadata_ShouldHaveCorrectName() + { + var check = new AppServiceAuthRequirementCheck(_mockAuthValidator); + check.Name.Should().Be("App Service Authentication"); + } + + [Fact] + public void Metadata_ShouldHaveCorrectCategory() + { + var check = new AppServiceAuthRequirementCheck(_mockAuthValidator); + check.Category.Should().Be("Azure"); + } + + [Fact] + public void Constructor_WithNullValidator_ShouldThrowArgumentNullException() + { + var act = () => new AppServiceAuthRequirementCheck(null!); + act.Should().Throw() + .WithParameterName("auth"); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs index 6c83b7b5..fe2693fe 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs @@ -52,10 +52,9 @@ public async Task CheckAsync_ShouldLogMainWarningMessage() // Act await check.CheckAsync(config, _mockLogger); - // Assert - // Verify the logger was called with the warning output line + // Assert — [WARN] output is logged at Warning severity (not Information) _mockLogger.Received().Log( - LogLevel.Information, + LogLevel.Warning, Arg.Any(), Arg.Is(o => o.ToString()!.Contains("[WARN] Frontier Preview Program")), Arg.Any(), @@ -72,10 +71,9 @@ public async Task CheckAsync_ShouldLogRequirementName() // Act await check.CheckAsync(config, _mockLogger); - // Assert - // Verify the logger was called with the check name in the output line + // Assert — warning is logged at Warning severity and includes the check name _mockLogger.Received().Log( - LogLevel.Information, + LogLevel.Warning, Arg.Any(), Arg.Is(o => o.ToString()!.Contains("Frontier Preview Program")), Arg.Any(), diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/MosPrerequisitesRequirementCheckTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/MosPrerequisitesRequirementCheckTests.cs new file mode 100644 index 00000000..9bbe60cb --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/MosPrerequisitesRequirementCheckTests.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services.Requirements; + +/// +/// Unit tests for MosPrerequisitesRequirementCheck +/// +public class MosPrerequisitesRequirementCheckTests +{ + private readonly GraphApiService _mockGraphApiService; + private readonly AgentBlueprintService _mockBlueprintService; + private readonly ILogger _mockLogger; + + public MosPrerequisitesRequirementCheckTests() + { + var mockExecutor = Substitute.ForPartsOf(NullLogger.Instance); + _mockGraphApiService = Substitute.For(NullLogger.Instance, mockExecutor); + _mockBlueprintService = Substitute.ForPartsOf(NullLogger.Instance, _mockGraphApiService); + _mockLogger = Substitute.For(); + } + + [Fact] + public async Task CheckAsync_WhenClientAppIdMissing_ShouldReturnFailure() + { + // Arrange — missing ClientAppId causes EnsureMosPrerequisitesAsync to throw SetupValidationException + var check = new MosPrerequisitesRequirementCheck(_mockGraphApiService, _mockBlueprintService); + var config = new Agent365Config { TenantId = "test-tenant" }; // no ClientAppId + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert + result.Should().NotBeNull(); + result.Passed.Should().BeFalse(); + result.ErrorMessage.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task CheckAsync_WhenSetupValidationExceptionHasMitigationSteps_ShouldIncludeThemInResolution() + { + // Arrange — missing ClientAppId causes SetupValidationException + var check = new MosPrerequisitesRequirementCheck(_mockGraphApiService, _mockBlueprintService); + var config = new Agent365Config { TenantId = "test-tenant" }; // no ClientAppId + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert — SetupValidationException maps to a Failure with guidance + result.Passed.Should().BeFalse(); + result.ResolutionGuidance.Should().NotBeNullOrEmpty(); + } + + [Fact] + public async Task CheckAsync_WhenSetupValidationExceptionHasNoMitigationSteps_ShouldUseFallbackResolution() + { + // Arrange — GraphGetAsync returns null for the app lookup, causing SetupValidationException + // with no mitigation steps (the default exception message is used) + var check = new MosPrerequisitesRequirementCheck(_mockGraphApiService, _mockBlueprintService); + var config = new Agent365Config + { + TenantId = "test-tenant", + ClientAppId = "00000000-0000-0000-0000-000000000001" + }; + + _mockGraphApiService.GraphGetAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()) + .Returns((System.Text.Json.JsonDocument?)null); + + // Act + var result = await check.CheckAsync(config, _mockLogger); + + // Assert — app not found throws SetupValidationException, check maps it to Failure with fallback guidance + result.Passed.Should().BeFalse(); + result.ResolutionGuidance.Should().Contain("a365 setup all"); + } + + [Fact] + public void Metadata_ShouldHaveCorrectName() + { + var check = new MosPrerequisitesRequirementCheck(_mockGraphApiService, _mockBlueprintService); + check.Name.Should().Be("MOS Prerequisites"); + } + + [Fact] + public void Metadata_ShouldHaveCorrectCategory() + { + var check = new MosPrerequisitesRequirementCheck(_mockGraphApiService, _mockBlueprintService); + check.Category.Should().Be("MOS"); + } + + [Fact] + public void Constructor_WithNullGraphApiService_ShouldThrowArgumentNullException() + { + var act = () => new MosPrerequisitesRequirementCheck(null!, _mockBlueprintService); + act.Should().Throw() + .WithParameterName("graphApiService"); + } + + [Fact] + public void Constructor_WithNullBlueprintService_ShouldThrowArgumentNullException() + { + var act = () => new MosPrerequisitesRequirementCheck(_mockGraphApiService, null!); + act.Should().Throw() + .WithParameterName("blueprintService"); + } +} From c14ab48f738c031fbb9c00fcceb7e1598a100ee6 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Mon, 9 Mar 2026 12:25:42 -0700 Subject: [PATCH 09/11] fix: address PR #312 review comments (exit codes, log levels, skip-graph, tests) - Remove redundant failure/warning logging from PrerequisiteRunner.RunAsync: ExecuteCheckWithLoggingAsync already emits [PASS]/[FAIL]/[WARN] output; the runner was re-logging the same error message and resolution guidance, producing ~5 lines of output for a single failed check instead of 3. Runner now only tracks the boolean pass/fail result. - Fix \n escape in AppServiceAuthRequirementCheck resolution message: Raw \n does not render in log output; replaced with a single readable sentence. - Fix double-error when config file is not found: ConfigService.LoadAsync was logging "Static configuration file not found" before throwing ConfigFileNotFoundException. The global exception handler in Program.cs then displayed the same message again via ExceptionHandler. Removed the pre-throw LogError; the exception message surfaces once. - Fix redundant "Configuration file not found:" prefix in CleanupCommand and CreateInstanceCommand catch blocks: ex.IssueDescription already contains the full message including the path, so the extra prefix was duplicating it. - Update PrerequisiteRunnerTests: RunAsync_WithWarningCheck_ShouldReturnTrue no longer asserts on warning logging from the runner (that is the check's responsibility via ExecuteCheckWithLoggingAsync). Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/CleanupCommand.cs | 2 +- .../Commands/CreateInstanceCommand.cs | 2 +- .../Services/ConfigService.cs | 1 - .../Services/PrerequisiteRunner.cs | 12 ------------ .../AppServiceAuthRequirementCheck.cs | 2 +- .../Services/PrerequisiteRunnerTests.cs | 12 ++++-------- 6 files changed, 7 insertions(+), 24 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs index 2ddc1b24..d255d659 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs @@ -998,7 +998,7 @@ private static void PrintOrphanSummary( } catch (ConfigFileNotFoundException ex) { - logger.LogError("Configuration file not found: {Message}", ex.IssueDescription); + logger.LogError("{Message}", ex.IssueDescription); return null; } catch (Exception ex) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs index 4134c95c..1ec70dc5 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs @@ -502,7 +502,7 @@ private static Command CreateLicensesSubcommand( } catch (ConfigFileNotFoundException ex) { - logger.LogError("Configuration file not found: {Message}", ex.IssueDescription); + logger.LogError("{Message}", ex.IssueDescription); return null; } catch (Exception ex) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs index f3ed912d..3996c603 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs @@ -241,7 +241,6 @@ public async Task LoadAsync( // Validate static config file exists if (!File.Exists(resolvedConfigPath)) { - _logger?.LogError("Static configuration file not found: {ConfigPath}", resolvedConfigPath); throw new ConfigFileNotFoundException(resolvedConfigPath); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs index 490d9549..e4e6d1d2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs @@ -28,18 +28,6 @@ public async Task RunAsync( if (!result.Passed) { passed = false; - - if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) - logger.LogError("{CheckName}: {ErrorMessage}", check.Name, result.ErrorMessage); - - if (!string.IsNullOrWhiteSpace(result.ResolutionGuidance)) - logger.LogError(" Resolution: {ResolutionGuidance}", result.ResolutionGuidance); - } - else if (result.IsWarning) - { - var warningMessage = result.ErrorMessage ?? result.Details; - logger.LogWarning("{CheckName}: {WarningMessage}", check.Name, - string.IsNullOrWhiteSpace(warningMessage) ? "Warning reported with no message" : warningMessage); } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AppServiceAuthRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AppServiceAuthRequirementCheck.cs index ce190d29..be80cf8d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AppServiceAuthRequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AppServiceAuthRequirementCheck.cs @@ -48,6 +48,6 @@ private async Task CheckImplementationAsync( ? RequirementCheckResult.Success() : RequirementCheckResult.Failure( "Azure App Service token is expired or revoked", - "Run: az logout\n az login --tenant "); + "Run 'az logout' then 'az login --tenant ' and retry"); } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/PrerequisiteRunnerTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/PrerequisiteRunnerTests.cs index cffdbd31..ad1ccbbb 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/PrerequisiteRunnerTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/PrerequisiteRunnerTests.cs @@ -114,7 +114,7 @@ public async Task RunAsync_WithMultipleFailingChecks_ShouldReturnFalseAndLogAll( } [Fact] - public async Task RunAsync_WithWarningCheck_ShouldReturnTrueAndLogWarning() + public async Task RunAsync_WithWarningCheck_ShouldReturnTrue() { // Arrange var runner = new PrerequisiteRunner(); @@ -128,14 +128,10 @@ public async Task RunAsync_WithWarningCheck_ShouldReturnTrueAndLogWarning() // Act var result = await runner.RunAsync(checks, _config, _mockLogger); - // Assert + // Assert: warnings do not block execution. + // Warning output is emitted by the check itself (via ExecuteCheckWithLoggingAsync), + // not by the runner. result.Should().BeTrue("a warning does not block execution"); - _mockLogger.Received().Log( - LogLevel.Warning, - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any>()); } [Fact] From 675ae8c63f9fd5b2af0e0ee699c70d6ffc187ea7 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Mon, 9 Mar 2026 15:58:08 -0700 Subject: [PATCH 10/11] fix: clean up deploy error output and Graph API error logging - DeployCommand: stop re-wrapping DeployAppException (was causing 3x stderr repetition) - DeploymentService: throw short actionable exceptions instead of embedding full az cli stderr; move timing message after successful upload; remove duplicate [6/7] step label; pass suppressErrorLogging:true to ExecuteWithStreamingAsync - CommandExecutor: add suppressErrorLogging param to ExecuteWithStreamingAsync; filter empty lines from stderr stream to avoid blank [Azure] prefixed lines - NodeBuilder: downgrade CleanAsync log to Debug to remove out-of-sequence noise - GraphApiService/FederatedCredentialService: extract clean error message from Graph JSON response body; move raw response body to LogDebug - BlueprintSubcommand: replace multi-line FIC failure errors with single warning - CleanConsoleFormatter: remove WARNING: text prefix from warning lines (keep yellow color) - Tests: add regression tests for exception re-wrapping, site-start timeout, and generic az cli failure; update ExecuteWithStreamingAsync mocks for new suppressErrorLogging parameter; update CleanConsoleFormatter tests for new warning format Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/DeployCommand.cs | 4 +- .../SetupSubcommands/BlueprintSubcommand.cs | 9 +- .../Services/CommandExecutor.cs | 13 ++- .../Services/DeploymentService.cs | 53 +++------ .../Services/FederatedCredentialService.cs | 31 +++-- .../Services/GraphApiService.cs | 41 ++++++- .../Services/Helpers/CleanConsoleFormatter.cs | 2 - .../Services/NodeBuilder.cs | 2 +- .../Commands/DeployCommandTests.cs | 46 +++++++- .../Services/DeploymentServiceTests.cs | 108 +++++++++++++++++- .../Helpers/CleanConsoleFormatterTests.cs | 9 +- .../MicrosoftGraphTokenProviderTests.cs | 7 ++ .../FrontierPreviewRequirementCheckTests.cs | 2 +- 13 files changed, 256 insertions(+), 71 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs index cfc42f45..244c535b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs @@ -525,9 +525,11 @@ private static void HandleDeploymentException(Exception ex, ILogger logger) logger.LogInformation(" 3. Run 'a365 deploy' to perform a deployment"); logger.LogInformation(""); break; + case DeployAppException deployAppEx: + // Already a structured exception with clean message — let it propagate as-is + throw deployAppEx; default: logger.LogError("Deployment failed: {Message}", ex.Message); - throw new DeployAppException($"Deployment failed: {ex.Message}", ex); } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index 6b67920d..6c047629 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -1208,14 +1208,7 @@ await retryHelper.ExecuteWithRetryAsync( } else { - logger.LogError("Failed to create Federated Identity Credential: {Error}", ficCreateResult?.ErrorMessage ?? "Unknown error"); - logger.LogError("The agent instance may not be able to authenticate using Managed Identity"); - } - - if (!ficSuccess) - { - logger.LogWarning("Federated Identity Credential configuration incomplete"); - logger.LogWarning("You may need to create the credential manually in Entra ID"); + logger.LogWarning("[WARN] Federated Identity Credential creation failed - you may need to create it manually in Entra ID"); } } else if (!useManagedIdentity) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/CommandExecutor.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/CommandExecutor.cs index ba47a82c..d9fa6a4d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/CommandExecutor.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/CommandExecutor.cs @@ -115,6 +115,7 @@ public virtual async Task ExecuteWithStreamingAsync( string outputPrefix = "", bool interactive = false, Func? outputTransform = null, + bool suppressErrorLogging = false, CancellationToken cancellationToken = default) { _logger.LogDebug("Executing with streaming: {Command} {Arguments} (Interactive={Interactive})", command, arguments, interactive); @@ -178,10 +179,14 @@ public virtual async Task ExecuteWithStreamingAsync( errorBuilder.AppendLine(args.Data); // Azure CLI writes informational messages to stderr with "WARNING:" prefix // Strip it for cleaner output - var cleanData = IsAzureCliCommand(command) - ? StripAzureWarningPrefix(args.Data) + var cleanData = IsAzureCliCommand(command) + ? StripAzureWarningPrefix(args.Data) : args.Data; - Console.WriteLine($"{outputPrefix}{cleanData}"); + // Skip blank lines that result from stripping az cli prefixes + if (!string.IsNullOrWhiteSpace(cleanData)) + { + Console.WriteLine($"{outputPrefix}{cleanData}"); + } } }; @@ -200,7 +205,7 @@ public virtual async Task ExecuteWithStreamingAsync( StandardError = errorBuilder.ToString() }; - if (result.ExitCode != 0) + if (result.ExitCode != 0 && !suppressErrorLogging) { _logger.LogError("Command failed with exit code {ExitCode}", result.ExitCode); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs index 7cdf8275..ed577666 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs @@ -183,57 +183,38 @@ private async Task DeployToAzureAsync(DeploymentConfiguration config, string pro // Explicitly set the correct runtime configuration before deployment await EnsureCorrectRuntimeConfigurationAsync(config.ResourceGroup, config.AppName, platform, projectDir); - _logger.LogInformation("Deployment typically takes 2-5 minutes to complete"); _logger.LogDebug("Using async deployment to avoid Azure SCM gateway timeout (4-5 minute limit)"); - _logger.LogInformation("Monitor progress: https://{AppName}.scm.azurewebsites.net/api/deployments/latest", config.AppName); - _logger.LogInformation(""); var deployArgs = $"webapp deploy --resource-group {config.ResourceGroup} --name {config.AppName} --src-path \"{zipPath}\" --type zip --async true"; _logger.LogInformation("Uploading deployment package..."); - var deployResult = await _executor.ExecuteWithStreamingAsync("az", deployArgs, projectDir, "[Azure] "); + var deployResult = await _executor.ExecuteWithStreamingAsync("az", deployArgs, projectDir, "[Azure] ", suppressErrorLogging: true); if (!deployResult.Success) { - _logger.LogError("Deployment upload failed with exit code {ExitCode}", deployResult.ExitCode); - if (!string.IsNullOrWhiteSpace(deployResult.StandardError)) - { - _logger.LogError("Deployment error: {Error}", deployResult.StandardError); + bool isSiteStartTimeout = + deployResult.StandardError.Contains("site failed to start within 10 mins", StringComparison.OrdinalIgnoreCase) || + deployResult.StandardError.Contains("worker proccess failed to start", StringComparison.OrdinalIgnoreCase); - // Graceful handling for site start timeout - if (deployResult.StandardError.Contains("site failed to start within 10 mins", StringComparison.OrdinalIgnoreCase) || - deployResult.StandardError.Contains("worker proccess failed to start", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogError("The deployment failed because the site did not start within the expected time."); - _logger.LogError("This is often caused by application startup issues, missing dependencies, or misconfiguration."); - _logger.LogError("Check the runtime logs for more details: https://{AppName}.scm.azurewebsites.net/api/logs/docker", config.AppName); - _logger.LogError("Common causes include:"); - _logger.LogError(" - Incorrect startup command or entry point"); - _logger.LogError(" - Missing Python/Node/.NET dependencies"); - _logger.LogError(" - Application errors on startup"); - _logger.LogError(" - Port binding issues (ensure your app listens on the correct port)"); - _logger.LogError(" - Long initialization times"); - _logger.LogError("Review your application logs and configuration, then redeploy."); - } + if (isSiteStartTimeout) + { + _logger.LogInformation(""); + _logger.LogInformation("Common causes for site startup failure:"); + _logger.LogInformation(" - Incorrect startup command or entry point"); + _logger.LogInformation(" - Missing dependencies"); + _logger.LogInformation(" - Application errors on startup"); + _logger.LogInformation(" - Port binding issues (app must listen on the port set by the PORT environment variable)"); + throw new DeployAppException( + $"Site failed to start within the allotted time. Check runtime logs: https://{config.AppName}.scm.azurewebsites.net/api/logs/docker"); } - // Print a summary for the user - _logger.LogInformation("========================================"); - _logger.LogInformation("Deployment Summary"); - _logger.LogInformation("App Name: {AppName}", config.AppName); - _logger.LogInformation("App URL: https://{AppName}.azurewebsites.net", config.AppName); - _logger.LogInformation("Resource Group: {ResourceGroup}", config.ResourceGroup); - _logger.LogInformation("Deployment failed. See error details above."); - _logger.LogInformation("========================================"); - - throw new DeployAppException($"Azure deployment failed: {deployResult.StandardError}"); + throw new DeployAppException($"az webapp deploy failed with exit code {deployResult.ExitCode}"); } _logger.LogInformation(""); _logger.LogInformation("Deployment package uploaded successfully!"); _logger.LogInformation(""); - _logger.LogInformation("Deployment is continuing in the background on Azure"); - _logger.LogInformation("Application will be available in 2-5 minutes"); + _logger.LogInformation("Build and startup are running on Azure. This may take several minutes."); _logger.LogInformation(""); _logger.LogInformation("Monitor deployment status:"); _logger.LogInformation(" Web: https://{AppName}.scm.azurewebsites.net/api/deployments/latest", config.AppName); @@ -257,7 +238,7 @@ private async Task DeployToAzureAsync(DeploymentConfiguration config, string pro private async Task CreateDeploymentPackageAsync(string projectDir, string publishPath, string deploymentZipName) { var zipPath = Path.Combine(projectDir, deploymentZipName); - _logger.LogInformation("[6/7] Creating deployment package: {ZipPath}", zipPath); + _logger.LogInformation("Creating deployment package: {ZipPath}", zipPath); // Delete old zip if exists with retry logic if (File.Exists(zipPath)) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs index 2873bbf2..c6bcadcd 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs @@ -316,14 +316,13 @@ public async Task CreateFederatedCredentialAsyn continue; } - // Both endpoints failed - _logger.LogError("Failed to create federated credential: HTTP {StatusCode} {ReasonPhrase}", response.StatusCode, response.ReasonPhrase); - if (!string.IsNullOrWhiteSpace(response.Body)) - { - _logger.LogError("Error details: {Body}", response.Body); - } - - _logger.LogError("Failed to create federated credential: {Name}", name); + // Both endpoints failed — log one clean error + var graphError = TryExtractGraphErrorMessage(response.Body); + if (graphError != null) + _logger.LogError("Failed to create federated credential '{Name}': {ErrorMessage}", name, graphError); + else + _logger.LogError("Failed to create federated credential '{Name}': HTTP {StatusCode} {ReasonPhrase}", name, response.StatusCode, response.ReasonPhrase); + _logger.LogDebug("Federated credential error response body: {Body}", response.Body); return new FederatedCredentialCreateResult { Success = false, @@ -471,4 +470,20 @@ public async Task DeleteAllFederatedCredentialsAsync( return false; } } + + private static string? TryExtractGraphErrorMessage(string? body) + { + if (string.IsNullOrWhiteSpace(body)) return null; + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("error", out var error) && + error.TryGetProperty("message", out var msg)) + { + return msg.GetString(); + } + } + catch { /* ignore parse errors */ } + return null; + } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index de3cbdad..82d52547 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -307,7 +307,12 @@ private async Task EnsureGraphHeadersAsync(string tenantId, CancellationTo var body = await resp.Content.ReadAsStringAsync(ct); if (!resp.IsSuccessStatusCode) { - _logger.LogError("Graph POST {Url} failed {Code} {Reason}: {Body}", url, (int)resp.StatusCode, resp.ReasonPhrase, body); + var errorMessage = TryExtractGraphErrorMessage(body); + if (errorMessage != null) + _logger.LogError("Graph POST {Url} failed: {ErrorMessage}", url, errorMessage); + else + _logger.LogError("Graph POST {Url} failed {Code} {Reason}", url, (int)resp.StatusCode, resp.ReasonPhrase); + _logger.LogDebug("Graph POST response body: {Body}", body); return null; } @@ -366,7 +371,12 @@ public virtual async Task GraphPatchAsync(string tenantId, string relative if (!resp.IsSuccessStatusCode) { var body = await resp.Content.ReadAsStringAsync(ct); - _logger.LogError("Graph PATCH {Url} failed {Code} {Reason}: {Body}", url, (int)resp.StatusCode, resp.ReasonPhrase, body); + var errorMessage = TryExtractGraphErrorMessage(body); + if (errorMessage != null) + _logger.LogError("Graph PATCH {Url} failed: {ErrorMessage}", url, errorMessage); + else + _logger.LogError("Graph PATCH {Url} failed {Code} {Reason}", url, (int)resp.StatusCode, resp.ReasonPhrase); + _logger.LogDebug("Graph PATCH response body: {Body}", body); } return resp.IsSuccessStatusCode; @@ -394,7 +404,12 @@ public async Task GraphDeleteAsync( if (!resp.IsSuccessStatusCode) { var body = await resp.Content.ReadAsStringAsync(ct); - _logger.LogError("Graph DELETE {Url} failed {Code} {Reason}: {Body}", url, (int)resp.StatusCode, resp.ReasonPhrase, body); + var errorMessage = TryExtractGraphErrorMessage(body); + if (errorMessage != null) + _logger.LogError("Graph DELETE {Url} failed: {ErrorMessage}", url, errorMessage); + else + _logger.LogError("Graph DELETE {Url} failed {Code} {Reason}", url, (int)resp.StatusCode, resp.ReasonPhrase); + _logger.LogDebug("Graph DELETE response body: {Body}", body); return false; } @@ -672,4 +687,24 @@ public virtual async Task IsApplicationOwnerAsync( return false; } } + + /// + /// Attempts to extract a human-readable error message from a Graph API JSON error response body. + /// Returns null if the body cannot be parsed or does not contain an error message. + /// + private static string? TryExtractGraphErrorMessage(string body) + { + if (string.IsNullOrWhiteSpace(body)) return null; + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("error", out var error) && + error.TryGetProperty("message", out var msg)) + { + return msg.GetString(); + } + } + catch { /* ignore parse errors */ } + return null; + } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/CleanConsoleFormatter.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/CleanConsoleFormatter.cs index dac31d7c..0b96915f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/CleanConsoleFormatter.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/CleanConsoleFormatter.cs @@ -64,14 +64,12 @@ public override void Write( if (isConsole) { Console.ForegroundColor = ConsoleColor.Yellow; - Console.Write("WARNING: "); Console.Write(message); Console.ResetColor(); Console.WriteLine(); } else { - textWriter.Write("WARNING: "); textWriter.WriteLine(message); } break; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/NodeBuilder.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/NodeBuilder.cs index ae8dc1d2..f179b097 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/NodeBuilder.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/NodeBuilder.cs @@ -51,7 +51,7 @@ public async Task ValidateEnvironmentAsync() public async Task CleanAsync(string projectDir) { - _logger.LogInformation("Cleaning Node.js project..."); + _logger.LogDebug("Cleaning Node.js publish output..."); // Remove node_modules if it exists var nodeModulesPath = Path.Combine(projectDir, "node_modules"); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DeployCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DeployCommandTests.cs index 5cffaaf5..6f7376ce 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DeployCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DeployCommandTests.cs @@ -5,8 +5,10 @@ using System.CommandLine.Builder; using System.CommandLine.IO; using System.CommandLine.Parsing; +using FluentAssertions; using Microsoft.Extensions.Logging; using Microsoft.Agents.A365.DevTools.Cli.Commands; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging.Abstractions; @@ -24,7 +26,6 @@ public class DeployCommandTests private readonly ConfigService _mockConfigService; private readonly CommandExecutor _mockExecutor; private readonly DeploymentService _mockDeploymentService; - private readonly IPrerequisiteRunner _mockPrerequisiteRunner; private readonly AzureAuthValidator _mockAuthValidator; private readonly GraphApiService _mockGraphApiService; private readonly AgentBlueprintService _mockBlueprintService; @@ -54,7 +55,6 @@ public DeployCommandTests() mockNodeLogger, mockPythonLogger); - _mockPrerequisiteRunner = Substitute.For(); _mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, _mockExecutor); _mockGraphApiService = Substitute.ForPartsOf(Substitute.For>(), _mockExecutor); _mockBlueprintService = Substitute.ForPartsOf(Substitute.For>(), _mockGraphApiService); @@ -120,6 +120,48 @@ public void UpdateCommand_Should_Have_Verbose_Option() } + /// + /// Regression: HandleDeploymentException must not wrap a DeployAppException in another DeployAppException. + /// Wrapping caused the full az cli stderr (stored in the exception message) to be printed 3 times. + /// + [Fact] + public void HandleDeploymentException_WithDeployAppException_RethrowsWithoutWrapping() + { + // Arrange + var original = new DeployAppException("Site failed to start. Check runtime logs: https://myapp.scm.azurewebsites.net/api/logs/docker"); + var method = typeof(DeployCommand).GetMethod( + "HandleDeploymentException", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + // Act + var act = () => method!.Invoke(null, new object[] { original, _mockLogger }); + + // Assert — must rethrow the same type without wrapping + act.Should().Throw() + .WithInnerException() + .Where(ex => ReferenceEquals(ex, original), "the same instance must be rethrown, not a new wrapper"); + } + + /// + /// Regression: HandleDeploymentException must wrap non-DeployAppException in DeployAppException. + /// + [Fact] + public void HandleDeploymentException_WithGenericException_WrapsInDeployAppException() + { + // Arrange + var original = new InvalidOperationException("Something unexpected"); + var method = typeof(DeployCommand).GetMethod( + "HandleDeploymentException", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + // Act + var act = () => method!.Invoke(null, new object[] { original, _mockLogger }); + + // Assert — generic exceptions should be wrapped + act.Should().Throw() + .WithInnerException(); + } + // NOTE: Integration tests that verify actual service invocation through command execution // are omitted here as they require complex mocking of logging infrastructure. // The command functionality is tested through integration/end-to-end tests when running diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/DeploymentServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/DeploymentServiceTests.cs index 20d9e82a..f980813b 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/DeploymentServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/DeploymentServiceTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; @@ -153,4 +154,109 @@ public async Task GetLinuxFxVersionForPlatformAsync_Unknown_DefaultsToDotNet8() // Assert result.Should().Be("DOTNETCORE|8.0", "Unknown platform should default to .NET 8.0 to avoid PHP container selection"); } -} + + /// + /// Regression: DeployAppException message must not contain raw az cli stderr. + /// The site-start timeout case should produce a short, actionable message with the docker logs URL. + /// Uses restart=true to bypass the build pipeline and test only the Azure deploy error path. + /// + [Theory] + [InlineData("ERROR: Deployment failed because the site failed to start within 10 mins.\nInprogressInstances: 0")] + [InlineData("Error: Deployment for site 'myapp' failed because the worker proccess failed to start within the allotted time.")] + public async Task DeployAsync_SiteStartTimeout_ThrowsDeployAppExceptionWithDockerLogUrl(string azCliStderr) + { + // Arrange — create a temporary publish directory so restart=true path succeeds past the dir check + var publishDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "publish"); + Directory.CreateDirectory(publishDir); + File.WriteAllText(Path.Combine(publishDir, "index.js"), "// test"); + + try + { + var projectDir = Path.GetDirectoryName(publishDir)!; + var config = new DeploymentConfiguration + { + ResourceGroup = "rg-test", + AppName = "myapp", + ProjectPath = projectDir, + DeploymentZip = "app.zip", + PublishOutputPath = "publish", + Platform = ProjectPlatform.NodeJs + }; + + _mockExecutor + .ExecuteWithStreamingAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any>(), + Arg.Any(), Arg.Any()) + .Returns(new CommandResult { ExitCode = 1, StandardError = azCliStderr }); + + _mockExecutor + .ExecuteAsync(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new CommandResult { ExitCode = 0 }); + + // Act — restart=true skips build pipeline, goes directly to zip + deploy + var act = async () => await _deploymentService.DeployAsync(config, verbose: false, restart: true); + + // Assert + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("scm.azurewebsites.net", "docker log URL must be in the exception message"); + ex.Which.Message.Should().NotContain("WARNING:", "raw az cli stderr must not leak into the exception message"); + ex.Which.Message.Should().NotContain("InprogressInstances:", "raw az cli polling output must not leak into the exception message"); + } + finally + { + Directory.Delete(Path.GetDirectoryName(publishDir)!, recursive: true); + } + } + + /// + /// Regression: a generic az cli failure (not timeout) should produce a short message with exit code only. + /// + [Fact] + public async Task DeployAsync_GenericAzCliFailure_ThrowsDeployAppExceptionWithExitCodeOnly() + { + // Arrange + var publishDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString(), "publish"); + Directory.CreateDirectory(publishDir); + File.WriteAllText(Path.Combine(publishDir, "index.js"), "// test"); + + try + { + var projectDir = Path.GetDirectoryName(publishDir)!; + var config = new DeploymentConfiguration + { + ResourceGroup = "rg-test", + AppName = "myapp", + ProjectPath = projectDir, + DeploymentZip = "app.zip", + PublishOutputPath = "publish", + Platform = ProjectPlatform.NodeJs + }; + + _mockExecutor + .ExecuteWithStreamingAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any>(), + Arg.Any(), Arg.Any()) + .Returns(new CommandResult { ExitCode = 2, StandardError = "WARNING: Some az output\nERROR: Resource group not found" }); + + _mockExecutor + .ExecuteAsync(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new CommandResult { ExitCode = 0 }); + + // Act + var act = async () => await _deploymentService.DeployAsync(config, verbose: false, restart: true); + + // Assert + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("exit code 2"); + ex.Which.Message.Should().NotContain("WARNING:", "raw az cli stderr must not appear in the exception message"); + } + finally + { + Directory.Delete(Path.GetDirectoryName(publishDir)!, recursive: true); + } + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/CleanConsoleFormatterTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/CleanConsoleFormatterTests.cs index ecb3fcec..0f111494 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/CleanConsoleFormatterTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/CleanConsoleFormatterTests.cs @@ -88,7 +88,7 @@ public void Write_WithCriticalLevel_OutputsMessageWithErrorPrefix() } [Fact] - public void Write_WithWarningLevel_OutputsMessageWithWarningPrefix() + public void Write_WithWarningLevel_OutputsMessageWithoutWarningPrefix() { // Arrange var message = "This is a warning message"; @@ -97,9 +97,9 @@ public void Write_WithWarningLevel_OutputsMessageWithWarningPrefix() // Act _formatter.Write(logEntry, null, _consoleWriter); - // Assert + // Assert - warning messages are yellow but have no "WARNING:" prefix (message already contains [WARN] tag) var output = _consoleWriter.ToString(); - output.Should().Contain("WARNING:"); + output.Should().NotContain("WARNING:"); output.Should().Contain(message); } @@ -135,7 +135,7 @@ public void Write_WithExceptionAndWarning_IncludesExceptionDetails() // Assert var output = _consoleWriter.ToString(); - output.Should().Contain("WARNING:"); + output.Should().NotContain("WARNING:"); output.Should().Contain(message); output.Should().Contain("Test warning exception"); output.Should().Contain("ArgumentException"); @@ -229,6 +229,7 @@ public void Constructor_CreatesFormatterWithCleanName() [Theory] [InlineData(LogLevel.Information)] + [InlineData(LogLevel.Warning)] [InlineData(LogLevel.Debug)] [InlineData(LogLevel.Trace)] public void Write_WithNonWarningOrErrorLevel_DoesNotIncludePrefix(LogLevel logLevel) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs index 17200c86..18a05ccd 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs @@ -36,6 +36,7 @@ public async Task GetMgGraphAccessTokenAsync_WithValidClientAppId_IncludesClient Arg.Any(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()) .Returns(new CommandResult { ExitCode = 0, StandardOutput = expectedToken, StandardError = string.Empty }); @@ -53,6 +54,7 @@ await _executor.Received(1).ExecuteWithStreamingAsync( Arg.Any(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()); } @@ -71,6 +73,7 @@ public async Task GetMgGraphAccessTokenAsync_WithoutClientAppId_OmitsClientIdPar Arg.Any(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()) .Returns(new CommandResult { ExitCode = 0, StandardOutput = expectedToken, StandardError = string.Empty }); @@ -88,6 +91,7 @@ await _executor.Received(1).ExecuteWithStreamingAsync( Arg.Any(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()); } @@ -163,6 +167,7 @@ public async Task GetMgGraphAccessTokenAsync_WhenExecutionFails_ReturnsNull() Arg.Any(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()) .Returns(new CommandResult { ExitCode = 1, StandardOutput = string.Empty, StandardError = "PowerShell error" }); @@ -190,6 +195,7 @@ public async Task GetMgGraphAccessTokenAsync_WithValidToken_ReturnsToken() Arg.Any(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()) .Returns(new CommandResult { ExitCode = 0, StandardOutput = expectedToken, StandardError = string.Empty }); @@ -236,6 +242,7 @@ public async Task GetMgGraphAccessTokenAsync_EscapesSingleQuotesInClientAppId() Arg.Any(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()) .Returns(new CommandResult { ExitCode = 0, StandardOutput = expectedToken, StandardError = string.Empty }); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs index fe2693fe..c4fa69c2 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs @@ -52,7 +52,7 @@ public async Task CheckAsync_ShouldLogMainWarningMessage() // Act await check.CheckAsync(config, _mockLogger); - // Assert — [WARN] output is logged at Warning severity (not Information) + // Assert — [WARN] output is logged at Warning severity (yellow color, no WARNING: text prefix from formatter) _mockLogger.Received().Log( LogLevel.Warning, Arg.Any(), From 8acad5ad44c3a1bb80e3802c5931d6955c3b2df3 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Mon, 9 Mar 2026 16:16:48 -0700 Subject: [PATCH 11/11] fix: address PR #312 review comments (warning details, typo, docs, DI cleanup) - RequirementCheck: log both ErrorMessage and Details in warning path so FrontierPreview URL is no longer silently dropped - DeploymentService: also match correct spelling "worker process failed to start" alongside the existing az cli typo to make timeout detection robust - PrerequisiteRunner: update XML summary to accurately describe behavior (checks handle their own logging; runner only aggregates pass/fail) - Program.cs: remove unused IPrerequisiteRunner/PrerequisiteRunner DI registration (no command injects it; checks run via RequirementsSubcommand.RunChecksOrExitAsync) - design.md: fix Core Types snippet to match actual RequirementCheckResult API (Passed/IsWarning/ErrorMessage/ResolutionGuidance/Details, not RequirementCheckStatus/Issue/Resolution) - design.md: fix FrontierPreviewRequirementCheck category from "Azure" to "Tenant Enrollment" - MosPrerequisitesRequirementCheckTests: rewrite misleading test to actually exercise the MitigationSteps mapping branch by throwing SetupValidationException with known steps Co-Authored-By: Claude Sonnet 4.6 --- .../Program.cs | 3 --- .../Services/DeploymentService.cs | 3 ++- .../Services/PrerequisiteRunner.cs | 3 ++- .../Services/Requirements/RequirementCheck.cs | 5 ++++- .../design.md | 12 +++++++----- .../MosPrerequisitesRequirementCheckTests.cs | 19 +++++++++++++++---- 6 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index af400e92..08b2ce02 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -231,9 +231,6 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini services.AddSingleton(); services.AddSingleton(); - // Add prerequisite runner - services.AddSingleton(); - // Add multi-platform deployment services services.AddSingleton(); services.AddSingleton(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs index ed577666..a1ee94f9 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DeploymentService.cs @@ -194,7 +194,8 @@ private async Task DeployToAzureAsync(DeploymentConfiguration config, string pro { bool isSiteStartTimeout = deployResult.StandardError.Contains("site failed to start within 10 mins", StringComparison.OrdinalIgnoreCase) || - deployResult.StandardError.Contains("worker proccess failed to start", StringComparison.OrdinalIgnoreCase); + deployResult.StandardError.Contains("worker proccess failed to start", StringComparison.OrdinalIgnoreCase) || + deployResult.StandardError.Contains("worker process failed to start", StringComparison.OrdinalIgnoreCase); if (isSiteStartTimeout) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs index e4e6d1d2..6b9422b6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/PrerequisiteRunner.cs @@ -8,7 +8,8 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// -/// Runs prerequisite checks for a command and logs failures with actionable guidance. +/// Runs prerequisite checks for a command and aggregates pass/fail results. +/// Each check handles its own [PASS]/[FAIL]/[WARN] logging via ExecuteCheckWithLoggingAsync. /// public class PrerequisiteRunner : IPrerequisiteRunner { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs index 98e8b731..7938ee6b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs @@ -69,7 +69,10 @@ protected async Task ExecuteCheckWithLoggingAsync( { if (result.IsWarning) { - LogCheckWarning(logger, result.ErrorMessage ?? result.Details); + var warningMessage = (!string.IsNullOrWhiteSpace(result.ErrorMessage) && !string.IsNullOrWhiteSpace(result.Details)) + ? $"{result.ErrorMessage} - {result.Details}" + : result.ErrorMessage ?? result.Details; + LogCheckWarning(logger, warningMessage); } else { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/design.md b/src/Microsoft.Agents.A365.DevTools.Cli/design.md index d2d77778..a0be376d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/design.md +++ b/src/Microsoft.Agents.A365.DevTools.Cli/design.md @@ -216,12 +216,14 @@ Commands validate prerequisites through a structured check system before perform // Each check returns a structured result public class RequirementCheckResult { - public RequirementCheckStatus Status { get; } // Success, Warning, Failure - public string? Issue { get; } // What went wrong - public string? Resolution { get; } // How to fix it + public bool Passed { get; } // true = pass or warning, false = failure + public bool IsWarning { get; } // true = warning (non-blocking) + public string? ErrorMessage { get; } // What went wrong + public string? ResolutionGuidance { get; } // How to fix it + public string? Details { get; } // Additional context (e.g., URLs) } -// Base class handles the [PASS]/[FAIL] output line +// Base class handles [PASS]/[FAIL]/[WARN] output and check execution public abstract class RequirementCheck : IRequirementCheck { public abstract string Name { get; } @@ -267,7 +269,7 @@ Commands supporting `--dry-run` skip checks entirely — the `RunChecksOrExitAsy |-------|----------|---------| | `AzureAuthRequirementCheck` | Azure | setup all, setup infra, deploy, cleanup azure | | `AppServiceAuthRequirementCheck` | Azure | deploy | -| `FrontierPreviewRequirementCheck` | Azure | setup all, setup infra | +| `FrontierPreviewRequirementCheck` | Tenant Enrollment | setup all, setup infra | | `PowerShellModulesRequirementCheck` | Tools | setup all, setup infra | | `InfrastructureRequirementCheck` | Configuration | setup infra | | `MosPrerequisitesRequirementCheck` | MOS | publish | diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/MosPrerequisitesRequirementCheckTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/MosPrerequisitesRequirementCheckTests.cs index 9bbe60cb..5b04c300 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/MosPrerequisitesRequirementCheckTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/MosPrerequisitesRequirementCheckTests.cs @@ -49,16 +49,27 @@ public async Task CheckAsync_WhenClientAppIdMissing_ShouldReturnFailure() [Fact] public async Task CheckAsync_WhenSetupValidationExceptionHasMitigationSteps_ShouldIncludeThemInResolution() { - // Arrange — missing ClientAppId causes SetupValidationException + // Arrange — mock GraphGetAsync to throw SetupValidationException with explicit mitigation steps + var mitigationStep = "Grant admin consent via https://entra.microsoft.com"; var check = new MosPrerequisitesRequirementCheck(_mockGraphApiService, _mockBlueprintService); - var config = new Agent365Config { TenantId = "test-tenant" }; // no ClientAppId + var config = new Agent365Config + { + TenantId = "test-tenant", + ClientAppId = "00000000-0000-0000-0000-000000000001" + }; + + _mockGraphApiService.GraphGetAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()) + .Returns(Task.FromException(new SetupValidationException( + issueDescription: "MOS service principal not found", + mitigationSteps: [mitigationStep]))); // Act var result = await check.CheckAsync(config, _mockLogger); - // Assert — SetupValidationException maps to a Failure with guidance + // Assert — mitigation steps from the exception must appear in ResolutionGuidance result.Passed.Should().BeFalse(); - result.ResolutionGuidance.Should().NotBeNullOrEmpty(); + result.ResolutionGuidance.Should().Contain(mitigationStep); } [Fact]