Files
LudosData/backend/tests/LudosData.Api.Tests/AuthTests.cs
T
ckochandClaude Opus 5 d5a0e42fed Add collector fields, including market value
Rating, notes, condition, region, purchase price and date, plus a market
value carrying the timestamp and source that make it interpretable.

Condition is load-bearing rather than cosmetic: price feeds quote per
condition, so it selects which quoted price applies to a copy. Market value
records when it was captured and where it came from — a collection total is
only as good as its staleness — and an edit to an unrelated field leaves
that timestamp alone, so a stale price cannot start looking freshly checked.

Two storage decisions worth naming:

  * Money is stored as integer minor units. SQLite has no decimal type and
    EF Core maps decimal to TEXT, which compares lexically: "9.00" sorts
    above "10.00" and SUM is unavailable. A value converter keeps decimals
    in C# while ordering and totalling work. A test pins the ordering.
  * Enums serialise as names. The default is ordinals, which meant the API
    rejected the browser's {"condition":"Cib"} with a 400 while the C# tests
    passed, because they round-tripped ints and never spoke the client's
    dialect. The tests now share the API's serializer options.

Also fixes a data-loss bug in the Python tools. Both built their PUT body
from a hardcoded list of field names, so any column added to the model was
omitted and therefore nulled. Adding collector fields meant the next art or
enrichment run would have erased every rating, note, condition, price and
valuation in the library. Payloads are now built by excluding the handful of
server-owned fields, so new columns carry through by default.

The migration was rehearsed against a copy of the live database before being
applied: 105 rows, descriptions and developers intact.

67 backend tests, 8 frontend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:29:48 -04:00

172 lines
6.4 KiB
C#

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.PostJsonAsync("/api/auth/register", Registration("reg-ok"));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var auth = await response.Content.ReadJsonAsync<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.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<AvailabilityPayload>(
"/api/auth/available?userName=taken-name");
var free = await client.GetJsonAsync<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.GetJsonAsync<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);
}