Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<string, string, object?> AllProtectedOperations()
{
var data = new TheoryData<string, string, object?>();
foreach (var op in Operations)
data.Add(op.Name, op.Query, op.Variables);
return data;
}

public static TheoryData<string, string, object?> OperationsRequiringUserIdClaim()
{
var data = new TheoryData<string, string, object?>();
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<IOptions<JwtOptions>>().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<object>(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<object>(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<object>(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<object>(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<Shared.Models.LoginResponse>(
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<Shared.Models.LogoutResponse>(
Mutations.Users.LogoutUser, new { });

Assert.False(response.HasErrors);
Assert.True(response.Data?.LogoutUser?.Boolean);
}
}
Original file line number Diff line number Diff line change
@@ -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<TournamentByIdResponse>(
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<TournamentByIdResponse>(
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<TournamentsResponse>(
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<TournamentByIdResponse>(
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<TournamentByIdResponse>(
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<TournamentByIdResponse>(
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} "));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<TournamentByIdResponse>(
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<TournamentByIdResponse>(
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!);
}
}
Loading
Loading