diff --git a/src/TALXIS.CLI.Platform.Dataverse.Runtime/Bootstrapping/DataverseConnectionProviderBootstrapper.cs b/src/TALXIS.CLI.Platform.Dataverse.Runtime/Bootstrapping/DataverseConnectionProviderBootstrapper.cs index 6f49357c..c48da8b0 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Runtime/Bootstrapping/DataverseConnectionProviderBootstrapper.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Runtime/Bootstrapping/DataverseConnectionProviderBootstrapper.cs @@ -136,49 +136,39 @@ private sealed record BindingNames( ? null : await FindExistingProfileAsync(existingConnection.Id, acquired.Credential.Id, ct).ConfigureAwait(false); - if (!string.IsNullOrEmpty(explicitName)) - return new BindingNames(explicitName, existingConnection?.Id ?? explicitName); + // Resolve environment metadata on every path (not only when deriving + // a name) so EnvironmentType reaches the connection and the + // destructive-operation guard can tell dev/test apart from production. + var environment = await TryGetEnvironmentAsync(request, environmentUrl, cloud, acquired, ct).ConfigureAwait(false); - if (existingProfile is not null) - return new BindingNames(existingProfile.Id, existingProfile.ConnectionRef!); + // Fall back to metadata already stored on the connection so a failed + // catalog lookup does not wipe values a previous refresh persisted. + var resolvedEnvironmentId = environment?.EnvironmentId ?? existingConnection?.EnvironmentId; + var resolvedDisplayName = environment?.DisplayName ?? existingConnection?.DisplayName; + var resolvedEnvironmentType = environment?.EnvironmentType ?? existingConnection?.EnvironmentType; + var resolvedOrganizationId = environment?.OrganizationId; + if (resolvedOrganizationId is null && Guid.TryParse(existingConnection?.OrganizationId, out var existingOrgId)) + resolvedOrganizationId = existingOrgId; - string? preferredBase = null; - Guid? resolvedEnvironmentId = null; - string? resolvedDisplayName = null; - EnvironmentType? resolvedEnvironmentType = null; - Guid? resolvedOrganizationId = null; - var ephemeralConnection = new Connection + if (!string.IsNullOrEmpty(explicitName)) { - Id = "(ephemeral)", - Provider = request.Provider, - EnvironmentUrl = environmentUrl.ToString().TrimEnd('/'), - Cloud = cloud, - TenantId = request.TenantId ?? acquired.TenantId, - }; + return new BindingNames(explicitName, existingConnection?.Id ?? explicitName, + resolvedEnvironmentId, resolvedDisplayName, resolvedEnvironmentType, resolvedOrganizationId); + } - try + if (existingProfile is not null) { - var environment = await _environmentCatalog - .TryGetByEnvironmentUrlAsync(ephemeralConnection, acquired.Credential, environmentUrl, ct) - .ConfigureAwait(false); - if (environment is not null) - { - // Include tenant domain in the slug so multi-customer users can - // distinguish environments across tenants at a glance. - var tenantDomain = CredentialAliasResolver.ExtractTenantShortName(acquired.Upn); - preferredBase = ProviderUrlResolver.DeriveDefaultName(environment.DisplayName, request.EnvironmentUrl, tenantDomain); - resolvedEnvironmentId = environment.EnvironmentId; - resolvedDisplayName = environment.DisplayName; - resolvedEnvironmentType = environment.EnvironmentType; - resolvedOrganizationId = environment.OrganizationId; - } + return new BindingNames(existingProfile.Id, existingProfile.ConnectionRef!, + resolvedEnvironmentId, resolvedDisplayName, resolvedEnvironmentType, resolvedOrganizationId); } - catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) + + string? preferredBase = null; + if (environment is not null) { - _logger.LogWarning( - ex, - "Could not resolve Power Platform environment metadata for '{Url}'. Falling back to the URL host.", - request.EnvironmentUrl); + // Include tenant domain in the slug so multi-customer users can + // distinguish environments across tenants at a glance. + var tenantDomain = CredentialAliasResolver.ExtractTenantShortName(acquired.Upn); + preferredBase = ProviderUrlResolver.DeriveDefaultName(environment.DisplayName, request.EnvironmentUrl, tenantDomain); } if (string.IsNullOrEmpty(preferredBase)) @@ -214,6 +204,38 @@ await _profiles.GetAsync(candidate, existsCt).ConfigureAwait(false) is not null resolvedDisplayName, resolvedEnvironmentType, resolvedOrganizationId); } + private async Task TryGetEnvironmentAsync( + ProfileBootstrapRequest request, + Uri environmentUrl, + CloudInstance cloud, + InteractiveCredentialResult acquired, + CancellationToken ct) + { + var ephemeralConnection = new Connection + { + Id = "(ephemeral)", + Provider = request.Provider, + EnvironmentUrl = environmentUrl.ToString().TrimEnd('/'), + Cloud = cloud, + TenantId = request.TenantId ?? acquired.TenantId, + }; + + try + { + return await _environmentCatalog + .TryGetByEnvironmentUrlAsync(ephemeralConnection, acquired.Credential, environmentUrl, ct) + .ConfigureAwait(false); + } + catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException) + { + _logger.LogWarning( + ex, + "Could not resolve Power Platform environment metadata for '{Url}'. Falling back to the URL host.", + request.EnvironmentUrl); + return null; + } + } + private async Task FindExistingConnectionAsync( ProfileBootstrapRequest request, CloudInstance cloud, diff --git a/tests/TALXIS.CLI.Tests/Config/Bootstrapping/ProfileCreateOneLinerTests.cs b/tests/TALXIS.CLI.Tests/Config/Bootstrapping/ProfileCreateOneLinerTests.cs index ecf7ae6c..e7761a14 100644 --- a/tests/TALXIS.CLI.Tests/Config/Bootstrapping/ProfileCreateOneLinerTests.cs +++ b/tests/TALXIS.CLI.Tests/Config/Bootstrapping/ProfileCreateOneLinerTests.cs @@ -282,6 +282,76 @@ public async Task UrlMode_ExplicitName_OverridesDerived() Assert.NotNull(await connections.GetAsync("my-profile", default)); } + [Fact] + public async Task UrlMode_ExplicitName_StillResolvesEnvironmentMetadata() + { + var catalog = new CommandTestHost.FakePowerPlatformEnvironmentCatalog(); + catalog.Add(new PowerPlatformEnvironmentSummary( + EnvironmentId: Guid.Parse("11111111-2222-3333-4444-555555555555"), + DisplayName: "Contoso DevBox", + EnvironmentUrl: new Uri("https://devbox.crm4.dynamics.com/"), + UniqueName: "contoso-devbox", + DomainName: "devbox", + OrganizationId: Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + EnvironmentType: EnvironmentType.Developer)); + + using var host = new CommandTestHost(environmentCatalog: catalog); + using (OutputWriter.RedirectTo(new StringWriter())) + { + var exit = await new ProfileCreateCliCommand + { + Url = "https://devbox.crm4.dynamics.com/", + Name = "devbox-2896", + }.RunAsync(); + Assert.Equal(0, exit); + } + + var connections = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + var connection = await connections.GetAsync("devbox-2896", default); + Assert.NotNull(connection); + Assert.Equal(EnvironmentType.Developer, connection!.EnvironmentType); + Assert.Equal("Contoso DevBox", connection.DisplayName); + Assert.Equal(Guid.Parse("11111111-2222-3333-4444-555555555555"), connection.EnvironmentId); + Assert.Equal("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", connection.OrganizationId); + } + + [Fact] + public async Task UrlMode_Recreate_WhenLookupFails_PreservesExistingMetadata() + { + var catalog = new CommandTestHost.FakePowerPlatformEnvironmentCatalog(); + catalog.Add(new PowerPlatformEnvironmentSummary( + EnvironmentId: Guid.Parse("11111111-2222-3333-4444-555555555555"), + DisplayName: "Contoso DevBox", + EnvironmentUrl: new Uri("https://devbox.crm4.dynamics.com/"), + UniqueName: "contoso-devbox", + DomainName: "devbox", + OrganizationId: Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), + EnvironmentType: EnvironmentType.Developer)); + + using var host = new CommandTestHost(environmentCatalog: catalog); + using (OutputWriter.RedirectTo(new StringWriter())) + { + Assert.Equal(0, await new ProfileCreateCliCommand + { + Url = "https://devbox.crm4.dynamics.com/", + Name = "devbox-2896", + }.RunAsync()); + + catalog.Failure = new InvalidOperationException("admin lookup failed"); + Assert.Equal(0, await new ProfileCreateCliCommand + { + Url = "https://devbox.crm4.dynamics.com/", + Name = "devbox-2896", + }.RunAsync()); + } + + var connections = (IConnectionStore)host.Provider.GetService(typeof(IConnectionStore))!; + var connection = await connections.GetAsync("devbox-2896", default); + Assert.NotNull(connection); + Assert.Equal(EnvironmentType.Developer, connection!.EnvironmentType); + Assert.Equal("Contoso DevBox", connection.DisplayName); + } + [Fact] public async Task UrlMode_InfersSovereignCloud_FromEnvironmentUrl_WhenCloudOmitted() {