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.PostJsonAsync("/api/games", Game(title)); response.EnsureSuccessStatusCode(); var created = await response.Content.ReadJsonAsync(); 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.GetJsonAsync("/api/games"); var bobPage = await bob.GetJsonAsync("/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.PutJsonAsync($"/api/games/{aliceGame}", Game("Hijacked")); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); var after = await alice.GetJsonAsync($"/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.GetJsonAsync("/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.PostJsonAsync("/api/games", new { title = "Attempted Handover", system = "SNES", own = true, ownerId = bobUser!.Id, userId = bobUser.Id, }); response.EnsureSuccessStatusCode(); var alicePage = await alice.GetJsonAsync("/api/games"); var bobPage = await bob.GetJsonAsync("/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.PostJsonAsync("/api/games", new { title = "A", system = "N64", genre = "fps", own = true }); await bob.PostJsonAsync("/api/games", new { title = "B", system = "PS2", genre = "rpg", own = true }); var facets = await alice.GetJsonAsync("/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.PostJsonAsync("/api/games", Game("x"))).StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.PutJsonAsync("/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); }