Skip to content
151 changes: 151 additions & 0 deletions src/Service.Tests/UnitTests/HealthCheckUtilitiesUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
#nullable enable

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Config.ObjectModel;
using Azure.DataApiBuilder.Core.Authorization;
using Azure.DataApiBuilder.Service.HealthCheck;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
Expand Down Expand Up @@ -150,5 +155,151 @@ public void NormalizeConnectionString_EmptyString_ReturnsEmpty()
// Assert
Assert.AreEqual(string.Empty, result);
}
/// <summary>
/// Tests that GetCurrentRole returns "anonymous" when no auth headers are present.
/// </summary>
[TestMethod]
public void GetCurrentRole_NoHeaders_ReturnsAnonymous()
{
HealthCheckHelper helper = CreateHelper();
string role = helper.GetCurrentRole(roleHeader: string.Empty, roleToken: string.Empty);
Assert.AreEqual(AuthorizationResolver.ROLE_ANONYMOUS, role);
}

/// <summary>
/// Tests that GetCurrentRole returns "authenticated" when a bearer token is present but no role header is supplied.
/// </summary>
[TestMethod]
public void GetCurrentRole_BearerTokenOnly_ReturnsAuthenticated()
{
HealthCheckHelper helper = CreateHelper();
string role = helper.GetCurrentRole(roleHeader: string.Empty, roleToken: "some-bearer-token");
Assert.AreEqual(AuthorizationResolver.ROLE_AUTHENTICATED, role);
}

/// <summary>
/// Tests that GetCurrentRole returns the explicit role value when the X-MS-API-ROLE header is provided.
/// </summary>
[TestMethod]
[DataRow("anonymous", DisplayName = "Explicit anonymous role header")]
[DataRow("authenticated", DisplayName = "Explicit authenticated role header")]
[DataRow("customrole", DisplayName = "Custom role header")]
public void GetCurrentRole_ExplicitRoleHeader_ReturnsHeaderValue(string explicitRole)
{
HealthCheckHelper helper = CreateHelper();
string role = helper.GetCurrentRole(roleHeader: explicitRole, roleToken: string.Empty);
Assert.AreEqual(explicitRole, role);
}

/// <summary>
/// Tests that the role header takes priority over the bearer token when both are present.
/// </summary>
[TestMethod]
public void GetCurrentRole_BothHeaderAndToken_RoleHeaderWins()
{
HealthCheckHelper helper = CreateHelper();
string role = helper.GetCurrentRole(roleHeader: "customrole", roleToken: "some-bearer-token");
Assert.AreEqual("customrole", role);
}

/// <summary>
/// Tests that ReadRoleHeaders correctly reads X-MS-API-ROLE from the request.
/// </summary>
[TestMethod]
public void ReadRoleHeaders_WithRoleHeader_ReturnsRoleHeader()
{
HealthCheckHelper helper = CreateHelper();
DefaultHttpContext context = new();
context.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER] = "myrole";

(string roleHeader, string roleToken) = helper.ReadRoleHeaders(context);

Assert.AreEqual("myrole", roleHeader);
Assert.AreEqual(string.Empty, roleToken);
}

/// <summary>
/// Tests that ReadRoleHeaders returns empty strings when no headers are present.
/// </summary>
[TestMethod]
public void ReadRoleHeaders_NoHeaders_ReturnsEmpty()
{
HealthCheckHelper helper = CreateHelper();
DefaultHttpContext context = new();

(string roleHeader, string roleToken) = helper.ReadRoleHeaders(context);

Assert.AreEqual(string.Empty, roleHeader);
Assert.AreEqual(string.Empty, roleToken);
}

/// <summary>
/// Tests that the cached health response does not reuse a previous caller's currentRole.
/// GetCurrentRole is a pure function: same input always produces same output,
/// and different inputs (representing different callers) produce different outputs.
/// </summary>
[TestMethod]
public void GetCurrentRole_CacheDoesNotLeakRole_DifferentCallersGetDifferentRoles()
{
HealthCheckHelper helper = CreateHelper();

// Simulate request 1 (anonymous, no headers)
string role1 = helper.GetCurrentRole(roleHeader: string.Empty, roleToken: string.Empty);

// Simulate request 2 (authenticated, with bearer token)
string role2 = helper.GetCurrentRole(roleHeader: string.Empty, roleToken: "bearer-token");

// Simulate request 3 (explicit custom role)
string role3 = helper.GetCurrentRole(roleHeader: "adminrole", roleToken: string.Empty);

Assert.AreEqual(AuthorizationResolver.ROLE_ANONYMOUS, role1);
Assert.AreEqual(AuthorizationResolver.ROLE_AUTHENTICATED, role2);
Assert.AreEqual("adminrole", role3);
}

