Add Infrastructure/Api/StreamingGateway test projects
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).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Musify.Infrastructure.Services;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Infrastructure.Tests.Services;
|
||||
|
||||
public sealed class PictureServiceTests
|
||||
{
|
||||
private static PictureService CreateService() => new(NullLogger<PictureService>.Instance);
|
||||
|
||||
private static MemoryStream CreateSourceImage(int width, int height)
|
||||
{
|
||||
using var image = new Image<Rgba32>(width, height, new Rgba32(255, 0, 0));
|
||||
var stream = new MemoryStream();
|
||||
image.SaveAsPng(stream);
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResizePictureAsWebpAsync_LargerThanTarget_ShrinksToFitWithinTheRequestedBox()
|
||||
{
|
||||
using var source = CreateSourceImage(800, 600);
|
||||
|
||||
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 200, 200, CancellationToken.None);
|
||||
|
||||
using var result = await Image.LoadAsync(resized);
|
||||
Assert.True(result.Width <= 200);
|
||||
Assert.True(result.Height <= 200);
|
||||
Assert.True(result.Width == 200 || result.Height == 200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResizePictureAsWebpAsync_ReturnsAValidWebpStream()
|
||||
{
|
||||
using var source = CreateSourceImage(100, 100);
|
||||
|
||||
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 50, 50, CancellationToken.None);
|
||||
|
||||
var format = await Image.DetectFormatAsync(resized);
|
||||
Assert.Equal("WEBP", format?.Name, ignoreCase: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResizePictureAsWebpAsync_PreservesAspectRatio()
|
||||
{
|
||||
using var source = CreateSourceImage(400, 200);
|
||||
|
||||
await using var resized = await CreateService().ResizePictureAsWebpAsync(source, 100, 100, CancellationToken.None);
|
||||
|
||||
using var result = await Image.LoadAsync(resized);
|
||||
Assert.Equal(100, result.Width);
|
||||
Assert.Equal(50, result.Height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Infrastructure.Configuration;
|
||||
using Musify.Infrastructure.Services;
|
||||
using Musify.Infrastructure.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Infrastructure.Tests.Services;
|
||||
|
||||
[Collection(InfrastructureCollection.Name)]
|
||||
public sealed class PlayListPresetSeederTests(InfrastructureTestFixture fixture)
|
||||
{
|
||||
private static PlayListConfiguration NewConfiguration(bool overwritePresetPictures) => new()
|
||||
{
|
||||
Routes = new PlayListRoutes { ParentFolders = $"PlayLists-{Guid.NewGuid():N}" },
|
||||
SeedPresetPictures = true,
|
||||
OverwritePresetPictures = overwritePresetPictures
|
||||
};
|
||||
|
||||
private (PlayListPresetSeeder Seeder, IStorageService Storage) Build(PlayListConfiguration configuration)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(configuration);
|
||||
services.AddSingleton(new ApplicationStorageConfiguration { Bucket = InfrastructureTestFixture.S3Bucket });
|
||||
services.AddSingleton<IStorageService>(_ => new StorageService(
|
||||
fixture.CreateDatabase(),
|
||||
fixture.CreateS3Client(),
|
||||
NullLogger<StorageService>.Instance,
|
||||
new InfrastructureStorageConfiguration
|
||||
{
|
||||
Address = fixture.S3ServiceUrl,
|
||||
AccessKey = InfrastructureTestFixture.S3AccessKey,
|
||||
SecretAccessKey = InfrastructureTestFixture.S3SecretKey,
|
||||
ForcePathStyle = true,
|
||||
UseHttp = true,
|
||||
}));
|
||||
services.AddSingleton<IPictureService>(new PictureService(NullLogger<PictureService>.Instance));
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var seeder = new PlayListPresetSeeder(provider.GetRequiredService<IServiceScopeFactory>(), NullLogger<PlayListPresetSeeder>.Instance);
|
||||
return (seeder, provider.GetRequiredService<IStorageService>());
|
||||
}
|
||||
|
||||
private static async Task RunToCompletionAsync(PlayListPresetSeeder seeder)
|
||||
{
|
||||
await seeder.StartAsync(CancellationToken.None);
|
||||
await (seeder.ExecuteTask ?? Task.CompletedTask);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SeedsAllFourPresetPictures()
|
||||
{
|
||||
var configuration = NewConfiguration(overwritePresetPictures: false);
|
||||
var (seeder, storage) = Build(configuration);
|
||||
|
||||
await RunToCompletionAsync(seeder);
|
||||
|
||||
var routes = configuration.Routes;
|
||||
foreach (var key in new[]
|
||||
{
|
||||
routes.BuildOriginalPicturePath(routes.PresetOriginalPicture),
|
||||
routes.PresetSmallPicturePath,
|
||||
routes.PresetMediumPicturePath,
|
||||
routes.PresetLargePicturePath
|
||||
})
|
||||
{
|
||||
var metadata = await storage.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||
Assert.True(metadata is not null, $"expected {key} to have been seeded");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_RunTwiceWithoutOverwrite_StaysIdempotent()
|
||||
{
|
||||
var configuration = NewConfiguration(overwritePresetPictures: false);
|
||||
var (firstSeeder, storage) = Build(configuration);
|
||||
await RunToCompletionAsync(firstSeeder);
|
||||
|
||||
var (secondSeeder, _) = Build(configuration);
|
||||
await RunToCompletionAsync(secondSeeder);
|
||||
|
||||
var metadata = await storage.HeadObjectAsync(
|
||||
InfrastructureTestFixture.S3Bucket, configuration.Routes.PresetSmallPicturePath, CancellationToken.None);
|
||||
Assert.NotNull(metadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_SeedPresetPicturesDisabled_UploadsNothing()
|
||||
{
|
||||
var configuration = NewConfiguration(overwritePresetPictures: false);
|
||||
configuration.SeedPresetPictures = false;
|
||||
var (seeder, storage) = Build(configuration);
|
||||
|
||||
await RunToCompletionAsync(seeder);
|
||||
|
||||
var metadata = await storage.HeadObjectAsync(
|
||||
InfrastructureTestFixture.S3Bucket, configuration.Routes.PresetSmallPicturePath, CancellationToken.None);
|
||||
Assert.Null(metadata);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Musify.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Infrastructure.Tests.Services;
|
||||
|
||||
public sealed class SingleFlightCacheTests
|
||||
{
|
||||
private static SingleFlightCache CreateCache() => new(new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateAsync_SecondCallWithinDuration_ReturnsCachedValueWithoutCallingFactory()
|
||||
{
|
||||
var cache = CreateCache();
|
||||
var calls = 0;
|
||||
Task<ErrorOr<int>> Factory() { calls++; return Task.FromResult<ErrorOr<int>>(calls); }
|
||||
|
||||
var first = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory);
|
||||
var second = await cache.GetOrCreateAsync("key", TimeSpan.FromMinutes(1), Factory);
|
||||
|
||||
Assert.Equal(1, first.Value);
|
||||
Assert.Equal(1, second.Value);
|
||||
Assert.Equal(1, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateAsync_ConcurrentCallsForTheSameKey_CollapseIntoASingleFactoryCall()
|
||||
{
|
||||
var cache = CreateCache();
|
||||
var calls = 0;
|
||||
var gate = new TaskCompletionSource();
|
||||
|
||||
async Task<ErrorOr<int>> Factory()
|
||||
{
|
||||
Interlocked.Increment(ref calls);
|
||||
await gate.Task;
|
||||
return 7;
|
||||
}
|
||||
|
||||
var first = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory);
|
||||
var second = cache.GetOrCreateAsync("concurrent-key", TimeSpan.FromMinutes(1), Factory);
|
||||
gate.SetResult();
|
||||
|
||||
var results = await Task.WhenAll(first, second);
|
||||
|
||||
Assert.Equal(1, calls);
|
||||
Assert.All(results, result => Assert.Equal(7, result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateAsync_FactoryReturnsError_DoesNotCacheIt()
|
||||
{
|
||||
var cache = CreateCache();
|
||||
var calls = 0;
|
||||
Task<ErrorOr<int>> Factory()
|
||||
{
|
||||
calls++;
|
||||
return Task.FromResult<ErrorOr<int>>(Error.Failure(description: "boom"));
|
||||
}
|
||||
|
||||
await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory);
|
||||
await cache.GetOrCreateAsync("error-key", TimeSpan.FromMinutes(1), Factory);
|
||||
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetOrCreateAsync_NonPositiveDuration_AlwaysCallsTheFactory()
|
||||
{
|
||||
var cache = CreateCache();
|
||||
var calls = 0;
|
||||
Task<ErrorOr<int>> Factory() { calls++; return Task.FromResult<ErrorOr<int>>(calls); }
|
||||
|
||||
await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory);
|
||||
await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory);
|
||||
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Musify.Infrastructure.Configuration;
|
||||
using Musify.Infrastructure.Services;
|
||||
using Musify.Infrastructure.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Infrastructure.Tests.Services;
|
||||
|
||||
[Collection(InfrastructureCollection.Name)]
|
||||
public sealed class StorageServiceTests(InfrastructureTestFixture fixture)
|
||||
{
|
||||
private StorageService CreateService(out Musify.Infrastructure.Persistence.Database database)
|
||||
{
|
||||
database = fixture.CreateDatabase();
|
||||
var storageConfiguration = new InfrastructureStorageConfiguration
|
||||
{
|
||||
Address = fixture.S3ServiceUrl,
|
||||
AccessKey = InfrastructureTestFixture.S3AccessKey,
|
||||
SecretAccessKey = InfrastructureTestFixture.S3SecretKey,
|
||||
ForcePathStyle = true,
|
||||
UseHttp = true,
|
||||
};
|
||||
return new StorageService(database, fixture.CreateS3Client(), NullLogger<StorageService>.Instance, storageConfiguration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UploadThenGetFile_RoundTripsTheSameBytes()
|
||||
{
|
||||
var service = CreateService(out var database);
|
||||
await using var _ = database;
|
||||
var key = $"tests/{Guid.NewGuid():N}/round-trip.txt";
|
||||
var content = "hello seaweedfs"u8.ToArray();
|
||||
|
||||
await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||
|
||||
await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
var readBack = await reader.ReadToEndAsync();
|
||||
|
||||
Assert.Equal("hello seaweedfs", readBack);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata()
|
||||
{
|
||||
var service = CreateService(out var database);
|
||||
await using var _ = database;
|
||||
var key = $"tests/{Guid.NewGuid():N}/head.txt";
|
||||
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||
|
||||
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal(1, metadata.ContentLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HeadObjectAsync_MissingObject_ReturnsNull()
|
||||
{
|
||||
var service = CreateService(out var database);
|
||||
await using var _ = database;
|
||||
|
||||
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", CancellationToken.None);
|
||||
|
||||
Assert.Null(metadata);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveFileAsync_ExistingObject_DeletesIt()
|
||||
{
|
||||
var service = CreateService(out var database);
|
||||
await using var _ = database;
|
||||
var key = $"tests/{Guid.NewGuid():N}/to-remove.txt";
|
||||
await service.UploadFileAsync(new MemoryStream("x"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||
|
||||
await service.RemoveFileAsync(InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||
|
||||
Assert.Null(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyFileAsync_CopiesToTheDestinationKey()
|
||||
{
|
||||
var service = CreateService(out var database);
|
||||
await using var _ = database;
|
||||
var sourceKey = $"tests/{Guid.NewGuid():N}/source.txt";
|
||||
var destinationKey = $"tests/{Guid.NewGuid():N}/destination.txt";
|
||||
await service.UploadFileAsync(new MemoryStream("copy-me"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, sourceKey, CancellationToken.None);
|
||||
|
||||
await service.CopyFileAsync(InfrastructureTestFixture.S3Bucket, sourceKey, InfrastructureTestFixture.S3Bucket, destinationKey, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, destinationKey, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListObjectsAsync_ReturnsEveryObjectUnderThePrefix()
|
||||
{
|
||||
var service = CreateService(out var database);
|
||||
await using var _ = database;
|
||||
var prefix = $"tests/{Guid.NewGuid():N}";
|
||||
await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", CancellationToken.None);
|
||||
await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/b.txt", CancellationToken.None);
|
||||
|
||||
var keys = new List<string>();
|
||||
await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, CancellationToken.None))
|
||||
keys.Add(item.Key);
|
||||
|
||||
Assert.Equal([$"{prefix}/a.txt", $"{prefix}/b.txt"], keys.Order());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveFolderAsync_DeletesEveryObjectUnderThePrefix()
|
||||
{
|
||||
var service = CreateService(out var database);
|
||||
await using var _ = database;
|
||||
var prefix = $"tests/{Guid.NewGuid():N}";
|
||||
await service.UploadFileAsync(new MemoryStream("a"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/a.txt", CancellationToken.None);
|
||||
await service.UploadFileAsync(new MemoryStream("b"u8.ToArray()), "text/plain", InfrastructureTestFixture.S3Bucket, $"{prefix}/nested/b.txt", CancellationToken.None);
|
||||
|
||||
await service.RemoveFolderAsync(InfrastructureTestFixture.S3Bucket, prefix, CancellationToken.None);
|
||||
|
||||
var remaining = new List<string>();
|
||||
await foreach (var item in service.ListObjectsAsync(InfrastructureTestFixture.S3Bucket, prefix, CancellationToken.None))
|
||||
remaining.Add(item.Key);
|
||||
Assert.Empty(remaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUploadUrlAsync_PresignedPut_CanActuallyUploadTheObject()
|
||||
{
|
||||
var service = CreateService(out var database);
|
||||
await using var _ = database;
|
||||
var key = $"tests/{Guid.NewGuid():N}/presigned.txt";
|
||||
|
||||
var uploadUrl = await service.GetUploadUrlAsync(
|
||||
InfrastructureTestFixture.S3Bucket, key, "text/plain", TimeSpan.FromMinutes(5), CancellationToken.None);
|
||||
|
||||
using var httpClient = new HttpClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, uploadUrl)
|
||||
{
|
||||
// Not StringContent: it appends "; charset=utf-8" to Content-Type, which no longer
|
||||
// matches the "text/plain" the presigned URL's signature was computed over (SigV4
|
||||
// signs Content-Type byte-for-byte) and the server answers 403 SignatureDoesNotMatch.
|
||||
Content = new ByteArrayContent(Encoding.UTF8.GetBytes("presigned content"))
|
||||
};
|
||||
request.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/plain");
|
||||
// GetUploadUrlAsync defaults preventOverwrite to true, which signs the URL together with
|
||||
// this header (see StorageService.GetUploadUrlAsync) — the actual PUT has to send it too,
|
||||
// or the signature no longer matches and the server answers 403.
|
||||
request.Headers.TryAddWithoutValidation("If-None-Match", "*");
|
||||
var response = await httpClient.SendAsync(request);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.True(response.StatusCode == HttpStatusCode.OK, $"status={response.StatusCode}, body={responseBody}");
|
||||
var metadata = await service.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, CancellationToken.None);
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Equal("presigned content".Length, metadata.ContentLength);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Musify.Infrastructure.Configuration;
|
||||
using Musify.Infrastructure.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Infrastructure.Tests.Services;
|
||||
|
||||
public sealed class StreamTicketServiceTests : IDisposable
|
||||
{
|
||||
private readonly string privateKeyPath = Path.GetTempFileName();
|
||||
private readonly RSA rsa = RSA.Create(2048);
|
||||
|
||||
public StreamTicketServiceTests() => File.WriteAllText(privateKeyPath, rsa.ExportPkcs8PrivateKeyPem());
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
rsa.Dispose();
|
||||
File.Delete(privateKeyPath);
|
||||
}
|
||||
|
||||
private StreamTicketService CreateService(int ticketTtlSeconds = 3600) => new(new StreamTicketConfiguration
|
||||
{
|
||||
PrivateKeyPath = privateKeyPath,
|
||||
Audience = "media-gateway",
|
||||
Issuer = "musify-webapi",
|
||||
TicketTtlSeconds = ticketTtlSeconds
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void IssueTicket_ReturnsATokenSignedWithTheConfiguredKeyAndClaims()
|
||||
{
|
||||
using var service = CreateService(ticketTtlSeconds: 120);
|
||||
|
||||
var ticket = service.IssueTicket(userId: 42, keyPrefix: "Tracks/ProcessedAudios/abc/");
|
||||
|
||||
Assert.Equal(120, ticket.ExpiresInSeconds);
|
||||
|
||||
var handler = new JsonWebTokenHandler();
|
||||
var jwt = handler.ReadJsonWebToken(ticket.Token);
|
||||
Assert.Equal("42", jwt.GetClaim("sub").Value);
|
||||
Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value);
|
||||
Assert.Equal("musify-webapi", jwt.Issuer);
|
||||
Assert.Contains("media-gateway", jwt.Audiences);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IssueTicket_TokenValidatesAgainstTheMatchingPublicKey()
|
||||
{
|
||||
using var service = CreateService();
|
||||
var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/xyz/");
|
||||
|
||||
var publicKeyPem = rsa.ExportSubjectPublicKeyInfoPem();
|
||||
using var verifyingRsa = RSA.Create();
|
||||
verifyingRsa.ImportFromPem(publicKeyPem);
|
||||
|
||||
var handler = new JsonWebTokenHandler();
|
||||
var result = await handler.ValidateTokenAsync(ticket.Token, new TokenValidationParameters
|
||||
{
|
||||
ValidIssuer = "musify-webapi",
|
||||
ValidAudience = "media-gateway",
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
IssuerSigningKey = new RsaSecurityKey(verifyingRsa),
|
||||
});
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user