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:
2026-08-04 12:16:33 -04:00
co-authored by Claude Opus 5
parent cd5c8fb24e
commit 130921cf89
8 changed files with 794 additions and 0 deletions
@@ -0,0 +1,160 @@
using System.Net;
using System.Net.Http.Json;
namespace LudosData.Api.Tests;
/// <summary>
/// The rules that matter most.
///
/// The API this replaced took the owner from a client-supplied query parameter
/// (<c>filter[]=userId,eq,N</c>), so any valid token could read any other user's
/// library by editing a number. These tests pin the replacement: ownership comes
/// from the JWT subject, and a row belonging to someone else is indistinguishable
/// from one that does not exist.
/// </summary>
public class OwnershipTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
{
private static object Game(string title) => new
{
title,
system = "SNES",
genre = "rpg",
year = "1995",
own = true,
dumped = false,
played = false,
finished = false,
};
private static async Task<int> CreateGameAsync(HttpClient client, string title)
{
var response = await client.PostAsJsonAsync("/api/games", Game(title));
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync<GamePayload>();
return created!.Id;
}
[Fact]
public async Task A_user_sees_only_their_own_games()
{
var alice = await factory.CreateUserClientAsync("own-alice");
var bob = await factory.CreateUserClientAsync("own-bob");
await CreateGameAsync(alice, "Alice's Game");
await CreateGameAsync(bob, "Bob's Game");
var alicePage = await alice.GetFromJsonAsync<PagePayload>("/api/games");
var bobPage = await bob.GetFromJsonAsync<PagePayload>("/api/games");
Assert.Single(alicePage!.Items);
Assert.Equal("Alice's Game", alicePage.Items[0].Title);
Assert.Single(bobPage!.Items);
Assert.Equal("Bob's Game", bobPage.Items[0].Title);
}
[Fact]
public async Task Reading_another_users_game_returns_404_not_403()
{
var alice = await factory.CreateUserClientAsync("read-alice");
var bob = await factory.CreateUserClientAsync("read-bob");
var aliceGame = await CreateGameAsync(alice, "Private");
var response = await bob.GetAsync($"/api/games/{aliceGame}");
// 404, not 403: a 403 would confirm the id exists.
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact]
public async Task Updating_another_users_game_is_refused_and_changes_nothing()
{
var alice = await factory.CreateUserClientAsync("upd-alice");
var bob = await factory.CreateUserClientAsync("upd-bob");
var aliceGame = await CreateGameAsync(alice, "Untouched");
var response = await bob.PutAsJsonAsync($"/api/games/{aliceGame}", Game("Hijacked"));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var after = await alice.GetFromJsonAsync<GamePayload>($"/api/games/{aliceGame}");
Assert.Equal("Untouched", after!.Title);
}
[Fact]
public async Task Deleting_another_users_game_is_refused_and_the_row_survives()
{
var alice = await factory.CreateUserClientAsync("del-alice");
var bob = await factory.CreateUserClientAsync("del-bob");
var aliceGame = await CreateGameAsync(alice, "Survivor");
var response = await bob.DeleteAsync($"/api/games/{aliceGame}");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
var after = await alice.GetAsync($"/api/games/{aliceGame}");
Assert.Equal(HttpStatusCode.OK, after.StatusCode);
}
[Fact]
public async Task An_ownerId_in_the_request_body_cannot_reassign_a_game()
{
var alice = await factory.CreateUserClientAsync("spoof-alice");
var bob = await factory.CreateUserClientAsync("spoof-bob");
var bobUser = await bob.GetFromJsonAsync<LudosApiFactory.UserPayload>("/api/auth/me");
// Alice creates a game while claiming it belongs to Bob. The contract has
// no ownerId, so this should be ignored rather than honoured.
var response = await alice.PostAsJsonAsync("/api/games", new
{
title = "Attempted Handover",
system = "SNES",
own = true,
ownerId = bobUser!.Id,
userId = bobUser.Id,
});
response.EnsureSuccessStatusCode();
var alicePage = await alice.GetFromJsonAsync<PagePayload>("/api/games");
var bobPage = await bob.GetFromJsonAsync<PagePayload>("/api/games");
Assert.Single(alicePage!.Items);
Assert.Empty(bobPage!.Items);
}
[Fact]
public async Task Facets_are_scoped_to_the_signed_in_user()
{
var alice = await factory.CreateUserClientAsync("facet-alice");
var bob = await factory.CreateUserClientAsync("facet-bob");
await alice.PostAsJsonAsync("/api/games", new { title = "A", system = "N64", genre = "fps", own = true });
await bob.PostAsJsonAsync("/api/games", new { title = "B", system = "PS2", genre = "rpg", own = true });
var facets = await alice.GetFromJsonAsync<FacetsPayload>("/api/games/facets");
Assert.Equal(["N64"], facets!.Systems);
Assert.Equal(["fps"], facets.Genres);
}
[Fact]
public async Task Every_games_route_requires_a_token()
{
var anonymous = factory.CreateClient();
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/games")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/games/1")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/api/games/facets")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,
(await anonymous.PostAsJsonAsync("/api/games", Game("x"))).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,
(await anonymous.PutAsJsonAsync("/api/games/1", Game("x"))).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.DeleteAsync("/api/games/1")).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.PostAsync("/api/images", null)).StatusCode);
}
private record GamePayload(int Id, string Title, string? System, string? Art, string? ArtUrl);
private record PagePayload(List<GamePayload> Items, int Page, int PageSize, int Total, int TotalPages);
private record FacetsPayload(List<string> Systems, List<string> Genres);
}