File: Packaging\NuGetConfigMergerTests.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.Xml.Linq;
using Aspire.Cli.Packaging;
using Aspire.Cli.NuGet;
using Aspire.Cli.Tests.Utils;
 
namespace Aspire.Cli.Tests.Packaging;
 
public class NuGetConfigMergerTests
{
    private readonly ITestOutputHelper _outputHelper;
 
    public NuGetConfigMergerTests(ITestOutputHelper outputHelper)
    {
        _outputHelper = outputHelper;
    }
 
    private static async Task<FileInfo> WriteConfigAsync(DirectoryInfo dir, string content)
    {
        var path = Path.Combine(dir.FullName, "NuGet.config");
        await File.WriteAllTextAsync(path, content);
        return new FileInfo(path);
    }
 
    private sealed class FakeNuGetPackageCache : INuGetPackageCache
    {
        public Task<IEnumerable<Aspire.Shared.NuGetPackageCli>> GetTemplatePackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
        {
            _ = workingDirectory; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>([]);
        }
        public Task<IEnumerable<Aspire.Shared.NuGetPackageCli>> GetIntegrationPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
        {
            _ = workingDirectory; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>([]);
        }
        public Task<IEnumerable<Aspire.Shared.NuGetPackageCli>> GetCliPackagesAsync(DirectoryInfo workingDirectory, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
        {
            _ = workingDirectory; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>([]);
        }
        public Task<IEnumerable<Aspire.Shared.NuGetPackageCli>> GetPackagesAsync(DirectoryInfo workingDirectory, string packageId, Func<string, bool>? filter, bool prerelease, FileInfo? nugetConfigFile, CancellationToken cancellationToken)
        {
            _ = workingDirectory; _ = packageId; _ = filter; _ = prerelease; _ = nugetConfigFile; _ = cancellationToken; return Task.FromResult<IEnumerable<Aspire.Shared.NuGetPackageCli>>([]);
        }
    }
 
    private static PackageChannel CreateChannel(PackageMapping[] mappings) => PackageChannel.CreateExplicitChannel("test", PackageChannelQuality.Both, mappings, new FakeNuGetPackageCache());
 
    [Fact]
    public async Task CreateOrUpdateAsync_CreatesConfigFromMappings_WhenNoExistingConfig()
    {
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var root = workspace.WorkspaceRoot;
 
        var mappings = new[]
        {
            new PackageMapping("Aspire.*", "https://feed1.example"),
            new PackageMapping(PackageMapping.AllPackages, "https://feed2.example")
        };
 
    var channel = CreateChannel(mappings);
    await NuGetConfigMerger.CreateOrUpdateAsync(root, channel);
 
        var targetConfigPath = Path.Combine(root.FullName, "NuGet.config");
        Assert.True(File.Exists(targetConfigPath));
 
    using var tempConfig = await TemporaryNuGetConfig.CreateAsync(mappings);
    var expected = await File.ReadAllTextAsync(tempConfig.ConfigFile.FullName);
        var actual = await File.ReadAllTextAsync(targetConfigPath);
        Assert.Equal(NormalizeLineEndings(expected), NormalizeLineEndings(actual));
    }
 
    [Fact]
    public async Task CreateOrUpdateAsync_GeneratesConfigFromMappings_WhenChannelProvided()
    {
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var root = workspace.WorkspaceRoot;
 
        var mappings = new[]
        {
            new PackageMapping("Aspire.*", "https://feed1.example"),
            new PackageMapping(PackageMapping.AllPackages, "https://feed2.example")
        };
 
    var channel = CreateChannel(mappings);
    await NuGetConfigMerger.CreateOrUpdateAsync(root, channel);
 
        var targetConfigPath = Path.Combine(root.FullName, "NuGet.config");
        Assert.True(File.Exists(targetConfigPath));
 
        var xml = XDocument.Load(targetConfigPath);
        var packageSources = xml.Root!.Element("packageSources")!;
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == "https://feed1.example");
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == "https://feed2.example");
 
