diff --git a/backend/LudosData.slnx b/backend/LudosData.slnx index 00978ca..170f591 100644 --- a/backend/LudosData.slnx +++ b/backend/LudosData.slnx @@ -2,4 +2,7 @@ + + + diff --git a/backend/src/LudosData.Api/Program.cs b/backend/src/LudosData.Api/Program.cs index ba8f29b..1bbbc0f 100644 --- a/backend/src/LudosData.Api/Program.cs +++ b/backend/src/LudosData.Api/Program.cs @@ -166,3 +166,10 @@ app.MapHealthChecks("/health").AllowAnonymous(); await DbSeeder.MigrateAndSeedAsync(app.Services); app.Run(); + +/// +/// Top-level statements compile to an internal Program class, which +/// WebApplicationFactory cannot reach. Declaring it public here lets the test +/// project boot the real application rather than a stand-in. +/// +public partial class Program; diff --git a/backend/tests/LudosData.Api.Tests/AuthTests.cs b/backend/tests/LudosData.Api.Tests/AuthTests.cs new file mode 100644 index 0000000..3605802 --- /dev/null +++ b/backend/tests/LudosData.Api.Tests/AuthTests.cs @@ -0,0 +1,171 @@ +using System.Net; +using System.Net.Http.Json; + +namespace LudosData.Api.Tests; + +public class AuthTests(LudosApiFactory factory) : IClassFixture +{ + 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(); + 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( + "/api/auth/available?userName=taken-name"); + var free = await client.GetFromJsonAsync( + "/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("/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); + } + + /// A structurally valid token signed with a key the API does not trust. + 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); +} diff --git a/backend/tests/LudosData.Api.Tests/GamesTests.cs b/backend/tests/LudosData.Api.Tests/GamesTests.cs new file mode 100644 index 0000000..280d9f9 --- /dev/null +++ b/backend/tests/LudosData.Api.Tests/GamesTests.cs @@ -0,0 +1,258 @@ +using System.Net; +using System.Net.Http.Json; + +namespace LudosData.Api.Tests; + +public class GamesTests(LudosApiFactory factory) : IClassFixture +{ + private static object Game( + string title, string system = "SNES", string genre = "rpg", string? year = "1995", + string? developer = null, string? publisher = null, + bool own = true, bool dumped = false, bool played = false, bool finished = false) => new + { + title, system, genre, year, developer, publisher, + own, dumped, played, finished, + }; + + private static async Task SeedLibraryAsync(HttpClient client) + { + await client.PostAsJsonAsync("/api/games", Game("Chrono Trigger", "SNES", "rpg", "1995", developer: "Square")); + await client.PostAsJsonAsync("/api/games", Game("Super Metroid", "SNES", "platformer", "1994")); + await client.PostAsJsonAsync("/api/games", Game("GoldenEye 007", "N64", "fps", "1997", publisher: "Nintendo")); + await client.PostAsJsonAsync("/api/games", Game("Banjo-Kazooie", "N64", "adventure", "1998", played: true, finished: true)); + await client.PostAsJsonAsync("/api/games", Game("Ico", "PS2", "adventure", "2001", played: true)); + } + + [Fact] + public async Task List_paginates_and_reports_totals() + { + var client = await factory.CreateUserClientAsync("page-user"); + await SeedLibraryAsync(client); + + var page = await client.GetFromJsonAsync("/api/games?page=1&pageSize=2"); + + Assert.Equal(2, page!.Items.Count); + Assert.Equal(5, page.Total); + Assert.Equal(3, page.TotalPages); + } + + [Fact] + public async Task Search_matches_title_developer_and_publisher() + { + var client = await factory.CreateUserClientAsync("search-user"); + await SeedLibraryAsync(client); + + var byTitle = await client.GetFromJsonAsync("/api/games?search=metroid"); + var byDeveloper = await client.GetFromJsonAsync("/api/games?search=Square"); + var byPublisher = await client.GetFromJsonAsync("/api/games?search=Nintendo"); + + Assert.Equal("Super Metroid", Assert.Single(byTitle!.Items).Title); + Assert.Equal("Chrono Trigger", Assert.Single(byDeveloper!.Items).Title); + Assert.Equal("GoldenEye 007", Assert.Single(byPublisher!.Items).Title); + } + + [Fact] + public async Task Filters_narrow_by_system_genre_and_status() + { + var client = await factory.CreateUserClientAsync("filter-user"); + await SeedLibraryAsync(client); + + var n64 = await client.GetFromJsonAsync("/api/games?system=N64"); + var adventure = await client.GetFromJsonAsync("/api/games?genre=adventure"); + var finished = await client.GetFromJsonAsync("/api/games?finished=true"); + var unplayed = await client.GetFromJsonAsync("/api/games?played=false"); + + Assert.Equal(2, n64!.Total); + Assert.Equal(2, adventure!.Total); + Assert.Equal(1, finished!.Total); + Assert.Equal(3, unplayed!.Total); + } + + [Fact] + public async Task Sort_orders_ascending_and_descending() + { + var client = await factory.CreateUserClientAsync("sort-user"); + await SeedLibraryAsync(client); + + var ascending = await client.GetFromJsonAsync("/api/games?sort=title&dir=asc"); + var descending = await client.GetFromJsonAsync("/api/games?sort=title&dir=desc"); + + Assert.Equal("Banjo-Kazooie", ascending!.Items.First().Title); + Assert.Equal("Super Metroid", descending!.Items.First().Title); + } + + [Fact] + public async Task An_unknown_sort_key_falls_back_to_title_rather_than_failing() + { + var client = await factory.CreateUserClientAsync("sort-unknown"); + await SeedLibraryAsync(client); + + // The sort parameter is matched against an allow-list, so a value the API + // does not define can neither error nor reach the query shape. + var response = await client.GetAsync("/api/games?sort=id);DROP%20TABLE%20Games;--"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var page = await response.Content.ReadFromJsonAsync(); + Assert.Equal("Banjo-Kazooie", page!.Items.First().Title); + + // And the table is still there. + var after = await client.GetFromJsonAsync("/api/games"); + Assert.Equal(5, after!.Total); + } + + [Theory] + [InlineData("pageSize=0")] + [InlineData("pageSize=1000")] + [InlineData("page=0")] + public async Task Out_of_range_paging_is_rejected(string query) + { + var client = await factory.CreateUserClientAsync($"range-{query.GetHashCode():X}"); + + var response = await client.GetAsync($"/api/games?{query}"); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Create_requires_a_title() + { + var client = await factory.CreateUserClientAsync("title-required"); + + var empty = await client.PostAsJsonAsync("/api/games", new { title = "", system = "SNES" }); + var whitespace = await client.PostAsJsonAsync("/api/games", new { title = " ", system = "SNES" }); + + Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, whitespace.StatusCode); + } + + [Fact] + public async Task Update_changes_fields_and_moves_the_updated_timestamp() + { + var client = await factory.CreateUserClientAsync("update-user"); + var created = await (await client.PostAsJsonAsync("/api/games", Game("Before"))) + .Content.ReadFromJsonAsync(); + + await Task.Delay(15); // the stamp has sub-second resolution, but not zero + var updated = await (await client.PutAsJsonAsync( + $"/api/games/{created!.Id}", Game("After", finished: true))) + .Content.ReadFromJsonAsync(); + + Assert.Equal("After", updated!.Title); + Assert.True(updated.Finished); + Assert.Equal(created.CreatedAt, updated.CreatedAt); + Assert.True(updated.UpdatedAt > created.UpdatedAt); + } + + [Fact] + public async Task Delete_removes_the_game() + { + var client = await factory.CreateUserClientAsync("delete-user"); + var created = await (await client.PostAsJsonAsync("/api/games", Game("Doomed"))) + .Content.ReadFromJsonAsync(); + + var response = await client.DeleteAsync($"/api/games/{created!.Id}"); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/api/games/{created.Id}")).StatusCode); + } + + [Fact] + public async Task Blank_optional_fields_round_trip_as_null() + { + var client = await factory.CreateUserClientAsync("null-user"); + + var created = await (await client.PostAsJsonAsync("/api/games", new + { + title = " Trimmed ", + system = (string?)null, + genre = (string?)null, + own = true, + })).Content.ReadFromJsonAsync(); + + Assert.Equal("Trimmed", created!.Title); + Assert.Null(created.System); + Assert.Null(created.Genre); + } + + // ---- uploads --------------------------------------------------------- + + [Fact] + public async Task Uploading_a_real_image_stores_it_as_webp_and_serves_it() + { + var client = await factory.CreateUserClientAsync("upload-ok"); + + using var content = new MultipartFormDataContent(); + var image = new ByteArrayContent(TestImages.Png(16, 16)); + image.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"); + content.Add(image, "file", "cover.png"); + + var response = await client.PostAsync("/api/images", content); + response.EnsureSuccessStatusCode(); + var upload = await response.Content.ReadFromJsonAsync(); + + Assert.EndsWith(".webp", upload!.FileName); + // The stored name is generated server-side, never taken from the upload. + Assert.DoesNotContain("cover", upload.FileName); + + var served = await client.GetAsync(upload.Url); + Assert.Equal(HttpStatusCode.OK, served.StatusCode); + Assert.Equal("image/webp", served.Content.Headers.ContentType?.MediaType); + } + + [Fact] + public async Task Uploading_something_that_is_not_an_image_is_rejected() + { + var client = await factory.CreateUserClientAsync("upload-bad"); + + using var content = new MultipartFormDataContent(); + var text = new ByteArrayContent("this is not an image"u8.ToArray()); + // A truthful-looking content type and extension are not enough: the bytes + // have to decode. + text.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"); + content.Add(text, "file", "payload.png"); + + var response = await client.PostAsync("/api/images", content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Uploading_nothing_is_rejected() + { + var client = await factory.CreateUserClientAsync("upload-empty"); + + using var content = new MultipartFormDataContent(); + content.Add(new ByteArrayContent([]), "file", "empty.png"); + + var response = await client.PostAsync("/api/images", content); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task One_users_upload_is_not_reachable_under_another_users_folder() + { + var alice = await factory.CreateUserClientAsync("art-alice"); + var bob = await factory.CreateUserClientAsync("art-bob"); + + using var content = new MultipartFormDataContent(); + var image = new ByteArrayContent(TestImages.Png(8, 8)); + image.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"); + content.Add(image, "file", "a.png"); + var upload = await (await alice.PostAsync("/api/images", content)) + .Content.ReadFromJsonAsync(); + + var bobUser = await bob.GetFromJsonAsync("/api/auth/me"); + var file = upload!.Url.Split('/').Last(); + + var probe = await bob.GetAsync($"/uploads/{bobUser!.Id}/{file}"); + + Assert.Equal(HttpStatusCode.NotFound, probe.StatusCode); + } + + private record GamePayload( + int Id, string Title, string? System, string? Genre, bool Finished, + DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); + private record PagePayload(List Items, int Page, int PageSize, int Total, int TotalPages); + private record UploadPayload(string FileName, string Url); +} diff --git a/backend/tests/LudosData.Api.Tests/LudosApiFactory.cs b/backend/tests/LudosData.Api.Tests/LudosApiFactory.cs new file mode 100644 index 0000000..9617c53 --- /dev/null +++ b/backend/tests/LudosData.Api.Tests/LudosApiFactory.cs @@ -0,0 +1,79 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Hosting; + +namespace LudosData.Api.Tests; + +/// +/// Boots the real application against a throwaway SQLite file and upload folder. +/// +/// Every collaborator the API uses in production is exercised here — the same +/// pipeline, the same Identity configuration, the same JWT validation. Only the +/// storage locations and the signing key are swapped, so a test that passes says +/// something about the shipped application rather than about a stand-in. +/// +public class LudosApiFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), "ludos-tests", Guid.NewGuid().ToString("N")); + + public string SigningKey { get; } = "test-signing-key-of-at-least-32-characters-long"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + Directory.CreateDirectory(_root); + + builder.UseEnvironment(Environments.Development); + builder.UseSetting("ConnectionStrings:Default", $"Data Source={Path.Combine(_root, "test.db")}"); + builder.UseSetting("Uploads:RootPath", Path.Combine(_root, "uploads")); + builder.UseSetting("DataProtection:KeysPath", Path.Combine(_root, "keys")); + builder.UseSetting("Jwt:Key", SigningKey); + builder.UseSetting("Jwt:Issuer", "LudosData"); + builder.UseSetting("Jwt:Audience", "LudosData"); + // The seeder would otherwise create a user and import 105 games, which + // would make "does this user see only their own rows" untestable. + builder.UseSetting("Seed:Enabled", "false"); + } + + /// Registers a fresh user and returns a client authenticated as them. + public async Task CreateUserClientAsync(string userName) + { + var client = CreateClient(); + var response = await client.PostAsJsonAsync("/api/auth/register", new + { + userName, + email = $"{userName}@example.test", + password = "TestPassword123", + }); + + response.EnsureSuccessStatusCode(); + var auth = await response.Content.ReadFromJsonAsync(); + + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", auth!.Token); + return client; + } + + public Task InitializeAsync() => Task.CompletedTask; + + public new async Task DisposeAsync() + { + await base.DisposeAsync(); + try + { + if (Directory.Exists(_root)) + { + Directory.Delete(_root, recursive: true); + } + } + catch (IOException) + { + // A locked SQLite handle on a temp file is not worth failing a run over. + } + } + + public record AuthPayload(string Token, DateTimeOffset ExpiresAt, UserPayload User); + public record UserPayload(string Id, string UserName, string? Email); +} diff --git a/backend/tests/LudosData.Api.Tests/LudosData.Api.Tests.csproj b/backend/tests/LudosData.Api.Tests/LudosData.Api.Tests.csproj new file mode 100644 index 0000000..318aebf --- /dev/null +++ b/backend/tests/LudosData.Api.Tests/LudosData.Api.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/tests/LudosData.Api.Tests/OwnershipTests.cs b/backend/tests/LudosData.Api.Tests/OwnershipTests.cs new file mode 100644 index 0000000..e5da53b --- /dev/null +++ b/backend/tests/LudosData.Api.Tests/OwnershipTests.cs @@ -0,0 +1,160 @@ +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); +} diff --git a/backend/tests/LudosData.Api.Tests/TestImages.cs b/backend/tests/LudosData.Api.Tests/TestImages.cs new file mode 100644 index 0000000..2308a35 --- /dev/null +++ b/backend/tests/LudosData.Api.Tests/TestImages.cs @@ -0,0 +1,90 @@ +using System.IO.Compression; + +namespace LudosData.Api.Tests; + +/// +/// Builds a real PNG in memory, so upload tests exercise the actual decode path +/// rather than a fixture file that could drift or go missing. +/// +public static class TestImages +{ + public static byte[] Png(int width, int height) + { + // One filter byte per scanline, then RGB triples. + var raw = new byte[height * (1 + width * 3)]; + var offset = 0; + for (var y = 0; y < height; y++) + { + raw[offset++] = 0; // filter: none + for (var x = 0; x < width; x++) + { + raw[offset++] = (byte)(x * 8 % 256); + raw[offset++] = (byte)(y * 8 % 256); + raw[offset++] = 128; + } + } + + using var output = new MemoryStream(); + output.Write([0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A]); + + var header = new byte[13]; + WriteBigEndian(header, 0, width); + WriteBigEndian(header, 4, height); + header[8] = 8; // bit depth + header[9] = 2; // colour type: truecolour + WriteChunk(output, "IHDR", header); + + WriteChunk(output, "IDAT", ZlibCompress(raw)); + WriteChunk(output, "IEND", []); + + return output.ToArray(); + } + + private static void WriteBigEndian(byte[] buffer, int index, int value) + { + buffer[index] = (byte)(value >> 24); + buffer[index + 1] = (byte)(value >> 16); + buffer[index + 2] = (byte)(value >> 8); + buffer[index + 3] = (byte)value; + } + + private static void WriteChunk(Stream stream, string type, byte[] data) + { + var length = new byte[4]; + WriteBigEndian(length, 0, data.Length); + stream.Write(length); + + var typeBytes = System.Text.Encoding.ASCII.GetBytes(type); + stream.Write(typeBytes); + stream.Write(data); + + var crc = Crc32([.. typeBytes, .. data]); + var crcBytes = new byte[4]; + WriteBigEndian(crcBytes, 0, unchecked((int)crc)); + stream.Write(crcBytes); + } + + private static byte[] ZlibCompress(byte[] data) + { + using var output = new MemoryStream(); + using (var deflate = new ZLibStream(output, CompressionLevel.Fastest, leaveOpen: true)) + { + deflate.Write(data); + } + return output.ToArray(); + } + + private static uint Crc32(byte[] data) + { + var crc = 0xFFFFFFFFu; + foreach (var b in data) + { + crc ^= b; + for (var i = 0; i < 8; i++) + { + crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320u : crc >> 1; + } + } + return crc ^ 0xFFFFFFFFu; + } +}