fix(data): enforce portfolio validation isolation

This commit is contained in:
2026-06-01 14:28:51 +03:00
parent 76b3ff7ddf
commit 6e7a0cb493
5 changed files with 111 additions and 5 deletions
@@ -1,4 +1,5 @@
using Npgsql;
using System.Data;
namespace GmRelay.Bot.Tests.Web;
@@ -184,6 +185,78 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
}
[Theory]
[InlineData("portfolio_game_sessions", "session_id")]
[InlineData("portfolio_game_masters", "player_id")]
public async Task RepeatableReadConcurrentRequiredLinkDeletes_ShouldBeRejectedWithoutInvalidPublicCard(
string linkTable,
string linkColumn)
{
var database = await fixture.CreateMigratedDatabaseAsync();
await using var seedConnection = await database.OpenConnectionAsync();
var seed = await SeedCardAsync(
seedConnection,
isPublic: true,
sessionCount: linkTable == "portfolio_game_sessions" ? 2 : 1,
masterCount: linkTable == "portfolio_game_masters" ? 2 : 1);
await using var firstConnection = await database.OpenConnectionAsync();
await using var secondConnection = await database.OpenConnectionAsync();
await using var firstTransaction = await firstConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead);
await using var secondTransaction = await secondConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead);
var linkIds = linkTable == "portfolio_game_sessions" ? seed.SessionIds : seed.MasterIds;
await ExecuteNonQueryAsync(
firstConnection,
$"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId",
firstTransaction,
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId),
new NpgsqlParameter("linkId", linkIds[0]));
await ExecuteNonQueryAsync(
secondConnection,
$"DELETE FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId AND {linkColumn} = @linkId",
secondTransaction,
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId),
new NpgsqlParameter("linkId", linkIds[1]));
var commitStates = await Task.WhenAll(
CommitAndCaptureSqlStateAsync(firstTransaction),
CommitAndCaptureSqlStateAsync(secondTransaction));
Assert.All(commitStates, state => Assert.Equal(PostgresErrorCodes.FeatureNotSupported, state));
await using var verificationConnection = await database.OpenConnectionAsync();
Assert.True(await ExecuteScalarAsync<bool>(
verificationConnection,
"SELECT is_public FROM portfolio_games WHERE id = @portfolioGameId",
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
Assert.Equal(2, await ExecuteScalarAsync<long>(
verificationConnection,
$"SELECT COUNT(*) FROM {linkTable} WHERE portfolio_game_id = @portfolioGameId",
parameters: new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId)));
}
[Theory]
[InlineData(IsolationLevel.RepeatableRead)]
[InlineData(IsolationLevel.Serializable)]
public async Task NonReadCommittedPublishedCardLinkDelete_ShouldBeRejected(IsolationLevel isolationLevel)
{
var database = await fixture.CreateMigratedDatabaseAsync();
await using var connection = await database.OpenConnectionAsync();
var seed = await SeedCardAsync(connection, isPublic: true);
await using var transaction = await connection.BeginTransactionAsync(isolationLevel);
await ExecuteNonQueryAsync(
connection,
"DELETE FROM portfolio_game_sessions WHERE portfolio_game_id = @portfolioGameId",
transaction,
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId));
var exception = await Assert.ThrowsAsync<PostgresException>(
() => transaction.CommitAsync().WaitAsync(CommandTimeout));
Assert.Equal(PostgresErrorCodes.FeatureNotSupported, exception.SqlState);
}
[Theory]
[InlineData("portfolio_game_sessions")]
[InlineData("portfolio_game_masters")]
@@ -32,6 +32,8 @@ public sealed class PortfolioMigrationTests
Assert.Contains("CREATE INDEX ix_portfolio_game_reviews_pending ON portfolio_game_reviews (portfolio_game_id, created_at DESC) WHERE moderation_status = 'Pending';", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE FUNCTION validate_public_portfolio_game_required_links() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("PERFORM pg_advisory_xact_lock(20260530, 108);", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("current_setting('transaction_isolation') <> 'read committed'", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("USING ERRCODE = '0A000';", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("RAISE EXCEPTION 'published portfolio game % must have at least one linked session and at least one linked master', target_portfolio_game_id USING ERRCODE = '23514';", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links AFTER INSERT OR UPDATE OF is_public ON portfolio_games DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_sessions DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION validate_public_portfolio_game_required_links();", normalizedMigration, StringComparison.Ordinal);