using FluentAssertions; using InboxIntel.Application.Abstractions; using InboxIntel.Domain.Entities; using InboxIntel.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Xunit; namespace InboxIntel.IntegrationTests; /// /// Verifies the global query-filter IDOR safeguard on AppDbContext: an HTTP-scoped /// context (with a current user) sees only that user's rows even if a query forgets /// its manual UserId filter; a worker-scoped context (Guid.Empty) sees everything. /// public class TenantIsolationTests { private sealed class FakeCurrentUser : ICurrentUser { public Guid UserId { get; set; } public bool IsAuthenticated => UserId != Guid.Empty; } private static DbContextOptions InMemory(string name) => new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options; private static readonly Guid UserA = Guid.NewGuid(); private static readonly Guid UserB = Guid.NewGuid(); private static async Task SeedAsync(DbContextOptions opts) { // Seed with no current user (Guid.Empty) so the filter is bypassed for writes/reads here. using var seed = new AppDbContext(opts, new FakeCurrentUser()); seed.Emails.Add(new Email { UserId = UserA, GmailMessageId = "a1", Subject = "A-one" }); seed.Emails.Add(new Email { UserId = UserA, GmailMessageId = "a2", Subject = "A-two" }); seed.Emails.Add(new Email { UserId = UserB, GmailMessageId = "b1", Subject = "B-one" }); await seed.SaveChangesAsync(); } [Fact] public async Task Authenticated_context_sees_only_its_own_rows_even_without_manual_filter() { var opts = InMemory(nameof(Authenticated_context_sees_only_its_own_rows_even_without_manual_filter)); await SeedAsync(opts); // Note: NO manual .Where(e => e.UserId == ...) here — the global filter must enforce it. using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = UserA }); var emails = await ctx.Emails.ToListAsync(); emails.Should().HaveCount(2); emails.Should().OnlyContain(e => e.UserId == UserA); } [Fact] public async Task Other_users_row_is_invisible_by_id() { var opts = InMemory(nameof(Other_users_row_is_invisible_by_id)); await SeedAsync(opts); using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = UserA }); // Fetch B's row by its GmailMessageId — classic IDOR attempt; must return null. var leaked = await ctx.Emails.FirstOrDefaultAsync(e => e.GmailMessageId == "b1"); leaked.Should().BeNull(); } [Fact] public async Task Empty_current_user_bypasses_the_filter_for_background_workers() { var opts = InMemory(nameof(Empty_current_user_bypasses_the_filter_for_background_workers)); await SeedAsync(opts); // Guid.Empty == background/worker scope: must see all tenants' rows so sync/upsert works. using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = Guid.Empty }); var all = await ctx.Emails.ToListAsync(); all.Should().HaveCount(3); } }