Adds tests for yarp transformers and makes the request config configurable. (#2071)

* integration test for yarp transform

* introduce tests for timeouts
This commit is contained in:
Erwin van der Valk 2025-06-20 13:27:37 +02:00 committed by GitHub
parent 6cd370ebc6
commit 346cb75d43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 178 additions and 73 deletions

View file

@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Yarp.ReverseProxy.Forwarder;
using Yarp.ReverseProxy.Transforms.Builder;
namespace Duende.Bff.Yarp;
@ -18,19 +19,31 @@ public static class RouteBuilderExtensions
/// <summary>
/// Adds a remote BFF API endpoint
/// </summary>
/// <param name="endpoints"></param>
/// <param name="localPath"></param>
/// <param name="apiAddress"></param>
/// <param name="yarpTransformBuilder"></param>
/// <returns></returns>
/// <param name="endpoints">The endpoint route builder to add the endpoint to.</param>
/// <param name="localPath">The local path pattern for the BFF API endpoint.</param>
/// <param name="apiAddress">The remote API address to which requests will be forwarded.</param>
/// <param name="yarpTransformBuilder">
/// Optional. An action to configure YARP transforms for this proxy request.
/// If not provided, a default transform builder is used.
/// </param>
/// <param name="requestConfig">
/// Optional. Additional configuration for the forwarded request, such as timeouts or activity propagation.
/// If not specified, the default yarp configuration is used.
/// </param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for further configuration of the endpoint.</returns>
public static IEndpointConventionBuilder MapRemoteBffApiEndpoint(
this IEndpointRouteBuilder endpoints,
PathString localPath,
Uri apiAddress,
Action<TransformBuilderContext>? yarpTransformBuilder = null)
Action<TransformBuilderContext>? yarpTransformBuilder = null,
ForwarderRequestConfig? requestConfig = null
)
{
endpoints.CheckLicense();
// See if a default request config is registered in DI, otherwise use an empty one
requestConfig ??= endpoints.ServiceProvider.GetService<ForwarderRequestConfig>() ?? ForwarderRequestConfig.Empty;
// Configure the yarp transform pipeline. Either use the one provided or the default
yarpTransformBuilder ??= context =>
{
@ -45,7 +58,8 @@ public static class RouteBuilderExtensions
// Try to resolve the ITransformBuilder from DI. If it is not registered,
// throw a clearer exception. Otherwise, the call below fails with a less clear exception.
var _ = endpoints.ServiceProvider.GetService<ITransformBuilder>() ?? throw new InvalidOperationException("No ITransformBuilder has been registered. Have you called BffBuilder.AddRemoteApis()");
_ = endpoints.ServiceProvider.GetService<ITransformBuilder>()
?? throw new InvalidOperationException("No ITransformBuilder has been registered. Have you called BffBuilder.AddRemoteApis()");
return endpoints.MapForwarder(
pattern: localPath.Add("/{**catch-all}").Value!,
@ -53,7 +67,8 @@ public static class RouteBuilderExtensions
configureTransform: context =>
{
yarpTransformBuilder(context);
})
},
requestConfig: requestConfig)
.WithMetadata(new BffRemoteApiEndpointMetadata());
}

View file

@ -52,7 +52,7 @@ public class BffFrontendIndexTests : BffTestBase
await Bff.BrowserClient.Login()
.CheckResponseContent(Cdn.IndexHtml);
var result = await Bff.BrowserClient.CallBffHostApi(The.SubPath);
var result = await Bff.BrowserClient.CallBffHostApi(The.PathAndSubPath);
}
[Fact]
public async Task Given_index_can_call_local_api()

View file

