diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs index 8b4c3bd4..dc061405 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs @@ -13,6 +13,9 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands; public class CleanupCommand { + private const string AgenticUsersKey = "agentic users"; + private const string IdentitySpsKey = "identity SPs"; + public static Command CreateCommand( ILogger logger, IConfigService configService, @@ -49,7 +52,7 @@ public static Command CreateCommand( }, configOption, verboseOption); // Add subcommands for granular control - cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, botConfigurator, executor, agentBlueprintService, federatedCredentialService, correlationId: correlationId)); + cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService, correlationId: correlationId)); cleanupCommand.AddCommand(CreateAzureCleanupCommand(logger, configService, executor)); cleanupCommand.AddCommand(CreateInstanceCleanupCommand(logger, configService, executor)); @@ -62,6 +65,7 @@ private static Command CreateBlueprintCleanupCommand( IBotConfigurator botConfigurator, CommandExecutor executor, AgentBlueprintService agentBlueprintService, + IConfirmationProvider confirmationProvider, FederatedCredentialService federatedCredentialService, string? correlationId = null) { @@ -106,41 +110,114 @@ private static Command CreateBlueprintCleanupCommand( return; } - // Full blueprint cleanup (original behavior) + // Full blueprint cleanup with cascade instance deletion logger.LogInformation("Starting blueprint cleanup..."); - // Check if there's actually a blueprint to clean up if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) { logger.LogInformation("No blueprint application found to clean up"); return; } + // Query for agent instances linked to this blueprint before showing preview + logger.LogInformation("Querying for agent instances linked to blueprint..."); + List instances; + try + { + instances = (await agentBlueprintService.GetAgentInstancesForBlueprintAsync( + config.TenantId, + config.AgentBlueprintId))?.ToList() ?? new List(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to query agent instances for blueprint {BlueprintId}. Aborting cleanup.", config.AgentBlueprintId); + return; + } + + // Show preview logger.LogInformation(""); logger.LogInformation("Blueprint Cleanup Preview:"); logger.LogInformation("============================="); logger.LogInformation("Will delete Entra ID application: {BlueprintId}", config.AgentBlueprintId); logger.LogInformation(" Name: {DisplayName}", config.AgentBlueprintDisplayName); + + if (instances.Count > 0) + { + logger.LogInformation(""); + logger.LogInformation("Will also delete {Count} agent instance(s) linked to this blueprint:", instances.Count); + foreach (var instance in instances) + { + logger.LogInformation(" Instance: {DisplayName} (SP: {SpId})", instance.DisplayName ?? "(unnamed)", instance.IdentitySpId); + if (!string.IsNullOrWhiteSpace(instance.AgentUserId)) + logger.LogInformation(" Agentic user: {UserId}", instance.AgentUserId); + } + } + logger.LogInformation(""); - Console.Write("Continue with blueprint cleanup? (y/N): "); - var response = Console.ReadLine()?.Trim().ToLowerInvariant(); - if (response != "y" && response != "yes") + if (!await confirmationProvider.ConfirmAsync("Continue with blueprint cleanup? (y/N): ")) { logger.LogInformation("Cleanup cancelled by user"); return; } + // Delete instances first (warn and continue on failure) + var failedResources = new Dictionary> + { + [AgenticUsersKey] = new List(), + [IdentitySpsKey] = new List() + }; + + foreach (var instance in instances) + { + // Delete agentic user before identity SP + if (!string.IsNullOrWhiteSpace(instance.AgentUserId)) + { + logger.LogInformation("Deleting agentic user {UserId} for instance {DisplayName}...", + instance.AgentUserId, instance.DisplayName ?? instance.IdentitySpId); + + var userDeleted = await agentBlueprintService.DeleteAgentUserAsync( + config.TenantId, + instance.AgentUserId); + + if (!userDeleted) + { + logger.LogWarning("Failed to delete agentic user {UserId} -- will continue", instance.AgentUserId); + failedResources[AgenticUsersKey].Add(instance.AgentUserId!); + } + else + { + logger.LogInformation("Agentic user deleted"); + } + } + + // Delete identity SP + logger.LogInformation("Deleting agent identity SP {SpId} for instance {DisplayName}...", + instance.IdentitySpId, instance.DisplayName ?? instance.IdentitySpId); + + var spDeleted = await agentBlueprintService.DeleteAgentIdentityAsync( + config.TenantId, + instance.IdentitySpId); + + if (!spDeleted) + { + logger.LogWarning("Failed to delete agent identity SP {SpId} -- will continue", instance.IdentitySpId); + failedResources[IdentitySpsKey].Add(instance.IdentitySpId); + } + else + { + logger.LogInformation("Agent identity SP deleted"); + } + } + // Delete federated credentials first before deleting the blueprint logger.LogInformation(""); logger.LogInformation("Deleting federated credentials from blueprint..."); - + // Configure FederatedCredentialService with custom client app ID if available if (!string.IsNullOrWhiteSpace(config.ClientAppId)) - { federatedCredentialService.CustomClientAppId = config.ClientAppId; - } - + var ficsDeleted = await federatedCredentialService.DeleteAllFederatedCredentialsAsync( config.TenantId, config.AgentBlueprintId); @@ -155,28 +232,30 @@ private static Command CreateBlueprintCleanupCommand( logger.LogInformation("Federated credentials deleted successfully"); } - // Delete the agent blueprint using the special Graph API endpoint + // Delete the agent blueprint logger.LogInformation(""); logger.LogInformation("Deleting agent blueprint application..."); var deleted = await agentBlueprintService.DeleteAgentBlueprintAsync( config.TenantId, config.AgentBlueprintId); - + if (!deleted) { logger.LogWarning(""); - logger.LogWarning("Blueprint deletion failed."); + logger.LogWarning("Blueprint deletion failed. The blueprint still exists in Entra ID."); + PrintOrphanSummary(logger, failedResources); + if (!HasOrphanedResources(failedResources)) + { + logger.LogWarning("All agent instances were deleted. Retry 'a365 cleanup blueprint' or delete the blueprint manually via the Entra portal or Graph API."); + } return; } - // Blueprint deleted successfully logger.LogInformation("Agent blueprint application deleted successfully"); // Handle endpoint deletion if needed using shared helper if (!await DeleteMessagingEndpointAsync(logger, config, botConfigurator, correlationId: correlationId)) - { return; - } // Clear configuration after successful blueprint deletion logger.LogInformation(""); @@ -188,8 +267,14 @@ private static Command CreateBlueprintCleanupCommand( await configService.SaveStateAsync(config); logger.LogInformation("Local configuration cleared"); - logger.LogInformation(""); - logger.LogInformation("Blueprint cleanup completed successfully!"); + + // Emit orphan summary if any instance deletions failed (PrintOrphanSummary returns early if none) + PrintOrphanSummary(logger, failedResources); + if (!HasOrphanedResources(failedResources)) + { + logger.LogInformation(""); + logger.LogInformation("Blueprint cleanup completed successfully!"); + } } catch (Exception ex) { @@ -808,6 +893,37 @@ private static async Task ExecuteEndpointOnlyCleanupAsync( logger.LogInformation(""); } + /// + /// Checks whether any instance deletions were recorded as failures. + /// + private static bool HasOrphanedResources(Dictionary> failedResources) + { + return failedResources[AgenticUsersKey].Count + failedResources[IdentitySpsKey].Count > 0; + } + + /// + /// Prints a summary of orphaned Entra ID resources that could not be deleted. + /// This should be called whenever instance deletions have failed, regardless of + /// whether the blueprint deletion itself succeeded or failed. + /// + private static void PrintOrphanSummary( + ILogger logger, + Dictionary> failedResources) + { + if (!HasOrphanedResources(failedResources)) + { + return; + } + + logger.LogWarning("Blueprint cleanup completed with warnings."); + logger.LogWarning("The following resources could not be deleted and remain orphaned in Entra ID:"); + foreach (var userId in failedResources[AgenticUsersKey]) + logger.LogWarning(" Orphaned agentic user: {ResourceId}", userId); + foreach (var spId in failedResources[IdentitySpsKey]) + logger.LogWarning(" Orphaned identity SP: {ResourceId}", spId); + logger.LogWarning("Delete them manually via the Entra portal or Graph API."); + } + private static async Task LoadConfigAsync( FileInfo? configFile, ILogger logger, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/AgentInstanceInfo.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/AgentInstanceInfo.cs new file mode 100644 index 00000000..86507fef --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/AgentInstanceInfo.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Models; + +/// +/// Represents an agent instance linked to a blueprint, consisting of an agent identity +/// service principal and an optional agentic user. +/// +public sealed record AgentInstanceInfo +{ + /// Graph object ID of the agent identity service principal. + public required string IdentitySpId { get; init; } + + /// Display name of the identity service principal, shown in cleanup preview. + public string? DisplayName { get; init; } + + /// Graph object ID of the linked agentic user, if one exists. + public string? AgentUserId { get; init; } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs index fa973f54..a5f8acb2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs @@ -77,7 +77,7 @@ public string? CustomClientAppId /// The blueprint application ID (object ID or app ID) /// Cancellation token /// True if deletion succeeded or resource not found; false otherwise - public async Task DeleteAgentBlueprintAsync( + public virtual async Task DeleteAgentBlueprintAsync( string tenantId, string blueprintId, CancellationToken cancellationToken = default) @@ -129,14 +129,14 @@ public async Task DeleteAgentBlueprintAsync( /// The unique identifier of the agent identity application to delete. /// A cancellation token that can be used to cancel the delete operation. /// True if deletion succeeded or resource not found; false otherwise - public async Task DeleteAgentIdentityAsync( + public virtual async Task DeleteAgentIdentityAsync( string tenantId, string applicationId, CancellationToken cancellationToken = default) { try { - _logger.LogInformation("Deleting agent identity application: {applicationId}", applicationId); + _logger.LogInformation("Deleting agent identity application: {ApplicationId}", applicationId); // Agent Identity deletion requires special delegated permission scope var requiredScopes = new[] { "AgentIdentityBlueprint.ReadWrite.All" }; @@ -162,6 +162,167 @@ public async Task DeleteAgentIdentityAsync( } } + /// + /// Queries Entra ID for all agent identity service principals linked to the given blueprint. + /// Returns an empty list when no instances are found. + /// Throws if the query fails so callers can distinguish a true "no instances" result from a query error. + /// + /// The tenant ID for authentication. + /// The blueprint application ID or object ID. + /// Cancellation token. + /// List of agent instances linked to the blueprint. + /// Thrown when the Graph query fails. + public virtual async Task> GetAgentInstancesForBlueprintAsync( + string tenantId, + string blueprintId, + CancellationToken cancellationToken = default) + { + var requiredScopes = new[] { "AgentIdentityBlueprint.ReadWrite.All" }; + var encodedId = Uri.EscapeDataString(blueprintId); + + // Fetch agent identity SPs and agent users for this blueprint sequentially to avoid races on shared HTTP headers + var spItems = await FetchAllPagesAsync( + tenantId, + $"/beta/servicePrincipals/microsoft.graph.agentIdentity?$filter=agentIdentityBlueprintId eq '{encodedId}'&$select=id,displayName", + requiredScopes, + cancellationToken); + + var userItems = await FetchAllPagesAsync( + tenantId, + $"/beta/users/microsoft.graph.agentUser?$filter=agentIdentityBlueprintId eq '{encodedId}'&$select=id,identityParentId", + requiredScopes, + cancellationToken); + // Build lookup: identityParentId (SP object ID) -> user object ID + var userBySpId = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var user in userItems) + { + var parentId = user.TryGetProperty("identityParentId", out var p) ? p.GetString() : null; + var userId = user.TryGetProperty("id", out var uid) ? uid.GetString() : null; + if (!string.IsNullOrWhiteSpace(parentId) && !string.IsNullOrWhiteSpace(userId)) + { + userBySpId[parentId] = userId; + } + } + + // Correlate SPs with their agent users + var results = new List(); + foreach (var item in spItems) + { + var spId = item.TryGetProperty("id", out var id) ? id.GetString() : null; + if (string.IsNullOrWhiteSpace(spId)) + { + continue; + } + + var displayName = item.TryGetProperty("displayName", out var dn) ? dn.GetString() : null; + userBySpId.TryGetValue(spId, out var agentUserId); + + results.Add(new AgentInstanceInfo + { + IdentitySpId = spId, + DisplayName = displayName, + AgentUserId = string.IsNullOrWhiteSpace(agentUserId) ? null : agentUserId + }); + } + + return results; + } + + /// + /// Fetches all pages of a Graph API collection, following @odata.nextLink pagination. + /// Returns the deserialized "value" array items from all pages. + /// + private async Task> FetchAllPagesAsync( + string tenantId, + string initialPath, + string[] requiredScopes, + CancellationToken cancellationToken) + { + var items = new List(); + string? nextPageUrl = null; + var isFirstPage = true; + + do + { + var requestPath = isFirstPage ? initialPath : nextPageUrl!; + isFirstPage = false; + + using var doc = await _graphApiService.GraphGetAsync( + tenantId, + requestPath, + cancellationToken, + requiredScopes); + + if (doc is null) + { + _logger.LogError( + "Failed to retrieve data from Microsoft Graph for tenant '{TenantId}' and request path '{RequestPath}'. " + + "GraphGetAsync returned null, which likely indicates a non-success response or authentication issue.", + tenantId, + requestPath); + + throw new InvalidOperationException( + "Failed to retrieve data from Microsoft Graph. See logs for details about the underlying request failure."); + } + + if (doc.RootElement.TryGetProperty("value", out var valueArray) && + valueArray.ValueKind == JsonValueKind.Array) + { + foreach (var item in valueArray.EnumerateArray()) + { + items.Add(item.Clone()); + } + } + + nextPageUrl = doc.RootElement.TryGetProperty("@odata.nextLink", out var nextLink) + ? nextLink.GetString() + : null; + } + while (!string.IsNullOrEmpty(nextPageUrl)); + + return items; + } + + /// + /// Deletes an agentic user from Entra ID using the agentUsers beta endpoint. + /// + /// The tenant ID for authentication. + /// The object ID of the agentic user to delete. + /// Cancellation token. + /// True if deletion succeeded or user was not found; false on error. + public virtual async Task DeleteAgentUserAsync( + string tenantId, + string agentUserId, + CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation("Deleting agentic user: {AgentUserId}", agentUserId); + + var requiredScopes = new[] { "AgentIdentityBlueprint.ReadWrite.All" }; + var deletePath = $"/beta/agentUsers/{agentUserId}"; + + var success = await _graphApiService.GraphDeleteAsync( + tenantId, + deletePath, + cancellationToken, + treatNotFoundAsSuccess: true, + scopes: requiredScopes); + + if (success) + _logger.LogInformation("Agentic user deleted successfully"); + else + _logger.LogError("Failed to delete agentic user: {AgentUserId}", agentUserId); + + return success; + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception deleting agentic user: {AgentUserId}", agentUserId); + return false; + } + } + /// /// Sets inheritable permissions for an agent blueprint with proper scope merging. /// Checks if permissions already exist and merges scopes if needed via PATCH. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 1854f999..7cc8dab6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -284,8 +284,13 @@ private async Task EnsureGraphHeadersAsync(string tenantId, CancellationTo var url = relativePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? relativePath : $"https://graph.microsoft.com{relativePath}"; - var resp = await _httpClient.GetAsync(url, ct); - if (!resp.IsSuccessStatusCode) return null; + using var resp = await _httpClient.GetAsync(url, ct); + if (!resp.IsSuccessStatusCode) + { + var errorBody = await resp.Content.ReadAsStringAsync(ct); + _logger.LogError("Graph GET {Url} failed {Code} {Reason}: {Body}", url, (int)resp.StatusCode, resp.ReasonPhrase, errorBody); + return null; + } var json = await resp.Content.ReadAsStringAsync(ct); return JsonDocument.Parse(json); @@ -298,9 +303,13 @@ private async Task EnsureGraphHeadersAsync(string tenantId, CancellationTo ? relativePath : $"https://graph.microsoft.com{relativePath}"; var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); - var resp = await _httpClient.PostAsync(url, content, ct); + using var resp = await _httpClient.PostAsync(url, content, ct); var body = await resp.Content.ReadAsStringAsync(ct); - if (!resp.IsSuccessStatusCode) return null; + if (!resp.IsSuccessStatusCode) + { + _logger.LogError("Graph POST {Url} failed {Code} {Reason}: {Body}", url, (int)resp.StatusCode, resp.ReasonPhrase, body); + return null; + } return string.IsNullOrWhiteSpace(body) ? null : JsonDocument.Parse(body); } @@ -320,7 +329,7 @@ public virtual async Task GraphPostWithResponseAsync(string tenan : $"https://graph.microsoft.com{relativePath}"; var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); - var resp = await _httpClient.PostAsync(url, content, ct); + using var resp = await _httpClient.PostAsync(url, content, ct); var body = await resp.Content.ReadAsStringAsync(ct); JsonDocument? json = null; @@ -350,8 +359,8 @@ public virtual async Task GraphPatchAsync(string tenantId, string relative ? relativePath : $"https://graph.microsoft.com{relativePath}"; var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); - var request = new HttpRequestMessage(new HttpMethod("PATCH"), url) { Content = content }; - var resp = await _httpClient.SendAsync(request, ct); + using var request = new HttpRequestMessage(new HttpMethod("PATCH"), url) { Content = content }; + using var resp = await _httpClient.SendAsync(request, ct); // Many PATCH calls return 204 NoContent on success if (!resp.IsSuccessStatusCode) @@ -359,7 +368,7 @@ public virtual async Task GraphPatchAsync(string tenantId, string relative var body = await resp.Content.ReadAsStringAsync(ct); _logger.LogError("Graph PATCH {Url} failed {Code} {Reason}: {Body}", url, (int)resp.StatusCode, resp.ReasonPhrase, body); } - + return resp.IsSuccessStatusCode; } 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 b9b903a7..a96692a9 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 @@ -1345,7 +1345,7 @@ public async Task UpdateEndpointAsync_WithValidUrl_ShouldDeleteAndRegisterEndpoi .Returns(Task.CompletedTask); _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync( - Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); _mockBotConfigurator.CreateEndpointWithAgentBlueprintAsync( @@ -1365,7 +1365,8 @@ await BlueprintSubcommand.UpdateEndpointAsync( await _mockBotConfigurator.Received(1).DeleteEndpointWithAgentBlueprintAsync( Arg.Any(), Arg.Any(), - config.AgentBlueprintId); + config.AgentBlueprintId, + Arg.Any()); await _mockBotConfigurator.Received(1).CreateEndpointWithAgentBlueprintAsync( Arg.Any(), @@ -1435,7 +1436,7 @@ public async Task UpdateEndpointAsync_WhenDeleteFails_ShouldThrowAndNotRegister( .Returns(Task.FromResult(config)); _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync( - Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(false); // Delete fails // Act & Assert @@ -1505,7 +1506,8 @@ await BlueprintSubcommand.UpdateEndpointAsync( await _mockBotConfigurator.DidNotReceive().DeleteEndpointWithAgentBlueprintAsync( Arg.Any(), Arg.Any(), - Arg.Any()); + Arg.Any(), + Arg.Any()); // Should still register the new endpoint await _mockBotConfigurator.Received(1).CreateEndpointWithAgentBlueprintAsync( 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 3ee26492..52ca8f69 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 @@ -47,9 +47,10 @@ public CleanupCommandBotEndpointTests() _mockBotConfigurator = Substitute.For(); _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()) + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) .Returns(Task.FromResult(true)); _mockTokenProvider = Substitute.For(); 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 d18c06dd..9b5b0ebe 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 @@ -254,24 +254,259 @@ public void CleanupSubcommands_ShouldHaveRequiredOptions() Assert.DoesNotContain("force", optionNames); } - [Fact(Skip = "Requires interactive confirmation. Refactor command to allow test automation.")] + [Fact] public async Task CleanupBlueprint_WithValidConfig_ShouldReturnSuccess() { // Arrange var config = CreateValidConfig(); _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); + _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(true); - var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); + var stubbedBlueprintService = CreateStubbedBlueprintService(); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, stubbedBlueprintService, _mockConfirmationProvider, _federatedCredentialService); var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; // Act var result = await command.InvokeAsync(args); // Assert - Assert.Equal(0, result); // Success exit code - - // Test behavior: Blueprint cleanup currently succeeds (placeholder implementation) - // When actual PowerShell integration is added, this test can be enhanced + result.Should().Be(0); + } + + private AgentBlueprintService CreateStubbedBlueprintService( + IReadOnlyList? instances = null, + bool deleteUserResult = true, + bool deleteIdentityResult = true, + bool deleteBlueprintResult = true) + { + var mockBlueprintLogger = Substitute.For>(); + var spyService = Substitute.ForPartsOf(mockBlueprintLogger, _graphApiService); + + spyService.GetAgentInstancesForBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(instances ?? (IReadOnlyList)Array.Empty()); + + spyService.DeleteAgentUserAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(deleteUserResult); + + spyService.DeleteAgentIdentityAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(deleteIdentityResult); + + spyService.DeleteAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(deleteBlueprintResult); + + return spyService; + } + + /// + /// Verifies that blueprint cleanup deletes agent instances before deleting the blueprint. + /// Instance deletion order: agentic user first, then identity SP, then blueprint. + /// + [Fact] + public async Task CleanupBlueprint_WithInstances_DeletesInstancesBeforeBlueprint() + { + // Arrange + var config = CreateValidConfig(); + // Capture blueprint ID before the command clears it during config save + var expectedBlueprintId = config.AgentBlueprintId!; + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); + _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + var instances = new List + { + new() { IdentitySpId = "sp-id-1", DisplayName = "Instance A", AgentUserId = "user-id-1" } + }; + var spyService = CreateStubbedBlueprintService(instances: instances); + + _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(true); + + var command = CleanupCommand.CreateCommand( + _mockLogger, _mockConfigService, _mockBotConfigurator, + _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService); + var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; + + // Act + var result = await command.InvokeAsync(args); + + // Assert + result.Should().Be(0); + + await spyService.Received(1).DeleteAgentUserAsync( + config.TenantId, "user-id-1", Arg.Any()); + + await spyService.Received(1).DeleteAgentIdentityAsync( + config.TenantId, "sp-id-1", Arg.Any()); + + await spyService.Received(1).DeleteAgentBlueprintAsync( + config.TenantId, expectedBlueprintId, Arg.Any()); + } + + /// + /// Verifies that blueprint cleanup with no instances proceeds exactly as before + /// (no instance deletion calls made). + /// + [Fact] + public async Task CleanupBlueprint_WithNoInstances_ProceedsAsNormal() + { + // Arrange + var config = CreateValidConfig(); + // Capture blueprint ID before the command clears it during config save + var expectedBlueprintId = config.AgentBlueprintId!; + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); + _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + var spyService = CreateStubbedBlueprintService(instances: Array.Empty()); + + _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(true); + + var command = CleanupCommand.CreateCommand( + _mockLogger, _mockConfigService, _mockBotConfigurator, + _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService); + var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; + + // Act + var result = await command.InvokeAsync(args); + + // Assert + result.Should().Be(0); + + await spyService.DidNotReceive().DeleteAgentUserAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + await spyService.DidNotReceive().DeleteAgentIdentityAsync( + Arg.Any(), Arg.Any(), Arg.Any()); + + await spyService.Received(1).DeleteAgentBlueprintAsync( + config.TenantId, expectedBlueprintId, Arg.Any()); + } + + /// + /// Verifies that when an instance deletion fails, a warning is emitted and the + /// blueprint is still deleted (warn-and-continue behaviour). + /// + [Fact] + public async Task CleanupBlueprint_InstanceDeletionFails_WarnsAndContinuesToBlueprint() + { + // Arrange + var config = CreateValidConfig(); + // Capture blueprint ID before the command clears it during config save + var expectedBlueprintId = config.AgentBlueprintId!; + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); + _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + var instances = new List + { + new() { IdentitySpId = "sp-id-1", DisplayName = "Instance A", AgentUserId = "user-id-1" } + }; + var spyService = CreateStubbedBlueprintService( + instances: instances, + deleteUserResult: false, + deleteIdentityResult: true, + deleteBlueprintResult: true); + + _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(true); + + var command = CleanupCommand.CreateCommand( + _mockLogger, _mockConfigService, _mockBotConfigurator, + _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService); + var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; + + // Act + var result = await command.InvokeAsync(args); + + // Assert -- command succeeds overall + result.Should().Be(0); + + // Blueprint is still deleted despite the instance failure + await spyService.Received(1).DeleteAgentBlueprintAsync( + config.TenantId, expectedBlueprintId, Arg.Any()); + + // Verify a warning was logged about the failed agentic user deletion + _mockLogger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Failed to delete agentic user") && o.ToString()!.Contains("user-id-1")), + Arg.Any(), + Arg.Any>()); + + // Verify the orphan summary warning was emitted for the failed resource + _mockLogger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Orphaned agentic user") && o.ToString()!.Contains("user-id-1")), + Arg.Any(), + Arg.Any>()); + } + + /// + /// Verifies that when instances are deleted successfully but the blueprint deletion fails, + /// a warning is logged about the incomplete cleanup state. + /// + [Fact] + public async Task CleanupBlueprint_WhenBlueprintDeletionFailsWithInstances_LogsWarning() + { + // Arrange + var config = CreateValidConfig(); + var expectedBlueprintId = config.AgentBlueprintId!; + _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); + _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + var instances = new List + { + new() { IdentitySpId = "sp-id-1", DisplayName = "Instance A", AgentUserId = "user-id-1" } + }; + var spyService = CreateStubbedBlueprintService( + instances: instances, + deleteUserResult: true, + deleteIdentityResult: true, + deleteBlueprintResult: false); + + _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(true); + + var command = CleanupCommand.CreateCommand( + _mockLogger, _mockConfigService, _mockBotConfigurator, + _mockExecutor, spyService, _mockConfirmationProvider, _federatedCredentialService); + var args = new[] { "cleanup", "blueprint", "--config", "test.json" }; + + // Act + var result = await command.InvokeAsync(args); + + // Assert + result.Should().Be(0); + + await spyService.Received(1).DeleteAgentUserAsync( + config.TenantId, "user-id-1", Arg.Any()); + + await spyService.Received(1).DeleteAgentIdentityAsync( + config.TenantId, "sp-id-1", Arg.Any()); + + await spyService.Received(1).DeleteAgentBlueprintAsync( + config.TenantId, expectedBlueprintId, Arg.Any()); + + // Verify that a warning was logged about the blueprint deletion failure + _mockLogger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Blueprint deletion failed. The blueprint still exists in Entra ID.")), + Arg.Any(), + Arg.Any>()); + + // Verify that the retry guidance message is also logged + _mockLogger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("All agent instances were deleted. Retry 'a365 cleanup blueprint'")), + Arg.Any(), + Arg.Any>()); } private static Agent365Config CreateValidConfig() @@ -501,7 +736,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndNoBlueprintId_ShouldLogErr // Verify no deletion operations were called (because blueprint ID is missing) await _mockBotConfigurator.DidNotReceive().DeleteEndpointWithAgentBlueprintAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } /// @@ -539,7 +774,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndNoBotName_ShouldLogInfo() // Verify no deletion operations were called (because BotName is empty) await _mockBotConfigurator.DidNotReceive().DeleteEndpointWithAgentBlueprintAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } /// @@ -673,7 +908,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndWhitespaceBlueprint_Should // Verify no deletion operations were called since blueprint ID is invalid await _mockBotConfigurator.DidNotReceive().DeleteEndpointWithAgentBlueprintAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } /// @@ -764,7 +999,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndEmptyInput_ShouldCancelCle // Arrange var config = CreateValidConfig(); _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _mockBotConfigurator.DeleteEndpointWithAgentBlueprintAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(true); var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService); @@ -785,7 +1020,7 @@ public async Task CleanupBlueprint_WithEndpointOnlyAndEmptyInput_ShouldCancelCle // Verify NO deletion was called because empty input defaults to cancel await _mockBotConfigurator.DidNotReceive().DeleteEndpointWithAgentBlueprintAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } finally { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs index 80fcdb73..8f29a294 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Text.Json; using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; using NSubstitute; @@ -165,73 +166,66 @@ public async Task SetInheritablePermissionsAsync_Patches_WhenPresent() public async Task DeleteAgentIdentityAsync_WithValidIdentity_ReturnsTrue() { // Arrange - var handler = new FakeHttpMessageHandler(); - var graphService = new GraphApiService(_mockGraphLogger, _mockExecutor, handler, _mockTokenProvider); - var service = new AgentBlueprintService(_mockLogger, graphService); - - const string tenantId = "12345678-1234-1234-1234-123456789012"; - const string identityId = "identity-sp-id-123"; - - _mockTokenProvider.GetMgGraphAccessTokenAsync( - tenantId, - Arg.Is>(scopes => scopes.Contains("AgentIdentityBlueprint.ReadWrite.All")), - false, - Arg.Any(), - Arg.Any()) - .Returns("fake-delegated-token"); - - handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.NoContent)); - - // Act - var result = await service.DeleteAgentIdentityAsync(tenantId, identityId); - - // Assert - result.Should().BeTrue(); - - await _mockTokenProvider.Received(1).GetMgGraphAccessTokenAsync( - tenantId, - Arg.Is>(scopes => scopes.Contains("AgentIdentityBlueprint.ReadWrite.All")), - false, - Arg.Any(), - Arg.Any()); + var (service, handler) = CreateServiceWithFakeHandler(); + using (handler) + { + const string tenantId = "12345678-1234-1234-1234-123456789012"; + const string identityId = "identity-sp-id-123"; + + // Override with specific scope assertion + _mockTokenProvider.GetMgGraphAccessTokenAsync( + tenantId, + Arg.Is>(scopes => scopes.Contains("AgentIdentityBlueprint.ReadWrite.All")), + false, + Arg.Any(), + Arg.Any()) + .Returns("fake-delegated-token"); + + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.NoContent)); + + // Act + var result = await service.DeleteAgentIdentityAsync(tenantId, identityId); + + // Assert + result.Should().BeTrue(); + + await _mockTokenProvider.Received(1).GetMgGraphAccessTokenAsync( + tenantId, + Arg.Is>(scopes => scopes.Contains("AgentIdentityBlueprint.ReadWrite.All")), + false, + Arg.Any(), + Arg.Any()); + } } [Fact] public async Task DeleteAgentIdentityAsync_WhenResourceNotFound_ReturnsTrueIdempotent() { // Arrange - var handler = new FakeHttpMessageHandler(); - var graphService = new GraphApiService(_mockGraphLogger, _mockExecutor, handler, _mockTokenProvider); - var service = new AgentBlueprintService(_mockLogger, graphService); - - const string tenantId = "12345678-1234-1234-1234-123456789012"; - const string identityId = "non-existent-identity"; - - _mockTokenProvider.GetMgGraphAccessTokenAsync( - Arg.Any(), - Arg.Any>(), - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns("fake-token"); - - handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.NotFound) + var (service, handler) = CreateServiceWithFakeHandler(); + using (handler) { - Content = new StringContent("{\"error\": {\"code\": \"Request_ResourceNotFound\"}}") - }); + const string tenantId = "12345678-1234-1234-1234-123456789012"; + const string identityId = "non-existent-identity"; - // Act - var result = await service.DeleteAgentIdentityAsync(tenantId, identityId); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("{\"error\": {\"code\": \"Request_ResourceNotFound\"}}") + }); - // Assert - result.Should().BeTrue("404 should be treated as success for idempotent deletion"); + // Act + var result = await service.DeleteAgentIdentityAsync(tenantId, identityId); + + // Assert + result.Should().BeTrue("404 should be treated as success for idempotent deletion"); + } } [Fact] public async Task DeleteAgentIdentityAsync_WhenTokenProviderIsNull_ReturnsFalse() { // Arrange - var handler = new FakeHttpMessageHandler(); + using var handler = new FakeHttpMessageHandler(); var graphService = new GraphApiService(_mockGraphLogger, _mockExecutor, handler, tokenProvider: null); var service = new AgentBlueprintService(_mockLogger, graphService); @@ -256,71 +250,204 @@ public async Task DeleteAgentIdentityAsync_WhenTokenProviderIsNull_ReturnsFalse( public async Task DeleteAgentIdentityAsync_WhenDeletionFails_ReturnsFalse() { // Arrange - var handler = new FakeHttpMessageHandler(); - var graphService = new GraphApiService(_mockGraphLogger, _mockExecutor, handler, _mockTokenProvider); - var service = new AgentBlueprintService(_mockLogger, graphService); + var (service, handler) = CreateServiceWithFakeHandler(); + using (handler) + { + const string tenantId = "12345678-1234-1234-1234-123456789012"; + const string identityId = "identity-123"; - const string tenantId = "12345678-1234-1234-1234-123456789012"; - const string identityId = "identity-123"; + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new StringContent("{\"error\": {\"code\": \"Authorization_RequestDenied\"}}") + }); - _mockTokenProvider.GetMgGraphAccessTokenAsync( - Arg.Any(), - Arg.Any>(), - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns("fake-token"); - - handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Forbidden) + // Act + var result = await service.DeleteAgentIdentityAsync(tenantId, identityId); + + // Assert + result.Should().BeFalse(); + + _mockGraphLogger.Received().Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Graph DELETE") && o.ToString()!.Contains("403")), + Arg.Any(), + Arg.Any>()); + } + } + + [Fact] + public async Task DeleteAgentIdentityAsync_WhenExceptionThrown_ReturnsFalse() + { + // Arrange + var (service, handler) = CreateServiceWithFakeHandler(); + using (handler) { - Content = new StringContent("{\"error\": {\"code\": \"Authorization_RequestDenied\"}}") - }); + const string tenantId = "12345678-1234-1234-1234-123456789012"; + const string identityId = "identity-123"; + + // Override token provider to throw + _mockTokenProvider.GetMgGraphAccessTokenAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromException(new HttpRequestException("Connection timeout"))); + + // Act + var result = await service.DeleteAgentIdentityAsync(tenantId, identityId); + + // Assert + result.Should().BeFalse(); + + _mockLogger.Received().Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Exception deleting agent identity")), + Arg.Any(), + Arg.Any>()); + } + } - // Act - var result = await service.DeleteAgentIdentityAsync(tenantId, identityId); + [Fact] + public async Task GetAgentInstancesForBlueprintAsync_ReturnsFilteredInstances() + { + // Arrange + var (service, handler) = CreateServiceWithFakeHandler(); + using (handler) + { + const string blueprintId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; - // Assert - result.Should().BeFalse(); + // Response 1: GET /beta/servicePrincipals/microsoft.graph.agentIdentity?$filter=agentIdentityBlueprintId eq '...' + // Server-side filtered response returns only matching SPs + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + value = new[] + { + new { id = "sp-obj-1", displayName = "Instance A", agentIdentityBlueprintId = blueprintId } + } + })) + }); - _mockGraphLogger.Received().Log( - LogLevel.Error, - Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Graph DELETE") && o.ToString()!.Contains("403")), - Arg.Any(), - Arg.Any>()); + // Response 2: GET /beta/users/microsoft.graph.agentUser?$filter=agentIdentityBlueprintId eq '...' + // Bulk query returns all agent users for the blueprint; correlated via identityParentId + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + value = new[] { new { id = "user-obj-1", identityParentId = "sp-obj-1" } } + })) + }); + + // Act + var instances = await service.GetAgentInstancesForBlueprintAsync("tenant-id", blueprintId); + + // Assert + instances.Should().HaveCount(1); + instances[0].IdentitySpId.Should().Be("sp-obj-1"); + instances[0].DisplayName.Should().Be("Instance A"); + instances[0].AgentUserId.Should().Be("user-obj-1"); + } } [Fact] - public async Task DeleteAgentIdentityAsync_WhenExceptionThrown_ReturnsFalse() + public async Task GetAgentInstancesForBlueprintAsync_ReturnsEmpty_WhenNoneFound() { // Arrange - var handler = new FakeHttpMessageHandler(); - var graphService = new GraphApiService(_mockGraphLogger, _mockExecutor, handler, _mockTokenProvider); - var service = new AgentBlueprintService(_mockLogger, graphService); + var (service, handler) = CreateServiceWithFakeHandler(); + using (handler) + { + // Response 1: SPs query returns empty + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new { value = Array.Empty() })) + }); - const string tenantId = "12345678-1234-1234-1234-123456789012"; - const string identityId = "identity-123"; + // Response 2: Users query returns empty (both run in parallel) + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new { value = Array.Empty() })) + }); + + // Act + var instances = await service.GetAgentInstancesForBlueprintAsync("tenant-id", "b2c3d4e5-f6a7-8901-bcde-f12345678901"); + + // Assert + instances.Should().BeEmpty(); + } + } + + [Fact] + public async Task GetAgentInstancesForBlueprintAsync_Throws_WhenGraphQueryFails() + { + // Arrange + var (service, _) = CreateServiceWithFakeHandler(); + // Override token provider to throw so the Graph call fails _mockTokenProvider.GetMgGraphAccessTokenAsync( - Arg.Any(), - Arg.Any>(), - Arg.Any(), - Arg.Any(), - Arg.Any()) + Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromException(new HttpRequestException("Connection timeout"))); - // Act - var result = await service.DeleteAgentIdentityAsync(tenantId, identityId); + // Act & Assert - exception must propagate so callers can abort rather than proceeding with 0 instances + await service.Invoking(s => s.GetAgentInstancesForBlueprintAsync("tenant-id", "blueprint-id")) + .Should().ThrowAsync(); + } - // Assert - result.Should().BeFalse(); + [Fact] + public async Task DeleteAgentUserAsync_ReturnsTrue_OnSuccess() + { + // Arrange + var (service, handler) = CreateServiceWithFakeHandler(); + using (handler) + { + // Queue HTTP response for DELETE /beta/agentUsers/{userId} + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.NoContent)); - _mockLogger.Received().Log( - LogLevel.Error, - Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Exception deleting agent identity")), - Arg.Any(), - Arg.Any>()); + // Act + var result = await service.DeleteAgentUserAsync("tenant-id", "user-obj-1"); + + // Assert + result.Should().BeTrue(); + } + } + + [Fact] + public async Task DeleteAgentUserAsync_ReturnsFalse_OnGraphError() + { + // Arrange + var (service, handler) = CreateServiceWithFakeHandler(); + using (handler) + { + // Override token provider to throw + _mockTokenProvider.GetMgGraphAccessTokenAsync( + Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new HttpRequestException("Connection timeout"))); + + // Act + var result = await service.DeleteAgentUserAsync("tenant-id", "user-obj-1"); + + // Assert + result.Should().BeFalse(); + } + } + + private (AgentBlueprintService service, FakeHttpMessageHandler handler) CreateServiceWithFakeHandler() + { + var handler = new FakeHttpMessageHandler(); + var executor = Substitute.For(Substitute.For>()); + executor.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new CommandResult + { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty })); + _mockTokenProvider.GetMgGraphAccessTokenAsync( + Arg.Any(), Arg.Any>(), Arg.Any(), + Arg.Any(), Arg.Any()) + .Returns("test-token"); + var graphService = new GraphApiService(_mockGraphLogger, executor, handler, _mockTokenProvider); + return (new AgentBlueprintService(_mockLogger, graphService), handler); } } @@ -328,15 +455,35 @@ public async Task DeleteAgentIdentityAsync_WhenExceptionThrown_ReturnsFalse() internal class FakeHttpMessageHandler : HttpMessageHandler { private readonly Queue _responses = new(); + private readonly List _sentResponses = new(); public void QueueResponse(HttpResponseMessage resp) => _responses.Enqueue(resp); protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { if (_responses.Count == 0) - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("") }); + { + var fallback = new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("") }; + _sentResponses.Add(fallback); + return Task.FromResult(fallback); + } var resp = _responses.Dequeue(); + _sentResponses.Add(resp); return Task.FromResult(resp); } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + foreach (var resp in _sentResponses) + resp.Dispose(); + _sentResponses.Clear(); + + while (_responses.Count > 0) + _responses.Dequeue().Dispose(); + } + base.Dispose(disposing); + } }