From 9d1b01aa8e4985d24db35a219c8daa8c848519d6 Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Thu, 29 May 2025 16:10:56 -0500 Subject: [PATCH 01/15] Align Cloud Controller response handling with Spring, improve tests --- .../CloudFoundrySecurityMiddleware.cs | 9 +-- .../CloudFoundry/PermissionsProvider.cs | 33 ++++++--- .../CloudControllerPermissionsMock.cs | 39 +++++++++++ .../CloudFoundrySecurityMiddlewareTest.cs | 68 ++++++++++++++++++- .../CloudFoundry/PermissionsProviderTest.cs | 61 +++++++++++++++-- .../CloudFoundry/StartupWithSecurity.cs | 15 ++++ 6 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs index b320799496..3475846187 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs @@ -25,6 +25,7 @@ internal sealed class CloudFoundrySecurityMiddleware private readonly RequestDelegate? _next; private readonly ILogger _logger; private readonly PermissionsProvider _permissionsProvider; + private const string BearerTokenPrefix = "Bearer "; public CloudFoundrySecurityMiddleware(IOptionsMonitor managementOptionsMonitor, IOptionsMonitor endpointOptionsMonitor, IEnumerable endpointOptionsMonitorProviders, @@ -101,13 +102,13 @@ public async Task InvokeAsync(HttpContext context) internal string GetAccessToken(HttpRequest request) { - if (request.Headers.TryGetValue(PermissionsProvider.AuthorizationHeaderName, out StringValues headerValue)) + if (request.Headers.TryGetValue(PermissionsProvider.AuthorizationHeaderName, out StringValues authorizationHeaderValue)) { - string header = headerValue.ToString(); + string authorizationValue = authorizationHeaderValue.ToString(); - if (header.StartsWith(PermissionsProvider.BearerHeaderNamePrefix, StringComparison.OrdinalIgnoreCase)) + if (authorizationValue.StartsWith(BearerTokenPrefix, StringComparison.OrdinalIgnoreCase)) { - return header[PermissionsProvider.BearerHeaderNamePrefix.Length..]; + return authorizationValue[BearerTokenPrefix.Length..]; } } diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs index 53c182585d..9de7e7bfef 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs @@ -19,16 +19,16 @@ namespace Steeltoe.Management.Endpoint.Actuators.CloudFoundry; internal sealed class PermissionsProvider { - private const string AuthorizationHeaderInvalid = "Authorization header is missing or invalid"; - private const string CloudfoundryNotReachableMessage = "Cloud controller not reachable"; private const string ReadSensitiveDataJsonPropertyName = "read_sensitive_data"; - public const string HttpClientName = "CloudFoundrySecurity"; - public const string ApplicationIdMissingMessage = "Application ID is not available"; - public const string CloudfoundryApiMissingMessage = "Cloud controller URL is not available"; public const string AccessDeniedMessage = "Access denied"; + public const string ApplicationIdMissingMessage = "Application ID is not available"; public const string AuthorizationHeaderName = "Authorization"; - public const string BearerHeaderNamePrefix = "Bearer "; - private static readonly TimeSpan GetPermissionsTimeout = TimeSpan.FromMilliseconds(5000); + public const string AuthorizationHeaderInvalid = "Authorization header is missing or invalid"; + public const string CloudfoundryApiMissingMessage = "Cloud controller URL is not available"; + public const string CloudfoundryNotReachableMessage = "Cloud controller not reachable"; + public const string HttpClientName = "CloudFoundrySecurity"; + public const string InvalidTokenMessage = "Invalid token"; + private static readonly TimeSpan GetPermissionsTimeout = TimeSpan.FromMilliseconds(5_000); private readonly IOptionsMonitor _optionsMonitor; private readonly IHttpClientFactory _httpClientFactory; @@ -75,23 +75,34 @@ public async Task GetPermissionsAsync(string accessToken, Cancel _logger.LogInformation("Cloud Foundry returned status: {HttpStatus} while obtaining permissions from: {PermissionsUri}", response.StatusCode, checkPermissionsUri); - return response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized - ? new SecurityResult(HttpStatusCode.Forbidden, AccessDeniedMessage) + if (response.StatusCode is HttpStatusCode.Forbidden) + { + return new SecurityResult(HttpStatusCode.Forbidden, AccessDeniedMessage); + } + + return (int)response.StatusCode is > 399 and < 500 + ? new SecurityResult(HttpStatusCode.Unauthorized, InvalidTokenMessage) : new SecurityResult(HttpStatusCode.ServiceUnavailable, CloudfoundryNotReachableMessage); } - EndpointPermissions permissions = await GetPermissionsAsync(response, cancellationToken); + EndpointPermissions permissions = await ParsePermissionsAsync(response, cancellationToken); return new SecurityResult(permissions); } catch (Exception exception) when (!exception.IsCancellation()) { _logger.LogError(exception, "Cloud Foundry returned exception while obtaining permissions from: {PermissionsUri}", checkPermissionsUri); + return new SecurityResult(HttpStatusCode.InternalServerError, exception.Message); + } + catch (Exception exception) + { + _logger.LogInformation(exception, "Task cancelled or timed out while obtaining permissions from: {PermissionsUri}", checkPermissionsUri); + return new SecurityResult(HttpStatusCode.ServiceUnavailable, CloudfoundryNotReachableMessage); } } - public async Task GetPermissionsAsync(HttpResponseMessage response, CancellationToken cancellationToken) + public async Task ParsePermissionsAsync(HttpResponseMessage response, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(response); diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs new file mode 100644 index 0000000000..c332163f43 --- /dev/null +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Net; +using RichardSzalay.MockHttp; + +namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry; + +internal static class CloudControllerPermissionsMock +{ + internal static MockHttpMessageHandler GetHttpMessageHandler() + { + MockHttpMessageHandler httpClientHandler = new(); + + httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/unavailable/permissions") + .Respond(HttpStatusCode.ServiceUnavailable, "application/json", "{}"); + + httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/not-found/permissions") + .Respond(HttpStatusCode.NotFound, "application/json", "{}"); + + httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/unauthorized/permissions") + .Respond(HttpStatusCode.Unauthorized, "application/json", "{}"); + + httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/forbidden/permissions") + .Respond(HttpStatusCode.Forbidden, "application/json", "{}"); + + httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/timeout/permissions").Throw(new TaskCanceledException()); + httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/exception/permissions").Throw(new HttpRequestException()); + + httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/no_sensitive_data/permissions").Respond(HttpStatusCode.OK, "application/json", + """{"read_sensitive_data": false, "read_basic_data": true}"""); + + httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/success/permissions").Respond(HttpStatusCode.OK, "application/json", + """{"read_sensitive_data": true, "read_basic_data": true}"""); + + return httpClientHandler; + } +} diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs index 8823396872..6a081abfe6 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs @@ -365,6 +365,72 @@ public async Task Redacts_HTTP_headers() logMessages.Should().Contain("Authorization: *"); } + [InlineData("unavailable", HttpStatusCode.ServiceUnavailable, PermissionsProvider.CloudfoundryNotReachableMessage, "true")] + [InlineData("unavailable", HttpStatusCode.OK, PermissionsProvider.CloudfoundryNotReachableMessage, "false")] + [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, "true")] + [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, "false")] + [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, "true")] + [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, "false")] + [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage, "true")] + [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage, "false")] + [InlineData("timeout", HttpStatusCode.ServiceUnavailable, PermissionsProvider.CloudfoundryNotReachableMessage, "true")] + [InlineData("timeout", HttpStatusCode.OK, PermissionsProvider.CloudfoundryNotReachableMessage, "false")] + [InlineData("exception", HttpStatusCode.InternalServerError, @"Exception of type \u0027System.Net.Http.HttpRequestException\u0027 was thrown.", "true")] + [InlineData("exception", HttpStatusCode.OK, @"Exception of type \u0027System.Net.Http.HttpRequestException\u0027 was thrown.", "false")] + [InlineData("no_sensitive_data", HttpStatusCode.OK, null, "true")] + [InlineData("success", HttpStatusCode.OK, null, "true")] + [Theory] + public async Task CloudControllerScenarioTesting(string cloudControllerResponse, HttpStatusCode steeltoeStatusCode, string? errorMessage, + string useStatusCodeFromResponse) + { + var appSettings = new Dictionary + { + ["vcap:application:application_id"] = cloudControllerResponse, + ["vcap:application:cf_api"] = "https://example.api.com", + ["management:endpoints:info:requiredPermissions"] = "FULL", + ["management:endpoints:UseStatusCodeFromResponse"] = useStatusCodeFromResponse + }; + + WebHostBuilder builder = TestWebHostBuilderFactory.Create(); + builder.UseStartup(); + builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(appSettings)); + + using IWebHost host = builder.Build(); + await host.StartAsync(TestContext.Current.CancellationToken); + + using HttpClient client = host.GetTestClient(); + var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication")); + httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken()); + HttpResponseMessage response = await client.SendAsync(httpRequestMessage, TestContext.Current.CancellationToken); + response.StatusCode.Should().Be(steeltoeStatusCode); + + if (!string.IsNullOrEmpty(errorMessage)) + { + string errorText = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + errorText.Should().Be($$"""{"security_error":"{{errorMessage}}"}"""); + } + else + { + string responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + responseBody.Should() + .Be( + """{"type":"steeltoe","_links":{"info":{"href":"http://localhost/cloudfoundryapplication/info","templated":false},"self":{"href":"http://localhost/cloudfoundryapplication","templated":false}}}"""); + + var fullPermissionRequest = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication/info")); + fullPermissionRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken()); + HttpResponseMessage fullPermissionResponse = await client.SendAsync(fullPermissionRequest, TestContext.Current.CancellationToken); + + fullPermissionResponse.StatusCode.Should().Be(cloudControllerResponse == "success" ? HttpStatusCode.OK : HttpStatusCode.Forbidden); + } + } + + private static string MockAccessToken() + { + return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwuY29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ." + + Convert.ToBase64String("signature"u8.ToArray()); + } + protected override void Dispose(bool disposing) { if (disposing) @@ -375,7 +441,7 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } - private HttpContext CreateRequest(string method, string path) + private static HttpContext CreateRequest(string method, string path) { HttpContext context = new DefaultHttpContext { diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs index de0802205a..232ea454d6 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs @@ -4,6 +4,7 @@ using System.Net; using System.Net.Http.Json; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; @@ -22,11 +23,12 @@ public void IsCloudFoundryRequest_ReturnsExpected() } [Fact] - public async Task GetPermissionsAsyncTest() + public async Task EmptyTokenIsUnauthorized() { PermissionsProvider permissionsProvider = GetPermissionsProvider(); - SecurityResult result = await permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken); - Assert.NotNull(result); + SecurityResult unauthorized = await permissionsProvider.GetPermissionsAsync(string.Empty, TestContext.Current.CancellationToken); + unauthorized.Code.Should().Be(HttpStatusCode.Unauthorized); + unauthorized.Message.Should().Be(PermissionsProvider.AuthorizationHeaderInvalid); } [Fact] @@ -41,8 +43,8 @@ public async Task GetPermissionsTest() }; response.Content = JsonContent.Create(permissions); - EndpointPermissions result = await permissionsProvider.GetPermissionsAsync(response, TestContext.Current.CancellationToken); - Assert.Equal(EndpointPermissions.Full, result); + EndpointPermissions result = await permissionsProvider.ParsePermissionsAsync(response, TestContext.Current.CancellationToken); + result.Should().Be(EndpointPermissions.Full); } private static PermissionsProvider GetPermissionsProvider() @@ -56,4 +58,53 @@ private static PermissionsProvider GetPermissionsProvider() return new PermissionsProvider(optionsMonitor, httpClientFactory, NullLogger.Instance); } + + [InlineData("unavailable", HttpStatusCode.ServiceUnavailable, PermissionsProvider.CloudfoundryNotReachableMessage)] + [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage)] + [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage)] + [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage)] + [InlineData("timeout", HttpStatusCode.ServiceUnavailable, PermissionsProvider.CloudfoundryNotReachableMessage)] + [InlineData("exception", HttpStatusCode.InternalServerError, "Exception of type 'System.Net.Http.HttpRequestException' was thrown.")] + [InlineData("no_sensitive_data", HttpStatusCode.OK, "")] + [InlineData("success", HttpStatusCode.OK, "")] + [Theory] + public async Task CloudControllerScenarioTesting(string cloudControllerResponse, HttpStatusCode steeltoeStatusCode, string? expectedMessage) + { + var appSettings = new Dictionary + { + ["vcap:application:cf_api"] = "https://example.api.com", + ["vcap:application:application_id"] = cloudControllerResponse + }; + + IOptionsMonitor optionsMonitor = GetOptionsMonitorFromSettings(appSettings!); + + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().Build()); + services.AddCloudFoundryActuator(); + + services.AddHttpClient(PermissionsProvider.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(_ => CloudControllerPermissionsMock.GetHttpMessageHandler()); + + await using ServiceProvider serviceProvider = services.BuildServiceProvider(true); + var httpClientFactory = serviceProvider.GetRequiredService(); + + var permissionsProvider = new PermissionsProvider(optionsMonitor, httpClientFactory, NullLogger.Instance); + + SecurityResult result = await permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken); + result.Code.Should().Be(steeltoeStatusCode); + result.Message.Should().Be(expectedMessage); + + switch (cloudControllerResponse) + { + case "success": + result.Permissions.Should().Be(EndpointPermissions.Full); + break; + case "no_sensitive_data": + result.Permissions.Should().Be(EndpointPermissions.Restricted); + break; + default: + result.Permissions.Should().Be(EndpointPermissions.None); + break; + } + } } diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/StartupWithSecurity.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/StartupWithSecurity.cs index 8b69ba826f..d4e9781077 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/StartupWithSecurity.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/StartupWithSecurity.cs @@ -2,8 +2,10 @@ // The .NET Foundation licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. +using System.Net; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; +using RichardSzalay.MockHttp; using Steeltoe.Management.Endpoint.Actuators.CloudFoundry; using Steeltoe.Management.Endpoint.Actuators.Hypermedia; using Steeltoe.Management.Endpoint.Actuators.Info; @@ -14,6 +16,8 @@ public sealed class StartupWithSecurity { public void ConfigureServices(IServiceCollection services) { + services.AddSingleton(_ => new MockHttpClientFactory(CloudControllerPermissionsMock.GetHttpMessageHandler())); + services.AddCloudFoundryActuator(); services.AddHypermediaActuator(); services.AddInfoActuator(); @@ -22,4 +26,15 @@ public void ConfigureServices(IServiceCollection services) public void Configure(IApplicationBuilder app) { } + + private sealed class MockHttpClientFactory(MockHttpMessageHandler mockHttpMessageHandler) : IHttpClientFactory + { + private readonly MockHttpMessageHandler _mockHttpMessageHandler = + mockHttpMessageHandler ?? throw new ArgumentNullException(nameof(mockHttpMessageHandler)); + + public HttpClient CreateClient(string name) + { + return _mockHttpMessageHandler.ToHttpClient(); + } + } } From 78bdc9b80216dc3d4c1c61e2bde8a287d75bd35b Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Thu, 29 May 2025 17:12:29 -0500 Subject: [PATCH 02/15] code cleanup --- .../CloudFoundrySecurityMiddleware.cs | 2 +- .../CloudFoundry/PermissionsProviderTest.cs | 24 +++++++++---------- .../CloudFoundry/StartupWithSecurity.cs | 1 - .../SpringBootAdminClient/FakeServer.cs | 2 +- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs index 3475846187..98f45abc4c 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs @@ -19,13 +19,13 @@ namespace Steeltoe.Management.Endpoint.Actuators.CloudFoundry; internal sealed class CloudFoundrySecurityMiddleware { + private const string BearerTokenPrefix = "Bearer "; private readonly IOptionsMonitor _managementOptionsMonitor; private readonly IOptionsMonitor _endpointOptionsMonitor; private readonly IEndpointOptionsMonitorProvider[] _endpointOptionsMonitorProviderArray; private readonly RequestDelegate? _next; private readonly ILogger _logger; private readonly PermissionsProvider _permissionsProvider; - private const string BearerTokenPrefix = "Bearer "; public CloudFoundrySecurityMiddleware(IOptionsMonitor managementOptionsMonitor, IOptionsMonitor endpointOptionsMonitor, IEnumerable endpointOptionsMonitorProviders, diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs index 232ea454d6..d48952bf7f 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs @@ -47,18 +47,6 @@ public async Task GetPermissionsTest() result.Should().Be(EndpointPermissions.Full); } - private static PermissionsProvider GetPermissionsProvider() - { - IOptionsMonitor optionsMonitor = GetOptionsMonitorFromSettings(); - - var services = new ServiceCollection(); - services.AddCloudFoundryActuator(); - using ServiceProvider serviceProvider = services.BuildServiceProvider(true); - var httpClientFactory = serviceProvider.GetRequiredService(); - - return new PermissionsProvider(optionsMonitor, httpClientFactory, NullLogger.Instance); - } - [InlineData("unavailable", HttpStatusCode.ServiceUnavailable, PermissionsProvider.CloudfoundryNotReachableMessage)] [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage)] [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage)] @@ -107,4 +95,16 @@ public async Task CloudControllerScenarioTesting(string cloudControllerResponse, break; } } + + private static PermissionsProvider GetPermissionsProvider() + { + IOptionsMonitor optionsMonitor = GetOptionsMonitorFromSettings(); + + var services = new ServiceCollection(); + services.AddCloudFoundryActuator(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(true); + var httpClientFactory = serviceProvider.GetRequiredService(); + + return new PermissionsProvider(optionsMonitor, httpClientFactory, NullLogger.Instance); + } } diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/StartupWithSecurity.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/StartupWithSecurity.cs index d4e9781077..ed2a6ee469 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/StartupWithSecurity.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/StartupWithSecurity.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. -using System.Net; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using RichardSzalay.MockHttp; diff --git a/src/Management/test/Endpoint.Test/SpringBootAdminClient/FakeServer.cs b/src/Management/test/Endpoint.Test/SpringBootAdminClient/FakeServer.cs index c023f58e44..9d8fb2db03 100644 --- a/src/Management/test/Endpoint.Test/SpringBootAdminClient/FakeServer.cs +++ b/src/Management/test/Endpoint.Test/SpringBootAdminClient/FakeServer.cs @@ -18,7 +18,7 @@ public FakeServer(IConfiguration configuration) string? urls = configuration.GetValue("urls"); Features.Set(urls != null - ? new FakeServerAddressesFeature(urls.Split(';').ToArray()) + ? new FakeServerAddressesFeature(urls.Split(';')) : new FakeServerAddressesFeature(["http://localhost:5000"])); } From 88c6c499a00f061fd4d5f2c2670ba6fad9d00c58 Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Fri, 30 May 2025 10:09:38 -0500 Subject: [PATCH 03/15] remove catching timeouts, add log assertions --- .../CloudFoundry/PermissionsProvider.cs | 6 -- .../CloudFoundrySecurityMiddlewareTest.cs | 94 ++++++++++++------- .../CloudFoundry/PermissionsProviderTest.cs | 35 ++++--- 3 files changed, 83 insertions(+), 52 deletions(-) diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs index 9de7e7bfef..5da211df05 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs @@ -94,12 +94,6 @@ public async Task GetPermissionsAsync(string accessToken, Cancel return new SecurityResult(HttpStatusCode.InternalServerError, exception.Message); } - catch (Exception exception) - { - _logger.LogInformation(exception, "Task cancelled or timed out while obtaining permissions from: {PermissionsUri}", checkPermissionsUri); - - return new SecurityResult(HttpStatusCode.ServiceUnavailable, CloudfoundryNotReachableMessage); - } } public async Task ParsePermissionsAsync(HttpResponseMessage response, CancellationToken cancellationToken) diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs index 6a081abfe6..1a3abfaa71 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs @@ -24,6 +24,21 @@ namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry; public sealed class CloudFoundrySecurityMiddlewareTest : BaseTest { + private const string CFForbiddenLog = + "INFO Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry returned status: Forbidden while obtaining permissions from: https://example.api.com/v2/apps/forbidden/permissions"; + + private const string CFExceptionLog = + "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry returned exception while obtaining permissions from: https://example.api.com/v2/apps/exception/permissions"; + + private const string MiddlewareForbiddenLog = + "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: Forbidden - Access denied"; + + private const string MiddlewareUnauthorizedLog = + "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: Unauthorized - Invalid token"; + + private const string MiddlewareUnavailableLog = + "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: ServiceUnavailable - Cloud controller not reachable"; + private readonly EnvironmentVariableScope _scope = new("VCAP_APPLICATION", "{}"); [Fact] @@ -365,23 +380,24 @@ public async Task Redacts_HTTP_headers() logMessages.Should().Contain("Authorization: *"); } - [InlineData("unavailable", HttpStatusCode.ServiceUnavailable, PermissionsProvider.CloudfoundryNotReachableMessage, "true")] - [InlineData("unavailable", HttpStatusCode.OK, PermissionsProvider.CloudfoundryNotReachableMessage, "false")] - [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, "true")] - [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, "false")] - [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, "true")] - [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, "false")] - [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage, "true")] - [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage, "false")] - [InlineData("timeout", HttpStatusCode.ServiceUnavailable, PermissionsProvider.CloudfoundryNotReachableMessage, "true")] - [InlineData("timeout", HttpStatusCode.OK, PermissionsProvider.CloudfoundryNotReachableMessage, "false")] - [InlineData("exception", HttpStatusCode.InternalServerError, @"Exception of type \u0027System.Net.Http.HttpRequestException\u0027 was thrown.", "true")] - [InlineData("exception", HttpStatusCode.OK, @"Exception of type \u0027System.Net.Http.HttpRequestException\u0027 was thrown.", "false")] - [InlineData("no_sensitive_data", HttpStatusCode.OK, null, "true")] - [InlineData("success", HttpStatusCode.OK, null, "true")] + [InlineData("unavailable", HttpStatusCode.ServiceUnavailable, PermissionsProvider.CloudfoundryNotReachableMessage, MiddlewareUnavailableLog, "true")] + [InlineData("unavailable", HttpStatusCode.OK, PermissionsProvider.CloudfoundryNotReachableMessage, MiddlewareUnavailableLog, "false")] + [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, MiddlewareUnauthorizedLog, "true")] + [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, MiddlewareUnauthorizedLog, "false")] + [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, MiddlewareUnauthorizedLog, "true")] + [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, MiddlewareUnauthorizedLog, "false")] + [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage, CFForbiddenLog, "true")] + [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage, CFForbiddenLog, "false")] + [InlineData("timeout", null, null, CFExceptionLog, "true")] + [InlineData("timeout", null, null, CFExceptionLog, "false")] + [InlineData("exception", HttpStatusCode.InternalServerError, "Exception of type \\u0027System.Net.Http.HttpRequestException\\u0027 was thrown.", + CFExceptionLog, "true")] + [InlineData("exception", HttpStatusCode.OK, @"Exception of type \u0027System.Net.Http.HttpRequestException\u0027 was thrown.", CFExceptionLog, "false")] + [InlineData("no_sensitive_data", HttpStatusCode.OK, null, MiddlewareForbiddenLog, "true")] + [InlineData("success", HttpStatusCode.OK, null, null, "true")] [Theory] - public async Task CloudControllerScenarioTesting(string cloudControllerResponse, HttpStatusCode steeltoeStatusCode, string? errorMessage, - string useStatusCodeFromResponse) + public async Task CloudControllerScenarioTesting(string cloudControllerResponse, HttpStatusCode? steeltoeStatusCode, string? errorMessage, + string? expectedLogs, string useStatusCodeFromResponse) { var appSettings = new Dictionary { @@ -391,8 +407,10 @@ public async Task CloudControllerScenarioTesting(string cloudControllerResponse, ["management:endpoints:UseStatusCodeFromResponse"] = useStatusCodeFromResponse }; + using var loggerProvider = new CapturingLoggerProvider(); WebHostBuilder builder = TestWebHostBuilderFactory.Create(); builder.UseStartup(); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders().AddProvider(loggerProvider)); builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(appSettings)); using IWebHost host = builder.Build(); @@ -401,27 +419,39 @@ public async Task CloudControllerScenarioTesting(string cloudControllerResponse, using HttpClient client = host.GetTestClient(); var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication")); httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken()); - HttpResponseMessage response = await client.SendAsync(httpRequestMessage, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(steeltoeStatusCode); - if (!string.IsNullOrEmpty(errorMessage)) + if (cloudControllerResponse == "timeout") { - string errorText = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - errorText.Should().Be($$"""{"security_error":"{{errorMessage}}"}"""); + await Assert.ThrowsAsync(() => client.SendAsync(httpRequestMessage, TestContext.Current.CancellationToken)); } else { - string responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - responseBody.Should() - .Be( - """{"type":"steeltoe","_links":{"info":{"href":"http://localhost/cloudfoundryapplication/info","templated":false},"self":{"href":"http://localhost/cloudfoundryapplication","templated":false}}}"""); - - var fullPermissionRequest = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication/info")); - fullPermissionRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken()); - HttpResponseMessage fullPermissionResponse = await client.SendAsync(fullPermissionRequest, TestContext.Current.CancellationToken); - - fullPermissionResponse.StatusCode.Should().Be(cloudControllerResponse == "success" ? HttpStatusCode.OK : HttpStatusCode.Forbidden); + HttpResponseMessage response = await client.SendAsync(httpRequestMessage, TestContext.Current.CancellationToken); + response.StatusCode.Should().Be(steeltoeStatusCode); + if (!string.IsNullOrEmpty(errorMessage)) + { + string errorText = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + errorText.Should().Be($$"""{"security_error":"{{errorMessage}}"}"""); + } + else + { + string responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + responseBody.Should() + .Be( + """{"type":"steeltoe","_links":{"info":{"href":"http://localhost/cloudfoundryapplication/info","templated":false},"self":{"href":"http://localhost/cloudfoundryapplication","templated":false}}}"""); + + var fullPermissionRequest = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication/info")); + fullPermissionRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken()); + HttpResponseMessage fullPermissionResponse = await client.SendAsync(fullPermissionRequest, TestContext.Current.CancellationToken); + + fullPermissionResponse.StatusCode.Should().Be(cloudControllerResponse == "success" ? HttpStatusCode.OK : HttpStatusCode.Forbidden); + } + if (!string.IsNullOrEmpty(expectedLogs)) + { + string logLines = loggerProvider.GetAsText(); + logLines.Should().Contain(expectedLogs); + } } } diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs index d48952bf7f..49c6c4cb02 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs @@ -78,21 +78,28 @@ public async Task CloudControllerScenarioTesting(string cloudControllerResponse, var permissionsProvider = new PermissionsProvider(optionsMonitor, httpClientFactory, NullLogger.Instance); - SecurityResult result = await permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken); - result.Code.Should().Be(steeltoeStatusCode); - result.Message.Should().Be(expectedMessage); - - switch (cloudControllerResponse) + if (cloudControllerResponse == "timeout") + { + await Assert.ThrowsAsync(() => permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken)); + } + else { - case "success": - result.Permissions.Should().Be(EndpointPermissions.Full); - break; - case "no_sensitive_data": - result.Permissions.Should().Be(EndpointPermissions.Restricted); - break; - default: - result.Permissions.Should().Be(EndpointPermissions.None); - break; + SecurityResult result = await permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken); + result.Code.Should().Be(steeltoeStatusCode); + result.Message.Should().Be(expectedMessage); + + switch (cloudControllerResponse) + { + case "success": + result.Permissions.Should().Be(EndpointPermissions.Full); + break; + case "no_sensitive_data": + result.Permissions.Should().Be(EndpointPermissions.Restricted); + break; + default: + result.Permissions.Should().Be(EndpointPermissions.None); + break; + } } } From e662a7bd1615f895b41e828e800dba62a89b2e11 Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Fri, 30 May 2025 10:36:42 -0500 Subject: [PATCH 04/15] catch cancel/timeout again, with trace-log and rethrow --- .../CloudFoundry/PermissionsProvider.cs | 9 ++++++- .../CloudFoundrySecurityMiddlewareTest.cs | 27 +++++++++++-------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs index 5da211df05..fef73aa696 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs @@ -88,8 +88,15 @@ public async Task GetPermissionsAsync(string accessToken, Cancel EndpointPermissions permissions = await ParsePermissionsAsync(response, cancellationToken); return new SecurityResult(permissions); } - catch (Exception exception) when (!exception.IsCancellation()) + catch (Exception exception) { + if (exception.IsCancellation()) + { + _logger.LogTrace(exception, "Task cancelled or timed out while obtaining permissions from: {PermissionsUri}", checkPermissionsUri); + + throw; + } + _logger.LogError(exception, "Cloud Foundry returned exception while obtaining permissions from: {PermissionsUri}", checkPermissionsUri); return new SecurityResult(HttpStatusCode.InternalServerError, exception.Message); diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs index 1a3abfaa71..63d19984fb 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs @@ -30,6 +30,9 @@ public sealed class CloudFoundrySecurityMiddlewareTest : BaseTest private const string CFExceptionLog = "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry returned exception while obtaining permissions from: https://example.api.com/v2/apps/exception/permissions"; + private const string CFTimeoutLog = + "TRCE Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Task cancelled or timed out while obtaining permissions from: https://example.api.com/v2/apps/timeout/permissions"; + private const string MiddlewareForbiddenLog = "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: Forbidden - Access denied"; @@ -39,6 +42,7 @@ public sealed class CloudFoundrySecurityMiddlewareTest : BaseTest private const string MiddlewareUnavailableLog = "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: ServiceUnavailable - Cloud controller not reachable"; + private const string CFExceptionMessage = @"Exception of type \u0027System.Net.Http.HttpRequestException\u0027 was thrown."; private readonly EnvironmentVariableScope _scope = new("VCAP_APPLICATION", "{}"); [Fact] @@ -388,11 +392,10 @@ public async Task Redacts_HTTP_headers() [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, MiddlewareUnauthorizedLog, "false")] [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage, CFForbiddenLog, "true")] [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage, CFForbiddenLog, "false")] - [InlineData("timeout", null, null, CFExceptionLog, "true")] - [InlineData("timeout", null, null, CFExceptionLog, "false")] - [InlineData("exception", HttpStatusCode.InternalServerError, "Exception of type \\u0027System.Net.Http.HttpRequestException\\u0027 was thrown.", - CFExceptionLog, "true")] - [InlineData("exception", HttpStatusCode.OK, @"Exception of type \u0027System.Net.Http.HttpRequestException\u0027 was thrown.", CFExceptionLog, "false")] + [InlineData("timeout", null, null, CFTimeoutLog, "true")] + [InlineData("timeout", null, null, CFTimeoutLog, "false")] + [InlineData("exception", HttpStatusCode.InternalServerError, CFExceptionMessage, CFExceptionLog, "true")] + [InlineData("exception", HttpStatusCode.OK, CFExceptionMessage, CFExceptionLog, "false")] [InlineData("no_sensitive_data", HttpStatusCode.OK, null, MiddlewareForbiddenLog, "true")] [InlineData("success", HttpStatusCode.OK, null, null, "true")] [Theory] @@ -410,7 +413,7 @@ public async Task CloudControllerScenarioTesting(string cloudControllerResponse, using var loggerProvider = new CapturingLoggerProvider(); WebHostBuilder builder = TestWebHostBuilderFactory.Create(); builder.UseStartup(); - builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders().AddProvider(loggerProvider)); + builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders().AddProvider(loggerProvider).SetMinimumLevel(LogLevel.Trace)); builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(appSettings)); using IWebHost host = builder.Build(); @@ -428,6 +431,7 @@ public async Task CloudControllerScenarioTesting(string cloudControllerResponse, { HttpResponseMessage response = await client.SendAsync(httpRequestMessage, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(steeltoeStatusCode); + if (!string.IsNullOrEmpty(errorMessage)) { string errorText = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); @@ -447,11 +451,12 @@ public async Task CloudControllerScenarioTesting(string cloudControllerResponse, fullPermissionResponse.StatusCode.Should().Be(cloudControllerResponse == "success" ? HttpStatusCode.OK : HttpStatusCode.Forbidden); } - if (!string.IsNullOrEmpty(expectedLogs)) - { - string logLines = loggerProvider.GetAsText(); - logLines.Should().Contain(expectedLogs); - } + } + + if (!string.IsNullOrEmpty(expectedLogs)) + { + string logLines = loggerProvider.GetAsText(); + logLines.Should().Contain(expectedLogs); } } From 17ab4d235ce0182f495abcc1f59020ab2ed1ce78 Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Mon, 2 Jun 2025 16:10:43 -0500 Subject: [PATCH 05/15] PR feedback - Use Microsoft.Net.Http.Headers.HeaderNames.Authorization - Move permissions messages to static class - Rename to ParsePermissionsResponseAsync - test cleanup --- .../CloudFoundrySecurityMiddleware.cs | 9 +- .../CloudFoundry/PermissionsProvider.cs | 29 ++-- .../CloudControllerPermissionsMock.cs | 23 +-- .../CloudFoundrySecurityMiddlewareTest.cs | 162 ++++++++---------- .../CloudFoundry/PermissionsProviderTest.cs | 44 ++--- .../CloudFoundry/StartupWithSecurity.cs | 14 -- 6 files changed, 127 insertions(+), 154 deletions(-) diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs index 98f45abc4c..a23bfc5b96 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; using Steeltoe.Common; using Steeltoe.Management.Configuration; using Steeltoe.Management.Endpoint.Actuators.Hypermedia; @@ -64,13 +65,13 @@ public async Task InvokeAsync(HttpContext context) _logger.LogCritical( "The Application Id could not be found. Make sure the Cloud Foundry Configuration Provider has been added to the application configuration."); - await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.ApplicationIdMissingMessage)); + await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.ApplicationIdMissing)); return; } if (string.IsNullOrEmpty(endpointOptions.Api)) { - await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.CloudfoundryApiMissingMessage)); + await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudfoundryApiMissing)); return; } @@ -88,7 +89,7 @@ public async Task InvokeAsync(HttpContext context) if (targetEndpointOptions.RequiredPermissions > givenPermissions.Permissions) { - await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage)); + await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied)); return; } } @@ -102,7 +103,7 @@ public async Task InvokeAsync(HttpContext context) internal string GetAccessToken(HttpRequest request) { - if (request.Headers.TryGetValue(PermissionsProvider.AuthorizationHeaderName, out StringValues authorizationHeaderValue)) + if (request.Headers.TryGetValue(HeaderNames.Authorization, out StringValues authorizationHeaderValue)) { string authorizationValue = authorizationHeaderValue.ToString(); diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs index fef73aa696..15f38a01d0 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs @@ -20,14 +20,7 @@ namespace Steeltoe.Management.Endpoint.Actuators.CloudFoundry; internal sealed class PermissionsProvider { private const string ReadSensitiveDataJsonPropertyName = "read_sensitive_data"; - public const string AccessDeniedMessage = "Access denied"; - public const string ApplicationIdMissingMessage = "Application ID is not available"; - public const string AuthorizationHeaderName = "Authorization"; - public const string AuthorizationHeaderInvalid = "Authorization header is missing or invalid"; - public const string CloudfoundryApiMissingMessage = "Cloud controller URL is not available"; - public const string CloudfoundryNotReachableMessage = "Cloud controller not reachable"; public const string HttpClientName = "CloudFoundrySecurity"; - public const string InvalidTokenMessage = "Invalid token"; private static readonly TimeSpan GetPermissionsTimeout = TimeSpan.FromMilliseconds(5_000); private readonly IOptionsMonitor _optionsMonitor; @@ -55,7 +48,7 @@ public async Task GetPermissionsAsync(string accessToken, Cancel { if (string.IsNullOrEmpty(accessToken)) { - return new SecurityResult(HttpStatusCode.Unauthorized, AuthorizationHeaderInvalid); + return new SecurityResult(HttpStatusCode.Unauthorized, Messages.AuthorizationHeaderInvalid); } CloudFoundryEndpointOptions options = _optionsMonitor.CurrentValue; @@ -77,15 +70,15 @@ public async Task GetPermissionsAsync(string accessToken, Cancel if (response.StatusCode is HttpStatusCode.Forbidden) { - return new SecurityResult(HttpStatusCode.Forbidden, AccessDeniedMessage); + return new SecurityResult(HttpStatusCode.Forbidden, Messages.AccessDenied); } return (int)response.StatusCode is > 399 and < 500 - ? new SecurityResult(HttpStatusCode.Unauthorized, InvalidTokenMessage) - : new SecurityResult(HttpStatusCode.ServiceUnavailable, CloudfoundryNotReachableMessage); + ? new SecurityResult(HttpStatusCode.Unauthorized, Messages.InvalidToken) + : new SecurityResult(HttpStatusCode.ServiceUnavailable, Messages.CloudfoundryNotReachable); } - EndpointPermissions permissions = await ParsePermissionsAsync(response, cancellationToken); + EndpointPermissions permissions = await ParsePermissionsResponseAsync(response, cancellationToken); return new SecurityResult(permissions); } catch (Exception exception) @@ -103,7 +96,7 @@ public async Task GetPermissionsAsync(string accessToken, Cancel } } - public async Task ParsePermissionsAsync(HttpResponseMessage response, CancellationToken cancellationToken) + public async Task ParsePermissionsResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(response); @@ -139,4 +132,14 @@ private HttpClient CreateHttpClient() httpClient.ConfigureForSteeltoe(GetPermissionsTimeout); return httpClient; } + + internal static class Messages + { + public const string AccessDenied = "Access denied"; + public const string ApplicationIdMissing = "Application ID is not available"; + public const string AuthorizationHeaderInvalid = "Authorization header is missing or invalid"; + public const string CloudfoundryApiMissing = "Cloud controller URL is not available"; + public const string CloudfoundryNotReachable = "Cloud controller not reachable"; + public const string InvalidToken = "Invalid token"; + } } diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs index c332163f43..9d1aa75773 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs @@ -4,34 +4,35 @@ using System.Net; using RichardSzalay.MockHttp; +using Steeltoe.Common.TestResources; namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry; internal static class CloudControllerPermissionsMock { - internal static MockHttpMessageHandler GetHttpMessageHandler() + internal static DelegateToMockHttpClientHandler GetHttpMessageHandler() { - MockHttpMessageHandler httpClientHandler = new(); + var httpClientHandler = new DelegateToMockHttpClientHandler(); - httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/unavailable/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/unavailable/permissions") .Respond(HttpStatusCode.ServiceUnavailable, "application/json", "{}"); - httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/not-found/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/not-found/permissions") .Respond(HttpStatusCode.NotFound, "application/json", "{}"); - httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/unauthorized/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/unauthorized/permissions") .Respond(HttpStatusCode.Unauthorized, "application/json", "{}"); - httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/forbidden/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/forbidden/permissions") .Respond(HttpStatusCode.Forbidden, "application/json", "{}"); - httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/timeout/permissions").Throw(new TaskCanceledException()); - httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/exception/permissions").Throw(new HttpRequestException()); + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/timeout/permissions").Throw(new TaskCanceledException()); + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/exception/permissions").Throw(new HttpRequestException()); - httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/no_sensitive_data/permissions").Respond(HttpStatusCode.OK, "application/json", - """{"read_sensitive_data": false, "read_basic_data": true}"""); + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/no_sensitive_data/permissions").Respond(HttpStatusCode.OK, + "application/json", """{"read_sensitive_data": false, "read_basic_data": true}"""); - httpClientHandler.When(HttpMethod.Get, "https://example.api.com/v2/apps/success/permissions").Respond(HttpStatusCode.OK, "application/json", + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/success/permissions").Respond(HttpStatusCode.OK, "application/json", """{"read_sensitive_data": true, "read_basic_data": true}"""); return httpClientHandler; diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs index 63d19984fb..d719fed6f1 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs @@ -2,8 +2,10 @@ // The .NET Foundation licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. +using System.Globalization; using System.Net; using System.Net.Http.Headers; +using System.Text.Json.Nodes; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; @@ -14,6 +16,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; +using Steeltoe.Common.Http.HttpClientPooling; using Steeltoe.Common.TestResources; using Steeltoe.Configuration.CloudFoundry; using Steeltoe.Management.Configuration; @@ -42,11 +45,16 @@ public sealed class CloudFoundrySecurityMiddlewareTest : BaseTest private const string MiddlewareUnavailableLog = "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: ServiceUnavailable - Cloud controller not reachable"; - private const string CFExceptionMessage = @"Exception of type \u0027System.Net.Http.HttpRequestException\u0027 was thrown."; + private const string CFExceptionMessage = "Exception of type 'System.Net.Http.HttpRequestException' was thrown."; + + private static readonly string MockAccessToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwuY29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ." + + Convert.ToBase64String("signature"u8.ToArray()); + private readonly EnvironmentVariableScope _scope = new("VCAP_APPLICATION", "{}"); [Fact] - public async Task CloudFoundrySecurityMiddleware_MissingApplicationID_ReturnsServiceUnavailable() + public async Task MissingApplicationIdReturnsServiceUnavailable() { WebHostBuilder builder = TestWebHostBuilderFactory.Create(); builder.UseStartup(); @@ -66,7 +74,7 @@ public async Task CloudFoundrySecurityMiddleware_MissingApplicationID_ReturnsSer } [Fact] - public async Task CloudFoundrySecurityMiddleware_MissingCloudFoundryApi_ReturnsServiceUnavailable() + public async Task MissingCloudFoundryApiReturnsServiceUnavailable() { var appSettings = new Dictionary { @@ -92,7 +100,7 @@ public async Task CloudFoundrySecurityMiddleware_MissingCloudFoundryApi_ReturnsS } [Fact] - public async Task CloudFoundrySecurityMiddleware_TargetEndpointNotConfigured_DelegatesToEndpointMiddleware() + public async Task TargetEndpointNotConfiguredDelegatesToEndpointMiddleware() { var appSettings = new Dictionary { @@ -118,7 +126,7 @@ public async Task CloudFoundrySecurityMiddleware_TargetEndpointNotConfigured_Del } [Fact] - public async Task CloudFoundrySecurityMiddleware_MissingAccessToken_ReturnsUnauthorized() + public async Task MissingAccessTokenReturnsUnauthorized() { var appSettings = new Dictionary { @@ -145,7 +153,7 @@ public async Task CloudFoundrySecurityMiddleware_MissingAccessToken_ReturnsUnaut } [Fact] - public async Task CloudFoundrySecurityMiddleware_UseStatusCodeFromResponseFalse_ReturnsOkAndContent() + public async Task UseStatusCodeFromResponseFalseReturnsOkAndContent() { var appSettings = new Dictionary { @@ -171,7 +179,7 @@ public async Task CloudFoundrySecurityMiddleware_UseStatusCodeFromResponseFalse_ } [Fact] - public async Task CloudFoundrySecurityMiddleware_UseStatusCodeFromResponseFalse_ReturnsUnauthorized() + public async Task UseStatusCodeFromResponseFalseReturnsUnauthorized() { var appSettings = new Dictionary { @@ -199,7 +207,7 @@ public async Task CloudFoundrySecurityMiddleware_UseStatusCodeFromResponseFalse_ } [Fact] - public async Task CloudFoundrySecurityMiddleware_SkipsSecurityCheckIfCloudFoundryDisabled() + public async Task SkipsSecurityCheckIfCloudFoundryDisabled() { var appSettings = new Dictionary { @@ -221,7 +229,7 @@ public async Task CloudFoundrySecurityMiddleware_SkipsSecurityCheckIfCloudFoundr } [Fact] - public async Task CloudFoundrySecurityMiddleware_SkipsSecurityCheckIfCloudFoundryActuatorDisabled() + public async Task SkipsSecurityCheckIfCloudFoundryActuatorDisabled() { var appSettings = new Dictionary { @@ -243,7 +251,7 @@ public async Task CloudFoundrySecurityMiddleware_SkipsSecurityCheckIfCloudFoundr } [Fact] - public async Task CloudFoundrySecurityMiddleware_SkipsSecurityCheckIfCloudFoundryActuatorDisabledViaEnvironmentVariable() + public async Task SkipsSecurityCheckIfCloudFoundryActuatorDisabledViaEnvironmentVariable() { using var scope = new EnvironmentVariableScope("MANAGEMENT__ENDPOINTS__CLOUDFOUNDRY__ENABLED", "False"); @@ -262,44 +270,7 @@ public async Task CloudFoundrySecurityMiddleware_SkipsSecurityCheckIfCloudFoundr } [Fact] - public async Task CloudFoundrySecurityMiddleware_InvokeAsync_ReturnsExpected() - { - var appSettings = new Dictionary - { - ["management:endpoints:info:enabled"] = "true", - ["vcap:application:application_id"] = "foobar", - ["vcap:application:cf_api"] = "http://localhost:9999/foo" - }; - - WebHostBuilder builder = TestWebHostBuilderFactory.Create(); - builder.UseStartup(); - builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(appSettings)); - - using IWebHost host = builder.Build(); - await host.StartAsync(TestContext.Current.CancellationToken); - - using HttpClient client = host.GetTestClient(); - HttpResponseMessage response = await client.GetAsync(new Uri("http://localhost/cloudfoundryapplication"), TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); // We expect the authorization to fail, but the FindTargetEndpoint logic to work. - - Assert.Equal("""{"security_error":"Authorization header is missing or invalid"}""", - await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - - Assert.NotNull(response.Content.Headers.ContentType); - Assert.Equal("application/json", response.Content.Headers.ContentType.MediaType); - - HttpResponseMessage response2 = await client.GetAsync(new Uri("http://localhost/cloudfoundryapplication/info"), TestContext.Current.CancellationToken); - Assert.Equal(HttpStatusCode.Unauthorized, response2.StatusCode); - - Assert.Equal("""{"security_error":"Authorization header is missing or invalid"}""", - await response2.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - - Assert.NotNull(response2.Content.Headers.ContentType); - Assert.Equal("application/json", response2.Content.Headers.ContentType.MediaType); - } - - [Fact] - public async Task GetAccessToken_ReturnsExpected() + public async Task GetAccessTokenReturnsExpected() { IOptionsMonitor endpointOptionsMonitor = GetOptionsMonitorFromSettings(); IOptionsMonitor managementOptionsMonitor = GetOptionsMonitorFromSettings(); @@ -324,7 +295,7 @@ public async Task GetAccessToken_ReturnsExpected() } [Fact] - public async Task GetPermissions_ReturnsExpected() + public async Task GetPermissionsReturnsExpected() { IOptionsMonitor endpointOptionsMonitor = GetOptionsMonitorFromSettings(); IOptionsMonitor managementOptionsMonitor = GetOptionsMonitorFromSettings(); @@ -346,7 +317,7 @@ public async Task GetPermissions_ReturnsExpected() } [Fact] - public async Task Throws_when_Add_method_not_called() + public async Task ThrowsWhenAddMethodNotCalled() { WebApplicationBuilder builder = TestWebApplicationBuilderFactory.Create(); await using WebApplication app = builder.Build(); @@ -356,7 +327,7 @@ public async Task Throws_when_Add_method_not_called() } [Fact] - public async Task Redacts_HTTP_headers() + public async Task RedactsHttpHeaders() { var appSettings = new Dictionary { @@ -384,30 +355,30 @@ public async Task Redacts_HTTP_headers() logMessages.Should().Contain("Authorization: *"); } - [InlineData("unavailable", HttpStatusCode.ServiceUnavailable, PermissionsProvider.CloudfoundryNotReachableMessage, MiddlewareUnavailableLog, "true")] - [InlineData("unavailable", HttpStatusCode.OK, PermissionsProvider.CloudfoundryNotReachableMessage, MiddlewareUnavailableLog, "false")] - [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, MiddlewareUnauthorizedLog, "true")] - [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, MiddlewareUnauthorizedLog, "false")] - [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, MiddlewareUnauthorizedLog, "true")] - [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage, MiddlewareUnauthorizedLog, "false")] - [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage, CFForbiddenLog, "true")] - [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage, CFForbiddenLog, "false")] - [InlineData("timeout", null, null, CFTimeoutLog, "true")] - [InlineData("timeout", null, null, CFTimeoutLog, "false")] - [InlineData("exception", HttpStatusCode.InternalServerError, CFExceptionMessage, CFExceptionLog, "true")] - [InlineData("exception", HttpStatusCode.OK, CFExceptionMessage, CFExceptionLog, "false")] - [InlineData("no_sensitive_data", HttpStatusCode.OK, null, MiddlewareForbiddenLog, "true")] - [InlineData("success", HttpStatusCode.OK, null, null, "true")] + [InlineData("unavailable", HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudfoundryNotReachable, MiddlewareUnavailableLog, true)] + [InlineData("unavailable", HttpStatusCode.OK, PermissionsProvider.Messages.CloudfoundryNotReachable, MiddlewareUnavailableLog, false)] + [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken, MiddlewareUnauthorizedLog, true)] + [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken, MiddlewareUnauthorizedLog, false)] + [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken, MiddlewareUnauthorizedLog, true)] + [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken, MiddlewareUnauthorizedLog, false)] + [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied, CFForbiddenLog, true)] + [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied, CFForbiddenLog, false)] + [InlineData("timeout", null, null, CFTimeoutLog, true)] + [InlineData("timeout", null, null, CFTimeoutLog, false)] + [InlineData("exception", HttpStatusCode.InternalServerError, CFExceptionMessage, CFExceptionLog, true)] + [InlineData("exception", HttpStatusCode.OK, CFExceptionMessage, CFExceptionLog, false)] + [InlineData("no_sensitive_data", HttpStatusCode.OK, null, MiddlewareForbiddenLog, true)] + [InlineData("success", HttpStatusCode.OK, null, null, true)] [Theory] - public async Task CloudControllerScenarioTesting(string cloudControllerResponse, HttpStatusCode? steeltoeStatusCode, string? errorMessage, - string? expectedLogs, string useStatusCodeFromResponse) + public async Task InvokeAsyncReturnsExpected(string scenario, HttpStatusCode? steeltoeStatusCode, string? errorMessage, string? expectedLogs, + bool useStatusCodeFromResponse) { var appSettings = new Dictionary { - ["vcap:application:application_id"] = cloudControllerResponse, + ["vcap:application:application_id"] = scenario, ["vcap:application:cf_api"] = "https://example.api.com", ["management:endpoints:info:requiredPermissions"] = "FULL", - ["management:endpoints:UseStatusCodeFromResponse"] = useStatusCodeFromResponse + ["management:endpoints:UseStatusCodeFromResponse"] = useStatusCodeFromResponse.ToString(CultureInfo.InvariantCulture) }; using var loggerProvider = new CapturingLoggerProvider(); @@ -417,40 +388,55 @@ public async Task CloudControllerScenarioTesting(string cloudControllerResponse, builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(appSettings)); using IWebHost host = builder.Build(); + host.Services.GetRequiredService().Using(CloudControllerPermissionsMock.GetHttpMessageHandler()); await host.StartAsync(TestContext.Current.CancellationToken); using HttpClient client = host.GetTestClient(); - var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication")); - httpRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken()); + var hypermediaRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication")); + hypermediaRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken); + var fullPermissionRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication/info")); + fullPermissionRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken); - if (cloudControllerResponse == "timeout") + if (scenario == "timeout") { - await Assert.ThrowsAsync(() => client.SendAsync(httpRequestMessage, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => client.SendAsync(hypermediaRequestMessage, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => client.SendAsync(fullPermissionRequestMessage, TestContext.Current.CancellationToken)); } else { - HttpResponseMessage response = await client.SendAsync(httpRequestMessage, TestContext.Current.CancellationToken); + HttpResponseMessage response = await client.SendAsync(hypermediaRequestMessage, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(steeltoeStatusCode); - if (!string.IsNullOrEmpty(errorMessage)) + string? jsonErrorValue = JsonValue.Create(errorMessage)?.ToJsonString(); + + if (jsonErrorValue != null) { string errorText = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - errorText.Should().Be($$"""{"security_error":"{{errorMessage}}"}"""); + errorText.Should().Be($$"""{"security_error":{{jsonErrorValue}}}"""); } else { string responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - responseBody.Should() - .Be( - """{"type":"steeltoe","_links":{"info":{"href":"http://localhost/cloudfoundryapplication/info","templated":false},"self":{"href":"http://localhost/cloudfoundryapplication","templated":false}}}"""); - - var fullPermissionRequest = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication/info")); - fullPermissionRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken()); - HttpResponseMessage fullPermissionResponse = await client.SendAsync(fullPermissionRequest, TestContext.Current.CancellationToken); - - fullPermissionResponse.StatusCode.Should().Be(cloudControllerResponse == "success" ? HttpStatusCode.OK : HttpStatusCode.Forbidden); + responseBody.Should().BeJson(""" + { + "type":"steeltoe", + "_links":{ + "info":{ + "href":"http://localhost/cloudfoundryapplication/info", + "templated":false + }, + "self":{ + "href":"http://localhost/cloudfoundryapplication", + "templated":false + } + } + } + """); } + + HttpResponseMessage fullPermissionResponse = await client.SendAsync(fullPermissionRequestMessage, TestContext.Current.CancellationToken); + fullPermissionResponse.StatusCode.Should().Be(scenario == "no_sensitive_data" ? HttpStatusCode.Forbidden : steeltoeStatusCode); } if (!string.IsNullOrEmpty(expectedLogs)) @@ -460,12 +446,6 @@ public async Task CloudControllerScenarioTesting(string cloudControllerResponse, } } - private static string MockAccessToken() - { - return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwuY29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ." + - Convert.ToBase64String("signature"u8.ToArray()); - } - protected override void Dispose(bool disposing) { if (disposing) diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs index 49c6c4cb02..08a02cecf3 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Steeltoe.Common.Http.HttpClientPooling; using Steeltoe.Management.Configuration; using Steeltoe.Management.Endpoint.Actuators.CloudFoundry; @@ -16,7 +17,7 @@ namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry; public sealed class PermissionsProviderTest : BaseTest { [Fact] - public void IsCloudFoundryRequest_ReturnsExpected() + public void IsCloudFoundryRequestReturnsExpected() { Assert.True(PermissionsProvider.IsCloudFoundryRequest("/cloudfoundryapplication")); Assert.True(PermissionsProvider.IsCloudFoundryRequest("/cloudfoundryapplication/badpath")); @@ -28,40 +29,43 @@ public async Task EmptyTokenIsUnauthorized() PermissionsProvider permissionsProvider = GetPermissionsProvider(); SecurityResult unauthorized = await permissionsProvider.GetPermissionsAsync(string.Empty, TestContext.Current.CancellationToken); unauthorized.Code.Should().Be(HttpStatusCode.Unauthorized); - unauthorized.Message.Should().Be(PermissionsProvider.AuthorizationHeaderInvalid); + unauthorized.Message.Should().Be(PermissionsProvider.Messages.AuthorizationHeaderInvalid); } - [Fact] - public async Task GetPermissionsTest() + [InlineData(false, true, EndpointPermissions.Restricted)] + [InlineData(true, true, EndpointPermissions.Full)] + [Theory] + public async Task ParsePermissionsAsyncReturnsExpected(bool readSensitive, bool readBasic, EndpointPermissions expectedPermissions) { PermissionsProvider permissionsProvider = GetPermissionsProvider(); var response = new HttpResponseMessage(HttpStatusCode.OK); - var permissions = new Dictionary + var permissionsJson = new Dictionary { - ["read_sensitive_data"] = true + ["read_sensitive_data"] = readSensitive, + ["read_basic_data"] = readBasic }; - response.Content = JsonContent.Create(permissions); - EndpointPermissions result = await permissionsProvider.ParsePermissionsAsync(response, TestContext.Current.CancellationToken); - result.Should().Be(EndpointPermissions.Full); + response.Content = JsonContent.Create(permissionsJson); + EndpointPermissions result = await permissionsProvider.ParsePermissionsResponseAsync(response, TestContext.Current.CancellationToken); + result.Should().Be(expectedPermissions); } - [InlineData("unavailable", HttpStatusCode.ServiceUnavailable, PermissionsProvider.CloudfoundryNotReachableMessage)] - [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage)] - [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.InvalidTokenMessage)] - [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.AccessDeniedMessage)] - [InlineData("timeout", HttpStatusCode.ServiceUnavailable, PermissionsProvider.CloudfoundryNotReachableMessage)] + [InlineData("unavailable", HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudfoundryNotReachable)] + [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken)] + [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken)] + [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied)] + [InlineData("timeout", HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudfoundryNotReachable)] [InlineData("exception", HttpStatusCode.InternalServerError, "Exception of type 'System.Net.Http.HttpRequestException' was thrown.")] [InlineData("no_sensitive_data", HttpStatusCode.OK, "")] [InlineData("success", HttpStatusCode.OK, "")] [Theory] - public async Task CloudControllerScenarioTesting(string cloudControllerResponse, HttpStatusCode steeltoeStatusCode, string? expectedMessage) + public async Task GetPermissionsAsyncReturnsExpected(string scenario, HttpStatusCode steeltoeStatusCode, string expectedMessage) { var appSettings = new Dictionary { ["vcap:application:cf_api"] = "https://example.api.com", - ["vcap:application:application_id"] = cloudControllerResponse + ["vcap:application:application_id"] = scenario }; IOptionsMonitor optionsMonitor = GetOptionsMonitorFromSettings(appSettings!); @@ -70,15 +74,13 @@ public async Task CloudControllerScenarioTesting(string cloudControllerResponse, services.AddSingleton(new ConfigurationBuilder().Build()); services.AddCloudFoundryActuator(); - services.AddHttpClient(PermissionsProvider.HttpClientName) - .ConfigurePrimaryHttpMessageHandler(_ => CloudControllerPermissionsMock.GetHttpMessageHandler()); - await using ServiceProvider serviceProvider = services.BuildServiceProvider(true); + serviceProvider.GetRequiredService().Using(CloudControllerPermissionsMock.GetHttpMessageHandler()); var httpClientFactory = serviceProvider.GetRequiredService(); var permissionsProvider = new PermissionsProvider(optionsMonitor, httpClientFactory, NullLogger.Instance); - if (cloudControllerResponse == "timeout") + if (scenario == "timeout") { await Assert.ThrowsAsync(() => permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken)); } @@ -88,7 +90,7 @@ public async Task CloudControllerScenarioTesting(string cloudControllerResponse, result.Code.Should().Be(steeltoeStatusCode); result.Message.Should().Be(expectedMessage); - switch (cloudControllerResponse) + switch (scenario) { case "success": result.Permissions.Should().Be(EndpointPermissions.Full); diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/StartupWithSecurity.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/StartupWithSecurity.cs index ed2a6ee469..8b69ba826f 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/StartupWithSecurity.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/StartupWithSecurity.cs @@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -using RichardSzalay.MockHttp; using Steeltoe.Management.Endpoint.Actuators.CloudFoundry; using Steeltoe.Management.Endpoint.Actuators.Hypermedia; using Steeltoe.Management.Endpoint.Actuators.Info; @@ -15,8 +14,6 @@ public sealed class StartupWithSecurity { public void ConfigureServices(IServiceCollection services) { - services.AddSingleton(_ => new MockHttpClientFactory(CloudControllerPermissionsMock.GetHttpMessageHandler())); - services.AddCloudFoundryActuator(); services.AddHypermediaActuator(); services.AddInfoActuator(); @@ -25,15 +22,4 @@ public void ConfigureServices(IServiceCollection services) public void Configure(IApplicationBuilder app) { } - - private sealed class MockHttpClientFactory(MockHttpMessageHandler mockHttpMessageHandler) : IHttpClientFactory - { - private readonly MockHttpMessageHandler _mockHttpMessageHandler = - mockHttpMessageHandler ?? throw new ArgumentNullException(nameof(mockHttpMessageHandler)); - - public HttpClient CreateClient(string name) - { - return _mockHttpMessageHandler.ToHttpClient(); - } - } } From 66066436bda1d360760c2141cebcd60dced166df Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Tue, 3 Jun 2025 08:30:59 -0500 Subject: [PATCH 06/15] Update src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs Co-authored-by: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> --- .../Actuators/CloudFoundry/PermissionsProviderTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs index 08a02cecf3..e06a5eb6a0 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs @@ -35,7 +35,7 @@ public async Task EmptyTokenIsUnauthorized() [InlineData(false, true, EndpointPermissions.Restricted)] [InlineData(true, true, EndpointPermissions.Full)] [Theory] - public async Task ParsePermissionsAsyncReturnsExpected(bool readSensitive, bool readBasic, EndpointPermissions expectedPermissions) + public async Task ParsePermissionsResponseAsyncReturnsExpected(bool readSensitive, bool readBasic, EndpointPermissions expectedPermissions) { PermissionsProvider permissionsProvider = GetPermissionsProvider(); var response = new HttpResponseMessage(HttpStatusCode.OK); From 8d282900fdc13fb0db04ea04ceabed71c4f0d8ef Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Tue, 3 Jun 2025 08:59:20 -0500 Subject: [PATCH 07/15] PR feedback --- .../CloudFoundrySecurityMiddlewareTest.cs | 21 +++++++++---------- .../CloudFoundry/PermissionsProviderTest.cs | 5 ++--- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs index d719fed6f1..8508ddcbaa 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs @@ -392,25 +392,24 @@ public async Task InvokeAsyncReturnsExpected(string scenario, HttpStatusCode? st await host.StartAsync(TestContext.Current.CancellationToken); using HttpClient client = host.GetTestClient(); - var hypermediaRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication")); - hypermediaRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken); - var fullPermissionRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication/info")); - fullPermissionRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken); + var testAuthenticationRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication")); + testAuthenticationRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken); + var testAuthorizationRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication/info")); + testAuthorizationRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken); if (scenario == "timeout") { - await Assert.ThrowsAsync(() => client.SendAsync(hypermediaRequestMessage, TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync(() => client.SendAsync(fullPermissionRequestMessage, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => client.SendAsync(testAuthenticationRequestMessage, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(() => client.SendAsync(testAuthorizationRequestMessage, TestContext.Current.CancellationToken)); } else { - HttpResponseMessage response = await client.SendAsync(hypermediaRequestMessage, TestContext.Current.CancellationToken); + HttpResponseMessage response = await client.SendAsync(testAuthenticationRequestMessage, TestContext.Current.CancellationToken); response.StatusCode.Should().Be(steeltoeStatusCode); - string? jsonErrorValue = JsonValue.Create(errorMessage)?.ToJsonString(); - - if (jsonErrorValue != null) + if (errorMessage != null) { + string jsonErrorValue = JsonValue.Create(errorMessage).ToJsonString(); string errorText = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); errorText.Should().Be($$"""{"security_error":{{jsonErrorValue}}}"""); } @@ -435,7 +434,7 @@ public async Task InvokeAsyncReturnsExpected(string scenario, HttpStatusCode? st """); } - HttpResponseMessage fullPermissionResponse = await client.SendAsync(fullPermissionRequestMessage, TestContext.Current.CancellationToken); + HttpResponseMessage fullPermissionResponse = await client.SendAsync(testAuthorizationRequestMessage, TestContext.Current.CancellationToken); fullPermissionResponse.StatusCode.Should().Be(scenario == "no_sensitive_data" ? HttpStatusCode.Forbidden : steeltoeStatusCode); } diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs index e06a5eb6a0..a0e14a77df 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs @@ -40,13 +40,12 @@ public async Task ParsePermissionsResponseAsyncReturnsExpected(bool readSensitiv PermissionsProvider permissionsProvider = GetPermissionsProvider(); var response = new HttpResponseMessage(HttpStatusCode.OK); - var permissionsJson = new Dictionary + response.Content = JsonContent.Create(new Dictionary { ["read_sensitive_data"] = readSensitive, ["read_basic_data"] = readBasic - }; + }); - response.Content = JsonContent.Create(permissionsJson); EndpointPermissions result = await permissionsProvider.ParsePermissionsResponseAsync(response, TestContext.Current.CancellationToken); result.Should().Be(expectedPermissions); } From 214503198300879bf7879b409df710c44a8d696b Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Tue, 3 Jun 2025 12:54:09 -0500 Subject: [PATCH 08/15] back to not catching cancellation, use real web host and other test cleanup --- .../CloudFoundry/PermissionsProvider.cs | 9 +- .../CloudControllerPermissionsMock.cs | 2 +- .../CloudFoundrySecurityMiddlewareTest.cs | 86 +++++++++---------- .../CloudFoundry/PermissionsProviderTest.cs | 51 +++++------ 4 files changed, 66 insertions(+), 82 deletions(-) diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs index 15f38a01d0..cc66829e60 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs @@ -81,15 +81,8 @@ public async Task GetPermissionsAsync(string accessToken, Cancel EndpointPermissions permissions = await ParsePermissionsResponseAsync(response, cancellationToken); return new SecurityResult(permissions); } - catch (Exception exception) + catch (Exception exception) when (!exception.IsCancellation()) { - if (exception.IsCancellation()) - { - _logger.LogTrace(exception, "Task cancelled or timed out while obtaining permissions from: {PermissionsUri}", checkPermissionsUri); - - throw; - } - _logger.LogError(exception, "Cloud Foundry returned exception while obtaining permissions from: {PermissionsUri}", checkPermissionsUri); return new SecurityResult(HttpStatusCode.InternalServerError, exception.Message); diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs index 9d1aa75773..72687a2f29 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs @@ -26,7 +26,7 @@ internal static DelegateToMockHttpClientHandler GetHttpMessageHandler() httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/forbidden/permissions") .Respond(HttpStatusCode.Forbidden, "application/json", "{}"); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/timeout/permissions").Throw(new TaskCanceledException()); + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/timeout/permissions").Throw(new TimeoutException()); httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/exception/permissions").Throw(new HttpRequestException()); httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/no_sensitive_data/permissions").Respond(HttpStatusCode.OK, diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs index 8508ddcbaa..9850626a48 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs @@ -21,6 +21,8 @@ using Steeltoe.Configuration.CloudFoundry; using Steeltoe.Management.Configuration; using Steeltoe.Management.Endpoint.Actuators.CloudFoundry; +using Steeltoe.Management.Endpoint.Actuators.Hypermedia; +using Steeltoe.Management.Endpoint.Actuators.Info; using Steeltoe.Management.Endpoint.Configuration; namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry; @@ -34,7 +36,7 @@ public sealed class CloudFoundrySecurityMiddlewareTest : BaseTest "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry returned exception while obtaining permissions from: https://example.api.com/v2/apps/exception/permissions"; private const string CFTimeoutLog = - "TRCE Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Task cancelled or timed out while obtaining permissions from: https://example.api.com/v2/apps/timeout/permissions"; + "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry returned exception while obtaining permissions from: https://example.api.com/v2/apps/timeout/permissions"; private const string MiddlewareForbiddenLog = "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: Forbidden - Access denied"; @@ -363,14 +365,14 @@ public async Task RedactsHttpHeaders() [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken, MiddlewareUnauthorizedLog, false)] [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied, CFForbiddenLog, true)] [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied, CFForbiddenLog, false)] - [InlineData("timeout", null, null, CFTimeoutLog, true)] - [InlineData("timeout", null, null, CFTimeoutLog, false)] + [InlineData("timeout", HttpStatusCode.InternalServerError, "The operation has timed out.", CFTimeoutLog, true)] + [InlineData("timeout", HttpStatusCode.OK, "The operation has timed out.", CFTimeoutLog, false)] [InlineData("exception", HttpStatusCode.InternalServerError, CFExceptionMessage, CFExceptionLog, true)] [InlineData("exception", HttpStatusCode.OK, CFExceptionMessage, CFExceptionLog, false)] [InlineData("no_sensitive_data", HttpStatusCode.OK, null, MiddlewareForbiddenLog, true)] [InlineData("success", HttpStatusCode.OK, null, null, true)] [Theory] - public async Task InvokeAsyncReturnsExpected(string scenario, HttpStatusCode? steeltoeStatusCode, string? errorMessage, string? expectedLogs, + public async Task IntegrationTestReturnsExpected(string scenario, HttpStatusCode? steeltoeStatusCode, string? errorMessage, string? expectedLogs, bool useStatusCodeFromResponse) { var appSettings = new Dictionary @@ -382,62 +384,56 @@ public async Task InvokeAsyncReturnsExpected(string scenario, HttpStatusCode? st }; using var loggerProvider = new CapturingLoggerProvider(); - WebHostBuilder builder = TestWebHostBuilderFactory.Create(); - builder.UseStartup(); - builder.ConfigureLogging(loggingBuilder => loggingBuilder.ClearProviders().AddProvider(loggerProvider).SetMinimumLevel(LogLevel.Trace)); - builder.ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(appSettings)); + WebApplicationBuilder builder = TestWebApplicationBuilderFactory.CreateDefault(false); + builder.Services.AddCloudFoundryActuator(); + builder.Services.AddHypermediaActuator(); + builder.Services.AddInfoActuator(); + builder.Logging.ClearProviders().AddProvider(loggerProvider).SetMinimumLevel(LogLevel.Trace); + builder.Configuration.AddInMemoryCollection(appSettings); - using IWebHost host = builder.Build(); + await using WebApplication host = builder.Build(); host.Services.GetRequiredService().Using(CloudControllerPermissionsMock.GetHttpMessageHandler()); await host.StartAsync(TestContext.Current.CancellationToken); - using HttpClient client = host.GetTestClient(); - var testAuthenticationRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication")); + using var client = new HttpClient(); + var testAuthenticationRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost:5000/cloudfoundryapplication")); testAuthenticationRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken); - var testAuthorizationRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication/info")); + var testAuthorizationRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost:5000/cloudfoundryapplication/info")); testAuthorizationRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken); - if (scenario == "timeout") + HttpResponseMessage response = await client.SendAsync(testAuthenticationRequestMessage, TestContext.Current.CancellationToken); + response.StatusCode.Should().Be(steeltoeStatusCode); + + if (errorMessage != null) { - await Assert.ThrowsAsync(() => client.SendAsync(testAuthenticationRequestMessage, TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync(() => client.SendAsync(testAuthorizationRequestMessage, TestContext.Current.CancellationToken)); + string jsonErrorValue = JsonValue.Create(errorMessage).ToJsonString(); + string errorText = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + errorText.Should().Be($$"""{"security_error":{{jsonErrorValue}}}"""); } else { - HttpResponseMessage response = await client.SendAsync(testAuthenticationRequestMessage, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(steeltoeStatusCode); - - if (errorMessage != null) - { - string jsonErrorValue = JsonValue.Create(errorMessage).ToJsonString(); - string errorText = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - errorText.Should().Be($$"""{"security_error":{{jsonErrorValue}}}"""); - } - else - { - string responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - responseBody.Should().BeJson(""" - { - "type":"steeltoe", - "_links":{ - "info":{ - "href":"http://localhost/cloudfoundryapplication/info", - "templated":false - }, - "self":{ - "href":"http://localhost/cloudfoundryapplication", - "templated":false - } + string responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + responseBody.Should().BeJson(""" + { + "type":"steeltoe", + "_links":{ + "info":{ + "href":"http://localhost:5000/cloudfoundryapplication/info", + "templated":false + }, + "self":{ + "href":"http://localhost:5000/cloudfoundryapplication", + "templated":false } } - """); - } - - HttpResponseMessage fullPermissionResponse = await client.SendAsync(testAuthorizationRequestMessage, TestContext.Current.CancellationToken); - fullPermissionResponse.StatusCode.Should().Be(scenario == "no_sensitive_data" ? HttpStatusCode.Forbidden : steeltoeStatusCode); + } + """); } + HttpResponseMessage fullPermissionResponse = await client.SendAsync(testAuthorizationRequestMessage, TestContext.Current.CancellationToken); + fullPermissionResponse.StatusCode.Should().Be(scenario == "no_sensitive_data" ? HttpStatusCode.Forbidden : steeltoeStatusCode); + if (!string.IsNullOrEmpty(expectedLogs)) { string logLines = loggerProvider.GetAsText(); diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs index a0e14a77df..af1a81271b 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs @@ -38,15 +38,17 @@ public async Task EmptyTokenIsUnauthorized() public async Task ParsePermissionsResponseAsyncReturnsExpected(bool readSensitive, bool readBasic, EndpointPermissions expectedPermissions) { PermissionsProvider permissionsProvider = GetPermissionsProvider(); - var response = new HttpResponseMessage(HttpStatusCode.OK); - response.Content = JsonContent.Create(new Dictionary + var cloudControllerResponse = new HttpResponseMessage(HttpStatusCode.OK) { - ["read_sensitive_data"] = readSensitive, - ["read_basic_data"] = readBasic - }); + Content = JsonContent.Create(new Dictionary + { + ["read_sensitive_data"] = readSensitive, + ["read_basic_data"] = readBasic + }) + }; - EndpointPermissions result = await permissionsProvider.ParsePermissionsResponseAsync(response, TestContext.Current.CancellationToken); + EndpointPermissions result = await permissionsProvider.ParsePermissionsResponseAsync(cloudControllerResponse, TestContext.Current.CancellationToken); result.Should().Be(expectedPermissions); } @@ -54,7 +56,7 @@ public async Task ParsePermissionsResponseAsyncReturnsExpected(bool readSensitiv [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken)] [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken)] [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied)] - [InlineData("timeout", HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudfoundryNotReachable)] + [InlineData("timeout", HttpStatusCode.InternalServerError, "The operation has timed out.")] [InlineData("exception", HttpStatusCode.InternalServerError, "Exception of type 'System.Net.Http.HttpRequestException' was thrown.")] [InlineData("no_sensitive_data", HttpStatusCode.OK, "")] [InlineData("success", HttpStatusCode.OK, "")] @@ -79,28 +81,21 @@ public async Task GetPermissionsAsyncReturnsExpected(string scenario, HttpStatus var permissionsProvider = new PermissionsProvider(optionsMonitor, httpClientFactory, NullLogger.Instance); - if (scenario == "timeout") - { - await Assert.ThrowsAsync(() => permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken)); - } - else - { - SecurityResult result = await permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken); - result.Code.Should().Be(steeltoeStatusCode); - result.Message.Should().Be(expectedMessage); + SecurityResult result = await permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken); + result.Code.Should().Be(steeltoeStatusCode); + result.Message.Should().Be(expectedMessage); - switch (scenario) - { - case "success": - result.Permissions.Should().Be(EndpointPermissions.Full); - break; - case "no_sensitive_data": - result.Permissions.Should().Be(EndpointPermissions.Restricted); - break; - default: - result.Permissions.Should().Be(EndpointPermissions.None); - break; - } + switch (scenario) + { + case "success": + result.Permissions.Should().Be(EndpointPermissions.Full); + break; + case "no_sensitive_data": + result.Permissions.Should().Be(EndpointPermissions.Restricted); + break; + default: + result.Permissions.Should().Be(EndpointPermissions.None); + break; } } From 505a278d0b6ee9ea55a828b9692b68b588950db7 Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Thu, 5 Jun 2025 14:33:43 -0500 Subject: [PATCH 09/15] Catch timeouts on permissions check - move integration scenarios to dedicated class - allow assert on presence of multiple log entries per scenario - use a mock on RedactsHttpHeaders now that exceptions aren't caught --- .../Common/Extensions/ExceptionExtensions.cs | 17 +++ .../CloudFoundrySecurityMiddleware.cs | 2 +- .../CloudFoundry/PermissionsProvider.cs | 13 +-- .../CloudControllerPermissionsMock.cs | 4 +- .../CloudFoundrySecurityMiddlewareTest.cs | 56 ++-------- ...dFoundrySecurityMiddlewareTestScenarios.cs | 102 ++++++++++++++++++ .../CloudFoundry/PermissionsProviderTest.cs | 45 +++++--- 7 files changed, 169 insertions(+), 70 deletions(-) create mode 100644 src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs diff --git a/src/Common/src/Common/Extensions/ExceptionExtensions.cs b/src/Common/src/Common/Extensions/ExceptionExtensions.cs index 903c556b27..8e6c55b665 100644 --- a/src/Common/src/Common/Extensions/ExceptionExtensions.cs +++ b/src/Common/src/Common/Extensions/ExceptionExtensions.cs @@ -59,4 +59,21 @@ public static bool IsCancellation(this Exception? exception) return false; } + + /// + /// Determines whether the thrown exception results from an HTTP request timeout. + /// + /// + /// The caught exception to inspect. + /// + public static bool IsTimeout(this Exception? exception) + { + // See note in remarks at https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.sendasync. + if (exception is OperationCanceledException && exception.InnerException?.GetType() == typeof(TimeoutException)) + { + return true; + } + + return false; + } } diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs index a23bfc5b96..0829b4715e 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs @@ -71,7 +71,7 @@ public async Task InvokeAsync(HttpContext context) if (string.IsNullOrEmpty(endpointOptions.Api)) { - await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudfoundryApiMissing)); + await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudFoundryApiMissing)); return; } diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs index cc66829e60..ae05d4981d 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs @@ -75,17 +75,17 @@ public async Task GetPermissionsAsync(string accessToken, Cancel return (int)response.StatusCode is > 399 and < 500 ? new SecurityResult(HttpStatusCode.Unauthorized, Messages.InvalidToken) - : new SecurityResult(HttpStatusCode.ServiceUnavailable, Messages.CloudfoundryNotReachable); + : new SecurityResult(HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryNotReachable); } EndpointPermissions permissions = await ParsePermissionsResponseAsync(response, cancellationToken); return new SecurityResult(permissions); } - catch (Exception exception) when (!exception.IsCancellation()) + catch (Exception exception) when (exception.IsTimeout()) { - _logger.LogError(exception, "Cloud Foundry returned exception while obtaining permissions from: {PermissionsUri}", checkPermissionsUri); + _logger.LogError(exception, "Cloud Foundry request timed out while obtaining permissions from: {PermissionsUri}", checkPermissionsUri); - return new SecurityResult(HttpStatusCode.InternalServerError, exception.Message); + return new SecurityResult(HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryTimeout); } } @@ -131,8 +131,9 @@ internal static class Messages public const string AccessDenied = "Access denied"; public const string ApplicationIdMissing = "Application ID is not available"; public const string AuthorizationHeaderInvalid = "Authorization header is missing or invalid"; - public const string CloudfoundryApiMissing = "Cloud controller URL is not available"; - public const string CloudfoundryNotReachable = "Cloud controller not reachable"; + public const string CloudFoundryApiMissing = "Cloud controller URL is not available"; + public const string CloudFoundryNotReachable = "Cloud controller not reachable"; + public const string CloudFoundryTimeout = "Cloud controller request timed out"; public const string InvalidToken = "Invalid token"; } } diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs index 72687a2f29..0c035f7098 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs @@ -26,7 +26,9 @@ internal static DelegateToMockHttpClientHandler GetHttpMessageHandler() httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/forbidden/permissions") .Respond(HttpStatusCode.Forbidden, "application/json", "{}"); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/timeout/permissions").Throw(new TimeoutException()); + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/timeout/permissions") + .Throw(new OperationCanceledException(null, new TimeoutException())); + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/exception/permissions").Throw(new HttpRequestException()); httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/no_sensitive_data/permissions").Respond(HttpStatusCode.OK, diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs index 9850626a48..b736f77a7f 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs @@ -29,26 +29,6 @@ namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry; public sealed class CloudFoundrySecurityMiddlewareTest : BaseTest { - private const string CFForbiddenLog = - "INFO Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry returned status: Forbidden while obtaining permissions from: https://example.api.com/v2/apps/forbidden/permissions"; - - private const string CFExceptionLog = - "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry returned exception while obtaining permissions from: https://example.api.com/v2/apps/exception/permissions"; - - private const string CFTimeoutLog = - "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry returned exception while obtaining permissions from: https://example.api.com/v2/apps/timeout/permissions"; - - private const string MiddlewareForbiddenLog = - "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: Forbidden - Access denied"; - - private const string MiddlewareUnauthorizedLog = - "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: Unauthorized - Invalid token"; - - private const string MiddlewareUnavailableLog = - "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: ServiceUnavailable - Cloud controller not reachable"; - - private const string CFExceptionMessage = "Exception of type 'System.Net.Http.HttpRequestException' was thrown."; - private static readonly string MockAccessToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwuY29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ." + Convert.ToBase64String("signature"u8.ToArray()); @@ -333,8 +313,8 @@ public async Task RedactsHttpHeaders() { var appSettings = new Dictionary { - ["vcap:application:application_id"] = "foobar", - ["vcap:application:cf_api"] = "http://domain-name-that-does-not-exist.com:9999/foo" + ["vcap:application:application_id"] = "success", + ["vcap:application:cf_api"] = "https://example.api.com" }; var capturingLoggerProvider = new CapturingLoggerProvider(category => category.StartsWith("System.Net.Http.HttpClient", StringComparison.Ordinal)); @@ -345,11 +325,12 @@ public async Task RedactsHttpHeaders() builder.Services.AddLogging(options => options.SetMinimumLevel(LogLevel.Trace).AddProvider(capturingLoggerProvider)); builder.Services.AddCloudFoundryActuator(); await using WebApplication app = builder.Build(); + app.Services.GetRequiredService().Using(CloudControllerPermissionsMock.GetHttpMessageHandler()); await app.StartAsync(TestContext.Current.CancellationToken); using HttpClient httpClient = app.GetTestClient(); var requestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost/cloudfoundryapplication")); - requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "some"); + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken); _ = await httpClient.SendAsync(requestMessage, TestContext.Current.CancellationToken); @@ -357,22 +338,9 @@ public async Task RedactsHttpHeaders() logMessages.Should().Contain("Authorization: *"); } - [InlineData("unavailable", HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudfoundryNotReachable, MiddlewareUnavailableLog, true)] - [InlineData("unavailable", HttpStatusCode.OK, PermissionsProvider.Messages.CloudfoundryNotReachable, MiddlewareUnavailableLog, false)] - [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken, MiddlewareUnauthorizedLog, true)] - [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken, MiddlewareUnauthorizedLog, false)] - [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken, MiddlewareUnauthorizedLog, true)] - [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken, MiddlewareUnauthorizedLog, false)] - [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied, CFForbiddenLog, true)] - [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied, CFForbiddenLog, false)] - [InlineData("timeout", HttpStatusCode.InternalServerError, "The operation has timed out.", CFTimeoutLog, true)] - [InlineData("timeout", HttpStatusCode.OK, "The operation has timed out.", CFTimeoutLog, false)] - [InlineData("exception", HttpStatusCode.InternalServerError, CFExceptionMessage, CFExceptionLog, true)] - [InlineData("exception", HttpStatusCode.OK, CFExceptionMessage, CFExceptionLog, false)] - [InlineData("no_sensitive_data", HttpStatusCode.OK, null, MiddlewareForbiddenLog, true)] - [InlineData("success", HttpStatusCode.OK, null, null, true)] + [ClassData(typeof(CloudFoundrySecurityMiddlewareTestScenarios))] [Theory] - public async Task IntegrationTestReturnsExpected(string scenario, HttpStatusCode? steeltoeStatusCode, string? errorMessage, string? expectedLogs, + public async Task IntegrationTestReturnsExpected(string scenario, HttpStatusCode? steeltoeStatusCode, string? errorMessage, string[] expectedLogs, bool useStatusCodeFromResponse) { var appSettings = new Dictionary @@ -385,10 +353,10 @@ public async Task IntegrationTestReturnsExpected(string scenario, HttpStatusCode using var loggerProvider = new CapturingLoggerProvider(); WebApplicationBuilder builder = TestWebApplicationBuilderFactory.CreateDefault(false); + builder.Logging.ClearProviders().AddProvider(loggerProvider); builder.Services.AddCloudFoundryActuator(); builder.Services.AddHypermediaActuator(); builder.Services.AddInfoActuator(); - builder.Logging.ClearProviders().AddProvider(loggerProvider).SetMinimumLevel(LogLevel.Trace); builder.Configuration.AddInMemoryCollection(appSettings); await using WebApplication host = builder.Build(); @@ -408,7 +376,8 @@ public async Task IntegrationTestReturnsExpected(string scenario, HttpStatusCode { string jsonErrorValue = JsonValue.Create(errorMessage).ToJsonString(); string errorText = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - errorText.Should().Be($$"""{"security_error":{{jsonErrorValue}}}"""); + + errorText.Should().Be(steeltoeStatusCode == HttpStatusCode.InternalServerError ? errorMessage : $$"""{"security_error":{{jsonErrorValue}}}"""); } else { @@ -434,11 +403,8 @@ public async Task IntegrationTestReturnsExpected(string scenario, HttpStatusCode HttpResponseMessage fullPermissionResponse = await client.SendAsync(testAuthorizationRequestMessage, TestContext.Current.CancellationToken); fullPermissionResponse.StatusCode.Should().Be(scenario == "no_sensitive_data" ? HttpStatusCode.Forbidden : steeltoeStatusCode); - if (!string.IsNullOrEmpty(expectedLogs)) - { - string logLines = loggerProvider.GetAsText(); - logLines.Should().Contain(expectedLogs); - } + string logLines = loggerProvider.GetAsText(); + logLines.Should().ContainAll(expectedLogs); } protected override void Dispose(bool disposing) diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs new file mode 100644 index 0000000000..e0dda1f83c --- /dev/null +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Net; +using Messages = Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider.Messages; + +namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry; + +internal sealed class CloudFoundrySecurityMiddlewareTestScenarios : TheoryData +{ + private const string CFForbiddenLog = + "INFO Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry returned status: Forbidden while obtaining permissions from: https://example.api.com/v2/apps/forbidden/permissions"; + + private const string CFExceptionLogStart = "FAIL Microsoft.AspNetCore.Server.Kestrel: Connection id"; + private const string CFExceptionLogEnd = ": An unhandled exception was thrown by the application."; + + private const string CFUnauthorizedLog = + "INFO Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry returned status: Unauthorized while obtaining permissions from: https://example.api.com/v2/apps/unauthorized/permissions"; + + private const string CFNotFoundLog = + "INFO Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry returned status: NotFound while obtaining permissions from: https://example.api.com/v2/apps/not-found/permissions"; + + private const string CFTimeoutLog = + "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry request timed out while obtaining permissions from: https://example.api.com/v2/apps/timeout/permissions"; + + private const string MiddlewareForbiddenLog = + $"FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: Forbidden - {Messages.AccessDenied}"; + + private const string MiddlewareTimeoutLog = + $"FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: ServiceUnavailable - {Messages.CloudFoundryTimeout}"; + + private const string MiddlewareUnauthorizedLog = + $"FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: Unauthorized - {Messages.InvalidToken}"; + + private const string MiddlewareUnavailableLog = + $"FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: ServiceUnavailable - {Messages.CloudFoundryNotReachable}"; + + private const string SuccessLog = + "INFO System.Net.Http.HttpClient.CloudFoundrySecurity.ClientHandler: Sending HTTP request GET https://example.api.com/v2/apps/success/permissions"; + + public CloudFoundrySecurityMiddlewareTestScenarios() + { + Add("exception", HttpStatusCode.InternalServerError, string.Empty, [ + CFExceptionLogStart, + CFExceptionLogEnd + ], true); + + Add("exception", HttpStatusCode.InternalServerError, string.Empty, [ + CFExceptionLogStart, + CFExceptionLogEnd + ], false); + + Add("forbidden", HttpStatusCode.Forbidden, Messages.AccessDenied, [ + CFForbiddenLog, + MiddlewareForbiddenLog + ], true); + + Add("forbidden", HttpStatusCode.Forbidden, Messages.AccessDenied, [ + CFForbiddenLog, + MiddlewareForbiddenLog + ], false); + + Add("no_sensitive_data", HttpStatusCode.OK, null, [MiddlewareForbiddenLog], true); + + Add("not-found", HttpStatusCode.Unauthorized, Messages.InvalidToken, [ + CFNotFoundLog, + MiddlewareUnauthorizedLog + ], true); + + Add("not-found", HttpStatusCode.Unauthorized, Messages.InvalidToken, [ + CFNotFoundLog, + MiddlewareUnauthorizedLog + ], false); + + Add("success", HttpStatusCode.OK, null, [SuccessLog], true); + + Add("timeout", HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryTimeout, [ + CFTimeoutLog, + MiddlewareTimeoutLog + ], true); + + Add("timeout", HttpStatusCode.OK, Messages.CloudFoundryTimeout, [ + CFTimeoutLog, + MiddlewareTimeoutLog + ], false); + + Add("unauthorized", HttpStatusCode.Unauthorized, Messages.InvalidToken, [ + CFUnauthorizedLog, + MiddlewareUnauthorizedLog + ], true); + + Add("unauthorized", HttpStatusCode.Unauthorized, Messages.InvalidToken, [ + CFUnauthorizedLog, + MiddlewareUnauthorizedLog + ], false); + + Add("unavailable", HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryNotReachable, [MiddlewareUnavailableLog], true); + + Add("unavailable", HttpStatusCode.OK, Messages.CloudFoundryNotReachable, [MiddlewareUnavailableLog], false); + } +} diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs index af1a81271b..5dd8e80057 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs @@ -52,16 +52,16 @@ public async Task ParsePermissionsResponseAsyncReturnsExpected(bool readSensitiv result.Should().Be(expectedPermissions); } - [InlineData("unavailable", HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudfoundryNotReachable)] + [InlineData("unavailable", HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudFoundryNotReachable)] [InlineData("not-found", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken)] [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken)] [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied)] - [InlineData("timeout", HttpStatusCode.InternalServerError, "The operation has timed out.")] - [InlineData("exception", HttpStatusCode.InternalServerError, "Exception of type 'System.Net.Http.HttpRequestException' was thrown.")] + [InlineData("timeout", HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudFoundryTimeout)] + [InlineData("exception", null, "Exception of type 'System.Net.Http.HttpRequestException' was thrown.")] [InlineData("no_sensitive_data", HttpStatusCode.OK, "")] [InlineData("success", HttpStatusCode.OK, "")] [Theory] - public async Task GetPermissionsAsyncReturnsExpected(string scenario, HttpStatusCode steeltoeStatusCode, string expectedMessage) + public async Task GetPermissionsAsyncReturnsExpected(string scenario, HttpStatusCode? steeltoeStatusCode, string expectedMessage) { var appSettings = new Dictionary { @@ -81,21 +81,32 @@ public async Task GetPermissionsAsyncReturnsExpected(string scenario, HttpStatus var permissionsProvider = new PermissionsProvider(optionsMonitor, httpClientFactory, NullLogger.Instance); - SecurityResult result = await permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken); - result.Code.Should().Be(steeltoeStatusCode); - result.Message.Should().Be(expectedMessage); + if (scenario == "exception") + { + var exception = await Assert.ThrowsAsync(() => + permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken)); - switch (scenario) + exception.StatusCode.Should().Be(steeltoeStatusCode); + exception.Message.Should().Be(expectedMessage); + } + else { - case "success": - result.Permissions.Should().Be(EndpointPermissions.Full); - break; - case "no_sensitive_data": - result.Permissions.Should().Be(EndpointPermissions.Restricted); - break; - default: - result.Permissions.Should().Be(EndpointPermissions.None); - break; + SecurityResult result = await permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken); + result.Code.Should().Be(steeltoeStatusCode); + result.Message.Should().Be(expectedMessage); + + switch (scenario) + { + case "success": + result.Permissions.Should().Be(EndpointPermissions.Full); + break; + case "no_sensitive_data": + result.Permissions.Should().Be(EndpointPermissions.Restricted); + break; + default: + result.Permissions.Should().Be(EndpointPermissions.None); + break; + } } } From df9b462cb76c33294b624ae0cc0e28da201e1464 Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Fri, 6 Jun 2025 09:42:12 -0500 Subject: [PATCH 10/15] naming things, use typeof for logs, comments --- .../Common/Extensions/ExceptionExtensions.cs | 2 +- .../CloudFoundrySecurityMiddleware.cs | 4 +- .../CloudFoundry/PermissionsProvider.cs | 4 +- .../CloudFoundrySecurityMiddlewareTest.cs | 2 +- ...dFoundrySecurityMiddlewareTestScenarios.cs | 88 +++++++++---------- .../CloudFoundry/PermissionsProviderTest.cs | 6 +- 6 files changed, 54 insertions(+), 52 deletions(-) diff --git a/src/Common/src/Common/Extensions/ExceptionExtensions.cs b/src/Common/src/Common/Extensions/ExceptionExtensions.cs index 8e6c55b665..9d3cf1919d 100644 --- a/src/Common/src/Common/Extensions/ExceptionExtensions.cs +++ b/src/Common/src/Common/Extensions/ExceptionExtensions.cs @@ -66,7 +66,7 @@ public static bool IsCancellation(this Exception? exception) /// /// The caught exception to inspect. /// - public static bool IsTimeout(this Exception? exception) + public static bool IsHttpClientTimeout(this Exception? exception) { // See note in remarks at https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient.sendasync. if (exception is OperationCanceledException && exception.InnerException?.GetType() == typeof(TimeoutException)) diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs index 0829b4715e..ecf259c2d7 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs @@ -164,7 +164,9 @@ private async Task ReturnErrorAsync(HttpContext context, SecurityResult error) _logger.LogError("Actuator Security Error: {Code} - {Message}", error.Code, error.Message); context.Response.Headers.Append("Content-Type", "application/json;charset=UTF-8"); - // allowing override of 400-level errors is more likely to cause confusion than to be useful + // UseStatusCodeFromResponse was added to prevent IIS/HWC from blocking the response body on 500-level errors. + // Blocking 400-level error responses would be more likely to cause confusion than to be useful. + // See https://github.com/SteeltoeOSS/Steeltoe/issues/418 for more information. if (_managementOptionsMonitor.CurrentValue.UseStatusCodeFromResponse || (int)error.Code < 500) { context.Response.StatusCode = (int)error.Code; diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs index ae05d4981d..fa4f05ac13 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs @@ -81,9 +81,9 @@ public async Task GetPermissionsAsync(string accessToken, Cancel EndpointPermissions permissions = await ParsePermissionsResponseAsync(response, cancellationToken); return new SecurityResult(permissions); } - catch (Exception exception) when (exception.IsTimeout()) + catch (Exception exception) when (exception.IsHttpClientTimeout()) { - _logger.LogError(exception, "Cloud Foundry request timed out while obtaining permissions from: {PermissionsUri}", checkPermissionsUri); + _logger.LogWarning(exception, "Cloud Foundry request timed out while obtaining permissions from: {PermissionsUri}", checkPermissionsUri); return new SecurityResult(HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryTimeout); } diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs index b736f77a7f..1a4610dff3 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs @@ -340,7 +340,7 @@ public async Task RedactsHttpHeaders() [ClassData(typeof(CloudFoundrySecurityMiddlewareTestScenarios))] [Theory] - public async Task IntegrationTestReturnsExpected(string scenario, HttpStatusCode? steeltoeStatusCode, string? errorMessage, string[] expectedLogs, + public async Task Returns_expected_response_on_permission_check(string scenario, HttpStatusCode? steeltoeStatusCode, string? errorMessage, string[] expectedLogs, bool useStatusCodeFromResponse) { var appSettings = new Dictionary diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs index e0dda1f83c..37e7aa2b2d 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs @@ -3,100 +3,100 @@ // See the LICENSE file in the project root for more information. using System.Net; +using Steeltoe.Management.Endpoint.Actuators.CloudFoundry; using Messages = Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider.Messages; namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry; internal sealed class CloudFoundrySecurityMiddlewareTestScenarios : TheoryData { - private const string CFForbiddenLog = - "INFO Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry returned status: Forbidden while obtaining permissions from: https://example.api.com/v2/apps/forbidden/permissions"; + private const string ExceptionLogStart = "FAIL Microsoft.AspNetCore.Server.Kestrel: Connection id"; + private const string ExceptionLogEnd = ": An unhandled exception was thrown by the application."; - private const string CFExceptionLogStart = "FAIL Microsoft.AspNetCore.Server.Kestrel: Connection id"; - private const string CFExceptionLogEnd = ": An unhandled exception was thrown by the application."; - - private const string CFUnauthorizedLog = - "INFO Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry returned status: Unauthorized while obtaining permissions from: https://example.api.com/v2/apps/unauthorized/permissions"; + private const string SuccessLog = + "INFO System.Net.Http.HttpClient.CloudFoundrySecurity.ClientHandler: Sending HTTP request GET https://example.api.com/v2/apps/success/permissions"; - private const string CFNotFoundLog = - "INFO Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry returned status: NotFound while obtaining permissions from: https://example.api.com/v2/apps/not-found/permissions"; + private readonly string _permissionsCheckForbiddenLog = + $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status: Forbidden while obtaining permissions from: https://example.api.com/v2/apps/forbidden/permissions"; - private const string CFTimeoutLog = - "FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.PermissionsProvider: Cloud Foundry request timed out while obtaining permissions from: https://example.api.com/v2/apps/timeout/permissions"; + private readonly string _permissionsCheckUnauthorizedLog = + $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status: Unauthorized while obtaining permissions from: https://example.api.com/v2/apps/unauthorized/permissions"; - private const string MiddlewareForbiddenLog = - $"FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: Forbidden - {Messages.AccessDenied}"; + private readonly string _permissionsCheckNotFoundLog = + $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status: NotFound while obtaining permissions from: https://example.api.com/v2/apps/not-found/permissions"; - private const string MiddlewareTimeoutLog = - $"FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: ServiceUnavailable - {Messages.CloudFoundryTimeout}"; + private readonly string _permissionsCheckTimeoutLog = + $"WARN {typeof(PermissionsProvider)}: Cloud Foundry request timed out while obtaining permissions from: https://example.api.com/v2/apps/timeout/permissions"; + private readonly string _middlewareForbiddenLog = + $"FAIL {typeof(CloudFoundrySecurityMiddleware)}: Actuator Security Error: Forbidden - {Messages.AccessDenied}"; - private const string MiddlewareUnauthorizedLog = - $"FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: Unauthorized - {Messages.InvalidToken}"; + private readonly string _middlewareTimeoutLog = + $"FAIL {typeof(CloudFoundrySecurityMiddleware)}: Actuator Security Error: ServiceUnavailable - {Messages.CloudFoundryTimeout}"; - private const string MiddlewareUnavailableLog = - $"FAIL Steeltoe.Management.Endpoint.Actuators.CloudFoundry.CloudFoundrySecurityMiddleware: Actuator Security Error: ServiceUnavailable - {Messages.CloudFoundryNotReachable}"; + private readonly string _middlewareUnauthorizedLog = + $"FAIL {typeof(CloudFoundrySecurityMiddleware)}: Actuator Security Error: Unauthorized - {Messages.InvalidToken}"; - private const string SuccessLog = - "INFO System.Net.Http.HttpClient.CloudFoundrySecurity.ClientHandler: Sending HTTP request GET https://example.api.com/v2/apps/success/permissions"; + private readonly string _middlewareUnavailableLog = + $"FAIL {typeof(CloudFoundrySecurityMiddleware)}: Actuator Security Error: ServiceUnavailable - {Messages.CloudFoundryNotReachable}"; public CloudFoundrySecurityMiddlewareTestScenarios() { Add("exception", HttpStatusCode.InternalServerError, string.Empty, [ - CFExceptionLogStart, - CFExceptionLogEnd + ExceptionLogStart, + ExceptionLogEnd ], true); Add("exception", HttpStatusCode.InternalServerError, string.Empty, [ - CFExceptionLogStart, - CFExceptionLogEnd + ExceptionLogStart, + ExceptionLogEnd ], false); Add("forbidden", HttpStatusCode.Forbidden, Messages.AccessDenied, [ - CFForbiddenLog, - MiddlewareForbiddenLog + _permissionsCheckForbiddenLog, + _middlewareForbiddenLog ], true); Add("forbidden", HttpStatusCode.Forbidden, Messages.AccessDenied, [ - CFForbiddenLog, - MiddlewareForbiddenLog + _permissionsCheckForbiddenLog, + _middlewareForbiddenLog ], false); - Add("no_sensitive_data", HttpStatusCode.OK, null, [MiddlewareForbiddenLog], true); + Add("no_sensitive_data", HttpStatusCode.OK, null, [_middlewareForbiddenLog], true); Add("not-found", HttpStatusCode.Unauthorized, Messages.InvalidToken, [ - CFNotFoundLog, - MiddlewareUnauthorizedLog + _permissionsCheckNotFoundLog, + _middlewareUnauthorizedLog ], true); Add("not-found", HttpStatusCode.Unauthorized, Messages.InvalidToken, [ - CFNotFoundLog, - MiddlewareUnauthorizedLog + _permissionsCheckNotFoundLog, + _middlewareUnauthorizedLog ], false); Add("success", HttpStatusCode.OK, null, [SuccessLog], true); Add("timeout", HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryTimeout, [ - CFTimeoutLog, - MiddlewareTimeoutLog + _permissionsCheckTimeoutLog, + _middlewareTimeoutLog ], true); Add("timeout", HttpStatusCode.OK, Messages.CloudFoundryTimeout, [ - CFTimeoutLog, - MiddlewareTimeoutLog + _permissionsCheckTimeoutLog, + _middlewareTimeoutLog ], false); Add("unauthorized", HttpStatusCode.Unauthorized, Messages.InvalidToken, [ - CFUnauthorizedLog, - MiddlewareUnauthorizedLog + _permissionsCheckUnauthorizedLog, + _middlewareUnauthorizedLog ], true); Add("unauthorized", HttpStatusCode.Unauthorized, Messages.InvalidToken, [ - CFUnauthorizedLog, - MiddlewareUnauthorizedLog + _permissionsCheckUnauthorizedLog, + _middlewareUnauthorizedLog ], false); - Add("unavailable", HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryNotReachable, [MiddlewareUnavailableLog], true); + Add("unavailable", HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryNotReachable, [_middlewareUnavailableLog], true); - Add("unavailable", HttpStatusCode.OK, Messages.CloudFoundryNotReachable, [MiddlewareUnavailableLog], false); + Add("unavailable", HttpStatusCode.OK, Messages.CloudFoundryNotReachable, [_middlewareUnavailableLog], false); } } diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs index 5dd8e80057..2ae143f9c0 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs @@ -61,7 +61,7 @@ public async Task ParsePermissionsResponseAsyncReturnsExpected(bool readSensitiv [InlineData("no_sensitive_data", HttpStatusCode.OK, "")] [InlineData("success", HttpStatusCode.OK, "")] [Theory] - public async Task GetPermissionsAsyncReturnsExpected(string scenario, HttpStatusCode? steeltoeStatusCode, string expectedMessage) + public async Task Returns_expected_response_on_permission_check(string scenario, HttpStatusCode? steeltoeStatusCode, string errorMessage) { var appSettings = new Dictionary { @@ -87,13 +87,13 @@ public async Task GetPermissionsAsyncReturnsExpected(string scenario, HttpStatus permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken)); exception.StatusCode.Should().Be(steeltoeStatusCode); - exception.Message.Should().Be(expectedMessage); + exception.Message.Should().Be(errorMessage); } else { SecurityResult result = await permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken); result.Code.Should().Be(steeltoeStatusCode); - result.Message.Should().Be(expectedMessage); + result.Message.Should().Be(errorMessage); switch (scenario) { From 93b330b9fd1135561dfa1e037aa34aabd1343be8 Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Fri, 6 Jun 2025 09:52:59 -0500 Subject: [PATCH 11/15] remove inner log, formatting --- .../Actuators/CloudFoundry/PermissionsProvider.cs | 2 -- .../CloudFoundrySecurityMiddlewareTest.cs | 4 ++-- .../CloudFoundrySecurityMiddlewareTestScenarios.cs | 12 ++---------- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs index fa4f05ac13..bb6261a259 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs @@ -83,8 +83,6 @@ public async Task GetPermissionsAsync(string accessToken, Cancel } catch (Exception exception) when (exception.IsHttpClientTimeout()) { - _logger.LogWarning(exception, "Cloud Foundry request timed out while obtaining permissions from: {PermissionsUri}", checkPermissionsUri); - return new SecurityResult(HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryTimeout); } } diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs index 1a4610dff3..8cfc505da0 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs @@ -340,8 +340,8 @@ public async Task RedactsHttpHeaders() [ClassData(typeof(CloudFoundrySecurityMiddlewareTestScenarios))] [Theory] - public async Task Returns_expected_response_on_permission_check(string scenario, HttpStatusCode? steeltoeStatusCode, string? errorMessage, string[] expectedLogs, - bool useStatusCodeFromResponse) + public async Task Returns_expected_response_on_permission_check(string scenario, HttpStatusCode? steeltoeStatusCode, string? errorMessage, + string[] expectedLogs, bool useStatusCodeFromResponse) { var appSettings = new Dictionary { diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs index 37e7aa2b2d..2400786296 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs @@ -25,8 +25,6 @@ internal sealed class CloudFoundrySecurityMiddlewareTestScenarios : TheoryData Date: Fri, 6 Jun 2025 10:37:09 -0500 Subject: [PATCH 12/15] catch HttpRequestException --- .../CloudFoundry/PermissionsProvider.cs | 5 +++ .../CloudFoundry/PermissionsProviderTest.cs | 39 +++++++------------ 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs index bb6261a259..7b9d77c571 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs @@ -81,6 +81,11 @@ public async Task GetPermissionsAsync(string accessToken, Cancel EndpointPermissions permissions = await ParsePermissionsResponseAsync(response, cancellationToken); return new SecurityResult(permissions); } + catch (HttpRequestException exception) + { + return new SecurityResult(HttpStatusCode.ServiceUnavailable, + $"Exception of type '{typeof(HttpRequestException)}' with error '{exception.HttpRequestError}' was thrown"); + } catch (Exception exception) when (exception.IsHttpClientTimeout()) { return new SecurityResult(HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryTimeout); diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs index 2ae143f9c0..64af08f578 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs @@ -57,7 +57,7 @@ public async Task ParsePermissionsResponseAsyncReturnsExpected(bool readSensitiv [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken)] [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied)] [InlineData("timeout", HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudFoundryTimeout)] - [InlineData("exception", null, "Exception of type 'System.Net.Http.HttpRequestException' was thrown.")] + [InlineData("exception", HttpStatusCode.ServiceUnavailable, "Exception of type 'System.Net.Http.HttpRequestException' with error 'Unknown' was thrown")] [InlineData("no_sensitive_data", HttpStatusCode.OK, "")] [InlineData("success", HttpStatusCode.OK, "")] [Theory] @@ -81,32 +81,21 @@ public async Task Returns_expected_response_on_permission_check(string scenario, var permissionsProvider = new PermissionsProvider(optionsMonitor, httpClientFactory, NullLogger.Instance); - if (scenario == "exception") - { - var exception = await Assert.ThrowsAsync(() => - permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken)); + SecurityResult result = await permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken); + result.Code.Should().Be(steeltoeStatusCode); + result.Message.Should().Be(errorMessage); - exception.StatusCode.Should().Be(steeltoeStatusCode); - exception.Message.Should().Be(errorMessage); - } - else + switch (scenario) { - SecurityResult result = await permissionsProvider.GetPermissionsAsync("testToken", TestContext.Current.CancellationToken); - result.Code.Should().Be(steeltoeStatusCode); - result.Message.Should().Be(errorMessage); - - switch (scenario) - { - case "success": - result.Permissions.Should().Be(EndpointPermissions.Full); - break; - case "no_sensitive_data": - result.Permissions.Should().Be(EndpointPermissions.Restricted); - break; - default: - result.Permissions.Should().Be(EndpointPermissions.None); - break; - } + case "success": + result.Permissions.Should().Be(EndpointPermissions.Full); + break; + case "no_sensitive_data": + result.Permissions.Should().Be(EndpointPermissions.Restricted); + break; + default: + result.Permissions.Should().Be(EndpointPermissions.None); + break; } } From 121e5127a9649105452f9c474c74ee1d9912829f Mon Sep 17 00:00:00 2001 From: Tim Hess Date: Fri, 6 Jun 2025 10:54:41 -0500 Subject: [PATCH 13/15] update middleware test expectations --- ...udFoundrySecurityMiddlewareTestScenarios.cs | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs index 2400786296..37d15d47f7 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs @@ -10,9 +10,6 @@ namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry; internal sealed class CloudFoundrySecurityMiddlewareTestScenarios : TheoryData { - private const string ExceptionLogStart = "FAIL Microsoft.AspNetCore.Server.Kestrel: Connection id"; - private const string ExceptionLogEnd = ": An unhandled exception was thrown by the application."; - private const string SuccessLog = "INFO System.Net.Http.HttpClient.CloudFoundrySecurity.ClientHandler: Sending HTTP request GET https://example.api.com/v2/apps/success/permissions"; @@ -28,6 +25,9 @@ internal sealed class CloudFoundrySecurityMiddlewareTestScenarios : TheoryData Date: Wed, 11 Jun 2025 08:39:48 -0500 Subject: [PATCH 14/15] adjust a log level, more detail in test exception --- .../CloudFoundry/CloudFoundrySecurityMiddleware.cs | 2 +- .../CloudFoundry/CloudControllerPermissionsMock.cs | 3 ++- .../CloudFoundrySecurityMiddlewareTestScenarios.cs | 8 ++++---- .../Actuators/CloudFoundry/PermissionsProviderTest.cs | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs index ecf259c2d7..440f5de94c 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs @@ -62,7 +62,7 @@ public async Task InvokeAsync(HttpContext context) { if (string.IsNullOrEmpty(endpointOptions.ApplicationId)) { - _logger.LogCritical( + _logger.LogError( "The Application Id could not be found. Make sure the Cloud Foundry Configuration Provider has been added to the application configuration."); await ReturnErrorAsync(context, new SecurityResult(HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.ApplicationIdMissing)); diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs index 0c035f7098..5d581d2d1b 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs @@ -29,7 +29,8 @@ internal static DelegateToMockHttpClientHandler GetHttpMessageHandler() httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/timeout/permissions") .Throw(new OperationCanceledException(null, new TimeoutException())); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/exception/permissions").Throw(new HttpRequestException()); + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/exception/permissions") + .Throw(new HttpRequestException(HttpRequestError.NameResolutionError)); httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/no_sensitive_data/permissions").Respond(HttpStatusCode.OK, "application/json", """{"read_sensitive_data": false, "read_basic_data": true}"""); diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs index 37d15d47f7..133ee605a7 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs @@ -26,7 +26,7 @@ internal sealed class CloudFoundrySecurityMiddlewareTestScenarios : TheoryData Date: Wed, 11 Jun 2025 09:17:43 -0500 Subject: [PATCH 15/15] format --- .../Actuators/CloudFoundry/PermissionsProviderTest.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs index 879ce65429..6589f52072 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/PermissionsProviderTest.cs @@ -57,7 +57,8 @@ public async Task ParsePermissionsResponseAsyncReturnsExpected(bool readSensitiv [InlineData("unauthorized", HttpStatusCode.Unauthorized, PermissionsProvider.Messages.InvalidToken)] [InlineData("forbidden", HttpStatusCode.Forbidden, PermissionsProvider.Messages.AccessDenied)] [InlineData("timeout", HttpStatusCode.ServiceUnavailable, PermissionsProvider.Messages.CloudFoundryTimeout)] - [InlineData("exception", HttpStatusCode.ServiceUnavailable, "Exception of type 'System.Net.Http.HttpRequestException' with error 'NameResolutionError' was thrown")] + [InlineData("exception", HttpStatusCode.ServiceUnavailable, + "Exception of type 'System.Net.Http.HttpRequestException' with error 'NameResolutionError' was thrown")] [InlineData("no_sensitive_data", HttpStatusCode.OK, "")] [InlineData("success", HttpStatusCode.OK, "")] [Theory]