Add backend test suite covering auth, ownership and the query surface
The API had no tests. These run against the real application through WebApplicationFactory — same pipeline, same Identity configuration, same JWT validation — with only the SQLite file, upload folder and signing key swapped, so a passing test says something about what ships. The ownership tests pin the rule the old PHP API got wrong: a second user sees an empty library, gets 404 (not 403, which would confirm the id exists) when reading, updating or deleting someone else's game, and cannot reassign ownership by putting ownerId or userId in the request body. Also covered: the password policy, that login is indistinguishable between a wrong password and an absent user, that the availability endpoint leaks no row data, that a token signed with an untrusted key is refused, that the sort parameter is allow-listed rather than interpolated, and that uploads must decode as an image regardless of extension or content type. 36 tests, ~1s. Program is now declared public partial so the test host can reach it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace LudosData.Api.Tests;
|
||||
|
||||
public class AuthTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
|
||||
{
|
||||
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.PostAsJsonAsync("/api/auth/register", Registration("reg-ok"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var auth = await response.Content.ReadFromJsonAsync<LudosApiFactory.AuthPayload>();
|
||||
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.PostAsJsonAsync(
|
||||
"/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.PostAsJsonAsync("/api/auth/register", Registration("dupe-user"));
|
||||
|
||||
var second = await client.PostAsJsonAsync("/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.PostAsJsonAsync("/api/auth/register", Registration("login-ok"));
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/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.PostAsJsonAsync("/api/auth/register", Registration("login-bad"));
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/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.PostAsJsonAsync("/api/auth/register", Registration("enum-real"));
|
||||
|
||||
var wrongPassword = await client.PostAsJsonAsync(
|
||||
"/api/auth/login", new { userName = "enum-real", password = "WrongPassword123" });
|
||||
var noSuchUser = await client.PostAsJsonAsync(
|
||||
"/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.PostAsJsonAsync("/api/auth/register", Registration("taken-name"));
|
||||
|
||||
var taken = await client.GetFromJsonAsync<AvailabilityPayload>(
|
||||
"/api/auth/available?userName=taken-name");
|
||||
var free = await client.GetFromJsonAsync<AvailabilityPayload>(
|
||||
"/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.GetFromJsonAsync<LudosApiFactory.UserPayload>("/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);
|
||||
}
|
||||
|
||||
/// <summary>A structurally valid token signed with a key the API does not trust.</summary>
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user