Files
LudosData/backend/tests/LudosData.Api.Tests/LudosApiFactory.cs
T
ckochandClaude Opus 5 130921cf89 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>
2026-08-04 12:16:33 -04:00

80 lines
3.0 KiB
C#

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);
}