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)
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user