using System.IdentityModel.Tokens.Jwt; using System.Net; using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.IdentityModel.Tokens; using Tiku.Api.Options; using Tiku.Application.Security; using Tiku.Domain.Tenancy; namespace Tiku.IntegrationTests.Api; public sealed class SecurityFoundationTests { private static readonly JwtOptions JwtOptions = new() { Issuer = "tiku-backend", Audience = "tiku-api", SigningKey = "development-only-tiku-signing-key-change-before-production" }; [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/_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 sessionId = await factory.SeedActiveSessionAsync(userId); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new( "Bearer", CreateToken([ new Claim(TikuClaimTypes.UserId, userId.ToString()), new Claim(TikuClaimTypes.SessionId, sessionId.ToString()), new Claim(TikuClaimTypes.TenantId, Guid.NewGuid().ToString()), new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString()) ])); var response = await client.GetAsync("/api/_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 sessionId = await factory.SeedActiveSessionAsync(userId); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new( "Bearer", CreateToken([ new Claim(TikuClaimTypes.UserId, userId.ToString()), new Claim(TikuClaimTypes.SessionId, sessionId.ToString()) ])); var response = await client.GetAsync("/api/_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 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/health"); HttpResponseMessage? rejectedResponse = null; for (var index = 0; index < 1200; index++) { rejectedResponse?.Dispose(); rejectedResponse = await client.GetAsync("/api/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(); } private static string CreateToken(IEnumerable claims) { var credentials = new SigningCredentials( new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtOptions.SigningKey)), SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( JwtOptions.Issuer, JwtOptions.Audience, claims, expires: DateTime.UtcNow.AddMinutes(5), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); } }