test(harness): stabilize backend+frontend QA test suite (12/12+63/63 unit+integration, 45/45 frontend)
Stabilize test harness across full stack: Backend integration tests: - Fix Auth/Club/Migration/RLS/Member/Tenant/RLS Isolation/Shift/Task test suites - Add AssemblyInfo.cs for test configuration - Enhance CustomWebApplicationFactory + TestAuthHandler for stable test environment - Expand RlsIsolationTests with comprehensive multi-tenant RLS verification Frontend test harness: - Align vitest.config.ts with backend API changes - Add bunfig.toml for bun test environment stability - Enhance api.test.ts with proper test setup integration - Expand test/setup.ts with fixture initialization All tests now passing: backend 12/12 unit + 63/63 integration, frontend 45/45
This commit is contained in:
@@ -1,194 +1,56 @@
|
||||
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 WorkClub.Tests.Integration.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace WorkClub.Tests.Integration.Middleware;
|
||||
|
||||
public class TenantValidationTests : IClassFixture<CustomWebApplicationFactory>
|
||||
public class TenantValidationTests : IntegrationTestBase
|
||||
{
|
||||
private readonly CustomWebApplicationFactory _factory;
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public TenantValidationTests(CustomWebApplicationFactory factory)
|
||||
public TenantValidationTests(CustomWebApplicationFactory<Program> factory) : base(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);
|
||||
AuthenticateAs("test@test.com", new Dictionary<string, string> { ["club-1"] = "admin" });
|
||||
SetTenant("club-1");
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
_client.DefaultRequestHeaders.Add("X-Tenant-Id", "club-1");
|
||||
var response = await Client.GetAsync("/api/test");
|
||||
|
||||
// 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);
|
||||
AuthenticateAs("test@test.com", new Dictionary<string, string> { ["club-1"] = "admin" });
|
||||
SetTenant("club-2");
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
_client.DefaultRequestHeaders.Add("X-Tenant-Id", "club-2"); // User not member of club-2
|
||||
var response = await Client.GetAsync("/api/test");
|
||||
|
||||
// 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);
|
||||
AuthenticateAs("test@test.com", new Dictionary<string, string> { ["club-1"] = "admin" });
|
||||
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
// No X-Tenant-Id header
|
||||
var response = await Client.GetAsync("/api/test");
|
||||
|
||||
// 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");
|
||||
AuthenticateAsUnauthenticated();
|
||||
SetTenant("club-1");
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/test");
|
||||
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