From 968eab0f1924de8c98acb1027062f0f5a0fb297a Mon Sep 17 00:00:00 2001 From: Pranathi Rao Bora Date: Thu, 1 Oct 2026 14:27:35 -0700 Subject: [PATCH 1/4] Add Trusted Publishing for CloudTest execution --- .../NuGet.Services.EndToEnd.csproj | 5 + .../Properties/AssemblyInfo.cs | 2 + .../Support/Clients/GalleryClient.cs | 8 +- .../Support/CommonFixture.cs | 2 + .../Configuration/GalleryConfiguration.cs | 4 + .../TrustedPublishingSettings.cs | 14 + .../Support/E2EPublishingKeyProvider.cs | 209 +++++++++++ .../Support/TestSettings.cs | 63 +++- .../NuGet.Services.EndToEnd.Test.csproj | 2 + .../Support/E2EPublishingKeyProviderTests.cs | 332 ++++++++++++++++++ 10 files changed, 629 insertions(+), 12 deletions(-) create mode 100644 src/NuGet.Services.EndToEnd/Support/Configuration/TrustedPublishingSettings.cs create mode 100644 src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs create mode 100644 test/NuGet.Services.EndToEnd.Test/Support/E2EPublishingKeyProviderTests.cs diff --git a/src/NuGet.Services.EndToEnd/NuGet.Services.EndToEnd.csproj b/src/NuGet.Services.EndToEnd/NuGet.Services.EndToEnd.csproj index f90f9db..de3ba6a 100644 --- a/src/NuGet.Services.EndToEnd/NuGet.Services.EndToEnd.csproj +++ b/src/NuGet.Services.EndToEnd/NuGet.Services.EndToEnd.csproj @@ -66,6 +66,7 @@ + @@ -75,6 +76,7 @@ + @@ -153,6 +155,9 @@ + + 1.12.0 + 2.2.0 diff --git a/src/NuGet.Services.EndToEnd/Properties/AssemblyInfo.cs b/src/NuGet.Services.EndToEnd/Properties/AssemblyInfo.cs index 609a318..4b9a46d 100644 --- a/src/NuGet.Services.EndToEnd/Properties/AssemblyInfo.cs +++ b/src/NuGet.Services.EndToEnd/Properties/AssemblyInfo.cs @@ -2,8 +2,10 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; [assembly: AssemblyTitle("NuGet.Services.EndToEnd")] +[assembly: InternalsVisibleTo("NuGet.Services.EndToEnd.Test")] [assembly: ComVisible(false)] [assembly: Guid("35ca0b78-de1a-4c4e-a97f-85e84db17a2f")] diff --git a/src/NuGet.Services.EndToEnd/Support/Clients/GalleryClient.cs b/src/NuGet.Services.EndToEnd/Support/Clients/GalleryClient.cs index 0041cb2..87592d0 100644 --- a/src/NuGet.Services.EndToEnd/Support/Clients/GalleryClient.cs +++ b/src/NuGet.Services.EndToEnd/Support/Clients/GalleryClient.cs @@ -35,13 +35,7 @@ public GalleryClient(SimpleHttpClient httpClient, TestSettings testSettings) public Uri GetGalleryServiceBaseUrl() { - var serviceBaseUrl = _testSettings.GalleryConfiguration.ServiceDetails?.BaseUrl; - if (serviceBaseUrl != null) - { - return new Uri(serviceBaseUrl); - } - - return GetGalleryBaseUrl(); + return new Uri(_testSettings.GalleryConfiguration.GetServiceBaseUrl()); } public Uri GetGalleryBaseUrl() diff --git a/src/NuGet.Services.EndToEnd/Support/CommonFixture.cs b/src/NuGet.Services.EndToEnd/Support/CommonFixture.cs index fe27f62..a9e038b 100644 --- a/src/NuGet.Services.EndToEnd/Support/CommonFixture.cs +++ b/src/NuGet.Services.EndToEnd/Support/CommonFixture.cs @@ -14,6 +14,8 @@ public class CommonFixture : IAsyncLifetime public virtual async Task InitializeAsync() { TestSettings = await TestSettings.CreateAsync(); + // Initialize publishing authentication only during execution. + await TestSettings.InitializePublishingKeyAsync(); Clients = Clients.Initialize(TestSettings); } diff --git a/src/NuGet.Services.EndToEnd/Support/Configuration/GalleryConfiguration.cs b/src/NuGet.Services.EndToEnd/Support/Configuration/GalleryConfiguration.cs index 1e8b6e9..87e1139 100644 --- a/src/NuGet.Services.EndToEnd/Support/Configuration/GalleryConfiguration.cs +++ b/src/NuGet.Services.EndToEnd/Support/Configuration/GalleryConfiguration.cs @@ -13,5 +13,9 @@ public class GalleryConfiguration public string GalleryBaseUrl { get; set; } public ServiceDetails ServiceDetails { get; set; } + + // Use GalleryBaseUrl only when the service URL is null. Keep empty or invalid + // service URLs so they fail validation instead of silently targeting a different Gallery. + internal string GetServiceBaseUrl() => ServiceDetails?.BaseUrl ?? GalleryBaseUrl; } } diff --git a/src/NuGet.Services.EndToEnd/Support/Configuration/TrustedPublishingSettings.cs b/src/NuGet.Services.EndToEnd/Support/Configuration/TrustedPublishingSettings.cs new file mode 100644 index 0000000..d48398d --- /dev/null +++ b/src/NuGet.Services.EndToEnd/Support/Configuration/TrustedPublishingSettings.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace NuGet.Services.EndToEnd.Support +{ + public sealed class TrustedPublishingSettings + { + public string Environment { get; set; } + + public string ResourceUri { get; set; } + + public string[] AllowedGalleryHosts { get; set; } + } +} \ No newline at end of file diff --git a/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs b/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs new file mode 100644 index 0000000..8af6ac6 --- /dev/null +++ b/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs @@ -0,0 +1,209 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Identity; +using Newtonsoft.Json; + +namespace NuGet.Services.EndToEnd.Support +{ + /// + /// Exchanges the CloudTest VM's managed identity token for a short-lived Gallery key. + /// This does not use the pipeline identity, developer credentials, or a stored API key. + /// + internal sealed class E2EPublishingKeyProvider + { + private readonly Func _createCredential; + private readonly Func _createHttpClient; + private readonly Func _utcNow; + + internal E2EPublishingKeyProvider() + : this(CreateCredential, CreateHttpClient, () => DateTimeOffset.UtcNow) + { + } + + internal E2EPublishingKeyProvider( + Func createCredential, + Func createHttpClient, + Func utcNow) + { + _createCredential = createCredential ?? throw new ArgumentNullException(nameof(createCredential)); + _createHttpClient = createHttpClient ?? throw new ArgumentNullException(nameof(createHttpClient)); + _utcNow = utcNow ?? throw new ArgumentNullException(nameof(utcNow)); + } + + internal async Task AcquireAsync(TestSettings settings) + { + // Validate everything before constructing a credential or making any network request. + var context = GetContext(settings); + if (!Guid.TryParse(settings.ManagedIdentityClientId, out var clientId) || clientId == Guid.Empty) + { + throw new InvalidOperationException("E2E trusted publishing requires a non-empty managed identity client ID."); + } + + AccessToken token; + try + { + var credential = _createCredential(clientId.ToString()); + var request = new TokenRequestContext(new[] { context.ResourceUri }); + token = await credential.GetTokenAsync(request, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception) + { + // SDK exceptions can contain HTTP details. + throw new InvalidOperationException("Cannot acquire an Entra token using the CloudTest managed identity."); + } + + if (string.IsNullOrWhiteSpace(token.Token)) + { + throw new InvalidOperationException("Managed identity did not return an access token."); + } + + using (var client = _createHttpClient()) + using (var request = new HttpRequestMessage(HttpMethod.Post, new Uri(context.GalleryUri, "api/v2/token"))) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + request.Headers.UserAgent.ParseAdd("NuGet-E2E-CloudTest/1.0"); + request.Content = new StringContent(JsonConvert.SerializeObject(new + { + username = settings.TestAccountOwner, + tokenType = "ApiKey" + }), Encoding.UTF8, "application/json"); + + string content; + try + { + // Do not use SimpleHttpClient/RetryUtility here: a token can be exchanged only once. + using (var response = await client.SendAsync(request).ConfigureAwait(false)) + { + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"Gallery token exchange failed (HTTP {(int)response.StatusCode}); not retrying. Check the environment policy and possible token replay."); + } + + content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } + } + catch (HttpRequestException) + { + throw new InvalidOperationException("Gallery token exchange failed; not retrying an ambiguous request. Details omitted."); + } + catch (OperationCanceledException) + { + throw new InvalidOperationException("Gallery token exchange timed out or was canceled; not retrying an ambiguous request."); + } + + PublishingKey result; + try + { + result = JsonConvert.DeserializeObject(content); + } + catch (JsonException) + { + throw new InvalidOperationException("Gallery returned invalid JSON; response omitted."); + } + + if (result == null || result.TokenType != "ApiKey" || string.IsNullOrWhiteSpace(result.ApiKey)) + { + throw new InvalidOperationException("Gallery returned an invalid token exchange response. Expected tokenType 'ApiKey' and a non-empty apiKey."); + } + + if (!result.Expires.HasValue || result.Expires.Value <= _utcNow()) + { + throw new InvalidOperationException("Gallery returned a publishing key with a missing or expired timestamp."); + } + + return result; + } + } + + internal static PublishingContext GetContext(TestSettings settings) + { + if (settings == null) { throw new ArgumentNullException(nameof(settings)); } + var match = Regex.Match(settings.ConfigurationName ?? string.Empty, + @"\A(Dev|Int|Prod)(?:-[A-Za-z0-9][A-Za-z0-9_-]*)?\z", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + if (!match.Success) + { + throw new InvalidOperationException("E2E trusted publishing requires a Dev, Int, or Prod configuration."); + } + if (string.IsNullOrWhiteSpace(settings.TestAccountOwner)) + { + throw new InvalidOperationException("E2E trusted publishing requires a non-empty policy username."); + } + + // Resource and destination allowlist come from NuGet.Services deployment configuration. + var publishing = settings.TrustedPublishing; + if (publishing == null || + !string.Equals(publishing.Environment, match.Groups[1].Value, StringComparison.OrdinalIgnoreCase) || + !TryGetHttpsRootUri(publishing.ResourceUri, out _) || + publishing.AllowedGalleryHosts == null || publishing.AllowedGalleryHosts.Length == 0 || + publishing.AllowedGalleryHosts.Any(host => Uri.CheckHostName(host) != UriHostNameType.Dns)) + { + throw new InvalidOperationException("E2E trusted publishing requires deployment configuration with a matching environment, HTTPS resource URI, and approved Gallery hosts."); + } + + var baseUrl = settings.GalleryConfiguration?.GetServiceBaseUrl(); + if (!TryGetHttpsRootUri(baseUrl, out var gallery) || + !publishing.AllowedGalleryHosts.Contains(gallery.Host, StringComparer.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("E2E trusted publishing requires an approved HTTPS Gallery root URL for the configuration environment."); + } + + // Keep the configured resource spelling; Uri.AbsoluteUri would append a slash. + return new PublishingContext(publishing.ResourceUri, gallery); + } + + private static bool TryGetHttpsRootUri(string value, out Uri uri) + { + return Uri.TryCreate(value, UriKind.Absolute, out uri) && + uri.Scheme == Uri.UriSchemeHttps && uri.IsDefaultPort && + string.IsNullOrEmpty(uri.UserInfo) && string.IsNullOrEmpty(uri.Query) && + string.IsNullOrEmpty(uri.Fragment) && uri.AbsolutePath == "/"; + } + + private static TokenCredential CreateCredential(string clientId) + { + var options = new TokenCredentialOptions(); + options.Retry.MaxRetries = 2; + options.Retry.NetworkTimeout = TimeSpan.FromSeconds(20); + options.Diagnostics.IsLoggingContentEnabled = false; + return new ManagedIdentityCredential(clientId, options); + } + + private static HttpClient CreateHttpClient() + { + return new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) + { + Timeout = TimeSpan.FromSeconds(20) + }; + } + + internal sealed class PublishingKey + { + public string TokenType { get; set; } + public string ApiKey { get; set; } + public DateTimeOffset? Expires { get; set; } + } + + internal sealed class PublishingContext + { + internal PublishingContext(string resourceUri, Uri galleryUri) + { + ResourceUri = resourceUri; + GalleryUri = galleryUri; + } + + internal string ResourceUri { get; } + internal Uri GalleryUri { get; } + } + } +} \ No newline at end of file diff --git a/src/NuGet.Services.EndToEnd/Support/TestSettings.cs b/src/NuGet.Services.EndToEnd/Support/TestSettings.cs index 12c9126..62fecab 100644 --- a/src/NuGet.Services.EndToEnd/Support/TestSettings.cs +++ b/src/NuGet.Services.EndToEnd/Support/TestSettings.cs @@ -28,9 +28,53 @@ public class TestSettings private static TestSettings _testSettings = null; private static SemaphoreSlim _semaphore = new SemaphoreSlim(1); + private readonly Lazy _initializePublishingKey; - private TestSettings() + internal TestSettings(E2EPublishingKeyProvider publishingKeyProvider) { + if (publishingKeyProvider == null) { throw new ArgumentNullException(nameof(publishingKeyProvider)); } + + // Share the same authentication task across fixtures, even if it fails. + _initializePublishingKey = new Lazy(() => InitializePublishingKeyCoreAsync(publishingKeyProvider), + LazyThreadSafetyMode.ExecutionAndPublication); + } + + internal string ConfigurationName { get; set; } + + internal string ManagedIdentityClientId { get; set; } + + internal TrustedPublishingSettings TrustedPublishing { get; set; } + + public DateTimeOffset? PublishingKeyExpires { get; private set; } + + internal Task InitializePublishingKeyAsync() => _initializePublishingKey.Value; + + private async Task InitializePublishingKeyCoreAsync(E2EPublishingKeyProvider provider) + { + // Only standalone local configurations may use a supplied API key. + // Identity or deployment settings always require trusted publishing, with no fallback. + if (IsLocalConfiguration(ConfigurationName) && + string.IsNullOrEmpty(ManagedIdentityClientId) && TrustedPublishing == null) + { + if (string.IsNullOrWhiteSpace(ApiKey) || ApiKey == "API_KEY") + { + throw new InvalidOperationException("Local E2E execution requires a non-empty TestSettings.ApiKey; replace the API_KEY placeholder with your own key."); + } + + return; + } + + ApiKey = null; + var key = await provider.AcquireAsync(this).ConfigureAwait(false); + ApiKey = key.ApiKey; + PublishingKeyExpires = key.Expires; + } + + private static bool IsLocalConfiguration(string configurationName) + { + return string.Equals(configurationName, "Dev", StringComparison.OrdinalIgnoreCase) || + string.Equals(configurationName, "Int", StringComparison.OrdinalIgnoreCase) || + string.Equals(configurationName, "Prod", StringComparison.OrdinalIgnoreCase); } public string V3IndexUrl { get; set; } @@ -85,9 +129,7 @@ public static TestSettings Create() public static async Task CreateLocalTestConfigurationAsync(string configurationName) { - if (configurationName == "Dev" || - configurationName == "Int" || - configurationName == "Prod") + if (IsLocalConfiguration(configurationName)) { return await CreateInternalAsync(configurationName); } @@ -137,11 +179,22 @@ private static async Task CreateInternalAsync(string configuration var configurationRoot = builder.Build(); + return await CreateFromConfigurationAsync(configurationRoot, configurationName, new E2EPublishingKeyProvider()); + } + + internal static async Task CreateFromConfigurationAsync( + IConfigurationRoot configurationRoot, string configurationName, E2EPublishingKeyProvider publishingKeyProvider) + { + var managedIdentityClientId = configurationRoot["ManagedIdentityClientId"]; + var configuration = new E2ESecretConfigurationReader(configurationRoot, new ConfigurationRootSecretReaderFactory(configurationRoot)); await configuration.InjectSecrets(); - var testSettings = new TestSettings(); + var testSettings = new TestSettings(publishingKeyProvider); configurationRoot.GetSection("TestSettings").Bind(testSettings); + testSettings.ConfigurationName = configurationName; + testSettings.ManagedIdentityClientId = managedIdentityClientId; + testSettings.TrustedPublishing = configurationRoot.GetSection("TrustedPublishing").Get(); return testSettings; } diff --git a/test/NuGet.Services.EndToEnd.Test/NuGet.Services.EndToEnd.Test.csproj b/test/NuGet.Services.EndToEnd.Test/NuGet.Services.EndToEnd.Test.csproj index eb9ac64..a3fd85a 100644 --- a/test/NuGet.Services.EndToEnd.Test/NuGet.Services.EndToEnd.Test.csproj +++ b/test/NuGet.Services.EndToEnd.Test/NuGet.Services.EndToEnd.Test.csproj @@ -33,6 +33,7 @@ + @@ -43,6 +44,7 @@ + diff --git a/test/NuGet.Services.EndToEnd.Test/Support/E2EPublishingKeyProviderTests.cs b/test/NuGet.Services.EndToEnd.Test/Support/E2EPublishingKeyProviderTests.cs new file mode 100644 index 0000000..a2b4eab --- /dev/null +++ b/test/NuGet.Services.EndToEnd.Test/Support/E2EPublishingKeyProviderTests.cs @@ -0,0 +1,332 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace NuGet.Services.EndToEnd.Support +{ + public class E2EPublishingKeyProviderTests + { + private const string ClientId = "5bae2092-bff9-45b2-a511-8fa7bc9d87ca"; + private static readonly DateTimeOffset Now = new DateTimeOffset(2026, 9, 30, 0, 0, 0, TimeSpan.Zero); + + [Theory] + [InlineData("Dev-Test", "https://staging.dev.example.test", "https://dev-resource.example.test")] + [InlineData("Int-Test", "https://staging.int.example.test", "https://int-resource.example.test")] + [InlineData("Prod-Test", "https://prod.example.test", "https://prod-resource.example.test")] + [InlineData("dev-test", "https://staging.dev.example.test", "https://dev-resource.example.test")] + [InlineData("iNt-Test", "https://staging.int.example.test", "https://int-resource.example.test")] + [InlineData("pRoD-test", "https://prod.example.test", "https://prod-resource.example.test")] + public async Task ExchangesOnceWithExplicitIdentityAndEnvironmentScope(string name, string gallery, string resource) + { + var harness = new Harness(); + var settings = harness.Settings; + settings.ConfigurationName = name; + settings.TrustedPublishing.Environment = name.Split('-')[0].ToUpperInvariant(); + settings.TrustedPublishing.ResourceUri = resource; + settings.TrustedPublishing.AllowedGalleryHosts = new[] { new Uri(gallery).Host }; + settings.GalleryConfiguration.ServiceDetails.BaseUrl = gallery; + settings.TestAccountOwner = "ConfiguredOwner"; + + await settings.InitializePublishingKeyAsync(); + + Assert.Equal(ClientId, harness.SelectedClientId); + Assert.Equal(new[] { resource }, harness.Credential.Scopes); + Assert.Equal(1, harness.Credential.Calls); + Assert.Equal(1, harness.HttpCalls); + Assert.Equal(gallery + "/api/v2/token", harness.RequestUri.AbsoluteUri); + Assert.Equal(HttpMethod.Post, harness.Method); + Assert.Equal("Bearer fake-entra-token", harness.Authorization); + Assert.Equal("NuGet-E2E-CloudTest/1.0", harness.UserAgent); + Assert.Equal("ConfiguredOwner", (string)JObject.Parse(harness.Body)["username"]); + Assert.Equal("ApiKey", (string)JObject.Parse(harness.Body)["tokenType"]); + Assert.Equal("fake-publishing-key", settings.ApiKey); + Assert.Equal(Now.AddHours(1), settings.PublishingKeyExpires); + } + + [Fact] + public async Task RejectsInvalidDeploymentConfigurationBeforeAcquisition() + { + var invalidSettings = new Action[] + { + settings => settings.ConfigurationName = "other-Test", + settings => settings.ConfigurationName = "dev-test/other", + settings => settings.ConfigurationName = "dev-test\n", + settings => { settings.ConfigurationName = "Dev"; settings.TrustedPublishing = null; }, + settings => settings.TrustedPublishing = null, + settings => settings.TrustedPublishing.ResourceUri = null, + settings => settings.TrustedPublishing.ResourceUri = "http://dev.example.test", + settings => settings.TrustedPublishing.Environment = "Prod", + settings => settings.TrustedPublishing.AllowedGalleryHosts = null, + settings => settings.TrustedPublishing.AllowedGalleryHosts = new[] { "*.dev.nugettest.org" }, + settings => settings.TrustedPublishing.AllowedGalleryHosts = new[] { "other.example.test" } + }; + foreach (var invalidate in invalidSettings) + { + var harness = new Harness(); + harness.Settings.ApiKey = "fake-stored-key"; + invalidate(harness.Settings); + await Assert.ThrowsAsync(() => harness.Settings.InitializePublishingKeyAsync()); + Assert.Equal(0, harness.Credential.Calls); + Assert.Equal(0, harness.HttpCalls); + Assert.Null(harness.Settings.ApiKey); + } + } + + [Fact] + public async Task NeverRetriesOrFallsBackAfterExchangeFailure() + { + var harness = new Harness { Status = HttpStatusCode.Unauthorized, ResponseBody = "secret-response-must-not-appear" }; + harness.Settings.ApiKey = "fake-stored-key"; + var task = harness.Settings.InitializePublishingKeyAsync(); + var error = await Assert.ThrowsAsync(() => task); + var repeated = harness.Settings.InitializePublishingKeyAsync(); + Assert.Same(task, repeated); + await Assert.ThrowsAsync(() => repeated); + Assert.Contains("HTTP 401", error.Message); + Assert.DoesNotContain("secret-response", error.ToString()); + Assert.Equal(1, harness.Credential.Calls); + Assert.Equal(1, harness.HttpCalls); + Assert.Null(harness.Settings.ApiKey); + } + + [Fact] + public async Task RequiresIdentityWhenTrustedPublishingIsConfigured() + { + foreach (var name in new[] { "Dev", "Int", "Prod", "Dev-Test", "Int-Test", "Prod-Test" }) + { + var harness = new Harness(); + var root = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + { "TrustedPublishing:Environment", name.Split('-')[0] }, + { "TrustedPublishing:ResourceUri", "https://resource.example.test" }, + { "TrustedPublishing:AllowedGalleryHosts:0", "gallery.example.test" }, + { "TestSettings:ApiKey", "fake-stored-key" }, + { "TestSettings:TestAccountOwner", "ConfiguredOwner" }, + { "TestSettings:GalleryConfiguration:GalleryBaseUrl", "https://gallery.example.test" } + }).Build(); + + var settings = await TestSettings.CreateFromConfigurationAsync(root, name, harness.Provider); + Assert.Equal("fake-stored-key", settings.ApiKey); + Assert.Equal(0, harness.Credential.Calls); + + var error = await Assert.ThrowsAsync(() => settings.InitializePublishingKeyAsync()); + Assert.Contains("managed identity client ID", error.Message); + Assert.Null(settings.ApiKey); + Assert.Equal(0, harness.Credential.Calls); + Assert.Equal(0, harness.HttpCalls); + } + } + + [Fact] + public async Task UsesSuppliedApiKeyOnlyForStandaloneLocalConfigurations() + { + foreach (var name in new[] { "Dev", "Int", "Prod", "dev", "iNt", "pRoD", "Dev-Test", "Int-Test", "Prod-Test", "Other" }) + { + var harness = new Harness(); + var root = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + { "TestSettings:ApiKey", "fake-local-key" } + }).Build(); + var settings = await TestSettings.CreateFromConfigurationAsync(root, name, harness.Provider); + var task = settings.InitializePublishingKeyAsync(); + if (name.Contains("-") || name == "Other") + { + await Assert.ThrowsAsync(() => task); + Assert.Null(settings.ApiKey); + } + else + { + await task; + Assert.Same(task, settings.InitializePublishingKeyAsync()); + Assert.Equal("fake-local-key", settings.ApiKey); + } + + Assert.Null(settings.PublishingKeyExpires); + Assert.Null(harness.SelectedClientId); + Assert.Equal(0, harness.Credential.Calls); + Assert.Equal(0, harness.HttpCalls); + } + } + + [Fact] + public async Task RejectsMissingOrPlaceholderLocalApiKeysWithoutAcquisition() + { + foreach (var key in new[] { null, "", " ", "API_KEY" }) + { + var harness = new Harness(); + var settings = new TestSettings(harness.Provider) { ConfigurationName = "Dev", ApiKey = key }; + var error = await Assert.ThrowsAsync(() => settings.InitializePublishingKeyAsync()); + Assert.Contains("Local E2E execution requires", error.Message); + Assert.Null(harness.SelectedClientId); + Assert.Equal(0, harness.Credential.Calls); + Assert.Equal(0, harness.HttpCalls); + } + } + + [Fact] + public async Task DefersAcquisitionUntilExecutionAndSharesOneInMemoryKey() + { + var harness = new Harness(); + var root = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + { "ManagedIdentityClientId", ClientId }, + { "TrustedPublishing:Environment", "Dev" }, + { "TrustedPublishing:ResourceUri", "https://configured-resource.example.test" }, + { "TrustedPublishing:AllowedGalleryHosts:0", "dev.nugettest.org" }, + { "TestSettings:TestAccountOwner", "ConfiguredOwner" }, + { "TestSettings:GalleryConfiguration:GalleryBaseUrl", "https://dev.nugettest.org" } + }).Build(); + + var settings = await TestSettings.CreateFromConfigurationAsync(root, "Dev-Test", harness.Provider); + Assert.Equal(ClientId, settings.ManagedIdentityClientId); + Assert.Equal("Dev-Test", settings.ConfigurationName); + Assert.Equal("Dev", settings.TrustedPublishing.Environment); + Assert.Equal("https://configured-resource.example.test", settings.TrustedPublishing.ResourceUri); + Assert.Equal(new[] { "dev.nugettest.org" }, settings.TrustedPublishing.AllowedGalleryHosts); + Assert.Null(settings.ApiKey); + Assert.Equal(0, harness.Credential.Calls); + Assert.Equal(0, harness.HttpCalls); + + var gate = new TaskCompletionSource(); + harness.Credential.Acquire = () => gate.Task; + var tasks = Enumerable.Range(0, 2).Select(_ => settings.InitializePublishingKeyAsync()).ToArray(); + Assert.Same(tasks[0], tasks[1]); + Assert.Equal(1, harness.Credential.Calls); + Assert.Equal(0, harness.HttpCalls); + + gate.SetResult(new AccessToken("fake-entra-token", Now.AddHours(1))); + await Task.WhenAll(tasks); + await settings.InitializePublishingKeyAsync(); + Assert.Equal(1, harness.HttpCalls); + Assert.Equal(new[] { "https://configured-resource.example.test" }, harness.Credential.Scopes); + Assert.Equal("fake-publishing-key", settings.ApiKey); + Assert.Equal("https://dev.nugettest.org/api/v2/token", harness.RequestUri.AbsoluteUri); + Assert.Null(root["TestSettings:ApiKey"]); // Key exists only on the in-memory settings object. + } + + [Fact] + public async Task DeserializesExpiryAndRejectsInvalidResponsesWithoutLeakingSecrets() + { + foreach (var remaining in new[] { TimeSpan.FromTicks(1), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(55), TimeSpan.FromHours(1) }) + { + var expires = Now.Add(remaining).ToOffset(TimeSpan.FromHours(2)); + var valid = new Harness { ResponseBody = Response(expires) }; + await valid.Settings.InitializePublishingKeyAsync(); + Assert.Equal(expires, valid.Settings.PublishingKeyExpires); + Assert.Equal("fake-publishing-key", valid.Settings.ApiKey); + } + + var invalidResponses = new[] + { + "{\"tokenType\":\"ApiKey\",\"apiKey\":\"fake-publishing-key\"}", + "{\"tokenType\":\"ApiKey\",\"apiKey\":\"fake-publishing-key\",\"expires\":null}", + "{\"tokenType\":\"ApiKey\",\"apiKey\":\"fake-publishing-key\",\"expires\":\"secret-invalid-date\"}", + Response(Now), + Response(Now.AddMinutes(-1)) + }; + foreach (var response in invalidResponses) + { + var harness = new Harness { ResponseBody = response }; + var error = await Assert.ThrowsAsync(() => harness.Settings.InitializePublishingKeyAsync()); + Assert.DoesNotContain("fake-publishing-key", error.ToString()); + Assert.DoesNotContain("secret-invalid-date", error.ToString()); + Assert.Null(harness.Settings.ApiKey); + Assert.Null(harness.Settings.PublishingKeyExpires); + Assert.Equal(1, harness.HttpCalls); + } + } + + private static string Response(DateTimeOffset expires) + { + return JsonConvert.SerializeObject(new { tokenType = "ApiKey", apiKey = "fake-publishing-key", expires = expires.ToString("O") }); + } + + private sealed class Harness + { + internal Harness() + { + Provider = new E2EPublishingKeyProvider(id => + { + SelectedClientId = id; + return Credential; + }, () => new HttpClient(new Handler(this)), () => Now); + Settings = new TestSettings(Provider) + { + ConfigurationName = "Dev-Test", + ManagedIdentityClientId = ClientId, + TrustedPublishing = new TrustedPublishingSettings + { + Environment = "Dev", + ResourceUri = "https://dev-resource.example.test", + AllowedGalleryHosts = new[] { "dev.nugettest.org", "gallery-usnc-ase-staging.dev.nugettest.org" } + }, + TestAccountOwner = "ExamplePublishingOwner", + GalleryConfiguration = new GalleryConfiguration + { + GalleryBaseUrl = "https://dev.nugettest.org", + ServiceDetails = new ServiceDetails { BaseUrl = "https://gallery-usnc-ase-staging.dev.nugettest.org" } + } + }; + } + + internal readonly FakeCredential Credential = new FakeCredential(); + internal E2EPublishingKeyProvider Provider { get; } + internal TestSettings Settings { get; } + internal string SelectedClientId; + internal int HttpCalls; + internal HttpStatusCode Status = HttpStatusCode.OK; + internal string ResponseBody = Response(Now.AddHours(1)); + internal Uri RequestUri; + internal HttpMethod Method; + internal string Authorization; + internal string UserAgent; + internal string Body; + + private sealed class Handler : HttpMessageHandler + { + private readonly Harness _harness; + internal Handler(Harness harness) { _harness = harness; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _harness.HttpCalls++; + _harness.RequestUri = request.RequestUri; + _harness.Method = request.Method; + _harness.Authorization = request.Headers.Authorization.ToString(); + _harness.UserAgent = request.Headers.UserAgent.ToString(); + _harness.Body = await request.Content.ReadAsStringAsync(); + return new HttpResponseMessage(_harness.Status) { Content = new StringContent(_harness.ResponseBody) }; + } + } + } + + private sealed class FakeCredential : TokenCredential + { + internal int Calls; + internal string[] Scopes; + internal Func> Acquire = () => Task.FromResult(new AccessToken("fake-entra-token", Now.AddHours(1))); + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => throw new NotSupportedException("Only asynchronous acquisition is expected."); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + Calls++; + Scopes = requestContext.Scopes; + return new ValueTask(Acquire()); + } + } + } +} \ No newline at end of file From 4a2e2f1ed8c2bbe9e691c600cfe340f17a57b832 Mon Sep 17 00:00:00 2001 From: Pranathi Rao Bora Date: Fri, 2 Oct 2026 12:37:24 -0700 Subject: [PATCH 2/4] Refactoring changes --- .../Support/Clients/GalleryClient.cs | 10 ++++++++++ .../Support/E2EPublishingKeyProvider.cs | 20 ++++--------------- .../Support/E2EPublishingKeyProviderTests.cs | 4 ++-- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/NuGet.Services.EndToEnd/Support/Clients/GalleryClient.cs b/src/NuGet.Services.EndToEnd/Support/Clients/GalleryClient.cs index 87592d0..006c263 100644 --- a/src/NuGet.Services.EndToEnd/Support/Clients/GalleryClient.cs +++ b/src/NuGet.Services.EndToEnd/Support/Clients/GalleryClient.cs @@ -63,6 +63,7 @@ public async Task> AutocompletePackageVersionsAsync(string id, boo public async Task PushAsync(Stream nupkgStream, ITestOutputHelper logger, PackageType packageType) { + LogPublishingKeyExpiry(logger); var galleryEndpoint = GetGalleryServiceBaseUrl(); string url; @@ -195,6 +196,7 @@ private async Task SendToPackageAsync(HttpMethod method, string id, string versi private async Task SendAsync(HttpMethod method, string url, ITestOutputHelper logger, HttpContent content = null) { + LogPublishingKeyExpiry(logger); using (var httpClient = new HttpClient().AddUserAgent(nameof(GalleryClient))) using (var request = new HttpRequestMessage(method, url) { Content = content }) { @@ -206,5 +208,13 @@ private async Task SendAsync(HttpMethod method, string url, ITestOutputHelper lo } } } + + private void LogPublishingKeyExpiry(ITestOutputHelper logger) + { + if (_testSettings.PublishingKeyExpires.HasValue) + { + logger.WriteLine($"Publishing key expires at {_testSettings.PublishingKeyExpires.Value.UtcDateTime:O}."); + } + } } } diff --git a/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs b/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs index 8af6ac6..ef08e3f 100644 --- a/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs +++ b/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs @@ -43,7 +43,7 @@ internal E2EPublishingKeyProvider( internal async Task AcquireAsync(TestSettings settings) { // Validate everything before constructing a credential or making any network request. - var context = GetContext(settings); + var context = ValidateConfiguration(settings); if (!Guid.TryParse(settings.ManagedIdentityClientId, out var clientId) || clientId == Guid.Empty) { throw new InvalidOperationException("E2E trusted publishing requires a non-empty managed identity client ID."); @@ -81,7 +81,7 @@ internal async Task AcquireAsync(TestSettings settings) string content; try { - // Do not use SimpleHttpClient/RetryUtility here: a token can be exchanged only once. + // Do not retry the exchange: the token can be consumed by the first attempt. using (var response = await client.SendAsync(request).ConfigureAwait(false)) { if (!response.IsSuccessStatusCode) @@ -126,7 +126,7 @@ internal async Task AcquireAsync(TestSettings settings) } } - internal static PublishingContext GetContext(TestSettings settings) + private static (string ResourceUri, Uri GalleryUri) ValidateConfiguration(TestSettings settings) { if (settings == null) { throw new ArgumentNullException(nameof(settings)); } var match = Regex.Match(settings.ConfigurationName ?? string.Empty, @@ -158,8 +158,7 @@ internal static PublishingContext GetContext(TestSettings settings) throw new InvalidOperationException("E2E trusted publishing requires an approved HTTPS Gallery root URL for the configuration environment."); } - // Keep the configured resource spelling; Uri.AbsoluteUri would append a slash. - return new PublishingContext(publishing.ResourceUri, gallery); + return (publishing.ResourceUri, gallery); } private static bool TryGetHttpsRootUri(string value, out Uri uri) @@ -194,16 +193,5 @@ internal sealed class PublishingKey public DateTimeOffset? Expires { get; set; } } - internal sealed class PublishingContext - { - internal PublishingContext(string resourceUri, Uri galleryUri) - { - ResourceUri = resourceUri; - GalleryUri = galleryUri; - } - - internal string ResourceUri { get; } - internal Uri GalleryUri { get; } - } } } \ No newline at end of file diff --git a/test/NuGet.Services.EndToEnd.Test/Support/E2EPublishingKeyProviderTests.cs b/test/NuGet.Services.EndToEnd.Test/Support/E2EPublishingKeyProviderTests.cs index a2b4eab..d4004bf 100644 --- a/test/NuGet.Services.EndToEnd.Test/Support/E2EPublishingKeyProviderTests.cs +++ b/test/NuGet.Services.EndToEnd.Test/Support/E2EPublishingKeyProviderTests.cs @@ -18,7 +18,7 @@ namespace NuGet.Services.EndToEnd.Support { public class E2EPublishingKeyProviderTests { - private const string ClientId = "5bae2092-bff9-45b2-a511-8fa7bc9d87ca"; + private const string ClientId = "11111111-2222-4333-8444-555555555555"; private static readonly DateTimeOffset Now = new DateTimeOffset(2026, 9, 30, 0, 0, 0, TimeSpan.Zero); [Theory] @@ -219,7 +219,7 @@ public async Task DefersAcquisitionUntilExecutionAndSharesOneInMemoryKey() [Fact] public async Task DeserializesExpiryAndRejectsInvalidResponsesWithoutLeakingSecrets() { - foreach (var remaining in new[] { TimeSpan.FromTicks(1), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(55), TimeSpan.FromHours(1) }) + foreach (var remaining in new[] { TimeSpan.FromTicks(1), TimeSpan.FromHours(1) }) { var expires = Now.Add(remaining).ToOffset(TimeSpan.FromHours(2)); var valid = new Harness { ResponseBody = Response(expires) }; From 56589d828de2df6c3fea0490623f1d3f3c79e979 Mon Sep 17 00:00:00 2001 From: Pranathi Rao Bora Date: Fri, 2 Oct 2026 14:06:18 -0700 Subject: [PATCH 3/4] Switched from resource URI to Audience values --- .../TrustedPublishingSettings.cs | 3 +- .../Support/E2EPublishingKeyProvider.cs | 13 +++--- .../Support/E2EPublishingKeyProviderTests.cs | 46 ++++++++++++------- 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/src/NuGet.Services.EndToEnd/Support/Configuration/TrustedPublishingSettings.cs b/src/NuGet.Services.EndToEnd/Support/Configuration/TrustedPublishingSettings.cs index d48398d..7b804bd 100644 --- a/src/NuGet.Services.EndToEnd/Support/Configuration/TrustedPublishingSettings.cs +++ b/src/NuGet.Services.EndToEnd/Support/Configuration/TrustedPublishingSettings.cs @@ -7,7 +7,8 @@ public sealed class TrustedPublishingSettings { public string Environment { get; set; } - public string ResourceUri { get; set; } + // Gallery's Entra application client ID.. + public string Audience { get; set; } public string[] AllowedGalleryHosts { get; set; } } diff --git a/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs b/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs index ef08e3f..586080f 100644 --- a/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs +++ b/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs @@ -53,7 +53,8 @@ internal async Task AcquireAsync(TestSettings settings) try { var credential = _createCredential(clientId.ToString()); - var request = new TokenRequestContext(new[] { context.ResourceUri }); + // Azure.Identity sends this Gallery application ID as the managed-identity token resource. + var request = new TokenRequestContext(new[] { context.Audience }); token = await credential.GetTokenAsync(request, CancellationToken.None).ConfigureAwait(false); } catch (Exception) @@ -126,7 +127,7 @@ internal async Task AcquireAsync(TestSettings settings) } } - private static (string ResourceUri, Uri GalleryUri) ValidateConfiguration(TestSettings settings) + private static (string Audience, Uri GalleryUri) ValidateConfiguration(TestSettings settings) { if (settings == null) { throw new ArgumentNullException(nameof(settings)); } var match = Regex.Match(settings.ConfigurationName ?? string.Empty, @@ -140,15 +141,15 @@ private static (string ResourceUri, Uri GalleryUri) ValidateConfiguration(TestSe throw new InvalidOperationException("E2E trusted publishing requires a non-empty policy username."); } - // Resource and destination allowlist come from NuGet.Services deployment configuration. + // Audience and destination allowlist come from NuGet.Services deployment configuration. var publishing = settings.TrustedPublishing; if (publishing == null || !string.Equals(publishing.Environment, match.Groups[1].Value, StringComparison.OrdinalIgnoreCase) || - !TryGetHttpsRootUri(publishing.ResourceUri, out _) || + !Guid.TryParse(publishing.Audience, out var audience) || audience == Guid.Empty || publishing.AllowedGalleryHosts == null || publishing.AllowedGalleryHosts.Length == 0 || publishing.AllowedGalleryHosts.Any(host => Uri.CheckHostName(host) != UriHostNameType.Dns)) { - throw new InvalidOperationException("E2E trusted publishing requires deployment configuration with a matching environment, HTTPS resource URI, and approved Gallery hosts."); + throw new InvalidOperationException("E2E trusted publishing requires deployment configuration with a matching environment, non-empty Gallery audience GUID, and approved Gallery hosts."); } var baseUrl = settings.GalleryConfiguration?.GetServiceBaseUrl(); @@ -158,7 +159,7 @@ private static (string ResourceUri, Uri GalleryUri) ValidateConfiguration(TestSe throw new InvalidOperationException("E2E trusted publishing requires an approved HTTPS Gallery root URL for the configuration environment."); } - return (publishing.ResourceUri, gallery); + return (audience.ToString(), gallery); } private static bool TryGetHttpsRootUri(string value, out Uri uri) diff --git a/test/NuGet.Services.EndToEnd.Test/Support/E2EPublishingKeyProviderTests.cs b/test/NuGet.Services.EndToEnd.Test/Support/E2EPublishingKeyProviderTests.cs index d4004bf..cfc907a 100644 --- a/test/NuGet.Services.EndToEnd.Test/Support/E2EPublishingKeyProviderTests.cs +++ b/test/NuGet.Services.EndToEnd.Test/Support/E2EPublishingKeyProviderTests.cs @@ -19,22 +19,25 @@ namespace NuGet.Services.EndToEnd.Support public class E2EPublishingKeyProviderTests { private const string ClientId = "11111111-2222-4333-8444-555555555555"; + private const string DevAudience = "22222222-2222-4222-8222-222222222222"; + private const string IntAudience = "33333333-3333-4333-8333-333333333333"; + private const string ProdAudience = "44444444-4444-4444-8444-444444444444"; private static readonly DateTimeOffset Now = new DateTimeOffset(2026, 9, 30, 0, 0, 0, TimeSpan.Zero); [Theory] - [InlineData("Dev-Test", "https://staging.dev.example.test", "https://dev-resource.example.test")] - [InlineData("Int-Test", "https://staging.int.example.test", "https://int-resource.example.test")] - [InlineData("Prod-Test", "https://prod.example.test", "https://prod-resource.example.test")] - [InlineData("dev-test", "https://staging.dev.example.test", "https://dev-resource.example.test")] - [InlineData("iNt-Test", "https://staging.int.example.test", "https://int-resource.example.test")] - [InlineData("pRoD-test", "https://prod.example.test", "https://prod-resource.example.test")] - public async Task ExchangesOnceWithExplicitIdentityAndEnvironmentScope(string name, string gallery, string resource) + [InlineData("Dev-Test", "https://staging.dev.example.test", DevAudience)] + [InlineData("Int-Test", "https://staging.int.example.test", IntAudience)] + [InlineData("Prod-Test", "https://prod.example.test", ProdAudience)] + [InlineData("dev-test", "https://staging.dev.example.test", DevAudience)] + [InlineData("iNt-Test", "https://staging.int.example.test", IntAudience)] + [InlineData("pRoD-test", "https://prod.example.test", ProdAudience)] + public async Task ExchangesOnceWithExplicitIdentityAndEnvironmentScope(string name, string gallery, string audience) { var harness = new Harness(); var settings = harness.Settings; settings.ConfigurationName = name; settings.TrustedPublishing.Environment = name.Split('-')[0].ToUpperInvariant(); - settings.TrustedPublishing.ResourceUri = resource; + settings.TrustedPublishing.Audience = audience; settings.TrustedPublishing.AllowedGalleryHosts = new[] { new Uri(gallery).Host }; settings.GalleryConfiguration.ServiceDetails.BaseUrl = gallery; settings.TestAccountOwner = "ConfiguredOwner"; @@ -42,7 +45,7 @@ public async Task ExchangesOnceWithExplicitIdentityAndEnvironmentScope(string na await settings.InitializePublishingKeyAsync(); Assert.Equal(ClientId, harness.SelectedClientId); - Assert.Equal(new[] { resource }, harness.Credential.Scopes); + Assert.Equal(new[] { audience }, harness.Credential.Scopes); Assert.Equal(1, harness.Credential.Calls); Assert.Equal(1, harness.HttpCalls); Assert.Equal(gallery + "/api/v2/token", harness.RequestUri.AbsoluteUri); @@ -65,12 +68,21 @@ public async Task RejectsInvalidDeploymentConfigurationBeforeAcquisition() settings => settings.ConfigurationName = "dev-test\n", settings => { settings.ConfigurationName = "Dev"; settings.TrustedPublishing = null; }, settings => settings.TrustedPublishing = null, - settings => settings.TrustedPublishing.ResourceUri = null, - settings => settings.TrustedPublishing.ResourceUri = "http://dev.example.test", + settings => settings.TrustedPublishing.Audience = null, + settings => settings.TrustedPublishing.Audience = "", + settings => settings.TrustedPublishing.Audience = "not-a-guid", + settings => settings.TrustedPublishing.Audience = Guid.Empty.ToString(), + settings => settings.TrustedPublishing.Audience = "https://dev.example.test", settings => settings.TrustedPublishing.Environment = "Prod", settings => settings.TrustedPublishing.AllowedGalleryHosts = null, settings => settings.TrustedPublishing.AllowedGalleryHosts = new[] { "*.dev.nugettest.org" }, - settings => settings.TrustedPublishing.AllowedGalleryHosts = new[] { "other.example.test" } + settings => settings.TrustedPublishing.AllowedGalleryHosts = new[] { "other.example.test" }, + settings => settings.GalleryConfiguration.ServiceDetails.BaseUrl = "http://dev.nugettest.org", + settings => settings.GalleryConfiguration.ServiceDetails.BaseUrl = "https://dev.nugettest.org:8443", + settings => settings.GalleryConfiguration.ServiceDetails.BaseUrl = "https://user@dev.nugettest.org", + settings => settings.GalleryConfiguration.ServiceDetails.BaseUrl = "https://dev.nugettest.org/path", + settings => settings.GalleryConfiguration.ServiceDetails.BaseUrl = "https://dev.nugettest.org?query=value", + settings => settings.GalleryConfiguration.ServiceDetails.BaseUrl = "https://dev.nugettest.org#fragment" }; foreach (var invalidate in invalidSettings) { @@ -110,7 +122,7 @@ public async Task RequiresIdentityWhenTrustedPublishingIsConfigured() var root = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { { "TrustedPublishing:Environment", name.Split('-')[0] }, - { "TrustedPublishing:ResourceUri", "https://resource.example.test" }, + { "TrustedPublishing:Audience", DevAudience }, { "TrustedPublishing:AllowedGalleryHosts:0", "gallery.example.test" }, { "TestSettings:ApiKey", "fake-stored-key" }, { "TestSettings:TestAccountOwner", "ConfiguredOwner" }, @@ -183,7 +195,7 @@ public async Task DefersAcquisitionUntilExecutionAndSharesOneInMemoryKey() { { "ManagedIdentityClientId", ClientId }, { "TrustedPublishing:Environment", "Dev" }, - { "TrustedPublishing:ResourceUri", "https://configured-resource.example.test" }, + { "TrustedPublishing:Audience", IntAudience }, { "TrustedPublishing:AllowedGalleryHosts:0", "dev.nugettest.org" }, { "TestSettings:TestAccountOwner", "ConfiguredOwner" }, { "TestSettings:GalleryConfiguration:GalleryBaseUrl", "https://dev.nugettest.org" } @@ -193,7 +205,7 @@ public async Task DefersAcquisitionUntilExecutionAndSharesOneInMemoryKey() Assert.Equal(ClientId, settings.ManagedIdentityClientId); Assert.Equal("Dev-Test", settings.ConfigurationName); Assert.Equal("Dev", settings.TrustedPublishing.Environment); - Assert.Equal("https://configured-resource.example.test", settings.TrustedPublishing.ResourceUri); + Assert.Equal(IntAudience, settings.TrustedPublishing.Audience); Assert.Equal(new[] { "dev.nugettest.org" }, settings.TrustedPublishing.AllowedGalleryHosts); Assert.Null(settings.ApiKey); Assert.Equal(0, harness.Credential.Calls); @@ -210,7 +222,7 @@ public async Task DefersAcquisitionUntilExecutionAndSharesOneInMemoryKey() await Task.WhenAll(tasks); await settings.InitializePublishingKeyAsync(); Assert.Equal(1, harness.HttpCalls); - Assert.Equal(new[] { "https://configured-resource.example.test" }, harness.Credential.Scopes); + Assert.Equal(new[] { IntAudience }, harness.Credential.Scopes); Assert.Equal("fake-publishing-key", settings.ApiKey); Assert.Equal("https://dev.nugettest.org/api/v2/token", harness.RequestUri.AbsoluteUri); Assert.Null(root["TestSettings:ApiKey"]); // Key exists only on the in-memory settings object. @@ -269,7 +281,7 @@ internal Harness() TrustedPublishing = new TrustedPublishingSettings { Environment = "Dev", - ResourceUri = "https://dev-resource.example.test", + Audience = DevAudience, AllowedGalleryHosts = new[] { "dev.nugettest.org", "gallery-usnc-ase-staging.dev.nugettest.org" } }, TestAccountOwner = "ExamplePublishingOwner", From dbffa4d573530acc5888b20b148052ba66d61e0b Mon Sep 17 00:00:00 2001 From: Pranathi Rao Bora Date: Fri, 2 Oct 2026 15:19:33 -0700 Subject: [PATCH 4/4] Resolved review comments about refactoring --- .../NuGet.Services.EndToEnd.csproj | 1 + .../Support/E2EPublishingKeyProvider.cs | 10 ++-------- .../Support/PublishingKey.cs | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 src/NuGet.Services.EndToEnd/Support/PublishingKey.cs diff --git a/src/NuGet.Services.EndToEnd/NuGet.Services.EndToEnd.csproj b/src/NuGet.Services.EndToEnd/NuGet.Services.EndToEnd.csproj index de3ba6a..2cf04a0 100644 --- a/src/NuGet.Services.EndToEnd/NuGet.Services.EndToEnd.csproj +++ b/src/NuGet.Services.EndToEnd/NuGet.Services.EndToEnd.csproj @@ -67,6 +67,7 @@ + diff --git a/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs b/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs index 586080f..7a6a58f 100644 --- a/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs +++ b/src/NuGet.Services.EndToEnd/Support/E2EPublishingKeyProvider.cs @@ -131,7 +131,8 @@ private static (string Audience, Uri GalleryUri) ValidateConfiguration(TestSetti { if (settings == null) { throw new ArgumentNullException(nameof(settings)); } var match = Regex.Match(settings.ConfigurationName ?? string.Empty, - @"\A(Dev|Int|Prod)(?:-[A-Za-z0-9][A-Za-z0-9_-]*)?\z", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + @"\A(Dev|Int|Prod)(?:-[A-Za-z0-9][A-Za-z0-9_-]*)?\z", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase, + TimeSpan.FromSeconds(1)); if (!match.Success) { throw new InvalidOperationException("E2E trusted publishing requires a Dev, Int, or Prod configuration."); @@ -187,12 +188,5 @@ private static HttpClient CreateHttpClient() }; } - internal sealed class PublishingKey - { - public string TokenType { get; set; } - public string ApiKey { get; set; } - public DateTimeOffset? Expires { get; set; } - } - } } \ No newline at end of file diff --git a/src/NuGet.Services.EndToEnd/Support/PublishingKey.cs b/src/NuGet.Services.EndToEnd/Support/PublishingKey.cs new file mode 100644 index 0000000..608697b --- /dev/null +++ b/src/NuGet.Services.EndToEnd/Support/PublishingKey.cs @@ -0,0 +1,14 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace NuGet.Services.EndToEnd.Support +{ + internal sealed class PublishingKey + { + public string TokenType { get; set; } + public string ApiKey { get; set; } + public DateTimeOffset? Expires { get; set; } + } +} \ No newline at end of file