// 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.Compression;
using System.Xml.Linq;
using Aspire.Cli.NuGet;
using Aspire.Cli.Packaging;
using Aspire.Cli.Tests.TestServices;
using Aspire.Cli.Tests.Utils;
using Aspire.Hosting;
using Microsoft.DotNet.RemoteExecutor;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Logging.Testing;
using NuGet.Configuration;
using NuGet.ProjectModel;
using NuGet.Packaging;
using NuGet.Protocol;
namespace Aspire.Cli.Tests.NuGet;
public class NuGetClientTests(ITestOutputHelper outputHelper)
{
[Fact]
public async Task GetPackageVersionsAsync_CombinesEveryEligibleFeedsVersionsInSemanticOrder()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var first = workspace.CreateDirectory("first");
var second = workspace.CreateDirectory("second");
NuGetTestHelper.CreatePackage(first, "Example.Package", "9.0.0", new Dictionary<string, string>());
NuGetTestHelper.CreatePackage(second, "Example.Package", "10.0.0-preview.2", new Dictionary<string, string>());
NuGetTestHelper.CreatePackage(second, "Example.Package", "10.0.0-preview.10", new Dictionary<string, string>());
NuGetTestHelper.CreatePackage(second, "Example.Package", "9.0.0", new Dictionary<string, string>());
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "nuget.config"), """
<configuration>
<packageSources><clear /><add key="first" value="first" /><add key="second" value="second" /></packageSources>
<packageSourceMapping><clear /></packageSourceMapping>
</configuration>
""");
var versions = await NuGetTestHelper.CreateClient().GetPackageVersionsAsync(
"example.package", workspace.Path, TestContext.Current.CancellationToken);
Assert.Equal(["10.0.0-preview.10", "10.0.0-preview.2", "9.0.0"], versions.Select(version => version.ToNormalizedString()));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task GetPackageVersionsAsync_DoesNotContactExcludedSources(bool disabled)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feed = workspace.CreateDirectory("feed");
NuGetTestHelper.CreatePackage(feed, "Example.Package", "1.0.0", new Dictionary<string, string>());
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "nuget.config"), $$"""
<configuration>
<packageSources>
<clear /><add key="local" value="feed" /><add key="excluded" value="http://127.0.0.1:1/index.json" allowInsecureConnections="true" />
</packageSources>
{{(disabled ? "<disabledPackageSources><add key=\"excluded\" value=\"true\" /></disabledPackageSources>" : "")}}
<packageSourceMapping>
<clear /><packageSource key="local"><package pattern="Example.Package" /></packageSource>
<packageSource key="excluded"><package pattern="{{(disabled ? "Example.Package" : "Other.*")}}" /></packageSource>
</packageSourceMapping>
</configuration>
""");
var versions = await NuGetTestHelper.CreateClient().GetPackageVersionsAsync(
"Example.Package", workspace.Path, TestContext.Current.CancellationToken);
Assert.Equal(["1.0.0"], versions.Select(version => version.ToNormalizedString()));
}
[Fact]
public async Task GetPackageVersionsAsync_EmptySourcePolicyDoesNotSynthesizeSources()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
await File.WriteAllTextAsync(Path.Combine(workspace.Path, "nuget.config"),
"<configuration><packageSources><clear /></packageSources><packageSourceMapping><clear /></packageSourceMapping></configuration>");
var versions = await NuGetTestHelper.CreateClient().GetPackageVersionsAsync(
"Aspire.Hosting.Dotnet", workspace.Path, TestContext.Current.CancellationToken);
Assert.Empty(versions);
}
[Theory]
[InlineData(false, false, false)]
[InlineData(false, true, false)]
[InlineData(true, false, false)]
[InlineData(true, true, false)]
[InlineData(false, false, true)]
[InlineData(false, true, true)]
[InlineData(true, false, true)]
[InlineData(true, true, true)]
public async Task SearchAsync_EmptyEnabledSourcePolicyDoesNotSynthesizeSources(
bool disableSource, bool useExplicitConfig, bool hasExplicitSource)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
const string packageId = "Aspire.Test.Package";
CreatePackage(feedDirectory.FullName, packageId);
var sourceEntry = disableSource
? $"""<add key="local" value="{feedDirectory.FullName}" />"""
: string.Empty;
var disabledSources = disableSource
? """<disabledPackageSources><add key="local" value="true" /></disabledPackageSources>"""
: string.Empty;
var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config");
await File.WriteAllTextAsync(configPath, $"""
<configuration>
<packageSources>
<clear />
{sourceEntry}
</packageSources>
{disabledSources}
</configuration>
""");
var settings = useExplicitConfig
? Settings.LoadSpecificSettings(workspace.WorkspaceRoot.FullName, "NuGet.Config")
: NuGetTestHelper.LoadSettings(workspace.WorkspaceRoot.FullName);
string[] explicitSources = hasExplicitSource ? [feedDirectory.FullName] : [];
// Check selection before searching so a policy regression cannot contact an unconfigured feed.
Assert.Equal(explicitSources,
NuGetClient.LoadPackageSources(settings, explicitSources).Select(static source => source.Source));
var results = await NuGetTestHelper.CreateClient().SearchAsync(
packageId,
prerelease: false,
take: 1000,
explicitSources,
nugetConfigPath: useExplicitConfig ? configPath : null,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
if (hasExplicitSource)
{
var package = Assert.Single(results);
Assert.Equal(packageId, package.Id);
Assert.Equal("1.0.0", package.Version);
Assert.Equal(feedDirectory.FullName, package.Source);
}
else
{
Assert.Empty(results);
}
}
[Fact]
public async Task SearchAsync_ReturnsOnlyTheFirstPage()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
CreatePackage(feedDirectory.FullName, "Aspire.Test.Package.One");
CreatePackage(feedDirectory.FullName, "Aspire.Test.Package.Two");
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
// The aspire-managed helper requested one page per source and never paged further.
var results = await client.SearchAsync(
"Aspire.Test.Package",
prerelease: false,
take: 1,
[feedDirectory.FullName],
nugetConfigPath: null,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
Assert.Single(results);
}
[Fact]
public async Task SearchAsync_KeepsOneEntryPerPackageAcrossSources()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var firstFeedDirectory = workspace.CreateDirectory("first-feed");
var secondFeedDirectory = workspace.CreateDirectory("second-feed");
CreatePackage(firstFeedDirectory.FullName, "Aspire.Test.Package");
CreatePackage(secondFeedDirectory.FullName, "Aspire.Test.Package", version: "2.0.0");
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
var results = await client.SearchAsync(
"Aspire.Test.Package",
prerelease: false,
take: 1000,
[firstFeedDirectory.FullName, secondFeedDirectory.FullName],
nugetConfigPath: null,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
// Only the winning source's entry survives, so its versions are not merged with the other source's.
var package = Assert.Single(results);
Assert.Equal("Aspire.Test.Package", package.Id);
Assert.Equal("2.0.0", package.Version);
Assert.Equal(secondFeedDirectory.FullName, package.Source);
Assert.Equal(["2.0.0"], package.AllVersions);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SearchAsync_AppliesAmbientAndChannelMappingsBeforeDeduplication(bool useChannelOverlay)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var selectedFeed = workspace.CreateDirectory("selected-feed");
var ambientFeed = workspace.CreateDirectory("ambient-feed");
const string packageId = "Aspire.Hosting.SearchProbe";
const string communityPackageId = "CommunityToolkit.Aspire.Hosting.SearchProbe";
CreatePackage(selectedFeed.FullName, packageId);
CreatePackage(ambientFeed.FullName, packageId, version: "2.0.0");
CreatePackage(ambientFeed.FullName, communityPackageId, version: "2.0.0");
File.WriteAllText(
Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config"),
$"""
<configuration>
<packageSources>
<clear />
<add key="selected" value="{selectedFeed.FullName}" />
<add key="ambient" value="{ambientFeed.FullName}" />
</packageSources>
<packageSourceMapping>
<packageSource key="selected">
<package pattern="{packageId}" />
</packageSource>
<packageSource key="ambient">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
""");
using var configuration = await NuGetTestHelper.CreateService().CreatePackageOperationConfigurationAsync(
workspace.WorkspaceRoot,
useChannelOverlay ? [new PackageMapping("Aspire*", selectedFeed.FullName)] : null,
restrictToSelectedSources: false,
TestContext.Current.CancellationToken);
var client = new NuGetClient(new TestFeatures(), new TestEnvironment(), NullLogger<NuGetClient>.Instance);
var results = await client.SearchAsync(
"Aspire.Hosting",
prerelease: false,
take: 1000,
explicitSources: [],
configuration.ExplicitConfigFile?.FullName,
configuration.EffectiveWorkingDirectory.FullName,
TestContext.Current.CancellationToken);
Assert.Collection(
results,
package =>
{
Assert.Equal(packageId, package.Id);
Assert.Equal("1.0.0", package.Version);
Assert.Equal(["1.0.0"], package.AllVersions);
Assert.Equal(selectedFeed.FullName, package.Source);
},
package =>
{
Assert.Equal(communityPackageId, package.Id);
Assert.Equal("2.0.0", package.Version);
Assert.Equal(ambientFeed.FullName, package.Source);
});
}
[Fact]
public async Task SearchAndRestoreHonorMachineWideMappings()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var selectedFeed = workspace.CreateDirectory("selected-feed");
var ambientFeed = workspace.CreateDirectory("ambient-feed");
var packageDirectory = workspace.CreateDirectory("packages");
var restoreDirectory = workspace.CreateDirectory("restore");
var machineDirectory = workspace.CreateDirectory("machine");
var packageId = $"Aspire.Hosting.MachineProbe.{Guid.NewGuid():N}";
CreatePackage(selectedFeed.FullName, packageId, baseAssemblyContents: "selected");
CreatePackage(ambientFeed.FullName, packageId, version: "9.0.0");
File.WriteAllText(
Path.Combine(machineDirectory.FullName, "NuGet.Config"),
$"""
<configuration>
<packageSourceMapping>
<packageSource key="selected">
<package pattern="{packageId}" />
</packageSource>
</packageSourceMapping>
</configuration>
""");
File.WriteAllText(
Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config"),
$"""
<configuration>
<config>
<add key="globalPackagesFolder" value="{packageDirectory.FullName}" />
</config>
<packageSources>
<clear />
<add key="selected" value="{selectedFeed.FullName}" />
<add key="ambient" value="{ambientFeed.FullName}" />
</packageSources>
<packageSourceMapping>
<packageSource key="ambient">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
""");
var machineSettings = new Settings(machineDirectory.FullName, "NuGet.Config", isMachineWide: true);
var client = NuGetTestHelper.CreateClient(new TestMachineWideSettings(machineSettings));
var snapshot = client.GetSettings(workspace.WorkspaceRoot.FullName, new byte[NuGetSourceIdentity.KeySizeInBytes]);
using var restoredPackageScope = new RestoredPackageScope(
GetEffectiveGlobalPackagesFolder(nugetConfigPath: null, workspace.WorkspaceRoot.FullName),
packageId);
Assert.Contains(machineSettings.GetConfigFilePaths().Single(), snapshot.ConfigPaths);
var sdkResults = client.FilterPackageSearchResults(
[
new() { Id = packageId, Version = "1.0.0", Source = "selected" },
new() { Id = packageId, Version = "1.0.0", Source = selectedFeed.FullName },
new() { Id = packageId, Version = "9.0.0", Source = "ambient" },
new() { Id = packageId, Version = "9.0.0", Source = ambientFeed.FullName }
],
nugetConfigPath: null,
workspace.WorkspaceRoot.FullName);
Assert.Equal(["selected", selectedFeed.FullName], sdkResults.Select(package => package.Source));
var results = await client.SearchAsync(
packageId,
prerelease: false,
take: 1000,
explicitSources: [],
nugetConfigPath: null,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
var result = Assert.Single(results);
Assert.Equal("1.0.0", result.Version);
Assert.Equal(selectedFeed.FullName, result.Source);
await client.RestoreAsync(
[(packageId, result.Version)],
framework: "net10.0",
runtimeIdentifier: null,
restoreDirectory.FullName,
sources: [],
nugetConfigPaths: snapshot.ConfigPaths,
workspace.WorkspaceRoot.FullName,
globalPackagesFolderOverride: null,
sensitiveSources: [],
TestContext.Current.CancellationToken);
var restoredPackage = Assert.Single(ReadRestoredPackages(
restoreDirectory.FullName, nugetConfigPath: null, workspace.WorkspaceRoot.FullName));
Assert.Equal(packageId, restoredPackage.Id);
Assert.Equal(result.Version, restoredPackage.Version);
Assert.Equal(
"selected",
await File.ReadAllTextAsync(
Path.Combine(restoredPackage.InstallPath, "lib", "net10.0", "Aspire.Test.Package.dll"),
TestContext.Current.CancellationToken));
}
[Fact]
public async Task SearchAsync_ComparesVersionsAsStringsAcrossSources()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var firstFeedDirectory = workspace.CreateDirectory("first-feed");
var secondFeedDirectory = workspace.CreateDirectory("second-feed");
CreatePackage(firstFeedDirectory.FullName, "Aspire.Test.Package", version: "9.0.0");
CreatePackage(secondFeedDirectory.FullName, "Aspire.Test.Package", version: "10.0.0");
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
var results = await client.SearchAsync(
"Aspire.Test.Package",
prerelease: false,
take: 1000,
[firstFeedDirectory.FullName, secondFeedDirectory.FullName],
nugetConfigPath: null,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
// The aspire-managed helper picked the entry with the highest version *string*, so "9.0.0" wins over
// "10.0.0". This pins that behavior so a change to it is a deliberate decision rather than a side effect.
var package = Assert.Single(results);
Assert.Equal("9.0.0", package.Version);
Assert.Equal(firstFeedDirectory.FullName, package.Source);
}
[Fact]
public async Task SearchAsync_FallsBackToDiscoveryWhenConfigFileIsMissing()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
CreatePackage(feedDirectory.FullName, "Aspire.Test.Package");
File.WriteAllText(
Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config"),
$"""
<configuration>
<packageSources>
<clear />
<add key="local" value="{feedDirectory.FullName}" />
</packageSources>
</configuration>
""");
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
var results = await client.SearchAsync(
"Aspire.Test.Package",
prerelease: false,
take: 1000,
[],
nugetConfigPath: Path.Combine(workspace.WorkspaceRoot.FullName, "missing", "nuget.config"),
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
var package = Assert.Single(results);
Assert.Equal("Aspire.Test.Package", package.Id);
Assert.Equal(feedDirectory.FullName, package.Source);
}
[Fact]
public async Task SearchAsync_ReturnsResultsWhenOneSourceFails()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
CreatePackage(feedDirectory.FullName, "Aspire.Test.Package");
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
var results = await client.SearchAsync(
"Aspire.Test.Package",
prerelease: false,
take: 100,
["https://127.0.0.1:1/v3/index.json", feedDirectory.FullName],
nugetConfigPath: null,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
var package = Assert.Single(results);
Assert.Equal("Aspire.Test.Package", package.Id);
}
[Fact]
public async Task SearchAsync_RedactsCredentialBearingAmbientSourceDiagnostics()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
const string sourceNameSecret = "source-name-secret";
const string sourceValueSecret = "source-value-secret";
var sourceName = $"https://alias.example/v3/index.json?sig={sourceNameSecret}";
var sourceValue = $"https://127.0.0.1:1/v3/index.json?sig={sourceValueSecret}";
var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
File.WriteAllText(
nugetConfigPath,
$"""
<configuration>
<packageSources>
<clear />
<add key="{sourceName}" value="{sourceValue}" />
</packageSources>
</configuration>
""");
var logger = new FakeLogger<NuGetClient>();
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
logger);
var results = await client.SearchAsync(
"Aspire.Test.Package",
prerelease: false,
take: 100,
[],
nugetConfigPath,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
Assert.Empty(results);
var logRecords = logger.Collector.GetSnapshot();
Assert.NotEmpty(logRecords);
Assert.DoesNotContain(logRecords, record => record.Message.Contains(sourceNameSecret, StringComparison.Ordinal));
Assert.DoesNotContain(logRecords, record => record.Message.Contains(sourceValueSecret, StringComparison.Ordinal));
}
[Fact]
public async Task RestoreAsync_FailureReportsHelperOutput()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
var packagesDirectory = workspace.CreateDirectory("packages");
var restoreDirectory = workspace.CreateDirectory("restore");
var packageId = $"Aspire.Test.Missing.{Guid.NewGuid():N}";
var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
File.WriteAllText(
nugetConfigPath,
$"""
<configuration>
<config>
<add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
</config>
<packageSources>
<clear />
<add key="local" value="{feedDirectory.FullName}" />
</packageSources>
</configuration>
""");
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
var exception = await Assert.ThrowsAsync<NuGetOperationException>(() => client.RestoreAsync(
[(packageId, "[1.0.0]")],
"net10.0",
runtimeIdentifier: null,
restoreDirectory.FullName,
[],
nugetConfigPath,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken));
// Without debug logging the helper wrote only NuGet's warnings and errors, prefixed the way its logger
// prefixed them, followed by its own summary of the failed restore.
var lines = exception.Output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
Assert.Contains(lines, line => line.StartsWith("ERROR: ", StringComparison.Ordinal) && line.Contains(packageId, StringComparison.OrdinalIgnoreCase));
Assert.Contains(lines, line => line.StartsWith("Error: Restore failed: ", StringComparison.Ordinal));
}
[Fact]
public async Task RestoreAsync_UnexpectedFailureDoesNotRetainOriginalException()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var restoreDirectory = workspace.CreateDirectory("restore");
const string invalidVersion = "not-a-version";
var logger = new FakeLogger<NuGetClient>();
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
logger);
var exception = await Assert.ThrowsAsync<NuGetOperationException>(() => client.RestoreAsync(
[("Aspire.Test.Package", invalidVersion)],
"net10.0",
runtimeIdentifier: null,
restoreDirectory.FullName,
sources: [],
nugetConfigPaths: [],
workspace.WorkspaceRoot.FullName,
globalPackagesFolderOverride: null,
sensitiveSources: [],
TestContext.Current.CancellationToken));
Assert.Null(exception.InnerException);
Assert.Contains(invalidVersion, exception.Output, StringComparison.Ordinal);
Assert.Contains(
logger.Collector.GetSnapshot(),
record => record.Message.Contains(invalidVersion, StringComparison.Ordinal));
}
// The tests below observe process-wide state -- the real environment and NuGet's static credential service -- so
// each runs in its own process, where no other test's operation can overlap it.
[Fact]
public void RestoreAsync_RestoresSignatureVerificationVariableAfterSuccess()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
CreatePackage(feedDirectory.FullName, packageId);
var nugetConfigPath = CreateLocalFeedConfig(workspace, feedDirectory, workspace.CreateDirectory("packages"));
using var restoredPackageScope = new RestoredPackageScope(
GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName),
packageId);
RemoteExecutor.Invoke(
static async (packageId, configPath, restorePath, workingDirectory) =>
{
Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, null);
var client = new NuGetClient(new TestFeatures(), TestEnvironment.CreateLinux(), NullLogger<NuGetClient>.Instance);
await client.RestoreAsync(
[(packageId, "[1.0.0]")],
"net10.0",
runtimeIdentifier: null,
restorePath,
[],
configPath,
workingDirectory,
CancellationToken.None);
Assert.Null(Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification));
},
packageId,
nugetConfigPath,
workspace.CreateDirectory("restore").FullName,
workspace.WorkspaceRoot.FullName).Dispose();
}
[Fact]
public void RestoreAsync_RestoresSignatureVerificationVariableAfterFailure()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var nugetConfigPath = CreateLocalFeedConfig(workspace, workspace.CreateDirectory("feed"), workspace.CreateDirectory("packages"));
RemoteExecutor.Invoke(
static async (configPath, restorePath, workingDirectory) =>
{
Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, null);
var client = new NuGetClient(new TestFeatures(), TestEnvironment.CreateLinux(), NullLogger<NuGetClient>.Instance);
await Assert.ThrowsAsync<NuGetOperationException>(() => client.RestoreAsync(
[($"Aspire.Test.Missing.{Guid.NewGuid():N}", "[1.0.0]")],
"net10.0",
runtimeIdentifier: null,
restorePath,
[],
configPath,
workingDirectory,
CancellationToken.None));
Assert.Null(Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification));
},
nugetConfigPath,
workspace.CreateDirectory("restore").FullName,
workspace.WorkspaceRoot.FullName).Dispose();
}
[Fact]
public void RestoreAsync_RestoresSignatureVerificationVariableAfterCancellation()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
CreatePackage(feedDirectory.FullName, packageId);
var nugetConfigPath = CreateLocalFeedConfig(workspace, feedDirectory, workspace.CreateDirectory("packages"));
RemoteExecutor.Invoke(
static async (packageId, configPath, restorePath, workingDirectory) =>
{
Environment.SetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification, null);
var client = new NuGetClient(new TestFeatures(), TestEnvironment.CreateLinux(), NullLogger<NuGetClient>.Instance);
using var cancellationSource = new CancellationTokenSource();
cancellationSource.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => client.RestoreAsync(
[(packageId, "[1.0.0]")],
"net10.0",
runtimeIdentifier: null,
restorePath,
[],
configPath,
workingDirectory,
cancellationSource.Token));
Assert.Null(Environment.GetEnvironmentVariable(NuGetSignatureVerificationEnabler.DotNetNuGetSignatureVerification));
},
packageId,
nugetConfigPath,
workspace.CreateDirectory("restore").FullName,
workspace.WorkspaceRoot.FullName).Dispose();
}
[Fact]
public void BeginOperation_ResetsNuGetStateWhenLastOverlappingOperationEnds()
{
RemoteExecutor.Invoke(static () =>
{
var client = new NuGetClient(new TestFeatures(), new TestEnvironment(), NullLogger<NuGetClient>.Instance);
var first = client.BeginOperation();
var second = client.BeginOperation();
Assert.NotNull(HttpHandlerResourceV3.CredentialService);
// Ending one of two overlapping operations, even twice, must not reset state the other is still using.
first.Dispose();
first.Dispose();
Assert.NotNull(HttpHandlerResourceV3.CredentialService);
second.Dispose();
Assert.Null(HttpHandlerResourceV3.CredentialService);
// The next operation sets the credential service up again after the reset.
using (client.BeginOperation())
{
Assert.NotNull(HttpHandlerResourceV3.CredentialService);
}
Assert.Null(HttpHandlerResourceV3.CredentialService);
}).Dispose();
}
[Fact]
public void DiagnosticNuGetLogger_RedactsUnionOfActiveSensitiveSources()
{
const string firstSource = "https://first.example/v3/index.json?sig=first-secret";
const string secondSource = "https://second.example/v3/index.json?sig=second-secret";
var logger = new FakeLogger<NuGetClient>();
var diagnosticLogger = new NuGetClient.DiagnosticNuGetLogger(logger);
using var first = diagnosticLogger.RegisterSensitiveSources([firstSource]);
using var second = diagnosticLogger.RegisterSensitiveSources([secondSource]);
diagnosticLogger.LogDebug(
"Credential provider checked https://first-cdn.example/v3-flatcontainer/package/index.json?sig=first-secret " +
"and https://second-cdn.example/v3-flatcontainer/package/index.json?sig=second-secret.");
var record = Assert.Single(logger.Collector.GetSnapshot());
Assert.Equal(
"Credential provider checked https://first-cdn.example/v3-flatcontainer/package/index.json?*** " +
"and https://second-cdn.example/v3-flatcontainer/package/index.json?***.",
record.Message);
}
[Fact]
public void DiagnosticNuGetLogger_ReferenceCountsOverlappingSensitiveSources()
{
const string sharedSource = "https://feed.example/v3/index.json?sig=shared-secret";
const string nextSource = "https://next.example/v3/index.json?sig=next-secret";
var logger = new FakeLogger<NuGetClient>();
var diagnosticLogger = new NuGetClient.DiagnosticNuGetLogger(logger);
var first = diagnosticLogger.RegisterSensitiveSources([sharedSource]);
var second = diagnosticLogger.RegisterSensitiveSources([sharedSource]);
first.Dispose();
first.Dispose();
diagnosticLogger.LogDebug($"Credential provider checked {sharedSource}.");
second.Dispose();
using (diagnosticLogger.RegisterSensitiveSources([nextSource]))
{
diagnosticLogger.LogDebug($"Credential provider checked {sharedSource} and {nextSource}.");
}
Assert.Collection(
logger.Collector.GetSnapshot(),
record => Assert.Equal(
"Credential provider checked https://feed.example/v3/index.json.",
record.Message),
record => Assert.Equal(
$"Credential provider checked {sharedSource} and https://next.example/v3/index.json.",
record.Message));
}
[Fact]
public void SearchAsync_ResetsNuGetStateWhenComplete()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
CreatePackage(feedDirectory.FullName, "Aspire.Test.Package");
RemoteExecutor.Invoke(
static async (feedPath, workingDirectory) =>
{
var client = new NuGetClient(new TestFeatures(), new TestEnvironment(), NullLogger<NuGetClient>.Instance);
var results = await client.SearchAsync(
"Aspire.Test.Package",
prerelease: false,
take: 1000,
[feedPath],
nugetConfigPath: null,
workingDirectory,
CancellationToken.None);
Assert.Single(results);
Assert.Null(HttpHandlerResourceV3.CredentialService);
},
feedDirectory.FullName,
workspace.WorkspaceRoot.FullName).Dispose();
}
[Fact]
public async Task RestoreAndWriteManifestAsync_UsesLocalPackageRuntimeAssets()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
var packagesDirectory = workspace.CreateDirectory("packages");
var restoreDirectory = workspace.CreateDirectory("restore");
var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
CreatePackage(feedDirectory.FullName, packageId);
var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
File.WriteAllText(
nugetConfigPath,
$"""
<configuration>
<config>
<add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
</config>
<packageSources>
<clear />
<add key="local" value="{feedDirectory.FullName}" />
</packageSources>
</configuration>
""");
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
string? packageRoot = null;
try
{
await client.RestoreAsync(
[(packageId, "[1.0.0]")],
"net10.0",
"win-x64",
restoreDirectory.FullName,
[],
nugetConfigPath,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName,
nugetConfigPath, workspace.WorkspaceRoot.FullName);
packageRoot = Path.GetDirectoryName(restoredPackages[0].InstallPath);
var manifestPath = Path.Combine(restoreDirectory.FullName, IntegrationPackageProbeManifest.FileName);
await client.WriteManifestAsync(
Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName),
manifestPath,
"net10.0",
"win-x64",
TestContext.Current.CancellationToken);
// The manifest points at assets in the global packages folder; older CLIs copied them into a libs
// directory beside it instead.
Assert.False(Directory.Exists(Path.Combine(restoreDirectory.FullName, "libs")));
var manifest = IntegrationPackageProbeManifest.Load(manifestPath);
Assert.Equal(
Path.Combine(
restoredPackages[0].InstallPath,
"runtimes",
"win-x64",
"lib",
"net10.0",
"Aspire.Test.Package.dll"),
manifest.TryGetManagedAssemblyPath(new("Aspire.Test.Package")));
Assert.EndsWith(
Path.Combine("runtimes", "win-x64", "lib", "net10.0", "RuntimeOnly.dll"),
manifest.TryGetManagedAssemblyPath(new("RuntimeOnly")),
StringComparison.OrdinalIgnoreCase);
Assert.EndsWith(
Path.Combine("runtimes", "win-x64", "lib", "net10.0", "Neutral.resources.dll"),
manifest.TryGetManagedAssemblyPath(new("Neutral.resources")),
StringComparison.OrdinalIgnoreCase);
Assert.EndsWith(
Path.Combine("runtimes", "win-x64", "lib", "net10.0", "fr", "RuntimeOnly.resources.dll"),
manifest.TryGetManagedAssemblyPath(new("RuntimeOnly.resources, Culture=fr")),
StringComparison.OrdinalIgnoreCase);
Assert.EndsWith(
Path.Combine("runtimes", "win-x64", "lib", "net10.0", "de", "RuntimeOnly.resources.dll"),
manifest.TryGetManagedAssemblyPath(new("RuntimeOnly.resources, Culture=de")),
StringComparison.OrdinalIgnoreCase);
Assert.Single(manifest.GetNativeLibraryPaths("native-test"));
}
finally
{
if (packageRoot is not null)
{
Directory.Delete(packageRoot, recursive: true);
}
}
}
[Fact]
public void RestoreAsync_RespectsNuGetPackagesEnvironmentVariable()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
var environmentPackagesDirectory = workspace.CreateDirectory("env-packages");
var restoreDirectory = workspace.CreateDirectory("restore");
var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
CreatePackage(feedDirectory.FullName, packageId);
// No globalPackagesFolder entry: this covers the environment override specifically, which is
// resolved by NuGet's settings rather than by anything this client passes explicitly.
var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
File.WriteAllText(
nugetConfigPath,
$"""
<configuration>
<packageSources>
<clear />
<add key="local" value="{feedDirectory.FullName}" />
</packageSources>
</configuration>
""");
// The restore runs in a child process because NUGET_PACKAGES is read once when NuGet's
// settings are first loaded, and the test host has its own value pointing at a shared cache.
// The variable is set both on the child's start info and again inside the child: passing it
// through start info alone did not reach the child reliably when other classes ran alongside
// this one.
var options = new RemoteInvokeOptions();
options.StartInfo.Environment["NUGET_PACKAGES"] = environmentPackagesDirectory.FullName;
RemoteExecutor.Invoke(
static async (packageId, configPath, restorePath, workingDirectory, packagesDirectory) =>
{
Environment.SetEnvironmentVariable("NUGET_PACKAGES", packagesDirectory);
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
await client.RestoreAsync(
[(packageId, "[1.0.0]")],
"net10.0",
runtimeIdentifier: null,
restorePath,
[],
configPath,
workingDirectory,
CancellationToken.None);
},
packageId,
nugetConfigPath,
restoreDirectory.FullName,
workspace.WorkspaceRoot.FullName,
environmentPackagesDirectory.FullName,
options).Dispose();
// NuGet records the resolved packages folder in the assets file, which is also what feeds
// the restore cache key, so assert on it rather than only on the extracted files.
var assets = new LockFileFormat().Read(Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName));
Assert.Contains(
assets.PackageFolders,
folder => string.Equals(
Path.TrimEndingDirectorySeparator(folder.Path),
Path.TrimEndingDirectorySeparator(environmentPackagesDirectory.FullName),
StringComparison.OrdinalIgnoreCase));
// The package must actually land under the override, not merely be referenced from it.
Assert.True(
Directory.Exists(Path.Combine(environmentPackagesDirectory.FullName, packageId.ToLowerInvariant(), "1.0.0")),
$"Expected '{packageId}' to be installed under the NUGET_PACKAGES directory.");
}
[Fact]
public void RestoreAsync_RespectsNuGetConfigGlobalPackagesFolder()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
var configPackagesDirectory = workspace.CreateDirectory("config-packages");
var restoreDirectory = workspace.CreateDirectory("restore");
var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
CreatePackage(feedDirectory.FullName, packageId);
var nugetConfigPath = CreateLocalFeedConfig(workspace, feedDirectory, configPackagesDirectory);
// NUGET_PACKAGES takes precedence over globalPackagesFolder, and the test host often sets it to a shared
// cache, so the restore runs in a child process without it. The variable is cleared inside the child
// as well, for the same reason the NUGET_PACKAGES test sets it there.
var options = new RemoteInvokeOptions();
options.StartInfo.Environment.Remove("NUGET_PACKAGES");
RemoteExecutor.Invoke(
static async (packageId, configPath, restorePath, workingDirectory) =>
{
Environment.SetEnvironmentVariable("NUGET_PACKAGES", null);
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
await client.RestoreAsync(
[(packageId, "[1.0.0]")],
"net10.0",
runtimeIdentifier: null,
restorePath,
[],
configPath,
workingDirectory,
CancellationToken.None);
},
packageId,
nugetConfigPath,
restoreDirectory.FullName,
workspace.WorkspaceRoot.FullName,
options).Dispose();
var assets = new LockFileFormat().Read(Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName));
Assert.Contains(
assets.PackageFolders,
folder => string.Equals(
Path.TrimEndingDirectorySeparator(folder.Path),
Path.TrimEndingDirectorySeparator(configPackagesDirectory.FullName),
StringComparison.OrdinalIgnoreCase));
Assert.True(
Directory.Exists(Path.Combine(configPackagesDirectory.FullName, packageId.ToLowerInvariant(), "1.0.0")),
$"Expected '{packageId}' to be installed under the configured globalPackagesFolder.");
}
[Fact]
public async Task RestoreAndWriteManifestAsync_UsesRuntimeGraphFallbackAssets()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
var restoreDirectory = workspace.CreateDirectory("restore");
var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
CreatePackage(
feedDirectory.FullName,
packageId,
additionalEntries: new Dictionary<string, string>
{
["runtimes/unix-x64/lib/net10.0/UnixFallback.dll"] = "unix-fallback"
});
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
using var restoredPackageScope = new RestoredPackageScope(
GetEffectiveGlobalPackagesFolder(nugetConfigPath: null, workspace.WorkspaceRoot.FullName),
packageId);
await client.RestoreAsync(
[(packageId, "1.0.0")],
"net10.0",
"linux-x64",
restoreDirectory.FullName,
[feedDirectory.FullName],
nugetConfigPath: null,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName,
nugetConfigPath: null, workspace.WorkspaceRoot.FullName);
Assert.NotEmpty(restoredPackages);
var manifestPath = Path.Combine(restoreDirectory.FullName, IntegrationPackageProbeManifest.FileName);
await client.WriteManifestAsync(
Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName),
manifestPath,
"net10.0",
"linux-x64",
TestContext.Current.CancellationToken);
var manifest = IntegrationPackageProbeManifest.Load(manifestPath);
Assert.EndsWith(
Path.Combine("runtimes", "unix-x64", "lib", "net10.0", "UnixFallback.dll"),
manifest.TryGetManagedAssemblyPath(new("UnixFallback")),
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RestoreAndWriteManifestAsync_WritesCanonicalPackageIdForLowercaseRequest()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
var packagesDirectory = workspace.CreateDirectory("packages");
var restoreDirectory = workspace.CreateDirectory("restore");
var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
CreatePackage(
feedDirectory.FullName,
packageId,
additionalEntries: new Dictionary<string, string>
{
["lib/net10.0/Aspire.Test.Package.xml"] = "<doc />"
});
var nugetConfigPath = CreateLocalFeedConfig(workspace, feedDirectory, packagesDirectory);
using var restoredPackageScope = new RestoredPackageScope(
GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName),
packageId);
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
// Package IDs are case-insensitive, so a request may use any casing. The manifest's package ID must
// come from the restored package, not from the request.
await client.RestoreAsync(
[(packageId.ToLowerInvariant(), "1.0.0")],
"net10.0",
runtimeIdentifier: null,
restoreDirectory.FullName,
[],
nugetConfigPath,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
var restoredPackage = Assert.Single(ReadRestoredPackages(restoreDirectory.FullName,
nugetConfigPath, workspace.WorkspaceRoot.FullName));
var manifestPath = Path.Combine(restoreDirectory.FullName, IntegrationPackageProbeManifest.FileName);
await client.WriteManifestAsync(
Path.Combine(restoreDirectory.FullName, LockFileFormat.AssetsFileName),
manifestPath,
"net10.0",
runtimeIdentifier: null,
TestContext.Current.CancellationToken);
// Without a runtime identifier, the assets selected depend on the RID of the machine running the test,
// so check every entry rather than a fixed list.
var manifest = IntegrationPackageProbeManifest.Load(manifestPath);
Assert.NotEmpty(manifest.ManagedAssemblies);
Assert.All(manifest.ManagedAssemblies, assembly =>
{
Assert.Equal(packageId, assembly.PackageId);
Assert.StartsWith(restoredPackage.InstallPath, assembly.Path, StringComparison.OrdinalIgnoreCase);
Assert.EndsWith(".dll", assembly.Path, StringComparison.OrdinalIgnoreCase);
});
}
[Fact]
public async Task RestoreAsync_AppendsExplicitSourcesToConfiguredSources()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var configuredFeed = workspace.CreateDirectory("configured-feed");
var explicitFeed = workspace.CreateDirectory("explicit-feed");
var packagesDirectory = workspace.CreateDirectory("packages");
var restoreDirectory = workspace.CreateDirectory("restore");
var configuredPackageId = $"Aspire.Test.Configured.{Guid.NewGuid():N}";
var explicitPackageId = $"Aspire.Test.Explicit.{Guid.NewGuid():N}";
CreatePackage(configuredFeed.FullName, configuredPackageId);
CreatePackage(explicitFeed.FullName, explicitPackageId);
var nugetConfigPath = CreateLocalFeedConfig(workspace, configuredFeed, packagesDirectory);
using var restoredPackageScope = new RestoredPackageScope(
GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName),
configuredPackageId,
explicitPackageId);
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
// Each package exists on only one feed, so the restore succeeds only if the explicit source is used in
// addition to the configured one rather than in place of it.
await client.RestoreAsync(
[(configuredPackageId, "1.0.0"), (explicitPackageId, "1.0.0")],
"net10.0",
runtimeIdentifier: null,
restoreDirectory.FullName,
[explicitFeed.FullName],
nugetConfigPath,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName,
nugetConfigPath, workspace.WorkspaceRoot.FullName);
Assert.Equal(
[configuredPackageId, explicitPackageId],
restoredPackages.Select(package => package.Id).Order(StringComparer.Ordinal).ToArray());
}
[Fact]
public async Task RestoreAsync_HonorsPackageSourceMapping()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var firstFeed = workspace.CreateDirectory("first-feed");
var mappedFeed = workspace.CreateDirectory("mapped-feed");
var packagesDirectory = workspace.CreateDirectory("packages");
var restoreDirectory = workspace.CreateDirectory("restore");
var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
CreatePackage(firstFeed.FullName, packageId, "wrong-source");
CreatePackage(mappedFeed.FullName, packageId, "mapped-source");
var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
File.WriteAllText(
nugetConfigPath,
$"""
<configuration>
<config>
<add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
</config>
<packageSources>
<clear />
<add key="first" value="{firstFeed.FullName}" />
<add key="mapped" value="{mappedFeed.FullName}" />
</packageSources>
<packageSourceMapping>
<packageSource key="first">
<package pattern="Other.*" />
</packageSource>
<packageSource key="mapped">
<package pattern="Aspire.Test.Package.*" />
</packageSource>
</packageSourceMapping>
</configuration>
""");
using var restoredPackageScope = new RestoredPackageScope(
GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName),
packageId);
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
await client.RestoreAsync(
[(packageId, "1.0.0")],
"net10.0",
runtimeIdentifier: null,
restoreDirectory.FullName,
[],
nugetConfigPath,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName,
nugetConfigPath, workspace.WorkspaceRoot.FullName);
var restoredPackage = Assert.Single(restoredPackages);
Assert.Equal(
"mapped-source",
await File.ReadAllTextAsync(
Path.Combine(restoredPackage.InstallPath, "lib", "net10.0", "Aspire.Test.Package.dll"),
TestContext.Current.CancellationToken));
}
[Fact]
public async Task RestoreAsync_PrefersAvailableMappedSourceOverExplicitSource()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var mappedFeed = workspace.CreateDirectory("mapped-feed");
var explicitFeed = workspace.CreateDirectory("explicit-feed");
var packagesDirectory = workspace.CreateDirectory("packages");
var restoreDirectory = workspace.CreateDirectory("restore");
var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
CreatePackage(mappedFeed.FullName, packageId, "mapped-source");
CreatePackage(explicitFeed.FullName, packageId, "explicit-source");
var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
File.WriteAllText(
nugetConfigPath,
$"""
<configuration>
<config>
<add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
</config>
<packageSources>
<clear />
<add key="mapped" value="{mappedFeed.FullName}" />
</packageSources>
<packageSourceMapping>
<packageSource key=".">
<package pattern="Aspire.Test.Package.*" />
</packageSource>
<packageSource key="mapped">
<package pattern="Aspire.Test.Package.*" />
</packageSource>
</packageSourceMapping>
</configuration>
""");
using var restoredPackageScope = new RestoredPackageScope(
GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName),
packageId);
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
await client.RestoreAsync(
[(packageId, "1.0.0")],
"net10.0",
runtimeIdentifier: null,
restoreDirectory.FullName,
[explicitFeed.FullName],
nugetConfigPath,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName,
nugetConfigPath, workspace.WorkspaceRoot.FullName);
var restoredPackage = Assert.Single(restoredPackages);
Assert.Equal(
"mapped-source",
await File.ReadAllTextAsync(
Path.Combine(restoredPackage.InstallPath, "lib", "net10.0", "Aspire.Test.Package.dll"),
TestContext.Current.CancellationToken));
}
[Fact]
public async Task RestoreAsync_RejectsExplicitSourceWhenPackageHasNoMapping()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var explicitFeed = workspace.CreateDirectory("explicit-feed");
var packagesDirectory = workspace.CreateDirectory("packages");
var restoreDirectory = workspace.CreateDirectory("restore");
var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
CreatePackage(explicitFeed.FullName, packageId);
var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
File.WriteAllText(
nugetConfigPath,
$"""
<configuration>
<config>
<add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
</config>
<packageSources>
<clear />
</packageSources>
<packageSourceMapping>
<packageSource key=".">
<package pattern="Other.*" />
</packageSource>
</packageSourceMapping>
</configuration>
""");
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
var exception = await Assert.ThrowsAsync<NuGetOperationException>(() => client.RestoreAsync(
[(packageId, "1.0.0")],
"net10.0",
runtimeIdentifier: null,
restoreDirectory.FullName,
[explicitFeed.FullName],
nugetConfigPath,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken));
Assert.Contains(packageId, exception.Output, StringComparison.Ordinal);
Assert.Contains("PackageSourceMapping", exception.Output, StringComparison.Ordinal);
}
[Fact]
public async Task RestoreAsync_SelectsLowestSatisfyingDependencyVersion()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
var packagesDirectory = workspace.CreateDirectory("packages");
var restoreDirectory = workspace.CreateDirectory("restore");
var rootPackage = $"Aspire.Test.Package.Root.{Guid.NewGuid():N}";
var dependencyPackage = $"Aspire.Test.Package.Dep.{Guid.NewGuid():N}";
var missingPackage = $"Aspire.Test.Package.Missing.{Guid.NewGuid():N}";
CreatePackage(
feedDirectory.FullName,
rootPackage,
dependencies: [(dependencyPackage, "1.0.0")]);
CreatePackage(feedDirectory.FullName, dependencyPackage, version: "1.0.0");
// Newer versions of the dependency are not selectable under DependencyBehavior.Lowest.
// Expanding them anyway is what made the pre-walk fan out across the whole version history
// of every transitive package and stall restore (#19847), so they declare a dependency that
// does not exist to keep them clearly off the selected path.
CreatePackage(
feedDirectory.FullName,
dependencyPackage,
version: "2.0.0",
dependencies: [(missingPackage, "1.0.0")]);
CreatePackage(
feedDirectory.FullName,
dependencyPackage,
version: "3.0.0",
dependencies: [(missingPackage, "1.0.0")]);
var nugetConfigPath = CreateWorkspaceGlobalPackagesConfig(workspace, packagesDirectory);
using var restoredPackageScope = new RestoredPackageScope(
GetEffectiveGlobalPackagesFolder(nugetConfigPath, workspace.WorkspaceRoot.FullName),
rootPackage,
dependencyPackage);
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
await client.RestoreAsync(
[(rootPackage, "1.0.0")],
"net10.0",
runtimeIdentifier: null,
restoreDirectory.FullName,
[feedDirectory.FullName],
nugetConfigPath,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
var restoredPackages = ReadRestoredPackages(restoreDirectory.FullName,
nugetConfigPath, workspace.WorkspaceRoot.FullName);
Assert.Collection(
restoredPackages.OrderBy(package => package.Id, StringComparer.OrdinalIgnoreCase),
package =>
{
Assert.Equal(dependencyPackage, package.Id);
Assert.Equal("1.0.0", package.Version);
},
package =>
{
Assert.Equal(rootPackage, package.Id);
Assert.Equal("1.0.0", package.Version);
});
}
[Fact]
public async Task RestoreAsync_ReplacesIncompleteGlobalPackage()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var feedDirectory = workspace.CreateDirectory("feed");
var packagesDirectory = workspace.CreateDirectory("packages");
var restoreDirectory = workspace.CreateDirectory("restore");
var packageId = $"Aspire.Test.Package.{Guid.NewGuid():N}";
CreatePackage(feedDirectory.FullName, packageId);
var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
File.WriteAllText(
nugetConfigPath,
$"""
<configuration>
<config>
<add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
</config>
<packageSources>
<clear />
<add key="local" value="{feedDirectory.FullName}" />
</packageSources>
</configuration>
""");
var settings = Settings.LoadSpecificSettings(
Path.GetDirectoryName(nugetConfigPath)!,
Path.GetFileName(nugetConfigPath));
var globalPackagesFolder = SettingsUtility.GetGlobalPackagesFolder(settings);
var incompleteInstallPath = Path.Combine(globalPackagesFolder, packageId.ToLowerInvariant(), "1.0.0");
Directory.CreateDirectory(incompleteInstallPath);
File.WriteAllText(Path.Combine(incompleteInstallPath, "partial.txt"), "incomplete");
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
try
{
await client.RestoreAsync(
[(packageId, "1.0.0")],
"net10.0",
runtimeIdentifier: null,
restoreDirectory.FullName,
[],
nugetConfigPath,
workspace.WorkspaceRoot.FullName,
TestContext.Current.CancellationToken);
var package = Assert.Single(ReadRestoredPackages(restoreDirectory.FullName, nugetConfigPath, workspace.WorkspaceRoot.FullName));
Assert.Equal(incompleteInstallPath, package.InstallPath, ignoreCase: true);
Assert.True(File.Exists(Path.Combine(package.InstallPath, ".nupkg.metadata")));
Assert.True(File.Exists(Path.Combine(package.InstallPath, "lib", "net10.0", "Aspire.Test.Package.dll")));
}
finally
{
Directory.Delete(Path.Combine(globalPackagesFolder, packageId.ToLowerInvariant()), recursive: true);
}
}
[Fact]
public void GetSettings_ReturnsEffectiveSourcePolicy()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var projectDirectory = workspace.CreateDirectory("AppHost");
var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config");
var source = $"https://packages.example.com/v3/index.json?token={Guid.NewGuid():N}";
File.WriteAllText(
configPath,
$$"""
<configuration>
<packageSources>
<clear />
<add key="private" value="{{source}}" />
</packageSources>
<disabledPackageSources>
<add key="private" value="true" />
</disabledPackageSources>
<packageSourceMapping>
<packageSource key="private">
<package pattern="Aspire.*" />
</packageSource>
</packageSourceMapping>
<packageSourceCredentials>
<private>
<add key="Username" value="user" />
<add key="ClearTextPassword" value="secret" />
</private>
</packageSourceCredentials>
</configuration>
""");
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
var settings = client.GetSettings(
projectDirectory.FullName,
Enumerable.Repeat((byte)0x5A, NuGetSourceIdentity.KeySizeInBytes).ToArray());
Assert.Contains(configPath, settings.ConfigPaths);
var sourceInfo = Assert.Single(settings.Sources, source => source.Name == "private");
Assert.False(sourceInfo.IsEnabled);
Assert.True(sourceInfo.HasCredentials);
Assert.Contains(source, settings.SensitiveSourceValues);
var mapping = Assert.Single(settings.PackageSourceMappings);
Assert.Equal("private", mapping.SourceKey);
Assert.Equal(["Aspire.*"], mapping.Patterns);
Assert.Contains("private", settings.DisabledPackageSourceKeys);
Assert.Contains("private", settings.ReservedPackageSourceKeys);
Assert.NotEmpty(settings.CacheIdentity);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void GetSettings_RecognizesLocalCliHives(bool useFileUri)
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var hive = workspace.CreateDirectory(".aspire/hives/pr-test/packages");
var source = useFileUri ? new Uri(hive.FullName).AbsoluteUri : hive.FullName;
File.WriteAllText(
Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config"),
$$"""
<configuration>
<packageSources>
<clear />
<add key="ambient" value="{{source}}" />
</packageSources>
</configuration>
""");
var settings = NuGetTestHelper.CreateClient().GetSettings(
workspace.WorkspaceRoot.FullName,
new byte[NuGetSourceIdentity.KeySizeInBytes]);
Assert.True(Assert.Single(settings.Sources).IsCliManaged);
}
[Fact]
public void GetSettings_TreatsCredentialBearingSourceNameAsSensitive()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var projectDirectory = workspace.CreateDirectory("AppHost");
var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config");
const string sourceName = "https://user:credential-marker@packages.example.com";
const string source = "https://packages.example.com/v3/index.json";
File.WriteAllText(
configPath,
$$"""
<configuration>
<packageSources>
<clear />
<add key="{{sourceName}}" value="{{source}}" />
</packageSources>
</configuration>
""");
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
var settings = client.GetSettings(
projectDirectory.FullName,
Enumerable.Repeat((byte)0x5A, NuGetSourceIdentity.KeySizeInBytes).ToArray());
var sourceInfo = Assert.Single(settings.Sources);
Assert.Equal(sourceName, sourceInfo.Name);
Assert.Contains(sourceName, settings.SensitiveSourceValues);
Assert.DoesNotContain(source, settings.SensitiveSourceValues);
}
[Fact]
public void GetSettings_TrustedSignerChangeInvalidatesCacheIdentity()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var configPath = Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config");
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
var sourceIdentityKey = Enumerable.Repeat((byte)0x5A, NuGetSourceIdentity.KeySizeInBytes).ToArray();
WriteTrustedSignerConfig(configPath, new string('A', 64));
var firstSettings = client.GetSettings(workspace.WorkspaceRoot.FullName, sourceIdentityKey);
WriteTrustedSignerConfig(configPath, new string('B', 64));
var secondSettings = client.GetSettings(workspace.WorkspaceRoot.FullName, sourceIdentityKey);
Assert.NotEqual(firstSettings.CacheIdentity, secondSettings.CacheIdentity);
}
[Fact]
public void WriteNuGetConfig_WritesPolicySections()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var outputPath = Path.Combine(workspace.WorkspaceRoot.FullName, "policy", "NuGet.Config");
var client = new NuGetClient(
new TestFeatures(),
new TestEnvironment(),
NullLogger<NuGetClient>.Instance);
client.WriteNuGetConfig(
new NuGetConfigOverlay(
[("private", "https://packages.example.com/v3/index.json")],
[new("private", ["Aspire.*"])],
ClearDisabledPackageSources: true,
DisabledPackageSourceKeys: ["other"],
GlobalPackagesFolder: "/tmp/aspire-packages"),
outputPath);
var document = XDocument.Load(outputPath);
Assert.Equal(
"https://packages.example.com/v3/index.json",
document.Descendants("packageSources").Elements("add").Single().Attribute("value")?.Value);
Assert.Equal(
["Aspire.*"],
document.Descendants("packageSourceMapping").Elements("packageSource").Elements("package")
.Select(static package => package.Attribute("pattern")!.Value));
var disabledSources = Assert.Single(document.Descendants("disabledPackageSources"));
Assert.NotNull(disabledSources.Element("clear"));
Assert.Equal("other", disabledSources.Elements("add").Single().Attribute("key")?.Value);
Assert.Equal(
"/tmp/aspire-packages",
document.Descendants("config").Elements("add").Single().Attribute("value")?.Value);
}
[Fact]
public async Task WriteNuGetConfig_ClearsInheritedMappingsWhenReplacementIsEmpty()
{
using var workspace = TemporaryWorkspace.CreateForCli(outputHelper);
var originalPath = Path.Combine(workspace.WorkspaceRoot.FullName, "NuGet.Config");
await File.WriteAllTextAsync(originalPath, """
<configuration>
<packageSources>
<clear />
<add key="stable" value="https://stable.example/v3/index.json" />
<add key="daily" value="https://daily.example/v3/index.json" />
</packageSources>
<packageSourceMapping>
<clear />
<packageSource key="daily"><package pattern="Aspire*" /></packageSource>
</packageSourceMapping>
</configuration>
""");
var overlayDirectory = workspace.CreateDirectory("preview");
var overlayPath = Path.Combine(overlayDirectory.FullName, "NuGet.Config");
var client = NuGetTestHelper.CreateClient();
client.WriteNuGetConfig(
new NuGetConfigOverlay([], [], false, [], null) { ClearPackageSourceMappings = true },
overlayPath);
var settings = client.GetSettings(overlayDirectory.FullName, new byte[NuGetSourceIdentity.KeySizeInBytes]);
Assert.Empty(settings.PackageSourceMappings);
Assert.Equal(["stable", "daily"], settings.Sources.Select(source => source.Name));
await Verify(XDocument.Load(overlayPath).ToString(), "xml");
}
/// <summary>
/// Writes a NuGet config whose global packages folder defaults to the temporary workspace.
/// Environment overrides still follow native NuGet precedence.
/// </summary>
private static string CreateWorkspaceGlobalPackagesConfig(TemporaryWorkspace workspace, DirectoryInfo packagesDirectory)
{
var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
File.WriteAllText(
nugetConfigPath,
$"""
<configuration>
<config>
<add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
</config>
</configuration>
""");
return nugetConfigPath;
}
/// <summary>
/// Writes a nuget.config with a single local feed and a workspace-scoped global packages folder default.
/// Environment overrides still follow native NuGet precedence.
/// </summary>
private static string CreateLocalFeedConfig(TemporaryWorkspace workspace, DirectoryInfo feedDirectory, DirectoryInfo packagesDirectory)
{
var nugetConfigPath = Path.Combine(workspace.WorkspaceRoot.FullName, "nuget.config");
File.WriteAllText(
nugetConfigPath,
$"""
<configuration>
<config>
<add key="globalPackagesFolder" value="{packagesDirectory.FullName}" />
</config>
<packageSources>
<clear />
<add key="local" value="{feedDirectory.FullName}" />
</packageSources>
</configuration>
""");
return nugetConfigPath;
}
private static void WriteTrustedSignerConfig(string configPath, string fingerprint)
{
File.WriteAllText(
configPath,
$$"""
<configuration>
<trustedSigners>
<author name="test-author">
<certificate fingerprint="{{fingerprint}}" hashAlgorithm="SHA256" allowUntrustedRoot="false" />
</author>
</trustedSigners>
</configuration>
""");
}
/// <summary>
/// Resolves the global packages folder the client will actually restore into, using the same
/// settings lookup. NUGET_PACKAGES takes precedence over the <c>globalPackagesFolder</c> config
/// value, so a workspace-scoped config alone does not keep restores out of the machine-wide
/// folder on developer machines or CI agents that set the variable.
/// </summary>
private static string GetEffectiveGlobalPackagesFolder(string? nugetConfigPath, string workingDirectory)
{
var settings = nugetConfigPath is not null
? Settings.LoadSpecificSettings(Path.GetDirectoryName(nugetConfigPath)!, Path.GetFileName(nugetConfigPath))
: Settings.LoadDefaultSettings(workingDirectory);
return SettingsUtility.GetGlobalPackagesFolder(settings);
}
/// <summary>
/// Reads the packages NuGet restored from the assets file it wrote, mirroring how the manifest
/// step consumes restore output.
/// </summary>
private static IReadOnlyList<(string Id, string Version, string InstallPath)> ReadRestoredPackages(
string restoreDirectory,
string? nugetConfigPath,
string workingDirectory)
{
var lockFile = new LockFileFormat().Read(Path.Combine(restoreDirectory, LockFileFormat.AssetsFileName));
var packagesFolder = lockFile.PackageFolders.FirstOrDefault()?.Path
?? GetEffectiveGlobalPackagesFolder(nugetConfigPath, workingDirectory);
var pathResolver = new VersionFolderPathResolver(packagesFolder);
return lockFile.Libraries
.Where(library => string.Equals(library.Type, "package", StringComparison.OrdinalIgnoreCase))
.Select(library => (
library.Name,
library.Version.ToNormalizedString(),
pathResolver.GetInstallPath(library.Name, library.Version)))
.ToArray();
}
private static void CreatePackage(
string feedDirectory,
string packageId,
string baseAssemblyContents = "base",
string version = "1.0.0",
IReadOnlyDictionary<string, string>? additionalEntries = null,
IReadOnlyList<(string Id, string Version)>? dependencies = null)
{
var packagePath = Path.Combine(feedDirectory, $"{packageId}.{version}.nupkg");
using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create);
var dependencyElements = dependencies is null
? string.Empty
: $"""
<dependencies>
<group targetFramework="net10.0">
{string.Join(
Environment.NewLine,
dependencies.Select(dependency => $" <dependency id=\"{dependency.Id}\" version=\"{dependency.Version}\" />"))}
</group>
</dependencies>
""";
WriteEntry(
archive,
$"{packageId}.nuspec",
$"""
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata>
<id>{packageId}</id>
<version>{version}</version>
<authors>Aspire</authors>
<description>Package used to validate in-process NuGet restore.</description>
{dependencyElements}
</metadata>
</package>
""");
WriteEntry(archive, "lib/net10.0/Aspire.Test.Package.dll", baseAssemblyContents);
WriteEntry(archive, "runtimes/win-x64/lib/net10.0/Aspire.Test.Package.dll", "runtime");
WriteEntry(archive, "runtimes/win-x64/lib/net10.0/RuntimeOnly.dll", "runtime-only");
WriteEntry(archive, "runtimes/win-x64/lib/net10.0/Neutral.resources.dll", "neutral");
WriteEntry(archive, "runtimes/win-x64/lib/net10.0/fr/RuntimeOnly.resources.dll", "french");
WriteEntry(archive, "runtimes/win-x64/lib/net10.0/de/RuntimeOnly.resources.dll", "german");
WriteEntry(archive, "runtimes/win-x64/native/native-test.dll", "native");
if (additionalEntries is not null)
{
foreach (var (path, contents) in additionalEntries)
{
WriteEntry(archive, path, contents);
}
}
}
private static void WriteEntry(ZipArchive archive, string path, string contents)
{
using var writer = new StreamWriter(archive.CreateEntry(path).Open());
writer.Write(contents);
}
/// <summary>
/// Removes the packages a restore test installed from the global packages folder. Disposal runs
/// even when an assertion fails, so a failing test cannot leave packages behind in a folder that
/// outlives the temporary workspace.
/// </summary>
private sealed class RestoredPackageScope(string globalPackagesFolder, params string[] packageIds) : IDisposable
{
public void Dispose()
{
foreach (var packageId in packageIds)
{
var packageDirectory = Path.Combine(globalPackagesFolder, packageId.ToLowerInvariant());
if (!Directory.Exists(packageDirectory))
{
continue;
}
try
{
Directory.Delete(packageDirectory, recursive: true);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Another test process may hold the extracted files open. Leaving a stray
// package behind must not fail an otherwise passing test.
}
}
}
}
}
file static class NuGetClientTestExtensions
{
public static Task RestoreAsync(
this NuGetClient client,
IReadOnlyList<(string Id, string Version)> packages,
string framework,
string? runtimeIdentifier,
string outputPath,
IReadOnlyList<string> sources,
string? nugetConfigPath,
string workingDirectory,
CancellationToken cancellationToken)
=> client.RestoreAsync(
packages,
framework,
runtimeIdentifier,
outputPath,
sources,
nugetConfigPath is null ? [] : [nugetConfigPath],
workingDirectory,
globalPackagesFolderOverride: null,
sensitiveSources: [],
cancellationToken);
}