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:
@@ -2,4 +2,7 @@
|
|||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<Project Path="src/LudosData.Api/LudosData.Api.csproj" />
|
<Project Path="src/LudosData.Api/LudosData.Api.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Folder Name="/tests/">
|
||||||
|
<Project Path="tests/LudosData.Api.Tests/LudosData.Api.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -166,3 +166,10 @@ app.MapHealthChecks("/health").AllowAnonymous();
|
|||||||
await DbSeeder.MigrateAndSeedAsync(app.Services);
|
await DbSeeder.MigrateAndSeedAsync(app.Services);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public partial class Program;
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
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.PostAsJsonAsync("/api/auth/register", Registration("reg-ok"));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var auth = await response.Content.ReadFromJsonAsync<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.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<AvailabilityPayload>(
|
||||||
|
"/api/auth/available?userName=taken-name");
|
||||||
|
var free = await client.GetFromJsonAsync<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.GetFromJsonAsync<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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace LudosData.Api.Tests;
|
||||||
|
|
||||||
|
public class GamesTests(LudosApiFactory factory) : IClassFixture<LudosApiFactory>
|
||||||
|
{
|
||||||
|
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<PagePayload>("/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<PagePayload>("/api/games?search=metroid");
|
||||||
|
var byDeveloper = await client.GetFromJsonAsync<PagePayload>("/api/games?search=Square");
|
||||||
|
var byPublisher = await client.GetFromJsonAsync<PagePayload>("/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<PagePayload>("/api/games?system=N64");
|
||||||
|
var adventure = await client.GetFromJsonAsync<PagePayload>("/api/games?genre=adventure");
|
||||||
|
var finished = await client.GetFromJsonAsync<PagePayload>("/api/games?finished=true");
|
||||||
|
var unplayed = await client.GetFromJsonAsync<PagePayload>("/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<PagePayload>("/api/games?sort=title&dir=asc");
|
||||||
|
var descending = await client.GetFromJsonAsync<PagePayload>("/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<PagePayload>();
|
||||||
|
Assert.Equal("Banjo-Kazooie", page!.Items.First().Title);
|
||||||
|
|
||||||
|
// And the table is still there.
|
||||||
|
var after = await client.GetFromJsonAsync<PagePayload>("/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<GamePayload>();
|
||||||
|
|
||||||
|
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<GamePayload>();
|
||||||
|
|
||||||
|
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<GamePayload>();
|
||||||
|
|
||||||
|
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<GamePayload>();
|
||||||
|
|
||||||
|
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<UploadPayload>();
|
||||||
|
|
||||||
|
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<UploadPayload>();
|
||||||
|
|
||||||
|
var bobUser = await bob.GetFromJsonAsync<LudosApiFactory.UserPayload>("/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<GamePayload> Items, int Page, int PageSize, int Total, int TotalPages);
|
||||||
|
private record UploadPayload(string FileName, string Url);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class LudosApiFactory : WebApplicationFactory<Program>, 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Registers a fresh user and returns a client authenticated as them.</summary>
|
||||||
|
public async Task<HttpClient> 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<AuthPayload>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\LudosData.Api\LudosData.Api.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
|
namespace LudosData.Api.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user