@ -38,7 +38,7 @@ public class BffRemoteApiTests : BffTestBase
await Bff.BrowserClient.Login();
ApiCallDetails result = await Bff.BrowserClient.CallBffHostApi(The.SubPath);
ApiCallDetails result = await Bff.BrowserClient.CallBffHostApi(The.PathAndSubPath);
result.Sub.ShouldBe(The.Sub);
}
@ -60,7 +60,7 @@ public class BffRemoteApiTests : BffTestBase
})
);
ApiCallDetails result = await Bff.BrowserClient.CallBffHostApi(The.SubPath);
ApiCallDetails result = await Bff.BrowserClient.CallBffHostApi(The.PathAndSubPath);
result.Sub.ShouldBeNull();
if (requiredTokenType == RequiredTokenType.UserOrClient || requiredTokenType == RequiredTokenType.Client)
@ -90,7 +90,7 @@ public class BffRemoteApiTests : BffTestBase
})
);
await Bff.BrowserClient.CallBffHostApi(The.SubPath,
await Bff.BrowserClient.CallBffHostApi(The.PathAndSubPath,
expectedStatusCode: HttpStatusCode.Unauthorized);

View file

@ -62,7 +62,7 @@ public class DPoPTestsWithManualAuthentication : BffTestBase, IAsyncLifetime
{
ApiCallDetails callToApi = await Bff.BrowserClient.CallBffHostApi(
url: Bff.Url(The.SubPath)
url: Bff.Url(The.PathAndSubPath)
);
callToApi.RequestHeaders["DPoP"].First().ShouldNotBeNullOrEmpty();

View file

@ -45,7 +45,7 @@ public class DpopRemoteEndpointTests : BffTestBase, IAsyncLifetime
{
ApiCallDetails callToApi = await Bff.BrowserClient.CallBffHostApi(
url: Bff.Url(The.SubPath)
url: Bff.Url(The.PathAndSubPath)
);
callToApi.RequestHeaders["DPoP"].First().ShouldNotBeNullOrEmpty();

View file

@ -3,7 +3,6 @@
using System.Net;
using System.Security.Claims;
using System.Text.Json;
using Duende.Bff.AccessTokenManagement;
using Duende.Bff.Configuration;
using Duende.Bff.DynamicFrontends;
@ -16,6 +15,7 @@ using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Xunit.Abstractions;
using Yarp.ReverseProxy.Forwarder;
using Yarp.ReverseProxy.Transforms;
using Yarp.ReverseProxy.Transforms.Builder;
using Resource = Duende.Bff.AccessTokenManagement.Resource;
@ -611,57 +611,146 @@ public class RemoteEndpointTests : BffTestBase
}
[Theory, MemberData(nameof(AllSetups))]
public async Task endpoint_can_be_configured_with_custom_transform(BffSetupType setup)
public async Task MapRemoteBffApiEndpoint_can_override_default_transform(BffSetupType setup)
{
Bff.OnConfigureEndpoints += app =>
{
app.MapRemoteBffApiEndpoint(The.Path, Api.Url(The.Path),
c =>
app.MapRemoteBffApiEndpoint(The.Path, Api.Url(The.Path), c =>
{
DefaultBffYarpTransformerBuilders.DirectProxyWithAccessToken(The.Path, c);
c.AddRequestHeader("custom", "with value");
// Add a transform that adds the catchall route value to a header
c.AddRequestTransform(async context =>
{
c.CopyRequestHeaders = true;
DefaultBffYarpTransformerBuilders.DirectProxyWithAccessToken(The.Path, c);
})
.WithAccessToken(RequiredTokenType.UserOrClient)
.SkipAntiforgery();
// One of our customers asked how to access the catch-all route value in subsequent transforms
if (context.HttpContext.Request.RouteValues.TryGetValue("catch-all", out var value) && value is string s)
{
context.ProxyRequest.Headers.Add("catch-all", "/" + s);
}
await Task.CompletedTask;
});
})
.WithAccessToken();
};
ConfigureBff(setup);
await InitializeAsync();
await Bff.BrowserClient.Login();
var req = new HttpRequestMessage(HttpMethod.Get, Bff.Url(The.Path));
req.Headers.Add("x-csrf", "1");
req.Headers.Add("my-header-to-be-copied-by-yarp", "copied-value");
var response = await Bff.BrowserClient.SendAsync(req);
ApiCallDetails result = await Bff.BrowserClient.CallBffHostApi(
url: Bff.Url(The.PathAndSubPath)
);
response.IsSuccessStatusCode.ShouldBeTrue();
response.Content.Headers.ContentType!.MediaType.ShouldBe("application/json");
var json = await response.Content.ReadAsStringAsync();
var apiResult = JsonSerializer.Deserialize<ApiCallDetails>(json).ShouldNotBeNull();
apiResult.RequestHeaders["my-header-to-be-copied-by-yarp"].First().ShouldBe("copied-value");
result.RequestHeaders.Keys.ShouldNotContain("added-by-custom-default-transform");
result.RequestHeaders.TryGetValue("custom", out var customValue).ShouldBeTrue();
customValue.ShouldBe(["with value"],
"The custom header should be added by the custom transform registered in the test.");
response.Content.Headers.Select(x => x.Key).ShouldNotContain("added-by-custom-default-transform",
"a custom transform doesn't run the defaults");
result.RequestHeaders.TryGetValue("catch-all", out var catchAll).ShouldBeTrue();
catchAll.ShouldBe([The.SubPath],
"The catch-all route value should be added to the request headers by the custom transform registered in the test.");
}
// now I don't like timeouts in tests, but I don't know of a better way to create http timeouts.
[Theory, MemberData(nameof(AllSetups))]
public async Task can_disable_anti_forgery_check(BffSetupType setup)
public async Task Can_register_default_forwarder_config_for_MapRemoteBffApiEndpoint(BffSetupType setup)
{
var shouldDelay = false;
Api.OnConfigure += app =>
{
app.Use(async (c, n) =>
{
if (shouldDelay)
{
await Task.Delay(TimeSpan.FromSeconds(5));
}
await n();
});
};
Bff.OnConfigureServices += services =>
{
// Add a default ForwarderRequestConfig that has a 100 ms timeout
services.AddSingleton(new ForwarderRequestConfig()
{
ActivityTimeout = TimeSpan.FromMilliseconds(100)
});
};
Bff.OnConfigureEndpoints += app =>
{
app.MapRemoteBffApiEndpoint(The.Path, Api.Url(The.Path))
.WithAccessToken(RequiredTokenType.None);
app.MapRemoteBffApiEndpoint(
localPath: The.Path,
apiAddress: Api.Url(The.Path))
.WithAccessToken();
};
ConfigureBff(setup);
await InitializeAsync();
await Bff.BrowserClient.Login();
Bff.BffOptions.DisableAntiForgeryCheck = (c) => true;
// first, ensure that the 'normal' process works, becuase delay's are turned off.
await Bff.BrowserClient.CallBffHostApi(
url: Bff.Url(The.PathAndSubPath)
);
var req = new HttpRequestMessage(HttpMethod.Get, Bff.Url(The.Path));
var response = await Bff.BrowserClient.SendAsync(req);
// turn on delays. Now the timeout of 100 ms should kick in.
shouldDelay = true;
response.StatusCode.ShouldBe(HttpStatusCode.OK);
await Bff.BrowserClient.CallBffHostApi(
url: Bff.Url(The.PathAndSubPath),
expectedStatusCode: HttpStatusCode.BadGateway
);
}
// now I don't like timeouts in tests, but I don't know of a better way to create http timeouts.
[Theory, MemberData(nameof(AllSetups))]
public async Task MapRemoteBffApiEndpoint_can_override_config_to_configure_a_timeout(BffSetupType setup)
{
var shouldDelay = false;
Api.OnConfigure += app =>
{
app.Use(async (c, n) =>
{
if (shouldDelay)
{
await Task.Delay(TimeSpan.FromSeconds(5));
}
await n();
});
};
Bff.OnConfigureEndpoints += app =>
{
app.MapRemoteBffApiEndpoint(
localPath: The.Path,
apiAddress: Api.Url(The.Path),
requestConfig: new ForwarderRequestConfig()
{
// 100 ms timeout, which is not too short that the normal process might fail,
// but not too long that the test will take forever
ActivityTimeout = TimeSpan.FromMilliseconds(100)
})
.WithAccessToken();
};
ConfigureBff(setup);
await InitializeAsync();
await Bff.BrowserClient.Login();
// first, ensure that the 'normal' process works, becuase delay's are turned off.
await Bff.BrowserClient.CallBffHostApi(
url: Bff.Url(The.PathAndSubPath)
);
// turn on delays. Now the timeout of 100 ms should kick in.
shouldDelay = true;
await Bff.BrowserClient.CallBffHostApi(
url: Bff.Url(The.PathAndSubPath),
expectedStatusCode: HttpStatusCode.BadGateway
);
}
}

