2026-03-03 19:11:01 +01:00
using System.Collections.Concurrent ;
using System.Net ;
using System.Net.Http.Json ;
using Dapper ;
using Microsoft.EntityFrameworkCore ;
using Microsoft.Extensions.DependencyInjection ;
using Npgsql ;
using WorkClub.Domain.Entities ;
using WorkClub.Domain.Enums ;
using WorkClub.Infrastructure.Data ;
using WorkClub.Infrastructure.Data.Interceptors ;
using WorkClub.Tests.Integration.Infrastructure ;
namespace WorkClub.Tests.Integration.MultiTenancy ;
/// <summary>
/// Comprehensive RLS (Row-Level Security) integration tests proving multi-tenant data isolation.
/// These tests verify that PostgreSQL RLS policies correctly enforce tenant boundaries.
/// </summary>
public class RlsIsolationTests : IntegrationTestBase
{
private readonly AppDbContext _dbContext ;
public RlsIsolationTests ( CustomWebApplicationFactory < Program > factory ) : base ( factory )
{
var scope = factory . Services . CreateScope ( ) ;
_dbContext = scope . ServiceProvider . GetRequiredService < AppDbContext > ( ) ;
}
[Fact]
public async Task Test1_CompleteIsolation_TenantsSeeOnlyTheirData ( )
{
2026-03-06 09:19:32 +01:00
await ClearDataAsync ( ) ;
2026-03-03 19:11:01 +01:00
var clubAId = Guid . NewGuid ( ) ;
var clubBId = Guid . NewGuid ( ) ;
var memberA1Id = Guid . NewGuid ( ) ;
var memberB1Id = Guid . NewGuid ( ) ;
var workItemA1Id = Guid . NewGuid ( ) ;
var workItemB1Id = Guid . NewGuid ( ) ;
await using var adminConn = new NpgsqlConnection ( GetAdminConnectionString ( ) ) ;
await adminConn . OpenAsync ( ) ;
2026-03-06 09:19:32 +01:00
await using var txn = await adminConn . BeginTransactionAsync ( ) ;
2026-03-03 19:11:01 +01:00
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO clubs (""Id"", ""TenantId"", ""Name"", ""SportType"", ""CreatedAt"", ""UpdatedAt"")
2026-03-03 19:11:01 +01:00
VALUES (@Id, 'club-a', 'Club Alpha', 0, NOW(), NOW())" ,
new { Id = clubAId } ) ;
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO members (""Id"", ""TenantId"", ""ExternalUserId"", ""DisplayName"", ""Email"", ""ClubId"", ""Role"", ""CreatedAt"", ""UpdatedAt"")
VALUES (@Id, 'club-a', 'user-alice', 'Alice', 'alice@club-a.com', @ClubId, 1, NOW(), NOW())" ,
2026-03-03 19:11:01 +01:00
new { Id = memberA1Id , ClubId = clubAId } ) ;
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO work_items (""Id"", ""TenantId"", ""Title"", ""Status"", ""CreatedById"", ""ClubId"", ""CreatedAt"", ""UpdatedAt"")
2026-03-03 19:11:01 +01:00
VALUES (@Id, 'club-a', 'Task Alpha', 0, @MemberId, @ClubId, NOW(), NOW())" ,
new { Id = workItemA1Id , MemberId = memberA1Id , ClubId = clubAId } ) ;
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO clubs (""Id"", ""TenantId"", ""Name"", ""SportType"", ""CreatedAt"", ""UpdatedAt"")
2026-03-03 19:11:01 +01:00
VALUES (@Id, 'club-b', 'Club Beta', 1, NOW(), NOW())" ,
new { Id = clubBId } ) ;
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO members (""Id"", ""TenantId"", ""ExternalUserId"", ""DisplayName"", ""Email"", ""ClubId"", ""Role"", ""CreatedAt"", ""UpdatedAt"")
VALUES (@Id, 'club-b', 'user-bob', 'Bob', 'bob@club-b.com', @ClubId, 1, NOW(), NOW())" ,
2026-03-03 19:11:01 +01:00
new { Id = memberB1Id , ClubId = clubBId } ) ;
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO work_items (""Id"", ""TenantId"", ""Title"", ""Status"", ""CreatedById"", ""ClubId"", ""CreatedAt"", ""UpdatedAt"")
2026-03-03 19:11:01 +01:00
VALUES (@Id, 'club-b', 'Task Beta', 0, @MemberId, @ClubId, NOW(), NOW())" ,
new { Id = workItemB1Id , MemberId = memberB1Id , ClubId = clubBId } ) ;
2026-03-06 09:19:32 +01:00
await txn . CommitAsync ( ) ;
await using var connA = new NpgsqlConnection ( GetRlsUserConnectionString ( ) ) ;
2026-03-03 19:11:01 +01:00
await connA . OpenAsync ( ) ;
2026-03-06 09:19:32 +01:00
await using var txnA = await connA . BeginTransactionAsync ( ) ;
2026-03-03 19:11:01 +01:00
await connA . ExecuteAsync ( "SET LOCAL app.current_tenant_id = 'club-a'" ) ;
var workItemsA = ( await connA . QueryAsync < WorkItem > ( "SELECT * FROM work_items" ) ) . ToList ( ) ;
var clubsA = ( await connA . QueryAsync < Club > ( "SELECT * FROM clubs" ) ) . ToList ( ) ;
2026-03-06 09:19:32 +01:00
await txnA . CommitAsync ( ) ;
2026-03-03 19:11:01 +01:00
2026-03-06 09:19:32 +01:00
await using var connB = new NpgsqlConnection ( GetRlsUserConnectionString ( ) ) ;
2026-03-03 19:11:01 +01:00
await connB . OpenAsync ( ) ;
2026-03-06 09:19:32 +01:00
await using var txnB = await connB . BeginTransactionAsync ( ) ;
2026-03-03 19:11:01 +01:00
await connB . ExecuteAsync ( "SET LOCAL app.current_tenant_id = 'club-b'" ) ;
var workItemsB = ( await connB . QueryAsync < WorkItem > ( "SELECT * FROM work_items" ) ) . ToList ( ) ;
var clubsB = ( await connB . QueryAsync < Club > ( "SELECT * FROM clubs" ) ) . ToList ( ) ;
2026-03-06 09:19:32 +01:00
await txnB . CommitAsync ( ) ;
2026-03-03 19:11:01 +01:00
Assert . Single ( workItemsA ) ;
Assert . Equal ( workItemA1Id , workItemsA [ 0 ] . Id ) ;
Assert . Equal ( "club-a" , workItemsA [ 0 ] . TenantId ) ;
Assert . Equal ( "Task Alpha" , workItemsA [ 0 ] . Title ) ;
Assert . Single ( clubsA ) ;
Assert . Equal ( clubAId , clubsA [ 0 ] . Id ) ;
Assert . Single ( workItemsB ) ;
Assert . Equal ( workItemB1Id , workItemsB [ 0 ] . Id ) ;
Assert . Equal ( "club-b" , workItemsB [ 0 ] . TenantId ) ;
Assert . Equal ( "Task Beta" , workItemsB [ 0 ] . Title ) ;
Assert . Single ( clubsB ) ;
Assert . Equal ( clubBId , clubsB [ 0 ] . Id ) ;
Assert . DoesNotContain ( workItemsA , w = > w . Id = = workItemB1Id ) ;
Assert . DoesNotContain ( workItemsB , w = > w . Id = = workItemA1Id ) ;
}
[Fact]
public async Task Test2_NoContext_NoData_RlsBlocksEverything ( )
{
2026-03-06 09:19:32 +01:00
await ClearDataAsync ( ) ;
2026-03-03 19:11:01 +01:00
var clubAId = Guid . NewGuid ( ) ;
var clubBId = Guid . NewGuid ( ) ;
await using var adminConn = new NpgsqlConnection ( GetAdminConnectionString ( ) ) ;
await adminConn . OpenAsync ( ) ;
2026-03-06 09:19:32 +01:00
await using var txn = await adminConn . BeginTransactionAsync ( ) ;
2026-03-03 19:11:01 +01:00
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO clubs (""Id"", ""TenantId"", ""Name"", ""SportType"", ""CreatedAt"", ""UpdatedAt"")
2026-03-03 19:11:01 +01:00
VALUES (@Id1, 'club-a', 'Club Alpha', 0, NOW(), NOW()),
(@Id2, 'club-b', 'Club Beta', 1, NOW(), NOW())" ,
new { Id1 = clubAId , Id2 = clubBId } ) ;
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO work_items (""Id"", ""TenantId"", ""Title"", ""Status"", ""CreatedById"", ""ClubId"", ""CreatedAt"", ""UpdatedAt"")
2026-03-03 19:11:01 +01:00
SELECT gen_random_uuid(), 'club-a', 'Task A' || i, 0, gen_random_uuid(), @ClubId, NOW(), NOW()
FROM generate_series(1, 3) i" ,
new { ClubId = clubAId } ) ;
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO work_items (""Id"", ""TenantId"", ""Title"", ""Status"", ""CreatedById"", ""ClubId"", ""CreatedAt"", ""UpdatedAt"")
2026-03-03 19:11:01 +01:00
SELECT gen_random_uuid(), 'club-b', 'Task B' || i, 0, gen_random_uuid(), @ClubId, NOW(), NOW()
FROM generate_series(1, 3) i" ,
new { ClubId = clubBId } ) ;
2026-03-06 09:19:32 +01:00
await txn . CommitAsync ( ) ;
await using var conn = new NpgsqlConnection ( GetRlsUserConnectionString ( ) ) ;
2026-03-03 19:11:01 +01:00
await conn . OpenAsync ( ) ;
2026-03-06 09:19:32 +01:00
await using var queryTxn = await conn . BeginTransactionAsync ( ) ;
2026-03-03 19:11:01 +01:00
var clubs = ( await conn . QueryAsync < Club > ( "SELECT * FROM clubs" ) ) . ToList ( ) ;
var workItems = ( await conn . QueryAsync < WorkItem > ( "SELECT * FROM work_items" ) ) . ToList ( ) ;
2026-03-06 09:19:32 +01:00
await queryTxn . CommitAsync ( ) ;
2026-03-03 19:11:01 +01:00
Assert . Empty ( clubs ) ;
Assert . Empty ( workItems ) ;
}
[Fact]
public async Task Test3_InsertProtection_CrossTenantInsertBlocked ( )
{
2026-03-06 09:19:32 +01:00
await ClearDataAsync ( ) ;
2026-03-03 19:11:01 +01:00
var clubAId = Guid . NewGuid ( ) ;
var memberAId = Guid . NewGuid ( ) ;
await using var adminConn = new NpgsqlConnection ( GetAdminConnectionString ( ) ) ;
await adminConn . OpenAsync ( ) ;
2026-03-06 09:19:32 +01:00
await using var txn = await adminConn . BeginTransactionAsync ( ) ;
2026-03-03 19:11:01 +01:00
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO clubs (""Id"", ""TenantId"", ""Name"", ""SportType"", ""CreatedAt"", ""UpdatedAt"")
2026-03-03 19:11:01 +01:00
VALUES (@Id, 'club-a', 'Club Alpha', 0, NOW(), NOW())" ,
new { Id = clubAId } ) ;
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO members (""Id"", ""TenantId"", ""ExternalUserId"", ""DisplayName"", ""Email"", ""ClubId"", ""Role"", ""CreatedAt"", ""UpdatedAt"")
VALUES (@Id, 'club-a', 'user-alice', 'Alice', 'alice@club-a.com', @ClubId, 1, NOW(), NOW())" ,
2026-03-03 19:11:01 +01:00
new { Id = memberAId , ClubId = clubAId } ) ;
2026-03-06 09:19:32 +01:00
await txn . CommitAsync ( ) ;
await using var conn = new NpgsqlConnection ( GetRlsUserConnectionString ( ) ) ;
2026-03-03 19:11:01 +01:00
await conn . OpenAsync ( ) ;
2026-03-06 09:19:32 +01:00
await using var insertTxn = await conn . BeginTransactionAsync ( ) ;
2026-03-03 19:11:01 +01:00
await conn . ExecuteAsync ( "SET LOCAL app.current_tenant_id = 'club-a'" ) ;
var workItemId = Guid . NewGuid ( ) ;
var insertSql = @"
2026-03-06 09:19:32 +01:00
INSERT INTO work_items (""Id"", ""TenantId"", ""Title"", ""Status"", ""CreatedById"", ""ClubId"", ""CreatedAt"", ""UpdatedAt"")
2026-03-03 19:11:01 +01:00
VALUES (@Id, 'club-b', 'Malicious Task', 0, @MemberId, @ClubId, NOW(), NOW())" ;
2026-03-06 09:19:32 +01:00
var exception = await Assert . ThrowsAsync < Npgsql . PostgresException > ( async ( ) = >
await conn . ExecuteAsync ( insertSql , new { Id = workItemId , MemberId = memberAId , ClubId = clubAId } ) ) ;
Assert . Contains ( "row-level security policy" , exception . Message ) ;
await using var verifyConn = new NpgsqlConnection ( GetRlsUserConnectionString ( ) ) ;
await verifyConn . OpenAsync ( ) ;
await using var verifyTxn = await verifyConn . BeginTransactionAsync ( ) ;
await verifyConn . ExecuteAsync ( "SET LOCAL app.current_tenant_id = 'club-b'" ) ;
var insertedItems = ( await verifyConn . QueryAsync < WorkItem > (
"SELECT * FROM work_items WHERE \"Id\" = @Id" , new { Id = workItemId } ) ) . ToList ( ) ;
await verifyTxn . CommitAsync ( ) ;
2026-03-03 19:11:01 +01:00
Assert . Empty ( insertedItems ) ;
}
[Fact]
public async Task Test4_ConcurrentRequests_ConnectionPoolSafety ( )
{
2026-03-06 09:19:32 +01:00
await ClearDataAsync ( ) ;
2026-03-03 19:11:01 +01:00
var clubAId = Guid . NewGuid ( ) ;
var clubBId = Guid . NewGuid ( ) ;
await using var adminConn = new NpgsqlConnection ( GetAdminConnectionString ( ) ) ;
await adminConn . OpenAsync ( ) ;
2026-03-06 09:19:32 +01:00
await using var txn = await adminConn . BeginTransactionAsync ( ) ;
2026-03-03 19:11:01 +01:00
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO clubs (""Id"", ""TenantId"", ""Name"", ""SportType"", ""CreatedAt"", ""UpdatedAt"")
2026-03-03 19:11:01 +01:00
VALUES (@Id1, 'club-a', 'Club Alpha', 0, NOW(), NOW()),
(@Id2, 'club-b', 'Club Beta', 1, NOW(), NOW())" ,
new { Id1 = clubAId , Id2 = clubBId } ) ;
var memberAId = Guid . NewGuid ( ) ;
var memberBId = Guid . NewGuid ( ) ;
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO members (""Id"", ""TenantId"", ""ExternalUserId"", ""DisplayName"", ""Email"", ""ClubId"", ""Role"", ""CreatedAt"", ""UpdatedAt"")
VALUES (@IdA, 'club-a', 'user-alice', 'Alice', 'alice@club-a.com', @ClubAId, 1, NOW(), NOW()),
(@IdB, 'club-b', 'user-bob', 'Bob', 'bob@club-b.com', @ClubBId, 1, NOW(), NOW())" ,
2026-03-03 19:11:01 +01:00
new { IdA = memberAId , ClubAId = clubAId , IdB = memberBId , ClubBId = clubBId } ) ;
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO work_items (""Id"", ""TenantId"", ""Title"", ""Status"", ""CreatedById"", ""ClubId"", ""CreatedAt"", ""UpdatedAt"")
2026-03-03 19:11:01 +01:00
SELECT gen_random_uuid(), 'club-a', 'Task A' || i, 0, @MemberId, @ClubId, NOW(), NOW()
FROM generate_series(1, 25) i" ,
new { MemberId = memberAId , ClubId = clubAId } ) ;
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO work_items (""Id"", ""TenantId"", ""Title"", ""Status"", ""CreatedById"", ""ClubId"", ""CreatedAt"", ""UpdatedAt"")
2026-03-03 19:11:01 +01:00
SELECT gen_random_uuid(), 'club-b', 'Task B' || i, 0, @MemberId, @ClubId, NOW(), NOW()
FROM generate_series(1, 25) i" ,
new { MemberId = memberBId , ClubId = clubBId } ) ;
2026-03-06 09:19:32 +01:00
await txn . CommitAsync ( ) ;
2026-03-03 19:11:01 +01:00
var results = new ConcurrentBag < ( string TenantId , List < WorkItem > Items ) > ( ) ;
var tasks = new List < Task > ( ) ;
for ( int i = 0 ; i < 25 ; i + + )
{
tasks . Add ( Task . Run ( async ( ) = >
{
2026-03-06 09:19:32 +01:00
await using var conn = new NpgsqlConnection ( GetRlsUserConnectionString ( ) ) ;
2026-03-03 19:11:01 +01:00
await conn . OpenAsync ( ) ;
2026-03-06 09:19:32 +01:00
await using var queryTxn = await conn . BeginTransactionAsync ( ) ;
2026-03-03 19:11:01 +01:00
await conn . ExecuteAsync ( "SET LOCAL app.current_tenant_id = 'club-a'" ) ;
var items = ( await conn . QueryAsync < WorkItem > ( "SELECT * FROM work_items" ) ) . ToList ( ) ;
2026-03-06 09:19:32 +01:00
await queryTxn . CommitAsync ( ) ;
2026-03-03 19:11:01 +01:00
results . Add ( ( "club-a" , items ) ) ;
} ) ) ;
tasks . Add ( Task . Run ( async ( ) = >
{
2026-03-06 09:19:32 +01:00
await using var conn = new NpgsqlConnection ( GetRlsUserConnectionString ( ) ) ;
2026-03-03 19:11:01 +01:00
await conn . OpenAsync ( ) ;
2026-03-06 09:19:32 +01:00
await using var queryTxn = await conn . BeginTransactionAsync ( ) ;
2026-03-03 19:11:01 +01:00
await conn . ExecuteAsync ( "SET LOCAL app.current_tenant_id = 'club-b'" ) ;
var items = ( await conn . QueryAsync < WorkItem > ( "SELECT * FROM work_items" ) ) . ToList ( ) ;
2026-03-06 09:19:32 +01:00
await queryTxn . CommitAsync ( ) ;
2026-03-03 19:11:01 +01:00
results . Add ( ( "club-b" , items ) ) ;
} ) ) ;
}
await Task . WhenAll ( tasks ) ;
Assert . Equal ( 50 , results . Count ) ;
var clubAResults = results . Where ( r = > r . TenantId = = "club-a" ) . ToList ( ) ;
var clubBResults = results . Where ( r = > r . TenantId = = "club-b" ) . ToList ( ) ;
Assert . Equal ( 25 , clubAResults . Count ) ;
Assert . Equal ( 25 , clubBResults . Count ) ;
foreach ( var ( _ , items ) in clubAResults )
{
Assert . Equal ( 25 , items . Count ) ;
Assert . All ( items , item = > Assert . Equal ( "club-a" , item . TenantId ) ) ;
Assert . All ( items , item = > Assert . StartsWith ( "Task A" , item . Title ) ) ;
}
foreach ( var ( _ , items ) in clubBResults )
{
Assert . Equal ( 25 , items . Count ) ;
Assert . All ( items , item = > Assert . Equal ( "club-b" , item . TenantId ) ) ;
Assert . All ( items , item = > Assert . StartsWith ( "Task B" , item . Title ) ) ;
}
var allClubAItems = clubAResults . SelectMany ( r = > r . Items ) . ToList ( ) ;
var allClubBItems = clubBResults . SelectMany ( r = > r . Items ) . ToList ( ) ;
Assert . DoesNotContain ( allClubAItems , item = > item . TenantId = = "club-b" ) ;
Assert . DoesNotContain ( allClubBItems , item = > item . TenantId = = "club-a" ) ;
}
[Fact]
public async Task Test5_CrossTenantHeaderSpoof_MiddlewareBlocks ( )
{
// Arrange: Seed Club A only
var clubAId = Guid . NewGuid ( ) ;
await using var adminConn = new NpgsqlConnection ( GetAdminConnectionString ( ) ) ;
await adminConn . OpenAsync ( ) ;
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO clubs (""Id"", ""TenantId"", ""Name"", ""SportType"", ""CreatedAt"", ""UpdatedAt"")
2026-03-03 19:11:01 +01:00
VALUES (@Id, 'club-a', 'Club Alpha', 0, NOW(), NOW())" ,
new { Id = clubAId } ) ;
// Act: Authenticate as user with only club-a membership
AuthenticateAs ( "alice@club-a.com" , new Dictionary < string , string >
{
["club-a"] = "admin"
} ) ;
// Try to access club-b (user is NOT a member)
SetTenant ( "club-b" ) ;
// Make request to API endpoint - TenantValidationMiddleware should block
var response = await Client . GetAsync ( "/api/clubs" ) ;
// Assert: Middleware returns 403 Forbidden
Assert . Equal ( HttpStatusCode . Forbidden , response . StatusCode ) ;
}
[Fact]
public async Task Test6_InterceptorVerification_SetLocalExecuted ( )
{
2026-03-06 09:19:32 +01:00
await ClearDataAsync ( ) ;
2026-03-03 19:11:01 +01:00
var clubAId = Guid . NewGuid ( ) ;
var memberAId = Guid . NewGuid ( ) ;
await using var adminConn = new NpgsqlConnection ( GetAdminConnectionString ( ) ) ;
await adminConn . OpenAsync ( ) ;
2026-03-06 09:19:32 +01:00
await using var txn = await adminConn . BeginTransactionAsync ( ) ;
2026-03-03 19:11:01 +01:00
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO clubs (""Id"", ""TenantId"", ""Name"", ""SportType"", ""CreatedAt"", ""UpdatedAt"")
2026-03-03 19:11:01 +01:00
VALUES (@Id, 'club-a', 'Club Alpha', 0, NOW(), NOW())" ,
new { Id = clubAId } ) ;
await adminConn . ExecuteAsync ( @"
2026-03-06 09:19:32 +01:00
INSERT INTO members (""Id"", ""TenantId"", ""ExternalUserId"", ""DisplayName"", ""Email"", ""ClubId"", ""Role"", ""CreatedAt"", ""UpdatedAt"")
VALUES (@Id, 'club-a', 'user-alice', 'Alice', 'alice@club-a.com', @ClubId, 1, NOW(), NOW())" ,
2026-03-03 19:11:01 +01:00
new { Id = memberAId , ClubId = clubAId } ) ;
2026-03-06 09:19:32 +01:00
await txn . CommitAsync ( ) ;
await using var conn = new NpgsqlConnection ( GetRlsUserConnectionString ( ) ) ;
2026-03-03 19:11:01 +01:00
await conn . OpenAsync ( ) ;
2026-03-06 09:19:32 +01:00
await using var verifyTxn = await conn . BeginTransactionAsync ( ) ;
2026-03-03 19:11:01 +01:00
await conn . ExecuteAsync ( "SET LOCAL app.current_tenant_id = 'club-a'" ) ;
var currentTenant = await conn . ExecuteScalarAsync < string > (
"SELECT current_setting('app.current_tenant_id', true)" ) ;
Assert . Equal ( "club-a" , currentTenant ) ;
var members = ( await conn . QueryAsync < Member > ( "SELECT * FROM members" ) ) . ToList ( ) ;
2026-03-06 09:19:32 +01:00
await verifyTxn . CommitAsync ( ) ;
2026-03-03 19:11:01 +01:00
Assert . Single ( members ) ;
Assert . Equal ( memberAId , members [ 0 ] . Id ) ;
Assert . Equal ( "club-a" , members [ 0 ] . TenantId ) ;
var scope = Factory . Services . CreateScope ( ) ;
2026-03-06 09:19:32 +01:00
var interceptor = scope . ServiceProvider . GetService < TenantDbTransactionInterceptor > ( ) ;
2026-03-03 19:11:01 +01:00
Assert . NotNull ( interceptor ) ;
}
private string GetAppUserConnectionString ( )
{
var config = Factory . Services . GetRequiredService < Microsoft . Extensions . Configuration . IConfiguration > ( ) ;
var connectionString = config [ "ConnectionStrings:DefaultConnection" ] ;
return connectionString ! ;
}
private string GetAdminConnectionString ( )
{
var connString = GetAppUserConnectionString ( ) ;
return connString ;
}
2026-03-06 09:19:32 +01:00
private string GetRlsUserConnectionString ( )
{
var adminConnString = GetAdminConnectionString ( ) ;
var builder = new NpgsqlConnectionStringBuilder ( adminConnString )
{
Username = "rls_test_user" ,
Password = "rlspass"
} ;
return builder . ConnectionString ;
}
private async Task ClearDataAsync ( )
{
await using var adminConn = new NpgsqlConnection ( GetAdminConnectionString ( ) ) ;
await adminConn . OpenAsync ( ) ;
await using var txn = await adminConn . BeginTransactionAsync ( ) ;
await adminConn . ExecuteAsync ( @"
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO rls_test_user;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO rls_test_user;
" ) ;
await adminConn . ExecuteAsync ( @"
ALTER TABLE clubs ENABLE ROW LEVEL SECURITY;
ALTER TABLE clubs FORCE ROW LEVEL SECURITY;
ALTER TABLE members ENABLE ROW LEVEL SECURITY;
ALTER TABLE members FORCE ROW LEVEL SECURITY;
ALTER TABLE work_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE work_items FORCE ROW LEVEL SECURITY;
ALTER TABLE shifts ENABLE ROW LEVEL SECURITY;
ALTER TABLE shifts FORCE ROW LEVEL SECURITY;
ALTER TABLE shift_signups ENABLE ROW LEVEL SECURITY;
ALTER TABLE shift_signups FORCE ROW LEVEL SECURITY;
" ) ;
await adminConn . ExecuteAsync ( @"
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename='clubs' AND policyname='tenant_isolation_policy') THEN
CREATE POLICY tenant_isolation_policy ON clubs FOR ALL USING ((""TenantId"")::text = current_setting('app.current_tenant_id', true));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename='members' AND policyname='tenant_isolation_policy') THEN
CREATE POLICY tenant_isolation_policy ON members FOR ALL USING ((""TenantId"")::text = current_setting('app.current_tenant_id', true));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename='work_items' AND policyname='tenant_isolation_policy') THEN
CREATE POLICY tenant_isolation_policy ON work_items FOR ALL USING ((""TenantId"")::text = current_setting('app.current_tenant_id', true));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename='shifts' AND policyname='tenant_isolation_policy') THEN
CREATE POLICY tenant_isolation_policy ON shifts FOR ALL USING ((""TenantId"")::text = current_setting('app.current_tenant_id', true));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename='shift_signups' AND policyname='tenant_isolation_policy') THEN
CREATE POLICY tenant_isolation_policy ON shift_signups FOR ALL USING (""ShiftId"" IN (SELECT ""Id"" FROM shifts WHERE (""TenantId"")::text = current_setting('app.current_tenant_id', true)));
END IF;
END $$;
" ) ;
await adminConn . ExecuteAsync ( @"
DELETE FROM shift_signups;
DELETE FROM shifts;
DELETE FROM work_items;
DELETE FROM members;
DELETE FROM clubs;
" ) ;
await txn . CommitAsync ( ) ;
}
2026-03-03 19:11:01 +01:00
}