using System.Net; using System.Net.Http.Json; using System.Security.Claims; using System.Text.Encodings.Web; using FluentAssertions; using InboxIntel.Application.Abstractions; using InboxIntel.Domain.Entities; using InboxIntel.Infrastructure.Persistence; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Xunit; namespace InboxIntel.IntegrationTests; // ── Tests proving the Phase-2 audit fixes (see AUDIT_REPORT.md) ──────────────────────────── /// Pass-through auth scheme so integration tests can exercise authenticated /// endpoints (model validation, per-user rate limits) without a real Google login. public class TestAuthHandler : AuthenticationHandler { public const string Scheme = "Test"; // Stable across requests so per-user rate-limit partitions accumulate correctly. public static readonly string Uid = Guid.NewGuid().ToString(); public TestAuthHandler(IOptionsMonitor o, ILoggerFactory l, UrlEncoder e) : base(o, l, e) { } protected override Task HandleAuthenticateAsync() { var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-sub"), new Claim("inboxintel:uid", Uid), }, Scheme); return Task.FromResult(AuthenticateResult.Success( new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme))); } } /// Factory with the test auth scheme + tiny rate-limit windows so limits trip fast. public class AuditTestAppFactory : WebApplicationFactory { protected override IHost CreateHost(IHostBuilder builder) { builder.ConfigureHostConfiguration(cfg => cfg.AddInMemoryCollection(new Dictionary { ["Database:AutoMigrate"] = "false", ["GoogleOAuth:ClientId"] = "test-client-id", ["GoogleOAuth:ClientSecret"] = "test-client-secret", // H-2: make the auth policy trip on the 3rd request within the window. ["RateLimiting:AuthPermitLimit"] = "2", ["RateLimiting:WindowSeconds"] = "60", })); return base.CreateHost(builder); } protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureServices(services => { services.AddAuthentication(TestAuthHandler.Scheme) .AddScheme(TestAuthHandler.Scheme, _ => { }); services.PostConfigure(o => { o.DefaultAuthenticateScheme = TestAuthHandler.Scheme; o.DefaultChallengeScheme = TestAuthHandler.Scheme; }); }); } } public class AuditFixesTests : IClassFixture { private readonly AuditTestAppFactory _factory; public AuditFixesTests(AuditTestAppFactory factory) => _factory = factory; // H-1: FluentValidation auto-validation now rejects invalid DTOs at the boundary with 400 // (previously the registered validators never executed). [Fact] public async Task Invalid_search_request_is_rejected_with_400_by_the_validator() { var client = _factory.CreateClient(); var resp = await client.PostAsJsonAsync("/api/v1/search", new { page = 1, pageSize = 0 }); resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); } [Fact] public async Task Search_with_from_after_to_is_rejected_with_400() { var client = _factory.CreateClient(); var resp = await client.PostAsJsonAsync("/api/v1/search", new { page = 1, pageSize = 10, from = "2026-02-01", to = "2026-01-01" }); resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); } // H-2: the "auth" rate-limit policy returns 429 once the per-window permit is exhausted. [Fact] public async Task Auth_endpoint_rate_limits_with_429_after_the_permit_is_exhausted() { var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); var s1 = (await client.GetAsync("/api/v1/auth/login")).StatusCode; var s2 = (await client.GetAsync("/api/v1/auth/login")).StatusCode; var s3 = (await client.GetAsync("/api/v1/auth/login")).StatusCode; s1.Should().NotBe(HttpStatusCode.TooManyRequests); s2.Should().NotBe(HttpStatusCode.TooManyRequests); s3.Should().Be(HttpStatusCode.TooManyRequests); } } // M-1: absolute session lifetime — a session older than the cap (or missing its issued // stamp) is expired regardless of sliding renewal. public class SessionLifetimeTests { private static readonly DateTimeOffset Now = new(2026, 07, 02, 12, 0, 0, TimeSpan.Zero); private static readonly TimeSpan Max = TimeSpan.FromDays(30); [Fact] public void Fresh_session_is_not_expired() => SessionLifetime.IsExpired(Now.AddDays(-1).ToString("O"), Now, Max).Should().BeFalse(); [Fact] public void Session_older_than_the_cap_is_expired() => SessionLifetime.IsExpired(Now.AddDays(-31).ToString("O"), Now, Max).Should().BeTrue(); [Fact] public void Session_without_a_stamp_is_expired() => SessionLifetime.IsExpired(null, Now, Max).Should().BeTrue(); } // M-6: EmailLabel now carries a matching tenant query filter (via its Email navigation), so // join rows can never leak across users even without a manual Where. public class EmailLabelFilterTests { private sealed class FakeCurrentUser : ICurrentUser { public Guid UserId { get; set; } public bool IsAuthenticated => UserId != Guid.Empty; } [Fact] public async Task EmailLabels_are_invisible_across_users() { var userA = Guid.NewGuid(); var userB = Guid.NewGuid(); var opts = new DbContextOptionsBuilder() .UseInMemoryDatabase(nameof(EmailLabels_are_invisible_across_users)).Options; using (var seed = new AppDbContext(opts, new FakeCurrentUser())) { var emailA = new Email { UserId = userA, GmailMessageId = "a1" }; var emailB = new Email { UserId = userB, GmailMessageId = "b1" }; var labelA = new Label { UserId = userA, GmailLabelId = "LA", Name = "A" }; var labelB = new Label { UserId = userB, GmailLabelId = "LB", Name = "B" }; seed.AddRange(emailA, emailB, labelA, labelB, new EmailLabel { EmailId = emailA.Id, LabelId = labelA.Id }, new EmailLabel { EmailId = emailB.Id, LabelId = labelB.Id }); await seed.SaveChangesAsync(); } using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = userA }); var visible = await ctx.Set().ToListAsync(); // no manual Where — filter must enforce visible.Should().HaveCount(1); } }