using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Moq; using PowderCoating.Core.Entities; using PowderCoating.Core.Enums; using PowderCoating.Core.Interfaces; using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Repositories; using PowderCoating.Web.Controllers; namespace PowderCoating.UnitTests; /// /// Verifies that the explicit CompanyId == companyId predicates added to every /// user-facing controller action actually prevent cross-tenant data leakage. /// /// Each test seeds entities for TWO companies, creates a controller whose ITenantContext /// returns Company 1's ID, calls the action, and asserts that Company 2's data never /// appears in the result. /// /// These tests validate the defense-in-depth layer (explicit predicates in controllers) /// independently of the EF Core global query filters, which behave differently on the /// in-memory provider when no HttpContext is present. /// public class MultiTenantIsolationTests { // ── Repository-level isolation ──────────────────────────────────────────── /// /// FindAsync with an explicit CompanyId predicate returns only the matching company's rows, /// even when rows from other companies exist in the database. /// [Fact] public async Task Repository_FindAsync_WithCompanyIdPredicate_ExcludesOtherTenants() { await using var context = CreateContext(); context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice")); context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob")); context.Customers.Add(MakeCustomer(id: 3, companyId: 1, firstName: "Carol")); await context.SaveChangesAsync(); var uow = new UnitOfWork(context); var results = (await uow.Customers.FindAsync(c => c.CompanyId == 1)).ToList(); Assert.Equal(2, results.Count); Assert.All(results, c => Assert.Equal(1, c.CompanyId)); Assert.DoesNotContain(results, c => c.ContactFirstName == "Bob"); } [Fact] public async Task Repository_FindAsync_ReturnsEmpty_WhenNoMatchingCompanyId() { await using var context = CreateContext(); context.Customers.Add(MakeCustomer(id: 1, companyId: 2, firstName: "Bob")); await context.SaveChangesAsync(); var uow = new UnitOfWork(context); var results = await uow.Customers.FindAsync(c => c.CompanyId == 1); Assert.Empty(results); } // ── SmsConsentAuditController ───────────────────────────────────────────── [Fact] public async Task SmsConsentAudit_Index_ReturnsOnlyCurrentCompanyCustomers() { await using var context = CreateContext(); context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice")); context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob")); // other company context.Customers.Add(MakeCustomer(id: 3, companyId: 1, firstName: "Carol")); await context.SaveChangesAsync(); var controller = new SmsConsentAuditController( new UnitOfWork(context), MockTenant(companyId: 1), Mock.Of>()); SetHttpContext(controller); var result = await controller.Index(); var view = Assert.IsType(result); var vm = Assert.IsType(view.Model); Assert.Equal(2, vm.TotalCount); Assert.DoesNotContain(vm.Rows, r => r.CustomerName.Contains("Bob")); } [Fact] public async Task SmsConsentAudit_ExportCsv_ContainsOnlyCurrentCompanyCustomers() { await using var context = CreateContext(); context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice")); context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob")); await context.SaveChangesAsync(); var controller = new SmsConsentAuditController( new UnitOfWork(context), MockTenant(companyId: 1), Mock.Of>()); SetHttpContext(controller); var result = await controller.ExportCsv(); var file = Assert.IsType(result); var csv = System.Text.Encoding.UTF8.GetString(file.FileContents); Assert.Contains("Alice", csv); Assert.DoesNotContain("Bob", csv); } // ── TaxRatesController ──────────────────────────────────────────────────── [Fact] public async Task TaxRates_Index_ReturnsOnlyCurrentCompanyRates() { await using var context = CreateContext(); context.TaxRates.Add(MakeTaxRate(id: 1, companyId: 1, name: "State Tax")); context.TaxRates.Add(MakeTaxRate(id: 2, companyId: 2, name: "Foreign Tax")); // other company context.TaxRates.Add(MakeTaxRate(id: 3, companyId: 1, name: "Local Tax")); await context.SaveChangesAsync(); var controller = new TaxRatesController( new UnitOfWork(context), MockTenant(companyId: 1), Mock.Of>()); SetHttpContext(controller); var result = await controller.Index(); var view = Assert.IsType(result); var rates = Assert.IsAssignableFrom>(view.Model).ToList(); Assert.Equal(2, rates.Count); Assert.All(rates, r => Assert.Equal(1, r.CompanyId)); Assert.DoesNotContain(rates, r => r.Name == "Foreign Tax"); } // ── RecurringTemplatesController ────────────────────────────────────────── [Fact] public async Task RecurringTemplates_Index_ReturnsOnlyCurrentCompanyTemplates() { await using var context = CreateContext(); context.RecurringTemplates.Add(MakeRecurringTemplate(id: 1, companyId: 1, name: "Monthly Rent")); context.RecurringTemplates.Add(MakeRecurringTemplate(id: 2, companyId: 2, name: "Other Tenant Bill")); // other company await context.SaveChangesAsync(); var controller = new RecurringTemplatesController( new UnitOfWork(context), MockTenant(companyId: 1), CreateUserManagerMock().Object, Mock.Of>()); SetHttpContext(controller); var result = await controller.Index(); var view = Assert.IsType(result); var templates = Assert.IsAssignableFrom>(view.Model).ToList(); Assert.Single(templates); Assert.Equal("Monthly Rent", templates[0].Name); } // ── JobTemplatesController ──────────────────────────────────────────────── [Fact] public async Task JobTemplates_Index_ReturnsOnlyCurrentCompanyTemplates() { await using var context = CreateContext(); context.JobTemplates.Add(MakeJobTemplate(id: 1, companyId: 1, name: "Standard Wheel Coat")); context.JobTemplates.Add(MakeJobTemplate(id: 2, companyId: 2, name: "Other Company Template")); // other company await context.SaveChangesAsync(); var controller = new JobTemplatesController( new UnitOfWork(context), MockTenant(companyId: 1)); SetHttpContext(controller); var result = await controller.Index(); var view = Assert.IsType(result); var templates = Assert.IsAssignableFrom>(view.Model).ToList(); Assert.Single(templates); Assert.Equal("Standard Wheel Coat", templates[0].Name); } // ── Cross-tenant write protection ───────────────────────────────────────── /// /// Verifies that the companyId-scoped FindAsync used for SMS export returns zero /// rows for a company that has no customers, even when another company has many. /// Guards against the "empty predicate returns all" regression. /// [Fact] public async Task SmsConsentAudit_ExportCsv_IsEmpty_WhenCompanyHasNoCustomers() { await using var context = CreateContext(); context.Customers.Add(MakeCustomer(id: 1, companyId: 2, firstName: "Other")); context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Also Other")); await context.SaveChangesAsync(); var controller = new SmsConsentAuditController( new UnitOfWork(context), MockTenant(companyId: 1), // Company 1 has no customers Mock.Of>()); SetHttpContext(controller); var result = await controller.ExportCsv(); var file = Assert.IsType(result); var csv = System.Text.Encoding.UTF8.GetString(file.FileContents); // Only header row, no data rows Assert.DoesNotContain("Other", csv); } // ── Helpers ─────────────────────────────────────────────────────────────── private static ApplicationDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; return new ApplicationDbContext(options); } /// Returns a mock ITenantContext that always yields the given companyId. private static ITenantContext MockTenant(int companyId) { var mock = new Mock(); mock.Setup(t => t.GetCurrentCompanyId()).Returns(companyId); return mock.Object; } private static void SetHttpContext(Controller controller) { controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }; } private static Customer MakeCustomer(int id, int companyId, string firstName) => new() { Id = id, CompanyId = companyId, ContactFirstName = firstName, ContactLastName = "Test", IsCommercial = false }; private static TaxRate MakeTaxRate(int id, int companyId, string name) => new() { Id = id, CompanyId = companyId, Name = name, Rate = 8.5m }; private static RecurringTemplate MakeRecurringTemplate(int id, int companyId, string name) => new() { Id = id, CompanyId = companyId, Name = name, TemplateType = RecurringTemplateType.Bill, Frequency = RecurringFrequency.Monthly, IntervalCount = 1, NextFireDate = DateTime.Today, IsActive = true }; private static JobTemplate MakeJobTemplate(int id, int companyId, string name) => new() { Id = id, CompanyId = companyId, Name = name }; private static Mock> CreateUserManagerMock() { var store = new Mock>(); return new Mock>( store.Object, null!, null!, null!, null!, null!, null!, null!, null!); } }