diff --git a/README.md b/README.md index 2c8bda3..1cd6ff8 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ docs/ What each piece does and why (in Spanish) ``` ```sh -dotnet test backend/Musify.slnx # backend/Tests/ — no Docker, no database +dotnet test backend/Musify.slnx # backend/Tests/ — Docker running (Testcontainers: Postgres, SeaweedFS) ``` ## Run it diff --git a/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs b/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs index 40c7e69..8b42267 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/AuditableEntityInterceptor.cs @@ -24,6 +24,13 @@ namespace Musify.Infrastructure.Persistence if (context is null) return; + // SavingChanges(Async) fires before EF's own automatic change detection for this + // save, so entities modified through plain property assignment (query, then set a + // property, then SaveChanges — the normal case) still show as Unchanged here unless + // something already forced detection. Force it explicitly, otherwise UpdatedAt is + // never bumped on a real update, only on inserts. + context.ChangeTracker.DetectChanges(); + var now = DateTime.UtcNow; foreach (var entry in context.ChangeTracker.Entries()) diff --git a/backend/Core/Musify.Infrastructure/Services/StorageService.cs b/backend/Core/Musify.Infrastructure/Services/StorageService.cs index 9fb1076..2319524 100644 --- a/backend/Core/Musify.Infrastructure/Services/StorageService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StorageService.cs @@ -289,9 +289,13 @@ namespace Musify.Infrastructure.Services var deleteResponse = await amazonS3.DeleteObjectsAsync(deleteRequest, cancellationToken); - if (deleteResponse.DeleteErrors.Count > 0) + // Some S3-compatible backends (SeaweedFS included) omit the list from the + // XML response entirely when nothing failed, which the SDK deserializes as null + // rather than an empty list — real AWS S3 always returns a (possibly empty) list. + var deleteErrors = deleteResponse.DeleteErrors ?? []; + if (deleteErrors.Count > 0) { - var errors = string.Join(", ", deleteResponse.DeleteErrors.Select(e => $"{e.Key}: {e.Message}")); + var errors = string.Join(", ", deleteErrors.Select(e => $"{e.Key}: {e.Message}")); throw new InvalidOperationException($"Failed to delete some objects in folder {folderKey}: {errors}"); } diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index eec9cdd..2a49f52 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -61,6 +61,9 @@ + + + diff --git a/backend/Hosts/Musify.Api/Program.cs b/backend/Hosts/Musify.Api/Program.cs index 514a28d..25c11d6 100644 --- a/backend/Hosts/Musify.Api/Program.cs +++ b/backend/Hosts/Musify.Api/Program.cs @@ -45,4 +45,8 @@ app .MapUserEndpoints() .MapYouTubeEndpoints(); -app.Run(); \ No newline at end of file +app.Run(); + +// Makes the top-level-statements Program class public so +// WebApplicationFactory (Musify.Api.Tests) can see it. +public partial class Program; \ No newline at end of file diff --git a/backend/Musify.slnx b/backend/Musify.slnx index 3cea692..0d70203 100644 --- a/backend/Musify.slnx +++ b/backend/Musify.slnx @@ -12,5 +12,8 @@ + + + diff --git a/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs new file mode 100644 index 0000000..981dda9 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Authentication/CurrentUserTests.cs @@ -0,0 +1,84 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Musify.Api.Authentication; +using Xunit; + +namespace Musify.Api.Tests.Authentication; + +public sealed class CurrentUserTests +{ + private static async Task BindAsync(ClaimsPrincipal principal) + { + var context = new DefaultHttpContext { User = principal }; + var bound = await CurrentUser.BindAsync(context, parameter: null!); + return bound!; + } + + private static ClaimsPrincipal AuthenticatedPrincipal(params Claim[] claims) => + new(new ClaimsIdentity(claims, authenticationType: "TestAuth")); + + [Fact] + public async Task BindAsync_AuthenticatedPrincipalWithValidClaims_PopulatesEveryField() + { + var principal = AuthenticatedPrincipal( + new Claim(ClaimTypes.NameIdentifier, "42"), + new Claim(ClaimTypes.Name, "jdoe"), + new Claim(ClaimTypes.GivenName, "Jane"), + new Claim(ClaimTypes.Surname, "Doe")); + + var user = await BindAsync(principal); + + Assert.True(user.IsAuthenticated); + Assert.Equal(42, user.Id); + Assert.Equal(42, user.RequiredId); + Assert.Equal("jdoe", user.Username); + Assert.Equal("Jane", user.FirstName); + Assert.Equal("Doe", user.LastName); + } + + [Fact] + public async Task BindAsync_AnonymousPrincipal_IsNotAuthenticatedAndHasNoId() + { + var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); + + Assert.False(user.IsAuthenticated); + Assert.Null(user.Id); + } + + [Theory] + [InlineData("not-a-number")] + [InlineData("0")] + [InlineData("")] + public async Task BindAsync_NonNumericOrZeroNameIdentifier_LeavesIdNull(string rawId) + { + var user = await BindAsync(AuthenticatedPrincipal(new Claim(ClaimTypes.NameIdentifier, rawId))); + + Assert.Null(user.Id); + } + + [Fact] + public async Task RequiredId_MissingId_Throws() + { + var user = await BindAsync(new ClaimsPrincipal(new ClaimsIdentity())); + + Assert.Throws(() => user.RequiredId); + } + + [Fact] + public async Task HasClaim_ExistingClaimType_ReturnsTrue() + { + var user = await BindAsync(AuthenticatedPrincipal(new Claim("role", "admin"))); + + Assert.True(user.HasClaim("role")); + Assert.False(user.HasClaim("missing")); + } + + [Fact] + public async Task FindClaim_ExistingClaimType_ReturnsItsValue() + { + var user = await BindAsync(AuthenticatedPrincipal(new Claim("picture", "https://avatar.example/jane.png"))); + + Assert.Equal("https://avatar.example/jane.png", user.FindClaim("picture")); + Assert.Null(user.FindClaim("missing")); + } +} diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs new file mode 100644 index 0000000..8a3e980 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -0,0 +1,98 @@ +using System.Net; +using System.Net.Http.Json; +using Musify.Api.DataTransferObjects.Albums; +using Musify.Api.DataTransferObjects.Users; +using Musify.Api.Tests.TestSupport; +using Musify.Application.Albums.Responses; +using Xunit; + +namespace Musify.Api.Tests.Endpoints; + +[Collection(ApiCollection.Name)] +public sealed class AlbumEndpointsTests(ApiTestFixture fixture) +{ + private async Task CreateUserAsync() + { + var userId = Random.Shared.NextInt64(1, long.MaxValue); + var response = await fixture.CreateAnonymousClient() + .PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null)); + response.EnsureSuccessStatusCode(); + return userId; + } + + [Fact] + public async Task PostAlbums_NoAuthHeader_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient() + .PostAsJsonAsync("/albums", new CreateAlbumRequest("Unauthorized Album", null, null)); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PostAlbums_AuthenticatedExistingUser_ReturnsCreatedAlbum() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("My Album", "desc", 2024)); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal("My Album", body.Title); + Assert.Equal(userId, body.OwnerUserId); + } + + [Fact] + public async Task PostAlbums_BlankTitle_ReturnsValidationProblem() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + + var response = await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("", null, null)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetAlbumById_AfterCreate_ReturnsIt() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Findable", null, null))) + .Content.ReadFromJsonAsync(); + + var response = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created!.Id}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized() + { + var owner = await CreateUserAsync(); + var stranger = await CreateUserAsync(); + var created = await (await fixture.CreateAuthenticatedClient(owner).PostAsJsonAsync("/albums", new CreateAlbumRequest("Owned", null, null))) + .Content.ReadFromJsonAsync(); + + var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task DeleteAlbum_Owner_ReturnsNoContentAndAlbumIsGone() + { + var userId = await CreateUserAsync(); + var client = fixture.CreateAuthenticatedClient(userId); + var created = await (await client.PostAsJsonAsync("/albums", new CreateAlbumRequest("Deletable", null, null))) + .Content.ReadFromJsonAsync(); + + var deleteResponse = await client.DeleteAsync($"/albums/{created!.Id}"); + var getResponse = await fixture.CreateAnonymousClient().GetAsync($"/albums/{created.Id}"); + + Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode); + } +} diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs new file mode 100644 index 0000000..b0c287a --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs @@ -0,0 +1,73 @@ +using System.Net; +using System.Net.Http.Json; +using Musify.Api.DataTransferObjects.Users; +using Musify.Api.Tests.TestSupport; +using Musify.Application.Users.Responses; +using Xunit; + +namespace Musify.Api.Tests.Endpoints; + +[Collection(ApiCollection.Name)] +public sealed class UserEndpointsTests(ApiTestFixture fixture) +{ + [Fact] + public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + + var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Jane Doe", "Jane", "Doe")); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString); + var body = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(body); + Assert.Equal("Jane Doe", body.Name); + } + + [Fact] + public async Task PostUsers_BlankName_ReturnsValidationProblem() + { + var client = fixture.CreateAnonymousClient(); + + var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PostUsers_DuplicateId_ReturnsConflict() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "First", null, null)); + + var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null)); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task GetUserById_ExistingUser_ReturnsIt() + { + var client = fixture.CreateAnonymousClient(); + var userId = Random.Shared.NextInt64(1, long.MaxValue); + await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null)); + + var response = await client.GetAsync($"/users/{userId}"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal("Findable User", body?.Name); + } + + [Fact] + public async Task GetUserById_MissingUser_ReturnsNotFound() + { + var client = fixture.CreateAnonymousClient(); + + var response = await client.GetAsync($"/users/{Random.Shared.NextInt64(1, long.MaxValue)}"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs b/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs new file mode 100644 index 0000000..90b884e --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Extensions/ErrorOrHttpExtensionsTests.cs @@ -0,0 +1,137 @@ +using ErrorOr; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Musify.Api.Extensions; +using Xunit; + +namespace Musify.Api.Tests.Extensions; + +public sealed class ErrorOrHttpExtensionsTests +{ + // The concrete IResult types (NoContent, UnauthorizedHttpResult, ...) resolve logging/problem- + // details services from HttpContext.RequestServices when they execute, so it can't be empty. + private static readonly IServiceProvider Services = new ServiceCollection().AddLogging().AddProblemDetails().BuildServiceProvider(); + + private static async Task<(int StatusCode, string Body)> ExecuteAsync(IResult result) + { + var context = new DefaultHttpContext { RequestServices = Services }; + var bodyStream = new MemoryStream(); + context.Response.Body = bodyStream; + + await result.ExecuteAsync(context); + + bodyStream.Position = 0; + using var reader = new StreamReader(bodyStream); + return (context.Response.StatusCode, await reader.ReadToEndAsync()); + } + + [Fact] + public async Task ToHttpResult_Success_ReturnsNoContent() + { + ErrorOr result = new Success(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status204NoContent, statusCode); + } + + [Fact] + public async Task ToHttpResult_Value_ReturnsOkWithTheValue() + { + ErrorOr result = "hello"; + + var (statusCode, body) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status200OK, statusCode); + Assert.Contains("hello", body); + } + + [Fact] + public async Task ToHttpResult_NotFoundError_ReturnsNotFound() + { + ErrorOr result = Error.NotFound(description: "not here"); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status404NotFound, statusCode); + } + + [Fact] + public async Task ToHttpResult_UnauthorizedError_ReturnsUnauthorized() + { + ErrorOr result = Error.Unauthorized(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status401Unauthorized, statusCode); + } + + [Fact] + public async Task ToHttpResult_ForbiddenError_ReturnsForbidden() + { + ErrorOr result = Error.Forbidden(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status403Forbidden, statusCode); + } + + [Fact] + public async Task ToHttpResult_ConflictError_ReturnsConflict() + { + ErrorOr result = Error.Conflict(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status409Conflict, statusCode); + } + + [Fact] + public async Task ToHttpResult_FailureError_ReturnsProblem500() + { + ErrorOr result = Error.Failure(); + + var (statusCode, _) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status500InternalServerError, statusCode); + } + + [Fact] + public async Task ToHttpResult_ValidationErrors_ReturnsValidationProblem() + { + ErrorOr result = new List + { + Error.Validation("Name", "Name is required"), + Error.Validation("Name", "Name is too long"), + Error.Validation("Age", "Age must be positive"), + }; + + var (statusCode, body) = await ExecuteAsync(result.ToHttpResult()); + + Assert.Equal(StatusCodes.Status400BadRequest, statusCode); + Assert.Contains("Name is required", body); + Assert.Contains("Age must be positive", body); + } + + [Fact] + public async Task ToCreatedResult_Success_ReturnsCreatedAtTheGivenLocation() + { + ErrorOr result = "the-id"; + + var context = new DefaultHttpContext { RequestServices = Services, Response = { Body = new MemoryStream() } }; + await result.ToCreatedResult(value => $"/things/{value}").ExecuteAsync(context); + + Assert.Equal(StatusCodes.Status201Created, context.Response.StatusCode); + Assert.Equal("/things/the-id", context.Response.Headers.Location); + } + + [Fact] + public async Task ToCreatedResult_Error_ReturnsTheMappedErrorStatus() + { + ErrorOr result = Error.Conflict(); + + var (statusCode, _) = await ExecuteAsync(result.ToCreatedResult(value => $"/things/{value}")); + + Assert.Equal(StatusCodes.Status409Conflict, statusCode); + } +} diff --git a/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs b/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs new file mode 100644 index 0000000..cf0bf9b --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Filters/ValidationFilterTests.cs @@ -0,0 +1,66 @@ +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using Musify.Api.Filters; +using NSubstitute; +using Xunit; + +namespace Musify.Api.Tests.Filters; + +public sealed class ValidationFilterTests +{ + public sealed record Payload(string Name); + + // EndpointFilterInvocationContext.Create has no (HttpContext, object[]) overload — only + // Create, Create, etc. Passing an object[] to a params-object[] call site binds + // to Create with T0 inferred as object[] itself, so .Arguments ends up with one element + // (the array, boxed) instead of the array's contents — OfType() then never matches + // and the filter silently falls through to "no argument, skip validation" for every case. + private static EndpointFilterInvocationContext CreateContext(T argument) => + EndpointFilterInvocationContext.Create(new DefaultHttpContext(), argument); + + [Fact] + public async Task InvokeAsync_ValidPayload_CallsNext() + { + var validator = Substitute.For>(); + validator.ValidateAsync(Arg.Any(), Arg.Any()).Returns(new ValidationResult()); + var filter = new ValidationFilter(validator); + var context = CreateContext(new Payload("ok")); + + var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); + + Assert.Equal("next-called", result); + await validator.Received(1).ValidateAsync(Arg.Is(p => p.Name == "ok"), Arg.Any()); + } + + [Fact] + public async Task InvokeAsync_InvalidPayload_ReturnsValidationProblemWithoutCallingNext() + { + var validator = Substitute.For>(); + validator.ValidateAsync(Arg.Any(), Arg.Any()) + .Returns(new ValidationResult([new ValidationFailure("Name", "Name is required")])); + var filter = new ValidationFilter(validator); + var context = CreateContext(new Payload("")); + var nextCalled = false; + + var result = await filter.InvokeAsync(context, _ => { nextCalled = true; return ValueTask.FromResult(null); }); + + Assert.False(nextCalled); + var problem = Assert.IsType(result); + Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); + } + + [Fact] + public async Task InvokeAsync_NoMatchingArgument_CallsNextWithoutValidating() + { + var validator = Substitute.For>(); + var filter = new ValidationFilter(validator); + var context = CreateContext(42); + + var result = await filter.InvokeAsync(context, _ => ValueTask.FromResult("next-called")); + + Assert.Equal("next-called", result); + await validator.DidNotReceiveWithAnyArgs().ValidateAsync(Arg.Any(), Arg.Any()); + } +} diff --git a/backend/Tests/Musify.Api.Tests/Musify.Api.Tests.csproj b/backend/Tests/Musify.Api.Tests/Musify.Api.Tests.csproj new file mode 100644 index 0000000..51ebcb8 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Musify.Api.Tests.csproj @@ -0,0 +1,31 @@ + + + + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs new file mode 100644 index 0000000..917d2b0 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs @@ -0,0 +1,117 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Musify.Application.Contracts; +using NSubstitute; +using Testcontainers.PostgreSql; +using Xunit; + +namespace Musify.Api.Tests.TestSupport; + +/// +/// Boots the real Musify.Api host — real routing, real auth/authorization pipeline, +/// real validators, real Mediator handlers — against a private Postgres container (so EF +/// queries behave like production, not like a stand-in). The services this API talks to over +/// the network (object storage, YouTube, the message bus, stream-ticket signing) are replaced +/// with NSubstitute fakes: they are Infrastructure's job to verify against the real thing (see +/// Musify.Infrastructure.Tests), not the HTTP layer's. +/// +public sealed class ApiTestFixture : WebApplicationFactory, IAsyncLifetime +{ + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("musify_api_test") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + public IStorageService StorageService { get; } = Substitute.For(); + + public IEventBus EventBus { get; } = Substitute.For(); + + public IYouTubeMusicService YouTubeMusicService { get; } = Substitute.For(); + + public IStreamTicketService StreamTicketService { get; } = Substitute.For(); + + public async Task InitializeAsync() + { + await postgres.StartAsync(); + + using var scope = Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + await database.Database.MigrateAsync(); + } + + async Task IAsyncLifetime.DisposeAsync() + { + await base.DisposeAsync(); + await postgres.DisposeAsync(); + } + + /// An with no auth header — every protected endpoint answers 401. + public HttpClient CreateAnonymousClient() => CreateClient(); + + /// An authenticated as the given user id (see ). + public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user") + { + var client = CreateClient(); + client.DefaultRequestHeaders.Add(FakeAuthenticationHandler.UserIdHeader, userId.ToString()); + client.DefaultRequestHeaders.Add("X-Test-User-Name", userName); + return client; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((_, configuration) => + { + configuration.AddInMemoryCollection(new Dictionary + { + ["Database:ConnectionString"] = postgres.GetConnectionString(), + ["Authentication:MetadataAddress"] = "https://auth.musify.test/.well-known/openid-configuration", + ["Authentication:IssuerAddress"] = "https://auth.musify.test", + ["Authentication:AudienceAddress"] = "musify-api-test", + ["Authentication:ClientId"] = "musify-api-test", + ["Authentication:AuthorizationEndpoint"] = "https://auth.musify.test/authorize", + ["Authentication:TokenEndpoint"] = "https://auth.musify.test/token", + ["Authentication:Scopes:0"] = "openid", + ["StreamTicket:PrivateKeyPath"] = "unused-in-tests.pem", + ["OpenTelemetry:OtlpEndpoint"] = "http://localhost:4317", + ["MassTransit:Address"] = "rabbitmq://localhost:5672", + ["MassTransit:Username"] = "guest", + ["MassTransit:Password"] = "guest", + ["InfrastructureStorage:Address"] = "http://localhost:8333", + ["InfrastructureStorage:AccessKey"] = "unused-in-tests", + ["InfrastructureStorage:SecretAccessKey"] = "unused-in-tests", + ["InfrastructureStorage:ForcePathStyle"] = "true", + ["InfrastructureStorage:UseHttp"] = "true", + ["ApplicationStorage:Bucket"] = "musify-api-test", + ["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test", + ["PlayList:SeedPresetPictures"] = "false", + }); + }); + + builder.ConfigureTestServices(services => + { + services.Replace(ServiceDescriptor.Singleton(StorageService)); + services.Replace(ServiceDescriptor.Scoped(_ => EventBus)); + services.Replace(ServiceDescriptor.Singleton(YouTubeMusicService)); + services.Replace(ServiceDescriptor.Singleton(StreamTicketService)); + + // Overrides the real JwtBearer scheme as the default, so requests authenticate via + // FakeAuthenticationHandler instead of needing a real token and a reachable IdP. + services + .AddAuthentication(FakeAuthenticationHandler.SchemeName) + .AddScheme(FakeAuthenticationHandler.SchemeName, _ => { }); + }); + } +} + +[CollectionDefinition(Name)] +public sealed class ApiCollection : ICollectionFixture +{ + public const string Name = "Api"; +} diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs b/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs new file mode 100644 index 0000000..24f0231 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/TestSupport/FakeAuthenticationHandler.cs @@ -0,0 +1,37 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Musify.Api.Tests.TestSupport; + +/// +/// Replaces the real JwtBearer handler in the test host: a request carrying the +/// header authenticates as that user id, with no real token, +/// issuer or JWKS endpoint involved. A request without the header stays anonymous, so +/// endpoints under .RequireAuthorization() correctly answer 401. +/// +public sealed class FakeAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : AuthenticationHandler(options, logger, encoder) +{ + public const string SchemeName = "Test"; + public const string UserIdHeader = "X-Test-User-Id"; + + protected override Task HandleAuthenticateAsync() + { + if (!Request.Headers.TryGetValue(UserIdHeader, out var userId) || string.IsNullOrEmpty(userId)) + return Task.FromResult(AuthenticateResult.NoResult()); + + var claims = new List { new(ClaimTypes.NameIdentifier, userId!) }; + if (Request.Headers.TryGetValue("X-Test-User-Name", out var userName) && !string.IsNullOrEmpty(userName)) + claims.Add(new Claim(ClaimTypes.Name, userName!)); + + var identity = new ClaimsIdentity(claims, SchemeName); + var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), SchemeName); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs new file mode 100644 index 0000000..739991b --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Validators/CreateAlbumRequestValidatorTests.cs @@ -0,0 +1,61 @@ +using Musify.Api.DataTransferObjects.Albums; +using Musify.Api.Validators.Albums; +using Xunit; + +namespace Musify.Api.Tests.Validators; + +public sealed class CreateAlbumRequestValidatorTests +{ + private readonly CreateAlbumRequestValidator validator = new(); + + [Fact] + public void Validate_ValidRequest_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("My Album", "A short description", 2024)); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_ValidRequestWithoutOptionalFields_Passes() + { + var result = validator.Validate(new CreateAlbumRequest("My Album", null, null)); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_BlankTitle_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("", null, null)); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateAlbumRequest.Title)); + } + + [Fact] + public void Validate_TitleOverMaxLength_Fails() + { + var result = validator.Validate(new CreateAlbumRequest(new string('a', 201), null, null)); + + Assert.False(result.IsValid); + } + + [Theory] + [InlineData(1876)] + [InlineData(1877)] + public void Validate_ReleaseYearAtOrBeforeTheEarliestBoundary(int year) + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, year)); + + Assert.Equal(year >= 1877, result.IsValid); + } + + [Fact] + public void Validate_ReleaseYearMoreThanOneYearInTheFuture_Fails() + { + var result = validator.Validate(new CreateAlbumRequest("Title", null, DateTime.UtcNow.Year + 2)); + + Assert.False(result.IsValid); + } +} diff --git a/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs new file mode 100644 index 0000000..36ccb0f --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs @@ -0,0 +1,51 @@ +using Musify.Api.DataTransferObjects.Users; +using Musify.Api.Validators.Users; +using Xunit; + +namespace Musify.Api.Tests.Validators; + +public sealed class CreateUserRequestValidatorTests +{ + private readonly CreateUserRequestValidator validator = new(); + + [Fact] + public void Validate_ValidRequest_Passes() + { + var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", "Jane", "Doe")); + + Assert.True(result.IsValid); + } + + [Fact] + public void Validate_ZeroId_Fails() + { + var result = validator.Validate(new CreateUserRequest(0, "Jane Doe", null, null)); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.PropertyName == nameof(CreateUserRequest.Id)); + } + + [Fact] + public void Validate_BlankName_Fails() + { + var result = validator.Validate(new CreateUserRequest(1, "", null, null)); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_NameOverMaxLength_Fails() + { + var result = validator.Validate(new CreateUserRequest(1, new string('a', 49), null, null)); + + Assert.False(result.IsValid); + } + + [Fact] + public void Validate_OptionalNamesOmitted_Passes() + { + var result = validator.Validate(new CreateUserRequest(1, "Jane Doe", null, null)); + + Assert.True(result.IsValid); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Musify.Infrastructure.Tests.csproj b/backend/Tests/Musify.Infrastructure.Tests/Musify.Infrastructure.Tests.csproj new file mode 100644 index 0000000..de87180 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Musify.Infrastructure.Tests.csproj @@ -0,0 +1,34 @@ + + + + false + true + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs new file mode 100644 index 0000000..6f47980 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/AuditableEntityInterceptorTests.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Domain.Entities; +using Musify.Infrastructure.Tests.TestSupport; +using Xunit; + +namespace Musify.Infrastructure.Tests.Persistence; + +[Collection(InfrastructureCollection.Name)] +public sealed class AuditableEntityInterceptorTests(InfrastructureTestFixture fixture) +{ + [Fact] + public async Task SavingChanges_NewEntity_SetsCreatedAtAndUpdatedAtToNow() + { + await using var database = fixture.CreateDatabase(); + var before = DateTime.UtcNow; + + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "audit-user", NormalizedName = "AUDIT-USER" }; + await database.Users.AddAsync(user); + await database.SaveChangesAsync(CancellationToken.None); + + var after = DateTime.UtcNow; + + Assert.InRange(user.CreatedAt, before, after); + Assert.InRange(user.UpdatedAt, before, after); + } + + [Fact] + public async Task SavingChanges_ModifiedEntity_BumpsUpdatedAtButKeepsCreatedAt() + { + await using var database = fixture.CreateDatabase(); + + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "audit-user-2", NormalizedName = "AUDIT-USER-2" }; + await database.Users.AddAsync(user); + await database.SaveChangesAsync(CancellationToken.None); + var originalCreatedAt = user.CreatedAt; + + await Task.Delay(TimeSpan.FromMilliseconds(50)); + + user.Name = "audit-user-2-renamed"; + await database.SaveChangesAsync(CancellationToken.None); + + Assert.Equal(originalCreatedAt, user.CreatedAt); + Assert.True(user.UpdatedAt > originalCreatedAt, + $"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}"); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs new file mode 100644 index 0000000..c65b178 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Persistence/DatabaseTests.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Tests.TestSupport; +using Xunit; + +namespace Musify.Infrastructure.Tests.Persistence; + +[Collection(InfrastructureCollection.Name)] +public sealed class DatabaseTests(InfrastructureTestFixture fixture) +{ + [Fact] + public async Task SaveChangesAsync_LocalTrackWithOwnedTypes_RoundTripsThroughPostgres() + { + await using var database = fixture.CreateDatabase(); + + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "roundtrip-user", NormalizedName = "ROUNDTRIP-USER" }; + await database.Users.AddAsync(user); + + var track = new LocalTrack + { + Title = "Round Trip Track", + NormalizedTitle = "ROUND TRIP TRACK", + DurationSeconds = 123.4, + OwnerUserId = user.Id, + Owner = user, + Pictures = new TrackPictures { OriginalName = "cover.webp", ProcessingStatus = ProcessingStatus.Pending }, + Audio = new TrackAudio { OriginalName = "song.mp3", TranscodeStatus = ProcessingStatus.Pending } + }; + await database.LocalTracks.AddAsync(track); + await database.SaveChangesAsync(CancellationToken.None); + + await using var reloaded = fixture.CreateDatabase(); + var stored = await reloaded.Tracks.AsNoTracking().SingleAsync(t => t.Id == track.Id); + + Assert.IsType(stored); + Assert.Equal("Round Trip Track", stored.Title); + Assert.Equal("cover.webp", stored.Pictures.OriginalName); + Assert.Equal("song.mp3", stored.Audio.OriginalName); + Assert.Equal(user.Id, ((LocalTrack)stored).OwnerUserId); + } + + [Fact] + public async Task ExternalTracks_SourceAndExternalIdUniqueIndex_RejectsDuplicates() + { + await using var database = fixture.CreateDatabase(); + + var videoId = $"dup-{Guid.NewGuid():N}"; + await database.ExternalTracks.AddAsync(BuildExternalTrack(videoId)); + await database.SaveChangesAsync(CancellationToken.None); + + await using var second = fixture.CreateDatabase(); + await second.ExternalTracks.AddAsync(BuildExternalTrack(videoId)); + + await Assert.ThrowsAsync(() => second.SaveChangesAsync(CancellationToken.None)); + } + + [Fact] + public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows() + { + await using var database = fixture.CreateDatabase(); + + var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "cascade-user", NormalizedName = "CASCADE-USER" }; + var album = new UserAlbum { Title = "Cascade Album", NormalizedTitle = "CASCADE ALBUM", OwnerUserId = user.Id }; + var track = BuildExternalTrack($"cascade-{Guid.NewGuid():N}"); + var link = new AlbumHasTrack { AlbumId = album.Id, TrackId = track.Id, TrackNumber = 1 }; + + await database.AddRangeAsync(user, album, track, link); + await database.SaveChangesAsync(CancellationToken.None); + + await using var deleter = fixture.CreateDatabase(); + var albumToDelete = await deleter.UserAlbums.SingleAsync(a => a.Id == album.Id); + deleter.UserAlbums.Remove(albumToDelete); + await deleter.SaveChangesAsync(CancellationToken.None); + + await using var verifier = fixture.CreateDatabase(); + Assert.False(await verifier.AlbumHasTracks.AnyAsync(l => l.AlbumId == album.Id)); + Assert.True(await verifier.ExternalTracks.AnyAsync(t => t.Id == track.Id)); + } + + private static ExternalTrack BuildExternalTrack(string externalId) => new() + { + Title = "External", + NormalizedTitle = "EXTERNAL", + DurationSeconds = 100, + Source = TrackSource.YouTube, + ExternalId = externalId, + Pictures = new TrackPictures(), + Audio = new TrackAudio() + }; +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs new file mode 100644 index 0000000..11ea899 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/PictureServiceTests.cs @@ -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.Instance); + + private static MemoryStream CreateSourceImage(int width, int height) + { + using var image = new Image(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); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/PlayListPresetSeederTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/PlayListPresetSeederTests.cs new file mode 100644 index 0000000..94a5038 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/PlayListPresetSeederTests.cs @@ -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(_ => new StorageService( + fixture.CreateDatabase(), + fixture.CreateS3Client(), + NullLogger.Instance, + new InfrastructureStorageConfiguration + { + Address = fixture.S3ServiceUrl, + AccessKey = InfrastructureTestFixture.S3AccessKey, + SecretAccessKey = InfrastructureTestFixture.S3SecretKey, + ForcePathStyle = true, + UseHttp = true, + })); + services.AddSingleton(new PictureService(NullLogger.Instance)); + + var provider = services.BuildServiceProvider(); + var seeder = new PlayListPresetSeeder(provider.GetRequiredService(), NullLogger.Instance); + return (seeder, provider.GetRequiredService()); + } + + 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); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs new file mode 100644 index 0000000..da41272 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/SingleFlightCacheTests.cs @@ -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> Factory() { calls++; return Task.FromResult>(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> 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> Factory() + { + calls++; + return Task.FromResult>(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> Factory() { calls++; return Task.FromResult>(calls); } + + await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory); + await cache.GetOrCreateAsync("no-cache-key", TimeSpan.Zero, Factory); + + Assert.Equal(2, calls); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs new file mode 100644 index 0000000..d597271 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StorageServiceTests.cs @@ -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.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(); + 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(); + 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); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs new file mode 100644 index 0000000..b701dc9 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs @@ -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); + } +} diff --git a/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs b/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs new file mode 100644 index 0000000..980f690 --- /dev/null +++ b/backend/Tests/Musify.Infrastructure.Tests/TestSupport/InfrastructureTestFixture.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using Amazon.S3; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Musify.Infrastructure.Configuration; +using Musify.Infrastructure.Persistence; +using Testcontainers.PostgreSql; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using Xunit; + +namespace Musify.Infrastructure.Tests.TestSupport; + +/// +/// One Postgres container and one SeaweedFS container (same image and startup +/// command as deploy/compose.yml), started once for every test in the +/// and torn down when the run finishes. +/// EF Core migrations run once here too, against the real Database +/// DbContext, so a broken migration fails the whole run immediately. +/// +public sealed class InfrastructureTestFixture : IAsyncLifetime +{ + public const string S3AccessKey = "musify-test"; + public const string S3SecretKey = "musify-test-secret"; + public const string S3Bucket = "musify-test-bucket"; + + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("musify_test") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + private readonly IContainer seaweedFs = new ContainerBuilder("chrislusf/seaweedfs:latest") + .WithEntrypoint("/bin/sh", "-c") + .WithCommand(BuildSeaweedFsStartupScript()) + .WithPortBinding(9333, assignRandomHostPort: true) + .WithPortBinding(8333, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(request => request.ForPort(9333).ForPath("/cluster/healthz"))) + .Build(); + + public string ConnectionString => postgres.GetConnectionString(); + + public string S3ServiceUrl => $"http://{seaweedFs.Hostname}:{seaweedFs.GetMappedPublicPort(8333)}"; + + public async Task InitializeAsync() + { + await Task.WhenAll(postgres.StartAsync(), seaweedFs.StartAsync()); + + await using var database = new Database(new DatabaseConfiguration { ConnectionString = ConnectionString }); + await database.Database.MigrateAsync(); + + // SeaweedFS does not auto-create buckets (see deploy/compose.yml's seaweedfs-init). + using var s3 = CreateS3Client(); + await s3.PutBucketAsync(S3Bucket); + } + + public async Task DisposeAsync() + { + await postgres.DisposeAsync(); + await seaweedFs.DisposeAsync(); + } + + /// A fresh, DI-wired , so AuditableEntityInterceptor + /// (attached in AddDatabase's DI registration, not in the class itself) is active — + /// same wiring the real app uses, not a bare new Database(...). + public Database CreateDatabase() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([new("Database:ConnectionString", ConnectionString)]) + .Build(); + + var services = new ServiceCollection(); + services.AddDatabase(configuration); + return services.BuildServiceProvider().GetRequiredService(); + } + + /// An client configured exactly like + /// ServicesDependencyInjection.AddStorageService, pointed at the SeaweedFS container. + public IAmazonS3 CreateS3Client() => + new AmazonS3Client(S3AccessKey, S3SecretKey, new AmazonS3Config + { + ServiceURL = S3ServiceUrl, + ForcePathStyle = true, + UseHttp = true, + }); + + private static string BuildSeaweedFsStartupScript() + { + var identity = JsonSerializer.Serialize(new + { + identities = new[] + { + new + { + name = "musify-test", + credentials = new[] { new { accessKey = S3AccessKey, secretKey = S3SecretKey } }, + actions = new[] { "Admin", "Read", "Write" } + } + } + }); + + return $""" + cat > /tmp/s3.conf <<'JSON' + {identity} + JSON + exec weed server -ip.bind=0.0.0.0 -dir=/data -master.volumeSizeLimitMB=1024 -volume.max=0 -filer -s3 -s3.config=/tmp/s3.conf + """; + } +} + +[CollectionDefinition(Name)] +public sealed class InfrastructureCollection : ICollectionFixture +{ + public const string Name = "Infrastructure"; +} diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs new file mode 100644 index 0000000..1f95b55 --- /dev/null +++ b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs @@ -0,0 +1,146 @@ +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 { ["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 { ["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); + } +} diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs new file mode 100644 index 0000000..8585975 --- /dev/null +++ b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs @@ -0,0 +1,153 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Musify.StreamingGateway.Authentication; +using Musify.StreamingGateway.Configuration; +using Musify.StreamingGateway.Middleware; +using Xunit; + +namespace Musify.StreamingGateway.Tests.Middleware; + +public sealed class TicketValidationMiddlewareTests : IDisposable +{ + private const string Prefix = "Tracks/ProcessedAudios/abc"; + + private readonly string publicKeyPath = Path.GetTempFileName(); + private readonly RSA rsa = RSA.Create(2048); + private readonly StreamTicketValidationOptions options; + + public TicketValidationMiddlewareTests() + { + File.WriteAllText(publicKeyPath, rsa.ExportSubjectPublicKeyInfoPem()); + options = new StreamTicketValidationOptions + { + PublicKeyPath = publicKeyPath, + Audience = "media-gateway", + Issuer = "musify-webapi", + }; + } + + public void Dispose() + { + rsa.Dispose(); + File.Delete(publicKeyPath); + } + + private string IssueValidToken(string prefix = Prefix) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + { + Issuer = options.Issuer, + Audience = options.Audience, + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(5), + Claims = new Dictionary { ["prefix"] = prefix }, + SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), + }); + + private (TicketValidationMiddleware Middleware, Func WasNextCalled) CreateMiddleware() + { + var nextCalled = false; + RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; + var validator = new TicketValidator(Options.Create(options)); + var middleware = new TicketValidationMiddleware(next, validator, Options.Create(options), NullLogger.Instance); + return (middleware, () => nextCalled); + } + + [Fact] + public async Task InvokeAsync_PathOutsideMediaPrefix_PassesThroughWithoutValidatingATicket() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = "/health"; + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_NoTicket_ReturnsUnauthorized() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + + await middleware.InvokeAsync(context); + + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_ValidTicketAsQueryParameter_ForTheRequestedObject_PassesThroughAndStripsTheTicket() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}&keep=1"); + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + Assert.Equal("keep=1", context.Request.QueryString.Value?.TrimStart('?')); + } + + [Fact] + public async Task InvokeAsync_ValidTicketAsHeader_PassesThrough() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.Headers["X-Stream-Ticket"] = IssueValidToken(); + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + } + + [Fact] + public async Task InvokeAsync_ObjectKeyOutsideAuthorizedPrefix_ReturnsForbidden() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = "/media/Tracks/ProcessedAudios/someone-elses-track/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + + await middleware.InvokeAsync(context); + + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); + } + + [Theory] + [InlineData($"/media/{Prefix}/../../etc/passwd")] + [InlineData($"/media/{Prefix}/./audio.m4a")] + public async Task InvokeAsync_PathTraversalSegment_ReturnsBadRequest(string path) + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = path; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + + await middleware.InvokeAsync(context); + + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_TicketPrefixExactlyMatchesTheRequestedObject_PassesThrough() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + } +} diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Musify.StreamingGateway.Tests.csproj b/backend/Tests/Musify.StreamingGateway.Tests/Musify.StreamingGateway.Tests.csproj new file mode 100644 index 0000000..d5ded5d --- /dev/null +++ b/backend/Tests/Musify.StreamingGateway.Tests/Musify.StreamingGateway.Tests.csproj @@ -0,0 +1,25 @@ + + + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/docs/development.md b/docs/development.md index adb3590..33c0809 100644 --- a/docs/development.md +++ b/docs/development.md @@ -78,13 +78,21 @@ openssl rsa -in deploy/keys/stream_private.pem -pubout -out deploy/keys/stream_p dotnet test backend/Musify.slnx ``` -`backend/Tests/` (carpeta `/Tests/` en el `.slnx`) tiene dos proyectos xUnit, -sin Docker ni Postgres real: +`backend/Tests/` (carpeta `/Tests/` en el `.slnx`) tiene cinco proyectos xUnit: -| Proyecto | Cubre | -|---|---| -| `Musify.Domain.Tests` | Lógica de los value objects (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Las entidades son en su mayoría anémicas — sin comportamiento propio, nada más que testear ahí. | -| `Musify.Application.Tests` | Los ~45 handlers y servicios de `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes, YouTube). | +| Proyecto | Cubre | Docker | +|---|---|---| +| `Musify.Domain.Tests` | Lógica de los value objects (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Las entidades son en su mayoría anémicas — sin comportamiento propio, nada más que testear ahí. | No | +| `Musify.Application.Tests` | Los ~45 handlers y servicios de `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes, YouTube). | No | +| `Musify.Infrastructure.Tests` | `Database`/migraciones, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `SingleFlightCache`, `PlayListPresetSeeder`. | **Sí** | +| `Musify.Api.Tests` | Endpoints HTTP reales (routing, auth, `ValidationFilter`, mapeo `ErrorOr`→HTTP) vía `WebApplicationFactory`, más `ErrorOrHttpExtensions`/`CurrentUser`/validators FluentValidation en aislado. | **Sí** | +| `Musify.StreamingGateway.Tests` | `TicketValidator` (RS256) y `TicketValidationMiddleware` (traversal, límites de prefijo, extracción de ticket). | No | + +`Musify.Worker` no tiene proyecto de test: su `Program.cs` es puro *wiring* de +DI/host (Hangfire, MassTransit, jobs) — la lógica real que ejecuta vive en +`Musify.Infrastructure`, ya cubierta ahí. + +### Application.Tests: por qué SQLite en memoria y no mocks `IDatabase` expone `DbSet` directamente, así que los handlers arman LINQ real (`Where`, `Include`, `Select`...) contra él — no se puede mockear eso con @@ -97,6 +105,31 @@ construyen entidades y configuración con valores por defecto sensatos. `IStorageService`, `IEventBus`, `IYouTubeMusicService`, etc. sí se mockean con NSubstitute, al ser interfaces normales. +### Infrastructure.Tests y Api.Tests: Testcontainers + +Estos dos sí necesitan Docker corriendo. Levantan un Postgres real +(`Testcontainers.PostgreSql`) y, en `Infrastructure.Tests`, también un +SeaweedFS real (mismo `chrislusf/seaweedfs:latest` e imagen/comando que +`deploy/compose.yml`, no MinIO) — así `StorageService` se prueba contra el +mismo backend S3-compatible que usa producción, no contra AWS S3. Los +contenedores se levantan una vez por corrida (`ICollectionFixture`), con +migraciones EF Core reales aplicadas contra el Postgres efímero. + +`Api.Tests` usa `WebApplicationFactory` contra ese mismo Postgres — +routing, auth y validación reales — pero sustituye por fakes los servicios que +hablan por red (`IStorageService`, `IEventBus`, `IYouTubeMusicService`, +`IStreamTicketService`) y el esquema de autenticación JWT real por un handler +de prueba (ver `TestSupport/FakeAuthenticationHandler.cs`) que autentica según +un header, sin necesitar un Zitadel real. + +Escribir estos tests contra el backend real encontró y arregló dos bugs reales +(no simulados): `StorageService.RemoveFolderAsync` reventaba con +`NullReferenceException` contra SeaweedFS (su respuesta `DeleteObjects` omite +la lista `` cuando no hay errores, a diferencia de AWS S3), y +`AuditableEntityInterceptor` nunca actualizaba `UpdatedAt` en una actualización +normal (carga → modifica → guarda) porque corría antes de que EF detectara los +cambios pendientes. + ## Config (AppSettings) Convención: `AppSettings.json` es la plantilla con valores vacíos o neutros; diff --git a/docs/projects.md b/docs/projects.md index e19ddda..10c196b 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -59,11 +59,13 @@ Cliente web (SvelteKit 2 + Svelte 5 + Tailwind 4). La sesión OIDC vive en el servidor (cookie cifrada); las llamadas al backend salen del servidor con el access token, y las subidas se hacen contra las URLs prefirmadas de S3. -## Musify.Domain.Tests + Musify.Application.Tests +## Tests -`backend/Tests/`, carpeta `/Tests/` en el `.slnx`. xUnit, sin Docker ni Postgres -real — `Application.Tests` corre los handlers contra SQLite en memoria (ver -[development.md](development.md#tests)). +`backend/Tests/`, carpeta `/Tests/` en el `.slnx`: cinco proyectos xUnit — +`Musify.Domain.Tests`, `Musify.Application.Tests` (SQLite en memoria, sin +Docker), `Musify.Infrastructure.Tests` y `Musify.Api.Tests` (Testcontainers: +Postgres + SeaweedFS reales) y `Musify.StreamingGateway.Tests`. Detalle en +[development.md](development.md#tests). ## Otras carpetas