diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Authentication/JwtValidationTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Authentication/JwtValidationTests.cs new file mode 100644 index 0000000..a373418 --- /dev/null +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Authentication/JwtValidationTests.cs @@ -0,0 +1,140 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using TournamentAPI.Configuration; +using TournamentAPI.IntegrationTests.Infrastructure; +using TournamentAPI.Shared.MutationExamples; +using TournamentAPI.Shared.QueryExamples; + +namespace TournamentAPI.IntegrationTests.GraphQL.Tests.Authentication; + +public class JwtValidationTests : BaseIntegrationTest +{ + public JwtValidationTests(IntegrationTestWebAppFactory factory) : base(factory) + { + } + + private static readonly (string Name, string Query, object? Variables, bool CallsGetUserId)[] Operations = + [ + ("GetMe", Queries.Users.GetMe, null, true), + ("LogoutUser", Mutations.Users.LogoutUser, null, false), + ("AddParticipant", Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { userId = 1, tournamentId = 1 } }, true), + ("JoinTournament", Mutations.Tournaments.JoinTournament, + new { input = new { tournamentId = 1 } }, true), + ("CreateTournament", Mutations.Tournaments.CreateTournamentWithBasicFieldsReturn, + new { input = new { name = "Auth Test Tournament", startDate = DateTime.UtcNow.AddDays(1), status = "OPEN", maxParticipants = 4 } }, true), + ("UpdateTournament", Mutations.Tournaments.UpdateTournamentWithBasicFieldsReturn, + new { input = new { tournamentId = 1 } }, true), + ("DeleteTournament", Mutations.Tournaments.DeleteTournament, + new { input = new { tournamentId = 999999 } }, true), + ("GenerateBracket", Mutations.Bracket.GenerateBracket, + new { input = new { tournamentId = 1 } }, true), + ("UpdateRound", Mutations.Bracket.UpdateRound, + new { input = new { bracketId = 1, roundNumber = 1 } }, true), + ("Play", Mutations.Match.Play, + new { input = new { matchId = 1, winnerId = 1, player1Score = 1, player2Score = 0 } }, true), + ("CorrectMatchResult", Mutations.Match.CorrectMatchResult, + new { input = new { matchId = 1, winnerId = 1, player1Score = 1, player2Score = 0, version = "" } }, true), + ("UpdateEmailVisibility", Mutations.Users.UpdateEmailVisibility, + new { input = new { isEmailPublic = true } }, true), + ]; + + public static TheoryData AllProtectedOperations() + { + var data = new TheoryData(); + foreach (var op in Operations) + data.Add(op.Name, op.Query, op.Variables); + return data; + } + + public static TheoryData OperationsRequiringUserIdClaim() + { + var data = new TheoryData(); + foreach (var op in Operations.Where(o => o.CallsGetUserId)) + data.Add(op.Name, op.Query, op.Variables); + return data; + } + + private JwtOptions GetJwtOptions() => Factory.Services.GetRequiredService>().Value; + + [Theory] + [MemberData(nameof(AllProtectedOperations))] + public async Task ProtectedOperation_WithExpiredToken_ReturnsAuthenticationError(string name, string query, object? variables) + { + using var client = CreateClient(); + var token = JwtTestTokenFactory.CreateExpiredToken(GetJwtOptions(), 1, "alice", "alice@example.com"); + client.SetAuthToken(token); + + var response = await client.ExecuteQueryAsync(query, variables); + + Assert.True(response.HasErrors, $"{name} was expected to fail with an expired token."); + var error = response.Errors!.First(); + Assert.Equal("AUTH_NOT_AUTHENTICATED", error.Extensions?["code"]?.ToString()); + } + + [Theory] + [MemberData(nameof(AllProtectedOperations))] + public async Task ProtectedOperation_WithTamperedSignature_ReturnsAuthenticationError(string name, string query, object? variables) + { + using var client = CreateClient(); + var token = JwtTestTokenFactory.CreateTokenWithWrongSignature(GetJwtOptions(), 1, "alice", "alice@example.com"); + client.SetAuthToken(token); + + var response = await client.ExecuteQueryAsync(query, variables); + + Assert.True(response.HasErrors, $"{name} was expected to fail with a tampered signature."); + var error = response.Errors!.First(); + Assert.Equal("AUTH_NOT_AUTHENTICATED", error.Extensions?["code"]?.ToString()); + } + + [Theory] + [MemberData(nameof(AllProtectedOperations))] + public async Task ProtectedOperation_WithMalformedTokenString_ReturnsAuthenticationError(string name, string query, object? variables) + { + using var client = CreateClient(); + client.SetAuthToken(JwtTestTokenFactory.CreateMalformedTokenString()); + + var response = await client.ExecuteQueryAsync(query, variables); + + Assert.True(response.HasErrors, $"{name} was expected to fail with a malformed token."); + var error = response.Errors!.First(); + Assert.Equal("AUTH_NOT_AUTHENTICATED", error.Extensions?["code"]?.ToString()); + } + + [Theory] + [MemberData(nameof(OperationsRequiringUserIdClaim))] + public async Task ProtectedOperation_WithTokenMissingNameIdentifierClaim_ReturnsDifferentErrorThanAuthenticationFailure(string name, string query, object? variables) + { + using var client = CreateClient(); + var token = JwtTestTokenFactory.CreateTokenMissingNameIdentifierClaim(GetJwtOptions(), 1, "alice", "alice@example.com"); + client.SetAuthToken(token); + + var response = await client.ExecuteQueryAsync(query, variables); + + Assert.True(response.HasErrors, $"{name} was expected to fail without a NameIdentifier claim."); + var error = response.Errors!.First(); + Assert.NotEqual("AUTH_NOT_AUTHENTICATED", error.Extensions?["code"]?.ToString()); + } + + [Fact] + public async Task LogoutUser_WithTokenMissingNameIdentifierClaim_Succeeds() + { + using var client = CreateClient(); + + var loginResponse = await client.ExecuteMutationAsync( + Mutations.Users.LoginUser, + new { input = new { email = "alice@example.com", password = "Password123!" } }); + var rawRefreshToken = client.GetRefreshTokenCookie(); + Assert.NotNull(rawRefreshToken); + client.SetRefreshTokenCookie(rawRefreshToken); + + var token = JwtTestTokenFactory.CreateTokenMissingNameIdentifierClaim(GetJwtOptions(), 1, "alice", "alice@example.com"); + client.SetAuthToken(token); + + var response = await client.ExecuteMutationAsync( + Mutations.Users.LogoutUser, new { }); + + Assert.False(response.HasErrors); + Assert.True(response.Data?.LogoutUser?.Boolean); + } +} diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/DataLoaders/BatchingTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/DataLoaders/BatchingTests.cs new file mode 100644 index 0000000..d313d5e --- /dev/null +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/DataLoaders/BatchingTests.cs @@ -0,0 +1,146 @@ +using TournamentAPI.Shared.Models; + +namespace TournamentAPI.IntegrationTests.GraphQL.Tests.DataLoaders; + +public class BatchingTests : BaseIntegrationTest +{ + public BatchingTests(IntegrationTestWebAppFactory factory) : base(factory) + { + } + + [Fact] + public async Task GetTournamentById_WithManyMatchesReferencingManyUsers_BatchesApplicationUserLookups() + { + // Arrange + Factory.CommandRecorder.Clear(); + using var client = CreateClient(); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithAllMatchesAndPlayers, + new { id = 3 }); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data?.TournamentById?.Bracket?.MatchesByBracket); + Assert.Equal(7, response.Data.TournamentById.Bracket.MatchesByBracket.TotalCount); + + var userTableCommands = CountTableCommands("AspNetUsers"); + Assert.Equal(1, userTableCommands); + } + + [Fact] + public async Task GetTournamentById_WithManyParticipants_BatchesParticipantAndBackReferenceLookups() + { + // Arrange + Factory.CommandRecorder.Clear(); + using var client = CreateClient(); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsAndTournamentBackReference, + new { id = 3 }); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data?.TournamentById?.Participants); + Assert.Equal(8, response.Data.TournamentById.Participants.TotalCount); + + var userTableCommands = CountTableCommands("AspNetUsers"); + var tournamentTableCommands = CountTableCommands("Tournaments"); + + Assert.Equal(1, userTableCommands); + Assert.Equal(2, tournamentTableCommands); + } + + [Fact] + public async Task GetTournaments_WithManyBrackets_BatchesBracketLookups() + { + // Arrange + Factory.CommandRecorder.Clear(); + using var client = CreateClient(); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetAllWithBracketAndMatches); + + // Assert + Assert.False(response.HasErrors); + Assert.NotNull(response.Data?.Tournaments); + + var bracketTableCommands = CountTableCommands("Brackets"); + Assert.Equal(1, bracketTableCommands); + } + + [Fact] + public async Task GetTournamentParticipants_WithManyParticipants_BatchesWonTournamentIdLookups() + { + // Arrange + Factory.CommandRecorder.Clear(); + using var client = CreateClient(); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsWonTournaments, + new { id = 3 }); + + // Assert + Assert.False(response.HasErrors); + Assert.Equal(8, response.Data?.TournamentById?.Participants?.TotalCount); + + var tournamentTableCommands = CountTableCommands("Tournaments"); + // 16, not 9: 1 root tournamentById fetch + 1 batched id-lookup covering all 8 + // participants (id resolution IS batched) + 8 per-participant page-fetch queries to + // materialize the actual won-tournament entities + 6 extra count probes for the + // participants with no won tournaments. See memory: history-connections-not-fully-batched. + Assert.Equal(16, tournamentTableCommands); + } + + [Fact] + public async Task GetTournamentParticipants_WithManyParticipants_BatchesWonMatchIdLookups() + { + // Arrange + Factory.CommandRecorder.Clear(); + using var client = CreateClient(); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsWonMatches, + new { id = 3 }); + + // Assert + Assert.False(response.HasErrors); + Assert.Equal(8, response.Data?.TournamentById?.Participants?.TotalCount); + + var matchTableCommands = CountTableCommands("Matches"); + // 9 = 1 batched id-lookup covering all 8 participants + 1 page-fetch query per + // participant to materialize the actual won-match entities. + Assert.Equal(9, matchTableCommands); + } + + [Fact] + public async Task GetTournamentParticipants_WithManyParticipants_BatchesPlayedTournamentIdLookups() + { + // Arrange + Factory.CommandRecorder.Clear(); + using var client = CreateClient(); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsPlayedTournaments, + new { id = 3 }); + + // Assert + Assert.False(response.HasErrors); + Assert.Equal(8, response.Data?.TournamentById?.Participants?.TotalCount); + + var participantTableCommands = CountTableCommands("TournamentParticipants"); + // 3, not 1: 2 baseline commands the participants connection itself always issues + // (its own totalCount and item fetch) + 1 batched id-lookup covering all 8 participants + // for playedTournaments - the id-lookup step really is a single batched query. + Assert.Equal(3, participantTableCommands); + } + + private int CountTableCommands(string tableName) => + Factory.CommandRecorder.Commands.Count(c => c.Contains($"[{tableName}]") || c.Contains($" {tableName} ")); +} diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchQueryTests.cs index 4ce6485..f489430 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchQueryTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Matches/MatchQueryTests.cs @@ -59,4 +59,46 @@ public async Task GetMatchesForRound_WithPlayerDetails_ReturnsMatchesWithPlayers Assert.NotNull(match.Winner); } } + + [Fact] + public async Task GetMatchesForRound_WithRoundFilter_ExcludesOtherRounds() + { + // Act + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Match.GetMatchesForRoundWithBasicFields, + new + { + tournamentId = 3, + roundNumber = 1 + }); + + // Assert + Assert.False(response.HasErrors); + var matches = response.Data!.TournamentById!.Bracket!.MatchesByBracket!; + Assert.Equal(4, matches.TotalCount); + Assert.All(matches.Nodes!, m => Assert.Equal(1, m.Round)); + } + + [Fact] + public async Task GetMatchesForRound_WithNonExistentRound_ReturnsEmptyConnection() + { + // Act + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Match.GetMatchesForRoundWithBasicFields, + new + { + tournamentId = 3, + roundNumber = 99 + }); + + // Assert + Assert.False(response.HasErrors); + var matches = response.Data!.TournamentById!.Bracket!.MatchesByBracket!; + Assert.Equal(0, matches.TotalCount); + Assert.Empty(matches.Nodes!); + } } diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Participants/ParticipantQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Participants/ParticipantQueryTests.cs new file mode 100644 index 0000000..a565859 --- /dev/null +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Participants/ParticipantQueryTests.cs @@ -0,0 +1,68 @@ +using TournamentAPI.Shared.Models; + +namespace TournamentAPI.IntegrationTests.GraphQL.Tests.Participants; + +public class ParticipantQueryTests : BaseIntegrationTest +{ + public ParticipantQueryTests(IntegrationTestWebAppFactory factory) : base(factory) + { + } + + [Fact] + public async Task GetTournamentParticipants_WithDescendingSlotNumberSort_ReturnsInDescendingOrder() + { + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsSortedBySlotNumberDescending, + new { id = 3 }); + + Assert.False(response.HasErrors); + var participantIds = response.Data!.TournamentById!.Participants!.Nodes!.Select(n => n.ParticipantId).ToList(); + Assert.Equal([8, 7, 6, 5, 4, 3, 2, 1], participantIds); + } + + [Fact] + public async Task GetTournamentParticipants_WithAscendingSlotNumberSort_ReturnsInAscendingOrder() + { + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsSortedBySlotNumberAscending, + new { id = 3 }); + + Assert.False(response.HasErrors); + var participantIds = response.Data!.TournamentById!.Participants!.Nodes!.Select(n => n.ParticipantId).ToList(); + Assert.Equal([1, 2, 3, 4, 5, 6, 7, 8], participantIds); + } + + [Fact] + public async Task GetTournamentParticipants_WithSlotNumberFilter_ReturnsOnlyMatchingParticipants() + { + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsFilteredBySlotNumberGreaterThan, + new { id = 3, minSlot = 5 }); + + Assert.False(response.HasErrors); + var participants = response.Data!.TournamentById!.Participants!; + Assert.Equal(3, participants.TotalCount); + Assert.Equal([6, 7, 8], participants.Nodes!.Select(n => n.ParticipantId).OrderBy(id => id)); + } + + [Fact] + public async Task GetTournamentParticipants_WithParticipantIdFilter_ReturnsOnlyThatParticipant() + { + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantsFilteredByParticipantId, + new { id = 3, participantId = 3 }); + + Assert.False(response.HasErrors); + var participants = response.Data!.TournamentById!.Participants!; + Assert.Equal(1, participants.TotalCount); + Assert.Equal(3, participants.Nodes!.Single().ParticipantId); + } +} diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Participants/SlotNumberAssignmentTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Participants/SlotNumberAssignmentTests.cs new file mode 100644 index 0000000..b3d5229 --- /dev/null +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Participants/SlotNumberAssignmentTests.cs @@ -0,0 +1,226 @@ +using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data.Models; +using TournamentAPI.Shared.Helpers; +using TournamentAPI.Shared.Models; + +namespace TournamentAPI.IntegrationTests.GraphQL.Tests.Participants; + +public class SlotNumberAssignmentTests : BaseIntegrationTest +{ + public SlotNumberAssignmentTests(IntegrationTestWebAppFactory factory) : base(factory) + { + } + + private async Task LoginAsync(string email, string password = "Password123!") + { + var client = CreateClient(); + + var tokenResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email, password } }); + client.SetAuthToken(tokenResponse.Data!.LoginUser!.String!); + + return client; + } + + private async Task CreateOpenTournamentAsync(int ownerId, int maxParticipants) + { + var tournament = new Tournament + { + Name = "Slot Number Test Tournament", + StartDate = DateTime.UtcNow.AddDays(7), + Status = TournamentStatus.Open, + OwnerId = ownerId, + MaxParticipants = maxParticipants + }; + + DbContext.Tournaments.Add(tournament); + await DbContext.SaveChangesAsync(); + + return tournament; + } + + [Fact] + public async Task AddParticipant_SequentialAdds_AssignStrictlyIncreasingUniqueSlotNumbers() + { + var alice = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "alice"); + var tournament = await CreateOpenTournamentAsync(alice.Id, maxParticipants: 5); + using var owner = await LoginAsync("alice@example.com"); + + var participantUserNames = new[] { "bob", "carol", "david", "emma" }; + var expectedSlot = 1; + + foreach (var userName in participantUserNames) + { + var user = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == userName); + + var response = await owner.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId = tournament.Id, userId = user.Id } }); + + Assert.False(response.HasErrors); + + var slot = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournament.Id && tp.ParticipantId == user.Id) + .Select(tp => tp.SlotNumber) + .SingleAsync(); + Assert.Equal(expectedSlot, slot); + expectedSlot++; + } + + var finalSlots = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournament.Id) + .Select(tp => tp.SlotNumber) + .OrderBy(s => s) + .ToListAsync(); + Assert.Equal(Enumerable.Range(1, participantUserNames.Length), finalSlots); + } + + [Fact] + public async Task JoinTournament_SequentialJoins_AssignStrictlyIncreasingUniqueSlotNumbers() + { + var alice = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "alice"); + var tournament = await CreateOpenTournamentAsync(alice.Id, maxParticipants: 5); + + var joinerEmails = new[] { "bob@example.com", "carol@example.com", "david@example.com", "emma@example.com" }; + var expectedSlot = 1; + + foreach (var email in joinerEmails) + { + using var client = await LoginAsync(email); + + var response = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.JoinTournament, + new { input = new { tournamentId = tournament.Id } }); + + Assert.False(response.HasErrors); + + var user = await DbContext.Users.AsNoTracking().FirstAsync(u => u.Email == email); + var slot = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournament.Id && tp.ParticipantId == user.Id) + .Select(tp => tp.SlotNumber) + .SingleAsync(); + Assert.Equal(expectedSlot, slot); + expectedSlot++; + } + + var finalSlots = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournament.Id) + .Select(tp => tp.SlotNumber) + .OrderBy(s => s) + .ToListAsync(); + Assert.Equal(Enumerable.Range(1, joinerEmails.Length), finalSlots); + } + + [Fact] + public async Task AddParticipant_And_JoinTournament_Interleaved_ProduceNoGapsOrDuplicates() + { + var alice = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "alice"); + var tournament = await CreateOpenTournamentAsync(alice.Id, maxParticipants: 5); + using var owner = await LoginAsync("alice@example.com"); + + var bob = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "bob"); + var david = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "david"); + + var addBobResponse = await owner.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId = tournament.Id, userId = bob.Id } }); + Assert.False(addBobResponse.HasErrors); + + using (var carolClient = await LoginAsync("carol@example.com")) + { + var joinCarolResponse = await carolClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.JoinTournament, + new { input = new { tournamentId = tournament.Id } }); + Assert.False(joinCarolResponse.HasErrors); + } + + var addDavidResponse = await owner.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, + new { input = new { tournamentId = tournament.Id, userId = david.Id } }); + Assert.False(addDavidResponse.HasErrors); + + using (var emmaClient = await LoginAsync("emma@example.com")) + { + var joinEmmaResponse = await emmaClient.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.JoinTournament, + new { input = new { tournamentId = tournament.Id } }); + Assert.False(joinEmmaResponse.HasErrors); + } + + var finalSlots = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournament.Id) + .Select(tp => tp.SlotNumber) + .OrderBy(s => s) + .ToListAsync(); + Assert.Equal([1, 2, 3, 4], finalSlots); + } + + [Fact] + public async Task JoinTournament_AfterRaceCondition_WinningRequestHasCorrectSlotNumberAndNoGapExists() + { + var tournamentId = 14; + using var client = await LoginAsync("carol@example.com"); + using var client2 = await LoginAsync("david@example.com"); + + var variables = new { input = new { tournamentId } }; + + var task1 = client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.JoinTournament, variables); + var task2 = client2.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Tournaments.JoinTournament, variables); + var results = await Task.WhenAll(task1, task2); + + Assert.Single(results, r => !r.HasErrors); + Assert.Single(results, r => r.HasErrors); + + var tournament = await DbContext.Tournaments.AsNoTracking().FirstAsync(t => t.Id == tournamentId); + var finalSlots = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournamentId) + .Select(tp => tp.SlotNumber) + .OrderBy(s => s) + .ToListAsync(); + + Assert.Equal(Enumerable.Range(1, tournament.MaxParticipants), finalSlots); + Assert.Equal(tournament.MaxParticipants, finalSlots.Max()); + } + + [Fact] + public async Task AddParticipant_AfterRaceCondition_WinningRequestHasCorrectSlotNumberAndNoGapExists() + { + var tournamentId = 14; + var carol = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "carol"); + var david = await DbContext.Users.AsNoTracking().FirstAsync(u => u.UserName == "david"); + using var client = await LoginAsync("emma@example.com"); + using var client2 = await LoginAsync("emma@example.com"); + + var variables1 = new { input = new { tournamentId, userId = carol.Id } }; + var variables2 = new { input = new { tournamentId, userId = david.Id } }; + + var task1 = client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, variables1); + var task2 = client2.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Participant.AddParticipantWithBasicFieldsReturn, variables2); + var results = await Task.WhenAll(task1, task2); + + Assert.Single(results, r => !r.HasErrors); + Assert.Single(results, r => r.HasErrors); + + var tournament = await DbContext.Tournaments.AsNoTracking().FirstAsync(t => t.Id == tournamentId); + var finalSlots = await DbContext.TournamentParticipants + .AsNoTracking() + .Where(tp => tp.TournamentId == tournamentId) + .Select(tp => tp.SlotNumber) + .OrderBy(s => s) + .ToListAsync(); + + Assert.Equal(Enumerable.Range(1, tournament.MaxParticipants), finalSlots); + Assert.Equal(tournament.MaxParticipants, finalSlots.Max()); + } +} diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs index ebeed78..e258fce 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Tournaments/TournamentQueryTests.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using TournamentAPI.Data.Models; using TournamentAPI.Shared.Models; namespace TournamentAPI.IntegrationTests.GraphQL.Tests.Tournaments; @@ -81,6 +82,152 @@ public async Task GetTournamentById_WithParticipants_HidesPrivateParticipantEmai Assert.Null(henry!.Email); } + [Fact] + public async Task GetTournamentById_Participants_AsAnonymousViewer_ShowsAllPublicEmailsAndHidesOnlyHenrys() + { + using var client = CreateClient(); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantEmailOnly, + new { id = 3 }); + + Assert.False(response.HasErrors); + var expectedEmails = new Dictionary + { + [1] = "alice@example.com", + [2] = "bob@example.com", + [3] = "carol@example.com", + [4] = "david@example.com", + [5] = "emma@example.com", + [6] = "frank@example.com", + [7] = "grace@example.com", + [8] = null, + }; + + var participants = response.Data!.TournamentById!.Participants!.Nodes!.Select(p => p.Participant!); + Assert.Equal(expectedEmails.Count, participants.Count()); + foreach (var participant in participants) + { + Assert.Equal(expectedEmails[participant.Id], participant.Email); + } + } + + [Fact] + public async Task GetTournamentById_Participants_AsUnrelatedAuthenticatedViewer_ShowsAllPublicEmailsAndHidesOnlyHenrys() + { + using var client = CreateClient(); + + await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.RegisterUser, + new { input = new { email = "ivan@example.com", userName = "ivan", password = "Password123!" } }); + + var loginResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "ivan@example.com", password = "Password123!" } }); + client.SetAuthToken(loginResponse.Data!.LoginUser!.String!); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantEmailOnly, + new { id = 3 }); + + Assert.False(response.HasErrors); + var expectedEmails = new Dictionary + { + [1] = "alice@example.com", + [2] = "bob@example.com", + [3] = "carol@example.com", + [4] = "david@example.com", + [5] = "emma@example.com", + [6] = "frank@example.com", + [7] = "grace@example.com", + [8] = null, + }; + + var participants = response.Data!.TournamentById!.Participants!.Nodes!.Select(p => p.Participant!); + Assert.Equal(expectedEmails.Count, participants.Count()); + foreach (var participant in participants) + { + Assert.Equal(expectedEmails[participant.Id], participant.Email); + } + } + + [Fact] + public async Task GetTournamentById_Participants_AsHenryViewingHimself_ShowsHenrysOwnEmailButLeavesOthersUnaffected() + { + using var client = CreateClient(); + + var loginResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "henry@example.com", password = "Password123!" } }); + client.SetAuthToken(loginResponse.Data!.LoginUser!.String!); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantEmailOnly, + new { id = 3 }); + + Assert.False(response.HasErrors); + var expectedEmails = new Dictionary + { + [1] = "alice@example.com", + [2] = "bob@example.com", + [3] = "carol@example.com", + [4] = "david@example.com", + [5] = "emma@example.com", + [6] = "frank@example.com", + [7] = "grace@example.com", + [8] = "henry@example.com", + }; + + var participants = response.Data!.TournamentById!.Participants!.Nodes!.Select(p => p.Participant!); + Assert.Equal(expectedEmails.Count, participants.Count()); + foreach (var participant in participants) + { + Assert.Equal(expectedEmails[participant.Id], participant.Email); + } + } + + [Fact] + public async Task GetTournamentById_Participants_WithTwoPrivateEmailParticipants_EachOnlyRevealsOwnEmailToSelf() + { + var ivan = new ApplicationUser + { + UserName = "ivan", + Email = "ivan@example.com", + FirstName = "Ivan", + LastName = "Ivanov", + IsEmailPublic = false + }; + DbContext.Users.Add(ivan); + await DbContext.SaveChangesAsync(); + + DbContext.TournamentParticipants.Add(new TournamentParticipant + { + TournamentId = 3, + ParticipantId = ivan.Id, + SlotNumber = 9 + }); + await DbContext.SaveChangesAsync(); + + using var client = CreateClient(); + var loginResponse = await client.ExecuteMutationAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email = "henry@example.com", password = "Password123!" } }); + client.SetAuthToken(loginResponse.Data!.LoginUser!.String!); + + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Tournaments.GetByIdWithParticipantEmailOnly, + new { id = 3 }); + + Assert.False(response.HasErrors); + var nodes = response.Data!.TournamentById!.Participants!.Nodes!; + + var henryNode = nodes.Single(n => n.Participant!.Id == 8); + Assert.Equal("henry@example.com", henryNode.Participant!.Email); + + var ivanNode = nodes.Single(n => n.Participant!.Id == ivan.Id); + Assert.Null(ivanNode.Participant!.Email); + } + [Fact] public async Task GetTournamentById_WithOwner_ShowsPublicEmail_EvenWhenIsEmailPublicNotRequested() { diff --git a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs index 720e4a2..5bac75e 100644 --- a/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs +++ b/TournamentAPI.IntegrationTests/GraphQL/Tests/Users/UserQueryTests.cs @@ -247,4 +247,79 @@ public async Task GetMe_WonMatches_ExcludesMatchesNeedingReplay() var wonMatchIds = response.Data.Me.WonMatches.Nodes?.Select(m => m.Id).ToList(); Assert.DoesNotContain(match.Id, wonMatchIds); } + + [Fact] + public async Task GetMe_PlayedTournaments_WithNameFilter_ReturnsOnlyMatchingTournaments() + { + // Arrange + var email = "alice@example.com"; + using var client = CreateClient(); + + var token = await client.ExecuteQueryAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email, password = "Password123!" } }); + client.SetAuthToken(token.Data.LoginUser.String); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Users.GetMePlayedTournamentsWithNameFilter, + new { nameFilter = "Cup" }); + + // Assert + Assert.False(response.HasErrors); + var playedTournaments = response.Data!.Me!.PlayedTournaments!; + Assert.Equal(1, playedTournaments.TotalCount); + Assert.Equal(["Champions Cup"], playedTournaments.Nodes!.Select(t => t.Name)); + } + + [Fact] + public async Task GetMe_WonMatches_WithRoundFilter_ReturnsOnlyMatchingRoundMatches() + { + // Arrange + var email = "alice@example.com"; + using var client = CreateClient(); + + var expectedMatch = await DbContext.Matches + .AsNoTracking() + .SingleAsync(m => m.WinnerId == 1 && m.Round == 2 && m.Bracket.Tournament.Name == "Winter Championship 2024"); + + var token = await client.ExecuteQueryAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email, password = "Password123!" } }); + client.SetAuthToken(token.Data.LoginUser.String); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Users.GetMeWonMatchesWithRoundFilter, + new { roundNumber = 2 }); + + // Assert + Assert.False(response.HasErrors); + var wonMatches = response.Data!.Me!.WonMatches!; + Assert.Equal(1, wonMatches.TotalCount); + Assert.Equal(expectedMatch.Id, wonMatches.Nodes!.Single().Id); + } + + [Fact] + public async Task GetMe_PlayedTournaments_WithSorting_ReturnsSortedResults() + { + // Arrange + var email = "alice@example.com"; + using var client = CreateClient(); + + var token = await client.ExecuteQueryAsync( + Shared.MutationExamples.Mutations.Users.LoginUser, + new { input = new { email, password = "Password123!" } }); + client.SetAuthToken(token.Data.LoginUser.String); + + // Act + var response = await client.ExecuteQueryAsync( + Shared.QueryExamples.Queries.Users.GetMePlayedTournamentsSortedByNameDescending); + + // Assert + Assert.False(response.HasErrors); + var names = response.Data!.Me!.PlayedTournaments!.Nodes!.Select(t => t.Name).ToList(); + Assert.True(names.Count > 1); + Assert.Equal(names.OrderByDescending(n => n, StringComparer.Ordinal), names); + } } diff --git a/TournamentAPI.IntegrationTests/Infrastructure/JwtTestTokenFactory.cs b/TournamentAPI.IntegrationTests/Infrastructure/JwtTestTokenFactory.cs new file mode 100644 index 0000000..3b996bc --- /dev/null +++ b/TournamentAPI.IntegrationTests/Infrastructure/JwtTestTokenFactory.cs @@ -0,0 +1,82 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; +using TournamentAPI.Configuration; + +namespace TournamentAPI.IntegrationTests.Infrastructure; + +public static class JwtTestTokenFactory +{ + public static string CreateExpiredToken(JwtOptions options, int userId, string userName, string email) + { + var token = new JwtSecurityToken( + options.Issuer, + options.Audience, + CreateClaims(userId, userName, email), + expires: DateTime.UtcNow.AddMinutes(-10), + signingCredentials: CreateSigningCredentials(options.Key)); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public static string CreateTokenWithWrongSignature(JwtOptions options, int userId, string userName, string email) + { + var wrongKey = Convert.ToBase64String(Guid.NewGuid().ToByteArray()) + Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + + var token = new JwtSecurityToken( + options.Issuer, + options.Audience, + CreateClaims(userId, userName, email), + expires: DateTime.UtcNow.AddMinutes(10), + signingCredentials: CreateSigningCredentials(wrongKey)); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public static string CreateTokenMissingNameIdentifierClaim(JwtOptions options, int userId, string userName, string email) + { + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim(JwtRegisteredClaimNames.Iat, + DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), + ClaimValueTypes.Integer64), + new Claim(ClaimTypes.Name, userName), + new Claim(ClaimTypes.Email, email) + }; + + var token = new JwtSecurityToken( + options.Issuer, + options.Audience, + claims, + expires: DateTime.UtcNow.AddMinutes(10), + signingCredentials: CreateSigningCredentials(options.Key)); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public static string CreateMalformedTokenString() => "not.a.jwt"; + + private static Claim[] CreateClaims(int userId, string userName, string email) + { + return + [ + new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim(JwtRegisteredClaimNames.Iat, + DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), + ClaimValueTypes.Integer64), + new Claim(ClaimTypes.Name, userName), + new Claim(ClaimTypes.Email, email), + new Claim(ClaimTypes.NameIdentifier, userId.ToString()) + ]; + } + + private static SigningCredentials CreateSigningCredentials(string key) + { + return new SigningCredentials( + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), + SecurityAlgorithms.HmacSha256); + } +} diff --git a/TournamentAPI.IntegrationTests/Infrastructure/QueryCommandRecorder.cs b/TournamentAPI.IntegrationTests/Infrastructure/QueryCommandRecorder.cs new file mode 100644 index 0000000..bad0c6b --- /dev/null +++ b/TournamentAPI.IntegrationTests/Infrastructure/QueryCommandRecorder.cs @@ -0,0 +1,56 @@ +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace TournamentAPI.IntegrationTests.Infrastructure; + +public class QueryCommandRecorder : DbCommandInterceptor +{ + private readonly List _commands = new(); + private readonly object _lock = new(); + + public IReadOnlyList Commands + { + get + { + lock (_lock) + { + return _commands.ToList(); + } + } + } + + public void Clear() + { + lock (_lock) + { + _commands.Clear(); + } + } + + public override InterceptionResult ReaderExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + Record(command); + return base.ReaderExecuting(command, eventData, result); + } + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Record(command); + return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); + } + + private void Record(DbCommand command) + { + lock (_lock) + { + _commands.Add(command.CommandText); + } + } +} diff --git a/TournamentAPI.IntegrationTests/IntegrationTestWebAppFactory.cs b/TournamentAPI.IntegrationTests/IntegrationTestWebAppFactory.cs index 16ee838..9ae63ff 100644 --- a/TournamentAPI.IntegrationTests/IntegrationTestWebAppFactory.cs +++ b/TournamentAPI.IntegrationTests/IntegrationTestWebAppFactory.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Hosting; using Testcontainers.MsSql; using TournamentAPI.Data; +using TournamentAPI.IntegrationTests.Infrastructure; namespace TournamentAPI.IntegrationTests; @@ -18,6 +19,8 @@ public class IntegrationTestWebAppFactory : WebApplicationFactory, IAsy .WithPassword("Your_password123") .Build(); + public QueryCommandRecorder CommandRecorder { get; } = new(); + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureTestServices(services => @@ -29,6 +32,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddDbContextFactory(options => { options.UseSqlServer(connectionString); + options.AddInterceptors(CommandRecorder); }); services.AddHangfire(config => config.UseSqlServerStorage(connectionString)); diff --git a/TournamentAPI.Shared/Models/ResponseModels.cs b/TournamentAPI.Shared/Models/ResponseModels.cs index dbf43d6..1f6a81d 100644 --- a/TournamentAPI.Shared/Models/ResponseModels.cs +++ b/TournamentAPI.Shared/Models/ResponseModels.cs @@ -73,6 +73,7 @@ public class ApplicationUserNode public bool IsEmailPublic { get; set; } public TournamentsConnection? WonTournaments { get; set; } public TournamentsConnection? PlayedTournaments { get; set; } + public MatchesByBracketConnection? WonMatches { get; set; } } public class BracketNode diff --git a/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs b/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs index d54bc3c..27d119a 100644 --- a/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs +++ b/TournamentAPI.Shared/QueryExamples/TournamentQueries.cs @@ -314,6 +314,137 @@ public static class Tournaments } """; + public const string GetByIdWithParticipantsSortedBySlotNumberDescending = """ + query($id: Int!) { + tournamentById(id: $id) { + id + participants(first: 20, order: { slotNumber: DESC }) { + totalCount + edges { + node { + participantId + tournamentId + } + } + } + } + } + """; + + public const string GetByIdWithParticipantsSortedBySlotNumberAscending = """ + query($id: Int!) { + tournamentById(id: $id) { + id + participants(first: 20, order: { slotNumber: ASC }) { + totalCount + edges { + node { + participantId + tournamentId + } + } + } + } + } + """; + + public const string GetByIdWithParticipantsFilteredBySlotNumberGreaterThan = """ + query($id: Int!, $minSlot: Int!) { + tournamentById(id: $id) { + id + participants(first: 20, where: { slotNumber: { gt: $minSlot } }) { + totalCount + edges { + node { + participantId + tournamentId + } + } + } + } + } + """; + + public const string GetByIdWithParticipantsFilteredByParticipantId = """ + query($id: Int!, $participantId: Int!) { + tournamentById(id: $id) { + id + participants(first: 20, where: { participantId: { eq: $participantId } }) { + totalCount + edges { + node { + participantId + tournamentId + } + } + } + } + } + """; + + public const string GetByIdWithParticipantsWonTournaments = """ + query($id: Int!) { + tournamentById(id: $id) { + id + participants(first: 20) { + totalCount + edges { + node { + participant { + id + wonTournaments(first: 5) { + totalCount + } + } + } + } + } + } + } + """; + + public const string GetByIdWithParticipantsWonMatches = """ + query($id: Int!) { + tournamentById(id: $id) { + id + participants(first: 20) { + totalCount + edges { + node { + participant { + id + wonMatches(first: 5) { + totalCount + } + } + } + } + } + } + } + """; + + public const string GetByIdWithParticipantsPlayedTournaments = """ + query($id: Int!) { + tournamentById(id: $id) { + id + participants(first: 20) { + totalCount + edges { + node { + participant { + id + playedTournaments(first: 5) { + totalCount + } + } + } + } + } + } + } + """; + public const string GetByIdWithOwnerTournamentHistory = """ query($id: Int!) { tournamentById(id: $id) { @@ -360,6 +491,64 @@ public static class Tournaments } """; + public const string GetByIdWithAllMatchesAndPlayers = """ + query($id: Int!) { + tournamentById(id: $id) { + id + name + bracket { + id + tournamentId + matchesByBracket(first: 20) { + totalCount + edges { + node { + id + round + player1Id + player2Id + winnerId + player1 { + id + } + player2 { + id + } + winner { + id + } + } + } + } + } + } + } + """; + + public const string GetByIdWithParticipantsAndTournamentBackReference = """ + query($id: Int!) { + tournamentById(id: $id) { + id + name + participants(first: 20) { + totalCount + edges { + node { + participantId + tournamentId + participant { + id + } + tournament { + id + } + } + } + } + } + } + """; + public const string GetByIdWithBracketAndMatches = """ query($id: Int!) { tournamentById(id: $id) { diff --git a/TournamentAPI.Shared/QueryExamples/UserQueries.cs b/TournamentAPI.Shared/QueryExamples/UserQueries.cs index 824c8a2..6e411d3 100644 --- a/TournamentAPI.Shared/QueryExamples/UserQueries.cs +++ b/TournamentAPI.Shared/QueryExamples/UserQueries.cs @@ -54,5 +54,53 @@ public static class Users } } """; + + public const string GetMePlayedTournamentsWithNameFilter = """ + query($nameFilter: String!) { + me { + playedTournaments(first: 10, where: { name: { contains: $nameFilter } }) { + totalCount + edges { + node { + id + name + } + } + } + } + } + """; + + public const string GetMeWonMatchesWithRoundFilter = """ + query($roundNumber: Int!) { + me { + wonMatches(first: 10, where: { round: { eq: $roundNumber } }) { + totalCount + edges { + node { + id + round + } + } + } + } + } + """; + + public const string GetMePlayedTournamentsSortedByNameDescending = """ + query { + me { + playedTournaments(first: 20, order: { name: DESC }) { + totalCount + edges { + node { + id + name + } + } + } + } + } + """; } } diff --git a/TournamentAPI.UnitTests/Schema/ApplicationUserSchemaTests.cs b/TournamentAPI.UnitTests/Schema/ApplicationUserSchemaTests.cs new file mode 100644 index 0000000..91c6783 --- /dev/null +++ b/TournamentAPI.UnitTests/Schema/ApplicationUserSchemaTests.cs @@ -0,0 +1,99 @@ +using HotChocolate; +using HotChocolate.Execution; +using HotChocolate.Types; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TournamentAPI.Brackets; +using TournamentAPI.Configuration.Extensions; +using TournamentAPI.Matches; +using TournamentAPI.Participants; +using TournamentAPI.Services; +using TournamentAPI.Tournaments; +using TournamentAPI.Users; + +namespace TournamentAPI.UnitTests.Schema; + +public class ApplicationUserSchemaTests +{ + private static async Task BuildSchemaAsync() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Jwt:Key"] = "unit-test-signing-key-unit-test-signing-key", + ["Jwt:Issuer"] = "unit-test-issuer", + ["Jwt:Audience"] = "unit-test-audience", + ["ConnectionStrings:DefaultConnection"] = "Server=.;Database=schema-test;TrustServerCertificate=True;", + ["HealthCheck:ApiKey"] = "unit-test-health-check-key" + }) + .Build(); + + var services = new ServiceCollection(); + + services.AddSingleton(configuration); + services.AddLogging(); + services.AddMetrics(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddApplicationOptions(); + services.AddApplicationDatabase(); + services.AddApplicationAuthentication(); + services.AddApplicationAuthorization(); + services.AddApplicationMetrics(); + services.AddApplicationGraphQL(isDevelopment: true); + + var provider = services.BuildServiceProvider(); + var manager = provider.GetRequiredService(); + var executor = await manager.GetExecutorAsync(); + + return executor.Schema; + } + + [Fact] + public async Task ApplicationUserType_ExposesExactlyAllowListedFields() + { + var schema = await BuildSchemaAsync(); + var type = schema.Types.GetType("ApplicationUser"); + + var fieldNames = type.Fields + .Where(f => !f.IsIntrospectionField) + .Select(f => f.Name) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + var expected = new[] + { + "email", "firstName", "id", "isEmailPublic", "lastName", + "playedTournaments", "wonMatches", "wonTournaments" + } + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal(expected, fieldNames); + } + + [Fact] + public async Task ApplicationUserType_DoesNotExposeIdentityInternalFields() + { + var schema = await BuildSchemaAsync(); + var type = schema.Types.GetType("ApplicationUser"); + + var fieldNames = type.Fields.Select(f => f.Name).ToHashSet(); + + var identityInternalFields = new[] { "userName", "passwordHash", "securityStamp", "phoneNumber" }; + + foreach (var field in identityInternalFields) + { + Assert.DoesNotContain(field, fieldNames); + } + } +} diff --git a/TournamentAPI/TournamentAPI.csproj b/TournamentAPI/TournamentAPI.csproj index e81b93a..57614b6 100644 --- a/TournamentAPI/TournamentAPI.csproj +++ b/TournamentAPI/TournamentAPI.csproj @@ -7,6 +7,10 @@ true + + + +