View file

@ -37,7 +37,7 @@ public class YarpTests : BffTestBase
await InitializeAsync();
await Bff.BrowserClient.CallBffHostApi(
path: The.SubPath,
path: The.PathAndSubPath,
expectedStatusCode: HttpStatusCode.OK
);
}
@ -50,7 +50,7 @@ public class YarpTests : BffTestBase
ConfigureYarp(Some.RouteConfig().WithAntiforgeryCheck());
await InitializeAsync();
var req = new HttpRequestMessage(HttpMethod.Get, The.SubPath);
var req = new HttpRequestMessage(HttpMethod.Get, The.PathAndSubPath);
var response = await Bff.BrowserClient.SendAsync(req);
response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
@ -70,7 +70,7 @@ public class YarpTests : BffTestBase
ConfigureYarp(Some.RouteConfig().WithAntiforgeryCheck());
await InitializeAsync();
var req = new HttpRequestMessage(HttpMethod.Get, The.SubPath);
var req = new HttpRequestMessage(HttpMethod.Get, The.PathAndSubPath);
var response = await Bff.BrowserClient.SendAsync(req);
response.StatusCode.ShouldBe(HttpStatusCode.OK);
@ -85,7 +85,7 @@ public class YarpTests : BffTestBase
await InitializeAsync();
await Bff.BrowserClient.CallBffHostApi(
path: The.SubPath,
path: The.PathAndSubPath,
expectedStatusCode: HttpStatusCode.OK
);
}
@ -99,7 +99,7 @@ public class YarpTests : BffTestBase
await InitializeAsync();
await Bff.BrowserClient.CallBffHostApi(
path: The.SubPath,
path: The.PathAndSubPath,
expectedStatusCode: HttpStatusCode.Unauthorized
);
}
@ -113,12 +113,12 @@ public class YarpTests : BffTestBase
await InitializeAsync();
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(
path: The.SubPath,
path: The.PathAndSubPath,
expectedStatusCode: HttpStatusCode.OK
);
apiResult.Method.ShouldBe(HttpMethod.Get);
apiResult.Path.ShouldBe(The.SubPath);
apiResult.Path.ShouldBe(The.PathAndSubPath);
apiResult.Sub.ShouldBeNull();
apiResult.ClientId.ShouldBeNull();
}
@ -136,12 +136,12 @@ public class YarpTests : BffTestBase
await InitializeAsync();
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(
path: The.SubPath,
path: The.PathAndSubPath,
expectedStatusCode: HttpStatusCode.OK
);
apiResult.Method.ShouldBe(HttpMethod.Get);
apiResult.Path.ShouldBe(The.SubPath);
apiResult.Path.ShouldBe(The.PathAndSubPath);
apiResult.Sub.ShouldBeNull();
apiResult.ClientId.ShouldBeNull();
}
@ -156,11 +156,11 @@ public class YarpTests : BffTestBase
await Bff.BrowserClient.Login();
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(
path: The.SubPath
path: The.PathAndSubPath
);
apiResult.Method.ShouldBe(HttpMethod.Get);
apiResult.Path.ShouldBe(The.SubPath);
apiResult.Path.ShouldBe(The.PathAndSubPath);
apiResult.Sub.ShouldBe(The.Sub);
apiResult.ClientId.ShouldBe(The.ClientId);
}
@ -178,12 +178,12 @@ public class YarpTests : BffTestBase
await Bff.BrowserClient.Login();
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(
path: The.SubPath,
path: The.PathAndSubPath,
method: HttpMethod.Put
);
apiResult.Method.ShouldBe(HttpMethod.Put);
apiResult.Path.ShouldBe(The.SubPath);
apiResult.Path.ShouldBe(The.PathAndSubPath);
apiResult.Sub.ShouldBe(The.Sub);
apiResult.ClientId.ShouldBe(The.ClientId);
}
@ -198,12 +198,12 @@ public class YarpTests : BffTestBase
await Bff.BrowserClient.Login();
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(
path: The.SubPath,
path: The.PathAndSubPath,
method: HttpMethod.Post
);
apiResult.Method.ShouldBe(HttpMethod.Post);
apiResult.Path.ShouldBe(The.SubPath);
apiResult.Path.ShouldBe(The.PathAndSubPath);
apiResult.Sub.ShouldBe(The.Sub);
apiResult.ClientId.ShouldBe(The.ClientId);
}
@ -218,12 +218,12 @@ public class YarpTests : BffTestBase
await Bff.BrowserClient.Login();
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(
path: The.SubPath,
path: The.PathAndSubPath,
method: HttpMethod.Post
);
apiResult.Method.ShouldBe(HttpMethod.Post);
apiResult.Path.ShouldBe(The.SubPath);
apiResult.Path.ShouldBe(The.PathAndSubPath);
apiResult.Sub.ShouldBe(The.Sub);
apiResult.ClientId.ShouldBe(The.ClientId);
}
@ -238,11 +238,11 @@ public class YarpTests : BffTestBase
await Bff.BrowserClient.Login();
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(
path: The.SubPath
path: The.PathAndSubPath
);
apiResult.Method.ShouldBe(HttpMethod.Get);
apiResult.Path.ShouldBe(The.SubPath);
apiResult.Path.ShouldBe(The.PathAndSubPath);
apiResult.Sub.ShouldBeNull();
apiResult.ClientId.ShouldBe(The.ClientId);
}
@ -257,11 +257,11 @@ public class YarpTests : BffTestBase
await Bff.BrowserClient.Login();
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(
path: The.SubPath
path: The.PathAndSubPath
);
apiResult.Method.ShouldBe(HttpMethod.Get);
apiResult.Path.ShouldBe(The.SubPath);
apiResult.Path.ShouldBe(The.PathAndSubPath);
apiResult.Sub.ShouldBe(The.Sub);
apiResult.ClientId.ShouldBe(The.ClientId);
}
@ -288,11 +288,11 @@ public class YarpTests : BffTestBase
await Bff.BrowserClient.Login("/somepath");
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(
path: "/somepath" + The.SubPath
path: "/somepath" + The.PathAndSubPath
);
apiResult.Method.ShouldBe(HttpMethod.Get);
apiResult.Path.ShouldBe(The.SubPath);
apiResult.Path.ShouldBe(The.PathAndSubPath);
apiResult.Sub.ShouldBe(The.Sub);
apiResult.ClientId.ShouldBe(The.ClientId);
}
@ -308,7 +308,7 @@ public class YarpTests : BffTestBase
await Bff.BrowserClient.Login();
await Bff.BrowserClient.CallBffHostApi(
path: The.SubPath,
path: The.PathAndSubPath,
expectedStatusCode: HttpStatusCode.Unauthorized
);
}
@ -324,7 +324,7 @@ public class YarpTests : BffTestBase
await Bff.BrowserClient.Login();
await Bff.BrowserClient.CallBffHostApi(
path: The.SubPath,
path: The.PathAndSubPath,
expectedStatusCode: HttpStatusCode.Forbidden
);
}

