diff --git a/src/PPDS.Auth/Cloud/CloudEndpoints.cs b/src/PPDS.Auth/Cloud/CloudEndpoints.cs index 87f07b4b..aeea195e 100644 --- a/src/PPDS.Auth/Cloud/CloudEndpoints.cs +++ b/src/PPDS.Auth/Cloud/CloudEndpoints.cs @@ -131,6 +131,29 @@ public static string GetPowerAutomateApiUrl(CloudEnvironment cloud) }; } + /// + /// Gets the Power Apps Service scope URL for the specified cloud environment. + /// This is the resource/audience used when acquiring tokens for the Flow API and Connections API. + /// + /// + /// The Flow API (api.flow.microsoft.com) requires tokens with the service.powerapps.com audience, + /// not the api.powerapps.com or api.flow.microsoft.com audiences. + /// + /// The cloud environment. + /// The Power Apps Service scope URL (without /.default suffix). + public static string GetPowerAppsServiceScope(CloudEnvironment cloud) + { + return cloud switch + { + CloudEnvironment.Public => "https://service.powerapps.com", + CloudEnvironment.UsGov => "https://service.powerapps.us", + CloudEnvironment.UsGovHigh => "https://high.service.powerapps.us", + CloudEnvironment.UsGovDod => "https://service.apps.appsplatform.us", + CloudEnvironment.China => "https://service.powerapps.cn", + _ => throw new ArgumentOutOfRangeException(nameof(cloud), cloud, "Unknown cloud environment") + }; + } + /// /// Parses a cloud environment from a string value. /// diff --git a/src/PPDS.Auth/Credentials/IPowerPlatformTokenProvider.cs b/src/PPDS.Auth/Credentials/IPowerPlatformTokenProvider.cs index 51e282a9..a4ecb1b2 100644 --- a/src/PPDS.Auth/Credentials/IPowerPlatformTokenProvider.cs +++ b/src/PPDS.Auth/Credentials/IPowerPlatformTokenProvider.cs @@ -29,6 +29,16 @@ public interface IPowerPlatformTokenProvider : IDisposable /// If authentication fails. Task GetPowerAutomateTokenAsync(CancellationToken cancellationToken = default); + /// + /// Acquires an access token for the Flow API using the correct service.powerapps.com scope. + /// Use this method for Flow API and Connections API operations, which require the + /// service.powerapps.com audience rather than api.flow.microsoft.com. + /// + /// Cancellation token. + /// A valid access token with service.powerapps.com audience. + /// If authentication fails. + Task GetFlowApiTokenAsync(CancellationToken cancellationToken = default); + /// /// Acquires an access token for the specified Power Platform resource. /// diff --git a/src/PPDS.Auth/Credentials/PowerPlatformTokenProvider.cs b/src/PPDS.Auth/Credentials/PowerPlatformTokenProvider.cs index 4786295d..5c12e8d2 100644 --- a/src/PPDS.Auth/Credentials/PowerPlatformTokenProvider.cs +++ b/src/PPDS.Auth/Credentials/PowerPlatformTokenProvider.cs @@ -161,6 +161,15 @@ public Task GetPowerAutomateTokenAsync(CancellationToken can return GetTokenForResourceAsync(resource, cancellationToken); } + /// + public Task GetFlowApiTokenAsync(CancellationToken cancellationToken = default) + { + // The Flow API and Connections API require tokens with the service.powerapps.com scope, + // not the api.flow.microsoft.com scope. This is a Microsoft API design quirk. + var resource = CloudEndpoints.GetPowerAppsServiceScope(_cloud); + return GetTokenForResourceAsync(resource, cancellationToken); + } + /// public async Task GetTokenForResourceAsync(string resource, CancellationToken cancellationToken = default) { diff --git a/src/PPDS.Cli/CHANGELOG.md b/src/PPDS.Cli/CHANGELOG.md index afe6c3c6..a272066e 100644 --- a/src/PPDS.Cli/CHANGELOG.md +++ b/src/PPDS.Cli/CHANGELOG.md @@ -21,6 +21,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Session-scoped connection pooling for faster subsequent queries - Graceful degradation when not in a TTY environment - **Version header at startup** - CLI now outputs diagnostic header to stderr: version info (CLI, SDK, .NET runtime) and platform. Enables correlating issues to specific builds. Skipped for `--help`, `--version`, or no arguments. See [ADR-0022](../../docs/adr/0022_IMPORT_DIAGNOSTICS_ARCHITECTURE.md). +- **`ppds flows` command group** - Manage cloud flows ([#142](https://github.com/joshsmithxrm/ppds-sdk/issues/142)): + - `ppds flows list` - List cloud flows (supports `--solution`, `--state`) + - `ppds flows get ` - Get flow details by unique name + - `ppds flows url ` - Get Power Automate maker URL for a flow +- **`ppds connections` command group** - List Power Platform connections ([#144](https://github.com/joshsmithxrm/ppds-sdk/issues/144)): + - `ppds connections list` - List connections from Power Apps Admin API (supports `--connector`) + - `ppds connections get ` - Get connection details by ID +- **`ppds connectionreferences` command group** - Manage connection references with orphan detection ([#143](https://github.com/joshsmithxrm/ppds-sdk/issues/143)): + - `ppds connectionreferences list` - List connection references (supports `--solution`, `--orphaned`) + - `ppds connectionreferences get ` - Get connection reference details by logical name + - `ppds connectionreferences flows ` - List flows using a connection reference + - `ppds connectionreferences connections ` - Show bound connection details + - `ppds connectionreferences analyze` - Analyze flow-to-connection-reference relationships with orphan detection +- **`ppds deployment-settings` command group** - Generate and sync deployment settings files ([#145](https://github.com/joshsmithxrm/ppds-sdk/issues/145)): + - `ppds deployment-settings generate` - Generate deployment settings file from current environment + - `ppds deployment-settings sync` - Sync existing file with solution (preserves values, adds new entries, removes stale) + - `ppds deployment-settings validate` - Validate deployment settings file against solution - **`ppds solutions` command group** - Manage Power Platform solutions ([#137](https://github.com/joshsmithxrm/ppds-sdk/issues/137)): - `ppds solutions list` - List solutions in environment (supports `--include-managed`, `--filter`) - `ppds solutions get ` - Get solution details by unique name diff --git a/src/PPDS.Cli/Commands/ConnectionReferences/AnalyzeCommand.cs b/src/PPDS.Cli/Commands/ConnectionReferences/AnalyzeCommand.cs new file mode 100644 index 00000000..07c53d33 --- /dev/null +++ b/src/PPDS.Cli/Commands/ConnectionReferences/AnalyzeCommand.cs @@ -0,0 +1,241 @@ +using System.CommandLine; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Infrastructure.Errors; +using PPDS.Cli.Infrastructure.Output; +using PPDS.Dataverse.Services; + +namespace PPDS.Cli.Commands.ConnectionReferences; + +/// +/// Analyze flow-connection reference relationships and detect orphans. +/// +public static class AnalyzeCommand +{ + public static Command Create() + { + var orphansOnlyOption = new Option("--orphans-only") + { + Description = "Only show orphaned relationships" + }; + + var command = new Command("analyze", "Analyze flow-connection reference relationships (orphan detection)") + { + ConnectionReferencesCommandGroup.SolutionOption, + orphansOnlyOption, + ConnectionReferencesCommandGroup.ProfileOption, + ConnectionReferencesCommandGroup.EnvironmentOption + }; + + GlobalOptions.AddToCommand(command); + + command.SetAction(async (parseResult, cancellationToken) => + { + var solution = parseResult.GetValue(ConnectionReferencesCommandGroup.SolutionOption); + var orphansOnly = parseResult.GetValue(orphansOnlyOption); + var profile = parseResult.GetValue(ConnectionReferencesCommandGroup.ProfileOption); + var environment = parseResult.GetValue(ConnectionReferencesCommandGroup.EnvironmentOption); + var globalOptions = GlobalOptions.GetValues(parseResult); + + return await ExecuteAsync(solution, orphansOnly, profile, environment, globalOptions, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + string? solution, + bool orphansOnly, + string? profile, + string? environment, + GlobalOptionValues globalOptions, + CancellationToken cancellationToken) + { + var writer = ServiceFactory.CreateOutputWriter(globalOptions); + + try + { + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + globalOptions.Verbose, + globalOptions.Debug, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var crService = serviceProvider.GetRequiredService(); + + if (!globalOptions.IsJsonMode) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.Error.WriteLine(); + } + + var analysis = await crService.AnalyzeAsync(solution, cancellationToken); + + var relationships = orphansOnly + ? analysis.Relationships.Where(r => r.Type != RelationshipType.FlowToConnectionReference).ToList() + : analysis.Relationships; + + if (globalOptions.IsJsonMode) + { + var output = new AnalysisOutput + { + Summary = new AnalysisSummary + { + ValidCount = analysis.ValidCount, + OrphanedFlowCount = analysis.OrphanedFlowCount, + OrphanedConnectionReferenceCount = analysis.OrphanedConnectionReferenceCount, + HasOrphans = analysis.HasOrphans + }, + Relationships = relationships.Select(r => new RelationshipOutput + { + Type = r.Type.ToString(), + FlowUniqueName = r.FlowUniqueName, + FlowDisplayName = r.FlowDisplayName, + ConnectionReferenceLogicalName = r.ConnectionReferenceLogicalName, + ConnectionReferenceDisplayName = r.ConnectionReferenceDisplayName, + ConnectorId = r.ConnectorId, + IsBound = r.IsBound + }).ToList() + }; + + writer.WriteSuccess(output); + } + else + { + Console.Error.WriteLine("Flow-Connection Reference Analysis"); + Console.Error.WriteLine("==================================="); + Console.Error.WriteLine(); + Console.Error.WriteLine($" Valid relationships: {analysis.ValidCount}"); + Console.Error.WriteLine($" Orphaned flows (missing CRs): {analysis.OrphanedFlowCount}"); + Console.Error.WriteLine($" Orphaned connection references (unused): {analysis.OrphanedConnectionReferenceCount}"); + Console.Error.WriteLine(); + + if (!analysis.HasOrphans && orphansOnly) + { + Console.Error.WriteLine("No orphans detected."); + return ExitCodes.Success; + } + + if (relationships.Count == 0) + { + Console.Error.WriteLine("No relationships found."); + return ExitCodes.Success; + } + + // Group by type for cleaner output + var orphanedFlows = relationships.Where(r => r.Type == RelationshipType.OrphanedFlow).ToList(); + var orphanedCRs = relationships.Where(r => r.Type == RelationshipType.OrphanedConnectionReference).ToList(); + var validRelationships = relationships.Where(r => r.Type == RelationshipType.FlowToConnectionReference).ToList(); + + if (orphanedFlows.Count > 0) + { + Console.Error.WriteLine(); + Console.Error.WriteLine("ORPHANED FLOWS (referencing missing connection references):"); + Console.Error.WriteLine("------------------------------------------------------------"); + foreach (var r in orphanedFlows) + { + Console.Error.WriteLine($" Flow: {r.FlowDisplayName ?? r.FlowUniqueName}"); + Console.Error.WriteLine($" References missing CR: {r.ConnectionReferenceLogicalName}"); + Console.Error.WriteLine(); + } + } + + if (orphanedCRs.Count > 0) + { + Console.Error.WriteLine(); + Console.Error.WriteLine("ORPHANED CONNECTION REFERENCES (not used by any flow):"); + Console.Error.WriteLine("------------------------------------------------------"); + foreach (var r in orphanedCRs) + { + var boundStatus = r.IsBound == true ? "Bound" : "Unbound"; + Console.Error.WriteLine($" {r.ConnectionReferenceDisplayName ?? r.ConnectionReferenceLogicalName}"); + Console.Error.WriteLine($" Name: {r.ConnectionReferenceLogicalName} Status: {boundStatus}"); + if (r.ConnectorId != null) + { + Console.Error.WriteLine($" Connector: {r.ConnectorId}"); + } + Console.Error.WriteLine(); + } + } + + if (!orphansOnly && validRelationships.Count > 0) + { + Console.Error.WriteLine(); + Console.Error.WriteLine("VALID RELATIONSHIPS:"); + Console.Error.WriteLine("--------------------"); + foreach (var r in validRelationships) + { + var boundStatus = r.IsBound == true ? "Bound" : "Unbound"; + Console.Error.WriteLine($" Flow: {r.FlowDisplayName ?? r.FlowUniqueName}"); + Console.Error.WriteLine($" -> CR: {r.ConnectionReferenceLogicalName} ({boundStatus})"); + Console.Error.WriteLine(); + } + } + } + + return ExitCodes.Success; + } + catch (Exception ex) + { + var error = ExceptionMapper.Map(ex, context: "analyzing connection references", debug: globalOptions.Debug); + writer.WriteError(error); + return ExceptionMapper.ToExitCode(ex); + } + } + + #region Output Models + + private sealed class AnalysisOutput + { + [JsonPropertyName("summary")] + public AnalysisSummary Summary { get; set; } = new(); + + [JsonPropertyName("relationships")] + public List Relationships { get; set; } = new(); + } + + private sealed class AnalysisSummary + { + [JsonPropertyName("validCount")] + public int ValidCount { get; set; } + + [JsonPropertyName("orphanedFlowCount")] + public int OrphanedFlowCount { get; set; } + + [JsonPropertyName("orphanedConnectionReferenceCount")] + public int OrphanedConnectionReferenceCount { get; set; } + + [JsonPropertyName("hasOrphans")] + public bool HasOrphans { get; set; } + } + + private sealed class RelationshipOutput + { + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("flowUniqueName")] + public string? FlowUniqueName { get; set; } + + [JsonPropertyName("flowDisplayName")] + public string? FlowDisplayName { get; set; } + + [JsonPropertyName("connectionReferenceLogicalName")] + public string? ConnectionReferenceLogicalName { get; set; } + + [JsonPropertyName("connectionReferenceDisplayName")] + public string? ConnectionReferenceDisplayName { get; set; } + + [JsonPropertyName("connectorId")] + public string? ConnectorId { get; set; } + + [JsonPropertyName("isBound")] + public bool? IsBound { get; set; } + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/ConnectionReferences/ConnectionReferencesCommandGroup.cs b/src/PPDS.Cli/Commands/ConnectionReferences/ConnectionReferencesCommandGroup.cs new file mode 100644 index 00000000..7cae3f50 --- /dev/null +++ b/src/PPDS.Cli/Commands/ConnectionReferences/ConnectionReferencesCommandGroup.cs @@ -0,0 +1,43 @@ +using System.CommandLine; + +namespace PPDS.Cli.Commands.ConnectionReferences; + +/// +/// Command group for connection reference operations. +/// +public static class ConnectionReferencesCommandGroup +{ + /// Shared profile option. + public static readonly Option ProfileOption = new("--profile", "-p") + { + Description = "Authentication profile name" + }; + + /// Shared environment option. + public static readonly Option EnvironmentOption = new("--environment", "-e") + { + Description = "Environment URL override" + }; + + /// Shared solution filter option. + public static readonly Option SolutionOption = new("--solution", "-s") + { + Description = "Filter by solution unique name" + }; + + /// + /// Creates the connectionreferences command group. + /// + public static Command Create() + { + var command = new Command("connectionreferences", "Manage connection references"); + + command.Subcommands.Add(ListCommand.Create()); + command.Subcommands.Add(GetCommand.Create()); + command.Subcommands.Add(FlowsCommand.Create()); + command.Subcommands.Add(ConnectionsCommand.Create()); + command.Subcommands.Add(AnalyzeCommand.Create()); + + return command; + } +} diff --git a/src/PPDS.Cli/Commands/ConnectionReferences/ConnectionsCommand.cs b/src/PPDS.Cli/Commands/ConnectionReferences/ConnectionsCommand.cs new file mode 100644 index 00000000..9ac79730 --- /dev/null +++ b/src/PPDS.Cli/Commands/ConnectionReferences/ConnectionsCommand.cs @@ -0,0 +1,243 @@ +using System.CommandLine; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Infrastructure.Errors; +using PPDS.Cli.Infrastructure.Output; +using PPDS.Cli.Services; +using PPDS.Dataverse.Services; + +namespace PPDS.Cli.Commands.ConnectionReferences; + +/// +/// Show bound connection details for a connection reference. +/// +public static class ConnectionsCommand +{ + public static Command Create() + { + var nameArgument = new Argument("name") + { + Description = "The logical name of the connection reference" + }; + + var command = new Command("connections", "Show bound connection for a connection reference") + { + nameArgument, + ConnectionReferencesCommandGroup.ProfileOption, + ConnectionReferencesCommandGroup.EnvironmentOption + }; + + GlobalOptions.AddToCommand(command); + + command.SetAction(async (parseResult, cancellationToken) => + { + var name = parseResult.GetValue(nameArgument)!; + var profile = parseResult.GetValue(ConnectionReferencesCommandGroup.ProfileOption); + var environment = parseResult.GetValue(ConnectionReferencesCommandGroup.EnvironmentOption); + var globalOptions = GlobalOptions.GetValues(parseResult); + + return await ExecuteAsync(name, profile, environment, globalOptions, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + string name, + string? profile, + string? environment, + GlobalOptionValues globalOptions, + CancellationToken cancellationToken) + { + var writer = ServiceFactory.CreateOutputWriter(globalOptions); + + try + { + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + globalOptions.Verbose, + globalOptions.Debug, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var crService = serviceProvider.GetRequiredService(); + + if (!globalOptions.IsJsonMode) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.Error.WriteLine(); + } + + var cr = await crService.GetAsync(name, cancellationToken); + + if (cr == null) + { + writer.WriteError(new StructuredError( + ErrorCodes.Operation.NotFound, + $"Connection reference '{name}' not found")); + return ExitCodes.NotFoundError; + } + + if (!cr.IsBound) + { + if (globalOptions.IsJsonMode) + { + var output = new ConnectionBindingOutput + { + ConnectionReference = new ConnectionRefInfo + { + LogicalName = cr.LogicalName, + DisplayName = cr.DisplayName, + ConnectorId = cr.ConnectorId + }, + IsBound = false, + Connection = null + }; + writer.WriteSuccess(output); + } + else + { + Console.Error.WriteLine($"Connection Reference: {cr.DisplayName ?? cr.LogicalName}"); + Console.Error.WriteLine(); + Console.Error.WriteLine("This connection reference is not bound to a connection."); + Console.Error.WriteLine("Use the Power Apps portal or pac solution commands to bind a connection."); + } + return ExitCodes.Success; + } + + // Try to get connection details from Power Apps API + ConnectionInfo? connection = null; + try + { + var connectionService = serviceProvider.GetService(); + if (connectionService != null && cr.ConnectionId != null) + { + connection = await connectionService.GetAsync(cr.ConnectionId, cancellationToken); + } + } + catch (Exception ex) + { + // Connection service may not be available or may fail (e.g., SPN auth) + if (!globalOptions.IsJsonMode) + { + Console.Error.WriteLine($"Warning: Could not retrieve connection details: {ex.Message}"); + Console.Error.WriteLine(); + } + } + + if (globalOptions.IsJsonMode) + { + var output = new ConnectionBindingOutput + { + ConnectionReference = new ConnectionRefInfo + { + LogicalName = cr.LogicalName, + DisplayName = cr.DisplayName, + ConnectorId = cr.ConnectorId + }, + IsBound = true, + Connection = connection != null ? new ConnectionDetails + { + ConnectionId = connection.ConnectionId, + DisplayName = connection.DisplayName, + ConnectorDisplayName = connection.ConnectorDisplayName, + Status = connection.Status.ToString(), + CreatedBy = connection.CreatedBy + } : new ConnectionDetails + { + ConnectionId = cr.ConnectionId!, + DisplayName = null, + ConnectorDisplayName = null, + Status = "Unknown", + CreatedBy = null + } + }; + + writer.WriteSuccess(output); + } + else + { + Console.WriteLine($"Connection Reference: {cr.DisplayName ?? cr.LogicalName}"); + Console.WriteLine($" Logical Name: {cr.LogicalName}"); + Console.WriteLine($" Connector ID: {cr.ConnectorId}"); + Console.WriteLine(); + Console.WriteLine("Bound Connection:"); + Console.WriteLine($" Connection ID: {cr.ConnectionId}"); + + if (connection != null) + { + if (connection.DisplayName != null) + { + Console.WriteLine($" Display Name: {connection.DisplayName}"); + } + if (connection.ConnectorDisplayName != null) + { + Console.WriteLine($" Connector: {connection.ConnectorDisplayName}"); + } + Console.WriteLine($" Status: {connection.Status}"); + if (connection.CreatedBy != null) + { + Console.WriteLine($" Created by: {connection.CreatedBy}"); + } + } + } + + return ExitCodes.Success; + } + catch (Exception ex) + { + var error = ExceptionMapper.Map(ex, context: "getting connection binding", debug: globalOptions.Debug); + writer.WriteError(error); + return ExceptionMapper.ToExitCode(ex); + } + } + + #region Output Models + + private sealed class ConnectionBindingOutput + { + [JsonPropertyName("connectionReference")] + public ConnectionRefInfo ConnectionReference { get; set; } = new(); + + [JsonPropertyName("isBound")] + public bool IsBound { get; set; } + + [JsonPropertyName("connection")] + public ConnectionDetails? Connection { get; set; } + } + + private sealed class ConnectionRefInfo + { + [JsonPropertyName("logicalName")] + public string LogicalName { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("connectorId")] + public string? ConnectorId { get; set; } + } + + private sealed class ConnectionDetails + { + [JsonPropertyName("connectionId")] + public string ConnectionId { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("connectorDisplayName")] + public string? ConnectorDisplayName { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("createdBy")] + public string? CreatedBy { get; set; } + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/ConnectionReferences/FlowsCommand.cs b/src/PPDS.Cli/Commands/ConnectionReferences/FlowsCommand.cs new file mode 100644 index 00000000..ac1be276 --- /dev/null +++ b/src/PPDS.Cli/Commands/ConnectionReferences/FlowsCommand.cs @@ -0,0 +1,181 @@ +using System.CommandLine; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Infrastructure.Errors; +using PPDS.Cli.Infrastructure.Output; +using PPDS.Dataverse.Services; + +namespace PPDS.Cli.Commands.ConnectionReferences; + +/// +/// List flows using a specific connection reference. +/// +public static class FlowsCommand +{ + public static Command Create() + { + var nameArgument = new Argument("name") + { + Description = "The logical name of the connection reference" + }; + + var command = new Command("flows", "List flows that use a connection reference") + { + nameArgument, + ConnectionReferencesCommandGroup.ProfileOption, + ConnectionReferencesCommandGroup.EnvironmentOption + }; + + GlobalOptions.AddToCommand(command); + + command.SetAction(async (parseResult, cancellationToken) => + { + var name = parseResult.GetValue(nameArgument)!; + var profile = parseResult.GetValue(ConnectionReferencesCommandGroup.ProfileOption); + var environment = parseResult.GetValue(ConnectionReferencesCommandGroup.EnvironmentOption); + var globalOptions = GlobalOptions.GetValues(parseResult); + + return await ExecuteAsync(name, profile, environment, globalOptions, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + string name, + string? profile, + string? environment, + GlobalOptionValues globalOptions, + CancellationToken cancellationToken) + { + var writer = ServiceFactory.CreateOutputWriter(globalOptions); + + try + { + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + globalOptions.Verbose, + globalOptions.Debug, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var crService = serviceProvider.GetRequiredService(); + + if (!globalOptions.IsJsonMode) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.Error.WriteLine(); + } + + // First verify the CR exists + var cr = await crService.GetAsync(name, cancellationToken); + if (cr == null) + { + writer.WriteError(new StructuredError( + ErrorCodes.Operation.NotFound, + $"Connection reference '{name}' not found")); + return ExitCodes.NotFoundError; + } + + var flows = await crService.GetFlowsUsingAsync(name, cancellationToken); + + if (globalOptions.IsJsonMode) + { + var output = new FlowsUsingCrOutput + { + ConnectionReference = new ConnectionRefSummary + { + LogicalName = cr.LogicalName, + DisplayName = cr.DisplayName, + IsBound = cr.IsBound + }, + Flows = flows.Select(f => new FlowSummary + { + UniqueName = f.UniqueName, + DisplayName = f.DisplayName, + State = f.State.ToString() + }).ToList() + }; + + writer.WriteSuccess(output); + } + else + { + Console.Error.WriteLine($"Connection Reference: {cr.DisplayName ?? cr.LogicalName}"); + Console.Error.WriteLine(); + + if (flows.Count == 0) + { + Console.Error.WriteLine("No flows use this connection reference."); + } + else + { + Console.Error.WriteLine($"Flows using this connection reference ({flows.Count}):"); + Console.Error.WriteLine(); + + foreach (var f in flows) + { + var stateDisplay = f.State switch + { + FlowState.Activated => "On", + FlowState.Suspended => "Suspended", + _ => "Off" + }; + + Console.WriteLine($" {f.DisplayName ?? f.UniqueName}"); + Console.WriteLine($" Name: {f.UniqueName} State: {stateDisplay}"); + Console.WriteLine(); + } + } + } + + return ExitCodes.Success; + } + catch (Exception ex) + { + var error = ExceptionMapper.Map(ex, context: "getting flows for connection reference", debug: globalOptions.Debug); + writer.WriteError(error); + return ExceptionMapper.ToExitCode(ex); + } + } + + #region Output Models + + private sealed class FlowsUsingCrOutput + { + [JsonPropertyName("connectionReference")] + public ConnectionRefSummary ConnectionReference { get; set; } = new(); + + [JsonPropertyName("flows")] + public List Flows { get; set; } = new(); + } + + private sealed class ConnectionRefSummary + { + [JsonPropertyName("logicalName")] + public string LogicalName { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("isBound")] + public bool IsBound { get; set; } + } + + private sealed class FlowSummary + { + [JsonPropertyName("uniqueName")] + public string UniqueName { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("state")] + public string State { get; set; } = string.Empty; + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/ConnectionReferences/GetCommand.cs b/src/PPDS.Cli/Commands/ConnectionReferences/GetCommand.cs new file mode 100644 index 00000000..bcef09ee --- /dev/null +++ b/src/PPDS.Cli/Commands/ConnectionReferences/GetCommand.cs @@ -0,0 +1,176 @@ +using System.CommandLine; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Infrastructure.Errors; +using PPDS.Cli.Infrastructure.Output; +using PPDS.Dataverse.Services; + +namespace PPDS.Cli.Commands.ConnectionReferences; + +/// +/// Get a specific connection reference by logical name. +/// +public static class GetCommand +{ + public static Command Create() + { + var nameArgument = new Argument("name") + { + Description = "The logical name of the connection reference" + }; + + var command = new Command("get", "Get a connection reference by logical name") + { + nameArgument, + ConnectionReferencesCommandGroup.ProfileOption, + ConnectionReferencesCommandGroup.EnvironmentOption + }; + + GlobalOptions.AddToCommand(command); + + command.SetAction(async (parseResult, cancellationToken) => + { + var name = parseResult.GetValue(nameArgument)!; + var profile = parseResult.GetValue(ConnectionReferencesCommandGroup.ProfileOption); + var environment = parseResult.GetValue(ConnectionReferencesCommandGroup.EnvironmentOption); + var globalOptions = GlobalOptions.GetValues(parseResult); + + return await ExecuteAsync(name, profile, environment, globalOptions, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + string name, + string? profile, + string? environment, + GlobalOptionValues globalOptions, + CancellationToken cancellationToken) + { + var writer = ServiceFactory.CreateOutputWriter(globalOptions); + + try + { + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + globalOptions.Verbose, + globalOptions.Debug, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var crService = serviceProvider.GetRequiredService(); + + if (!globalOptions.IsJsonMode) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.Error.WriteLine(); + } + + var cr = await crService.GetAsync(name, cancellationToken); + + if (cr == null) + { + writer.WriteError(new StructuredError( + ErrorCodes.Operation.NotFound, + $"Connection reference '{name}' not found")); + return ExitCodes.NotFoundError; + } + + if (globalOptions.IsJsonMode) + { + var output = new ConnectionReferenceDetail + { + Id = cr.Id, + LogicalName = cr.LogicalName, + DisplayName = cr.DisplayName, + Description = cr.Description, + ConnectorId = cr.ConnectorId, + ConnectionId = cr.ConnectionId, + IsBound = cr.IsBound, + IsManaged = cr.IsManaged, + CreatedOn = cr.CreatedOn, + ModifiedOn = cr.ModifiedOn + }; + + writer.WriteSuccess(output); + } + else + { + Console.WriteLine($"Connection Reference: {cr.DisplayName ?? cr.LogicalName}"); + Console.WriteLine($" Logical Name: {cr.LogicalName}"); + Console.WriteLine($" ID: {cr.Id}"); + Console.WriteLine($" Status: {(cr.IsBound ? "Bound" : "Unbound")}"); + Console.WriteLine($" Managed: {(cr.IsManaged ? "Yes" : "No")}"); + + if (!string.IsNullOrEmpty(cr.Description)) + { + Console.WriteLine($" Description: {cr.Description}"); + } + + if (cr.ConnectorId != null) + { + Console.WriteLine(); + Console.WriteLine($" Connector ID: {cr.ConnectorId}"); + } + + if (cr.ConnectionId != null) + { + Console.WriteLine($" Connection ID: {cr.ConnectionId}"); + } + + Console.WriteLine(); + Console.WriteLine($" Created: {cr.CreatedOn}"); + Console.WriteLine($" Modified: {cr.ModifiedOn}"); + } + + return ExitCodes.Success; + } + catch (Exception ex) + { + var error = ExceptionMapper.Map(ex, context: "getting connection reference", debug: globalOptions.Debug); + writer.WriteError(error); + return ExceptionMapper.ToExitCode(ex); + } + } + + #region Output Models + + private sealed class ConnectionReferenceDetail + { + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("logicalName")] + public string LogicalName { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("connectorId")] + public string? ConnectorId { get; set; } + + [JsonPropertyName("connectionId")] + public string? ConnectionId { get; set; } + + [JsonPropertyName("isBound")] + public bool IsBound { get; set; } + + [JsonPropertyName("isManaged")] + public bool IsManaged { get; set; } + + [JsonPropertyName("createdOn")] + public DateTime? CreatedOn { get; set; } + + [JsonPropertyName("modifiedOn")] + public DateTime? ModifiedOn { get; set; } + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/ConnectionReferences/ListCommand.cs b/src/PPDS.Cli/Commands/ConnectionReferences/ListCommand.cs new file mode 100644 index 00000000..0d63eebb --- /dev/null +++ b/src/PPDS.Cli/Commands/ConnectionReferences/ListCommand.cs @@ -0,0 +1,164 @@ +using System.CommandLine; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Infrastructure.Errors; +using PPDS.Cli.Infrastructure.Output; +using PPDS.Dataverse.Services; + +namespace PPDS.Cli.Commands.ConnectionReferences; + +/// +/// List connection references. +/// +public static class ListCommand +{ + public static Command Create() + { + var unboundOption = new Option("--unbound") + { + Description = "Only show connection references without a bound connection" + }; + + var command = new Command("list", "List connection references") + { + ConnectionReferencesCommandGroup.SolutionOption, + unboundOption, + ConnectionReferencesCommandGroup.ProfileOption, + ConnectionReferencesCommandGroup.EnvironmentOption + }; + + GlobalOptions.AddToCommand(command); + + command.SetAction(async (parseResult, cancellationToken) => + { + var solution = parseResult.GetValue(ConnectionReferencesCommandGroup.SolutionOption); + var unboundOnly = parseResult.GetValue(unboundOption); + var profile = parseResult.GetValue(ConnectionReferencesCommandGroup.ProfileOption); + var environment = parseResult.GetValue(ConnectionReferencesCommandGroup.EnvironmentOption); + var globalOptions = GlobalOptions.GetValues(parseResult); + + return await ExecuteAsync(solution, unboundOnly, profile, environment, globalOptions, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + string? solution, + bool unboundOnly, + string? profile, + string? environment, + GlobalOptionValues globalOptions, + CancellationToken cancellationToken) + { + var writer = ServiceFactory.CreateOutputWriter(globalOptions); + + try + { + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + globalOptions.Verbose, + globalOptions.Debug, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var crService = serviceProvider.GetRequiredService(); + + if (!globalOptions.IsJsonMode) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.Error.WriteLine(); + } + + var connectionRefs = await crService.ListAsync(solution, unboundOnly, cancellationToken); + + if (globalOptions.IsJsonMode) + { + var output = connectionRefs.Select(cr => new ConnectionReferenceListItem + { + Id = cr.Id, + LogicalName = cr.LogicalName, + DisplayName = cr.DisplayName, + ConnectorId = cr.ConnectorId, + ConnectionId = cr.ConnectionId, + IsBound = cr.IsBound, + IsManaged = cr.IsManaged, + ModifiedOn = cr.ModifiedOn + }).ToList(); + + writer.WriteSuccess(output); + } + else + { + if (connectionRefs.Count == 0) + { + Console.Error.WriteLine("No connection references found."); + } + else + { + Console.Error.WriteLine($"Found {connectionRefs.Count} connection reference(s):"); + Console.Error.WriteLine(); + + foreach (var cr in connectionRefs) + { + var boundStatus = cr.IsBound ? "Bound" : "Unbound"; + + Console.WriteLine($" {cr.DisplayName ?? cr.LogicalName}"); + Console.WriteLine($" Name: {cr.LogicalName} Status: {boundStatus}"); + if (cr.ConnectorId != null) + { + Console.WriteLine($" Connector: {cr.ConnectorId}"); + } + if (cr.IsManaged) + { + Console.WriteLine($" Managed: Yes"); + } + Console.WriteLine(); + } + } + } + + return ExitCodes.Success; + } + catch (Exception ex) + { + var error = ExceptionMapper.Map(ex, context: "listing connection references", debug: globalOptions.Debug); + writer.WriteError(error); + return ExceptionMapper.ToExitCode(ex); + } + } + + #region Output Models + + private sealed class ConnectionReferenceListItem + { + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("logicalName")] + public string LogicalName { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("connectorId")] + public string? ConnectorId { get; set; } + + [JsonPropertyName("connectionId")] + public string? ConnectionId { get; set; } + + [JsonPropertyName("isBound")] + public bool IsBound { get; set; } + + [JsonPropertyName("isManaged")] + public bool IsManaged { get; set; } + + [JsonPropertyName("modifiedOn")] + public DateTime? ModifiedOn { get; set; } + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/Connections/ConnectionsCommandGroup.cs b/src/PPDS.Cli/Commands/Connections/ConnectionsCommandGroup.cs new file mode 100644 index 00000000..d6e43925 --- /dev/null +++ b/src/PPDS.Cli/Commands/Connections/ConnectionsCommandGroup.cs @@ -0,0 +1,38 @@ +using System.CommandLine; + +namespace PPDS.Cli.Commands.Connections; + +/// +/// Command group for Power Platform connection operations. +/// +/// +/// Connections are managed through the Power Apps Admin API, not Dataverse. +/// This command group queries the Power Apps API to list and get connection details. +/// +public static class ConnectionsCommandGroup +{ + /// Shared profile option. + public static readonly Option ProfileOption = new("--profile", "-p") + { + Description = "Authentication profile name" + }; + + /// Shared environment option. + public static readonly Option EnvironmentOption = new("--environment", "-e") + { + Description = "Environment URL override" + }; + + /// + /// Creates the connections command group. + /// + public static Command Create() + { + var command = new Command("connections", "Manage Power Platform connections (Power Apps Admin API)"); + + command.Subcommands.Add(ListCommand.Create()); + command.Subcommands.Add(GetCommand.Create()); + + return command; + } +} diff --git a/src/PPDS.Cli/Commands/Connections/GetCommand.cs b/src/PPDS.Cli/Commands/Connections/GetCommand.cs new file mode 100644 index 00000000..a22ad0bb --- /dev/null +++ b/src/PPDS.Cli/Commands/Connections/GetCommand.cs @@ -0,0 +1,178 @@ +using System.CommandLine; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Infrastructure.Errors; +using PPDS.Cli.Infrastructure.Output; +using PPDS.Cli.Services; + +namespace PPDS.Cli.Commands.Connections; + +/// +/// Get a specific Power Platform connection by ID. +/// +public static class GetCommand +{ + public static Command Create() + { + var idArgument = new Argument("id") + { + Description = "The connection ID" + }; + + var command = new Command("get", "Get a connection by ID") + { + idArgument, + ConnectionsCommandGroup.ProfileOption, + ConnectionsCommandGroup.EnvironmentOption + }; + + GlobalOptions.AddToCommand(command); + + command.SetAction(async (parseResult, cancellationToken) => + { + var id = parseResult.GetValue(idArgument)!; + var profile = parseResult.GetValue(ConnectionsCommandGroup.ProfileOption); + var environment = parseResult.GetValue(ConnectionsCommandGroup.EnvironmentOption); + var globalOptions = GlobalOptions.GetValues(parseResult); + + return await ExecuteAsync(id, profile, environment, globalOptions, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + string id, + string? profile, + string? environment, + GlobalOptionValues globalOptions, + CancellationToken cancellationToken) + { + var writer = ServiceFactory.CreateOutputWriter(globalOptions); + + try + { + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + globalOptions.Verbose, + globalOptions.Debug, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var connectionService = serviceProvider.GetRequiredService(); + + if (!globalOptions.IsJsonMode) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.Error.WriteLine(); + } + + var connection = await connectionService.GetAsync(id, cancellationToken); + + if (connection == null) + { + writer.WriteError(new StructuredError( + ErrorCodes.Operation.NotFound, + $"Connection '{id}' not found")); + return ExitCodes.NotFoundError; + } + + if (globalOptions.IsJsonMode) + { + var output = new ConnectionDetail + { + ConnectionId = connection.ConnectionId, + DisplayName = connection.DisplayName, + ConnectorId = connection.ConnectorId, + ConnectorDisplayName = connection.ConnectorDisplayName, + EnvironmentId = connection.EnvironmentId, + Status = connection.Status.ToString(), + IsShared = connection.IsShared, + CreatedBy = connection.CreatedBy, + CreatedOn = connection.CreatedOn, + ModifiedOn = connection.ModifiedOn + }; + + writer.WriteSuccess(output); + } + else + { + Console.WriteLine($"Connection: {connection.DisplayName ?? connection.ConnectionId}"); + Console.WriteLine($" ID: {connection.ConnectionId}"); + Console.WriteLine($" Connector: {connection.ConnectorDisplayName ?? connection.ConnectorId}"); + Console.WriteLine($" Connector ID: {connection.ConnectorId}"); + Console.WriteLine($" Status: {connection.Status}"); + Console.WriteLine($" Shared: {(connection.IsShared ? "Yes" : "No")}"); + + if (connection.EnvironmentId != null) + { + Console.WriteLine($" Environment: {connection.EnvironmentId}"); + } + + if (connection.CreatedBy != null) + { + Console.WriteLine($" Created by: {connection.CreatedBy}"); + } + + Console.WriteLine(); + Console.WriteLine($" Created: {connection.CreatedOn}"); + Console.WriteLine($" Modified: {connection.ModifiedOn}"); + } + + return ExitCodes.Success; + } + catch (InvalidOperationException ex) when (ex.Message.Contains("Service principals")) + { + writer.WriteError(new StructuredError( + ErrorCodes.Auth.InsufficientPermissions, + ex.Message)); + return ExitCodes.AuthError; + } + catch (Exception ex) + { + var error = ExceptionMapper.Map(ex, context: "getting connection", debug: globalOptions.Debug); + writer.WriteError(error); + return ExceptionMapper.ToExitCode(ex); + } + } + + #region Output Models + + private sealed class ConnectionDetail + { + [JsonPropertyName("connectionId")] + public string ConnectionId { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("connectorId")] + public string ConnectorId { get; set; } = string.Empty; + + [JsonPropertyName("connectorDisplayName")] + public string? ConnectorDisplayName { get; set; } + + [JsonPropertyName("environmentId")] + public string? EnvironmentId { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("isShared")] + public bool IsShared { get; set; } + + [JsonPropertyName("createdBy")] + public string? CreatedBy { get; set; } + + [JsonPropertyName("createdOn")] + public DateTime? CreatedOn { get; set; } + + [JsonPropertyName("modifiedOn")] + public DateTime? ModifiedOn { get; set; } + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/Connections/ListCommand.cs b/src/PPDS.Cli/Commands/Connections/ListCommand.cs new file mode 100644 index 00000000..fce1c166 --- /dev/null +++ b/src/PPDS.Cli/Commands/Connections/ListCommand.cs @@ -0,0 +1,172 @@ +using System.CommandLine; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Infrastructure.Errors; +using PPDS.Cli.Infrastructure.Output; +using PPDS.Cli.Services; + +namespace PPDS.Cli.Commands.Connections; + +/// +/// List Power Platform connections from the Power Apps Admin API. +/// +public static class ListCommand +{ + public static Command Create() + { + var connectorOption = new Option("--connector") + { + Description = "Filter by connector ID (e.g., shared_commondataserviceforapps)" + }; + + var command = new Command("list", "List connections from Power Apps Admin API") + { + connectorOption, + ConnectionsCommandGroup.ProfileOption, + ConnectionsCommandGroup.EnvironmentOption + }; + + GlobalOptions.AddToCommand(command); + + command.SetAction(async (parseResult, cancellationToken) => + { + var connector = parseResult.GetValue(connectorOption); + var profile = parseResult.GetValue(ConnectionsCommandGroup.ProfileOption); + var environment = parseResult.GetValue(ConnectionsCommandGroup.EnvironmentOption); + var globalOptions = GlobalOptions.GetValues(parseResult); + + return await ExecuteAsync(connector, profile, environment, globalOptions, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + string? connector, + string? profile, + string? environment, + GlobalOptionValues globalOptions, + CancellationToken cancellationToken) + { + var writer = ServiceFactory.CreateOutputWriter(globalOptions); + + try + { + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + globalOptions.Verbose, + globalOptions.Debug, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var connectionService = serviceProvider.GetRequiredService(); + + if (!globalOptions.IsJsonMode) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.Error.WriteLine(); + } + + var connections = await connectionService.ListAsync(connector, cancellationToken); + + if (globalOptions.IsJsonMode) + { + var output = connections.Select(c => new ConnectionListItem + { + ConnectionId = c.ConnectionId, + DisplayName = c.DisplayName, + ConnectorId = c.ConnectorId, + ConnectorDisplayName = c.ConnectorDisplayName, + Status = c.Status.ToString(), + IsShared = c.IsShared, + CreatedBy = c.CreatedBy, + ModifiedOn = c.ModifiedOn + }).ToList(); + + writer.WriteSuccess(output); + } + else + { + if (connections.Count == 0) + { + Console.Error.WriteLine("No connections found."); + } + else + { + Console.Error.WriteLine($"Found {connections.Count} connection(s):"); + Console.Error.WriteLine(); + + foreach (var c in connections) + { + var statusDisplay = c.Status switch + { + ConnectionStatus.Connected => "Connected", + ConnectionStatus.Error => "Error", + _ => "Unknown" + }; + + Console.WriteLine($" {c.DisplayName ?? c.ConnectionId}"); + Console.WriteLine($" ID: {c.ConnectionId}"); + Console.WriteLine($" Connector: {c.ConnectorDisplayName ?? c.ConnectorId}"); + Console.WriteLine($" Status: {statusDisplay} Shared: {(c.IsShared ? "Yes" : "No")}"); + if (c.CreatedBy != null) + { + Console.WriteLine($" Created by: {c.CreatedBy}"); + } + Console.WriteLine(); + } + } + } + + return ExitCodes.Success; + } + catch (InvalidOperationException ex) when (ex.Message.Contains("Service principals")) + { + // Special handling for SPN limitation + writer.WriteError(new StructuredError( + ErrorCodes.Auth.InsufficientPermissions, + ex.Message)); + return ExitCodes.AuthError; + } + catch (Exception ex) + { + var error = ExceptionMapper.Map(ex, context: "listing connections", debug: globalOptions.Debug); + writer.WriteError(error); + return ExceptionMapper.ToExitCode(ex); + } + } + + #region Output Models + + private sealed class ConnectionListItem + { + [JsonPropertyName("connectionId")] + public string ConnectionId { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("connectorId")] + public string ConnectorId { get; set; } = string.Empty; + + [JsonPropertyName("connectorDisplayName")] + public string? ConnectorDisplayName { get; set; } + + [JsonPropertyName("status")] + public string Status { get; set; } = string.Empty; + + [JsonPropertyName("isShared")] + public bool IsShared { get; set; } + + [JsonPropertyName("createdBy")] + public string? CreatedBy { get; set; } + + [JsonPropertyName("modifiedOn")] + public DateTime? ModifiedOn { get; set; } + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/DeploymentSettings/DeploymentSettingsCommandGroup.cs b/src/PPDS.Cli/Commands/DeploymentSettings/DeploymentSettingsCommandGroup.cs new file mode 100644 index 00000000..eff6e00f --- /dev/null +++ b/src/PPDS.Cli/Commands/DeploymentSettings/DeploymentSettingsCommandGroup.cs @@ -0,0 +1,42 @@ +using System.CommandLine; + +namespace PPDS.Cli.Commands.DeploymentSettings; + +/// +/// Command group for deployment settings file operations. +/// +public static class DeploymentSettingsCommandGroup +{ + /// Shared profile option. + public static readonly Option ProfileOption = new("--profile", "-p") + { + Description = "Authentication profile name" + }; + + /// Shared environment option. + public static readonly Option EnvironmentOption = new("--environment", "-e") + { + Description = "Environment URL override" + }; + + /// Shared solution option. + public static readonly Option SolutionOption = new("--solution", "-s") + { + Description = "Solution unique name", + Required = true + }; + + /// + /// Creates the deployment-settings command group. + /// + public static Command Create() + { + var command = new Command("deployment-settings", "Generate, sync, and validate deployment settings files"); + + command.Subcommands.Add(GenerateCommand.Create()); + command.Subcommands.Add(SyncCommand.Create()); + command.Subcommands.Add(ValidateCommand.Create()); + + return command; + } +} diff --git a/src/PPDS.Cli/Commands/DeploymentSettings/GenerateCommand.cs b/src/PPDS.Cli/Commands/DeploymentSettings/GenerateCommand.cs new file mode 100644 index 00000000..461668cf --- /dev/null +++ b/src/PPDS.Cli/Commands/DeploymentSettings/GenerateCommand.cs @@ -0,0 +1,121 @@ +using System.CommandLine; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Infrastructure.Errors; +using PPDS.Cli.Infrastructure.Output; +using PPDS.Dataverse.Services; + +namespace PPDS.Cli.Commands.DeploymentSettings; + +/// +/// Generate a new deployment settings file from the current environment. +/// +public static class GenerateCommand +{ + private static readonly JsonSerializerOptions JsonWriteOptions = new() + { + WriteIndented = true + }; + + public static Command Create() + { + var outputOption = new Option("--output", "-o") + { + Description = "Output file path", + Required = true + }; + + var command = new Command("generate", "Generate a new deployment settings file from current environment") + { + DeploymentSettingsCommandGroup.SolutionOption, + outputOption, + DeploymentSettingsCommandGroup.ProfileOption, + DeploymentSettingsCommandGroup.EnvironmentOption + }; + + GlobalOptions.AddToCommand(command); + + command.SetAction(async (parseResult, cancellationToken) => + { + var solution = parseResult.GetValue(DeploymentSettingsCommandGroup.SolutionOption)!; + var output = parseResult.GetValue(outputOption)!; + var profile = parseResult.GetValue(DeploymentSettingsCommandGroup.ProfileOption); + var environment = parseResult.GetValue(DeploymentSettingsCommandGroup.EnvironmentOption); + var globalOptions = GlobalOptions.GetValues(parseResult); + + return await ExecuteAsync(solution, output, profile, environment, globalOptions, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + string solution, + string outputPath, + string? profile, + string? environment, + GlobalOptionValues globalOptions, + CancellationToken cancellationToken) + { + var writer = ServiceFactory.CreateOutputWriter(globalOptions); + + try + { + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + globalOptions.Verbose, + globalOptions.Debug, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var settingsService = serviceProvider.GetRequiredService(); + + if (!globalOptions.IsJsonMode) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.Error.WriteLine(); + Console.Error.WriteLine($"Generating deployment settings for solution '{solution}'..."); + } + + var settings = await settingsService.GenerateAsync(solution, cancellationToken); + + // Write to file + var json = JsonSerializer.Serialize(settings, JsonWriteOptions); + var fullPath = Path.GetFullPath(outputPath); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + await File.WriteAllTextAsync(fullPath, json, cancellationToken); + + if (globalOptions.IsJsonMode) + { + writer.WriteSuccess(new + { + outputPath = fullPath, + environmentVariableCount = settings.EnvironmentVariables.Count, + connectionReferenceCount = settings.ConnectionReferences.Count + }); + } + else + { + Console.Error.WriteLine(); + Console.Error.WriteLine($"Generated deployment settings file: {fullPath}"); + Console.Error.WriteLine($" Environment Variables: {settings.EnvironmentVariables.Count}"); + Console.Error.WriteLine($" Connection References: {settings.ConnectionReferences.Count}"); + } + + return ExitCodes.Success; + } + catch (Exception ex) + { + var error = ExceptionMapper.Map(ex, context: "generating deployment settings", debug: globalOptions.Debug); + writer.WriteError(error); + return ExceptionMapper.ToExitCode(ex); + } + } +} diff --git a/src/PPDS.Cli/Commands/DeploymentSettings/SyncCommand.cs b/src/PPDS.Cli/Commands/DeploymentSettings/SyncCommand.cs new file mode 100644 index 00000000..f4d0bac0 --- /dev/null +++ b/src/PPDS.Cli/Commands/DeploymentSettings/SyncCommand.cs @@ -0,0 +1,215 @@ +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Infrastructure.Errors; +using PPDS.Cli.Infrastructure.Output; +using PPDS.Dataverse.Services; + +namespace PPDS.Cli.Commands.DeploymentSettings; + +/// +/// Sync an existing deployment settings file with the current solution. +/// +public static class SyncCommand +{ + private static readonly JsonSerializerOptions JsonReadOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private static readonly JsonSerializerOptions JsonWriteOptions = new() + { + WriteIndented = true + }; + + public static Command Create() + { + var fileOption = new Option("--file", "-f") + { + Description = "Deployment settings file path", + Required = true + }; + + var dryRunOption = new Option("--dry-run") + { + Description = "Show what would change without modifying the file" + }; + + var command = new Command("sync", "Sync deployment settings file with solution (preserves existing values)") + { + DeploymentSettingsCommandGroup.SolutionOption, + fileOption, + dryRunOption, + DeploymentSettingsCommandGroup.ProfileOption, + DeploymentSettingsCommandGroup.EnvironmentOption + }; + + GlobalOptions.AddToCommand(command); + + command.SetAction(async (parseResult, cancellationToken) => + { + var solution = parseResult.GetValue(DeploymentSettingsCommandGroup.SolutionOption)!; + var file = parseResult.GetValue(fileOption)!; + var dryRun = parseResult.GetValue(dryRunOption); + var profile = parseResult.GetValue(DeploymentSettingsCommandGroup.ProfileOption); + var environment = parseResult.GetValue(DeploymentSettingsCommandGroup.EnvironmentOption); + var globalOptions = GlobalOptions.GetValues(parseResult); + + return await ExecuteAsync(solution, file, dryRun, profile, environment, globalOptions, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + string solution, + string filePath, + bool dryRun, + string? profile, + string? environment, + GlobalOptionValues globalOptions, + CancellationToken cancellationToken) + { + var writer = ServiceFactory.CreateOutputWriter(globalOptions); + + try + { + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + globalOptions.Verbose, + globalOptions.Debug, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var settingsService = serviceProvider.GetRequiredService(); + + if (!globalOptions.IsJsonMode) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.Error.WriteLine(); + + if (dryRun) + { + Console.Error.WriteLine("[Dry-Run Mode] No changes will be applied."); + Console.Error.WriteLine(); + } + + Console.Error.WriteLine($"Syncing deployment settings for solution '{solution}'..."); + } + + // Load existing settings if file exists + DeploymentSettingsFile? existingSettings = null; + var fullPath = Path.GetFullPath(filePath); + + if (File.Exists(fullPath)) + { + var existingJson = await File.ReadAllTextAsync(fullPath, cancellationToken); + existingSettings = JsonSerializer.Deserialize(existingJson, JsonReadOptions); + } + + var result = await settingsService.SyncAsync(solution, existingSettings, cancellationToken); + + var evStats = result.EnvironmentVariables; + var crStats = result.ConnectionReferences; + + if (!dryRun) + { + // Write updated file + var json = JsonSerializer.Serialize(result.Settings, JsonWriteOptions); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + await File.WriteAllTextAsync(fullPath, json, cancellationToken); + } + + if (globalOptions.IsJsonMode) + { + var output = new SyncOutput + { + FilePath = fullPath, + DryRun = dryRun, + EnvironmentVariables = new SyncStatsOutput + { + Added = evStats.Added, + Removed = evStats.Removed, + Preserved = evStats.Preserved + }, + ConnectionReferences = new SyncStatsOutput + { + Added = crStats.Added, + Removed = crStats.Removed, + Preserved = crStats.Preserved + } + }; + + writer.WriteSuccess(output); + } + else + { + Console.Error.WriteLine(); + Console.Error.WriteLine($"Sync complete{(dryRun ? " (dry-run)" : "")}:"); + Console.Error.WriteLine(); + Console.Error.WriteLine(" Environment Variables:"); + Console.Error.WriteLine($" Added: {evStats.Added}"); + Console.Error.WriteLine($" Removed: {evStats.Removed}"); + Console.Error.WriteLine($" Preserved: {evStats.Preserved}"); + Console.Error.WriteLine(); + Console.Error.WriteLine(" Connection References:"); + Console.Error.WriteLine($" Added: {crStats.Added}"); + Console.Error.WriteLine($" Removed: {crStats.Removed}"); + Console.Error.WriteLine($" Preserved: {crStats.Preserved}"); + + if (!dryRun) + { + Console.Error.WriteLine(); + Console.Error.WriteLine($"Updated: {fullPath}"); + } + } + + return ExitCodes.Success; + } + catch (Exception ex) + { + var error = ExceptionMapper.Map(ex, context: "syncing deployment settings", debug: globalOptions.Debug); + writer.WriteError(error); + return ExceptionMapper.ToExitCode(ex); + } + } + + #region Output Models + + private sealed class SyncOutput + { + [JsonPropertyName("filePath")] + public string FilePath { get; set; } = string.Empty; + + [JsonPropertyName("dryRun")] + public bool DryRun { get; set; } + + [JsonPropertyName("environmentVariables")] + public SyncStatsOutput EnvironmentVariables { get; set; } = new(); + + [JsonPropertyName("connectionReferences")] + public SyncStatsOutput ConnectionReferences { get; set; } = new(); + } + + private sealed class SyncStatsOutput + { + [JsonPropertyName("added")] + public int Added { get; set; } + + [JsonPropertyName("removed")] + public int Removed { get; set; } + + [JsonPropertyName("preserved")] + public int Preserved { get; set; } + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/DeploymentSettings/ValidateCommand.cs b/src/PPDS.Cli/Commands/DeploymentSettings/ValidateCommand.cs new file mode 100644 index 00000000..a68742e3 --- /dev/null +++ b/src/PPDS.Cli/Commands/DeploymentSettings/ValidateCommand.cs @@ -0,0 +1,225 @@ +using System.CommandLine; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Infrastructure.Errors; +using PPDS.Cli.Infrastructure.Output; +using PPDS.Dataverse.Services; + +namespace PPDS.Cli.Commands.DeploymentSettings; + +/// +/// Validate a deployment settings file against the current solution. +/// +public static class ValidateCommand +{ + private static readonly JsonSerializerOptions JsonReadOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public static Command Create() + { + var fileOption = new Option("--file", "-f") + { + Description = "Deployment settings file path", + Required = true + }; + + var command = new Command("validate", "Validate deployment settings file against solution") + { + DeploymentSettingsCommandGroup.SolutionOption, + fileOption, + DeploymentSettingsCommandGroup.ProfileOption, + DeploymentSettingsCommandGroup.EnvironmentOption + }; + + GlobalOptions.AddToCommand(command); + + command.SetAction(async (parseResult, cancellationToken) => + { + var solution = parseResult.GetValue(DeploymentSettingsCommandGroup.SolutionOption)!; + var file = parseResult.GetValue(fileOption)!; + var profile = parseResult.GetValue(DeploymentSettingsCommandGroup.ProfileOption); + var environment = parseResult.GetValue(DeploymentSettingsCommandGroup.EnvironmentOption); + var globalOptions = GlobalOptions.GetValues(parseResult); + + return await ExecuteAsync(solution, file, profile, environment, globalOptions, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + string solution, + string filePath, + string? profile, + string? environment, + GlobalOptionValues globalOptions, + CancellationToken cancellationToken) + { + var writer = ServiceFactory.CreateOutputWriter(globalOptions); + + try + { + var fullPath = Path.GetFullPath(filePath); + + if (!File.Exists(fullPath)) + { + writer.WriteError(new StructuredError( + ErrorCodes.Validation.FileNotFound, + $"File not found: {fullPath}")); + return ExitCodes.InvalidArguments; + } + + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + globalOptions.Verbose, + globalOptions.Debug, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var settingsService = serviceProvider.GetRequiredService(); + + if (!globalOptions.IsJsonMode) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.Error.WriteLine(); + Console.Error.WriteLine($"Validating deployment settings for solution '{solution}'..."); + } + + // Load settings file + var json = await File.ReadAllTextAsync(fullPath, cancellationToken); + var settings = JsonSerializer.Deserialize(json, JsonReadOptions); + + if (settings == null) + { + writer.WriteError(new StructuredError( + ErrorCodes.Validation.InvalidValue, + "Failed to parse deployment settings file")); + return ExitCodes.InvalidArguments; + } + + var result = await settingsService.ValidateAsync(solution, settings, cancellationToken); + + if (globalOptions.IsJsonMode) + { + var output = new ValidationOutput + { + FilePath = fullPath, + IsValid = result.IsValid, + ErrorCount = result.Issues.Count(i => i.Severity == ValidationSeverity.Error), + WarningCount = result.Issues.Count(i => i.Severity == ValidationSeverity.Warning), + Issues = result.Issues.Select(i => new IssueOutput + { + Severity = i.Severity.ToString(), + EntryType = i.EntryType, + Name = i.Name, + Message = i.Message + }).ToList() + }; + + writer.WriteSuccess(output); + } + else + { + Console.Error.WriteLine(); + + if (result.IsValid) + { + Console.Error.WriteLine("Validation passed - no issues found."); + } + else + { + var errorCount = result.Issues.Count(i => i.Severity == ValidationSeverity.Error); + var warningCount = result.Issues.Count(i => i.Severity == ValidationSeverity.Warning); + + Console.Error.WriteLine($"Validation complete: {errorCount} error(s), {warningCount} warning(s)"); + Console.Error.WriteLine(); + + // Group by severity + var errors = result.Issues.Where(i => i.Severity == ValidationSeverity.Error).ToList(); + var warnings = result.Issues.Where(i => i.Severity == ValidationSeverity.Warning).ToList(); + + if (errors.Count > 0) + { + Console.Error.WriteLine("ERRORS:"); + foreach (var issue in errors) + { + Console.Error.WriteLine($" [{issue.EntryType}] {issue.Name}"); + Console.Error.WriteLine($" {issue.Message}"); + } + Console.Error.WriteLine(); + } + + if (warnings.Count > 0) + { + Console.Error.WriteLine("WARNINGS:"); + foreach (var issue in warnings) + { + Console.Error.WriteLine($" [{issue.EntryType}] {issue.Name}"); + Console.Error.WriteLine($" {issue.Message}"); + } + } + } + } + + // Return error exit code if there are errors (not just warnings) + var hasErrors = result.Issues.Any(i => i.Severity == ValidationSeverity.Error); + return hasErrors ? ExitCodes.Failure : ExitCodes.Success; + } + catch (JsonException ex) + { + writer.WriteError(new StructuredError( + ErrorCodes.Validation.InvalidValue, + $"Invalid JSON in deployment settings file: {ex.Message}")); + return ExitCodes.InvalidArguments; + } + catch (Exception ex) + { + var error = ExceptionMapper.Map(ex, context: "validating deployment settings", debug: globalOptions.Debug); + writer.WriteError(error); + return ExceptionMapper.ToExitCode(ex); + } + } + + #region Output Models + + private sealed class ValidationOutput + { + [JsonPropertyName("filePath")] + public string FilePath { get; set; } = string.Empty; + + [JsonPropertyName("isValid")] + public bool IsValid { get; set; } + + [JsonPropertyName("errorCount")] + public int ErrorCount { get; set; } + + [JsonPropertyName("warningCount")] + public int WarningCount { get; set; } + + [JsonPropertyName("issues")] + public List Issues { get; set; } = new(); + } + + private sealed class IssueOutput + { + [JsonPropertyName("severity")] + public string Severity { get; set; } = string.Empty; + + [JsonPropertyName("entryType")] + public string EntryType { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/Flows/FlowsCommandGroup.cs b/src/PPDS.Cli/Commands/Flows/FlowsCommandGroup.cs new file mode 100644 index 00000000..ae2c9e7f --- /dev/null +++ b/src/PPDS.Cli/Commands/Flows/FlowsCommandGroup.cs @@ -0,0 +1,41 @@ +using System.CommandLine; + +namespace PPDS.Cli.Commands.Flows; + +/// +/// Command group for cloud flow operations. +/// +public static class FlowsCommandGroup +{ + /// Shared profile option. + public static readonly Option ProfileOption = new("--profile", "-p") + { + Description = "Authentication profile name" + }; + + /// Shared environment option. + public static readonly Option EnvironmentOption = new("--environment", "-e") + { + Description = "Environment URL override" + }; + + /// Shared solution filter option. + public static readonly Option SolutionOption = new("--solution", "-s") + { + Description = "Filter by solution unique name" + }; + + /// + /// Creates the flows command group. + /// + public static Command Create() + { + var command = new Command("flows", "Manage cloud flows (Power Automate)"); + + command.Subcommands.Add(ListCommand.Create()); + command.Subcommands.Add(GetCommand.Create()); + command.Subcommands.Add(UrlCommand.Create()); + + return command; + } +} diff --git a/src/PPDS.Cli/Commands/Flows/GetCommand.cs b/src/PPDS.Cli/Commands/Flows/GetCommand.cs new file mode 100644 index 00000000..3a9b0d6c --- /dev/null +++ b/src/PPDS.Cli/Commands/Flows/GetCommand.cs @@ -0,0 +1,190 @@ +using System.CommandLine; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Infrastructure.Errors; +using PPDS.Cli.Infrastructure.Output; +using PPDS.Dataverse.Services; + +namespace PPDS.Cli.Commands.Flows; + +/// +/// Get a specific cloud flow by unique name. +/// +public static class GetCommand +{ + public static Command Create() + { + var nameArgument = new Argument("name") + { + Description = "The unique name of the flow" + }; + + var command = new Command("get", "Get a cloud flow by unique name") + { + nameArgument, + FlowsCommandGroup.ProfileOption, + FlowsCommandGroup.EnvironmentOption + }; + + GlobalOptions.AddToCommand(command); + + command.SetAction(async (parseResult, cancellationToken) => + { + var name = parseResult.GetValue(nameArgument)!; + var profile = parseResult.GetValue(FlowsCommandGroup.ProfileOption); + var environment = parseResult.GetValue(FlowsCommandGroup.EnvironmentOption); + var globalOptions = GlobalOptions.GetValues(parseResult); + + return await ExecuteAsync(name, profile, environment, globalOptions, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + string name, + string? profile, + string? environment, + GlobalOptionValues globalOptions, + CancellationToken cancellationToken) + { + var writer = ServiceFactory.CreateOutputWriter(globalOptions); + + try + { + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + globalOptions.Verbose, + globalOptions.Debug, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var flowService = serviceProvider.GetRequiredService(); + + if (!globalOptions.IsJsonMode) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.Error.WriteLine(); + } + + var flow = await flowService.GetAsync(name, cancellationToken); + + if (flow == null) + { + writer.WriteError(new StructuredError( + ErrorCodes.Operation.NotFound, + $"Cloud flow '{name}' not found")); + return ExitCodes.NotFoundError; + } + + if (globalOptions.IsJsonMode) + { + var output = new FlowDetail + { + Id = flow.Id, + UniqueName = flow.UniqueName, + DisplayName = flow.DisplayName, + Description = flow.Description, + State = flow.State.ToString(), + Category = flow.Category.ToString(), + IsManaged = flow.IsManaged, + ConnectionReferenceLogicalNames = flow.ConnectionReferenceLogicalNames, + OwnerId = flow.OwnerId, + OwnerName = flow.OwnerName, + CreatedOn = flow.CreatedOn, + ModifiedOn = flow.ModifiedOn + }; + + writer.WriteSuccess(output); + } + else + { + Console.WriteLine($"Flow: {flow.DisplayName ?? flow.UniqueName}"); + Console.WriteLine($" Unique Name: {flow.UniqueName}"); + Console.WriteLine($" ID: {flow.Id}"); + Console.WriteLine($" State: {flow.State}"); + Console.WriteLine($" Category: {flow.Category}"); + Console.WriteLine($" Managed: {(flow.IsManaged ? "Yes" : "No")}"); + + if (!string.IsNullOrEmpty(flow.Description)) + { + Console.WriteLine($" Description: {flow.Description}"); + } + + if (flow.ConnectionReferenceLogicalNames.Count > 0) + { + Console.WriteLine(); + Console.WriteLine($" Connection References ({flow.ConnectionReferenceLogicalNames.Count}):"); + foreach (var cr in flow.ConnectionReferenceLogicalNames) + { + Console.WriteLine($" - {cr}"); + } + } + + if (flow.OwnerName != null) + { + Console.WriteLine(); + Console.WriteLine($" Owner: {flow.OwnerName}"); + } + + Console.WriteLine(); + Console.WriteLine($" Created: {flow.CreatedOn}"); + Console.WriteLine($" Modified: {flow.ModifiedOn}"); + } + + return ExitCodes.Success; + } + catch (Exception ex) + { + var error = ExceptionMapper.Map(ex, context: "getting cloud flow", debug: globalOptions.Debug); + writer.WriteError(error); + return ExceptionMapper.ToExitCode(ex); + } + } + + #region Output Models + + private sealed class FlowDetail + { + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("uniqueName")] + public string UniqueName { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("state")] + public string State { get; set; } = string.Empty; + + [JsonPropertyName("category")] + public string Category { get; set; } = string.Empty; + + [JsonPropertyName("isManaged")] + public bool IsManaged { get; set; } + + [JsonPropertyName("connectionReferenceLogicalNames")] + public List ConnectionReferenceLogicalNames { get; set; } = new(); + + [JsonPropertyName("ownerId")] + public Guid? OwnerId { get; set; } + + [JsonPropertyName("ownerName")] + public string? OwnerName { get; set; } + + [JsonPropertyName("createdOn")] + public DateTime? CreatedOn { get; set; } + + [JsonPropertyName("modifiedOn")] + public DateTime? ModifiedOn { get; set; } + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/Flows/ListCommand.cs b/src/PPDS.Cli/Commands/Flows/ListCommand.cs new file mode 100644 index 00000000..f81b2edc --- /dev/null +++ b/src/PPDS.Cli/Commands/Flows/ListCommand.cs @@ -0,0 +1,183 @@ +using System.CommandLine; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Infrastructure.Errors; +using PPDS.Cli.Infrastructure.Output; +using PPDS.Dataverse.Services; + +namespace PPDS.Cli.Commands.Flows; + +/// +/// List cloud flows. +/// +public static class ListCommand +{ + public static Command Create() + { + var stateOption = new Option("--state") + { + Description = "Filter by state (Draft, Activated, Suspended)" + }; + + var command = new Command("list", "List cloud flows") + { + FlowsCommandGroup.SolutionOption, + stateOption, + FlowsCommandGroup.ProfileOption, + FlowsCommandGroup.EnvironmentOption + }; + + GlobalOptions.AddToCommand(command); + + command.SetAction(async (parseResult, cancellationToken) => + { + var solution = parseResult.GetValue(FlowsCommandGroup.SolutionOption); + var stateStr = parseResult.GetValue(stateOption); + var profile = parseResult.GetValue(FlowsCommandGroup.ProfileOption); + var environment = parseResult.GetValue(FlowsCommandGroup.EnvironmentOption); + var globalOptions = GlobalOptions.GetValues(parseResult); + + return await ExecuteAsync(solution, stateStr, profile, environment, globalOptions, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + string? solution, + string? stateStr, + string? profile, + string? environment, + GlobalOptionValues globalOptions, + CancellationToken cancellationToken) + { + var writer = ServiceFactory.CreateOutputWriter(globalOptions); + + try + { + // Parse state filter if provided + FlowState? state = null; + if (!string.IsNullOrEmpty(stateStr)) + { + if (!Enum.TryParse(stateStr, ignoreCase: true, out var parsedState)) + { + writer.WriteError(new StructuredError( + ErrorCodes.Validation.InvalidValue, + $"Invalid state '{stateStr}'. Valid values: Draft, Activated, Suspended")); + return ExitCodes.InvalidArguments; + } + state = parsedState; + } + + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + globalOptions.Verbose, + globalOptions.Debug, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var flowService = serviceProvider.GetRequiredService(); + + if (!globalOptions.IsJsonMode) + { + var connectionInfo = serviceProvider.GetRequiredService(); + ConsoleHeader.WriteConnectedAs(connectionInfo); + Console.Error.WriteLine(); + } + + var flows = await flowService.ListAsync(solution, state, cancellationToken); + + if (globalOptions.IsJsonMode) + { + var output = flows.Select(f => new FlowListItem + { + Id = f.Id, + UniqueName = f.UniqueName, + DisplayName = f.DisplayName, + State = f.State.ToString(), + Category = f.Category.ToString(), + IsManaged = f.IsManaged, + ConnectionReferenceCount = f.ConnectionReferenceLogicalNames.Count, + ModifiedOn = f.ModifiedOn + }).ToList(); + + writer.WriteSuccess(output); + } + else + { + if (flows.Count == 0) + { + Console.Error.WriteLine("No cloud flows found."); + } + else + { + Console.Error.WriteLine($"Found {flows.Count} cloud flow(s):"); + Console.Error.WriteLine(); + + foreach (var f in flows) + { + var stateDisplay = f.State switch + { + FlowState.Activated => "On", + FlowState.Suspended => "Suspended", + _ => "Off" + }; + + Console.WriteLine($" {f.DisplayName ?? f.UniqueName}"); + Console.WriteLine($" Name: {f.UniqueName} State: {stateDisplay} Type: {f.Category}"); + if (f.ConnectionReferenceLogicalNames.Count > 0) + { + Console.WriteLine($" Connection References: {f.ConnectionReferenceLogicalNames.Count}"); + } + if (f.IsManaged) + { + Console.WriteLine($" Managed: Yes"); + } + Console.WriteLine(); + } + } + } + + return ExitCodes.Success; + } + catch (Exception ex) + { + var error = ExceptionMapper.Map(ex, context: "listing cloud flows", debug: globalOptions.Debug); + writer.WriteError(error); + return ExceptionMapper.ToExitCode(ex); + } + } + + #region Output Models + + private sealed class FlowListItem + { + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("uniqueName")] + public string UniqueName { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("state")] + public string State { get; set; } = string.Empty; + + [JsonPropertyName("category")] + public string Category { get; set; } = string.Empty; + + [JsonPropertyName("isManaged")] + public bool IsManaged { get; set; } + + [JsonPropertyName("connectionReferenceCount")] + public int ConnectionReferenceCount { get; set; } + + [JsonPropertyName("modifiedOn")] + public DateTime? ModifiedOn { get; set; } + } + + #endregion +} diff --git a/src/PPDS.Cli/Commands/Flows/UrlCommand.cs b/src/PPDS.Cli/Commands/Flows/UrlCommand.cs new file mode 100644 index 00000000..04285699 --- /dev/null +++ b/src/PPDS.Cli/Commands/Flows/UrlCommand.cs @@ -0,0 +1,135 @@ +using System.CommandLine; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using PPDS.Cli.Infrastructure; +using PPDS.Cli.Infrastructure.Errors; +using PPDS.Cli.Infrastructure.Output; +using PPDS.Dataverse.Services; + +namespace PPDS.Cli.Commands.Flows; + +/// +/// Get the Power Automate maker URL for a cloud flow. +/// +public static class UrlCommand +{ + public static Command Create() + { + var nameArgument = new Argument("name") + { + Description = "The unique name of the flow" + }; + + var command = new Command("url", "Get Power Automate maker URL for a flow") + { + nameArgument, + FlowsCommandGroup.ProfileOption, + FlowsCommandGroup.EnvironmentOption + }; + + GlobalOptions.AddToCommand(command); + + command.SetAction(async (parseResult, cancellationToken) => + { + var name = parseResult.GetValue(nameArgument)!; + var profile = parseResult.GetValue(FlowsCommandGroup.ProfileOption); + var environment = parseResult.GetValue(FlowsCommandGroup.EnvironmentOption); + var globalOptions = GlobalOptions.GetValues(parseResult); + + return await ExecuteAsync(name, profile, environment, globalOptions, cancellationToken); + }); + + return command; + } + + private static async Task ExecuteAsync( + string name, + string? profile, + string? environment, + GlobalOptionValues globalOptions, + CancellationToken cancellationToken) + { + var writer = ServiceFactory.CreateOutputWriter(globalOptions); + + try + { + await using var serviceProvider = await ProfileServiceFactory.CreateFromProfilesAsync( + profile, + environment, + globalOptions.Verbose, + globalOptions.Debug, + ProfileServiceFactory.DefaultDeviceCodeCallback, + cancellationToken); + + var flowService = serviceProvider.GetRequiredService(); + var connectionInfo = serviceProvider.GetRequiredService(); + + var flow = await flowService.GetAsync(name, cancellationToken); + + if (flow == null) + { + writer.WriteError(new StructuredError( + ErrorCodes.Operation.NotFound, + $"Cloud flow '{name}' not found")); + return ExitCodes.NotFoundError; + } + + // Require environment ID for maker URL + if (string.IsNullOrEmpty(connectionInfo.EnvironmentId)) + { + writer.WriteError(new StructuredError( + ErrorCodes.Validation.RequiredField, + "Environment ID is not available. Re-select the environment with 'ppds env select' to populate the environment ID.")); + return ExitCodes.ValidationError; + } + + // Build Power Automate URL using the Power Platform environment ID GUID + // Format: https://make.powerautomate.com/environments/{environmentId}/flows/{flowId}/details + var makerUrl = $"https://make.powerautomate.com/environments/{connectionInfo.EnvironmentId}/flows/{flow.Id}/details"; + + if (globalOptions.IsJsonMode) + { + var output = new UrlOutput + { + Id = flow.Id, + UniqueName = flow.UniqueName, + DisplayName = flow.DisplayName, + MakerUrl = makerUrl + }; + + writer.WriteSuccess(output); + } + else + { + Console.WriteLine(makerUrl); + } + + return ExitCodes.Success; + } + catch (Exception ex) + { + var error = ExceptionMapper.Map(ex, context: "getting flow URL", debug: globalOptions.Debug); + writer.WriteError(error); + return ExceptionMapper.ToExitCode(ex); + } + } + + #region Output Models + + private sealed class UrlOutput + { + [JsonPropertyName("id")] + public Guid Id { get; set; } + + [JsonPropertyName("uniqueName")] + public string UniqueName { get; set; } = string.Empty; + + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + [JsonPropertyName("makerUrl")] + public string MakerUrl { get; set; } = string.Empty; + } + + #endregion +} diff --git a/src/PPDS.Cli/Infrastructure/EnvironmentResolverHelper.cs b/src/PPDS.Cli/Infrastructure/EnvironmentResolverHelper.cs index 01695e7f..c283ffce 100644 --- a/src/PPDS.Cli/Infrastructure/EnvironmentResolverHelper.cs +++ b/src/PPDS.Cli/Infrastructure/EnvironmentResolverHelper.cs @@ -22,6 +22,12 @@ public sealed class ResolvedEnvironment /// Gets the environment unique name. /// public string? UniqueName { get; init; } + + /// + /// Gets the Power Platform environment ID. + /// Required for Power Apps Admin API operations. + /// + public string? EnvironmentId { get; init; } } /// @@ -86,7 +92,8 @@ public static async Task ResolveAsync( { Url = resolved.ApiUrl, DisplayName = resolved.FriendlyName, - UniqueName = resolved.UniqueName + UniqueName = resolved.UniqueName, + EnvironmentId = resolved.EnvironmentId }; } @@ -183,7 +190,8 @@ private static ResolvedEnvironment ResolveFromList( { Url = resolved.ApiUrl, DisplayName = resolved.FriendlyName, - UniqueName = resolved.UniqueName + UniqueName = resolved.UniqueName, + EnvironmentId = resolved.EnvironmentId }; } } diff --git a/src/PPDS.Cli/Infrastructure/ProfileServiceFactory.cs b/src/PPDS.Cli/Infrastructure/ProfileServiceFactory.cs index bc900269..10604df0 100644 --- a/src/PPDS.Cli/Infrastructure/ProfileServiceFactory.cs +++ b/src/PPDS.Cli/Infrastructure/ProfileServiceFactory.cs @@ -32,6 +32,12 @@ public sealed class ResolvedConnectionInfo /// Gets the environment display name if available. /// public string? EnvironmentDisplayName { get; init; } + + /// + /// Gets the Power Platform environment ID if available. + /// Required for Power Apps Admin API operations. + /// + public string? EnvironmentId { get; init; } } /// @@ -75,7 +81,7 @@ public static async Task CreateFromProfileAsync( } // Resolve environment - handles URL, name, ID, or uses profile's saved environment - var (envUrl, envDisplayName) = await ResolveEnvironmentAsync( + var (envUrl, envDisplayName, envId) = await ResolveEnvironmentAsync( profile, environmentOverride, cancellationToken).ConfigureAwait(false); // Create credential store for secure secret lookups (registered in DI for disposal) @@ -89,16 +95,17 @@ public static async Task CreateFromProfileAsync( { Profile = profile, EnvironmentUrl = envUrl, - EnvironmentDisplayName = envDisplayName + EnvironmentDisplayName = envDisplayName, + EnvironmentId = envId }; return CreateProviderFromSources(new[] { adapter }, connectionInfo, credentialStore, verbose, debug); } /// - /// Resolves an environment identifier to a URL and display name. + /// Resolves an environment identifier to a URL, display name, and environment ID. /// - private static async Task<(string Url, string? DisplayName)> ResolveEnvironmentAsync( + private static async Task<(string Url, string? DisplayName, string? EnvironmentId)> ResolveEnvironmentAsync( AuthProfile profile, string? environmentOverride, CancellationToken cancellationToken) @@ -114,21 +121,22 @@ public static async Task CreateFromProfileAsync( " 1. Select an environment: ppds env select \n" + " 2. Specify on command: --environment "); } - return (profile.Environment.Url, profile.Environment.DisplayName); + return (profile.Environment.Url, profile.Environment.DisplayName, profile.Environment.EnvironmentId); } // Check if it's already a URL if (Uri.TryCreate(environmentOverride, UriKind.Absolute, out var uri) && (uri.Scheme == "https" || uri.Scheme == "http")) { - return (environmentOverride.TrimEnd('/'), uri.Host); + // URL override - we don't have the environment ID + return (environmentOverride.TrimEnd('/'), uri.Host, null); } // Resolve name/ID to URL using GlobalDiscoveryService var resolved = await EnvironmentResolverHelper.ResolveAsync( profile, environmentOverride, cancellationToken).ConfigureAwait(false); - return (resolved.Url, resolved.DisplayName); + return (resolved.Url, resolved.DisplayName, resolved.EnvironmentId); } /// @@ -168,7 +176,7 @@ public static async Task CreateFromProfilesAsync( var firstProfile = collection.GetByName(names[0]) ?? throw new InvalidOperationException($"Profile '{names[0]}' not found."); - var (envUrl, envDisplayName) = await ResolveEnvironmentAsync( + var (envUrl, envDisplayName, envId) = await ResolveEnvironmentAsync( firstProfile, environmentOverride, cancellationToken).ConfigureAwait(false); // Create credential store for secure secret lookups (registered in DI for disposal) @@ -187,7 +195,8 @@ public static async Task CreateFromProfilesAsync( { Profile = firstProfile, EnvironmentUrl = envUrl, - EnvironmentDisplayName = envDisplayName + EnvironmentDisplayName = envDisplayName, + EnvironmentId = envId }; return CreateProviderFromSources(adapters, connectionInfo, credentialStore, verbose, debug); diff --git a/src/PPDS.Cli/Program.cs b/src/PPDS.Cli/Program.cs index c12085a0..709ac5fc 100644 --- a/src/PPDS.Cli/Program.cs +++ b/src/PPDS.Cli/Program.cs @@ -1,18 +1,22 @@ using System.CommandLine; using PPDS.Cli.Commands.Auth; +using PPDS.Cli.Commands.Connections; +using PPDS.Cli.Commands.ConnectionReferences; using PPDS.Cli.Commands.Data; +using PPDS.Cli.Commands.DeploymentSettings; using PPDS.Cli.Commands.Env; +using PPDS.Cli.Commands.EnvironmentVariables; +using PPDS.Cli.Commands.Flows; +using PPDS.Cli.Commands.ImportJobs; +using PPDS.Cli.Commands.Internal; using PPDS.Cli.Commands.Metadata; using PPDS.Cli.Commands.Plugins; using PPDS.Cli.Commands.Query; -using PPDS.Cli.Commands.Internal; +using PPDS.Cli.Commands.Roles; using PPDS.Cli.Commands.Serve; -using PPDS.Cli.Commands; using PPDS.Cli.Commands.Solutions; -using PPDS.Cli.Commands.ImportJobs; -using PPDS.Cli.Commands.EnvironmentVariables; using PPDS.Cli.Commands.Users; -using PPDS.Cli.Commands.Roles; +using PPDS.Cli.Commands; using PPDS.Cli.Infrastructure; using PPDS.Cli.Interactive; @@ -62,6 +66,10 @@ public static async Task Main(string[] args) rootCommand.Subcommands.Add(SolutionsCommandGroup.Create()); rootCommand.Subcommands.Add(ImportJobsCommandGroup.Create()); rootCommand.Subcommands.Add(EnvironmentVariablesCommandGroup.Create()); + rootCommand.Subcommands.Add(FlowsCommandGroup.Create()); + rootCommand.Subcommands.Add(ConnectionsCommandGroup.Create()); + rootCommand.Subcommands.Add(ConnectionReferencesCommandGroup.Create()); + rootCommand.Subcommands.Add(DeploymentSettingsCommandGroup.Create()); rootCommand.Subcommands.Add(UsersCommandGroup.Create()); rootCommand.Subcommands.Add(RolesCommandGroup.Create()); rootCommand.Subcommands.Add(ServeCommand.Create()); diff --git a/src/PPDS.Cli/Services/ConnectionService.cs b/src/PPDS.Cli/Services/ConnectionService.cs new file mode 100644 index 00000000..8cb36e85 --- /dev/null +++ b/src/PPDS.Cli/Services/ConnectionService.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using PPDS.Auth.Cloud; +using PPDS.Auth.Credentials; + +namespace PPDS.Cli.Services; + +/// +/// Service for Power Platform connection operations via the Power Apps Admin API. +/// +public class ConnectionService : IConnectionService +{ + private readonly IPowerPlatformTokenProvider _tokenProvider; + private readonly CloudEnvironment _cloud; + private readonly string _environmentId; + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + /// + /// Initializes a new instance of the class. + /// + /// The Power Platform token provider. + /// The cloud environment. + /// The environment ID. + /// The logger. + public ConnectionService( + IPowerPlatformTokenProvider tokenProvider, + CloudEnvironment cloud, + string environmentId, + ILogger logger) + { + _tokenProvider = tokenProvider ?? throw new ArgumentNullException(nameof(tokenProvider)); + _cloud = cloud; + _environmentId = environmentId ?? throw new ArgumentNullException(nameof(environmentId)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _httpClient = new HttpClient(); + } + + /// + public async Task> ListAsync( + string? connectorFilter = null, + CancellationToken cancellationToken = default) + { + // Use service.powerapps.com scope for Power Apps Admin API + var token = await _tokenProvider.GetFlowApiTokenAsync(cancellationToken); + var baseUrl = CloudEndpoints.GetPowerAppsApiUrl(_cloud); + + // Power Apps Admin API connections endpoint + // Path: /providers/Microsoft.PowerApps/scopes/admin/environments/{environmentId}/connections + var url = $"{baseUrl}/providers/Microsoft.PowerApps/scopes/admin/environments/{_environmentId}/connections?api-version=2016-11-01"; + + if (!string.IsNullOrEmpty(connectorFilter)) + { + // Filter by connector ID + url += $"&$filter=properties/apiId eq '{connectorFilter}'"; + } + + _logger.LogDebug("Querying connections from Power Apps Admin API: {Url}", url); + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError("Power Apps Admin API error: {StatusCode} - {Content}", response.StatusCode, errorContent); + + // Check for SPN limitation + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden || + response.StatusCode == System.Net.HttpStatusCode.Unauthorized) + { + throw new InvalidOperationException( + $"Cannot access connections API. Status: {response.StatusCode}. " + + "Service principals have limited access to the Connections API. " + + "Use interactive or device code authentication for full functionality."); + } + + throw new HttpRequestException($"Power Apps Admin API error: {response.StatusCode} - {errorContent}"); + } + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var apiResponse = JsonSerializer.Deserialize(content, JsonOptions); + + if (apiResponse?.Value == null) + { + return new List(); + } + + var connections = apiResponse.Value.Select(MapToConnectionInfo).ToList(); + _logger.LogDebug("Found {Count} connections", connections.Count); + + return connections; + } + + /// + public async Task GetAsync( + string connectionId, + CancellationToken cancellationToken = default) + { + // Use service.powerapps.com scope for Power Apps Admin API + var token = await _tokenProvider.GetFlowApiTokenAsync(cancellationToken); + var baseUrl = CloudEndpoints.GetPowerAppsApiUrl(_cloud); + + // Direct connection lookup via Power Apps Admin API + var url = $"{baseUrl}/providers/Microsoft.PowerApps/scopes/admin/environments/{_environmentId}/connections/{connectionId}?api-version=2016-11-01"; + + _logger.LogDebug("Getting connection from Power Apps Admin API: {Url}", url); + + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError("Power Apps Admin API error: {StatusCode} - {Content}", response.StatusCode, errorContent); + throw new HttpRequestException($"Power Apps Admin API error: {response.StatusCode}"); + } + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var connectionData = JsonSerializer.Deserialize(content, JsonOptions); + + return connectionData != null ? MapToConnectionInfo(connectionData) : null; + } + + private static ConnectionInfo MapToConnectionInfo(PowerAppsConnectionData data) + { + var status = ConnectionStatus.Unknown; + if (data.Properties?.Statuses != null) + { + var hasError = data.Properties.Statuses.Any(s => + string.Equals(s.Status, "Error", StringComparison.OrdinalIgnoreCase)); + status = hasError ? ConnectionStatus.Error : ConnectionStatus.Connected; + } + + return new ConnectionInfo + { + ConnectionId = ExtractConnectionId(data.Name ?? string.Empty), + DisplayName = data.Properties?.DisplayName, + ConnectorId = data.Properties?.ApiId ?? string.Empty, + ConnectorDisplayName = data.Properties?.ApiDisplayName, + EnvironmentId = data.Properties?.Environment?.Name, + Status = status, + IsShared = data.Properties?.IsShared ?? false, + CreatedOn = data.Properties?.CreatedTime, + ModifiedOn = data.Properties?.LastModifiedTime, + CreatedBy = data.Properties?.CreatedBy?.Email + }; + } + + private static string ExtractConnectionId(string name) + { + // Name format: /providers/Microsoft.PowerApps/connections/{connectionId} + var parts = name.Split('/'); + return parts.Length > 0 ? parts[^1] : name; + } + + #region API Response Models + + private sealed class PowerAppsConnectionsResponse + { + public List? Value { get; set; } + } + + private sealed class PowerAppsConnectionData + { + public string? Name { get; set; } + public string? Id { get; set; } + public string? Type { get; set; } + public ConnectionProperties? Properties { get; set; } + } + + private sealed class ConnectionProperties + { + public string? DisplayName { get; set; } + public string? ApiId { get; set; } + public string? ApiDisplayName { get; set; } + public bool? IsShared { get; set; } + public DateTime? CreatedTime { get; set; } + public DateTime? LastModifiedTime { get; set; } + public EnvironmentRef? Environment { get; set; } + public List? Statuses { get; set; } + public UserInfo? CreatedBy { get; set; } + } + + private sealed class EnvironmentRef + { + public string? Name { get; set; } + public string? Id { get; set; } + } + + private sealed class ConnectionStatusItem + { + public string? Status { get; set; } + public string? Error { get; set; } + } + + private sealed class UserInfo + { + public string? Id { get; set; } + public string? DisplayName { get; set; } + public string? Email { get; set; } + } + + #endregion +} diff --git a/src/PPDS.Cli/Services/IConnectionService.cs b/src/PPDS.Cli/Services/IConnectionService.cs new file mode 100644 index 00000000..0c65e8f8 --- /dev/null +++ b/src/PPDS.Cli/Services/IConnectionService.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PPDS.Cli.Services; + +/// +/// Service for Power Platform connection operations via the Power Apps Admin API. +/// +/// +/// +/// Connections are managed through the Power Apps Admin API, not Dataverse. +/// Connection References (in Dataverse) reference Connections (in Power Apps API). +/// +/// +/// SPN Limitations: Service principals have limited access to the Connections API. +/// User-delegated authentication (interactive/device code) provides full functionality. +/// +/// +/// Note: This service lives in PPDS.Cli rather than PPDS.Dataverse because +/// it requires IPowerPlatformTokenProvider from PPDS.Auth, and PPDS.Dataverse does not +/// reference PPDS.Auth (to avoid circular dependencies). +/// +/// +public interface IConnectionService +{ + /// + /// Lists connections from the Power Apps Admin API. + /// + /// Optional filter by connector ID (e.g., "shared_commondataserviceforapps"). + /// Cancellation token. + /// List of connections. + Task> ListAsync( + string? connectorFilter = null, + CancellationToken cancellationToken = default); + + /// + /// Gets a specific connection by ID. + /// + /// The connection ID. + /// Cancellation token. + /// The connection info, or null if not found. + Task GetAsync( + string connectionId, + CancellationToken cancellationToken = default); +} + +/// +/// Power Platform connection information from the Power Apps Admin API. +/// +public sealed record ConnectionInfo +{ + /// Gets the connection ID (GUID format but stored as string). + public required string ConnectionId { get; init; } + + /// Gets the display name of the connection. + public string? DisplayName { get; init; } + + /// Gets the connector ID (e.g., "/providers/Microsoft.PowerApps/apis/shared_commondataserviceforapps"). + public required string ConnectorId { get; init; } + + /// Gets the connector display name. + public string? ConnectorDisplayName { get; init; } + + /// Gets the environment ID where the connection exists. + public string? EnvironmentId { get; init; } + + /// Gets the connection status. + public ConnectionStatus Status { get; init; } + + /// Gets whether the connection is shared or personal. + public bool IsShared { get; init; } + + /// Gets the creation date. + public DateTime? CreatedOn { get; init; } + + /// Gets the last modified date. + public DateTime? ModifiedOn { get; init; } + + /// Gets the creator's email address. + public string? CreatedBy { get; init; } +} + +/// +/// Connection status. +/// +public enum ConnectionStatus +{ + /// Connection is healthy and working. + Connected, + + /// Connection has an error (credentials expired, etc.). + Error, + + /// Connection status is unknown or not provided. + Unknown +} diff --git a/src/PPDS.Cli/Services/ServiceRegistration.cs b/src/PPDS.Cli/Services/ServiceRegistration.cs index f511b72a..a5f46b84 100644 --- a/src/PPDS.Cli/Services/ServiceRegistration.cs +++ b/src/PPDS.Cli/Services/ServiceRegistration.cs @@ -1,4 +1,8 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using PPDS.Auth.Credentials; +using PPDS.Auth.Profiles; +using PPDS.Cli.Infrastructure; using PPDS.Cli.Services.Query; namespace PPDS.Cli.Services; @@ -23,9 +27,57 @@ public static IServiceCollection AddCliApplicationServices(this IServiceCollecti // Query services services.AddTransient(); - // Future services will be registered here: - // services.AddTransient(); - // services.AddTransient(); + // Connection service - requires profile-based token provider and environment ID + // Registered as factory because it needs runtime values from ResolvedConnectionInfo + services.AddTransient(sp => + { + var connectionInfo = sp.GetRequiredService(); + var credentialStore = sp.GetRequiredService(); + var logger = sp.GetRequiredService>(); + + // Environment ID is required for Power Apps Admin API + if (string.IsNullOrEmpty(connectionInfo.EnvironmentId)) + { + throw new InvalidOperationException( + "Environment ID is not available. Power Apps Admin API operations require an environment " + + "that was resolved through Global Discovery Service. Direct URL connections do not provide " + + "the environment ID needed for connection operations."); + } + + // Create token provider from profile + var profile = connectionInfo.Profile; + IPowerPlatformTokenProvider tokenProvider; + + if (profile.AuthMethod == AuthMethod.ClientSecret) + { + // SPN - need secret from credential store (keyed by ApplicationId) + if (string.IsNullOrEmpty(profile.ApplicationId)) + { + throw new InvalidOperationException( + $"Profile '{profile.DisplayIdentifier}' is configured for ClientSecret auth but has no ApplicationId."); + } + + var storedCredential = credentialStore.GetAsync(profile.ApplicationId).GetAwaiter().GetResult(); + if (storedCredential?.ClientSecret == null) + { + throw new InvalidOperationException( + $"Client secret not found for application '{profile.ApplicationId}'. " + + "Run 'ppds auth create' to recreate the profile with credentials."); + } + tokenProvider = PowerPlatformTokenProvider.FromProfileWithSecret(profile, storedCredential.ClientSecret); + } + else + { + // User-delegated auth + tokenProvider = PowerPlatformTokenProvider.FromProfile(profile); + } + + return new ConnectionService( + tokenProvider, + profile.Cloud, + connectionInfo.EnvironmentId, + logger); + }); return services; } diff --git a/src/PPDS.Dataverse/CHANGELOG.md b/src/PPDS.Dataverse/CHANGELOG.md index 453b6010..ae13d066 100644 --- a/src/PPDS.Dataverse/CHANGELOG.md +++ b/src/PPDS.Dataverse/CHANGELOG.md @@ -11,6 +11,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`IFlowService`** - Service for cloud flow operations ([#142](https://github.com/joshsmithxrm/ppds-sdk/issues/142)): + - `ListAsync` - List flows with optional solution and state filters + - `GetAsync` - Get flow by unique name + - `GetByIdAsync` - Get flow by ID + - Parses flow `clientdata` JSON to extract connection reference logical names +- **`IConnectionReferenceService`** - Service for connection reference operations with orphan detection ([#143](https://github.com/joshsmithxrm/ppds-sdk/issues/143)): + - `ListAsync` - List connection references with solution and orphan filtering + - `GetAsync` - Get connection reference by logical name + - `GetFlowsUsingAsync` - Get flows that use a specific connection reference + - `AnalyzeAsync` - Full relationship analysis with orphan detection (flows referencing missing CRs, CRs not used by any flow) +- **`IDeploymentSettingsService`** - Service for PAC-compatible deployment settings files ([#145](https://github.com/joshsmithxrm/ppds-sdk/issues/145)): + - `GenerateAsync` - Generate settings from current environment (captures current values) + - `SyncAsync` - Sync existing file with solution (preserves values, adds new entries, removes stale) + - `ValidateAsync` - Validate settings against solution (missing entries, stale entries, unbound CRs) +- **`FlowClientDataParser`** - Utility for extracting connection reference logical names from flow clientdata JSON - **`Workflow` early-bound entity** - Entity class for Power Automate flows (classic workflows). Supports flow management operations. ([#149](https://github.com/joshsmithxrm/ppds-sdk/issues/149)) - **`ConnectionReference` early-bound entity** - Entity class for connection references used by flows and canvas apps. Fixed naming from pac modelbuilder's inconsistent lowercase output. ([#149](https://github.com/joshsmithxrm/ppds-sdk/issues/149)) - **Field-level error context in bulk operation errors** - `BulkOperationError` now includes `FieldName` (extracted from error messages) and `FieldValueDescription` (sanitized value info for EntityReferences). Makes debugging lookup failures and required field errors easier. See [ADR-0022](../../docs/adr/0022_IMPORT_DIAGNOSTICS_ARCHITECTURE.md). diff --git a/src/PPDS.Dataverse/DependencyInjection/ServiceCollectionExtensions.cs b/src/PPDS.Dataverse/DependencyInjection/ServiceCollectionExtensions.cs index 577099c8..e6b4d4d1 100644 --- a/src/PPDS.Dataverse/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/PPDS.Dataverse/DependencyInjection/ServiceCollectionExtensions.cs @@ -257,6 +257,11 @@ public static IServiceCollection RegisterDataverseServices(this IServiceCollecti services.AddTransient(); services.AddTransient(); + // Phase 2 services + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + return services; } } diff --git a/src/PPDS.Dataverse/Services/ConnectionReferenceService.cs b/src/PPDS.Dataverse/Services/ConnectionReferenceService.cs new file mode 100644 index 00000000..527d4c3a --- /dev/null +++ b/src/PPDS.Dataverse/Services/ConnectionReferenceService.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using PPDS.Dataverse.Generated; +using PPDS.Dataverse.Pooling; +using PPDS.Dataverse.Services.Utilities; + +namespace PPDS.Dataverse.Services; + +/// +/// Service for connection reference operations in Dataverse. +/// +public class ConnectionReferenceService : IConnectionReferenceService +{ + private readonly IDataverseConnectionPool _pool; + private readonly IFlowService _flowService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The connection pool. + /// The flow service for relationship analysis. + /// The logger. + public ConnectionReferenceService( + IDataverseConnectionPool pool, + IFlowService flowService, + ILogger logger) + { + _pool = pool ?? throw new ArgumentNullException(nameof(pool)); + _flowService = flowService ?? throw new ArgumentNullException(nameof(flowService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task> ListAsync( + string? solutionName = null, + bool unboundOnly = false, + CancellationToken cancellationToken = default) + { + await using var client = await _pool.GetClientAsync(cancellationToken: cancellationToken); + + var query = new QueryExpression(ConnectionReference.EntityLogicalName) + { + ColumnSet = new ColumnSet( + ConnectionReference.Fields.ConnectionReferenceId, + ConnectionReference.Fields.ConnectionReferenceLogicalName, + ConnectionReference.Fields.ConnectionReferenceDisplayName, + ConnectionReference.Fields.Description, + ConnectionReference.Fields.ConnectionId, + ConnectionReference.Fields.ConnectorId, + ConnectionReference.Fields.IsManaged, + ConnectionReference.Fields.CreatedOn, + ConnectionReference.Fields.ModifiedOn), + Orders = { new OrderExpression(ConnectionReference.Fields.ConnectionReferenceLogicalName, OrderType.Ascending) } + }; + + // Only active connection references + query.Criteria.AddCondition(ConnectionReference.Fields.StateCode, ConditionOperator.Equal, 0); + + // Filter to unbound only if specified + if (unboundOnly) + { + query.Criteria.AddCondition(ConnectionReference.Fields.ConnectionId, ConditionOperator.Null); + } + + // Filter by solution if specified + if (!string.IsNullOrEmpty(solutionName)) + { + var solutionLink = query.AddLink( + SolutionComponent.EntityLogicalName, + ConnectionReference.Fields.ConnectionReferenceId, + SolutionComponent.Fields.ObjectId); + solutionLink.EntityAlias = "sc"; + + var solutionLink2 = solutionLink.AddLink( + Solution.EntityLogicalName, + SolutionComponent.Fields.SolutionId, + Solution.Fields.SolutionId); + solutionLink2.EntityAlias = "sol"; + solutionLink2.LinkCriteria.AddCondition( + Solution.Fields.UniqueName, ConditionOperator.Equal, solutionName); + } + + _logger.LogDebug("Querying connection references"); + var results = await client.RetrieveMultipleAsync(query, cancellationToken); + + var connectionRefs = results.Entities.Select(MapToInfo).ToList(); + _logger.LogDebug("Found {Count} connection references", connectionRefs.Count); + + return connectionRefs; + } + + /// + public async Task GetAsync( + string logicalName, + CancellationToken cancellationToken = default) + { + await using var client = await _pool.GetClientAsync(cancellationToken: cancellationToken); + + var query = new QueryExpression(ConnectionReference.EntityLogicalName) + { + ColumnSet = new ColumnSet(true), + TopCount = 1 + }; + query.Criteria.AddCondition( + ConnectionReference.Fields.ConnectionReferenceLogicalName, + ConditionOperator.Equal, + logicalName); + query.Criteria.AddCondition(ConnectionReference.Fields.StateCode, ConditionOperator.Equal, 0); + + var results = await client.RetrieveMultipleAsync(query, cancellationToken); + + if (results.Entities.Count == 0) + { + return null; + } + + return MapToInfo(results.Entities[0]); + } + + /// + public async Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken = default) + { + await using var client = await _pool.GetClientAsync(cancellationToken: cancellationToken); + + try + { + var entity = await client.RetrieveAsync( + ConnectionReference.EntityLogicalName, + id, + new ColumnSet(true), + cancellationToken); + + return MapToInfo(entity); + } + catch (Exception ex) when (ex.Message.Contains("does not exist")) + { + return null; + } + } + + /// + public async Task> GetFlowsUsingAsync( + string logicalName, + CancellationToken cancellationToken = default) + { + // Get all flows and filter by those referencing this connection reference + var allFlows = await _flowService.ListAsync(cancellationToken: cancellationToken); + + // Case-insensitive comparison for connection reference names + return allFlows + .Where(f => f.ConnectionReferenceLogicalNames + .Any(cr => string.Equals(cr, logicalName, StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + /// + public async Task AnalyzeAsync( + string? solutionName = null, + CancellationToken cancellationToken = default) + { + _logger.LogDebug("Analyzing flow-connection reference relationships"); + + // Get all connection references and flows in the solution + var connectionRefs = await ListAsync(solutionName, cancellationToken: cancellationToken); + var flows = await _flowService.ListAsync(solutionName, cancellationToken: cancellationToken); + + // Build case-insensitive lookup for connection references + var crLookup = connectionRefs.ToDictionary( + cr => cr.LogicalName, + cr => cr, + StringComparer.OrdinalIgnoreCase); + + // Track which CRs are used by at least one flow + var usedCrLogicalNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + var relationships = new List(); + + // Process each flow's connection reference dependencies + foreach (var flow in flows) + { + foreach (var crName in flow.ConnectionReferenceLogicalNames) + { + if (crLookup.TryGetValue(crName, out var cr)) + { + // Valid relationship: flow uses existing CR + relationships.Add(new FlowConnectionRelationship + { + Type = RelationshipType.FlowToConnectionReference, + FlowUniqueName = flow.UniqueName, + FlowDisplayName = flow.DisplayName, + ConnectionReferenceLogicalName = cr.LogicalName, + ConnectionReferenceDisplayName = cr.DisplayName, + ConnectorId = cr.ConnectorId, + IsBound = cr.IsBound + }); + usedCrLogicalNames.Add(cr.LogicalName); + } + else + { + // Orphaned flow: references CR that doesn't exist + relationships.Add(new FlowConnectionRelationship + { + Type = RelationshipType.OrphanedFlow, + FlowUniqueName = flow.UniqueName, + FlowDisplayName = flow.DisplayName, + ConnectionReferenceLogicalName = crName, // The missing CR name + ConnectionReferenceDisplayName = null, + ConnectorId = null, + IsBound = null + }); + } + } + } + + // Find orphaned connection references (exist but not used by any flow) + foreach (var cr in connectionRefs) + { + if (!usedCrLogicalNames.Contains(cr.LogicalName)) + { + relationships.Add(new FlowConnectionRelationship + { + Type = RelationshipType.OrphanedConnectionReference, + FlowUniqueName = null, + FlowDisplayName = null, + ConnectionReferenceLogicalName = cr.LogicalName, + ConnectionReferenceDisplayName = cr.DisplayName, + ConnectorId = cr.ConnectorId, + IsBound = cr.IsBound + }); + } + } + + var analysis = new FlowConnectionAnalysis { Relationships = relationships }; + + _logger.LogDebug( + "Analysis complete: {Valid} valid, {OrphanedFlows} orphaned flows, {OrphanedCRs} orphaned CRs", + analysis.ValidCount, + analysis.OrphanedFlowCount, + analysis.OrphanedConnectionReferenceCount); + + return analysis; + } + + private static ConnectionReferenceInfo MapToInfo(Entity entity) + { + return new ConnectionReferenceInfo + { + Id = entity.Id, + LogicalName = entity.GetAttributeValue(ConnectionReference.Fields.ConnectionReferenceLogicalName) ?? string.Empty, + DisplayName = entity.GetAttributeValue(ConnectionReference.Fields.ConnectionReferenceDisplayName), + Description = entity.GetAttributeValue(ConnectionReference.Fields.Description), + ConnectionId = entity.GetAttributeValue(ConnectionReference.Fields.ConnectionId), + ConnectorId = entity.GetAttributeValue(ConnectionReference.Fields.ConnectorId), + IsManaged = entity.GetAttributeValue(ConnectionReference.Fields.IsManaged), + CreatedOn = entity.GetAttributeValue(ConnectionReference.Fields.CreatedOn), + ModifiedOn = entity.GetAttributeValue(ConnectionReference.Fields.ModifiedOn) + }; + } +} diff --git a/src/PPDS.Dataverse/Services/DeploymentSettingsService.cs b/src/PPDS.Dataverse/Services/DeploymentSettingsService.cs new file mode 100644 index 00000000..a92d87f5 --- /dev/null +++ b/src/PPDS.Dataverse/Services/DeploymentSettingsService.cs @@ -0,0 +1,314 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace PPDS.Dataverse.Services; + +/// +/// Service for deployment settings file operations. +/// +public class DeploymentSettingsService : IDeploymentSettingsService +{ + private readonly IEnvironmentVariableService _envVarService; + private readonly IConnectionReferenceService _connectionRefService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public DeploymentSettingsService( + IEnvironmentVariableService envVarService, + IConnectionReferenceService connectionRefService, + ILogger logger) + { + _envVarService = envVarService ?? throw new ArgumentNullException(nameof(envVarService)); + _connectionRefService = connectionRefService ?? throw new ArgumentNullException(nameof(connectionRefService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task GenerateAsync( + string solutionName, + CancellationToken cancellationToken = default) + { + _logger.LogDebug("Generating deployment settings for solution '{Solution}'", solutionName); + + // Get current environment variables and connection references from the solution + var envVars = await _envVarService.ListAsync(solutionName, cancellationToken); + var connectionRefs = await _connectionRefService.ListAsync(solutionName, cancellationToken: cancellationToken); + + var settings = new DeploymentSettingsFile + { + EnvironmentVariables = envVars + .Where(ev => ev.Type != "Secret") // Don't include secrets in deployment settings + .Select(ev => new EnvironmentVariableEntry + { + SchemaName = ev.SchemaName, + Value = ev.CurrentValue ?? ev.DefaultValue ?? string.Empty + }) + .OrderBy(ev => ev.SchemaName, StringComparer.Ordinal) + .ToList(), + + ConnectionReferences = connectionRefs + .Select(cr => new ConnectionReferenceEntry + { + LogicalName = cr.LogicalName, + ConnectionId = cr.ConnectionId ?? string.Empty, + ConnectorId = cr.ConnectorId ?? string.Empty + }) + .OrderBy(cr => cr.LogicalName, StringComparer.Ordinal) + .ToList() + }; + + _logger.LogDebug( + "Generated settings: {EnvVarCount} environment variables, {CRCount} connection references", + settings.EnvironmentVariables.Count, + settings.ConnectionReferences.Count); + + return settings; + } + + /// + public async Task SyncAsync( + string solutionName, + DeploymentSettingsFile? existingSettings, + CancellationToken cancellationToken = default) + { + _logger.LogDebug("Syncing deployment settings for solution '{Solution}'", solutionName); + + // Get current state from environment + var currentEnvVars = await _envVarService.ListAsync(solutionName, cancellationToken); + var currentConnectionRefs = await _connectionRefService.ListAsync(solutionName, cancellationToken: cancellationToken); + + // Build current schema names sets + var currentEnvVarNames = currentEnvVars + .Where(ev => ev.Type != "Secret") + .Select(ev => ev.SchemaName) + .ToHashSet(StringComparer.Ordinal); + + var currentCRNames = currentConnectionRefs + .Select(cr => cr.LogicalName) + .ToHashSet(StringComparer.Ordinal); + + // Process environment variables + var existingEnvVars = existingSettings?.EnvironmentVariables + .ToDictionary(ev => ev.SchemaName, ev => ev, StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + + var syncedEnvVars = new List(); + var evStats = new SyncStatisticsBuilder(); + + foreach (var ev in currentEnvVars.Where(ev => ev.Type != "Secret")) + { + if (existingEnvVars.TryGetValue(ev.SchemaName, out var existing)) + { + // Preserve existing value + syncedEnvVars.Add(new EnvironmentVariableEntry + { + SchemaName = ev.SchemaName, + Value = existing.Value + }); + evStats.Preserved++; + } + else + { + // New entry - use current environment value + syncedEnvVars.Add(new EnvironmentVariableEntry + { + SchemaName = ev.SchemaName, + Value = ev.CurrentValue ?? ev.DefaultValue ?? string.Empty + }); + evStats.Added++; + } + } + + // Count removed entries + evStats.Removed = existingEnvVars.Keys.Count(name => !currentEnvVarNames.Contains(name)); + + // Process connection references + var existingCRs = existingSettings?.ConnectionReferences + .ToDictionary(cr => cr.LogicalName, cr => cr, StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + + var syncedCRs = new List(); + var crStats = new SyncStatisticsBuilder(); + + foreach (var cr in currentConnectionRefs) + { + if (existingCRs.TryGetValue(cr.LogicalName, out var existing)) + { + // Preserve existing values + syncedCRs.Add(new ConnectionReferenceEntry + { + LogicalName = cr.LogicalName, + ConnectionId = existing.ConnectionId, + ConnectorId = existing.ConnectorId + }); + crStats.Preserved++; + } + else + { + // New entry - use current environment value + syncedCRs.Add(new ConnectionReferenceEntry + { + LogicalName = cr.LogicalName, + ConnectionId = cr.ConnectionId ?? string.Empty, + ConnectorId = cr.ConnectorId ?? string.Empty + }); + crStats.Added++; + } + } + + // Count removed entries + crStats.Removed = existingCRs.Keys.Count(name => !currentCRNames.Contains(name)); + + // Sort for deterministic output + var settings = new DeploymentSettingsFile + { + EnvironmentVariables = syncedEnvVars + .OrderBy(ev => ev.SchemaName, StringComparer.Ordinal) + .ToList(), + ConnectionReferences = syncedCRs + .OrderBy(cr => cr.LogicalName, StringComparer.Ordinal) + .ToList() + }; + + _logger.LogDebug( + "Sync complete - EnvVars: +{EVAdded} -{EVRemoved} ={EVPreserved}, CRs: +{CRAdded} -{CRRemoved} ={CRPreserved}", + evStats.Added, evStats.Removed, evStats.Preserved, + crStats.Added, crStats.Removed, crStats.Preserved); + + return new DeploymentSettingsSyncResult + { + Settings = settings, + EnvironmentVariables = evStats.Build(), + ConnectionReferences = crStats.Build() + }; + } + + /// + public async Task ValidateAsync( + string solutionName, + DeploymentSettingsFile settings, + CancellationToken cancellationToken = default) + { + _logger.LogDebug("Validating deployment settings for solution '{Solution}'", solutionName); + + var issues = new List(); + + // Get current state from environment + var currentEnvVars = await _envVarService.ListAsync(solutionName, cancellationToken); + var currentConnectionRefs = await _connectionRefService.ListAsync(solutionName, cancellationToken: cancellationToken); + + var currentEnvVarNames = currentEnvVars + .ToDictionary(ev => ev.SchemaName, ev => ev, StringComparer.Ordinal); + var currentCRNames = currentConnectionRefs + .ToDictionary(cr => cr.LogicalName, cr => cr, StringComparer.Ordinal); + + // Validate environment variables + foreach (var ev in settings.EnvironmentVariables) + { + if (!currentEnvVarNames.TryGetValue(ev.SchemaName, out var current)) + { + issues.Add(new ValidationIssue + { + Severity = ValidationSeverity.Warning, + EntryType = "EnvironmentVariable", + Name = ev.SchemaName, + Message = "Not found in solution - entry will be ignored during import" + }); + } + else if (string.IsNullOrEmpty(ev.Value) && current.IsRequired) + { + issues.Add(new ValidationIssue + { + Severity = ValidationSeverity.Error, + EntryType = "EnvironmentVariable", + Name = ev.SchemaName, + Message = "Required environment variable has empty value" + }); + } + } + + // Check for missing required environment variables + foreach (var ev in currentEnvVars.Where(ev => ev.IsRequired && ev.Type != "Secret")) + { + if (!settings.EnvironmentVariables.Any(e => + string.Equals(e.SchemaName, ev.SchemaName, StringComparison.Ordinal))) + { + issues.Add(new ValidationIssue + { + Severity = ValidationSeverity.Error, + EntryType = "EnvironmentVariable", + Name = ev.SchemaName, + Message = "Required environment variable missing from settings file" + }); + } + } + + // Validate connection references + foreach (var cr in settings.ConnectionReferences) + { + if (!currentCRNames.ContainsKey(cr.LogicalName)) + { + issues.Add(new ValidationIssue + { + Severity = ValidationSeverity.Warning, + EntryType = "ConnectionReference", + Name = cr.LogicalName, + Message = "Not found in solution - entry will be ignored during import" + }); + } + else if (string.IsNullOrEmpty(cr.ConnectionId)) + { + issues.Add(new ValidationIssue + { + Severity = ValidationSeverity.Warning, + EntryType = "ConnectionReference", + Name = cr.LogicalName, + Message = "Missing ConnectionId - will prompt during import" + }); + } + } + + // Check for missing connection references + foreach (var cr in currentConnectionRefs) + { + if (!settings.ConnectionReferences.Any(c => + string.Equals(c.LogicalName, cr.LogicalName, StringComparison.Ordinal))) + { + issues.Add(new ValidationIssue + { + Severity = ValidationSeverity.Warning, + EntryType = "ConnectionReference", + Name = cr.LogicalName, + Message = "Connection reference missing from settings file - will prompt during import" + }); + } + } + + _logger.LogDebug("Validation complete: {IssueCount} issues found", issues.Count); + + return new DeploymentSettingsValidation { Issues = issues }; + } + + /// + /// Helper to build sync statistics. + /// + private sealed class SyncStatisticsBuilder + { + public int Added { get; set; } + public int Removed { get; set; } + public int Preserved { get; set; } + + public SyncStatistics Build() => new() + { + Added = Added, + Removed = Removed, + Preserved = Preserved + }; + } +} diff --git a/src/PPDS.Dataverse/Services/FlowService.cs b/src/PPDS.Dataverse/Services/FlowService.cs new file mode 100644 index 00000000..6d73538e --- /dev/null +++ b/src/PPDS.Dataverse/Services/FlowService.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; +using PPDS.Dataverse.Generated; +using PPDS.Dataverse.Pooling; +using PPDS.Dataverse.Services.Utilities; + +namespace PPDS.Dataverse.Services; + +/// +/// Service for cloud flow (Power Automate) operations. +/// +public class FlowService : IFlowService +{ + private readonly IDataverseConnectionPool _pool; + private readonly ILogger _logger; + + // Category values for cloud flows + private const int ModernFlowCategory = 5; + private const int DesktopFlowCategory = 6; + + /// + /// Initializes a new instance of the class. + /// + /// The connection pool. + /// The logger. + public FlowService( + IDataverseConnectionPool pool, + ILogger logger) + { + _pool = pool ?? throw new ArgumentNullException(nameof(pool)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task> ListAsync( + string? solutionName = null, + FlowState? state = null, + CancellationToken cancellationToken = default) + { + await using var client = await _pool.GetClientAsync(cancellationToken: cancellationToken); + + var query = new QueryExpression(Workflow.EntityLogicalName) + { + ColumnSet = new ColumnSet( + Workflow.Fields.WorkflowId, + Workflow.Fields.UniqueName, + Workflow.Fields.Name, + Workflow.Fields.Description, + Workflow.Fields.StateCode, + Workflow.Fields.Category, + Workflow.Fields.IsManaged, + Workflow.Fields.ClientData, + Workflow.Fields.CreatedOn, + Workflow.Fields.ModifiedOn, + Workflow.Fields.OwnerId), + Orders = { new OrderExpression(Workflow.Fields.Name, OrderType.Ascending) } + }; + + // Filter to only cloud flows (ModernFlow=5 or DesktopFlow=6) + var categoryFilter = new FilterExpression(LogicalOperator.Or); + categoryFilter.AddCondition(Workflow.Fields.Category, ConditionOperator.Equal, ModernFlowCategory); + categoryFilter.AddCondition(Workflow.Fields.Category, ConditionOperator.Equal, DesktopFlowCategory); + query.Criteria.AddFilter(categoryFilter); + + // Filter by state if specified + if (state.HasValue) + { + query.Criteria.AddCondition(Workflow.Fields.StateCode, ConditionOperator.Equal, (int)state.Value); + } + + // Filter by solution if specified + if (!string.IsNullOrEmpty(solutionName)) + { + var solutionLink = query.AddLink( + SolutionComponent.EntityLogicalName, + Workflow.Fields.WorkflowId, + SolutionComponent.Fields.ObjectId); + solutionLink.EntityAlias = "sc"; + + var solutionLink2 = solutionLink.AddLink( + Solution.EntityLogicalName, + SolutionComponent.Fields.SolutionId, + Solution.Fields.SolutionId); + solutionLink2.EntityAlias = "sol"; + solutionLink2.LinkCriteria.AddCondition( + Solution.Fields.UniqueName, ConditionOperator.Equal, solutionName); + } + + _logger.LogDebug("Querying cloud flows"); + var results = await client.RetrieveMultipleAsync(query, cancellationToken); + + var flows = results.Entities.Select(MapToFlowInfo).ToList(); + _logger.LogDebug("Found {Count} cloud flows", flows.Count); + + return flows; + } + + /// + public async Task GetAsync( + string uniqueName, + CancellationToken cancellationToken = default) + { + await using var client = await _pool.GetClientAsync(cancellationToken: cancellationToken); + + var query = new QueryExpression(Workflow.EntityLogicalName) + { + ColumnSet = new ColumnSet(true), + TopCount = 1 + }; + + // Filter by unique name + query.Criteria.AddCondition(Workflow.Fields.UniqueName, ConditionOperator.Equal, uniqueName); + + // Filter to only cloud flows + var categoryFilter = new FilterExpression(LogicalOperator.Or); + categoryFilter.AddCondition(Workflow.Fields.Category, ConditionOperator.Equal, ModernFlowCategory); + categoryFilter.AddCondition(Workflow.Fields.Category, ConditionOperator.Equal, DesktopFlowCategory); + query.Criteria.AddFilter(categoryFilter); + + var results = await client.RetrieveMultipleAsync(query, cancellationToken); + + if (results.Entities.Count == 0) + { + return null; + } + + return MapToFlowInfo(results.Entities[0]); + } + + /// + public async Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken = default) + { + await using var client = await _pool.GetClientAsync(cancellationToken: cancellationToken); + + try + { + var entity = await client.RetrieveAsync( + Workflow.EntityLogicalName, + id, + new ColumnSet(true), + cancellationToken); + + // Verify it's a cloud flow + var category = entity.GetAttributeValue(Workflow.Fields.Category)?.Value; + if (category != ModernFlowCategory && category != DesktopFlowCategory) + { + return null; + } + + return MapToFlowInfo(entity); + } + catch (Exception ex) when (ex.Message.Contains("does not exist")) + { + return null; + } + } + + private static FlowInfo MapToFlowInfo(Entity entity) + { + var stateValue = entity.GetAttributeValue(Workflow.Fields.StateCode)?.Value ?? 0; + var categoryValue = entity.GetAttributeValue(Workflow.Fields.Category)?.Value ?? ModernFlowCategory; + var clientData = entity.GetAttributeValue(Workflow.Fields.ClientData); + var ownerRef = entity.GetAttributeValue(Workflow.Fields.OwnerId); + + return new FlowInfo + { + Id = entity.Id, + UniqueName = entity.GetAttributeValue(Workflow.Fields.UniqueName) ?? string.Empty, + DisplayName = entity.GetAttributeValue(Workflow.Fields.Name), + Description = entity.GetAttributeValue(Workflow.Fields.Description), + State = (FlowState)stateValue, + Category = (FlowCategory)categoryValue, + IsManaged = entity.GetAttributeValue(Workflow.Fields.IsManaged), + ConnectionReferenceLogicalNames = FlowClientDataParser.ExtractConnectionReferenceLogicalNames(clientData), + CreatedOn = entity.GetAttributeValue(Workflow.Fields.CreatedOn), + ModifiedOn = entity.GetAttributeValue(Workflow.Fields.ModifiedOn), + OwnerId = ownerRef?.Id, + OwnerName = ownerRef?.Name + }; + } +} diff --git a/src/PPDS.Dataverse/Services/IConnectionReferenceService.cs b/src/PPDS.Dataverse/Services/IConnectionReferenceService.cs new file mode 100644 index 00000000..063cf88f --- /dev/null +++ b/src/PPDS.Dataverse/Services/IConnectionReferenceService.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace PPDS.Dataverse.Services; + +/// +/// Service for connection reference operations in Dataverse. +/// +/// +/// Connection references are Dataverse entities that reference Power Platform connections. +/// This service provides CRUD operations and relationship analysis with flows. +/// +public interface IConnectionReferenceService +{ + /// + /// Lists connection references. + /// + /// Optional solution filter (unique name). + /// If true, only return connection references without a bound connection. + /// Cancellation token. + /// List of connection references. + Task> ListAsync( + string? solutionName = null, + bool unboundOnly = false, + CancellationToken cancellationToken = default); + + /// + /// Gets a specific connection reference by logical name. + /// + /// The connection reference logical name. + /// Cancellation token. + /// The connection reference info, or null if not found. + Task GetAsync( + string logicalName, + CancellationToken cancellationToken = default); + + /// + /// Gets a specific connection reference by ID. + /// + /// The connection reference ID. + /// Cancellation token. + /// The connection reference info, or null if not found. + Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken = default); + + /// + /// Gets all flows that use a specific connection reference. + /// + /// The connection reference logical name. + /// Cancellation token. + /// List of flows that reference this connection reference. + Task> GetFlowsUsingAsync( + string logicalName, + CancellationToken cancellationToken = default); + + /// + /// Analyzes flow-connection reference relationships within a solution. + /// Detects orphaned flows (referencing missing CRs) and orphaned CRs (not used by any flow). + /// + /// Optional solution filter (unique name). + /// Cancellation token. + /// List of relationships including orphan detection. + Task AnalyzeAsync( + string? solutionName = null, + CancellationToken cancellationToken = default); +} + +/// +/// Connection reference information from Dataverse. +/// +public sealed record ConnectionReferenceInfo +{ + /// Gets the connection reference ID. + public required Guid Id { get; init; } + + /// Gets the logical name (schema name). + public required string LogicalName { get; init; } + + /// Gets the display name. + public string? DisplayName { get; init; } + + /// Gets the description. + public string? Description { get; init; } + + /// Gets the bound connection ID (from Power Apps API). + public string? ConnectionId { get; init; } + + /// Gets the connector ID. + public string? ConnectorId { get; init; } + + /// Gets whether this is a managed component. + public bool IsManaged { get; init; } + + /// Gets whether a connection is bound (ConnectionId is set). + public bool IsBound => !string.IsNullOrEmpty(ConnectionId); + + /// Gets the created date. + public DateTime? CreatedOn { get; init; } + + /// Gets the modified date. + public DateTime? ModifiedOn { get; init; } +} + +/// +/// Result of analyzing flow-connection reference relationships. +/// +public sealed record FlowConnectionAnalysis +{ + /// Gets all relationship entries. + public required List Relationships { get; init; } + + /// Gets the count of valid flow-to-CR relationships. + public int ValidCount => Relationships.Count(r => r.Type == RelationshipType.FlowToConnectionReference); + + /// Gets the count of orphaned flows (referencing missing CRs). + public int OrphanedFlowCount => Relationships.Count(r => r.Type == RelationshipType.OrphanedFlow); + + /// Gets the count of orphaned CRs (not used by any flow). + public int OrphanedConnectionReferenceCount => Relationships.Count(r => r.Type == RelationshipType.OrphanedConnectionReference); + + /// Gets whether any orphans were detected. + public bool HasOrphans => OrphanedFlowCount > 0 || OrphanedConnectionReferenceCount > 0; +} + +/// +/// Represents a relationship between a flow and a connection reference. +/// +public sealed record FlowConnectionRelationship +{ + /// Gets the relationship type. + public required RelationshipType Type { get; init; } + + /// Gets the flow unique name (null for OrphanedConnectionReference). + public string? FlowUniqueName { get; init; } + + /// Gets the flow display name. + public string? FlowDisplayName { get; init; } + + /// Gets the connection reference logical name (null for OrphanedFlow). + public string? ConnectionReferenceLogicalName { get; init; } + + /// Gets the connection reference display name. + public string? ConnectionReferenceDisplayName { get; init; } + + /// Gets the connector ID. + public string? ConnectorId { get; init; } + + /// Gets whether the connection reference is bound to a connection. + public bool? IsBound { get; init; } +} + +/// +/// Types of flow-connection reference relationships. +/// +public enum RelationshipType +{ + /// Valid relationship: flow uses an existing connection reference. + FlowToConnectionReference, + + /// Orphaned flow: references a connection reference that doesn't exist. + OrphanedFlow, + + /// Orphaned connection reference: exists but not used by any flow. + OrphanedConnectionReference +} diff --git a/src/PPDS.Dataverse/Services/IDeploymentSettingsService.cs b/src/PPDS.Dataverse/Services/IDeploymentSettingsService.cs new file mode 100644 index 00000000..80d277ce --- /dev/null +++ b/src/PPDS.Dataverse/Services/IDeploymentSettingsService.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +namespace PPDS.Dataverse.Services; + +/// +/// Service for deployment settings file operations. +/// +/// +/// Deployment settings files configure environment-specific values (environment variables, +/// connection references) for solution deployment. This service generates, syncs, and validates +/// these files in the PAC-compatible format. +/// +public interface IDeploymentSettingsService +{ + /// + /// Generates a new deployment settings file from the current environment. + /// + /// The solution unique name. + /// Cancellation token. + /// The generated deployment settings. + Task GenerateAsync( + string solutionName, + CancellationToken cancellationToken = default); + + /// + /// Syncs an existing deployment settings file with the current solution. + /// Preserves existing values, adds new entries, removes obsolete entries. + /// + /// The solution unique name. + /// The existing settings to sync (null if new file). + /// Cancellation token. + /// The synced settings and statistics. + Task SyncAsync( + string solutionName, + DeploymentSettingsFile? existingSettings, + CancellationToken cancellationToken = default); + + /// + /// Validates a deployment settings file against the current solution. + /// + /// The solution unique name. + /// The settings file to validate. + /// Cancellation token. + /// Validation result with any issues found. + Task ValidateAsync( + string solutionName, + DeploymentSettingsFile settings, + CancellationToken cancellationToken = default); +} + +/// +/// PAC-compatible deployment settings file format. +/// +/// +/// This format is compatible with `pac solution import --settings-file`. +/// Entries are sorted by schema name (StringComparison.Ordinal) for deterministic output. +/// +public sealed class DeploymentSettingsFile +{ + /// Gets or sets the environment variables. + [JsonPropertyName("EnvironmentVariables")] + public List EnvironmentVariables { get; set; } = new(); + + /// Gets or sets the connection references. + [JsonPropertyName("ConnectionReferences")] + public List ConnectionReferences { get; set; } = new(); +} + +/// +/// Environment variable entry for deployment settings. +/// +public sealed class EnvironmentVariableEntry +{ + /// Gets or sets the schema name. + [JsonPropertyName("SchemaName")] + public string SchemaName { get; set; } = string.Empty; + + /// Gets or sets the value. + [JsonPropertyName("Value")] + public string Value { get; set; } = string.Empty; +} + +/// +/// Connection reference entry for deployment settings. +/// +public sealed class ConnectionReferenceEntry +{ + /// Gets or sets the logical name. + [JsonPropertyName("LogicalName")] + public string LogicalName { get; set; } = string.Empty; + + /// Gets or sets the connection ID. + [JsonPropertyName("ConnectionId")] + public string ConnectionId { get; set; } = string.Empty; + + /// Gets or sets the connector ID. + [JsonPropertyName("ConnectorId")] + public string ConnectorId { get; set; } = string.Empty; +} + +/// +/// Result of syncing deployment settings. +/// +public sealed class DeploymentSettingsSyncResult +{ + /// Gets the synced settings file. + public required DeploymentSettingsFile Settings { get; init; } + + /// Gets the environment variable sync statistics. + public required SyncStatistics EnvironmentVariables { get; init; } + + /// Gets the connection reference sync statistics. + public required SyncStatistics ConnectionReferences { get; init; } +} + +/// +/// Statistics for a sync operation. +/// +public sealed class SyncStatistics +{ + /// Gets the number of entries added. + public int Added { get; init; } + + /// Gets the number of entries removed. + public int Removed { get; init; } + + /// Gets the number of entries preserved (values kept from existing file). + public int Preserved { get; init; } +} + +/// +/// Result of validating deployment settings. +/// +public sealed class DeploymentSettingsValidation +{ + /// Gets whether the settings are valid. + public bool IsValid => Issues.Count == 0; + + /// Gets the validation issues. + public List Issues { get; init; } = new(); +} + +/// +/// A single validation issue. +/// +public sealed class ValidationIssue +{ + /// Gets the issue severity. + public required ValidationSeverity Severity { get; init; } + + /// Gets the entry type (EnvironmentVariable or ConnectionReference). + public required string EntryType { get; init; } + + /// Gets the schema/logical name of the entry. + public required string Name { get; init; } + + /// Gets the issue message. + public required string Message { get; init; } +} + +/// +/// Validation issue severity. +/// +public enum ValidationSeverity +{ + /// Warning - deployment may work but review recommended. + Warning, + + /// Error - deployment will likely fail. + Error +} diff --git a/src/PPDS.Dataverse/Services/IFlowService.cs b/src/PPDS.Dataverse/Services/IFlowService.cs new file mode 100644 index 00000000..d7c3e508 --- /dev/null +++ b/src/PPDS.Dataverse/Services/IFlowService.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace PPDS.Dataverse.Services; + +/// +/// Service for cloud flow (Power Automate) operations. +/// +public interface IFlowService +{ + /// + /// Lists cloud flows (Modern Flows and Desktop Flows). + /// + /// Optional solution filter (unique name). + /// Optional state filter. + /// Cancellation token. + /// List of cloud flows. + Task> ListAsync( + string? solutionName = null, + FlowState? state = null, + CancellationToken cancellationToken = default); + + /// + /// Gets a specific flow by unique name. + /// + /// The unique name of the flow. + /// Cancellation token. + /// The flow info, or null if not found. + Task GetAsync( + string uniqueName, + CancellationToken cancellationToken = default); + + /// + /// Gets a specific flow by ID. + /// + /// The workflow ID. + /// Cancellation token. + /// The flow info, or null if not found. + Task GetByIdAsync( + Guid id, + CancellationToken cancellationToken = default); +} + +/// +/// Cloud flow information. +/// +public sealed record FlowInfo +{ + /// Gets the workflow ID. + public required Guid Id { get; init; } + + /// Gets the unique name (schema name). + public required string UniqueName { get; init; } + + /// Gets the display name. + public string? DisplayName { get; init; } + + /// Gets the description. + public string? Description { get; init; } + + /// Gets the flow state (Draft, Activated, Suspended). + public required FlowState State { get; init; } + + /// Gets the flow category. + public required FlowCategory Category { get; init; } + + /// Gets whether the flow is managed. + public bool IsManaged { get; init; } + + /// + /// Gets the connection reference logical names extracted from client data. + /// These are the connection references that the flow depends on. + /// + public required List ConnectionReferenceLogicalNames { get; init; } + + /// Gets the created date. + public DateTime? CreatedOn { get; init; } + + /// Gets the modified date. + public DateTime? ModifiedOn { get; init; } + + /// Gets the owner ID. + public Guid? OwnerId { get; init; } + + /// Gets the owner name. + public string? OwnerName { get; init; } +} + +/// +/// Cloud flow state. +/// +public enum FlowState +{ + /// Flow is in draft/design mode. + Draft = 0, + + /// Flow is active and running. + Activated = 1, + + /// Flow has been suspended. + Suspended = 2 +} + +/// +/// Cloud flow category. Only cloud flow types are exposed. +/// +public enum FlowCategory +{ + /// Power Automate cloud flow. + ModernFlow = 5, + + /// Power Automate desktop flow. + DesktopFlow = 6 +} diff --git a/src/PPDS.Dataverse/Services/Utilities/FlowClientDataParser.cs b/src/PPDS.Dataverse/Services/Utilities/FlowClientDataParser.cs new file mode 100644 index 00000000..f5f8ebfa --- /dev/null +++ b/src/PPDS.Dataverse/Services/Utilities/FlowClientDataParser.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace PPDS.Dataverse.Services.Utilities; + +/// +/// Parses flow client data to extract connection reference logical names. +/// +public static class FlowClientDataParser +{ + /// + /// Extracts connection reference logical names from flow client data JSON. + /// + /// The raw client data JSON from the workflow entity. + /// List of connection reference logical names (case-preserved). + /// + /// Client data structure varies but connection references typically appear in: + /// - properties.connectionReferences (object with CR logical names as keys) + /// - definition.connectionReferences (alternative location) + /// + public static List ExtractConnectionReferenceLogicalNames(string? clientData) + { + var result = new List(); + + if (string.IsNullOrWhiteSpace(clientData)) + { + return result; + } + + try + { + using var doc = JsonDocument.Parse(clientData); + var root = doc.RootElement; + + // Try properties.connectionReferences first + if (root.TryGetProperty("properties", out var properties) && + properties.TryGetProperty("connectionReferences", out var connectionRefs)) + { + ExtractKeysFromObject(connectionRefs, result); + } + // Try top-level connectionReferences + else if (root.TryGetProperty("connectionReferences", out var topLevelRefs)) + { + ExtractKeysFromObject(topLevelRefs, result); + } + // Try definition.connectionReferences + else if (root.TryGetProperty("definition", out var definition) && + definition.TryGetProperty("connectionReferences", out var defRefs)) + { + ExtractKeysFromObject(defRefs, result); + } + } + catch (JsonException) + { + // Invalid JSON - return empty list + } + + return result; + } + + /// + /// Extracts all keys from a JSON object (connection reference logical names). + /// + private static void ExtractKeysFromObject(JsonElement element, List keys) + { + if (element.ValueKind != JsonValueKind.Object) + { + return; + } + + foreach (var property in element.EnumerateObject()) + { + if (!string.IsNullOrEmpty(property.Name)) + { + keys.Add(property.Name); + } + } + } +} diff --git a/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/ConnectionReferencesCommandGroupTests.cs b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/ConnectionReferencesCommandGroupTests.cs new file mode 100644 index 00000000..267aeac2 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/ConnectionReferencesCommandGroupTests.cs @@ -0,0 +1,168 @@ +using System.CommandLine; +using PPDS.Cli.Commands.ConnectionReferences; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.ConnectionReferences; + +public class ConnectionReferencesCommandGroupTests +{ + private readonly Command _command; + + public ConnectionReferencesCommandGroupTests() + { + _command = ConnectionReferencesCommandGroup.Create(); + } + + #region Command Structure Tests + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("connectionreferences", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("connection reference", _command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasListSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "list"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasGetSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "get"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasFlowsSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "flows"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasConnectionsSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "connections"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasAnalyzeSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "analyze"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasFiveSubcommands() + { + Assert.Equal(5, _command.Subcommands.Count); + } + + #endregion + + #region List Subcommand Tests + + [Fact] + public void ListSubcommand_HasSolutionOption() + { + var listCommand = _command.Subcommands.First(c => c.Name == "list"); + var option = listCommand.Options.FirstOrDefault(o => o.Name == "--solution"); + Assert.NotNull(option); + } + + [Fact] + public void ListSubcommand_HasUnboundOption() + { + var listCommand = _command.Subcommands.First(c => c.Name == "list"); + var option = listCommand.Options.FirstOrDefault(o => o.Name == "--unbound"); + Assert.NotNull(option); + } + + #endregion + + #region Get Subcommand Tests + + [Fact] + public void GetSubcommand_HasNameArgument() + { + var getCommand = _command.Subcommands.First(c => c.Name == "get"); + Assert.Single(getCommand.Arguments); + Assert.Equal("name", getCommand.Arguments[0].Name); + } + + #endregion + + #region Flows Subcommand Tests + + [Fact] + public void FlowsSubcommand_HasNameArgument() + { + var flowsCommand = _command.Subcommands.First(c => c.Name == "flows"); + Assert.Single(flowsCommand.Arguments); + Assert.Equal("name", flowsCommand.Arguments[0].Name); + } + + #endregion + + #region Connections Subcommand Tests + + [Fact] + public void ConnectionsSubcommand_HasNameArgument() + { + var connectionsCommand = _command.Subcommands.First(c => c.Name == "connections"); + Assert.Single(connectionsCommand.Arguments); + Assert.Equal("name", connectionsCommand.Arguments[0].Name); + } + + #endregion + + #region Analyze Subcommand Tests + + [Fact] + public void AnalyzeSubcommand_HasSolutionOption() + { + var analyzeCommand = _command.Subcommands.First(c => c.Name == "analyze"); + var option = analyzeCommand.Options.FirstOrDefault(o => o.Name == "--solution"); + Assert.NotNull(option); + } + + #endregion + + #region Shared Options Tests + + [Fact] + public void ProfileOption_HasCorrectName() + { + Assert.Equal("--profile", ConnectionReferencesCommandGroup.ProfileOption.Name); + } + + [Fact] + public void ProfileOption_HasShortAlias() + { + Assert.Contains("-p", ConnectionReferencesCommandGroup.ProfileOption.Aliases); + } + + [Fact] + public void EnvironmentOption_HasCorrectName() + { + Assert.Equal("--environment", ConnectionReferencesCommandGroup.EnvironmentOption.Name); + } + + [Fact] + public void EnvironmentOption_HasShortAlias() + { + Assert.Contains("-e", ConnectionReferencesCommandGroup.EnvironmentOption.Aliases); + } + + #endregion +} diff --git a/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/ConnectionsCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/ConnectionsCommandTests.cs new file mode 100644 index 00000000..f13cc12c --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/ConnectionsCommandTests.cs @@ -0,0 +1,64 @@ +using System.CommandLine; +using PPDS.Cli.Commands.ConnectionReferences; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.ConnectionReferences; + +public class ConnectionsCommandTests +{ + private readonly Command _command; + + public ConnectionsCommandTests() + { + _command = ConnectionsCommand.Create(); + } + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("connections", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("connection", _command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasNameArgument() + { + Assert.Single(_command.Arguments); + Assert.Equal("name", _command.Arguments[0].Name); + } + + [Fact] + public void Create_NameArgumentHasDescription() + { + var arg = _command.Arguments[0]; + Assert.NotNull(arg.Description); + Assert.Contains("logical name", arg.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasProfileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--profile"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasEnvironmentOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--environment"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasGlobalOptions() + { + // Global options include --output-format, --quiet, --verbose, etc. + var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format"); + Assert.NotNull(formatOption); + } +} diff --git a/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/FlowsCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/FlowsCommandTests.cs new file mode 100644 index 00000000..5da10e79 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/FlowsCommandTests.cs @@ -0,0 +1,63 @@ +using System.CommandLine; +using PPDS.Cli.Commands.ConnectionReferences; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.ConnectionReferences; + +public class FlowsCommandTests +{ + private readonly Command _command; + + public FlowsCommandTests() + { + _command = FlowsCommand.Create(); + } + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("flows", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("flow", _command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasNameArgument() + { + Assert.Single(_command.Arguments); + Assert.Equal("name", _command.Arguments[0].Name); + } + + [Fact] + public void Create_NameArgumentHasDescription() + { + var arg = _command.Arguments[0]; + Assert.NotNull(arg.Description); + Assert.Contains("logical name", arg.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasProfileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--profile"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasEnvironmentOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--environment"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasGlobalOptions() + { + var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format"); + Assert.NotNull(formatOption); + } +} diff --git a/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/GetCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/GetCommandTests.cs new file mode 100644 index 00000000..613cd4b5 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/ConnectionReferences/GetCommandTests.cs @@ -0,0 +1,63 @@ +using System.CommandLine; +using PPDS.Cli.Commands.ConnectionReferences; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.ConnectionReferences; + +public class GetCommandTests +{ + private readonly Command _command; + + public GetCommandTests() + { + _command = GetCommand.Create(); + } + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("get", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("connection reference", _command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasNameArgument() + { + Assert.Single(_command.Arguments); + Assert.Equal("name", _command.Arguments[0].Name); + } + + [Fact] + public void Create_NameArgumentHasDescription() + { + var arg = _command.Arguments[0]; + Assert.NotNull(arg.Description); + Assert.Contains("logical name", arg.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasProfileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--profile"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasEnvironmentOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--environment"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasGlobalOptions() + { + var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format"); + Assert.NotNull(formatOption); + } +} diff --git a/tests/PPDS.Cli.Tests/Commands/Connections/ConnectionsCommandGroupTests.cs b/tests/PPDS.Cli.Tests/Commands/Connections/ConnectionsCommandGroupTests.cs new file mode 100644 index 00000000..d4bf3695 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/Connections/ConnectionsCommandGroupTests.cs @@ -0,0 +1,103 @@ +using System.CommandLine; +using PPDS.Cli.Commands.Connections; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.Connections; + +public class ConnectionsCommandGroupTests +{ + private readonly Command _command; + + public ConnectionsCommandGroupTests() + { + _command = ConnectionsCommandGroup.Create(); + } + + #region Command Structure Tests + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("connections", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("connection", _command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasListSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "list"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasGetSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "get"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasTwoSubcommands() + { + Assert.Equal(2, _command.Subcommands.Count); + } + + #endregion + + #region List Subcommand Tests + + [Fact] + public void ListSubcommand_HasConnectorOption() + { + var listCommand = _command.Subcommands.First(c => c.Name == "list"); + var option = listCommand.Options.FirstOrDefault(o => o.Name == "--connector"); + Assert.NotNull(option); + } + + #endregion + + #region Get Subcommand Tests + + [Fact] + public void GetSubcommand_HasIdArgument() + { + var getCommand = _command.Subcommands.First(c => c.Name == "get"); + Assert.Single(getCommand.Arguments); + Assert.Equal("id", getCommand.Arguments[0].Name); + } + + #endregion + + #region Shared Options Tests + + [Fact] + public void ProfileOption_HasCorrectName() + { + Assert.Equal("--profile", ConnectionsCommandGroup.ProfileOption.Name); + } + + [Fact] + public void ProfileOption_HasShortAlias() + { + Assert.Contains("-p", ConnectionsCommandGroup.ProfileOption.Aliases); + } + + [Fact] + public void EnvironmentOption_HasCorrectName() + { + Assert.Equal("--environment", ConnectionsCommandGroup.EnvironmentOption.Name); + } + + [Fact] + public void EnvironmentOption_HasShortAlias() + { + Assert.Contains("-e", ConnectionsCommandGroup.EnvironmentOption.Aliases); + } + + #endregion +} diff --git a/tests/PPDS.Cli.Tests/Commands/Connections/GetCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/Connections/GetCommandTests.cs new file mode 100644 index 00000000..8aa3ae28 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/Connections/GetCommandTests.cs @@ -0,0 +1,63 @@ +using System.CommandLine; +using PPDS.Cli.Commands.Connections; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.Connections; + +public class GetCommandTests +{ + private readonly Command _command; + + public GetCommandTests() + { + _command = GetCommand.Create(); + } + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("get", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("connection", _command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasIdArgument() + { + Assert.Single(_command.Arguments); + Assert.Equal("id", _command.Arguments[0].Name); + } + + [Fact] + public void Create_IdArgumentHasDescription() + { + var arg = _command.Arguments[0]; + Assert.NotNull(arg.Description); + Assert.Contains("connection", arg.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasProfileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--profile"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasEnvironmentOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--environment"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasGlobalOptions() + { + var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format"); + Assert.NotNull(formatOption); + } +} diff --git a/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/DeploymentSettingsCommandGroupTests.cs b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/DeploymentSettingsCommandGroupTests.cs new file mode 100644 index 00000000..1c9fdb43 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/DeploymentSettingsCommandGroupTests.cs @@ -0,0 +1,146 @@ +using System.CommandLine; +using PPDS.Cli.Commands.DeploymentSettings; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.DeploymentSettings; + +public class DeploymentSettingsCommandGroupTests +{ + private readonly Command _command; + + public DeploymentSettingsCommandGroupTests() + { + _command = DeploymentSettingsCommandGroup.Create(); + } + + #region Command Structure Tests + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("deployment-settings", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("deployment settings", _command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasGenerateSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "generate"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasSyncSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "sync"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasValidateSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "validate"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasThreeSubcommands() + { + Assert.Equal(3, _command.Subcommands.Count); + } + + #endregion + + #region Generate Subcommand Tests + + [Fact] + public void GenerateSubcommand_HasSolutionOption() + { + var generateCommand = _command.Subcommands.First(c => c.Name == "generate"); + var option = generateCommand.Options.FirstOrDefault(o => o.Name == "--solution"); + Assert.NotNull(option); + } + + [Fact] + public void GenerateSubcommand_HasOutputOption() + { + var generateCommand = _command.Subcommands.First(c => c.Name == "generate"); + var option = generateCommand.Options.FirstOrDefault(o => o.Name == "--output"); + Assert.NotNull(option); + } + + #endregion + + #region Sync Subcommand Tests + + [Fact] + public void SyncSubcommand_HasSolutionOption() + { + var syncCommand = _command.Subcommands.First(c => c.Name == "sync"); + var option = syncCommand.Options.FirstOrDefault(o => o.Name == "--solution"); + Assert.NotNull(option); + } + + [Fact] + public void SyncSubcommand_HasFileOption() + { + var syncCommand = _command.Subcommands.First(c => c.Name == "sync"); + var option = syncCommand.Options.FirstOrDefault(o => o.Name == "--file"); + Assert.NotNull(option); + } + + #endregion + + #region Validate Subcommand Tests + + [Fact] + public void ValidateSubcommand_HasSolutionOption() + { + var validateCommand = _command.Subcommands.First(c => c.Name == "validate"); + var option = validateCommand.Options.FirstOrDefault(o => o.Name == "--solution"); + Assert.NotNull(option); + } + + [Fact] + public void ValidateSubcommand_HasFileOption() + { + var validateCommand = _command.Subcommands.First(c => c.Name == "validate"); + var option = validateCommand.Options.FirstOrDefault(o => o.Name == "--file"); + Assert.NotNull(option); + } + + #endregion + + #region Shared Options Tests + + [Fact] + public void ProfileOption_HasCorrectName() + { + Assert.Equal("--profile", DeploymentSettingsCommandGroup.ProfileOption.Name); + } + + [Fact] + public void ProfileOption_HasShortAlias() + { + Assert.Contains("-p", DeploymentSettingsCommandGroup.ProfileOption.Aliases); + } + + [Fact] + public void EnvironmentOption_HasCorrectName() + { + Assert.Equal("--environment", DeploymentSettingsCommandGroup.EnvironmentOption.Name); + } + + [Fact] + public void EnvironmentOption_HasShortAlias() + { + Assert.Contains("-e", DeploymentSettingsCommandGroup.EnvironmentOption.Aliases); + } + + #endregion +} diff --git a/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/GenerateCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/GenerateCommandTests.cs new file mode 100644 index 00000000..ec497a23 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/GenerateCommandTests.cs @@ -0,0 +1,75 @@ +using System.CommandLine; +using PPDS.Cli.Commands.DeploymentSettings; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.DeploymentSettings; + +public class GenerateCommandTests +{ + private readonly Command _command; + + public GenerateCommandTests() + { + _command = GenerateCommand.Create(); + } + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("generate", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("deployment settings", _command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasNoArguments() + { + Assert.Empty(_command.Arguments); + } + + [Fact] + public void Create_HasSolutionOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--solution"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasOutputOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--output"); + Assert.NotNull(option); + } + + [Fact] + public void Create_OutputOptionHasShortAlias() + { + var option = _command.Options.First(o => o.Name == "--output"); + Assert.Contains("-o", option.Aliases); + } + + [Fact] + public void Create_HasProfileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--profile"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasEnvironmentOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--environment"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasGlobalOptions() + { + var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format"); + Assert.NotNull(formatOption); + } +} diff --git a/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/SyncCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/SyncCommandTests.cs new file mode 100644 index 00000000..95bd665a --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/SyncCommandTests.cs @@ -0,0 +1,82 @@ +using System.CommandLine; +using PPDS.Cli.Commands.DeploymentSettings; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.DeploymentSettings; + +public class SyncCommandTests +{ + private readonly Command _command; + + public SyncCommandTests() + { + _command = SyncCommand.Create(); + } + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("sync", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("deployment settings", _command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasNoArguments() + { + Assert.Empty(_command.Arguments); + } + + [Fact] + public void Create_HasSolutionOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--solution"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasFileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--file"); + Assert.NotNull(option); + } + + [Fact] + public void Create_FileOptionHasShortAlias() + { + var option = _command.Options.First(o => o.Name == "--file"); + Assert.Contains("-f", option.Aliases); + } + + [Fact] + public void Create_HasDryRunOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--dry-run"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasProfileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--profile"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasEnvironmentOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--environment"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasGlobalOptions() + { + var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format"); + Assert.NotNull(formatOption); + } +} diff --git a/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/ValidateCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/ValidateCommandTests.cs new file mode 100644 index 00000000..83605653 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/DeploymentSettings/ValidateCommandTests.cs @@ -0,0 +1,75 @@ +using System.CommandLine; +using PPDS.Cli.Commands.DeploymentSettings; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.DeploymentSettings; + +public class ValidateCommandTests +{ + private readonly Command _command; + + public ValidateCommandTests() + { + _command = ValidateCommand.Create(); + } + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("validate", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("deployment settings", _command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasNoArguments() + { + Assert.Empty(_command.Arguments); + } + + [Fact] + public void Create_HasSolutionOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--solution"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasFileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--file"); + Assert.NotNull(option); + } + + [Fact] + public void Create_FileOptionHasShortAlias() + { + var option = _command.Options.First(o => o.Name == "--file"); + Assert.Contains("-f", option.Aliases); + } + + [Fact] + public void Create_HasProfileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--profile"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasEnvironmentOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--environment"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasGlobalOptions() + { + var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format"); + Assert.NotNull(formatOption); + } +} diff --git a/tests/PPDS.Cli.Tests/Commands/Flows/FlowsCommandGroupTests.cs b/tests/PPDS.Cli.Tests/Commands/Flows/FlowsCommandGroupTests.cs new file mode 100644 index 00000000..340ea372 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/Flows/FlowsCommandGroupTests.cs @@ -0,0 +1,130 @@ +using System.CommandLine; +using PPDS.Cli.Commands.Flows; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.Flows; + +public class FlowsCommandGroupTests +{ + private readonly Command _command; + + public FlowsCommandGroupTests() + { + _command = FlowsCommandGroup.Create(); + } + + #region Command Structure Tests + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("flows", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("flow", _command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasListSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "list"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasGetSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "get"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasUrlSubcommand() + { + var subcommand = _command.Subcommands.FirstOrDefault(c => c.Name == "url"); + Assert.NotNull(subcommand); + } + + [Fact] + public void Create_HasThreeSubcommands() + { + Assert.Equal(3, _command.Subcommands.Count); + } + + #endregion + + #region List Subcommand Tests + + [Fact] + public void ListSubcommand_HasSolutionOption() + { + var listCommand = _command.Subcommands.First(c => c.Name == "list"); + var option = listCommand.Options.FirstOrDefault(o => o.Name == "--solution"); + Assert.NotNull(option); + } + + [Fact] + public void ListSubcommand_HasStateOption() + { + var listCommand = _command.Subcommands.First(c => c.Name == "list"); + var option = listCommand.Options.FirstOrDefault(o => o.Name == "--state"); + Assert.NotNull(option); + } + + #endregion + + #region Get Subcommand Tests + + [Fact] + public void GetSubcommand_HasNameArgument() + { + var getCommand = _command.Subcommands.First(c => c.Name == "get"); + Assert.Single(getCommand.Arguments); + Assert.Equal("name", getCommand.Arguments[0].Name); + } + + #endregion + + #region Url Subcommand Tests + + [Fact] + public void UrlSubcommand_HasNameArgument() + { + var urlCommand = _command.Subcommands.First(c => c.Name == "url"); + Assert.Single(urlCommand.Arguments); + Assert.Equal("name", urlCommand.Arguments[0].Name); + } + + #endregion + + #region Shared Options Tests + + [Fact] + public void ProfileOption_HasCorrectName() + { + Assert.Equal("--profile", FlowsCommandGroup.ProfileOption.Name); + } + + [Fact] + public void ProfileOption_HasShortAlias() + { + Assert.Contains("-p", FlowsCommandGroup.ProfileOption.Aliases); + } + + [Fact] + public void EnvironmentOption_HasCorrectName() + { + Assert.Equal("--environment", FlowsCommandGroup.EnvironmentOption.Name); + } + + [Fact] + public void EnvironmentOption_HasShortAlias() + { + Assert.Contains("-e", FlowsCommandGroup.EnvironmentOption.Aliases); + } + + #endregion +} diff --git a/tests/PPDS.Cli.Tests/Commands/Flows/GetCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/Flows/GetCommandTests.cs new file mode 100644 index 00000000..79118efc --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/Flows/GetCommandTests.cs @@ -0,0 +1,63 @@ +using System.CommandLine; +using PPDS.Cli.Commands.Flows; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.Flows; + +public class GetCommandTests +{ + private readonly Command _command; + + public GetCommandTests() + { + _command = GetCommand.Create(); + } + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("get", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("flow", _command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasNameArgument() + { + Assert.Single(_command.Arguments); + Assert.Equal("name", _command.Arguments[0].Name); + } + + [Fact] + public void Create_NameArgumentHasDescription() + { + var arg = _command.Arguments[0]; + Assert.NotNull(arg.Description); + Assert.Contains("unique name", arg.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasProfileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--profile"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasEnvironmentOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--environment"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasGlobalOptions() + { + var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format"); + Assert.NotNull(formatOption); + } +} diff --git a/tests/PPDS.Cli.Tests/Commands/Flows/UrlCommandTests.cs b/tests/PPDS.Cli.Tests/Commands/Flows/UrlCommandTests.cs new file mode 100644 index 00000000..d94fc2a1 --- /dev/null +++ b/tests/PPDS.Cli.Tests/Commands/Flows/UrlCommandTests.cs @@ -0,0 +1,63 @@ +using System.CommandLine; +using PPDS.Cli.Commands.Flows; +using Xunit; + +namespace PPDS.Cli.Tests.Commands.Flows; + +public class UrlCommandTests +{ + private readonly Command _command; + + public UrlCommandTests() + { + _command = UrlCommand.Create(); + } + + [Fact] + public void Create_ReturnsCommandWithCorrectName() + { + Assert.Equal("url", _command.Name); + } + + [Fact] + public void Create_ReturnsCommandWithDescription() + { + Assert.Contains("url", _command.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasNameArgument() + { + Assert.Single(_command.Arguments); + Assert.Equal("name", _command.Arguments[0].Name); + } + + [Fact] + public void Create_NameArgumentHasDescription() + { + var arg = _command.Arguments[0]; + Assert.NotNull(arg.Description); + Assert.Contains("unique name", arg.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_HasProfileOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--profile"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasEnvironmentOption() + { + var option = _command.Options.FirstOrDefault(o => o.Name == "--environment"); + Assert.NotNull(option); + } + + [Fact] + public void Create_HasGlobalOptions() + { + var formatOption = _command.Options.FirstOrDefault(o => o.Name == "--output-format"); + Assert.NotNull(formatOption); + } +} diff --git a/tests/PPDS.Cli.Tests/Services/ConnectionServiceTests.cs b/tests/PPDS.Cli.Tests/Services/ConnectionServiceTests.cs new file mode 100644 index 00000000..3226995b --- /dev/null +++ b/tests/PPDS.Cli.Tests/Services/ConnectionServiceTests.cs @@ -0,0 +1,163 @@ +using Microsoft.Extensions.Logging; +using Moq; +using PPDS.Auth.Cloud; +using PPDS.Auth.Credentials; +using PPDS.Cli.Services; +using Xunit; + +namespace PPDS.Cli.Tests.Services; + +public class ConnectionServiceTests +{ + private readonly Mock _mockTokenProvider; + private readonly Mock> _mockLogger; + private readonly string _environmentId; + private readonly CloudEnvironment _cloud; + + public ConnectionServiceTests() + { + _mockTokenProvider = new Mock(); + _mockLogger = new Mock>(); + _environmentId = "test-environment-id"; + _cloud = CloudEnvironment.Public; + } + + #region Constructor Tests + + [Fact] + public void Constructor_WithValidParameters_CreatesInstance() + { + var service = new ConnectionService( + _mockTokenProvider.Object, + _cloud, + _environmentId, + _mockLogger.Object); + + Assert.NotNull(service); + } + + [Fact] + public void Constructor_WithNullTokenProvider_ThrowsArgumentNullException() + { + Assert.Throws(() => new ConnectionService( + null!, + _cloud, + _environmentId, + _mockLogger.Object)); + } + + [Fact] + public void Constructor_WithNullEnvironmentId_ThrowsArgumentNullException() + { + Assert.Throws(() => new ConnectionService( + _mockTokenProvider.Object, + _cloud, + null!, + _mockLogger.Object)); + } + + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + Assert.Throws(() => new ConnectionService( + _mockTokenProvider.Object, + _cloud, + _environmentId, + null!)); + } + + #endregion + + #region ListAsync Tests + + [Fact] + public async Task ListAsync_CallsGetFlowApiTokenAsync() + { + // Arrange + _mockTokenProvider + .Setup(x => x.GetFlowApiTokenAsync(It.IsAny())) + .ReturnsAsync(new PowerPlatformToken + { + AccessToken = "test-token", + ExpiresOn = DateTimeOffset.UtcNow.AddHours(1), + Resource = "https://service.powerapps.com" + }); + + var service = new ConnectionService( + _mockTokenProvider.Object, + _cloud, + _environmentId, + _mockLogger.Object); + + // Act & Assert (will fail due to HTTP call, but we verify the token was requested) + try + { + await service.ListAsync(); + } + catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException) + { + // Expected - no mock HTTP client (may throw HttpRequestException or InvalidOperationException for auth) + } + + _mockTokenProvider.Verify(x => x.GetFlowApiTokenAsync(It.IsAny()), Times.Once); + } + + #endregion + + #region GetAsync Tests + + [Fact] + public async Task GetAsync_CallsGetFlowApiTokenAsync() + { + // Arrange + _mockTokenProvider + .Setup(x => x.GetFlowApiTokenAsync(It.IsAny())) + .ReturnsAsync(new PowerPlatformToken + { + AccessToken = "test-token", + ExpiresOn = DateTimeOffset.UtcNow.AddHours(1), + Resource = "https://service.powerapps.com" + }); + + var service = new ConnectionService( + _mockTokenProvider.Object, + _cloud, + _environmentId, + _mockLogger.Object); + + // Act & Assert (will fail due to HTTP call, but we verify the token was requested) + try + { + await service.GetAsync("test-connection-id"); + } + catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException) + { + // Expected - no mock HTTP client (may throw HttpRequestException or InvalidOperationException for auth) + } + + _mockTokenProvider.Verify(x => x.GetFlowApiTokenAsync(It.IsAny()), Times.Once); + } + + #endregion + + #region Cloud Environment Tests + + [Theory] + [InlineData(CloudEnvironment.Public)] + [InlineData(CloudEnvironment.UsGov)] + [InlineData(CloudEnvironment.UsGovHigh)] + [InlineData(CloudEnvironment.UsGovDod)] + [InlineData(CloudEnvironment.China)] + public void Constructor_AcceptsAllCloudEnvironments(CloudEnvironment cloud) + { + var service = new ConnectionService( + _mockTokenProvider.Object, + cloud, + _environmentId, + _mockLogger.Object); + + Assert.NotNull(service); + } + + #endregion +} diff --git a/tests/PPDS.Dataverse.Tests/Services/ConnectionReferenceServiceTests.cs b/tests/PPDS.Dataverse.Tests/Services/ConnectionReferenceServiceTests.cs new file mode 100644 index 00000000..b9608c62 --- /dev/null +++ b/tests/PPDS.Dataverse.Tests/Services/ConnectionReferenceServiceTests.cs @@ -0,0 +1,126 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using PPDS.Dataverse.Configuration; +using PPDS.Dataverse.DependencyInjection; +using PPDS.Dataverse.Pooling; +using PPDS.Dataverse.Services; +using Xunit; + +namespace PPDS.Dataverse.Tests.Services; + +public class ConnectionReferenceServiceTests +{ + [Fact] + public void Constructor_ThrowsOnNullConnectionPool() + { + // Arrange + var flowService = new Mock().Object; + var logger = new NullLogger(); + + // Act + var act = () => new ConnectionReferenceService(null!, flowService, logger); + + // Assert + act.Should().Throw() + .And.ParamName.Should().Be("pool"); + } + + [Fact] + public void Constructor_ThrowsOnNullFlowService() + { + // Arrange + var pool = new Mock().Object; + var logger = new NullLogger(); + + // Act + var act = () => new ConnectionReferenceService(pool, null!, logger); + + // Assert + act.Should().Throw() + .And.ParamName.Should().Be("flowService"); + } + + [Fact] + public void Constructor_ThrowsOnNullLogger() + { + // Arrange + var pool = new Mock().Object; + var flowService = new Mock().Object; + + // Act + var act = () => new ConnectionReferenceService(pool, flowService, null!); + + // Assert + act.Should().Throw() + .And.ParamName.Should().Be("logger"); + } + + [Fact] + public void Constructor_CreatesInstance() + { + // Arrange + var pool = new Mock().Object; + var flowService = new Mock().Object; + var logger = new NullLogger(); + + // Act + var service = new ConnectionReferenceService(pool, flowService, logger); + + // Assert + service.Should().NotBeNull(); + } + + [Fact] + public void AddDataverseConnectionPool_RegistersIConnectionReferenceService() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDataverseConnectionPool(options => + { + options.Connections.Add(new DataverseConnection("Primary") + { + Url = "https://test.crm.dynamics.com", + ClientId = "test-client-id", + ClientSecret = "test-secret", + AuthType = DataverseAuthType.ClientSecret + }); + }); + + // Act + var provider = services.BuildServiceProvider(); + var crService = provider.GetService(); + + // Assert + crService.Should().NotBeNull(); + crService.Should().BeOfType(); + } + + [Fact] + public void AddDataverseConnectionPool_ConnectionReferenceServiceIsTransient() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDataverseConnectionPool(options => + { + options.Connections.Add(new DataverseConnection("Primary") + { + Url = "https://test.crm.dynamics.com", + ClientId = "test-client-id", + ClientSecret = "test-secret", + AuthType = DataverseAuthType.ClientSecret + }); + }); + + // Act + var provider = services.BuildServiceProvider(); + var service1 = provider.GetService(); + var service2 = provider.GetService(); + + // Assert + service1.Should().NotBeSameAs(service2); + } +} diff --git a/tests/PPDS.Dataverse.Tests/Services/DeploymentSettingsServiceTests.cs b/tests/PPDS.Dataverse.Tests/Services/DeploymentSettingsServiceTests.cs new file mode 100644 index 00000000..8f95ffa8 --- /dev/null +++ b/tests/PPDS.Dataverse.Tests/Services/DeploymentSettingsServiceTests.cs @@ -0,0 +1,507 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using PPDS.Dataverse.Configuration; +using PPDS.Dataverse.DependencyInjection; +using PPDS.Dataverse.Pooling; +using PPDS.Dataverse.Services; +using Xunit; + +namespace PPDS.Dataverse.Tests.Services; + +public class DeploymentSettingsServiceTests +{ + [Fact] + public void Constructor_ThrowsOnNullEnvVarService() + { + // Arrange + var connectionRefService = new Mock().Object; + var logger = new NullLogger(); + + // Act + var act = () => new DeploymentSettingsService(null!, connectionRefService, logger); + + // Assert + act.Should().Throw() + .And.ParamName.Should().Be("envVarService"); + } + + [Fact] + public void Constructor_ThrowsOnNullConnectionRefService() + { + // Arrange + var envVarService = new Mock().Object; + var logger = new NullLogger(); + + // Act + var act = () => new DeploymentSettingsService(envVarService, null!, logger); + + // Assert + act.Should().Throw() + .And.ParamName.Should().Be("connectionRefService"); + } + + [Fact] + public void Constructor_ThrowsOnNullLogger() + { + // Arrange + var envVarService = new Mock().Object; + var connectionRefService = new Mock().Object; + + // Act + var act = () => new DeploymentSettingsService(envVarService, connectionRefService, null!); + + // Assert + act.Should().Throw() + .And.ParamName.Should().Be("logger"); + } + + [Fact] + public void Constructor_CreatesInstance() + { + // Arrange + var envVarService = new Mock().Object; + var connectionRefService = new Mock().Object; + var logger = new NullLogger(); + + // Act + var service = new DeploymentSettingsService(envVarService, connectionRefService, logger); + + // Assert + service.Should().NotBeNull(); + } + + [Fact] + public void AddDataverseConnectionPool_RegistersIDeploymentSettingsService() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDataverseConnectionPool(options => + { + options.Connections.Add(new DataverseConnection("Primary") + { + Url = "https://test.crm.dynamics.com", + ClientId = "test-client-id", + ClientSecret = "test-secret", + AuthType = DataverseAuthType.ClientSecret + }); + }); + + // Act + var provider = services.BuildServiceProvider(); + var dsService = provider.GetService(); + + // Assert + dsService.Should().NotBeNull(); + dsService.Should().BeOfType(); + } + + [Fact] + public void AddDataverseConnectionPool_DeploymentSettingsServiceIsTransient() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDataverseConnectionPool(options => + { + options.Connections.Add(new DataverseConnection("Primary") + { + Url = "https://test.crm.dynamics.com", + ClientId = "test-client-id", + ClientSecret = "test-secret", + AuthType = DataverseAuthType.ClientSecret + }); + }); + + // Act + var provider = services.BuildServiceProvider(); + var service1 = provider.GetService(); + var service2 = provider.GetService(); + + // Assert + service1.Should().NotBeSameAs(service2); + } + + [Fact] + public async Task GenerateAsync_ReturnsSettingsWithEnvVarsAndConnectionRefs() + { + // Arrange + var envVarService = new Mock(); + envVarService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() + { + Id = Guid.NewGuid(), + SchemaName = "cr_TestVar1", + DisplayName = "Test Variable 1", + Type = "String", + CurrentValue = "value1" + }, + new() + { + Id = Guid.NewGuid(), + SchemaName = "cr_TestVar2", + DisplayName = "Test Variable 2", + Type = "String", + DefaultValue = "default2" + } + }); + + var connectionRefService = new Mock(); + connectionRefService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() + { + Id = Guid.NewGuid(), + LogicalName = "cr_dataverse", + ConnectionId = "conn-123", + ConnectorId = "/providers/Microsoft.PowerApps/apis/shared_commondataserviceforapps" + } + }); + + var logger = new NullLogger(); + var service = new DeploymentSettingsService(envVarService.Object, connectionRefService.Object, logger); + + // Act + var result = await service.GenerateAsync("TestSolution"); + + // Assert + result.EnvironmentVariables.Should().HaveCount(2); + result.EnvironmentVariables[0].SchemaName.Should().Be("cr_TestVar1"); + result.EnvironmentVariables[0].Value.Should().Be("value1"); + result.EnvironmentVariables[1].SchemaName.Should().Be("cr_TestVar2"); + result.EnvironmentVariables[1].Value.Should().Be("default2"); + + result.ConnectionReferences.Should().HaveCount(1); + result.ConnectionReferences[0].LogicalName.Should().Be("cr_dataverse"); + result.ConnectionReferences[0].ConnectionId.Should().Be("conn-123"); + } + + [Fact] + public async Task GenerateAsync_ExcludesSecretEnvironmentVariables() + { + // Arrange + var envVarService = new Mock(); + envVarService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = Guid.NewGuid(), SchemaName = "cr_NormalVar", Type = "String", CurrentValue = "normal" }, + new() { Id = Guid.NewGuid(), SchemaName = "cr_SecretVar", Type = "Secret", CurrentValue = "secret-value" } + }); + + var connectionRefService = new Mock(); + connectionRefService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var logger = new NullLogger(); + var service = new DeploymentSettingsService(envVarService.Object, connectionRefService.Object, logger); + + // Act + var result = await service.GenerateAsync("TestSolution"); + + // Assert + result.EnvironmentVariables.Should().HaveCount(1); + result.EnvironmentVariables[0].SchemaName.Should().Be("cr_NormalVar"); + } + + [Fact] + public async Task GenerateAsync_SortsEntriesByName() + { + // Arrange + var envVarService = new Mock(); + envVarService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = Guid.NewGuid(), SchemaName = "cr_Zebra", Type = "String" }, + new() { Id = Guid.NewGuid(), SchemaName = "cr_Apple", Type = "String" }, + new() { Id = Guid.NewGuid(), SchemaName = "cr_Mango", Type = "String" } + }); + + var connectionRefService = new Mock(); + connectionRefService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = Guid.NewGuid(), LogicalName = "cr_zebra_conn" }, + new() { Id = Guid.NewGuid(), LogicalName = "cr_apple_conn" } + }); + + var logger = new NullLogger(); + var service = new DeploymentSettingsService(envVarService.Object, connectionRefService.Object, logger); + + // Act + var result = await service.GenerateAsync("TestSolution"); + + // Assert + result.EnvironmentVariables.Select(ev => ev.SchemaName) + .Should().BeEquivalentTo(new[] { "cr_Apple", "cr_Mango", "cr_Zebra" }, + options => options.WithStrictOrdering()); + + result.ConnectionReferences.Select(cr => cr.LogicalName) + .Should().BeEquivalentTo(new[] { "cr_apple_conn", "cr_zebra_conn" }, + options => options.WithStrictOrdering()); + } + + [Fact] + public async Task SyncAsync_PreservesExistingValues() + { + // Arrange + var envVarService = new Mock(); + envVarService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = Guid.NewGuid(), SchemaName = "cr_Var1", Type = "String", CurrentValue = "new-value" } + }); + + var connectionRefService = new Mock(); + connectionRefService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = Guid.NewGuid(), LogicalName = "cr_conn1", ConnectionId = "new-conn-id" } + }); + + var existingSettings = new DeploymentSettingsFile + { + EnvironmentVariables = new List + { + new() { SchemaName = "cr_Var1", Value = "preserved-value" } + }, + ConnectionReferences = new List + { + new() { LogicalName = "cr_conn1", ConnectionId = "preserved-conn-id", ConnectorId = "preserved-connector" } + } + }; + + var logger = new NullLogger(); + var service = new DeploymentSettingsService(envVarService.Object, connectionRefService.Object, logger); + + // Act + var result = await service.SyncAsync("TestSolution", existingSettings); + + // Assert + result.Settings.EnvironmentVariables[0].Value.Should().Be("preserved-value"); + result.Settings.ConnectionReferences[0].ConnectionId.Should().Be("preserved-conn-id"); + result.EnvironmentVariables.Preserved.Should().Be(1); + result.ConnectionReferences.Preserved.Should().Be(1); + } + + [Fact] + public async Task SyncAsync_AddsNewEntries() + { + // Arrange + var envVarService = new Mock(); + envVarService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = Guid.NewGuid(), SchemaName = "cr_NewVar", Type = "String", CurrentValue = "new-value" } + }); + + var connectionRefService = new Mock(); + connectionRefService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = Guid.NewGuid(), LogicalName = "cr_new_conn", ConnectionId = "new-conn-id", ConnectorId = "new-connector" } + }); + + var existingSettings = new DeploymentSettingsFile + { + EnvironmentVariables = new List(), + ConnectionReferences = new List() + }; + + var logger = new NullLogger(); + var service = new DeploymentSettingsService(envVarService.Object, connectionRefService.Object, logger); + + // Act + var result = await service.SyncAsync("TestSolution", existingSettings); + + // Assert + result.Settings.EnvironmentVariables.Should().HaveCount(1); + result.Settings.EnvironmentVariables[0].Value.Should().Be("new-value"); + result.EnvironmentVariables.Added.Should().Be(1); + result.ConnectionReferences.Added.Should().Be(1); + } + + [Fact] + public async Task SyncAsync_ReportsRemovedEntries() + { + // Arrange + var envVarService = new Mock(); + envVarService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var connectionRefService = new Mock(); + connectionRefService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var existingSettings = new DeploymentSettingsFile + { + EnvironmentVariables = new List + { + new() { SchemaName = "cr_OldVar", Value = "old-value" } + }, + ConnectionReferences = new List + { + new() { LogicalName = "cr_old_conn", ConnectionId = "old-id" } + } + }; + + var logger = new NullLogger(); + var service = new DeploymentSettingsService(envVarService.Object, connectionRefService.Object, logger); + + // Act + var result = await service.SyncAsync("TestSolution", existingSettings); + + // Assert + result.Settings.EnvironmentVariables.Should().BeEmpty(); + result.Settings.ConnectionReferences.Should().BeEmpty(); + result.EnvironmentVariables.Removed.Should().Be(1); + result.ConnectionReferences.Removed.Should().Be(1); + } + + [Fact] + public async Task ValidateAsync_DetectsStaleEntries() + { + // Arrange + var envVarService = new Mock(); + envVarService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var connectionRefService = new Mock(); + connectionRefService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var settings = new DeploymentSettingsFile + { + EnvironmentVariables = new List + { + new() { SchemaName = "cr_StaleVar", Value = "value" } + }, + ConnectionReferences = new List + { + new() { LogicalName = "cr_stale_conn", ConnectionId = "id" } + } + }; + + var logger = new NullLogger(); + var service = new DeploymentSettingsService(envVarService.Object, connectionRefService.Object, logger); + + // Act + var result = await service.ValidateAsync("TestSolution", settings); + + // Assert + result.Issues.Should().Contain(i => i.Name == "cr_StaleVar" && i.Severity == ValidationSeverity.Warning); + result.Issues.Should().Contain(i => i.Name == "cr_stale_conn" && i.Severity == ValidationSeverity.Warning); + } + + [Fact] + public async Task ValidateAsync_DetectsMissingRequiredEnvVars() + { + // Arrange + var envVarService = new Mock(); + envVarService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = Guid.NewGuid(), SchemaName = "cr_RequiredVar", Type = "String", IsRequired = true } + }); + + var connectionRefService = new Mock(); + connectionRefService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var settings = new DeploymentSettingsFile + { + EnvironmentVariables = new List(), + ConnectionReferences = new List() + }; + + var logger = new NullLogger(); + var service = new DeploymentSettingsService(envVarService.Object, connectionRefService.Object, logger); + + // Act + var result = await service.ValidateAsync("TestSolution", settings); + + // Assert + result.Issues.Should().Contain(i => + i.Name == "cr_RequiredVar" && + i.Severity == ValidationSeverity.Error && + i.Message.Contains("missing")); + } + + [Fact] + public async Task ValidateAsync_DetectsEmptyRequiredEnvVarValues() + { + // Arrange + var envVarService = new Mock(); + envVarService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = Guid.NewGuid(), SchemaName = "cr_RequiredVar", Type = "String", IsRequired = true } + }); + + var connectionRefService = new Mock(); + connectionRefService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var settings = new DeploymentSettingsFile + { + EnvironmentVariables = new List + { + new() { SchemaName = "cr_RequiredVar", Value = "" } + }, + ConnectionReferences = new List() + }; + + var logger = new NullLogger(); + var service = new DeploymentSettingsService(envVarService.Object, connectionRefService.Object, logger); + + // Act + var result = await service.ValidateAsync("TestSolution", settings); + + // Assert + result.Issues.Should().Contain(i => + i.Name == "cr_RequiredVar" && + i.Severity == ValidationSeverity.Error && + i.Message.Contains("empty")); + } + + [Fact] + public async Task ValidateAsync_WarnsOnMissingConnectionId() + { + // Arrange + var envVarService = new Mock(); + envVarService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var connectionRefService = new Mock(); + connectionRefService.Setup(s => s.ListAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + new() { Id = Guid.NewGuid(), LogicalName = "cr_unbound", ConnectorId = "connector-id" } + }); + + var settings = new DeploymentSettingsFile + { + EnvironmentVariables = new List(), + ConnectionReferences = new List + { + new() { LogicalName = "cr_unbound", ConnectionId = "", ConnectorId = "connector-id" } + } + }; + + var logger = new NullLogger(); + var service = new DeploymentSettingsService(envVarService.Object, connectionRefService.Object, logger); + + // Act + var result = await service.ValidateAsync("TestSolution", settings); + + // Assert + result.Issues.Should().Contain(i => + i.Name == "cr_unbound" && + i.Severity == ValidationSeverity.Warning && + i.Message.Contains("ConnectionId")); + } +} diff --git a/tests/PPDS.Dataverse.Tests/Services/FlowServiceTests.cs b/tests/PPDS.Dataverse.Tests/Services/FlowServiceTests.cs new file mode 100644 index 00000000..22fbc9f0 --- /dev/null +++ b/tests/PPDS.Dataverse.Tests/Services/FlowServiceTests.cs @@ -0,0 +1,108 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using PPDS.Dataverse.Configuration; +using PPDS.Dataverse.DependencyInjection; +using PPDS.Dataverse.Pooling; +using PPDS.Dataverse.Services; +using Xunit; + +namespace PPDS.Dataverse.Tests.Services; + +public class FlowServiceTests +{ + [Fact] + public void Constructor_ThrowsOnNullConnectionPool() + { + // Arrange + var logger = new NullLogger(); + + // Act + var act = () => new FlowService(null!, logger); + + // Assert + act.Should().Throw() + .And.ParamName.Should().Be("pool"); + } + + [Fact] + public void Constructor_ThrowsOnNullLogger() + { + // Arrange + var pool = new Mock().Object; + + // Act + var act = () => new FlowService(pool, null!); + + // Assert + act.Should().Throw() + .And.ParamName.Should().Be("logger"); + } + + [Fact] + public void Constructor_CreatesInstance() + { + // Arrange + var pool = new Mock().Object; + var logger = new NullLogger(); + + // Act + var service = new FlowService(pool, logger); + + // Assert + service.Should().NotBeNull(); + } + + [Fact] + public void AddDataverseConnectionPool_RegistersIFlowService() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDataverseConnectionPool(options => + { + options.Connections.Add(new DataverseConnection("Primary") + { + Url = "https://test.crm.dynamics.com", + ClientId = "test-client-id", + ClientSecret = "test-secret", + AuthType = DataverseAuthType.ClientSecret + }); + }); + + // Act + var provider = services.BuildServiceProvider(); + var flowService = provider.GetService(); + + // Assert + flowService.Should().NotBeNull(); + flowService.Should().BeOfType(); + } + + [Fact] + public void AddDataverseConnectionPool_FlowServiceIsTransient() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDataverseConnectionPool(options => + { + options.Connections.Add(new DataverseConnection("Primary") + { + Url = "https://test.crm.dynamics.com", + ClientId = "test-client-id", + ClientSecret = "test-secret", + AuthType = DataverseAuthType.ClientSecret + }); + }); + + // Act + var provider = services.BuildServiceProvider(); + var service1 = provider.GetService(); + var service2 = provider.GetService(); + + // Assert + service1.Should().NotBeSameAs(service2); + } +} diff --git a/tests/PPDS.Dataverse.Tests/Services/Utilities/FlowClientDataParserTests.cs b/tests/PPDS.Dataverse.Tests/Services/Utilities/FlowClientDataParserTests.cs new file mode 100644 index 00000000..838915ca --- /dev/null +++ b/tests/PPDS.Dataverse.Tests/Services/Utilities/FlowClientDataParserTests.cs @@ -0,0 +1,266 @@ +using FluentAssertions; +using PPDS.Dataverse.Services.Utilities; +using Xunit; + +namespace PPDS.Dataverse.Tests.Services.Utilities; + +public class FlowClientDataParserTests +{ + [Fact] + public void ExtractConnectionReferenceLogicalNames_NullClientData_ReturnsEmptyList() + { + // Act + var result = FlowClientDataParser.ExtractConnectionReferenceLogicalNames(null); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void ExtractConnectionReferenceLogicalNames_EmptyClientData_ReturnsEmptyList() + { + // Act + var result = FlowClientDataParser.ExtractConnectionReferenceLogicalNames(""); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void ExtractConnectionReferenceLogicalNames_WhitespaceClientData_ReturnsEmptyList() + { + // Act + var result = FlowClientDataParser.ExtractConnectionReferenceLogicalNames(" "); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void ExtractConnectionReferenceLogicalNames_InvalidJson_ReturnsEmptyList() + { + // Act + var result = FlowClientDataParser.ExtractConnectionReferenceLogicalNames("not valid json {{{"); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void ExtractConnectionReferenceLogicalNames_PropertiesConnectionReferences_ExtractsKeys() + { + // Arrange + var clientData = """ + { + "properties": { + "connectionReferences": { + "cr_dataverse_connection": { "connectionId": "abc123" }, + "cr_sharepoint_connection": { "connectionId": "def456" } + } + } + } + """; + + // Act + var result = FlowClientDataParser.ExtractConnectionReferenceLogicalNames(clientData); + + // Assert + result.Should().HaveCount(2); + result.Should().Contain("cr_dataverse_connection"); + result.Should().Contain("cr_sharepoint_connection"); + } + + [Fact] + public void ExtractConnectionReferenceLogicalNames_TopLevelConnectionReferences_ExtractsKeys() + { + // Arrange + var clientData = """ + { + "connectionReferences": { + "new_cr_outlook": { "api": "/apis/outlook" }, + "new_cr_teams": { "api": "/apis/teams" } + } + } + """; + + // Act + var result = FlowClientDataParser.ExtractConnectionReferenceLogicalNames(clientData); + + // Assert + result.Should().HaveCount(2); + result.Should().Contain("new_cr_outlook"); + result.Should().Contain("new_cr_teams"); + } + + [Fact] + public void ExtractConnectionReferenceLogicalNames_DefinitionConnectionReferences_ExtractsKeys() + { + // Arrange + var clientData = """ + { + "definition": { + "connectionReferences": { + "ppds_cds_connection": {} + } + } + } + """; + + // Act + var result = FlowClientDataParser.ExtractConnectionReferenceLogicalNames(clientData); + + // Assert + result.Should().ContainSingle(); + result.Should().Contain("ppds_cds_connection"); + } + + [Fact] + public void ExtractConnectionReferenceLogicalNames_NoConnectionReferences_ReturnsEmptyList() + { + // Arrange + var clientData = """ + { + "properties": { + "displayName": "My Flow", + "state": "Started" + } + } + """; + + // Act + var result = FlowClientDataParser.ExtractConnectionReferenceLogicalNames(clientData); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void ExtractConnectionReferenceLogicalNames_EmptyConnectionReferences_ReturnsEmptyList() + { + // Arrange + var clientData = """ + { + "properties": { + "connectionReferences": {} + } + } + """; + + // Act + var result = FlowClientDataParser.ExtractConnectionReferenceLogicalNames(clientData); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void ExtractConnectionReferenceLogicalNames_ConnectionReferencesNotObject_ReturnsEmptyList() + { + // Arrange - connectionReferences is an array instead of object + var clientData = """ + { + "properties": { + "connectionReferences": ["cr1", "cr2"] + } + } + """; + + // Act + var result = FlowClientDataParser.ExtractConnectionReferenceLogicalNames(clientData); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void ExtractConnectionReferenceLogicalNames_PreservesCase() + { + // Arrange - mixed case logical names should be preserved + var clientData = """ + { + "properties": { + "connectionReferences": { + "CR_Dataverse_Connection": {}, + "cr_sharepoint_connection": {}, + "NEW_CR_OUTLOOK": {} + } + } + } + """; + + // Act + var result = FlowClientDataParser.ExtractConnectionReferenceLogicalNames(clientData); + + // Assert + result.Should().HaveCount(3); + result.Should().Contain("CR_Dataverse_Connection"); + result.Should().Contain("cr_sharepoint_connection"); + result.Should().Contain("NEW_CR_OUTLOOK"); + } + + [Fact] + public void ExtractConnectionReferenceLogicalNames_PropertiesTakesPrecedence() + { + // Arrange - when both properties.connectionReferences and top-level exist + var clientData = """ + { + "properties": { + "connectionReferences": { + "from_properties": {} + } + }, + "connectionReferences": { + "from_top_level": {} + } + } + """; + + // Act + var result = FlowClientDataParser.ExtractConnectionReferenceLogicalNames(clientData); + + // Assert + result.Should().ContainSingle(); + result.Should().Contain("from_properties"); + result.Should().NotContain("from_top_level"); + } + + [Fact] + public void ExtractConnectionReferenceLogicalNames_RealWorldClientData_ExtractsCorrectly() + { + // Arrange - realistic client data structure + var clientData = """ + { + "properties": { + "displayName": "Send Email on Record Create", + "definition": { + "triggers": {}, + "actions": {} + }, + "connectionReferences": { + "shared_commondataserviceforapps": { + "connectionReferenceLogicalName": "ppds_SharedCommonDataServiceForApps", + "api": { + "name": "shared_commondataserviceforapps" + } + }, + "shared_office365": { + "connectionReferenceLogicalName": "ppds_SharedOffice365", + "api": { + "name": "shared_office365" + } + } + }, + "state": "Started" + } + } + """; + + // Act + var result = FlowClientDataParser.ExtractConnectionReferenceLogicalNames(clientData); + + // Assert + result.Should().HaveCount(2); + result.Should().Contain("shared_commondataserviceforapps"); + result.Should().Contain("shared_office365"); + } +}