using Xunit; using Npgsql; namespace KArtSell.Integration.Tests; /// /// VS-01: Identity and Roles - Integration Tests /// Tests: Full user lifecycle, role management, permission enforcement /// Requires: PostgreSQL connection (via TestDatabaseConnection) /// public sealed class VS01_IdentityIntegrationTests : IAsyncLifetime { private NpgsqlDataSource _dataSource = null!; private const string TestDbName = "vs01_identity_test"; public async Task InitializeAsync() { // Create test database var connString = TestDatabaseConnection.GetConnectionString(); var adminConnString = connString.Replace(TestDatabaseConnection.DefaultDb, "postgres"); await using var adminConn = new NpgsqlConnection(adminConnString); await adminConn.OpenAsync(); try { await using var cmd = adminConn.CreateCommand(); cmd.CommandText = $"DROP DATABASE IF EXISTS {TestDbName} WITH (FORCE);"; await cmd.ExecuteNonQueryAsync(); } catch { /* DB doesn't exist */ } await using var createCmd = adminConn.CreateCommand(); createCmd.CommandText = $"CREATE DATABASE {TestDbName};"; await createCmd.ExecuteNonQueryAsync(); await adminConn.CloseAsync(); // Connect to test database and apply migrations var testConnString = connString.Replace(TestDatabaseConnection.DefaultDb, TestDbName); _dataSource = new NpgsqlDataSourceBuilder(testConnString).Build(); await ApplyIdentitySchemaAsync(); } public async Task DisposeAsync() { await _dataSource.DisposeAsync(); // Cleanup var connString = TestDatabaseConnection.GetConnectionString(); var adminConnString = connString.Replace(TestDatabaseConnection.DefaultDb, "postgres"); await using var adminConn = new NpgsqlConnection(adminConnString); await adminConn.OpenAsync(); await using var dropCmd = adminConn.CreateCommand(); dropCmd.CommandText = $"DROP DATABASE IF EXISTS {TestDbName} WITH (FORCE);"; await dropCmd.ExecuteNonQueryAsync(); await adminConn.CloseAsync(); } private async Task ApplyIdentitySchemaAsync() { await using var connection = await _dataSource.OpenConnectionAsync(); // Create roles table const string rolesSql = """ CREATE TABLE IF NOT EXISTS identity.roles ( id SERIAL PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE, description VARCHAR(255) ); INSERT INTO identity.roles (name, description) VALUES ('Admin', 'Full access'), ('Analyst', 'Read-only'), ('Trader', 'Trading access'), ('Viewer', 'View-only') ON CONFLICT DO NOTHING; """; await using var rolesCmd = connection.CreateCommand(); rolesCmd.CommandText = rolesSql; await rolesCmd.ExecuteNonQueryAsync(); // Create users table const string usersSql = """ CREATE TABLE IF NOT EXISTS identity.users ( id UUID PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, email_hash VARCHAR(64), password_hash VARCHAR(255), status VARCHAR(20) DEFAULT 'active', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, correlation_id VARCHAR(36) ); """; await using var usersCmd = connection.CreateCommand(); usersCmd.CommandText = usersSql; await usersCmd.ExecuteNonQueryAsync(); // Create user_roles table const string userRolesSql = """ CREATE TABLE IF NOT EXISTS identity.user_roles ( id BIGSERIAL PRIMARY KEY, user_id UUID NOT NULL REFERENCES identity.users(id), role_id INT NOT NULL REFERENCES identity.roles(id), assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, removed_at TIMESTAMP, correlation_id VARCHAR(36), UNIQUE(user_id, role_id) WHERE removed_at IS NULL ); """; await using var userRolesCmd = connection.CreateCommand(); userRolesCmd.CommandText = userRolesSql; await userRolesCmd.ExecuteNonQueryAsync(); } // ============ CREATE USER TESTS ============ [Fact] public async Task CreateUser_WithValidData_Succeeds() { // Arrange await using var connection = await _dataSource.OpenConnectionAsync(); var userId = Guid.NewGuid(); var email = "alice@example.com"; // Act await using var cmd = connection.CreateCommand(); cmd.CommandText = """ INSERT INTO identity.users (id, email, status, correlation_id) VALUES (@id, @email, 'active', @correlationId); """; cmd.Parameters.AddWithValue("@id", userId); cmd.Parameters.AddWithValue("@email", email); cmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); var result = await cmd.ExecuteNonQueryAsync(); // Assert Assert.Equal(1, result); } [Fact] public async Task CreateUser_DuplicateEmail_FailsWithConstraint() { // Arrange await using var connection = await _dataSource.OpenConnectionAsync(); var email = "bob@example.com"; // Create first user await using var cmd1 = connection.CreateCommand(); cmd1.CommandText = """ INSERT INTO identity.users (id, email, status, correlation_id) VALUES (@id, @email, 'active', @correlationId); """; cmd1.Parameters.AddWithValue("@id", Guid.NewGuid()); cmd1.Parameters.AddWithValue("@email", email); cmd1.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); await cmd1.ExecuteNonQueryAsync(); // Act & Assert: Try to create duplicate await using var cmd2 = connection.CreateCommand(); cmd2.CommandText = """ INSERT INTO identity.users (id, email, status, correlation_id) VALUES (@id, @email, 'active', @correlationId); """; cmd2.Parameters.AddWithValue("@id", Guid.NewGuid()); cmd2.Parameters.AddWithValue("@email", email); cmd2.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); await Assert.ThrowsAsync(() => cmd2.ExecuteNonQueryAsync()); } // ============ ROLE MANAGEMENT TESTS ============ [Fact] public async Task AssignRole_NewRole_Succeeds() { // Arrange await using var connection = await _dataSource.OpenConnectionAsync(); var userId = Guid.NewGuid(); // Create user await using var userCmd = connection.CreateCommand(); userCmd.CommandText = """ INSERT INTO identity.users (id, email, status, correlation_id) VALUES (@id, 'user@example.com', 'active', @correlationId); """; userCmd.Parameters.AddWithValue("@id", userId); userCmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); await userCmd.ExecuteNonQueryAsync(); // Act: Assign role await using var roleCmd = connection.CreateCommand(); roleCmd.CommandText = """ INSERT INTO identity.user_roles (user_id, role_id, correlation_id) SELECT @userId, id, @correlationId FROM identity.roles WHERE name = 'Analyst'; """; roleCmd.Parameters.AddWithValue("@userId", userId); roleCmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); var result = await roleCmd.ExecuteNonQueryAsync(); // Assert Assert.Equal(1, result); } [Fact] public async Task DuplicateRole_IsIdempotent() { // Arrange await using var connection = await _dataSource.OpenConnectionAsync(); var userId = Guid.NewGuid(); // Create user await using var userCmd = connection.CreateCommand(); userCmd.CommandText = """ INSERT INTO identity.users (id, email, status, correlation_id) VALUES (@id, 'user2@example.com', 'active', @correlationId); """; userCmd.Parameters.AddWithValue("@id", userId); userCmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); await userCmd.ExecuteNonQueryAsync(); // Assign role first time await using var roleCmd1 = connection.CreateCommand(); roleCmd1.CommandText = """ INSERT INTO identity.user_roles (user_id, role_id, correlation_id) SELECT @userId, id, @correlationId FROM identity.roles WHERE name = 'Analyst' ON CONFLICT (user_id, role_id) WHERE removed_at IS NULL DO NOTHING; """; roleCmd1.Parameters.AddWithValue("@userId", userId); roleCmd1.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); await roleCmd1.ExecuteNonQueryAsync(); // Act: Try to assign same role again await using var roleCmd2 = connection.CreateCommand(); roleCmd2.CommandText = """ INSERT INTO identity.user_roles (user_id, role_id, correlation_id) SELECT @userId, id, @correlationId FROM identity.roles WHERE name = 'Analyst' ON CONFLICT (user_id, role_id) WHERE removed_at IS NULL DO NOTHING; """; roleCmd2.Parameters.AddWithValue("@userId", userId); roleCmd2.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); var result = await roleCmd2.ExecuteNonQueryAsync(); // Assert: Should be 0 (no insert due to conflict) Assert.Equal(0, result); } [Fact] public async Task RevokeRole_UsingSoftDelete_Succeeds() { // Arrange await using var connection = await _dataSource.OpenConnectionAsync(); var userId = Guid.NewGuid(); // Create user and assign role await using var userCmd = connection.CreateCommand(); userCmd.CommandText = """ INSERT INTO identity.users (id, email, status, correlation_id) VALUES (@id, 'user3@example.com', 'active', @correlationId); INSERT INTO identity.user_roles (user_id, role_id, correlation_id) SELECT @id, id, @correlationId FROM identity.roles WHERE name = 'Analyst'; """; userCmd.Parameters.AddWithValue("@id", userId); userCmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); await userCmd.ExecuteNonQueryAsync(); // Act: Revoke role (soft delete) await using var revokeCmd = connection.CreateCommand(); revokeCmd.CommandText = """ UPDATE identity.user_roles SET removed_at = CURRENT_TIMESTAMP WHERE user_id = @userId AND role_id = (SELECT id FROM identity.roles WHERE name = 'Analyst'); """; revokeCmd.Parameters.AddWithValue("@userId", userId); var result = await revokeCmd.ExecuteNonQueryAsync(); // Assert Assert.Equal(1, result); // Verify: User should have no active roles await using var verifyCmd = connection.CreateCommand(); verifyCmd.CommandText = """ SELECT COUNT(*) FROM identity.user_roles WHERE user_id = @userId AND removed_at IS NULL; """; verifyCmd.Parameters.AddWithValue("@userId", userId); var activeRoles = (long?)await verifyCmd.ExecuteScalarAsync() ?? 0; Assert.Equal(0, activeRoles); } // ============ LIST USERS TESTS ============ [Fact] public async Task ListUsers_WithPagination_ReturnsCorrectSet() { // Arrange await using var connection = await _dataSource.OpenConnectionAsync(); // Create 5 users for (int i = 0; i < 5; i++) { await using var cmd = connection.CreateCommand(); cmd.CommandText = """ INSERT INTO identity.users (id, email, status, correlation_id) VALUES (@id, @email, 'active', @correlationId); """; cmd.Parameters.AddWithValue("@id", Guid.NewGuid()); cmd.Parameters.AddWithValue("@email", $"user{i}@example.com"); cmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); await cmd.ExecuteNonQueryAsync(); } // Act: Query page 1, limit 2 await using var selectCmd = connection.CreateCommand(); selectCmd.CommandText = """ SELECT COUNT(*) as total FROM identity.users; SELECT id, email FROM identity.users ORDER BY created_at DESC LIMIT 2 OFFSET 0; """; var reader = await selectCmd.ExecuteReaderAsync(); // Read total await reader.ReadAsync(); var total = (long)reader[0]; // Read results await reader.NextResultAsync(); var count = 0; while (await reader.ReadAsync()) { count++; } // Assert Assert.Equal(5, total); Assert.Equal(2, count); } // ============ PIT (Point-in-Time) TESTS ============ [Fact] public async Task PIT_Query_OnlyReturnsPublishedData() { // Arrange await using var connection = await _dataSource.OpenConnectionAsync(); var userId = Guid.NewGuid(); await using var cmd = connection.CreateCommand(); cmd.CommandText = """ INSERT INTO identity.users (id, email, status, published_at, correlation_id) VALUES (@id, 'user@example.com', 'active', CURRENT_TIMESTAMP, @correlationId); """; cmd.Parameters.AddWithValue("@id", userId); cmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); await cmd.ExecuteNonQueryAsync(); // Act: Query with PIT cutoff await using var selectCmd = connection.CreateCommand(); selectCmd.CommandText = """ SELECT COUNT(*) FROM identity.users WHERE published_at <= CURRENT_TIMESTAMP; """; var count = (long?)await selectCmd.ExecuteScalarAsync() ?? 0; // Assert Assert.True(count > 0, "Should find user with published_at <= now"); } // ============ CONSISTENCY TESTS ============ [Fact] public async Task Status_OnlyAllowsValidValues() { // Arrange await using var connection = await _dataSource.OpenConnectionAsync(); // Act & Assert: Try to insert invalid status await using var cmd = connection.CreateCommand(); cmd.CommandText = """ INSERT INTO identity.users (id, email, status, correlation_id) VALUES (@id, 'user@example.com', 'invalid_status', @correlationId); """; cmd.Parameters.AddWithValue("@id", Guid.NewGuid()); cmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString()); // Note: If CHECK constraint exists, this throws PostgresException // Otherwise, application layer validates try { await cmd.ExecuteNonQueryAsync(); } catch (PostgresException ex) when (ex.SqlState == "23514") { // CHECK constraint violated (expected) Assert.True(true); } } }