Files
work-club-manager/backend/WorkClub.Tests.Integration/Middleware/TenantValidationTests.cs
WorkClub Automation 28964c6767 feat(backend): add PostgreSQL schema, RLS policies, and multi-tenant middleware
- Add EF Core migrations for initial schema (clubs, members, work_items, shifts, shift_signups)
- Implement RLS policies with SET LOCAL for tenant isolation
- Add Finbuckle multi-tenant middleware with ClaimStrategy + HeaderStrategy fallback
- Create TenantValidationMiddleware to enforce JWT claims match X-Tenant-Id header
- Add tenant-aware DB interceptors (SaveChangesTenantInterceptor, TenantDbConnectionInterceptor)
- Configure AppDbContext with tenant scoping and RLS support
- Add test infrastructure: CustomWebApplicationFactory, TestAuthHandler, DatabaseFixture
- Write TDD integration tests for multi-tenant isolation and RLS enforcement
- Add health check null safety for connection string

Tasks: 7 (PostgreSQL schema + migrations + RLS), 8 (Finbuckle multi-tenancy + validation), 12 (test infrastructure)
2026-03-03 14:32:21 +01:00

195 lines
6.5 KiB
C#

using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using Xunit;
namespace WorkClub.Tests.Integration.Middleware;
public class TenantValidationTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly CustomWebApplicationFactory _factory;
private readonly HttpClient _client;
public TenantValidationTests(CustomWebApplicationFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact]
public async Task Request_WithValidTenantId_Returns200()
{
// Arrange: Create JWT with clubs claim containing club-1
var clubs = new Dictionary<string, string>
{
{ "club-1", "admin" }
};
var token = CreateTestJwt(clubs);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
_client.DefaultRequestHeaders.Add("X-Tenant-Id", "club-1");
// Act: Make request to test endpoint
var response = await _client.GetAsync("/api/test");
// Assert: Request should succeed
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Request_WithNonMemberTenantId_Returns403()
{
// Arrange: Create JWT with clubs claim (only club-1, not club-2)
var clubs = new Dictionary<string, string>
{
{ "club-1", "admin" }
};
var token = CreateTestJwt(clubs);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
_client.DefaultRequestHeaders.Add("X-Tenant-Id", "club-2"); // User not member of club-2
// Act
var response = await _client.GetAsync("/api/test");
// Assert: Cross-tenant access should be denied
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Request_WithoutTenantIdHeader_Returns400()
{
// Arrange: Create valid JWT but no X-Tenant-Id header
var clubs = new Dictionary<string, string>
{
{ "club-1", "admin" }
};
var token = CreateTestJwt(clubs);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// No X-Tenant-Id header
// Act
var response = await _client.GetAsync("/api/test");
// Assert: Missing header should return bad request
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Request_WithoutAuthentication_Returns401()
{
// Arrange: No authorization header
_client.DefaultRequestHeaders.Add("X-Tenant-Id", "club-1");
// Act
var response = await _client.GetAsync("/api/test");
// Assert: Unauthenticated request should be denied
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
private static string CreateTestJwt(Dictionary<string, string> clubs)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-secret-key-for-jwt-signing-must-be-at-least-32-chars"));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, "test-user-id"),
new Claim(ClaimTypes.Name, "test@test.com"),
new Claim("clubs", JsonSerializer.Serialize(clubs)) // JSON object claim
};
var token = new JwtSecurityToken(
issuer: "test-issuer",
audience: "test-audience",
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: creds
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
/// <summary>
/// Custom WebApplicationFactory for integration testing with test authentication.
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication("TestScheme")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("TestScheme", options => { });
services.AddAuthorization();
});
builder.UseEnvironment("Testing");
}
}
/// <summary>
/// Test authentication handler that validates JWT tokens without Keycloak.
/// </summary>
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public TestAuthHandler(
Microsoft.Extensions.Options.IOptionsMonitor<AuthenticationSchemeOptions> options,
Microsoft.Extensions.Logging.ILoggerFactory logger,
System.Text.Encodings.Web.UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var authHeader = Request.Headers.Authorization.ToString();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer "))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var token = authHeader.Substring("Bearer ".Length).Trim();
try
{
var handler = new JwtSecurityTokenHandler();
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-secret-key-for-jwt-signing-must-be-at-least-32-chars"));
var validationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = "test-issuer",
ValidAudience = "test-audience",
IssuerSigningKey = key
};
var principal = handler.ValidateToken(token, validationParameters, out _);
var ticket = new AuthenticationTicket(principal, "TestScheme");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
catch (Exception ex)
{
return Task.FromResult(AuthenticateResult.Fail(ex.Message));
}
}
}