        var psm = xml.Root!.Element("packageSourceMapping");
        Assert.NotNull(psm);
        Assert.Equal(2, psm!.Elements("packageSource").Count());
    }
 
    [Fact]
    public async Task CreateOrUpdateAsync_AddsMissingSources_WhenUpdatingExistingConfig()
    {
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var root = workspace.WorkspaceRoot;
 
        // Existing config with one source only
        await WriteConfigAsync(root,
            """
            <?xml version="1.0"?>
            <configuration>
                <packageSources>
                    <add key="https://feed1.example" value="https://feed1.example" />
                </packageSources>
                <packageSourceMapping>
                    <packageSource key="https://feed1.example">
                        <package pattern="Aspire.*" />
                    </packageSource>
                </packageSourceMapping>
            </configuration>
            """);
 
        var mappings = new[]
        {
            new PackageMapping("Aspire.*", "https://feed1.example"),
            new PackageMapping("Microsoft.*", "https://feed2.example") // feed2 missing
        };
 
    var channel = CreateChannel(mappings);
    await NuGetConfigMerger.CreateOrUpdateAsync(root, channel);
 
        var xml = XDocument.Load(Path.Combine(root.FullName, "NuGet.config"));
        var packageSources = xml.Root!.Element("packageSources")!;
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == "https://feed2.example");
 
        // Ensure existing mapping retained
        var psm = xml.Root!.Element("packageSourceMapping")!;
        Assert.NotNull(psm.Elements("packageSource").First().Elements("package").FirstOrDefault(p => (string?)p.Attribute("pattern") == "Aspire.*"));
    }
 
    [Fact]
    public async Task CreateOrUpdateAsync_RemapsPatternsAndRemovesEmptySources()
    {
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var root = workspace.WorkspaceRoot;
 
        // Existing config: pattern Lib.* mapped to old source only
        await WriteConfigAsync(root,
            """
            <?xml version="1.0"?>
            <configuration>
                <packageSources>
                    <add key="https://old.example" value="https://old.example" />
                </packageSources>
                <packageSourceMapping>
                    <packageSource key="https://old.example">
                        <package pattern="Lib.*" />
                    </packageSource>
                </packageSourceMapping>
            </configuration>
            """);
 
        var mappings = new[]
        {
            new PackageMapping("Lib.*", "https://new.example")
        };
 
    var channel = CreateChannel(mappings);
    await NuGetConfigMerger.CreateOrUpdateAsync(root, channel);
 
        var xml = XDocument.Load(Path.Combine(root.FullName, "NuGet.config"));
        var packageSources = xml.Root!.Element("packageSources")!;
        // Old source should be removed because it's no longer used
        Assert.DoesNotContain(packageSources.Elements("add"), e => (string?)e.Attribute("value") == "https://old.example");
        // New source should be present
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == "https://new.example");
 
        var psm = xml.Root!.Element("packageSourceMapping")!;
        Assert.Single(psm.Elements("packageSource"));
        Assert.Equal("https://new.example", (string?)psm.Element("packageSource")!.Attribute("key"));
        Assert.Equal("Lib.*", (string?)psm.Element("packageSource")!.Element("package")!.Attribute("pattern"));
    }
 
    [Fact]
    public async Task CreateOrUpdateAsync_CreatesPackageSourceMapping_WhenAbsent()
    {
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var root = workspace.WorkspaceRoot;
 
        // Existing config without packageSourceMapping
        await WriteConfigAsync(root,
            """
            <?xml version="1.0"?>
            <configuration>
                <packageSources>
                    <add key="https://feed1.example" value="https://feed1.example" />
                    <add key="https://feed2.example" value="https://feed2.example" />
                </packageSources>
            </configuration>
            """);
 
        var mappings = new[]
        {
            new PackageMapping("Aspire.*", "https://feed1.example"),
            new PackageMapping("Microsoft.*", "https://feed2.example")
        };
 
    var channel = CreateChannel(mappings);
    await NuGetConfigMerger.CreateOrUpdateAsync(root, channel);
 
        var xml = XDocument.Load(Path.Combine(root.FullName, "NuGet.config"));
        var psm = xml.Root!.Element("packageSourceMapping");
        Assert.NotNull(psm);
        Assert.Equal(2, psm!.Elements("packageSource").Count());
    }
 
    [Fact]
    public void HasMissingSources_ReturnsTrue_WhenConfigAbsent()
    {
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var root = workspace.WorkspaceRoot;
    var mappings = new[] { new PackageMapping("Aspire.*", "https://feed.example") };
    var channel = CreateChannel(mappings);
    Assert.True(NuGetConfigMerger.HasMissingSources(root, channel));
    }
 
    [Fact]
    public async Task HasMissingSources_ReturnsTrue_WhenPatternMappedToWrongSource()
    {
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var root = workspace.WorkspaceRoot;
 
        await WriteConfigAsync(root,
            """
            <?xml version="1.0"?>
            <configuration>
                <packageSources>
                    <add key="https://feed1.example" value="https://feed1.example" />
                    <add key="https://feed2.example" value="https://feed2.example" />
                </packageSources>
                <packageSourceMapping>
                    <packageSource key="https://feed1.example">
                        <package pattern="Aspire.*" />
                    </packageSource>
                </packageSourceMapping>
            </configuration>
            """);
 
        var mappings = new[]
        {
            new PackageMapping("Aspire.*", "https://feed2.example") // should be feed2, but config has feed1
        };
 
    var channel = CreateChannel(mappings);
    Assert.True(NuGetConfigMerger.HasMissingSources(root, channel));
    }
 
    [Fact]
    public async Task HasMissingSources_ReturnsFalse_WhenAllSourcesAndMappingsPresent()
    {
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var root = workspace.WorkspaceRoot;
 
        await WriteConfigAsync(root,
            """
            <?xml version="1.0"?>
            <configuration>
                <packageSources>
                    <add key="https://feed1.example" value="https://feed1.example" />
                    <add key="https://feed2.example" value="https://feed2.example" />
                </packageSources>
                <packageSourceMapping>
                    <packageSource key="https://feed1.example">
                        <package pattern="Aspire.*" />
                    </packageSource>
                    <packageSource key="https://feed2.example">
                        <package pattern="Microsoft.*" />
                    </packageSource>
                </packageSourceMapping>
            </configuration>
            """);
 
        var mappings = new[]
        {
            new PackageMapping("Aspire.*", "https://feed1.example"),
            new PackageMapping("Microsoft.*", "https://feed2.example")
        };
 
    var channel = CreateChannel(mappings);
    Assert.False(NuGetConfigMerger.HasMissingSources(root, channel));
    }
 
    [Fact]
    public async Task CreateOrUpdateAsync_ReusesExistingSourceKeys_WhenMappingToExistingSourcesByUrl()
    {
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var root = workspace.WorkspaceRoot;
 
        // Existing config with custom key names (like "nuget" instead of URL)
        await WriteConfigAsync(root,
            """
            <?xml version="1.0"?>
            <configuration>
                <packageSources>
                    <clear />
                    <add key="nuget" value="https://api.nuget.org/v3/index.json" />
                    <add key="dotnet9" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json" />
                </packageSources>
            </configuration>
            """);
 
        var mappings = new[]
        {
            new PackageMapping("Aspire.*", "https://example.com/aspire-feed"),
            new PackageMapping("*", "https://api.nuget.org/v3/index.json") // Should map to existing "nuget" key
        };
 
        var channel = CreateChannel(mappings);
        await NuGetConfigMerger.CreateOrUpdateAsync(root, channel);
 
        var xml = XDocument.Load(Path.Combine(root.FullName, "NuGet.config"));
        var packageSources = xml.Root!.Element("packageSources")!;
        
        // Existing sources should still be present with their original keys
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("key") == "nuget" && (string?)e.Attribute("value") == "https://api.nuget.org/v3/index.json");
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("key") == "dotnet9" && (string?)e.Attribute("value") == "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json");
 
        // New source should be added
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == "https://example.com/aspire-feed");
 
        // Package source mapping should use existing key "nuget" instead of URL
        var psm = xml.Root!.Element("packageSourceMapping")!;
        var nugetMapping = psm.Elements("packageSource").FirstOrDefault(ps => (string?)ps.Attribute("key") == "nuget");
        Assert.NotNull(nugetMapping);
        Assert.Contains(nugetMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "*");
 
        // Should NOT create a mapping with the URL as key when existing key exists
        var urlMapping = psm.Elements("packageSource").FirstOrDefault(ps => (string?)ps.Attribute("key") == "https://api.nuget.org/v3/index.json");
        Assert.Null(urlMapping);
    }
 
    [Fact]
    public async Task CreateOrUpdateAsync_PreservesAllExistingSources_WhenCreatingPackageSourceMappingForFirstTime()
    {
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var root = workspace.WorkspaceRoot;
 
        // Scenario from @mitchdenny: config has multiple sources but NO packageSourceMapping
        // This means all sources can serve all packages (implicit behavior)
        await WriteConfigAsync(root,
            """
            <?xml version="1.0"?>
            <configuration>
                <packageSources>
                    <clear />
                    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
                    <add key="custom" value="https://example.com/custom/nuget/v3/index.json" />
                </packageSources>
            </configuration>
            """);
 
        // aspire update adds specific mappings but doesn't include a wildcard
        var mappings = new[]
        {
            new PackageMapping("Aspire*", "https://example.com/aspire-daily")
        };
 
        var channel = CreateChannel(mappings);
        await NuGetConfigMerger.CreateOrUpdateAsync(root, channel);
 
        var xml = XDocument.Load(Path.Combine(root.FullName, "NuGet.config"));
        var packageSources = xml.Root!.Element("packageSources")!;
        
        // All original sources should still be present
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("key") == "nuget.org");
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("key") == "custom");
 
        // New aspire source should be added
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == "https://example.com/aspire-daily");
 
        // Debug: Print the XML to understand what's happening
        _outputHelper.WriteLine("Generated XML:");
        _outputHelper.WriteLine(xml.ToString());
 
        // Package source mapping should preserve the original behavior:
        // Since the original config had NO packageSourceMapping, all existing sources should get "*" patterns
        // so they can continue to serve packages
        var psm = xml.Root!.Element("packageSourceMapping")!;
        
        // The aspire source should have its specific pattern
        var aspireMapping = psm.Elements("packageSource").FirstOrDefault(ps => (string?)ps.Attribute("key") == "https://example.com/aspire-daily");
        Assert.NotNull(aspireMapping);
        Assert.Contains(aspireMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "Aspire*");
 
        // The existing sources should get wildcard patterns to preserve their original functionality
        var nugetMapping = psm.Elements("packageSource").FirstOrDefault(ps => (string?)ps.Attribute("key") == "nuget.org");
        Assert.NotNull(nugetMapping);
        Assert.Contains(nugetMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "*");
 
        var customMapping = psm.Elements("packageSource").FirstOrDefault(ps => (string?)ps.Attribute("key") == "custom");
        Assert.NotNull(customMapping);
        Assert.Contains(customMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "*");
    }
 
    [Fact]
    public async Task CreateOrUpdateAsync_AddsSpecificMappings_WhenExistingWildcardMappingPresent()
    {
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var root = workspace.WorkspaceRoot;
 
        // Scenario: existing config already has a wildcard mapping on nuget.org
        // When we add explicit mappings for Aspire packages to a new source,
        // the code should add the new mappings without interfering with the existing wildcard
        await WriteConfigAsync(root,
            """
            <?xml version="1.0"?>
            <configuration>
                <packageSources>
                    <clear />
                    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
                </packageSources>
                <packageSourceMapping>
                    <packageSource key="nuget.org">
                        <package pattern="*" />
                    </packageSource>
                </packageSourceMapping>
            </configuration>
            """);
 
        // aspire update adds specific mappings for Aspire packages to a new channel
        var mappings = new[]
        {
            new PackageMapping("Aspire*", "https://example.com/aspire-daily"),
            new PackageMapping("Microsoft.Extensions.ServiceDiscovery*", "https://example.com/aspire-daily")
        };
 
        var channel = CreateChannel(mappings);
        await NuGetConfigMerger.CreateOrUpdateAsync(root, channel);
 
        var xml = XDocument.Load(Path.Combine(root.FullName, "NuGet.config"));
        var packageSources = xml.Root!.Element("packageSources")!;
        
        // Original source should still be present
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("key") == "nuget.org");
 
        // New aspire source should be added
        Assert.Contains(packageSources.Elements("add"), e => (string?)e.Attribute("value") == "https://example.com/aspire-daily");
 
        // Debug: Print the XML to understand what's happening
        _outputHelper.WriteLine("Generated XML:");
        _outputHelper.WriteLine(xml.ToString());
 
        // Package source mapping should have both the original wildcard and the new specific mappings
        var psm = xml.Root!.Element("packageSourceMapping")!;
        
        // Original nuget.org should still have the wildcard pattern
        var nugetMapping = psm.Elements("packageSource").FirstOrDefault(ps => (string?)ps.Attribute("key") == "nuget.org");
        Assert.NotNull(nugetMapping);
        Assert.Contains(nugetMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "*");
 
        // The aspire source should have its specific patterns
        var aspireMapping = psm.Elements("packageSource").FirstOrDefault(ps => (string?)ps.Attribute("key") == "https://example.com/aspire-daily");
        Assert.NotNull(aspireMapping);
        Assert.Contains(aspireMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "Aspire*");
        Assert.Contains(aspireMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "Microsoft.Extensions.ServiceDiscovery*");
    }
 
    [Fact]
    public async Task CreateOrUpdateAsync_RemovesUnrequiredSources_InsteadOfAddingWildcardPattern()
    {
        using var workspace = TemporaryWorkspace.Create(_outputHelper);
        var root = workspace.WorkspaceRoot;
 
        // Existing config with a PR hive source that should be removed and a user-defined source that should be preserved
        await WriteConfigAsync(root,
            """
            <?xml version="1.0"?>
            <configuration>
                <packageSources>
                    <add key="https://valid.example" value="https://valid.example" />
                    <add key="C:\Users\user\.aspire\hives\invalid-pr" value="C:\Users\user\.aspire\hives\invalid-pr" />
                </packageSources>
                <packageSourceMapping>
                    <packageSource key="https://valid.example">
                        <package pattern="ValidPkg*" />
                    </packageSource>
                    <packageSource key="C:\Users\user\.aspire\hives\invalid-pr">
                        <package pattern="Aspire*" />
                        <package pattern="Microsoft.Extensions.ServiceDiscovery*" />
                    </packageSource>
                </packageSourceMapping>
            </configuration>
            """);
 
        // New mappings that remap Aspire patterns to nuget.org and add a wildcard
        var mappings = new[]
        {
            new PackageMapping("Aspire*", "https://api.nuget.org/v3/index.json"),
            new PackageMapping("Microsoft.Extensions.ServiceDiscovery*", "https://api.nuget.org/v3/index.json"),
            new PackageMapping("*", "https://api.nuget.org/v3/index.json")
        };
 
        var channel = CreateChannel(mappings);
        await NuGetConfigMerger.CreateOrUpdateAsync(root, channel);
 
        var xml = XDocument.Load(Path.Combine(root.FullName, "NuGet.config"));
        var packageSources = xml.Root!.Element("packageSources")!;
        
        // The PR hive source should be removed because it's safe to remove and no longer needed
        Assert.DoesNotContain(packageSources.Elements("add"), 
            e => (string?)e.Attribute("value") == "C:\\Users\\user\\.aspire\\hives\\invalid-pr");
        
        // The user-defined source should be preserved even though its patterns were remapped
        Assert.Contains(packageSources.Elements("add"), 
            e => (string?)e.Attribute("value") == "https://valid.example");
        
        // NuGet.org should be added for all the patterns
        Assert.Contains(packageSources.Elements("add"), 
            e => (string?)e.Attribute("value") == "https://api.nuget.org/v3/index.json");
 
        var psm = xml.Root!.Element("packageSourceMapping")!;
        
        // The PR hive source should not have any mapping entries (removed entirely)
        Assert.DoesNotContain(psm.Elements("packageSource"), 
            ps => (string?)ps.Attribute("key") == "C:\\Users\\user\\.aspire\\hives\\invalid-pr");
        
        // The user-defined source should get a wildcard pattern to remain functional
        var validExampleMapping = psm.Elements("packageSource")
            .FirstOrDefault(ps => (string?)ps.Attribute("key") == "https://valid.example");
        Assert.NotNull(validExampleMapping);
        Assert.Contains(validExampleMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "*");
        
        // NuGet.org should have all the patterns
        var nugetMapping = psm.Elements("packageSource")
            .FirstOrDefault(ps => (string?)ps.Attribute("key") == "https://api.nuget.org/v3/index.json");
        Assert.NotNull(nugetMapping);
        Assert.Contains(nugetMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "Aspire*");
        Assert.Contains(nugetMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "Microsoft.Extensions.ServiceDiscovery*");
        Assert.Contains(nugetMapping.Elements("package"), p => (string?)p.Attribute("pattern") == "*");
        
        // There should be two packageSource elements (nuget.org and valid.example)
        Assert.Equal(2, psm.Elements("packageSource").Count());
    }
 
    private static string NormalizeLineEndings(string text) => text.Replace("\r\n", "\n");
}