using System.Net;
using System.Net.Http.Json;
namespace LudosData.Api.Tests;
///
/// The rules that matter most.
///
/// The API this replaced took the owner from a client-supplied query parameter
/// (filter[]=userId,eq,N), 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.
///
public class OwnershipTests(LudosApiFactory factory) : IClassFixture
{
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 CreateGameAsync(HttpClient client, string title)
{
var response = await client.PostAsJsonAsync("/api/games", Game(title));
response.EnsureSuccessStatusCode();
var created = await response.Content.ReadFromJsonAsync();
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("/api/games");
var bobPage = await bob.GetFromJsonAsync("/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($"/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("/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("/api/games");
var bobPage = await bob.GetFromJsonAsync("/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("/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 Items, int Page, int PageSize, int Total, int TotalPages);
private record FacetsPayload(List Systems, List Genres);
}