From 6d23e8e3096bc4da8f42e843a3d6c7ae708974fb Mon Sep 17 00:00:00 2001 From: Andy Pixley <3723676+pixman20@users.noreply.github.com> Date: Mon, 13 Apr 2026 15:23:43 -0400 Subject: [PATCH 1/3] [BRE-1413] Investigation for supporting Canary via header based routing --- CANARY-FIXES.md | 44 +++++++++++++++ CANARY-ISSUES.md | 54 +++++++++++++++++++ bitwarden_license/src/Scim/Startup.cs | 4 +- bitwarden_license/src/Sso/Startup.cs | 4 +- src/Admin/Startup.cs | 5 +- src/Api/Startup.cs | 4 +- src/Billing/Startup.cs | 8 ++- src/Events/Startup.cs | 2 +- src/Icons/Startup.cs | 3 ++ src/Identity/Startup.cs | 12 ++++- src/Notifications/Startup.cs | 3 ++ .../HeaderPropagationResponseMiddleware.cs | 48 +++++++++++++++++ .../Utilities/ServiceCollectionExtensions.cs | 39 +++++++++++++- 13 files changed, 217 insertions(+), 13 deletions(-) create mode 100644 CANARY-FIXES.md create mode 100644 CANARY-ISSUES.md create mode 100644 src/SharedWeb/Utilities/HeaderPropagationResponseMiddleware.cs diff --git a/CANARY-FIXES.md b/CANARY-FIXES.md new file mode 100644 index 000000000000..8bc7ccb9ad4b --- /dev/null +++ b/CANARY-FIXES.md @@ -0,0 +1,44 @@ +# Canary Header Propagation Changes + +## What this does + +Three things are needed for coordinated canary deployments where all canary pods route to other canary pods: + +1. **Header propagation** — When a request arrives with a configured header (e.g. `X-Canary: true`), that header must be forwarded on all outbound HTTP calls so downstream services are also routed to their canary pods via AGC. Uses .NET 8's built-in `HeaderPropagation` middleware + `ConfigureHttpClientDefaults` to cover all `HttpClient` instances globally. + +2. **OIDC backchannel fix** — The Identity service's OpenID Connect handler creates its own internal `Backchannel` HttpClient that bypasses `IHttpClientFactory`. This is fixed by explicitly wiring the OIDC handler to use a factory-created client, ensuring the propagation handler applies to Identity → SSO backchannel calls. + +3. **Response middleware** — Echoes back a routed confirmation header (e.g. `X-Canary-Routed: true`) and tags the active trace span with pod name, build hash, and canary status for DataDog observability. + +All are **configuration-driven** — the header name is set via ConfigMap (`HeaderPropagation__Headers__0=X-Canary`), never hardcoded in server code. When unconfigured (e.g. self-hosted), all features are no-ops. + +## Service coverage + +| Service | Outbound propagation | Inbound middleware | Notes | +|---|---|---|---| +| **API** | `AddDefaultServices` | `UseDefaultMiddleware` | Makes calls to Identity, Notifications | +| **Identity** | `AddDefaultServices` | `UseDefaultMiddleware` | Makes calls to SSO (OIDC backchannel also fixed) | +| **Billing** | `AddDefaultServices` | `UseDefaultMiddleware` (added) | Receives calls from Admin | +| **Admin** | `AddDefaultServices` | `UseDefaultMiddleware` (added) | Makes calls to Billing, Identity | +| **SSO** | `AddDefaultServices` | `UseDefaultMiddleware` (added) | Receives calls from Identity | +| **SCIM** | `AddDefaultServices` | `UseDefaultMiddleware` (added) | Makes calls to Identity | +| **Events** | N/A | `UseDefaultMiddleware` | No outbound inter-service calls | +| **Notifications** | N/A | `UseDefaultMiddleware` (added) | No outbound inter-service calls | +| **Icons** | N/A | `UseDefaultMiddleware` (added) | No outbound inter-service calls | +| **EventsProcessor** | N/A | N/A | Background worker, no HTTP pipeline | + +Services marked "N/A" for outbound propagation don't make inter-service HTTP calls, so they only need the inbound middleware for the response header and DataDog tagging. Services marked "(added)" did not previously call that method and were updated in this change. + +`UseDefaultMiddleware` conditionally calls `UseHeaderPropagation()` only if `AddHeaderPropagation()` was registered (via `AddDefaultServices`), preventing runtime errors on services that don't make outbound HTTP calls. + +## Shared resources (not isolated by canary) + +Some communication channels between services are not HTTP-based. Header propagation does not and cannot isolate these. This is by design — canary routing operates at the HTTP layer only. + +**Database** — Canary and stable pods read from and write to the same database. A prerequisite for canary deployments is that database migrations are decoupled from application deployments: both the current and next app versions must handle the current and next database schema. This is being validated independently. + +**Azure Service Bus** — Events and application cache messages are published to shared topics/subscriptions. A canary pod may publish a message consumed by a stable pod and vice versa. Both app versions must handle messages from either version. + +**Redis** — The SignalR backplane uses shared Redis. Notifications published by canary pods may be relayed through stable pod connections. Both app versions must handle notifications from either version. + +These constraints apply equally to today's rolling deployments where old and new pods coexist during rollout. Canary does not introduce new data-plane risks — it extends the coexistence window. diff --git a/CANARY-ISSUES.md b/CANARY-ISSUES.md new file mode 100644 index 000000000000..88b0985ca7f1 --- /dev/null +++ b/CANARY-ISSUES.md @@ -0,0 +1,54 @@ +# Canary Header Propagation Issues + +Without header propagation, inter-service calls from canary pods lose the `X-Canary` header and fall through to stable pods via AGC. This breaks canary isolation in a coordinated deployment where all services should stay within the canary call chain. + +## Issue 1: Inter-service HTTP calls lose the canary header + +All cloud inter-service calls use external HTTPS URLs (e.g. `https://identity.usdev.bitwarden.pw`) which route through AGC. Without the `X-Canary` header on these outbound calls, AGC routes them to stable pods. + +**Affected calls:** + +**API → Identity** (token acquisition) +- `BaseIdentityClientService` calls `POST /connect/token` on Identity to get bearer tokens +- Every service that makes authenticated inter-service calls hits this path first + +**API → Notifications** (push notifications) +- `NotificationsApiPushEngine` calls `POST /send` on the Notifications service +- Triggered on cipher operations, folder changes, org updates — high frequency + +**Admin → Billing** (Stripe recovery) +- `ProcessStripeEventsController` calls `POST /stripe/recovery/events/*` on Billing +- Lower frequency, but would still route to stable Billing during canary + +**Admin → Identity** (token acquisition) +- Same `BaseIdentityClientService` pattern as API + +**Identity → SSO** (OIDC) +- Named HttpClient `"InternalSso"` in Identity's Startup +- Any SSO login flow would route to stable SSO + +**Notifications → Identity** (token acquisition) +- Same `BaseIdentityClientService` token pattern + +**Billing → Identity** (token acquisition) +- Same `BaseIdentityClientService` token pattern + +**Highest risk:** API → Identity and API → Notifications are the most frequent calls. A canary API pod would get its token from stable Identity and send push notifications to stable Notifications — breaking the canary isolation chain. + +## Issue 2: OIDC backchannel bypasses HttpClientFactory + +The Identity service's OpenID Connect middleware (for SSO login flows) creates its own internal `Backchannel` HttpClient. This client is not created through `IHttpClientFactory`, so `ConfigureHttpClientDefaults` does not apply. The `X-Canary` header would not propagate on Identity → SSO backchannel calls (discovery, token exchange, userinfo). + +## Issue 3: Shared resources are not isolated by canary routing + +Some communication channels between services are not HTTP and cannot be isolated via header propagation: + +- **Database** — Canary and stable pods share the same database. Writes from canary pods are visible to stable pods and vice versa. +- **Azure Service Bus** — Events and application cache messages are published to shared topics. A canary pod may publish a message consumed by a stable pod. +- **Redis** — SignalR backplane uses shared Redis. Notifications published by canary pods may be relayed by stable pods. + +These are data-plane concerns, not routing concerns. Header propagation cannot address them. + +## Resolution + +See [CANARY-FIXES.md](CANARY-FIXES.md) for how Issues 1 and 2 are resolved. Issue 3 is addressed by design constraints documented there. diff --git a/bitwarden_license/src/Scim/Startup.cs b/bitwarden_license/src/Scim/Startup.cs index a912562f72cd..e2d619607c1e 100644 --- a/bitwarden_license/src/Scim/Startup.cs +++ b/bitwarden_license/src/Scim/Startup.cs @@ -69,7 +69,7 @@ public void ConfigureServices(IServiceCollection services) // Services services.AddBaseServices(globalSettings); - services.AddDefaultServices(globalSettings); + services.AddDefaultServices(globalSettings, Configuration); services.AddDistributedCache(globalSettings); services.AddBillingOperations(); @@ -112,7 +112,7 @@ public void Configure( } // Default Middleware - app.UseDefaultMiddleware(env, globalSettings); + app.UseDefaultMiddleware(env, globalSettings, Configuration); // Add routing app.UseRouting(); diff --git a/bitwarden_license/src/Sso/Startup.cs b/bitwarden_license/src/Sso/Startup.cs index c4c676d51f5b..073ffb2eb124 100644 --- a/bitwarden_license/src/Sso/Startup.cs +++ b/bitwarden_license/src/Sso/Startup.cs @@ -79,7 +79,7 @@ public void ConfigureServices(IServiceCollection services) // Services services.AddBaseServices(globalSettings); - services.AddDefaultServices(globalSettings); + services.AddDefaultServices(globalSettings, Configuration); services.AddCoreLocalizationServices(); services.AddBillingOperations(); @@ -124,6 +124,8 @@ public void Configure( app.UseExceptionHandler("/Error"); } + app.UseDefaultMiddleware(environment, globalSettings, Configuration); + app.UseCoreLocalization(); // Add static files to the request pipeline. diff --git a/src/Admin/Startup.cs b/src/Admin/Startup.cs index 6c0a644ee689..91a2fe4ac75d 100644 --- a/src/Admin/Startup.cs +++ b/src/Admin/Startup.cs @@ -87,7 +87,7 @@ public void ConfigureServices(IServiceCollection services) // Services services.AddBaseServices(globalSettings); - services.AddDefaultServices(globalSettings); + services.AddDefaultServices(globalSettings, Configuration); services.AddScoped(); services.AddDistributedCache(globalSettings); services.AddBillingOperations(); @@ -144,6 +144,9 @@ public void Configure( app.UseForwardedHeaders(globalSettings); } + // Default Middleware + app.UseDefaultMiddleware(env, globalSettings, Configuration); + if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index c28c0b0b5089..905f2eda8ca1 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -181,7 +181,7 @@ public void ConfigureServices(IServiceCollection services) // Services services.AddBaseServices(globalSettings); - services.AddDefaultServices(globalSettings); + services.AddDefaultServices(globalSettings, Configuration); services.AddOrganizationSubscriptionServices(); services.AddCoreLocalizationServices(); services.AddBillingOperations(); @@ -241,7 +241,7 @@ public void Configure( app.UseMiddleware(); // Default Middleware - app.UseDefaultMiddleware(env, globalSettings); + app.UseDefaultMiddleware(env, globalSettings, Configuration); if (!globalSettings.SelfHosted) { diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 00fea5a49951..264da2f5a593 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -80,7 +80,7 @@ public void ConfigureServices(IServiceCollection services) // Services services.AddBaseServices(globalSettings); - services.AddDefaultServices(globalSettings); + services.AddDefaultServices(globalSettings, Configuration); services.AddDistributedCache(globalSettings); services.AddBillingOperations(); services.AddCommercialCoreServices(); @@ -123,11 +123,15 @@ public void ConfigureServices(IServiceCollection services) public void Configure( IApplicationBuilder app, - IWebHostEnvironment env) + IWebHostEnvironment env, + GlobalSettings globalSettings) { // Add general security headers app.UseMiddleware(); + // Default Middleware + app.UseDefaultMiddleware(env, globalSettings, Configuration); + if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index cf8534d134f7..785ae7c2a4eb 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -107,7 +107,7 @@ public void Configure( } // Default Middleware - app.UseDefaultMiddleware(env, globalSettings); + app.UseDefaultMiddleware(env, globalSettings, Configuration); // Add routing app.UseRouting(); diff --git a/src/Icons/Startup.cs b/src/Icons/Startup.cs index 5d9b5e5a30c7..d928b04acd98 100644 --- a/src/Icons/Startup.cs +++ b/src/Icons/Startup.cs @@ -71,6 +71,9 @@ public void Configure( app.UseForwardedHeaders(globalSettings); } + // Default Middleware + app.UseDefaultMiddleware(env, globalSettings, Configuration); + if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index bb1a974d82a7..eff1ab86b6b1 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -145,7 +145,7 @@ public void ConfigureServices(IServiceCollection services) // Services services.AddBaseServices(globalSettings); - services.AddDefaultServices(globalSettings); + services.AddDefaultServices(globalSettings, Configuration); services.AddOptionality(); services.AddCoreLocalizationServices(); services.AddBillingOperations(); @@ -165,6 +165,14 @@ public void ConfigureServices(IServiceCollection services) { client.BaseAddress = new Uri(globalSettings.BaseServiceUri.InternalSso); }); + + // Wire the OIDC "sso" handler to use a factory-created HttpClient so that + // ConfigureHttpClientDefaults (header propagation) applies to backchannel calls. + services.AddOptions("sso") + .Configure((options, factory) => + { + options.Backchannel = factory.CreateClient("InternalSso"); + }); } public void Configure( @@ -193,7 +201,7 @@ public void Configure( } // Default Middleware - app.UseDefaultMiddleware(environment, globalSettings); + app.UseDefaultMiddleware(environment, globalSettings, Configuration); if (!globalSettings.SelfHosted) { diff --git a/src/Notifications/Startup.cs b/src/Notifications/Startup.cs index 3a4dc2d4476c..7bdc6aade9d3 100644 --- a/src/Notifications/Startup.cs +++ b/src/Notifications/Startup.cs @@ -92,6 +92,9 @@ public void Configure( app.UseForwardedHeaders(globalSettings); } + // Default Middleware + app.UseDefaultMiddleware(env, globalSettings, Configuration); + if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); diff --git a/src/SharedWeb/Utilities/HeaderPropagationResponseMiddleware.cs b/src/SharedWeb/Utilities/HeaderPropagationResponseMiddleware.cs new file mode 100644 index 000000000000..6c4da7311a49 --- /dev/null +++ b/src/SharedWeb/Utilities/HeaderPropagationResponseMiddleware.cs @@ -0,0 +1,48 @@ +using System.Diagnostics; +using Bit.Core.Utilities; + +namespace Bit.SharedWeb.Utilities; + +/// +/// Middleware that adds response headers and telemetry tags for propagated headers. +/// When a configured propagation header is present on the inbound request, +/// this middleware sets a corresponding response header (e.g. X-Canary → X-Canary-Routed: true) +/// and tags the active trace span for observability (DataDog/OpenTelemetry). +/// +public class HeaderPropagationResponseMiddleware +{ + private static readonly string _podName = Environment.GetEnvironmentVariable("HOSTNAME") ?? "unknown"; + private static readonly string _gitHash = AssemblyHelpers.GetGitHash() ?? "unknown"; + private readonly RequestDelegate _next; + private readonly string[] _headers; + + public HeaderPropagationResponseMiddleware(RequestDelegate next, string[] headers) + { + _next = next; + _headers = headers; + } + + public async Task InvokeAsync(HttpContext context) + { + var activity = Activity.Current; + activity?.SetTag("pod.name", _podName); + activity?.SetTag("build.hash", _gitHash); + + foreach (var header in _headers) + { + if (context.Request.Headers.TryGetValue(header, out var value) + && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)) + { + activity?.SetTag(header.TrimStart('X', '-').ToLowerInvariant(), "true"); + context.Response.OnStarting(state => + { + var (ctx, h) = ((HttpContext, string))state; + ctx.Response.Headers[$"{h}-Routed"] = "true"; + return Task.CompletedTask; + }, (context, header)); + } + } + + await _next(context); + } +} diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 85886027ac2d..09d2ded6939b 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -67,6 +67,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.HeaderPropagation; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.HttpOverrides; @@ -257,13 +258,32 @@ public static void AddTokenizers(this IServiceCollection services) serviceProvider.GetRequiredService>>())); } - public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings) + public static void AddDefaultServices(this IServiceCollection services, GlobalSettings globalSettings, + IConfiguration configuration) { // Required for UserService services.AddWebAuthn(globalSettings); // Required for HTTP calls services.AddHttpClient(); + // Header propagation - forwards configured headers from inbound requests to outbound HttpClient calls. + // Headers are configured via environment/config (e.g. HeaderPropagation__Headers__0=X-Canary). + // When no headers are configured (e.g. self-hosted), this is a no-op. + var headersToPropagate = configuration.GetSection("HeaderPropagation:Headers").Get() ?? []; + if (headersToPropagate.Length > 0) + { + services.AddHeaderPropagation(options => + { + foreach (var header in headersToPropagate) + { + options.Headers.Add(header); + } + }); + + services.ConfigureHttpClientDefaults(builder => + builder.AddHeaderPropagationMessageHandler()); + } + services.AddSingleton(); services.AddSingleton((serviceProvider) => { @@ -550,13 +570,28 @@ public static GlobalSettings AddGlobalSettingsServices(this IServiceCollection s } public static void UseDefaultMiddleware(this IApplicationBuilder app, - IWebHostEnvironment env, GlobalSettings globalSettings) + IWebHostEnvironment env, GlobalSettings globalSettings, IConfiguration configuration) { app.UseMiddleware(); if (globalSettings.TestPlayIdTrackingEnabled) { app.UseMiddleware(); } + + // Header propagation - adds response headers + telemetry tags for observability. + // UseHeaderPropagation() is only called if AddHeaderPropagation() was registered + // (via AddDefaultServices). Services that don't make outbound HTTP calls only get + // the response middleware for DataDog tagging and response headers. + var headersToPropagate = configuration.GetSection("HeaderPropagation:Headers").Get() ?? []; + if (headersToPropagate.Length > 0) + { + if (app.ApplicationServices.GetService(typeof(HeaderPropagationValues)) != null) + { + app.UseHeaderPropagation(); + } + + app.UseMiddleware(headersToPropagate); + } } public static void UseForwardedHeaders(this IApplicationBuilder app, IGlobalSettings globalSettings) From ac1b0e5d813861c88f13d9ea132f34f087774eb2 Mon Sep 17 00:00:00 2001 From: Andy Pixley <3723676+pixman20@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:00:28 -0400 Subject: [PATCH 2/3] [BRE-1413] Fixing build errors --- src/Billing/Startup.cs | 1 + src/SharedWeb/SharedWeb.csproj | 5 +++++ .../Utilities/HeaderPropagationResponseMiddleware.cs | 3 ++- src/SharedWeb/Utilities/ServiceCollectionExtensions.cs | 4 ++-- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 264da2f5a593..d8272f3efc1d 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -9,6 +9,7 @@ using Bit.Core.Context; using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories.Noop; +using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; using Microsoft.Extensions.DependencyInjection.Extensions; diff --git a/src/SharedWeb/SharedWeb.csproj b/src/SharedWeb/SharedWeb.csproj index cd35b4224150..686ca1ab4aa1 100644 --- a/src/SharedWeb/SharedWeb.csproj +++ b/src/SharedWeb/SharedWeb.csproj @@ -5,6 +5,10 @@ $(WarningsNotAsErrors);CA1304 + + + + @@ -12,6 +16,7 @@ + diff --git a/src/SharedWeb/Utilities/HeaderPropagationResponseMiddleware.cs b/src/SharedWeb/Utilities/HeaderPropagationResponseMiddleware.cs index 6c4da7311a49..5d06fad0bab0 100644 --- a/src/SharedWeb/Utilities/HeaderPropagationResponseMiddleware.cs +++ b/src/SharedWeb/Utilities/HeaderPropagationResponseMiddleware.cs @@ -1,5 +1,6 @@ -using System.Diagnostics; +using System.Diagnostics; using Bit.Core.Utilities; +using Microsoft.AspNetCore.Http; namespace Bit.SharedWeb.Utilities; diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 09d2ded6939b..0a4cfea55888 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -67,8 +67,8 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.HeaderPropagation; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.HeaderPropagation; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; @@ -281,7 +281,7 @@ public static void AddDefaultServices(this IServiceCollection services, GlobalSe }); services.ConfigureHttpClientDefaults(builder => - builder.AddHeaderPropagationMessageHandler()); + builder.AddHeaderPropagation()); } services.AddSingleton(); From eda720ae354e763989da7029181d271eea66f7dc Mon Sep 17 00:00:00 2001 From: Andy Pixley <3723676+pixman20@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:41:52 -0400 Subject: [PATCH 3/3] [BRE-1413] Adding tests --- .../HeaderPropagationResponseMiddleware.cs | 7 +- ...eaderPropagationResponseMiddlewareTests.cs | 147 ++++++++++++++++++ 2 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 test/SharedWeb.Test/HeaderPropagationResponseMiddlewareTests.cs diff --git a/src/SharedWeb/Utilities/HeaderPropagationResponseMiddleware.cs b/src/SharedWeb/Utilities/HeaderPropagationResponseMiddleware.cs index 5d06fad0bab0..874c719fcd7a 100644 --- a/src/SharedWeb/Utilities/HeaderPropagationResponseMiddleware.cs +++ b/src/SharedWeb/Utilities/HeaderPropagationResponseMiddleware.cs @@ -35,12 +35,7 @@ public async Task InvokeAsync(HttpContext context) && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)) { activity?.SetTag(header.TrimStart('X', '-').ToLowerInvariant(), "true"); - context.Response.OnStarting(state => - { - var (ctx, h) = ((HttpContext, string))state; - ctx.Response.Headers[$"{h}-Routed"] = "true"; - return Task.CompletedTask; - }, (context, header)); + context.Response.Headers[$"{header}-Routed"] = "true"; } } diff --git a/test/SharedWeb.Test/HeaderPropagationResponseMiddlewareTests.cs b/test/SharedWeb.Test/HeaderPropagationResponseMiddlewareTests.cs new file mode 100644 index 000000000000..455b5472fbdb --- /dev/null +++ b/test/SharedWeb.Test/HeaderPropagationResponseMiddlewareTests.cs @@ -0,0 +1,147 @@ +using System.Diagnostics; +using Bit.SharedWeb.Utilities; +using Microsoft.AspNetCore.Http; +using NSubstitute; + +namespace SharedWeb.Test; + +public class HeaderPropagationResponseMiddlewareTests +{ + private readonly RequestDelegate _next; + + public HeaderPropagationResponseMiddlewareTests() + { + _next = Substitute.For(); + } + + [Fact] + public async Task InvokeAsync_WithMatchingHeader_SetsRoutedResponseHeader() + { + var middleware = new HeaderPropagationResponseMiddleware(_next, ["X-Canary"]); + var context = new DefaultHttpContext(); + context.Request.Headers["X-Canary"] = "true"; + + await middleware.InvokeAsync(context); + + Assert.Equal("true", context.Response.Headers["X-Canary-Routed"]); + await _next.Received(1).Invoke(context); + } + + [Fact] + public async Task InvokeAsync_WithoutHeader_DoesNotSetRoutedResponseHeader() + { + var middleware = new HeaderPropagationResponseMiddleware(_next, ["X-Canary"]); + var context = new DefaultHttpContext(); + + await middleware.InvokeAsync(context); + + Assert.False(context.Response.Headers.ContainsKey("X-Canary-Routed")); + await _next.Received(1).Invoke(context); + } + + [Fact] + public async Task InvokeAsync_WithHeaderValueNotTrue_DoesNotSetRoutedResponseHeader() + { + var middleware = new HeaderPropagationResponseMiddleware(_next, ["X-Canary"]); + var context = new DefaultHttpContext(); + context.Request.Headers["X-Canary"] = "false"; + + await middleware.InvokeAsync(context); + + Assert.False(context.Response.Headers.ContainsKey("X-Canary-Routed")); + await _next.Received(1).Invoke(context); + } + + [Fact] + public async Task InvokeAsync_WithHeaderValueTrue_CaseInsensitive() + { + var middleware = new HeaderPropagationResponseMiddleware(_next, ["X-Canary"]); + var context = new DefaultHttpContext(); + context.Request.Headers["X-Canary"] = "TRUE"; + + await middleware.InvokeAsync(context); + + Assert.Equal("true", context.Response.Headers["X-Canary-Routed"]); + } + + [Fact] + public async Task InvokeAsync_WithMultipleConfiguredHeaders_SetsMatchingRoutedHeaders() + { + var middleware = new HeaderPropagationResponseMiddleware(_next, ["X-Canary", "X-Preview"]); + var context = new DefaultHttpContext(); + context.Request.Headers["X-Canary"] = "true"; + + await middleware.InvokeAsync(context); + + Assert.Equal("true", context.Response.Headers["X-Canary-Routed"]); + Assert.False(context.Response.Headers.ContainsKey("X-Preview-Routed")); + } + + [Fact] + public async Task InvokeAsync_WithNoConfiguredHeaders_CallsNext() + { + var middleware = new HeaderPropagationResponseMiddleware(_next, []); + var context = new DefaultHttpContext(); + + await middleware.InvokeAsync(context); + + Assert.False(context.Response.Headers.ContainsKey("X-Canary-Routed")); + await _next.Received(1).Invoke(context); + } + + [Fact] + public async Task InvokeAsync_WithActivity_SetsBaselineTags() + { + using var listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(listener); + + using var source = new ActivitySource("test"); + using var activity = source.StartActivity("test-operation"); + + var middleware = new HeaderPropagationResponseMiddleware(_next, []); + var context = new DefaultHttpContext(); + + await middleware.InvokeAsync(context); + + Assert.Equal("unknown", activity?.GetTagItem("pod.name")); + Assert.NotNull(activity?.GetTagItem("build.hash")); + } + + [Fact] + public async Task InvokeAsync_WithActivityAndMatchingHeader_SetsCanaryTag() + { + using var listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData + }; + ActivitySource.AddActivityListener(listener); + + using var source = new ActivitySource("test"); + using var activity = source.StartActivity("test-operation"); + + var middleware = new HeaderPropagationResponseMiddleware(_next, ["X-Canary"]); + var context = new DefaultHttpContext(); + context.Request.Headers["X-Canary"] = "true"; + + await middleware.InvokeAsync(context); + + Assert.Equal("true", activity?.GetTagItem("canary")); + } + + [Fact] + public async Task InvokeAsync_AlwaysCallsNext() + { + var middleware = new HeaderPropagationResponseMiddleware(_next, ["X-Canary"]); + var context = new DefaultHttpContext(); + context.Request.Headers["X-Canary"] = "true"; + + await middleware.InvokeAsync(context); + + await _next.Received(1).Invoke(context); + } +}