Second pass on backend/Tests/, covering what Domain+Application (the first
pass) deliberately left out — the parts that talk to real infrastructure:
Musify.Infrastructure.Tests Database/migrations, StorageService,
StreamTicketService, AuditableEntityInterceptor,
PictureService, SingleFlightCache,
PlayListPresetSeeder.
Musify.Api.Tests Real HTTP endpoints via WebApplicationFactory
(routing, auth, ValidationFilter, ErrorOr->HTTP
mapping), plus isolated unit tests for
ErrorOrHttpExtensions, CurrentUser and the
FluentValidation validators.
Musify.StreamingGateway.Tests TicketValidator (RS256) and
TicketValidationMiddleware (path traversal,
prefix boundary, ticket extraction).
Musify.Worker gets no test project: its Program.cs is pure DI/host wiring
(Hangfire, MassTransit, jobs); the logic it runs lives in Musify.Infrastructure,
already covered there.
Per the user's direction, the parts that touch real infrastructure use
Testcontainers instead of fakes: a real Postgres (Testcontainers.PostgreSql)
for Infrastructure.Tests and Api.Tests, and — matching deploy/compose.yml
exactly (chrislusf/seaweedfs:latest, same startup command), not MinIO — a
real SeaweedFS container for StorageService. Api.Tests boots the actual
Musify.Api host against that Postgres (real routing/auth/validation) but
substitutes the services that talk over the network (storage, YouTube, the
message bus, stream-ticket signing) with NSubstitute fakes, and swaps the
JwtBearer scheme for a test-only header-based one (FakeAuthenticationHandler)
so tests don't need a reachable Zitadel. Application.Tests' approach — a real
DbContext (SQLite in-memory) rather than mocking IDatabase, since it exposes
DbSet<T> directly and handlers compose real LINQ queries against it — carries
over into Infrastructure.Tests/Api.Tests via real Postgres instead.
Testing against the real backend (not a fake) found two genuine production
bugs, both fixed here:
- StorageService.RemoveFolderAsync threw NullReferenceException against
SeaweedFS: its DeleteObjects response omits the <Error> list entirely when
nothing failed, which the AWS SDK deserializes as null rather than an empty
list (real AWS S3 always returns one). Now treats null as no errors.
- AuditableEntityInterceptor never bumped UpdatedAt on a normal update (load,
mutate a property, SaveChanges) — only on insert. SavingChanges(Async) fires
before EF's own automatic change detection for that save, so an entity
modified through a plain property setter still reported EntityState.Unchanged
inside the interceptor. Now calls ChangeTracker.DetectChanges() explicitly
first, which is the documented fix for exactly this interceptor pattern.
Also required to make the Api host testable: Program.cs now ends with `public
partial class Program;` (WebApplicationFactory<Program> needs the top-level-
statements Program class to be public — no behavior change), and
Directory.Packages.props gains Microsoft.AspNetCore.Mvc.Testing, Testcontainers
and Testcontainers.PostgreSql.
249 tests total across all five projects, all green: `dotnet test
backend/Musify.slnx` (needs Docker running for Infrastructure.Tests and
Api.Tests).
147 lines
4.8 KiB
C#
147 lines
4.8 KiB
C#
using System.Security.Cryptography;
|
|
using Microsoft.Extensions.Options;
|
|
using Microsoft.IdentityModel.JsonWebTokens;
|
|
using Microsoft.IdentityModel.Tokens;
|
|
using Musify.StreamingGateway.Authentication;
|
|
using Musify.StreamingGateway.Configuration;
|
|
using Xunit;
|
|
|
|
namespace Musify.StreamingGateway.Tests.Authentication;
|
|
|
|
public sealed class TicketValidatorTests : IDisposable
|
|
{
|
|
private const string Issuer = "musify-webapi";
|
|
private const string Audience = "media-gateway";
|
|
|
|
private readonly string publicKeyPath = Path.GetTempFileName();
|
|
private readonly RSA rsa = RSA.Create(2048);
|
|
private readonly JsonWebTokenHandler tokenHandler = new();
|
|
|
|
public TicketValidatorTests() => File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem());
|
|
|
|
public void Dispose()
|
|
{
|
|
rsa.Dispose();
|
|
File.Delete(publicKeyPath);
|
|
}
|
|
|
|
private TicketValidator CreateValidator() => new(Options.Create(new StreamTicketValidationOptions
|
|
{
|
|
PublicKeyPath = publicKeyPath,
|
|
Audience = Audience,
|
|
Issuer = Issuer
|
|
}));
|
|
|
|
private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience,
|
|
DateTime? expires = null, DateTime? notBefore = null)
|
|
{
|
|
var descriptor = new SecurityTokenDescriptor
|
|
{
|
|
Issuer = issuer,
|
|
Audience = audience,
|
|
NotBefore = notBefore ?? DateTime.UtcNow.AddMinutes(-1),
|
|
Expires = expires ?? DateTime.UtcNow.AddMinutes(5),
|
|
Claims = new Dictionary<string, object> { ["prefix"] = prefix },
|
|
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
|
};
|
|
return tokenHandler.CreateToken(descriptor);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateTicketAsync_ValidToken_ReturnsThePrefixClaim()
|
|
{
|
|
var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/");
|
|
|
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
|
|
|
Assert.Equal("Tracks/ProcessedAudios/xyz/", prefix);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task ValidateTicketAsync_BlankToken_ReturnsEmptyString(string? token)
|
|
{
|
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
|
|
|
Assert.Equal(string.Empty, prefix);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateTicketAsync_MalformedToken_ReturnsEmptyString()
|
|
{
|
|
var prefix = await CreateValidator().ValidateTicketAsync("not-a-real-jwt");
|
|
|
|
Assert.Equal(string.Empty, prefix);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateTicketAsync_ExpiredToken_ReturnsEmptyString()
|
|
{
|
|
var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50));
|
|
|
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
|
|
|
Assert.Equal(string.Empty, prefix);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateTicketAsync_WrongIssuer_ReturnsEmptyString()
|
|
{
|
|
var token = IssueToken(issuer: "someone-else");
|
|
|
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
|
|
|
Assert.Equal(string.Empty, prefix);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateTicketAsync_WrongAudience_ReturnsEmptyString()
|
|
{
|
|
var token = IssueToken(audience: "someone-else");
|
|
|
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
|
|
|
Assert.Equal(string.Empty, prefix);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsEmptyString()
|
|
{
|
|
using var otherKey = RSA.Create(2048);
|
|
var descriptor = new SecurityTokenDescriptor
|
|
{
|
|
Issuer = Issuer,
|
|
Audience = Audience,
|
|
NotBefore = DateTime.UtcNow.AddMinutes(-1),
|
|
Expires = DateTime.UtcNow.AddMinutes(5),
|
|
Claims = new Dictionary<string, object> { ["prefix"] = "Tracks/ProcessedAudios/abc/" },
|
|
SigningCredentials = new SigningCredentials(new RsaSecurityKey(otherKey), SecurityAlgorithms.RsaSha256),
|
|
};
|
|
var token = tokenHandler.CreateToken(descriptor);
|
|
|
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
|
|
|
Assert.Equal(string.Empty, prefix);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsEmptyString()
|
|
{
|
|
var descriptor = new SecurityTokenDescriptor
|
|
{
|
|
Issuer = Issuer,
|
|
Audience = Audience,
|
|
NotBefore = DateTime.UtcNow.AddMinutes(-1),
|
|
Expires = DateTime.UtcNow.AddMinutes(5),
|
|
SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256),
|
|
};
|
|
var token = tokenHandler.CreateToken(descriptor);
|
|
|
|
var prefix = await CreateValidator().ValidateTicketAsync(token);
|
|
|
|
Assert.Equal(string.Empty, prefix);
|
|
}
|
|
}
|