File: Certificates\NativeCertificateToolRunnerTests.cs
Web Access
Project: src\tests\Aspire.Cli.Tests\Aspire.Cli.Tests.csproj (Aspire.Cli.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.IO.Hashing;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Aspire.Cli.Certificates;
using Aspire.Cli.Tests.Utils;
using Microsoft.AspNetCore.Certificates.Generation;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
 
namespace Aspire.Cli.Tests.Certificates;
 
public class NativeCertificateToolRunnerTests(ITestOutputHelper outputHelper)
{
    [Fact]
    public void TrustHttpCertificateOnLinux_WithNoCurrentCertificate_CreatesAndTrustsCertificate()
    {
        var certificateManager = new TestCertificateManager();
        var runner = CreateRunner(certificateManager);
 
        var result = runner.TrustHttpCertificateOnLinux([], DateTimeOffset.UtcNow);
 
        Assert.Equal(EnsureCertificateResult.NewHttpsCertificateTrusted, result);
        Assert.True(certificateManager.SaveCalled);
        Assert.True(certificateManager.TrustCalled);
    }
 
    [Fact]
    public void TrustHttpCertificateOnLinux_WithExistingCurrentCertificate_TrustsWithoutSaving()
    {
        var certificateManager = new TestCertificateManager();
        using var certificate = certificateManager.CreateAspNetCoreHttpsDevelopmentCertificate(
            DateTimeOffset.UtcNow.AddDays(-1),
            DateTimeOffset.UtcNow.AddDays(365));
        var runner = CreateRunner(certificateManager);
 
        var result = runner.TrustHttpCertificateOnLinux([certificate], DateTimeOffset.UtcNow);
 
        Assert.Equal(EnsureCertificateResult.ExistingHttpsCertificateTrusted, result);
        Assert.False(certificateManager.SaveCalled);
        Assert.True(certificateManager.TrustCalled);
    }
 
    [Fact]
    public void TrustHttpCertificateOnLinux_WithOnlyOlderCertificate_CreatesCurrentCertificate()
    {
        var currentVersionManager = new TestCertificateManager(CertificateManager.CurrentAspNetCoreCertificateVersion);
        var olderVersionManager = new TestCertificateManager(CertificateManager.CurrentAspNetCoreCertificateVersion - 1);
        using var olderCertificate = olderVersionManager.CreateAspNetCoreHttpsDevelopmentCertificate(
            DateTimeOffset.UtcNow.AddDays(-1),
            DateTimeOffset.UtcNow.AddDays(365));
        var runner = CreateRunner(currentVersionManager);
 
        var result = runner.TrustHttpCertificateOnLinux([olderCertificate], DateTimeOffset.UtcNow);
 
        Assert.Equal(EnsureCertificateResult.NewHttpsCertificateTrusted, result);
        Assert.True(currentVersionManager.SaveCalled);
        Assert.True(currentVersionManager.TrustCalled);
    }
 
    [Fact]
    public void ExportDevCertificatePublicPem_WithUntrustedCertificate_ReturnsNull()
    {
        var certificateManager = new TestCertificateManager();
        using var certificate = certificateManager.CreateAspNetCoreHttpsDevelopmentCertificate(
            DateTimeOffset.UtcNow.AddDays(-1),
            DateTimeOffset.UtcNow.AddDays(365));
        certificateManager.Certificates.Add(certificate.Export(X509ContentType.Pfx));
        var logger = new FakeLogger<NativeCertificateToolRunner>();
        var runner = CreateRunner(certificateManager, logger);
        using var workspace = TemporaryWorkspace.Create(outputHelper);
 
        var result = runner.ExportDevCertificatePublicPem(
            workspace.WorkspaceRoot.FullName,
            TestContext.Current.CancellationToken);
 
        Assert.Null(result);
        Assert.Contains(
            logger.Collector.GetSnapshot(),
            record => record.Message.Contains("No trusted ASP.NET Core development certificate", StringComparison.Ordinal));
    }
 
    [Fact]
    public void GetOrCreateCertificateCacheFile_CachesPublicPemWithRestrictedPermissions()
    {
        var certificateManager = new TestCertificateManager();
        using var certificate = certificateManager.CreateAspNetCoreHttpsDevelopmentCertificate(
            DateTimeOffset.UtcNow.AddDays(-1),
            DateTimeOffset.UtcNow.AddDays(365));
        var logger = new FakeLogger<NativeCertificateToolRunner>();
        var runner = CreateRunner(certificateManager, logger);
        using var workspace = TemporaryWorkspace.Create(outputHelper);
        var outputDirectory = Path.Combine(workspace.WorkspaceRoot.FullName, "dev-certs");
        var pemContents = certificate.ExportCertificatePem();
        var pemBytes = Encoding.UTF8.GetBytes(pemContents);
        var hash = Convert.ToHexString(XxHash128.Hash(pemBytes)).ToLowerInvariant();
        var expectedPath = Path.Combine(outputDirectory, $"aspire-dev-cert-{hash}.pem");
 
        var firstResult = runner.GetOrCreateCertificateCacheFile(certificate, outputDirectory);
        var lastWriteTime = DateTime.UtcNow.AddHours(-1);
        File.SetLastWriteTimeUtc(firstResult, lastWriteTime);
        lastWriteTime = File.GetLastWriteTimeUtc(firstResult);
        var secondResult = runner.GetOrCreateCertificateCacheFile(certificate, outputDirectory);
 
        Assert.Equal(expectedPath, firstResult);
        Assert.Equal(firstResult, secondResult);
        Assert.Equal(pemContents, File.ReadAllText(firstResult));
        Assert.Equal(lastWriteTime, File.GetLastWriteTimeUtc(secondResult));
        Assert.Contains(
            logger.Collector.GetSnapshot(),
            record => record.Message.Contains("Writing development certificate PEM to cache", StringComparison.Ordinal));
        Assert.Contains(
            logger.Collector.GetSnapshot(),
            record => record.Message.Contains("Reusing cached development certificate PEM", StringComparison.Ordinal));
 
        if (!OperatingSystem.IsWindows())
        {
            Assert.Equal(
                UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute,
                File.GetUnixFileMode(outputDirectory));
            Assert.Equal(
                UnixFileMode.UserRead | UnixFileMode.UserWrite,
                File.GetUnixFileMode(firstResult));
        }
    }
 
    private static NativeCertificateToolRunner CreateRunner(
        CertificateManager certificateManager,
        FakeLogger<NativeCertificateToolRunner>? logger = null) =>
        new(
            certificateManager,
            TestEnvironment.CreateLinux(),
            logger ?? new FakeLogger<NativeCertificateToolRunner>());
 
    private sealed class TestCertificateManager(int version = CertificateManager.CurrentAspNetCoreCertificateVersion)
        : CertificateManager(NullLogger.Instance, CertificateManager.LocalhostHttpsDistinguishedName, version, version)
    {
        public List<byte[]> Certificates { get; } = [];
        public bool SaveCalled { get; private set; }
        public bool TrustCalled { get; private set; }
 
        protected override void PopulateCertificatesFromStore(
            X509Store store,
            List<X509Certificate2> certificates,
            bool requireExportable)
        {
            certificates.AddRange(
                Certificates.Select(certificate => X509CertificateLoader.LoadPkcs12(
                    certificate,
                    password: null,
                    X509KeyStorageFlags.Exportable)));
        }
 
        protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation)
        {
            SaveCalled = true;
            return certificate;
        }
 
        protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate)
        {
            TrustCalled = true;
            return TrustLevel.Full;
        }
 
        public override TrustLevel GetTrustLevel(X509Certificate2 certificate) => TrustLevel.None;
 
        internal override bool IsExportable(X509Certificate2 c) => true;
 
        protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate)
        {
        }
 
        protected override IList<X509Certificate2> GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation) => [];
 
        protected override void CreateDirectoryWithPermissions(string directoryPath)
        {
        }
 
        internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate) => new(true, null);
 
        internal override void CorrectCertificateState(X509Certificate2 candidate)
        {
        }
    }
}