fix(data): enforce portfolio validation isolation
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user