using System.Net; using System.Net.Http.Json; using System.Text.Json; using GameList.Domain; using GameList.Infrastructure; using GameList.Tests.Support; using Microsoft.EntityFrameworkCore; namespace GameList.Tests; public class AuthTests { [Fact] public async Task Register_trims_limits_and_sets_cookie_and_normalized_username() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); var response = await client.PostAsJsonAsync("/api/auth/register", new { Username = " MixedCaseUser ", Password = "Pass123!", DisplayName = " Display Name ", AdminKey = (string?)null }); response.EnsureSuccessStatusCode(); Assert.True(response.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains(PlayerIdentityExtensions.PlayerCookieName))); await factory.WithDbContextAsync(async db => { var player = await db.Players.AsNoTracking().SingleAsync(p => p.Username == "MixedCaseUser"); Assert.Equal("mixedcaseuser", player.NormalizedUsername); Assert.True(player.DisplayName!.Length <= 16); Assert.NotEqual(Array.Empty(), player.PasswordHash); Assert.NotEqual(Array.Empty(), player.PasswordSalt); }); } [Fact] public async Task Register_rejects_overlength_username_or_display_name() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); var tooLongUser = new string('u', 25); var userResp = await client.PostAsJsonAsync("/api/auth/register", new { Username = tooLongUser, Password = "Pass123!", DisplayName = "short" }); Assert.Equal(HttpStatusCode.BadRequest, userResp.StatusCode); var longDisplay = new string('d', 17); var displayResp = await client.PostAsJsonAsync("/api/auth/register", new { Username = "okuser", Password = "Pass123!", DisplayName = longDisplay }); Assert.Equal(HttpStatusCode.BadRequest, displayResp.StatusCode); } [Fact] public async Task Register_rejects_weak_passwords() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); var weak = await client.PostAsJsonAsync("/api/auth/register", new { Username = "weakpw", Password = "alllowercase1!", DisplayName = "weak" }); Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode); var json = await weak.Content.ReadFromJsonAsync(); Assert.Equal("Password must include at least one uppercase and one lowercase characters and and digit.", json.GetProperty("error").GetString()); } [Fact] public async Task Login_sets_last_login_and_fills_missing_display_name() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("loginfill"); await factory.WithDbContextAsync(async db => { var player = await db.Players.SingleAsync(); player.DisplayName = null; player.LastLoginAt = DateTimeOffset.UnixEpoch; await db.SaveChangesAsync(); }); var login = await client.LoginAsync("loginfill", "Pass123!"); login.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(async db => { var player = await db.Players.AsNoTracking().SingleAsync(); Assert.NotEqual(DateTimeOffset.UnixEpoch, player.LastLoginAt); Assert.Equal("loginfill", player.DisplayName); }); } [Fact] public async Task Register_with_admin_key_sets_admin_flag() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); var response = await client.RegisterAsync("adminuser", admin: true); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadFromJsonAsync(); Assert.True(json.GetProperty("isAdmin").GetBoolean()); await factory.WithDbContextAsync(async db => { var owner = await db.Players.AsNoTracking().SingleAsync(p => p.Username == "adminuser"); Assert.True(owner.IsOwner); Assert.True(owner.IsAdmin); }); } [Fact] public async Task Register_admin_key_is_bootstrap_only() { await using var factory = new TestWebApplicationFactory(); var first = factory.CreateClientWithCookies(); var second = factory.CreateClientWithCookies(); var firstAdmin = await first.RegisterAsync("firstadmin", admin: true); firstAdmin.EnsureSuccessStatusCode(); var secondAdmin = await second.RegisterAsync("secondadmin", admin: true); Assert.Equal(HttpStatusCode.BadRequest, secondAdmin.StatusCode); var body = await secondAdmin.Content.ReadFromJsonAsync(); Assert.Equal("Admin registration via admin key is disabled once an owner account exists.", body.GetProperty("error").GetString()); } [Fact] public async Task Auth_options_reports_owner_existence() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); var before = await client.GetFromJsonAsync("/api/auth/options"); Assert.False(before.GetProperty("ownerExists").GetBoolean()); var ownerRegister = await client.RegisterAsync("owner", admin: true); ownerRegister.EnsureSuccessStatusCode(); var after = await client.GetFromJsonAsync("/api/auth/options"); Assert.True(after.GetProperty("ownerExists").GetBoolean()); } [Fact] public async Task Register_duplicate_username_returns_conflict() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); var first = await client.RegisterAsync("duplicate"); first.EnsureSuccessStatusCode(); var second = await client.RegisterAsync("duplicate"); Assert.Equal(HttpStatusCode.Conflict, second.StatusCode); } [Fact] public async Task Login_with_wrong_password_returns_unauthorized() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("player1"); var login = await client.LoginAsync("player1", "wrongpass"); Assert.Equal(HttpStatusCode.Unauthorized, login.StatusCode); } [Fact] public async Task Register_validates_required_fields() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); var missing = await client.PostAsJsonAsync("/api/auth/register", new { Username = "", Password = "", DisplayName = "" }); Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode); var badKey = await client.PostAsJsonAsync("/api/auth/register", new { Username = "u", Password = "p", DisplayName = "d", AdminKey = "wrong" }); Assert.Equal(HttpStatusCode.BadRequest, badKey.StatusCode); } [Fact] public async Task Register_and_login_with_null_fields_return_bad_request() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); var register = await client.PostAsJsonAsync("/api/auth/register", new { Username = (string?)null, Password = (string?)null, DisplayName = (string?)null, AdminKey = (string?)null }); Assert.Equal(HttpStatusCode.BadRequest, register.StatusCode); var login = await client.PostAsJsonAsync("/api/auth/login", new { Username = (string?)null, Password = (string?)null }); Assert.Equal(HttpStatusCode.BadRequest, login.StatusCode); } [Fact] public async Task Non_admin_cannot_access_admin_routes() { await using var factory = new TestWebApplicationFactory(); var player = factory.CreateClientWithCookies(); await player.RegisterAsync("regular"); var resp = await player.GetAsync("/api/admin/vote-status"); Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode); var json = await resp.Content.ReadFromJsonAsync(); Assert.Equal("Unauthorized", json.GetProperty("title").GetString()); Assert.Equal("Unauthorized", json.GetProperty("detail").GetString()); Assert.Equal("Unauthorized", json.GetProperty("error").GetString()); } [Fact] public async Task Admin_can_access_admin_routes() { await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("adminuser", admin: true); var resp = await admin.GetAsync("/api/admin/vote-status"); resp.EnsureSuccessStatusCode(); } [Fact] public async Task Logout_clears_cookie() { await using var factory = new TestWebApplicationFactory(); var client = factory.CreateClientWithCookies(); await client.RegisterAsync("logoutme"); var resp = await client.PostAsync("/api/auth/logout", null); resp.EnsureSuccessStatusCode(); Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains("player"))); } [Fact] public async Task Owner_uniqueness_is_enforced_by_database_constraint() { await using var factory = new TestWebApplicationFactory(); var ownerClient = factory.CreateClientWithCookies(); await ownerClient.RegisterAsync("owner1", admin: true); var thrown = await Assert.ThrowsAsync(() => factory.WithDbContextAsync(async db => { var (hash, salt) = PasswordHasher.HashPassword("Pass123!"); db.Players.Add(new Player { Id = Guid.NewGuid(), Username = "owner2", NormalizedUsername = "owner2", PasswordHash = hash, PasswordSalt = salt, DisplayName = "Owner2", IsOwner = true, IsAdmin = true }); await db.SaveChangesAsync(); })); Assert.Contains("Players.IsOwner", thrown.InnerException?.Message ?? thrown.Message, StringComparison.OrdinalIgnoreCase); } }