fix(data): reject stale reschedule snapshots

This commit is contained in:
2026-06-02 07:57:30 +03:00
parent 85918c1e5d
commit 1a8161027c
5 changed files with 97 additions and 5 deletions
@@ -631,6 +631,81 @@ public sealed class PortfolioMigrationPostgresTests(PortfolioMigrationPostgresFi
]));
}
[Fact]
public async Task RepeatableReadStaleSnapshotFutureReschedule_ShouldBeRejectedWithoutInvalidPublicCard()
{
var database = await fixture.CreateMigratedDatabaseAsync();
await using var seedConnection = await database.OpenConnectionAsync();
var seed = await SeedCardAsync(seedConnection, isPublic: false);
var rescheduledSessionId = Guid.NewGuid();
await ExecuteNonQueryAsync(
seedConnection,
"""
INSERT INTO sessions (id, group_id, title, join_link, scheduled_at)
VALUES (@sessionId, @groupId, 'Completed Session', 'https://example.test/session', now() - interval '1 day');
""",
parameters:
[
new NpgsqlParameter("sessionId", rescheduledSessionId),
new NpgsqlParameter("groupId", seed.GroupId)
]);
await using var rescheduleConnection = await database.OpenConnectionAsync();
await using var publishConnection = await database.OpenConnectionAsync();
await using var rescheduleTransaction = await rescheduleConnection.BeginTransactionAsync(IsolationLevel.RepeatableRead);
Assert.Equal(0, await ExecuteScalarAsync<long>(
rescheduleConnection,
"""
SELECT COUNT(*)
FROM portfolio_game_sessions
WHERE portfolio_game_id = @portfolioGameId
AND session_id = @sessionId
""",
rescheduleTransaction,
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId),
new NpgsqlParameter("sessionId", rescheduledSessionId)));
await using (var publishTransaction = await publishConnection.BeginTransactionAsync())
{
await ExecuteNonQueryAsync(
publishConnection,
"""
INSERT INTO portfolio_game_sessions (portfolio_game_id, session_id)
VALUES (@portfolioGameId, @sessionId);
UPDATE portfolio_games
SET is_public = true,
published_at = COALESCE(published_at, now()),
updated_at = now()
WHERE id = @portfolioGameId
""",
publishTransaction,
new NpgsqlParameter("portfolioGameId", seed.PortfolioGameId),
new NpgsqlParameter("sessionId", rescheduledSessionId));
await publishTransaction.CommitAsync().WaitAsync(CommandTimeout);
}
Assert.Equal(1, await RescheduleSessionAsync(
rescheduleConnection,
rescheduleTransaction,
rescheduledSessionId));
var exception = await Assert.ThrowsAsync<PostgresException>(
() => rescheduleTransaction.CommitAsync().WaitAsync(CommandTimeout));
Assert.Equal(PostgresErrorCodes.FeatureNotSupported, exception.SqlState);
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.False(await ExecuteScalarAsync<bool>(
verificationConnection,
"SELECT scheduled_at >= now() FROM sessions WHERE id = @sessionId",
parameters: new NpgsqlParameter("sessionId", rescheduledSessionId)));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
@@ -48,6 +48,7 @@ public sealed class PortfolioMigrationTests
Assert.Contains("CREATE FUNCTION unpublish_public_portfolio_games_for_future_session() RETURNS TRIGGER LANGUAGE plpgsql", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("SELECT s.scheduled_at INTO final_scheduled_at FROM sessions s WHERE s.id = NEW.id;", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("IF final_scheduled_at >= now() THEN", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("IF final_scheduled_at >= now() THEN IF current_setting('transaction_isolation') <> 'read committed' THEN RAISE EXCEPTION 'portfolio future reschedule requires read committed isolation' USING ERRCODE = '0A000'; END IF; PERFORM pg.id", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("PERFORM pg.id FROM portfolio_games pg WHERE EXISTS", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg;", normalizedMigration, StringComparison.Ordinal);
Assert.Contains("ORDER BY pg.id FOR UPDATE OF pg; PERFORM pg_advisory_xact_lock(20260530, 108); UPDATE portfolio_games pg SET is_public = false", normalizedMigration, StringComparison.Ordinal);