303 lines
11 KiB
C#
303 lines
11 KiB
C#
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<byte>(), player.PasswordHash);
|
|
Assert.NotEqual(Array.Empty<byte>(), 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<JsonElement>();
|
|
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<JsonElement>();
|
|
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<JsonElement>();
|
|
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<JsonElement>("/api/auth/options");
|
|
Assert.False(before.GetProperty("ownerExists").GetBoolean());
|
|
|
|
var ownerRegister = await client.RegisterAsync("owner", admin: true);
|
|
ownerRegister.EnsureSuccessStatusCode();
|
|
|
|
var after = await client.GetFromJsonAsync<JsonElement>("/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<JsonElement>();
|
|
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<DbUpdateException>(() => 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);
|
|
}
|
|
}
|