From cda2d9c98bd4e931e993e5e47cfa7c1847b5971c Mon Sep 17 00:00:00 2001 From: Josina Joy Date: Tue, 25 Nov 2025 15:14:54 -0800 Subject: [PATCH] add more tests --- .../Commands/BlueprintSubcommandTests.cs | 560 +++++++++++++++++ .../Commands/EndpointSubcommandTests.cs | 591 ++++++++++++++++++ .../Commands/PermissionsSubcommandTests.cs | 494 +++++++++++++++ 3 files changed, 1645 insertions(+) create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/EndpointSubcommandTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs 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 new file mode 100644 index 00000000..d17412b7 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs @@ -0,0 +1,560 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.IO; +using System.CommandLine.Parsing; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Unit tests for Blueprint subcommand +/// +public class BlueprintSubcommandTests +{ + private readonly ILogger _mockLogger; + private readonly IConfigService _mockConfigService; + private readonly CommandExecutor _mockExecutor; + private readonly IAzureValidator _mockAzureValidator; + private readonly AzureWebAppCreator _mockWebAppCreator; + private readonly PlatformDetector _mockPlatformDetector; + + public BlueprintSubcommandTests() + { + _mockLogger = Substitute.For(); + _mockConfigService = Substitute.For(); + var mockExecutorLogger = Substitute.For>(); + _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); + _mockAzureValidator = Substitute.For(); + _mockWebAppCreator = Substitute.ForPartsOf(Substitute.For>()); + var mockPlatformDetectorLogger = Substitute.For>(); + _mockPlatformDetector = Substitute.ForPartsOf(mockPlatformDetectorLogger); + } + + [Fact] + public void CreateCommand_ShouldHaveCorrectName() + { + // Act + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + // Assert + command.Name.Should().Be("blueprint"); + } + + [Fact] + public void CreateCommand_ShouldHaveDescription() + { + // Act + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + // Assert + command.Description.Should().NotBeNullOrEmpty(); + command.Description.Should().Contain("agent blueprint"); + } + + [Fact] + public void CreateCommand_ShouldHaveConfigOption() + { + // Act + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + // Assert + var configOption = command.Options.FirstOrDefault(o => o.Name == "config"); + configOption.Should().NotBeNull(); + configOption!.Aliases.Should().Contain("--config"); + configOption.Aliases.Should().Contain("-c"); + } + + [Fact] + public void CreateCommand_ShouldHaveVerboseOption() + { + // Act + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + // Assert + var verboseOption = command.Options.FirstOrDefault(o => o.Name == "verbose"); + verboseOption.Should().NotBeNull(); + verboseOption!.Aliases.Should().Contain("--verbose"); + verboseOption.Aliases.Should().Contain("-v"); + } + + [Fact] + public void CreateCommand_ShouldHaveDryRunOption() + { + // Act + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + // Assert + var dryRunOption = command.Options.FirstOrDefault(o => o.Name == "dry-run"); + dryRunOption.Should().NotBeNull(); + dryRunOption!.Aliases.Should().Contain("--dry-run"); + } + + [Fact] + public async Task DryRun_ShouldLoadConfigAndNotExecute() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant", + AgentBlueprintDisplayName = "Test Blueprint" + }; + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + var parser = new CommandLineBuilder(command).Build(); + var testConsole = new TestConsole(); + + // Act + var result = await parser.InvokeAsync("--dry-run", testConsole); + + // Assert + result.Should().Be(0); + await _mockConfigService.Received(1).LoadAsync(Arg.Any(), Arg.Any()); + await _mockAzureValidator.DidNotReceiveWithAnyArgs().ValidateAllAsync(default!); + } + + [Fact] + public async Task DryRun_ShouldDisplayBlueprintInformation() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant-id", + AgentBlueprintDisplayName = "My Test Blueprint" + }; + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + var parser = new CommandLineBuilder(command).Build(); + var testConsole = new TestConsole(); + + // Act + var result = await parser.InvokeAsync("--dry-run", testConsole); + + // Assert + result.Should().Be(0); + + // Verify logger received appropriate calls about what would be done + _mockLogger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("DRY RUN")), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public async Task CreateBlueprintImplementation_WithMissingDisplayName_ShouldThrow() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", // Valid GUID format + SubscriptionId = "test-sub", + AgentBlueprintDisplayName = "" // Missing display name + }; + + var configFile = new FileInfo("test-config.json"); + + _mockAzureValidator.ValidateAllAsync(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, + _mockLogger, + skipInfrastructure: false, + isSetupAll: false); + + // Assert - Should return false when consent service fails + result.Should().BeFalse(); + } + + [Fact] + public async Task CreateBlueprintImplementation_WithAzureValidationFailure_ShouldReturnFalse() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + SubscriptionId = "test-sub", + AgentBlueprintDisplayName = "Test Blueprint" + }; + + var configFile = new FileInfo("test-config.json"); + + _mockAzureValidator.ValidateAllAsync(Arg.Any()) + .Returns(false); // Validation fails + + // Act + var result = await BlueprintSubcommand.CreateBlueprintImplementationAsync( + config, + configFile, + _mockExecutor, + _mockAzureValidator, + _mockLogger, + skipInfrastructure: false, + isSetupAll: false); + + // Assert + result.Should().BeFalse(); + await _mockAzureValidator.Received(1).ValidateAllAsync(config.SubscriptionId); + } + + [Fact] + public void CommandDescription_ShouldMentionRequiredPermissions() + { + // Act + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + // Assert + command.Description.Should().Contain("Agent ID Developer"); + } + + [Fact] + public async Task DryRun_WithCustomConfigPath_ShouldLoadCorrectFile() + { + // Arrange + var customPath = "custom-config.json"; + var config = new Agent365Config + { + TenantId = "test-tenant", + AgentBlueprintDisplayName = "Test Blueprint" + }; + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + var parser = new CommandLineBuilder(command).Build(); + var testConsole = new TestConsole(); + + // Act + var result = await parser.InvokeAsync($"--config {customPath} --dry-run", testConsole); + + // Assert + result.Should().Be(0); + await _mockConfigService.Received(1).LoadAsync( + Arg.Is(s => s.Contains(customPath)), + Arg.Any()); + } + + [Fact] + public async Task DryRun_ShouldNotCreateServicePrincipal() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant", + AgentBlueprintDisplayName = "Test Blueprint" + }; + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + var parser = new CommandLineBuilder(command).Build(); + var testConsole = new TestConsole(); + + // Act + var result = await parser.InvokeAsync("--dry-run", testConsole); + + // Assert + result.Should().Be(0); + + // Verify no Azure CLI commands were executed + await _mockExecutor.DidNotReceiveWithAnyArgs() + .ExecuteAsync(default!, default!, default, default, default, default); + } + + [Fact] + public void CreateCommand_ShouldHandleAllOptions() + { + // Act + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + // Assert - Verify all expected options are present + command.Options.Should().HaveCountGreaterOrEqualTo(3); + + var optionNames = command.Options.Select(o => o.Name).ToList(); + optionNames.Should().Contain("config"); + optionNames.Should().Contain("verbose"); + optionNames.Should().Contain("dry-run"); + } + + [Fact] + public async Task DryRun_WithMissingConfig_ShouldHandleGracefully() + { + // Arrange + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new FileNotFoundException("Config not found")); + + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + var parser = new CommandLineBuilder(command).Build(); + var testConsole = new TestConsole(); + + // Act & Assert + await Assert.ThrowsAsync( + async () => await parser.InvokeAsync("--dry-run", testConsole)); + } + + [Fact] + public void CreateCommand_DefaultConfigPath_ShouldBeA365ConfigJson() + { + // Act + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + // Assert - Verify the config option exists and has expected aliases + var configOption = command.Options.First(o => o.Name == "config"); + configOption.Should().NotBeNull(); + configOption.Aliases.Should().Contain("--config"); + configOption.Aliases.Should().Contain("-c"); + } + + [Fact] + public async Task CreateBlueprintImplementation_ShouldLogProgressMessages() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + SubscriptionId = "test-sub", + AgentBlueprintDisplayName = "Test Blueprint" + }; + + var configFile = new FileInfo("test-config.json"); + + _mockAzureValidator.ValidateAllAsync(Arg.Any()) + .Returns(false); // Fail fast for this test + + // Act + var result = await BlueprintSubcommand.CreateBlueprintImplementationAsync( + config, + configFile, + _mockExecutor, + _mockAzureValidator, + _mockLogger, + skipInfrastructure: false, + isSetupAll: false); + + // Assert + result.Should().BeFalse(); + + // Verify progress logging occurred + _mockLogger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Creating Agent Blueprint")), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public void CommandDescription_ShouldBeInformativeAndActionable() + { + // Act + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + // Assert - Verify description provides context and guidance + command.Description.Should().NotBeNullOrEmpty(); + command.Description.Should().ContainAny("blueprint", "agent", "Entra ID", "application"); + } + + [Fact] + public async Task DryRun_WithVerboseFlag_ShouldSucceed() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant", + AgentBlueprintDisplayName = "Test Blueprint" + }; + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + var parser = new CommandLineBuilder(command).Build(); + var testConsole = new TestConsole(); + + // Act + var result = await parser.InvokeAsync("--dry-run --verbose", testConsole); + + // Assert + result.Should().Be(0); + await _mockConfigService.Received(1).LoadAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DryRun_ShouldShowWhatWouldBeDone() + { + // Arrange + var config = new Agent365Config + { + TenantId = "tenant-123", + AgentBlueprintDisplayName = "Production Blueprint" + }; + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + var parser = new CommandLineBuilder(command).Build(); + var testConsole = new TestConsole(); + + // Act + var result = await parser.InvokeAsync("--dry-run", testConsole); + + // Assert + result.Should().Be(0); + + // Verify the display name and tenant are mentioned in logs + _mockLogger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Production Blueprint") || o.ToString()!.Contains("Display Name")), + Arg.Any(), + Arg.Any>()); + } + + [Fact] + public void CreateCommand_ShouldBeUsableInCommandPipeline() + { + // Act + var command = BlueprintSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockAzureValidator, + _mockWebAppCreator, + _mockPlatformDetector); + + // Assert - Verify command can be added to a parser + var parser = new CommandLineBuilder(command).Build(); + parser.Should().NotBeNull(); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/EndpointSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/EndpointSubcommandTests.cs new file mode 100644 index 00000000..120b820e --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/EndpointSubcommandTests.cs @@ -0,0 +1,591 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using System.CommandLine; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Unit tests for Endpoint subcommand +/// +[Collection("Sequential")] +public class EndpointSubcommandTests +{ + private readonly ILogger _mockLogger; + private readonly IConfigService _mockConfigService; + private readonly IBotConfigurator _mockBotConfigurator; + private readonly PlatformDetector _mockPlatformDetector; + + public EndpointSubcommandTests() + { + _mockLogger = Substitute.For(); + _mockConfigService = Substitute.For(); + _mockBotConfigurator = Substitute.For(); + + // Create a simple mock without using ForPartsOf which might be causing issues + _mockPlatformDetector = Substitute.For(Substitute.For>()); + } + + #region Command Structure Tests + + [Fact] + public void CreateCommand_ShouldHaveConfigOption() + { + // Act + var command = EndpointSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + + // Assert + var configOption = command.Options.FirstOrDefault(o => o.Name == "config"); + configOption.Should().NotBeNull(); + configOption!.Aliases.Should().Contain("--config"); + configOption.Aliases.Should().Contain("-c"); + } + + [Fact] + public void CreateCommand_ShouldHaveVerboseOption() + { + // Act + var command = EndpointSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + + // Assert + var verboseOption = command.Options.FirstOrDefault(o => o.Name == "verbose"); + verboseOption.Should().NotBeNull(); + verboseOption!.Aliases.Should().Contain("--verbose"); + verboseOption.Aliases.Should().Contain("-v"); + } + + [Fact] + public void CreateCommand_ShouldHaveDryRunOption() + { + // Act + var command = EndpointSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + + // Assert + var dryRunOption = command.Options.FirstOrDefault(o => o.Name == "dry-run"); + dryRunOption.Should().NotBeNull(); + dryRunOption!.Aliases.Should().Contain("--dry-run"); + } + + [Fact] + public void CommandDescription_ShouldMentionRequiredPermissions() + { + // Act + var command = EndpointSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + + // Assert + command.Description.Should().Contain("Azure Subscription Contributor"); + } + + [Fact] + public void CreateCommand_ShouldBeUsableInCommandPipeline() + { + // Act + var command = EndpointSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + + // Assert - Verify command can be created and has expected properties + command.Should().NotBeNull(); + command.Name.Should().Be("endpoint"); + command.Options.Should().HaveCountGreaterOrEqualTo(3); + } + + [Fact] + public void CreateCommand_ShouldHandleAllOptions() + { + // Act + var command = EndpointSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + + // Assert - Verify all expected options are present + command.Options.Should().HaveCountGreaterOrEqualTo(3); + + var optionNames = command.Options.Select(o => o.Name).ToList(); + optionNames.Should().Contain("config"); + optionNames.Should().Contain("verbose"); + optionNames.Should().Contain("dry-run"); + } + + [Fact] + public void CommandDescription_ShouldBeInformativeAndActionable() + { + // Act + var command = EndpointSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + + // Assert + command.Description.Should().NotBeNullOrEmpty(); + command.Description.Should().ContainAny("endpoint", "messaging", "Bot Service"); + } + + [Fact] + public void CommandDescription_ShouldMentionAzureBotService() + { + // Act + var command = EndpointSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + + // Assert + command.Description.Should().Contain("Azure Bot Service"); + } + + #endregion + + #region Validation Tests (Testing logic without parser) + + [Fact] + public async Task ValidationLogic_WithMissingBlueprintId_ShouldLogError() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant", + AgentBlueprintId = "", // Missing blueprint ID + WebAppName = "test-webapp" + }; + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + // Act - Load config and validate + var loadedConfig = await _mockConfigService.LoadAsync("test-config.json"); + + // Assert - Verify validation would catch this + loadedConfig.AgentBlueprintId.Should().BeEmpty(); + // In the actual command handler, Environment.Exit(1) would be called + } + + [Fact] + public async Task ValidationLogic_WithMissingWebAppName_ShouldLogError() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant", + AgentBlueprintId = "blueprint-123", + WebAppName = "" // Missing web app name + }; + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + // Act + var loadedConfig = await _mockConfigService.LoadAsync("test-config.json"); + + // Assert + loadedConfig.WebAppName.Should().BeEmpty(); + // In the actual command handler, Environment.Exit(1) would be called + } + + [Fact] + public async Task DryRunLogic_ShouldNotExecuteRegistration() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant", + AgentBlueprintId = "blueprint-123", + WebAppName = "test-webapp" + }; + + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + // Act - Simulate dry-run logic (loading config but not executing) + var loadedConfig = await _mockConfigService.LoadAsync("test-config.json"); + + // Assert - Verify config was loaded + loadedConfig.Should().NotBeNull(); + loadedConfig.AgentBlueprintId.Should().Be("blueprint-123"); + loadedConfig.WebAppName.Should().Be("test-webapp"); + + // Verify no bot configuration was attempted + await _mockBotConfigurator.DidNotReceiveWithAnyArgs() + .CreateEndpointWithAgentBlueprintAsync(default!, default!, default!, default!, default!); + } + + [Fact] + public void DryRunDisplay_ShouldShowEndpointInfo() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant", + AgentBlueprintId = "blueprint-456", + WebAppName = "my-agent-webapp" + }; + + // Act - Simulate what dry-run would display + var endpointName = $"{config.WebAppName}-endpoint"; + var messagingUrl = $"https://{config.WebAppName}.azurewebsites.net/api/messages"; + + // Assert + endpointName.Should().Be("my-agent-webapp-endpoint"); + messagingUrl.Should().Be("https://my-agent-webapp.azurewebsites.net/api/messages"); + } + + [Fact] + public void DryRunDisplay_ShouldShowMessagingUrl() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant", + AgentBlueprintId = "blueprint-789", + WebAppName = "production-agent" + }; + + // Act - Simulate messaging URL generation + var messagingUrl = $"https://{config.WebAppName}.azurewebsites.net/api/messages"; + + // Assert + messagingUrl.Should().Contain("production-agent.azurewebsites.net/api/messages"); + } + + #endregion + + #region RegisterEndpointAndSyncAsync Tests + + [Fact] + public async Task RegisterEndpointAndSyncAsync_WithValidConfig_ShouldSucceed() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + AgentBlueprintId = "blueprint-123", + WebAppName = "test-webapp", + Location = "eastus", + DeploymentProjectPath = Path.GetTempPath() + }; + + var testId = Guid.NewGuid().ToString(); + var configPath = Path.Combine(Path.GetTempPath(), $"test-config-{testId}.json"); + var generatedPath = Path.Combine(Path.GetTempPath(), $"a365.generated.config-{testId}.json"); + + // Create temporary generated config file + await File.WriteAllTextAsync(generatedPath, "{}"); + + try + { + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + _mockConfigService.SaveStateAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + _mockBotConfigurator.CreateEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + // Act + await EndpointSubcommand.RegisterEndpointAndSyncAsync( + configPath, + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + + // Assert + await _mockBotConfigurator.Received(1).CreateEndpointWithAgentBlueprintAsync( + Arg.Any(), + config.Location, + Arg.Is(s => s.Contains("test-webapp.azurewebsites.net")), + Arg.Any(), + config.AgentBlueprintId); + + await _mockConfigService.Received(1).SaveStateAsync(Arg.Any(), Arg.Any()); + } + finally + { + // Cleanup + if (File.Exists(generatedPath)) + { + File.Delete(generatedPath); + } + if (File.Exists(configPath)) + { + File.Delete(configPath); + } + } + } + + [Fact] + public async Task RegisterEndpointAndSyncAsync_ShouldSetCompletedFlag() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + AgentBlueprintId = "blueprint-456", + WebAppName = "test-webapp", + Location = "westus", + DeploymentProjectPath = Path.GetTempPath() + }; + + var testId = Guid.NewGuid().ToString(); + var configPath = Path.Combine(Path.GetTempPath(), $"test-config-{testId}.json"); + var generatedPath = Path.Combine(Path.GetTempPath(), $"a365.generated.config-{testId}.json"); + await File.WriteAllTextAsync(generatedPath, "{}"); + + try + { + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + Agent365Config? savedConfig = null; + _mockConfigService.SaveStateAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask) + .AndDoes(callInfo => savedConfig = callInfo.Arg()); + + _mockBotConfigurator.CreateEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + // Act + await EndpointSubcommand.RegisterEndpointAndSyncAsync( + configPath, + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + + // Assert + savedConfig.Should().NotBeNull(); + savedConfig!.Completed.Should().BeTrue(); + savedConfig.CompletedAt.Should().NotBeNull(); + savedConfig.CompletedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1)); + } + finally + { + if (File.Exists(generatedPath)) + { + File.Delete(generatedPath); + } + if (File.Exists(configPath)) + { + File.Delete(configPath); + } + } + } + + [Fact] + public async Task RegisterEndpointAndSyncAsync_ShouldLogProgressMessages() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + AgentBlueprintId = "blueprint-789", + WebAppName = "test-webapp", + Location = "eastus", + DeploymentProjectPath = Path.GetTempPath() + }; + + var testId = Guid.NewGuid().ToString(); + var configPath = Path.Combine(Path.GetTempPath(), $"test-config-{testId}.json"); + var generatedPath = Path.Combine(Path.GetTempPath(), $"a365.generated.config-{testId}.json"); + await File.WriteAllTextAsync(generatedPath, "{}"); + + try + { + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + _mockConfigService.SaveStateAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + _mockBotConfigurator.CreateEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + // Act + await EndpointSubcommand.RegisterEndpointAndSyncAsync( + configPath, + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + + // Assert + _mockLogger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Registering blueprint messaging endpoint")), + Arg.Any(), + Arg.Any>()); + + _mockLogger.Received().Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("registered successfully")), + Arg.Any(), + Arg.Any>()); + } + finally + { + if (File.Exists(generatedPath)) + { + File.Delete(generatedPath); + } + if (File.Exists(configPath)) + { + File.Delete(configPath); + } + } + } + + [Fact] + public async Task RegisterEndpointAndSyncAsync_WhenSyncFails_ShouldLogWarningButContinue() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + AgentBlueprintId = "blueprint-123", + WebAppName = "test-webapp", + Location = "eastus", + DeploymentProjectPath = "non-existent-path" // This will cause sync to skip with a warning + }; + + var testId = Guid.NewGuid().ToString(); + var configPath = Path.Combine(Path.GetTempPath(), $"test-config-{testId}.json"); + var generatedPath = Path.Combine(Path.GetTempPath(), $"a365.generated.config-{testId}.json"); + await File.WriteAllTextAsync(generatedPath, "{}"); + + try + { + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + _mockConfigService.SaveStateAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + _mockBotConfigurator.CreateEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + // Act - should not throw + await EndpointSubcommand.RegisterEndpointAndSyncAsync( + configPath, + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + + // Assert - ProjectSettingsSyncHelper logs a warning when deploymentProjectPath doesn't exist + _mockLogger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Project settings sync failed") && o.ToString()!.Contains("non-blocking")), + Arg.Any(), + Arg.Any>()); + } + finally + { + if (File.Exists(generatedPath)) + { + File.Delete(generatedPath); + } + if (File.Exists(configPath)) + { + File.Delete(configPath); + } + } + } + + [Fact] + public async Task RegisterEndpointAndSyncAsync_ShouldUpdateBotConfigurationInAgent365Config() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + AgentBlueprintId = "blueprint-123", + WebAppName = "test-webapp", + Location = "eastus", + DeploymentProjectPath = Path.GetTempPath() + }; + + var testId = Guid.NewGuid().ToString(); + var configPath = Path.Combine(Path.GetTempPath(), $"test-config-{testId}.json"); + var generatedPath = Path.Combine(Path.GetTempPath(), $"a365.generated.config-{testId}.json"); + await File.WriteAllTextAsync(generatedPath, "{}"); + + try + { + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(config)); + + Agent365Config? savedConfig = null; + _mockConfigService.SaveStateAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask) + .AndDoes(callInfo => savedConfig = callInfo.Arg()); + + _mockBotConfigurator.CreateEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + // Act + await EndpointSubcommand.RegisterEndpointAndSyncAsync( + configPath, + _mockLogger, + _mockConfigService, + _mockBotConfigurator, + _mockPlatformDetector); + + // Assert - Verify bot configuration was updated in config + savedConfig.Should().NotBeNull(); + savedConfig!.BotId.Should().Be(config.AgentBlueprintId); + savedConfig.BotMsaAppId.Should().Be(config.AgentBlueprintId); + savedConfig.BotMessagingEndpoint.Should().Contain("test-webapp.azurewebsites.net"); + } + finally + { + if (File.Exists(generatedPath)) + { + File.Delete(generatedPath); + } + if (File.Exists(configPath)) + { + File.Delete(configPath); + } + } + } + + #endregion +} 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 new file mode 100644 index 00000000..dc21aeb0 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs @@ -0,0 +1,494 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using System.CommandLine; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Unit tests for Permissions subcommand +/// +[Collection("Sequential")] +public class PermissionsSubcommandTests +{ + private readonly ILogger _mockLogger; + private readonly IConfigService _mockConfigService; + private readonly CommandExecutor _mockExecutor; + private readonly GraphApiService _mockGraphApiService; + + public PermissionsSubcommandTests() + { + _mockLogger = Substitute.For(); + _mockConfigService = Substitute.For(); + var mockExecutorLogger = Substitute.For>(); + _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); + _mockGraphApiService = Substitute.ForPartsOf(); + } + + #region Command Structure Tests + + [Fact] + public void CreateCommand_ShouldHaveMcpSubcommand() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + // Assert + var mcpSubcommand = command.Subcommands.FirstOrDefault(s => s.Name == "mcp"); + mcpSubcommand.Should().NotBeNull(); + } + + [Fact] + public void CreateCommand_ShouldHaveBotSubcommand() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + // Assert + var botSubcommand = command.Subcommands.FirstOrDefault(s => s.Name == "bot"); + botSubcommand.Should().NotBeNull(); + } + + [Fact] + public void CommandDescription_ShouldMentionRequiredPermissions() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + // Assert + command.Description.Should().Contain("Global Administrator"); + } + + [Fact] + public void CreateCommand_ShouldHaveBothSubcommands() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + // Assert + command.Subcommands.Should().HaveCount(2); + command.Subcommands.Should().Contain(s => s.Name == "mcp"); + command.Subcommands.Should().Contain(s => s.Name == "bot"); + } + + [Fact] + public void CreateCommand_ShouldBeUsableInCommandPipeline() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + // Assert + command.Should().NotBeNull(); + command.Name.Should().Be("permissions"); + command.Subcommands.Should().HaveCount(2); + } + + #endregion + + #region MCP Subcommand Tests + + [Fact] + public void McpSubcommand_ShouldHaveCorrectName() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + var mcpSubcommand = command.Subcommands.First(s => s.Name == "mcp"); + + // Assert + mcpSubcommand.Name.Should().Be("mcp"); + } + + [Fact] + public void McpSubcommand_ShouldHaveConfigOption() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + var mcpSubcommand = command.Subcommands.First(s => s.Name == "mcp"); + + // Assert + var configOption = mcpSubcommand.Options.FirstOrDefault(o => o.Name == "config"); + configOption.Should().NotBeNull(); + configOption!.Aliases.Should().Contain("--config"); + configOption.Aliases.Should().Contain("-c"); + } + + [Fact] + public void McpSubcommand_ShouldHaveVerboseOption() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + var mcpSubcommand = command.Subcommands.First(s => s.Name == "mcp"); + + // Assert + var verboseOption = mcpSubcommand.Options.FirstOrDefault(o => o.Name == "verbose"); + verboseOption.Should().NotBeNull(); + verboseOption!.Aliases.Should().Contain("--verbose"); + verboseOption.Aliases.Should().Contain("-v"); + } + + [Fact] + public void McpSubcommand_ShouldHaveDryRunOption() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + var mcpSubcommand = command.Subcommands.First(s => s.Name == "mcp"); + + // Assert + var dryRunOption = mcpSubcommand.Options.FirstOrDefault(o => o.Name == "dry-run"); + dryRunOption.Should().NotBeNull(); + dryRunOption!.Aliases.Should().Contain("--dry-run"); + } + + [Fact] + public void McpSubcommand_DescriptionShouldBeInformativeAndActionable() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + var mcpSubcommand = command.Subcommands.First(s => s.Name == "mcp"); + + // Assert + mcpSubcommand.Description.Should().NotBeNullOrEmpty(); + mcpSubcommand.Description.Should().ContainAny("MCP", "OAuth2", "permissions"); + } + + #endregion + + #region Bot Subcommand Tests + + [Fact] + public void BotSubcommand_ShouldHaveCorrectName() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + var botSubcommand = command.Subcommands.First(s => s.Name == "bot"); + + // Assert + botSubcommand.Name.Should().Be("bot"); + } + + [Fact] + public void BotSubcommand_ShouldHaveConfigOption() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + var botSubcommand = command.Subcommands.First(s => s.Name == "bot"); + + // Assert + var configOption = botSubcommand.Options.FirstOrDefault(o => o.Name == "config"); + configOption.Should().NotBeNull(); + configOption!.Aliases.Should().Contain("--config"); + configOption.Aliases.Should().Contain("-c"); + } + + [Fact] + public void BotSubcommand_ShouldHaveVerboseOption() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + var botSubcommand = command.Subcommands.First(s => s.Name == "bot"); + + // Assert + var verboseOption = botSubcommand.Options.FirstOrDefault(o => o.Name == "verbose"); + verboseOption.Should().NotBeNull(); + verboseOption!.Aliases.Should().Contain("--verbose"); + verboseOption.Aliases.Should().Contain("-v"); + } + + [Fact] + public void BotSubcommand_ShouldHaveDryRunOption() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + var botSubcommand = command.Subcommands.First(s => s.Name == "bot"); + + // Assert + var dryRunOption = botSubcommand.Options.FirstOrDefault(o => o.Name == "dry-run"); + dryRunOption.Should().NotBeNull(); + dryRunOption!.Aliases.Should().Contain("--dry-run"); + } + + [Fact] + public void BotSubcommand_DescriptionShouldMentionPrerequisites() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + var botSubcommand = command.Subcommands.First(s => s.Name == "bot"); + + // Assert + botSubcommand.Description.Should().Contain("Prerequisites"); + } + + [Fact] + public void BotSubcommand_DescriptionShouldBeInformativeAndActionable() + { + // Act + var command = PermissionsSubcommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService); + + var botSubcommand = command.Subcommands.First(s => s.Name == "bot"); + + // Assert + botSubcommand.Description.Should().NotBeNullOrEmpty(); + botSubcommand.Description.Should().ContainAny("Bot", "API", "permissions"); + } + + #endregion + + #region Validation Tests (Testing logic without parser) + + [Fact] + public void McpValidation_WithMissingBlueprintId_ShouldDetect() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant", + AgentBlueprintId = "", // Missing blueprint ID + DeploymentProjectPath = "." + }; + + // Act - Verify validation logic + var blueprintId = config.AgentBlueprintId; + + // Assert - Verify validation would catch this + blueprintId.Should().BeEmpty(); + } + + [Fact] + public void BotValidation_WithMissingBlueprintId_ShouldDetect() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant", + AgentBlueprintId = null // Missing blueprint ID + }; + + // Act + var blueprintId = config.AgentBlueprintId; + + // Assert + blueprintId.Should().BeNull(); + } + + [Fact] + public void DryRunLogic_ShouldNotExecutePermissionGrants() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant", + AgentBlueprintId = "blueprint-123", + DeploymentProjectPath = "." + }; + + // Act - Verify config properties + var blueprintId = config.AgentBlueprintId; + var tenantId = config.TenantId; + + // Assert - Config is valid for dry-run + blueprintId.Should().Be("blueprint-123"); + tenantId.Should().Be("test-tenant"); + } + + [Fact] + public void McpConfiguration_ShouldDescribeOAuth2Grants() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant-id", + AgentBlueprintId = "blueprint-456", + DeploymentProjectPath = ".", + Environment = "preprod" + }; + + // Act - This would be what dry-run displays + var environment = config.Environment; + var blueprintId = config.AgentBlueprintId; + + // Assert + environment.Should().Be("preprod"); + blueprintId.Should().Be("blueprint-456"); + } + + [Fact] + public void BotConfiguration_ShouldDescribeBotApiPermissions() + { + // Arrange + var config = new Agent365Config + { + TenantId = "test-tenant", + AgentBlueprintId = "blueprint-123" + }; + + // Act - Simulate what would be logged + var blueprintId = config.AgentBlueprintId; + + // Assert + blueprintId.Should().NotBeNullOrEmpty(); + } + + #endregion + + #region ConfigureMcpPermissionsAsync Tests + + [Fact] + public async Task ConfigureMcpPermissionsAsync_WithMissingManifest_ShouldHandleGracefully() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + AgentBlueprintId = "blueprint-123", + DeploymentProjectPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()) // Non-existent path + }; + + var configFile = new FileInfo("test-config.json"); + + // Act + var result = await PermissionsSubcommand.ConfigureMcpPermissionsAsync( + configFile.FullName, + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService, + config, + false); + + // Assert - Should handle missing manifest gracefully + result.Should().BeFalse(); + } + + #endregion + + #region ConfigureBotPermissionsAsync Tests + + [Fact] + public async Task ConfigureBotPermissionsAsync_WithMissingBlueprintId_ShouldReturnFalse() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + AgentBlueprintId = "" // Missing + }; + + var configFile = new FileInfo("test-config.json"); + + // Act + var result = await PermissionsSubcommand.ConfigureBotPermissionsAsync( + configFile.FullName, + _mockLogger, + _mockConfigService, + _mockExecutor, + config, + _mockGraphApiService, + false); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ConfigureBotPermissionsAsync_ShouldValidateBlueprintId() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + AgentBlueprintId = "blueprint-123" + }; + + var configFile = new FileInfo("test-config.json"); + + // Act - Even though it may fail, it should validate the blueprint ID first + var blueprintId = config.AgentBlueprintId; + + // Assert + blueprintId.Should().NotBeNullOrEmpty(); + } + + #endregion +} +