diff --git a/server/store/result_sql.go b/server/store/result_sql.go index 2079759c..81389a6a 100644 --- a/server/store/result_sql.go +++ b/server/store/result_sql.go @@ -94,6 +94,9 @@ func CompleteResult(ctx context.Context, db *sql.DB, receipt domain.ResultReceip } func CompleteResultWithResult(ctx context.Context, db *sql.DB, receipt domain.ResultReceipt, serverID, eventID string, payload []byte, result domain.MatchResult, now time.Time) error { + if result.MatchID != receipt.MatchID || result.ServerID != serverID || result.ResultNonce != receipt.ResultNonce || result.IntegrityState != receipt.IntegrityState || domain.ResultDigest(result) != receipt.PayloadDigest { + return fmt.Errorf("result does not match receipt") + } return completeResult(ctx, db, receipt, serverID, eventID, payload, now, &result) } diff --git a/server/store/result_sql_test.go b/server/store/result_sql_test.go index 1b557882..93612932 100644 --- a/server/store/result_sql_test.go +++ b/server/store/result_sql_test.go @@ -1,16 +1,22 @@ package store -import "testing" +import ( + "context" + "testing" + "time" + + "github.com/cosmic-clash/cosmic-clash/server/domain" +) func TestResultSQLPreservesReceiptConflictAndAtomicCommitBoundaries(t *testing.T) { checks := map[string][]string{ - ResultReceiptInsertSQL: {"ON CONFLICT DO NOTHING", "payload_digest", "integrity_state"}, - ResultReceiptSelectSQL: {"FOR UPDATE", "committed_at"}, - ResultCommitLockSQL: {"server_id = $2", "FOR UPDATE"}, - ResultMatchCompleteSQL: {"state = 'RESULT_PENDING'", "revision = revision + 1"}, - ResultReceiptCommitSQL: {"COALESCE(committed_at", "committed_at"}, - ResultOutboxSQL: {"match_completed", "aggregate_id", "revision"}, - RatingLockSQL: {"ORDER BY player_id", "FOR UPDATE"}, + ResultReceiptInsertSQL: {"ON CONFLICT DO NOTHING", "payload_digest", "integrity_state"}, + ResultReceiptSelectSQL: {"FOR UPDATE", "committed_at"}, + ResultCommitLockSQL: {"server_id = $2", "FOR UPDATE"}, + ResultMatchCompleteSQL: {"state = 'RESULT_PENDING'", "revision = revision + 1"}, + ResultReceiptCommitSQL: {"COALESCE(committed_at", "committed_at"}, + ResultOutboxSQL: {"match_completed", "aggregate_id", "revision"}, + RatingLockSQL: {"ORDER BY player_id", "FOR UPDATE"}, MatchParticipantRatingsSQL: {"match_participants", "JOIN ratings", "ORDER BY mp.player_id"}, RatingValuesSQL: {"player_id = ANY($1)", "ORDER BY player_id"}, RatingUpdateSQL: {"ranked_games = ranked_games + $5", "revision = revision + 1"}, @@ -24,6 +30,22 @@ func TestResultSQLPreservesReceiptConflictAndAtomicCommitBoundaries(t *testing.T } } +func TestCompleteResultWithResultRejectsReceiptResultMismatchBeforeDatabaseUse(t *testing.T) { + now := time.Unix(100, 0).UTC() + result := domain.MatchResult{ + MatchID: "match", ServerID: "server", ResultNonce: "nonce-1234567890123456", + Team0Score: 1, Team1Score: 0, IntegrityState: domain.IntegrityCertified, + } + receipt := domain.ResultReceipt{ + ResultID: "result", MatchID: result.MatchID, ResultNonce: result.ResultNonce, + PayloadDigest: domain.ResultDigest(result), IntegrityState: result.IntegrityState, ReceivedAt: now, + } + result.Team0Score = 2 + if err := CompleteResultWithResult(context.Background(), nil, receipt, "server", "event", []byte("payload"), result, now); err == nil { + t.Fatal("mismatched result was accepted") + } +} + func contains(value, fragment string) bool { for i := 0; i+len(fragment) <= len(value); i++ { if value[i:i+len(fragment)] == fragment {