using System.Net; using System.Net.Http.Json; namespace LudosData.Api.Tests; public class AuthTests(LudosApiFactory factory) : IClassFixture { private static object Registration(string user, string password = "TestPassword123") => new { userName = user, email = $"{user}@example.test", password, }; [Fact] public async Task Register_returns_a_usable_token() { var client = factory.CreateClient(); var response = await client.PostJsonAsync("/api/auth/register", Registration("reg-ok")); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var auth = await response.Content.ReadJsonAsync(); Assert.False(string.IsNullOrWhiteSpace(auth!.Token)); Assert.Equal("reg-ok", auth.User.UserName); Assert.True(auth.ExpiresAt > DateTimeOffset.UtcNow); } [Theory] [InlineData("short1A")] // under 12 characters [InlineData("alllowercase123")] // no uppercase [InlineData("ALLUPPERCASE123")] // no lowercase [InlineData("NoDigitsInHerePlease")] // no digit public async Task Register_enforces_the_password_policy(string password) { var client = factory.CreateClient(); var response = await client.PostJsonAsync( "/api/auth/register", Registration($"weak-{password.Length}-{password[0]}", password)); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } [Fact] public async Task Register_rejects_a_duplicate_username() { var client = factory.CreateClient(); await client.PostJsonAsync("/api/auth/register", Registration("dupe-user")); var second = await client.PostJsonAsync("/api/auth/register", Registration("dupe-user")); Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); } [Fact] public async Task Login_succeeds_with_the_right_password() { var client = factory.CreateClient(); await client.PostJsonAsync("/api/auth/register", Registration("login-ok")); var response = await client.PostJsonAsync( "/api/auth/login", new { userName = "login-ok", password = "TestPassword123" }); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } [Fact] public async Task Login_rejects_a_wrong_password() { var client = factory.CreateClient(); await client.PostJsonAsync("/api/auth/register", Registration("login-bad")); var response = await client.PostJsonAsync( "/api/auth/login", new { userName = "login-bad", password = "WrongPassword123" }); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Login_does_not_reveal_whether_a_username_exists() { var client = factory.CreateClient(); await client.PostJsonAsync("/api/auth/register", Registration("enum-real")); var wrongPassword = await client.PostJsonAsync( "/api/auth/login", new { userName = "enum-real", password = "WrongPassword123" }); var noSuchUser = await client.PostJsonAsync( "/api/auth/login", new { userName = "enum-absent", password = "WrongPassword123" }); // Identical status and body, so the endpoint cannot be used to harvest // valid usernames. Assert.Equal(noSuchUser.StatusCode, wrongPassword.StatusCode); Assert.Equal( await noSuchUser.Content.ReadAsStringAsync(), await wrongPassword.Content.ReadAsStringAsync()); } [Fact] public async Task Availability_reports_taken_and_free_names_without_leaking_the_row() { var client = factory.CreateClient(); await client.PostJsonAsync("/api/auth/register", Registration("taken-name")); var taken = await client.GetJsonAsync( "/api/auth/available?userName=taken-name"); var free = await client.GetJsonAsync( "/api/auth/available?userName=definitely-free-name"); Assert.False(taken!.Available); Assert.True(free!.Available); // The response carries a boolean and nothing else — the old API answered // this by returning the whole users row to anonymous callers. var raw = await client.GetStringAsync("/api/auth/available?userName=taken-name"); Assert.DoesNotContain("email", raw, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain("@example.test", raw, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Me_requires_authentication() { var client = factory.CreateClient(); var response = await client.GetAsync("/api/auth/me"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } [Fact] public async Task Me_returns_the_signed_in_user() { var client = await factory.CreateUserClientAsync("me-user"); var user = await client.GetJsonAsync("/api/auth/me"); Assert.Equal("me-user", user!.UserName); } [Fact] public async Task A_token_signed_with_the_wrong_key_is_rejected() { var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", ForgedToken()); var response = await client.GetAsync("/api/games"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } /// A structurally valid token signed with a key the API does not trust. private static string ForgedToken() { var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); var key = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey( System.Text.Encoding.UTF8.GetBytes("an-attacker-controlled-key-32-chars-min")); var token = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken( issuer: "LudosData", audience: "LudosData", claims: [new System.Security.Claims.Claim( System.Security.Claims.ClaimTypes.NameIdentifier, Guid.NewGuid().ToString())], expires: DateTime.UtcNow.AddHours(1), signingCredentials: new Microsoft.IdentityModel.Tokens.SigningCredentials( key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256)); return handler.WriteToken(token); } private record AvailabilityPayload(bool Available); }