using Dapper; using Microsoft.EntityFrameworkCore; using Npgsql; using Testcontainers.PostgreSql; using WorkClub.Domain.Entities; using WorkClub.Infrastructure.Data; namespace WorkClub.Tests.Integration.Data; public class RlsTests : IAsyncLifetime { private PostgreSqlContainer? _container; private string? _connectionString; private string? _adminConnectionString; public async Task InitializeAsync() { _container = new PostgreSqlBuilder() .WithImage("postgres:16-alpine") .WithDatabase("workclub") .WithUsername("app_user") .WithPassword("apppass") .Build(); await _container.StartAsync(); _connectionString = _container.GetConnectionString(); _adminConnectionString = _connectionString.Replace("app_user", "app_admin") .Replace("apppass", "adminpass"); await using var adminConn = new NpgsqlConnection(_adminConnectionString); await adminConn.ExecuteAsync("CREATE ROLE app_admin WITH LOGIN PASSWORD 'adminpass' SUPERUSER"); await adminConn.ExecuteAsync("GRANT ALL PRIVILEGES ON DATABASE workclub TO app_admin"); } public async Task DisposeAsync() { if (_container != null) { await _container.DisposeAsync(); } } [Fact] public async Task RLS_BlocksAccess_WithoutTenantContext() { await SeedTestDataAsync(); await using var connection = new NpgsqlConnection(_connectionString); await connection.OpenAsync(); var clubs = (await connection.QueryAsync( "SELECT * FROM clubs")).ToList(); Assert.Empty(clubs); } [Fact] public async Task RLS_AllowsAccess_WithCorrectTenantContext() { await SeedTestDataAsync(); await using var connection = new NpgsqlConnection(_connectionString); await connection.OpenAsync(); await connection.ExecuteAsync("SET LOCAL app.current_tenant_id = 'tenant-1'"); var clubs = (await connection.QueryAsync( "SELECT * FROM clubs WHERE tenant_id = 'tenant-1'")).ToList(); Assert.NotEmpty(clubs); Assert.All(clubs, c => Assert.Equal("tenant-1", c.TenantId)); } [Fact] public async Task RLS_IsolatesData_AcrossTenants() { await SeedTestDataAsync(); await using var connection = new NpgsqlConnection(_connectionString); await connection.OpenAsync(); await connection.ExecuteAsync("SET LOCAL app.current_tenant_id = 'tenant-1'"); var tenant1Clubs = (await connection.QueryAsync( "SELECT * FROM clubs")).ToList(); await connection.ExecuteAsync("SET LOCAL app.current_tenant_id = 'tenant-2'"); var tenant2Clubs = (await connection.QueryAsync( "SELECT * FROM clubs")).ToList(); Assert.NotEmpty(tenant1Clubs); Assert.NotEmpty(tenant2Clubs); Assert.All(tenant1Clubs, c => Assert.Equal("tenant-1", c.TenantId)); Assert.All(tenant2Clubs, c => Assert.Equal("tenant-2", c.TenantId)); var tenant1Ids = tenant1Clubs.Select(c => c.Id).ToHashSet(); var tenant2Ids = tenant2Clubs.Select(c => c.Id).ToHashSet(); Assert.Empty(tenant1Ids.Intersect(tenant2Ids)); } [Fact] public async Task RLS_CountsCorrectly_PerTenant() { await SeedTestDataAsync(); await using var connection = new NpgsqlConnection(_connectionString); await connection.OpenAsync(); await connection.ExecuteAsync("SET LOCAL app.current_tenant_id = 'tenant-1'"); var tenant1Count = await connection.ExecuteScalarAsync( "SELECT COUNT(*) FROM work_items"); await connection.ExecuteAsync("SET LOCAL app.current_tenant_id = 'tenant-2'"); var tenant2Count = await connection.ExecuteScalarAsync( "SELECT COUNT(*) FROM work_items"); Assert.Equal(5, tenant1Count); Assert.Equal(3, tenant2Count); } [Fact] public async Task RLS_AllowsBypass_ForAdminRole() { await SeedTestDataAsync(); await using var connection = new NpgsqlConnection(_adminConnectionString); await connection.OpenAsync(); var allClubs = (await connection.QueryAsync( "SELECT * FROM clubs")).ToList(); Assert.True(allClubs.Count >= 2); Assert.Contains(allClubs, c => c.TenantId == "tenant-1"); Assert.Contains(allClubs, c => c.TenantId == "tenant-2"); } [Fact] public async Task RLS_HandlesShiftSignups_WithSubquery() { await SeedTestDataAsync(); await using var connection = new NpgsqlConnection(_connectionString); await connection.OpenAsync(); await connection.ExecuteAsync("SET LOCAL app.current_tenant_id = 'tenant-1'"); var signups = (await connection.QueryAsync( "SELECT * FROM shift_signups")).ToList(); Assert.NotEmpty(signups); Assert.All(signups, s => Assert.Equal("tenant-1", s.TenantId)); } private async Task SeedTestDataAsync() { var options = new DbContextOptionsBuilder() .UseNpgsql(_connectionString) .Options; await using var context = new AppDbContext(options); await context.Database.MigrateAsync(); await using var adminConn = new NpgsqlConnection(_adminConnectionString); await adminConn.OpenAsync(); var club1Id = Guid.NewGuid(); var club2Id = Guid.NewGuid(); await adminConn.ExecuteAsync(@" INSERT INTO clubs (id, tenant_id, name, sport_type, created_at, updated_at) VALUES (@Id1, 'tenant-1', 'Club 1', 0, NOW(), NOW()), (@Id2, 'tenant-2', 'Club 2', 1, NOW(), NOW())", new { Id1 = club1Id, Id2 = club2Id }); await adminConn.ExecuteAsync(@" INSERT INTO work_items (id, tenant_id, title, status, created_by_id, club_id, created_at, updated_at) SELECT gen_random_uuid(), 'tenant-1', 'Task ' || i, 0, gen_random_uuid(), @ClubId, NOW(), NOW() FROM generate_series(1, 5) i", new { ClubId = club1Id }); await adminConn.ExecuteAsync(@" INSERT INTO work_items (id, tenant_id, title, status, created_by_id, club_id, created_at, updated_at) SELECT gen_random_uuid(), 'tenant-2', 'Task ' || i, 0, gen_random_uuid(), @ClubId, NOW(), NOW() FROM generate_series(1, 3) i", new { ClubId = club2Id }); var shift1Id = Guid.NewGuid(); await adminConn.ExecuteAsync(@" INSERT INTO shifts (id, tenant_id, title, start_time, end_time, club_id, created_by_id, created_at, updated_at) VALUES (@Id, 'tenant-1', 'Shift 1', NOW(), NOW() + interval '2 hours', @ClubId, gen_random_uuid(), NOW(), NOW())", new { Id = shift1Id, ClubId = club1Id }); await adminConn.ExecuteAsync(@" INSERT INTO shift_signups (id, tenant_id, shift_id, member_id, signed_up_at) VALUES (gen_random_uuid(), 'tenant-1', @ShiftId, gen_random_uuid(), NOW())", new { ShiftId = shift1Id }); } }