/// <summary>
/// Tests that parallel calls to GetCurrentRole with different roles do not bleed values across calls.
/// Validates the singleton-safe design (no shared mutable state).
/// </summary>
[TestMethod]
public async Task GetCurrentRole_ParallelRequests_NoRoleBleed()
{
HealthCheckHelper helper = CreateHelper();

// Run many parallel "requests" each with a unique role
int parallelCount = 50;
string[] expectedRoles = new string[parallelCount];
string[] actualRoles = new string[parallelCount];

for (int i = 0; i < parallelCount; i++)
{
expectedRoles[i] = $"role-{i}";
}

List<Task> tasks = new();
for (int i = 0; i < parallelCount; i++)
{
int index = i;
tasks.Add(Task.Run(() =>
{
actualRoles[index] = helper.GetCurrentRole(roleHeader: expectedRoles[index], roleToken: string.Empty);
}));
}

await Task.WhenAll(tasks);

for (int i = 0; i < parallelCount; i++)
{
Assert.AreEqual(expectedRoles[i], actualRoles[i], $"Role bleed detected at index {i}: expected '{expectedRoles[i]}' but got '{actualRoles[i]}'");
}
}

private static HealthCheckHelper CreateHelper()
{
Mock<ILogger<HealthCheckHelper>> loggerMock = new();
// HttpUtilities is not invoked by the methods under test (GetCurrentRole, ReadRoleHeaders),
// so passing null is safe here.
return new HealthCheckHelper(loggerMock.Object, null!);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// Licensed under the MIT License.

using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -76,43 +75,42 @@ public async Task WriteResponseAsync(HttpContext context)
// Global comprehensive Health Check Enabled
if (config.IsHealthEnabled)
{
_healthCheckHelper.StoreIncomingRoleHeader(context);
if (!_healthCheckHelper.IsUserAllowedToAccessHealthCheck(context, config.IsDevelopmentMode(), config.AllowedRolesForHealth))
(string roleHeader, string roleToken) = _healthCheckHelper.ReadRoleHeaders(context);
if (!_healthCheckHelper.IsUserAllowedToAccessHealthCheck(config.IsDevelopmentMode(), config.AllowedRolesForHealth, roleHeader))
{
_logger.LogError("Comprehensive Health Check Report is not allowed: 403 Forbidden due to insufficient permissions.");
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.CompleteAsync();
return;
}

string? response;
// Check if the cache is enabled
if (config.CacheTtlSecondsForHealthReport > 0)
{
ComprehensiveHealthCheckReport? report = null;
try
{
response = await _cache.GetOrSetAsync<string?>(
report = await _cache.GetOrSetAsync<ComprehensiveHealthCheckReport?>(
key: CACHE_KEY,
async (FusionCacheFactoryExecutionContext<string?> ctx, CancellationToken ct) =>
async (FusionCacheFactoryExecutionContext<ComprehensiveHealthCheckReport?> ctx, CancellationToken ct) =>
{
string? response = await ExecuteHealthCheckAsync(config).ConfigureAwait(false);
ComprehensiveHealthCheckReport? r = await _healthCheckHelper.GetHealthCheckResponseAsync(config, roleHeader, roleToken).ConfigureAwait(false);
ctx.Options.SetDuration(TimeSpan.FromSeconds(config.CacheTtlSecondsForHealthReport));
return response;
return r;
});

_logger.LogTrace($"Health check response is fetched from cache with key: {CACHE_KEY} and TTL: {config.CacheTtlSecondsForHealthReport} seconds.");
}
catch (Exception ex)
{
response = null; // Set response to null in case of an error
_logger.LogError($"Error in caching health check response: {ex.Message}");
}

// Ensure cachedResponse is not null before calling WriteAsync
if (response != null)
if (report != null)
{
// Return the cached or newly generated response
await context.Response.WriteAsync(response);
// Set currentRole per-request (not cached) so each caller sees their own role
await context.Response.WriteAsync(SerializeReport(report with { CurrentRole = _healthCheckHelper.GetCurrentRole(roleHeader, roleToken) }));
}
else
{
Expand All @@ -124,9 +122,9 @@ public async Task WriteResponseAsync(HttpContext context)
}
else
{
response = await ExecuteHealthCheckAsync(config).ConfigureAwait(false);
ComprehensiveHealthCheckReport report = await _healthCheckHelper.GetHealthCheckResponseAsync(config, roleHeader, roleToken).ConfigureAwait(false);
// Return the newly generated response
await context.Response.WriteAsync(response);
await context.Response.WriteAsync(SerializeReport(report with { CurrentRole = _healthCheckHelper.GetCurrentRole(roleHeader, roleToken) }));
}
}
else
Expand All @@ -139,13 +137,10 @@ public async Task WriteResponseAsync(HttpContext context)
return;
}

private async Task<string> ExecuteHealthCheckAsync(RuntimeConfig config)
private string SerializeReport(ComprehensiveHealthCheckReport report)
{
ComprehensiveHealthCheckReport dabHealthCheckReport = await _healthCheckHelper.GetHealthCheckResponseAsync(config);
string response = JsonSerializer.Serialize(dabHealthCheckReport, options: new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
_logger.LogTrace($"Health check response writer writing status as: {dabHealthCheckReport.Status}");

return response;
_logger.LogTrace($"Health check response writer writing status as: {report.Status}");
return JsonSerializer.Serialize(report, options: new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull });
}
}
}
Loading