using System.Net; using System.Net.Http.Headers; using System.Security.Claims; using System.Text.Json; using Tiku.Api.Options; using Tiku.Application.Security; namespace Tiku.IntegrationTests.Api; public sealed class SecurityFoundationTests { [Fact] public async Task Authenticated_policy_returns_unauthorized_without_token() { await using var factory = CreateFactory(); using var client = factory.CreateClient(); var response = await client.GetAsync("/api/system/security/authenticated"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Tenant_admin_policy_returns_forbidden_for_non_admin_member() { await using var factory = CreateFactory(); var userId = Guid.NewGuid(); var tenantId = Guid.NewGuid(); var sessionId = await factory.SeedActiveSessionAsync( userId, tenantId, includeMembership: true); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, userId.ToString()), new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), new Claim(TikuClaimTypes.TenantId, tenantId.ToString()) ])); var response = await client.GetAsync("/api/system/security/tenant-admin"); Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); } [Fact] public async Task Controller_pipeline_loads_authenticated_current_user() { var userId = Guid.NewGuid(); await using var factory = CreateFactory(); var tenantId = Guid.NewGuid(); var sessionId = await factory.SeedActiveSessionAsync( userId, tenantId, includeMembership: true); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, userId.ToString()), new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), new Claim(TikuClaimTypes.TenantId, tenantId.ToString()) ])); var response = await client.GetAsync("/api/system/security/authenticated"); var body = await response.Content.ReadAsStringAsync(); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains(userId.ToString(), body, StringComparison.OrdinalIgnoreCase); Assert.Contains("true", body, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Jwt_without_jti_and_iat_is_rejected() { await using var factory = CreateFactory(); var userId = Guid.NewGuid(); var tenantId = Guid.NewGuid(); var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, userId.ToString()), new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), new Claim(TikuClaimTypes.TenantId, tenantId.ToString()) ], includeStandardClaims: false)); var response = await client.GetAsync("/api/system/security/authenticated"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Jwt_with_unknown_kid_is_rejected() { await using var factory = CreateFactory(); var userId = Guid.NewGuid(); var tenantId = Guid.NewGuid(); var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId, includeMembership: true); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("x-tenant-code", tenantId.ToString("N")); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", TestJwtKeys.CreateToken([ new Claim(TikuClaimTypes.UserId, userId.ToString()), new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), new Claim(TikuClaimTypes.TenantId, tenantId.ToString()) ], keyId: "unknown-key")); var response = await client.GetAsync("/api/system/security/authenticated"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Global_rate_limiter_returns_too_many_requests_problem() { await using var factory = new ApiTestFactory(); using var client = factory.CreateClient(); using var firstResponse = await client.GetAsync("/api/system/health"); HttpResponseMessage? rejectedResponse = null; for (var index = 0; index < 1200; index++) { rejectedResponse?.Dispose(); rejectedResponse = await client.GetAsync("/api/system/health"); } using var secondResponse = rejectedResponse ?? throw new InvalidOperationException( "Rate limit test did not send a second request."); var body = JsonDocument.Parse(await secondResponse.Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, firstResponse.StatusCode); Assert.Equal(HttpStatusCode.TooManyRequests, secondResponse.StatusCode); Assert.Equal("rate_limited", body.RootElement.GetProperty("code").GetString()); } [Theory] [InlineData("https://tenant.example.com", true)] [InlineData("http://localhost:5173", true)] [InlineData("localhost:5173", false)] [InlineData("https://tenant.example.com/path", false)] public void Cors_options_validation_requires_absolute_http_origins(string origin, bool expected) { var options = new CorsOptions { AllowedOrigins = [origin] }; Assert.Equal(expected, OptionsValidation.BeValidCorsOptions(options)); } [Fact] public void Cors_options_validation_requires_explicit_origins_when_credentials_are_enabled() { var options = new CorsOptions { AllowCredentials = true }; Assert.False(OptionsValidation.BeValidCorsOptions(options)); } private static ApiTestFactory CreateFactory() { return new ApiTestFactory(); } }