View file

@ -46,7 +46,7 @@ public class ApiAndBffUseForwardedHeaders : BffTestBase, IAsyncLifetime
public async Task bff_host_name_should_propagate_to_api()
{
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(The.SubPath);
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(The.PathAndSubPath);
var host = apiResult.RequestHeaders["Host"].Single();
host.ShouldBe(Bff.Url().Host);
@ -55,7 +55,7 @@ public class ApiAndBffUseForwardedHeaders : BffTestBase, IAsyncLifetime
[Fact]
public async Task forwarded_host_name_with_header_forwarding_should_propagate_to_api()
{
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(The.SubPath,
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(The.PathAndSubPath,
headers: new()
{
["x-csrf"] = "1",

View file

@ -36,7 +36,7 @@ public class ApiUseForwardedHeaders : BffTestBase
{
await InitializeAsync();
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(The.SubPath);
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(The.PathAndSubPath);
var host = apiResult.RequestHeaders["Host"].Single();
host.ShouldBe(Bff.Url().Host);
@ -47,7 +47,7 @@ public class ApiUseForwardedHeaders : BffTestBase
{
await InitializeAsync();
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(The.SubPath,
ApiCallDetails apiResult = await Bff.BrowserClient.CallBffHostApi(The.PathAndSubPath,
headers: new()
{
["x-csrf"] = "1",

View file

@ -65,7 +65,7 @@
}
public static class RouteBuilderExtensions
{
public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapRemoteBffApiEndpoint(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, Microsoft.AspNetCore.Http.PathString localPath, System.Uri apiAddress, System.Action<Yarp.ReverseProxy.Transforms.Builder.TransformBuilderContext>? yarpTransformBuilder = null) { }
public static Microsoft.AspNetCore.Builder.IEndpointConventionBuilder MapRemoteBffApiEndpoint(this Microsoft.AspNetCore.Routing.IEndpointRouteBuilder endpoints, Microsoft.AspNetCore.Http.PathString localPath, System.Uri apiAddress, System.Action<Yarp.ReverseProxy.Transforms.Builder.TransformBuilderContext>? yarpTransformBuilder = null, Yarp.ReverseProxy.Forwarder.ForwarderRequestConfig? requestConfig = null) { }
}
public sealed class UserAccessTokenParameters : System.IEquatable<Duende.Bff.Yarp.UserAccessTokenParameters>
{

View file

@ -30,7 +30,8 @@ public class TestData
public Origin Origin = Origin.Parse($"https://{PropertyName()}:1234");
public int Port = 1234;
public PathString Path = new PathString($"/{PropertyName()}");
public PathString SubPath = new PathString($"/{PropertyName(nameof(Path))}/{PropertyName()}");
public PathString SubPath = new PathString($"/{PropertyName()}");
public PathString PathAndSubPath = new PathString($"/{PropertyName(nameof(Path))}/{PropertyName(nameof(SubPath))}");
public string Scope = PropertyName();
public string RouteId = PropertyName();
public string ClusterId = PropertyName();