diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1edcb752d92..097baf256bb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -58,6 +58,14 @@ jobs: versionOverrideArg: ${{ inputs.versionOverrideArg }} targets: '[{"os": "windows-latest", "runner": "windows-latest", "rids": "win-x64"}]' + build_cli_archive_windows_arm: + if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'microsoft' }} + name: Build native CLI archive (Windows ARM64) + uses: ./.github/workflows/build-cli-native-archives.yml + with: + versionOverrideArg: ${{ inputs.versionOverrideArg }} + targets: '[{"os": "windows-11-arm", "runner": "windows-11-arm", "rids": "win-arm64"}]' + build_cli_archive_macos: name: Build native CLI archive (macOS) uses: ./.github/workflows/build-cli-native-archives.yml @@ -189,6 +197,86 @@ jobs: with: versionOverrideArg: ${{ inputs.versionOverrideArg }} + cli_starter_validation_windows: + name: Aspire CLI Starter Validation (${{ matrix.os.name }}) + runs-on: ${{ matrix.os.runner }} + timeout-minutes: 15 + needs: [build_packages, build_cli_archive_windows, build_cli_archive_windows_arm] + if: ${{ github.event_name == 'pull_request' && github.repository_owner == 'microsoft' }} + strategy: + fail-fast: false + matrix: + os: + - name: Windows + runner: windows-latest + artifact_suffix: windows-x64 + max_startup_seconds: 120 + resource_ready_timeout_seconds: 120 + - name: Windows ARM64 + runner: windows-11-arm + artifact_suffix: windows-arm64 + max_startup_seconds: 120 + resource_ready_timeout_seconds: 300 + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Checkout PR head + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Setup .NET + uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 + with: + global-json-file: global.json + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + + - name: Install Aspire CLI from PR dogfood script + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + $scriptPath = Join-Path $env:GITHUB_WORKSPACE "eng/scripts/get-aspire-cli-pr.ps1" + & $scriptPath ${{ github.event.pull_request.number }} -WorkflowRunId ${{ github.run_id }} -SkipExtension + + aspire --version + + - name: Create starter app and validate startup + timeout-minutes: 12 + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + & "$env:GITHUB_WORKSPACE/eng/scripts/cli-starter-validation.ps1" ` + -PRNumber ${{ github.event.pull_request.number }} ` + -MaxStartupSeconds ${{ matrix.os.max_startup_seconds }} ` + -ResourceReadyTimeoutSeconds ${{ matrix.os.resource_ready_timeout_seconds }} + + - name: Collect CLI logs + if: always() + shell: pwsh + run: | + $validationRoot = Join-Path $env:RUNNER_TEMP 'aspire-cli-starter-validation' + $cliLogsDir = Join-Path $HOME '.aspire\logs' + $sharedLogsDir = Join-Path $validationRoot 'cli-logs' + + if (Test-Path $cliLogsDir) { + New-Item -ItemType Directory -Path $validationRoot -Force | Out-Null + Copy-Item -Path $cliLogsDir -Destination $sharedLogsDir -Recurse -Force + } + + - name: Upload starter validation diagnostics + if: always() + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: cli-starter-validation-${{ matrix.os.artifact_suffix }} + path: ${{ runner.temp }}/aspire-cli-starter-validation + if-no-files-found: ignore + extension_tests_win: name: Run VS Code extension tests (Windows) runs-on: windows-latest @@ -237,6 +325,7 @@ jobs: build_cli_archive_windows, build_cli_archive_macos, extension_tests_win, + cli_starter_validation_windows, typescript_sdk_tests, java_sdk_tests, tests_no_nugets, @@ -296,6 +385,8 @@ jobs: # primary no-nugets matrix exceeds the overflow threshold. # - tests_requires_nugets_macos: some runs intentionally produce no macOS requires-nugets # tests, so an empty matrix and 'skipped' result are expected. + # - cli_starter_validation_windows: this job only runs for pull requests and is expected to + # be skipped for other workflow events. # All other jobs in this gate are required, and a 'skipped' result is treated as a failure. if: >- ${{ always() && @@ -303,10 +394,11 @@ jobs: contains(needs.*.result, 'cancelled') || (github.event_name == 'pull_request' && (needs.extension_tests_win.result == 'skipped' || + needs.cli_starter_validation_windows.result == 'skipped' || needs.typescript_sdk_tests.result == 'skipped' || needs.java_sdk_tests.result == 'skipped' || needs.tests_no_nugets.result == 'skipped' || - needs.tests_requires_nugets_linux.result == 'skipped' || + needs.tests_requires_nugets_linux.result == 'skipped' || needs.tests_requires_nugets_windows.result == 'skipped' || (fromJson(needs.setup_for_tests.outputs.tests_matrix_requires_nugets_macos).include[0] != null && needs.tests_requires_nugets_macos.result == 'skipped') || diff --git a/eng/scripts/cli-starter-validation.ps1 b/eng/scripts/cli-starter-validation.ps1 new file mode 100644 index 00000000000..8a8c9a3f447 --- /dev/null +++ b/eng/scripts/cli-starter-validation.ps1 @@ -0,0 +1,267 @@ +#!/usr/bin/env pwsh + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, HelpMessage = "Pull request number used to select the PR dogfood channel")] + [ValidateRange(1, [int]::MaxValue)] + [int]$PRNumber, + + [Parameter(HelpMessage = "Maximum number of seconds allowed for aspire start to complete")] + [ValidateRange(1, [int]::MaxValue)] + [int]$MaxStartupSeconds = 120, + + [Parameter(HelpMessage = "Maximum number of seconds to wait for each expected resource to reach the requested status")] + [ValidateRange(1, [int]::MaxValue)] + [int]$ResourceReadyTimeoutSeconds = 120, + + [Parameter(HelpMessage = "Directory used to store starter validation projects and diagnostics")] + [string]$ValidationRoot = "" +) + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true +Set-StrictMode -Version Latest + +function Get-ValidationRoot +{ + if (-not [string]::IsNullOrWhiteSpace($ValidationRoot)) + { + return $ValidationRoot + } + + if (-not [string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) + { + return (Join-Path $env:RUNNER_TEMP 'aspire-cli-starter-validation') + } + + return (Join-Path ([System.IO.Path]::GetTempPath()) 'aspire-cli-starter-validation') +} + +function Get-FileContentOrEmpty +{ + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + if (Test-Path $Path) + { + return (Get-Content -Raw $Path) + } + + return '' +} + +function Get-CombinedProcessOutput +{ + param( + [Parameter(Mandatory = $true)] + [string]$StdOutPath, + + [Parameter(Mandatory = $true)] + [string]$StdErrPath + ) + + $stdout = Get-FileContentOrEmpty -Path $StdOutPath + $stderr = Get-FileContentOrEmpty -Path $StdErrPath + + if ($stdout -and $stderr) + { + return ($stdout, $stderr) -join [Environment]::NewLine + } + + return "$stdout$stderr" +} + +$validationRootPath = Get-ValidationRoot +Remove-Item -Recurse -Force $validationRootPath -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Path $validationRootPath -Force | Out-Null + +$templates = @( + @{ + TemplateId = 'aspire-ts-starter' + ProjectName = 'AspireCliTsStarterSmoke' + ExpectedResources = @('app', 'frontend') + }, + @{ + TemplateId = 'aspire-starter' + ProjectName = 'AspireCliCsStarterSmoke' + ExpectedResources = @('apiservice') + } +) + +$failures = [System.Collections.Generic.List[string]]::new() + +foreach ($template in $templates) +{ + $templateId = [string]$template.TemplateId + $projectName = [string]$template.ProjectName + $expectedResources = @($template.ExpectedResources) + $templateRoot = Join-Path $validationRootPath $templateId + $diagnosticsDir = Join-Path $templateRoot 'diagnostics' + $projectRoot = Join-Path $templateRoot $projectName + $startStdOutPath = Join-Path $diagnosticsDir 'aspire-start.stdout.log' + $startStdErrPath = Join-Path $diagnosticsDir 'aspire-start.stderr.log' + $startCombinedPath = Join-Path $diagnosticsDir 'aspire-start.log' + $preStartStopLogPath = Join-Path $diagnosticsDir 'aspire-stop-before-start.log' + $stopLogPath = Join-Path $diagnosticsDir 'aspire-stop.log' + + New-Item -ItemType Directory -Path $templateRoot -Force | Out-Null + New-Item -ItemType Directory -Path $diagnosticsDir -Force | Out-Null + + Push-Location $templateRoot + try + { + try + { + aspire new $templateId --name $projectName --output $projectRoot --channel "pr-$PRNumber" --non-interactive --nologo + + try + { + aspire stop *> $preStartStopLogPath + } + catch + { + $preStartStopOutput = Get-FileContentOrEmpty -Path $preStartStopLogPath + if ($preStartStopOutput -notmatch 'No running apphost found\.') + { + Write-Warning "$templateId pre-start cleanup with aspire stop failed: $($_.Exception.Message)" + if ($preStartStopOutput) + { + Write-Host $preStartStopOutput + } + } + } + + $startAt = Get-Date + $process = Start-Process -FilePath 'aspire' ` + -ArgumentList @('start') ` + -WorkingDirectory $projectRoot ` + -RedirectStandardOutput $startStdOutPath ` + -RedirectStandardError $startStdErrPath ` + -PassThru + + try + { + $process | Wait-Process -Timeout $MaxStartupSeconds -ErrorAction Stop + } + catch + { + if (-not $process.HasExited) + { + $process | Stop-Process -Force -ErrorAction SilentlyContinue + } + + throw "${templateId}: aspire start did not exit within $MaxStartupSeconds seconds." + } + + $elapsed = (Get-Date) - $startAt + $startOutput = Get-CombinedProcessOutput -StdOutPath $startStdOutPath -StdErrPath $startStdErrPath + + Set-Content -Path $startCombinedPath -Value $startOutput + + if ($process.ExitCode -ne 0) + { + throw "${templateId}: aspire start failed with exit code $($process.ExitCode)." + } + + if ($startOutput -match 'Timeout waiting for apphost to start') + { + throw "${templateId}: aspire start reported a startup timeout." + } + + if ($startOutput -notmatch 'Apphost started successfully\.') + { + throw "${templateId}: aspire start did not report a successful startup." + } + + Set-Location $projectRoot + + $resourcesStdOutPath = Join-Path $diagnosticsDir 'aspire-resources.stdout.log' + $resourcesStdErrPath = Join-Path $diagnosticsDir 'aspire-resources.stderr.log' + $resourcesCombinedPath = Join-Path $diagnosticsDir 'aspire-resources.log' + + $resourcesProcess = Start-Process -FilePath 'aspire' ` + -ArgumentList @('resources') ` + -WorkingDirectory $projectRoot ` + -RedirectStandardOutput $resourcesStdOutPath ` + -RedirectStandardError $resourcesStdErrPath ` + -Wait ` + -PassThru + + $resourcesOutput = Get-CombinedProcessOutput -StdOutPath $resourcesStdOutPath -StdErrPath $resourcesStdErrPath + + Set-Content -Path $resourcesCombinedPath -Value $resourcesOutput + Write-Host $resourcesOutput + + if ($resourcesProcess.ExitCode -ne 0) + { + throw "${templateId}: aspire resources failed with exit code $($resourcesProcess.ExitCode)." + } + + foreach ($resourceName in $expectedResources) + { + $sanitizedResourceName = $resourceName -replace '[^A-Za-z0-9_.-]', '_' + $waitStdOutPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.stdout.log" + $waitStdErrPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.stderr.log" + $waitCombinedPath = Join-Path $diagnosticsDir "aspire-wait-${sanitizedResourceName}.log" + + $waitProcess = Start-Process -FilePath 'aspire' ` + -ArgumentList @('wait', $resourceName, '--status', 'up', '--timeout', $ResourceReadyTimeoutSeconds) ` + -WorkingDirectory $projectRoot ` + -RedirectStandardOutput $waitStdOutPath ` + -RedirectStandardError $waitStdErrPath ` + -Wait ` + -PassThru + + $waitOutput = Get-CombinedProcessOutput -StdOutPath $waitStdOutPath -StdErrPath $waitStdErrPath + + Set-Content -Path $waitCombinedPath -Value $waitOutput + + if ($waitProcess.ExitCode -ne 0) + { + throw "${templateId}: aspire wait for resource $resourceName failed with exit code $($waitProcess.ExitCode)." + } + } + + Write-Host "$templateId started in $([math]::Round($elapsed.TotalSeconds, 2)) seconds." + } + catch + { + $message = $_.Exception.Message + Write-Warning $message + $failures.Add($message) + } + } + finally + { + if (Test-Path $projectRoot) + { + Push-Location $projectRoot + try + { + aspire stop *> $stopLogPath + } + catch + { + Write-Warning "$templateId cleanup with aspire stop failed: $($_.Exception.Message)" + $stopOutput = Get-FileContentOrEmpty -Path $stopLogPath + if ($stopOutput) + { + Write-Host $stopOutput + } + } + finally + { + Pop-Location + } + } + + Pop-Location + } +} + +if ($failures.Count -gt 0) +{ + throw ("Starter validation failures:`n- " + ($failures -join "`n- ")) +} diff --git a/src/Aspire.Cli/Certificates/CertificateService.cs b/src/Aspire.Cli/Certificates/CertificateService.cs index ca7d627d6b0..fc65a63b1e7 100644 --- a/src/Aspire.Cli/Certificates/CertificateService.cs +++ b/src/Aspire.Cli/Certificates/CertificateService.cs @@ -7,6 +7,7 @@ using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; +using Aspire.Cli.Utils; using Microsoft.AspNetCore.Certificates.Generation; namespace Aspire.Cli.Certificates; @@ -31,9 +32,12 @@ internal interface ICertificateService internal sealed class CertificateService( ICertificateToolRunner certificateToolRunner, IInteractionService interactionService, - AspireCliTelemetry telemetry) : ICertificateService + AspireCliTelemetry telemetry, + ICliHostEnvironment hostEnvironment, + Func? isWindows = null) : ICertificateService { private const string SslCertDirEnvVar = "SSL_CERT_DIR"; + private readonly Func _isWindows = isWindows ?? OperatingSystem.IsWindows; public async Task EnsureCertificatesTrustedAsync(CancellationToken cancellationToken) { @@ -74,6 +78,24 @@ private async Task HandleMachineReadableTrustAsync( // If not trusted at all, run the trust operation if (trustResult.IsNotTrusted) { + if (_isWindows() && !hostEnvironment.SupportsInteractiveInput) + { + if (!trustResult.HasCertificates) + { + var ensureResultCode = await interactionService.ShowStatusAsync( + InteractionServiceStrings.CheckingCertificates, + () => Task.FromResult(certificateToolRunner.EnsureHttpCertificateExists()), + emoji: KnownEmojis.LockedWithKey); + + if (!IsSuccessfulEnsureResult(ensureResultCode)) + { + interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.CertificatesMayNotBeFullyTrusted, ensureResultCode)); + } + } + + return; + } + var trustResultCode = await interactionService.ShowStatusAsync( InteractionServiceStrings.TrustingCertificates, () => Task.FromResult(certificateToolRunner.TrustHttpCertificate()), @@ -99,6 +121,10 @@ private async Task HandleMachineReadableTrustAsync( } } + private static bool IsSuccessfulEnsureResult(EnsureCertificateResult result) => + result is EnsureCertificateResult.Succeeded + or EnsureCertificateResult.ValidCertificatePresent; + private static void ConfigureSslCertDir(Dictionary environmentVariables) { // Get the dev-certs trust path (respects DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY override) diff --git a/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs b/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs index f800927769e..55474f9dab7 100644 --- a/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs @@ -15,6 +15,11 @@ internal interface ICertificateToolRunner /// CertificateTrustResult CheckHttpCertificate(); + /// + /// Ensures the HTTPS development certificate exists without trusting it. + /// + EnsureCertificateResult EnsureHttpCertificateExists(); + /// /// Trusts the HTTPS development certificate, creating one if necessary. /// diff --git a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs index ca9726743c3..fe41f34c17b 100644 --- a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs @@ -86,6 +86,16 @@ public EnsureCertificateResult TrustHttpCertificate() trust: true); } + public EnsureCertificateResult EnsureHttpCertificateExists() + { + var now = DateTimeOffset.Now; + return certificateManager.EnsureAspNetCoreHttpsDevelopmentCertificate( + now, + now.Add(TimeSpan.FromDays(365)), + trust: false, + isInteractive: false); + } + internal EnsureCertificateResult TrustHttpCertificateOnLinux(IEnumerable availableCertificates, DateTimeOffset now) { X509Certificate2? certificate = null; diff --git a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs index d6677f2a846..682ad1fb825 100644 --- a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs @@ -3,7 +3,10 @@ using System.Runtime.InteropServices; using Aspire.Cli.Certificates; +using Aspire.Cli.Interaction; +using Aspire.Cli.Telemetry; using Aspire.Cli.Tests.Utils; +using Aspire.Cli.Utils; using Microsoft.AspNetCore.Certificates.Generation; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.DependencyInjection; @@ -84,6 +87,13 @@ public async Task EnsureCertificatesTrustedAsync_WithNotTrustedCert_RunsTrustOpe } }; }; + options.CertificateServiceFactory = serviceProvider => + { + var certificateToolRunner = serviceProvider.GetRequiredService(); + var interactiveService = serviceProvider.GetRequiredService(); + var telemetry = serviceProvider.GetRequiredService(); + return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment(supportsInteractiveInput: true), isWindows: () => true); + }; }); var sp = services.BuildServiceProvider(); @@ -175,6 +185,13 @@ public async Task EnsureCertificatesTrustedAsync_WithNoCertificates_RunsTrustOpe } }; }; + options.CertificateServiceFactory = serviceProvider => + { + var certificateToolRunner = serviceProvider.GetRequiredService(); + var interactiveService = serviceProvider.GetRequiredService(); + var telemetry = serviceProvider.GetRequiredService(); + return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment(supportsInteractiveInput: true), isWindows: () => true); + }; }); var sp = services.BuildServiceProvider(); @@ -221,9 +238,116 @@ public async Task EnsureCertificatesTrustedAsync_TrustOperationFails_DisplaysWar Assert.NotNull(result); } + [Fact] + public async Task EnsureCertificatesTrustedAsync_OnWindowsCi_WithNotTrustedCert_SkipsTrustOperation() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var trustCalled = false; + var ensureCalled = false; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CertificateToolRunnerFactory = sp => + { + return new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => + { + return new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.None, + Certificates = [new DevCertInfo { Version = 5, TrustLevel = CertificateManager.TrustLevel.None, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] + }; + }, + TrustHttpCertificateCallback = () => + { + trustCalled = true; + return EnsureCertificateResult.ExistingHttpsCertificateTrusted; + }, + EnsureHttpCertificateExistsCallback = () => + { + ensureCalled = true; + return EnsureCertificateResult.ValidCertificatePresent; + } + }; + }; + options.CertificateServiceFactory = serviceProvider => + { + var certificateToolRunner = serviceProvider.GetRequiredService(); + var interactiveService = serviceProvider.GetRequiredService(); + var telemetry = serviceProvider.GetRequiredService(); + return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment(), isWindows: () => true); + }; + }); + + var sp = services.BuildServiceProvider(); + var cs = sp.GetRequiredService(); + + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + + Assert.NotNull(result); + Assert.False(trustCalled); + Assert.False(ensureCalled); + } + + [Fact] + public async Task EnsureCertificatesTrustedAsync_OnWindowsCi_WithNoCertificates_EnsuresCertificateExistsWithoutTrust() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var trustCalled = false; + var ensureCalled = false; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CertificateToolRunnerFactory = sp => + { + return new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => + { + return new CertificateTrustResult + { + HasCertificates = false, + TrustLevel = null, + Certificates = [] + }; + }, + TrustHttpCertificateCallback = () => + { + trustCalled = true; + return EnsureCertificateResult.NewHttpsCertificateTrusted; + }, + EnsureHttpCertificateExistsCallback = () => + { + ensureCalled = true; + return EnsureCertificateResult.Succeeded; + } + }; + }; + options.CertificateServiceFactory = serviceProvider => + { + var certificateToolRunner = serviceProvider.GetRequiredService(); + var interactiveService = serviceProvider.GetRequiredService(); + var telemetry = serviceProvider.GetRequiredService(); + return new CertificateService(certificateToolRunner, interactiveService, telemetry, new TestCliHostEnvironment(), isWindows: () => true); + }; + }); + + var sp = services.BuildServiceProvider(); + var cs = sp.GetRequiredService(); + + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + + Assert.NotNull(result); + Assert.False(trustCalled); + Assert.True(ensureCalled); + } + private sealed class TestCertificateToolRunner : ICertificateToolRunner { public Func? CheckHttpCertificateCallback { get; set; } + public Func? EnsureHttpCertificateExistsCallback { get; set; } public Func? TrustHttpCertificateCallback { get; set; } public CertificateTrustResult CheckHttpCertificate() @@ -249,7 +373,23 @@ public EnsureCertificateResult TrustHttpCertificate() : EnsureCertificateResult.ExistingHttpsCertificateTrusted; } + public EnsureCertificateResult EnsureHttpCertificateExists() + { + return EnsureHttpCertificateExistsCallback is not null + ? EnsureHttpCertificateExistsCallback() + : EnsureCertificateResult.ValidCertificatePresent; + } + public CertificateCleanResult CleanHttpCertificate() => new CertificateCleanResult { Success = true }; } + + private sealed class TestCliHostEnvironment(bool supportsInteractiveInput = false, bool supportsInteractiveOutput = false, bool supportsAnsi = true) : ICliHostEnvironment + { + public bool SupportsInteractiveInput => supportsInteractiveInput; + + public bool SupportsInteractiveOutput => supportsInteractiveOutput; + + public bool SupportsAnsi => supportsAnsi; + } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs index 14939e7f043..3e47f464a70 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs @@ -13,6 +13,7 @@ namespace Aspire.Cli.Tests.TestServices; internal sealed class TestCertificateToolRunner : ICertificateToolRunner { public Func? CheckHttpCertificateCallback { get; set; } + public Func? EnsureHttpCertificateExistsCallback { get; set; } public Func? TrustHttpCertificateCallback { get; set; } public Func? CleanHttpCertificateCallback { get; set; } @@ -39,6 +40,13 @@ public EnsureCertificateResult TrustHttpCertificate() : EnsureCertificateResult.ExistingHttpsCertificateTrusted; } + public EnsureCertificateResult EnsureHttpCertificateExists() + { + return EnsureHttpCertificateExistsCallback is not null + ? EnsureHttpCertificateExistsCallback() + : EnsureCertificateResult.ValidCertificatePresent; + } + public CertificateCleanResult CleanHttpCertificate() { return CleanHttpCertificateCallback is not null diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index e89b6fdca5c..757ea829731 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -408,7 +408,8 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var certificateToolRunner = serviceProvider.GetRequiredService(); var interactiveService = serviceProvider.GetRequiredService(); var telemetry = serviceProvider.GetRequiredService(); - return new CertificateService(certificateToolRunner, interactiveService, telemetry); + var hostEnvironment = serviceProvider.GetRequiredService(); + return new CertificateService(certificateToolRunner, interactiveService, telemetry, hostEnvironment); }; public Func DotNetCliExecutionFactoryFactory { get; set; } = (IServiceProvider serviceProvider) =>