diff --git a/backend/Core/Musify.Application/Albums/CreateAlbum.cs b/backend/Core/Musify.Application/Albums/CreateAlbum.cs index 4646035..4e124a1 100644 --- a/backend/Core/Musify.Application/Albums/CreateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/CreateAlbum.cs @@ -43,7 +43,7 @@ public class CreateAlbumCommandHandler( } var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken); + uploadIntentConfiguration, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, cancellationToken); if (pictureIntent.IsError) return pictureIntent.Errors; diff --git a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs index 1ceb9d9..d2984a5 100644 --- a/backend/Core/Musify.Application/Albums/UpdateAlbum.cs +++ b/backend/Core/Musify.Application/Albums/UpdateAlbum.cs @@ -62,7 +62,7 @@ public class UpdateAlbumCommandHandler( { var validation = await uploadIntentValidator.ValidateAndLoadAsync( uploadIntentConfiguration, - request.NewPictureIntentId.Value, request.UserId, cancellationToken); + request.NewPictureIntentId.Value, request.UserId, UploadIntentPurpose.AlbumPicture, cancellationToken); if (validation.IsError) return validation.Errors; diff --git a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs index 3481922..c2bb640 100644 --- a/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/CreatePlayList.cs @@ -92,7 +92,7 @@ public class CreatePlayListCommandHandler( } var validation = await uploadIntentValidator.ValidateAndLoadAsync( - uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken); + uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, cancellationToken); if (validation.IsError) return validation.Errors; diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index 1850462..6a299b8 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore; using Musify.Application.Contracts; using Musify.Application.Shared; using Musify.Application.Tracks.Responses; +using Musify.Domain.ValueObjects; using X.PagedList; using X.PagedList.EF; @@ -12,7 +13,8 @@ namespace Musify.Application.PlayLists; public record GetPlayListTracksQuery( Guid PlayListId, int PageNumber, - int PageSize) + int PageSize, + long? ViewerId = null) : IQuery>>; public class GetPlayListTracksQueryHandler(IDatabase database) @@ -22,7 +24,10 @@ public class GetPlayListTracksQueryHandler(IDatabase database) { var playListExists = await database.PlayLists .AsNoTracking() - .AnyAsync(p => p.Id == request.PlayListId, cancellationToken); + .AnyAsync( + p => p.Id == request.PlayListId + && (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId), + cancellationToken); if (!playListExists) return Error.NotFound(); diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs b/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs deleted file mode 100644 index 1b22591..0000000 --- a/backend/Core/Musify.Application/PlayLists/GetPlayLists.cs +++ /dev/null @@ -1,42 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Musify.Application.Contracts; -using Musify.Application.PlayLists.Responses; -using Musify.Application.Shared; -using X.PagedList; -using X.PagedList.EF; - -namespace Musify.Application.PlayLists; - -public record GetPlayListsQuery(int PageNumber, int PageSize) - : IQuery>>; - -public class GetPlayListsQueryHandler(IDatabase database) - : IQueryHandler>> -{ - public async ValueTask>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken) - { - var totalCount = await database.PlayLists.CountAsync(cancellationToken); - - var pagedEntities = await database.PlayLists - .AsNoTracking() - .OrderBy(p => p.Id) - .Select(p => new - { - PlayList = p, - CoverTrackIds = p.PlayListTracks - .OrderBy(plt => plt.Position) - .Take(PlayListApplicationResponse.CoverTrackCount) - .Select(plt => plt.TrackId) - .ToList() - }) - .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - - var pagedPlayLists = new StaticPagedList( - pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), - pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); - - return PaginatedResponse.FromPagedList(pagedPlayLists); - } -} diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs index 57f5817..0716ce7 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListsByUserId.cs @@ -15,7 +15,7 @@ public record GetPlayListsByUserIdQuery( string? Name, int PageNumber, int PageSize, - bool OnlyPublic = false) + long? ViewerId = null) : IQuery>>; public class GetPlayListsByUserIdQueryHandler(IDatabase database) @@ -25,12 +25,8 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database) { var playListsQuery = database.PlayLists .AsNoTracking() - .Where(p => p.UserId == request.UserId); - - if (request.OnlyPublic) - { - playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public); - } + .Where(p => p.UserId == request.UserId + && (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId)); if (!string.IsNullOrEmpty(request.Name)) { diff --git a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs index b5effd5..a791752 100644 --- a/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs +++ b/backend/Core/Musify.Application/PlayLists/UpdatePlayList.cs @@ -70,7 +70,7 @@ public class UpdatePlayListCommandHandler( { var validation = await uploadIntentValidator.ValidateAndLoadAsync( uploadIntentConfiguration, - request.NewPictureIntentId.Value, request.UserId, cancellationToken); + request.NewPictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, cancellationToken); if (validation.IsError) return validation.Errors; diff --git a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs index e37abfb..954d769 100644 --- a/backend/Core/Musify.Application/Services/UploadIntentValidator.cs +++ b/backend/Core/Musify.Application/Services/UploadIntentValidator.cs @@ -37,6 +37,7 @@ public sealed class UploadIntentValidator( UploadIntentConfiguration config, Guid intentId, long userId, + UploadIntentPurpose purpose, CancellationToken cancellationToken) { var intent = await database.UploadIntents @@ -45,6 +46,9 @@ public sealed class UploadIntentValidator( if (intent == null || intent.UserId != userId) return Error.NotFound(description: "Upload intent not found or not accessible."); + if (intent.Purpose != purpose) + return Error.Validation(description: "Upload intent was issued for a different purpose."); + if (intent.IsConsumed) return Error.Conflict(description: "Upload intent has already been consumed."); diff --git a/backend/Core/Musify.Application/Tracks/CreateTrack.cs b/backend/Core/Musify.Application/Tracks/CreateTrack.cs index 139cc53..d16e848 100644 --- a/backend/Core/Musify.Application/Tracks/CreateTrack.cs +++ b/backend/Core/Musify.Application/Tracks/CreateTrack.cs @@ -59,15 +59,18 @@ public class CreateTrackCommandHandler( return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible."); } + if (request.PictureIntentId == request.AudioIntentId) + return Error.Validation(description: "Picture and audio must use different upload intents."); + var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync( uploadIntentConfiguration, - request.PictureIntentId, request.UserId, cancellationToken); + request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken); if (pictureValidation.IsError) return pictureValidation.Errors; var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync( uploadIntentConfiguration, - request.AudioIntentId, request.UserId, cancellationToken); + request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken); if (audioValidation.IsError) return audioValidation.Errors; diff --git a/backend/Core/Musify.Application/Users/CreateUser.cs b/backend/Core/Musify.Application/Users/CreateUser.cs deleted file mode 100644 index 2eaa279..0000000 --- a/backend/Core/Musify.Application/Users/CreateUser.cs +++ /dev/null @@ -1,58 +0,0 @@ -using ErrorOr; -using Mediator; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using Musify.Application.Contracts; -using Musify.Application.Users.Responses; -using Musify.Domain.Entities; - -namespace Musify.Application.Users; - -public record CreateUserCommand(long Id, - string Name, - string? FirstName, - string? SecondName) - : ICommand> -{ - public static User ToEntity(CreateUserCommand command) - { - return new User - { - Id = command.Id, - Name = command.Name, - NormalizedName = command.Name.ToUpperInvariant(), - FirstName = command.FirstName, - SecondName = command.SecondName - }; - } -} - -public class CreateUserCommandHandler(IDatabase database, ILogger logger) - : ICommandHandler> -{ - public async ValueTask> Handle(CreateUserCommand request, CancellationToken cancellationToken) - { - var userExists = await database.Users.AnyAsync(u => u.Id == request.Id, cancellationToken); - if (userExists) - { - logger.LogInformation("User {UserId} already exists", request.Id); - return Error.Conflict(description: $"User {request.Id} already exists"); - } - - var newUser = CreateUserCommand.ToEntity(request); - - try - { - await database.Users.AddAsync(newUser, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) - { - logger.LogError(exception, "Failed to create user {UserId}", request.Id); - return Error.Failure(description: $"Failed to create user {request.Id}"); - } - - logger.LogInformation("Created user {UserId}", newUser.Id); - return UserApplicationResponse.FromEntity(newUser); - } -} diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Users/CreateUserRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Users/CreateUserRequest.cs deleted file mode 100644 index 9a6155c..0000000 --- a/backend/Hosts/Musify.Api/DataTransferObjects/Users/CreateUserRequest.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Musify.Api.DataTransferObjects.Users; - -public record CreateUserRequest( - long Id, - string Name, - string? FirstName, - string? SecondName); diff --git a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs index be41042..2fd1d69 100644 --- a/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/PlayListEndpoints.cs @@ -19,11 +19,6 @@ public static class PlayListEndpoints var group = app.MapGroup("/playlists") .WithTags("PlayLists"); - group.MapGet("/", GetPlayLists) - .WithName("GetPlayLists") - .WithSummary("Get Paginated PlayLists.") - .Produces>(); - group.MapGet("/{id}", GetPlayListById) .WithName("GetPlayListById") .WithSummary("Get A PlayList By Id.") @@ -32,7 +27,7 @@ public static class PlayListEndpoints group.MapGet("/users/{userId}", GetPlayListsByUserId) .WithName("GetPlayListsByUserId") - .WithSummary("Get Paginated PlayLists For A User.") + .WithSummary("Get Paginated PlayLists For A User. Private PlayLists Are Only Returned To Their Owner.") .Produces>(); group.MapGet("/{playlistId}/tracks", GetPlayListTracks) @@ -105,16 +100,6 @@ public static class PlayListEndpoints return app; } - private static async Task GetPlayLists( - IMediator mediator, - CancellationToken cancellationToken, - int pageNumber = 1, - int pageSize = 10) - { - var result = await mediator.Send(new GetPlayListsQuery(pageNumber, pageSize), cancellationToken); - return result.ToHttpResult(); - } - private static async Task GetPlayListById( IMediator mediator, CurrentUser currentUser, @@ -127,25 +112,26 @@ public static class PlayListEndpoints private static async Task GetPlayListsByUserId( IMediator mediator, + CurrentUser currentUser, long userId, CancellationToken cancellationToken, string? name, int pageNumber = 1, - int pageSize = 10, - bool onlyPublic = false) + int pageSize = 10) { - var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize, onlyPublic), cancellationToken); + var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize, currentUser.Id), cancellationToken); return result.ToHttpResult(); } private static async Task GetPlayListTracks( IMediator mediator, + CurrentUser currentUser, Guid playlistId, CancellationToken cancellationToken, int pageNumber = 1, int pageSize = 10) { - var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, pageNumber, pageSize), cancellationToken); + var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, pageNumber, pageSize, currentUser.Id), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index 63448eb..3c0029a 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -1,8 +1,6 @@ using Mediator; using Musify.Api.Authentication; -using Musify.Api.DataTransferObjects.Users; using Musify.Api.Extensions; -using Musify.Api.Filters; using Musify.Application.Shared; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; @@ -38,7 +36,10 @@ public static class UserEndpoints group.MapGet("/{id}/listening-stats", GetListeningStats) .WithName("GetListeningStats") .WithSummary("Get A User'S Listening Stats For The Current Week And Streak.") - .Produces(); + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); group.MapGet("/{id}/last-listened-track", GetLastTrackListenedByUserId) .WithName("GetLastTrackListenedByUserId") @@ -88,14 +89,6 @@ public static class UserEndpoints .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status401Unauthorized); - group.MapPost("/", CreateUser) - .WithName("CreateUser") - .WithSummary("Create A New User.") - .AddEndpointFilter>() - .Produces(StatusCodes.Status201Created) - .ProducesValidationProblem() - .Produces(StatusCodes.Status409Conflict); - return app; } @@ -128,12 +121,16 @@ public static class UserEndpoints return await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken); } - private static async Task GetListeningStats( + private static async Task GetListeningStats( IMediator mediator, + CurrentUser currentUser, long id, CancellationToken cancellationToken) { - return await mediator.Send(new GetListeningStatsQuery(id), cancellationToken); + if (currentUser.RequiredId != id) + return Results.StatusCode(StatusCodes.Status403Forbidden); + + return Results.Ok(await mediator.Send(new GetListeningStatsQuery(id), cancellationToken)); } private static async Task GetLastTrackListenedByUserId( @@ -207,16 +204,4 @@ public static class UserEndpoints var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken); return result.ToHttpResult(); } - - private static async Task CreateUser( - IMediator mediator, - CreateUserRequest request, - CancellationToken cancellationToken) - { - var result = await mediator.Send( - new CreateUserCommand(request.Id, request.Name, request.FirstName, request.SecondName), - cancellationToken); - - return result.ToCreatedResult(user => $"/users/{user.Id}"); - } } diff --git a/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs index d747056..0db9287 100644 --- a/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Albums/RequestAlbumPictureUploadRequestValidator.cs @@ -1,20 +1,20 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Albums; +using Musify.Application.Configuration; namespace Musify.Api.Validators.Albums; public sealed class RequestAlbumPictureUploadRequestValidator : AbstractValidator { - public RequestAlbumPictureUploadRequestValidator() + public RequestAlbumPictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration) { RuleFor(x => x.FileType) - .NotEmpty(); + .MustBeFileType(Uploads.PictureFileTypes); RuleFor(x => x.ContentType) - .NotEmpty(); + .MustBeContentType(Uploads.PictureContentTypes); RuleFor(x => x.ExpectedSizeBytes) - .GreaterThan(0) - .When(x => x.ExpectedSizeBytes.HasValue); + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); } } diff --git a/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs index 94c7529..7da850a 100644 --- a/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/PlayLists/RequestPlayListPictureUploadRequestValidator.cs @@ -1,20 +1,20 @@ using FluentValidation; using Musify.Api.DataTransferObjects.PlayLists; +using Musify.Application.Configuration; namespace Musify.Api.Validators.PlayLists; public sealed class RequestPlayListPictureUploadRequestValidator : AbstractValidator { - public RequestPlayListPictureUploadRequestValidator() + public RequestPlayListPictureUploadRequestValidator(UploadIntentConfiguration uploadConfiguration) { RuleFor(x => x.FileType) - .NotEmpty(); + .MustBeFileType(Uploads.PictureFileTypes); RuleFor(x => x.ContentType) - .NotEmpty(); + .MustBeContentType(Uploads.PictureContentTypes); RuleFor(x => x.ExpectedSizeBytes) - .GreaterThan(0) - .When(x => x.ExpectedSizeBytes.HasValue); + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); } } diff --git a/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs index e84a60b..ee2834e 100644 --- a/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs +++ b/backend/Hosts/Musify.Api/Validators/Tracks/RequestTrackUploadUrlsRequestValidator.cs @@ -1,30 +1,29 @@ using FluentValidation; using Musify.Api.DataTransferObjects.Tracks; +using Musify.Application.Configuration; namespace Musify.Api.Validators.Tracks; public sealed class RequestTrackUploadUrlsRequestValidator : AbstractValidator { - public RequestTrackUploadUrlsRequestValidator() + public RequestTrackUploadUrlsRequestValidator(UploadIntentConfiguration uploadConfiguration) { RuleFor(x => x.PictureFileType) - .NotEmpty(); + .MustBeFileType(Uploads.PictureFileTypes); RuleFor(x => x.PictureContentType) - .NotEmpty(); + .MustBeContentType(Uploads.PictureContentTypes); RuleFor(x => x.AudioFileType) - .NotEmpty(); + .MustBeFileType(Uploads.AudioFileTypes); RuleFor(x => x.AudioContentType) - .NotEmpty(); + .MustBeContentType(Uploads.AudioContentTypes); RuleFor(x => x.ExpectedPictureSizeBytes) - .GreaterThan(0) - .When(x => x.ExpectedPictureSizeBytes.HasValue); + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); RuleFor(x => x.ExpectedAudioSizeBytes) - .GreaterThan(0) - .When(x => x.ExpectedAudioSizeBytes.HasValue); + .MustBeValidUploadSize(uploadConfiguration.MaxUploadBytes); } } diff --git a/backend/Hosts/Musify.Api/Validators/Uploads.cs b/backend/Hosts/Musify.Api/Validators/Uploads.cs new file mode 100644 index 0000000..39ad9fc --- /dev/null +++ b/backend/Hosts/Musify.Api/Validators/Uploads.cs @@ -0,0 +1,42 @@ +using FluentValidation; + +namespace Musify.Api.Validators; + +public static class Uploads +{ + public static readonly IReadOnlySet PictureFileTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "jpg", "jpeg", "png", "webp" }; + + public static readonly IReadOnlySet PictureContentTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp" }; + + public static readonly IReadOnlySet AudioFileTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) { "mp3", "wav", "flac", "ogg", "m4a", "aac" }; + + public static readonly IReadOnlySet AudioContentTypes = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/wave", "audio/flac", "audio/x-flac", + "audio/ogg", "audio/mp4", "audio/x-m4a", "audio/m4a", "audio/aac" + }; + + public static IRuleBuilderOptions MustBeFileType( + this IRuleBuilder rule, IReadOnlySet allowed) => + rule + .NotEmpty() + .Length(1, 10) + .Matches("^[A-Za-z0-9]+$").WithMessage("'{PropertyName}' must contain only letters and digits.") + .Must(allowed.Contains).WithMessage("'{PropertyName}' must be one of: " + string.Join(", ", allowed) + "."); + + public static IRuleBuilderOptions MustBeContentType( + this IRuleBuilder rule, IReadOnlySet allowed) => + rule + .NotEmpty() + .Must(allowed.Contains).WithMessage("'{PropertyName}' is not an allowed content type."); + + public static IRuleBuilderOptions MustBeValidUploadSize( + this IRuleBuilder rule, long maxUploadBytes) => + rule + .Must(size => size is null || (size > 0 && size <= maxUploadBytes)) + .WithMessage($"'{{PropertyName}}' must be greater than 0 and at most {maxUploadBytes} bytes."); +} diff --git a/backend/Hosts/Musify.Api/Validators/Users/CreateUserRequestValidator.cs b/backend/Hosts/Musify.Api/Validators/Users/CreateUserRequestValidator.cs deleted file mode 100644 index bd8de92..0000000 --- a/backend/Hosts/Musify.Api/Validators/Users/CreateUserRequestValidator.cs +++ /dev/null @@ -1,25 +0,0 @@ -using FluentValidation; -using Musify.Api.DataTransferObjects.Users; - -namespace Musify.Api.Validators.Users; - -public sealed class CreateUserRequestValidator : AbstractValidator -{ - public CreateUserRequestValidator() - { - RuleFor(x => x.Id) - .NotEmpty(); - - RuleFor(x => x.Name) - .NotEmpty() - .MaximumLength(48); - - RuleFor(x => x.FirstName) - .MaximumLength(48) - .When(x => !string.IsNullOrWhiteSpace(x.FirstName)); - - RuleFor(x => x.SecondName) - .MaximumLength(48) - .When(x => !string.IsNullOrWhiteSpace(x.SecondName)); - } -} diff --git a/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs index 94b9644..89da5f8 100644 --- a/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs +++ b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs @@ -61,7 +61,7 @@ public sealed class TicketValidationMiddleware( if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes)) { logger.LogInformation( - "Rejected media request {ObjectKey}: requested range starts beyond the {MaxBytes}-byte preview limit", + "Rejected media request {ObjectKey}: requested range is a suffix range or starts beyond the {MaxBytes}-byte preview limit", objectKey, maxBytes); context.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; context.Response.Headers[HeaderNames.ContentRange] = $"bytes */{maxBytes}"; @@ -81,11 +81,19 @@ public sealed class TicketValidationMiddleware( return true; } - if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range) || range.Ranges.Count != 1) + if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range) + || range.Ranges.Count != 1 + || !string.Equals(range.Unit.ToString(), "bytes", StringComparison.OrdinalIgnoreCase)) + { + context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; return true; + } var requested = range.Ranges.Single(); - var start = requested.From ?? 0; + if (requested.From is null) + return false; + + var start = requested.From.Value; if (start >= maxBytes) return false; diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs index 92a1e15..49d0115 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/AlbumEndpointsTests.cs @@ -1,7 +1,6 @@ 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 Musify.Application.Contracts; @@ -13,14 +12,7 @@ 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; - } + private Task CreateUserAsync() => fixture.SeedUserAsync(); private async Task CreatePictureIntentAsync(HttpClient client) { diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs index 41f3e4e..2067135 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs @@ -3,7 +3,6 @@ using System.Net.Http.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Musify.Api.DataTransferObjects.Tracks; -using Musify.Api.DataTransferObjects.Users; using Musify.Api.Tests.TestSupport; using Musify.Domain.Entities; using Musify.Infrastructure.Persistence; @@ -14,14 +13,7 @@ namespace Musify.Api.Tests.Endpoints; [Collection(ApiCollection.Name)] public sealed class ListeningProgressEndpointsTests(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), TestContext.Current.CancellationToken); - response.EnsureSuccessStatusCode(); - return userId; - } + private Task CreateUserAsync() => fixture.SeedUserAsync(); private async Task SeedListenAsync(long userId, double durationSeconds) { diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs index c4df797..9d27991 100644 --- a/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs +++ b/backend/Tests/Musify.Api.Tests/Endpoints/UserEndpointsTests.cs @@ -4,7 +4,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Musify.Api.DataTransferObjects.Users; using Musify.Api.Tests.TestSupport; using Musify.Application.Tracks.Responses; using Musify.Application.Users.Responses; @@ -45,40 +44,13 @@ 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"), TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - Assert.Equal($"/users/{userId}", response.Headers.Location?.OriginalString); - var body = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); - Assert.NotNull(body); - Assert.Equal("Jane Doe", body.Name); - } - - [Fact] - public async Task PostUsers_BlankName_ReturnsValidationProblem() + public async Task PostUsers_IsNotExposed() { var client = fixture.CreateAnonymousClient(); - var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(Random.Shared.NextInt64(1, long.MaxValue), "", null, null), TestContext.Current.CancellationToken); + var response = await client.PostAsJsonAsync("/users", new { id = 1, name = "Intruder" }, TestContext.Current.CancellationToken); - 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), TestContext.Current.CancellationToken); - - var response = await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Second", null, null), TestContext.Current.CancellationToken); - - Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.True(response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed); } [Fact] @@ -86,7 +58,7 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture) { var client = fixture.CreateAnonymousClient(); var userId = Random.Shared.NextInt64(1, long.MaxValue); - await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, "Findable User", null, null), TestContext.Current.CancellationToken); + await fixture.SeedUserAsync(userId, "Findable User"); var response = await client.GetAsync($"/users/{userId}", TestContext.Current.CancellationToken); @@ -110,7 +82,7 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture) { var client = fixture.CreateAnonymousClient(); var userId = Random.Shared.NextInt64(1, long.MaxValue); - await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); + await fixture.SeedUserAsync(userId); await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10)); await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow); @@ -126,7 +98,7 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture) { var client = fixture.CreateAnonymousClient(); var userId = Random.Shared.NextInt64(1, long.MaxValue); - await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); + await fixture.SeedUserAsync(userId); var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken); diff --git a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs index 5d44ceb..e2a5961 100644 --- a/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs +++ b/backend/Tests/Musify.Api.Tests/TestSupport/ApiTestFixture.cs @@ -44,6 +44,23 @@ public sealed class ApiTestFixture : WebApplicationFactory, IAsyncLifet public HttpClient CreateAnonymousClient() => CreateClient(); + public async Task SeedUserAsync(long? userId = null, string? name = null) + { + var id = userId ?? Random.Shared.NextInt64(1, long.MaxValue); + var userName = name ?? $"user-{id}"; + + using var scope = Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + database.Users.Add(new Musify.Domain.Entities.User + { + Id = id, + Name = userName, + NormalizedName = userName.ToUpperInvariant() + }); + await database.SaveChangesAsync(); + return id; + } + public HttpClient CreateAuthenticatedClient(long userId, string userName = "test-user") { var client = CreateClient(); diff --git a/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs deleted file mode 100644 index 36ccb0f..0000000 --- a/backend/Tests/Musify.Api.Tests/Validators/CreateUserRequestValidatorTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -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.Api.Tests/Validators/UploadRequestValidatorTests.cs b/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs new file mode 100644 index 0000000..f671b0e --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Validators/UploadRequestValidatorTests.cs @@ -0,0 +1,49 @@ +using Musify.Api.DataTransferObjects.Albums; +using Musify.Api.DataTransferObjects.Tracks; +using Musify.Api.Validators.Albums; +using Musify.Api.Validators.Tracks; +using Musify.Application.Configuration; +using Xunit; + +namespace Musify.Api.Tests.Validators; + +public sealed class UploadRequestValidatorTests +{ + private readonly UploadIntentConfiguration configuration = new() { MaxUploadBytes = 1_000 }; + + [Fact] + public void PictureUpload_ValidRequest_Passes() + { + var validator = new RequestAlbumPictureUploadRequestValidator(configuration); + + var result = validator.Validate(new RequestAlbumPictureUploadRequest("webp", "image/webp", 500)); + + Assert.True(result.IsValid); + } + + [Theory] + [InlineData("exe", "image/webp", 500L)] + [InlineData("averyveryveryloongextension", "image/webp", 500L)] + [InlineData("we bp", "image/webp", 500L)] + [InlineData("webp", "text/html", 500L)] + [InlineData("webp", "image/webp", 0L)] + [InlineData("webp", "image/webp", 1_001L)] + public void PictureUpload_InvalidRequest_Fails(string fileType, string contentType, long size) + { + var validator = new RequestAlbumPictureUploadRequestValidator(configuration); + + var result = validator.Validate(new RequestAlbumPictureUploadRequest(fileType, contentType, size)); + + Assert.False(result.IsValid); + } + + [Fact] + public void TrackUpload_AudioTypesAreCheckedAgainstTheAudioAllowList() + { + var validator = new RequestTrackUploadUrlsRequestValidator(configuration); + + Assert.True(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "mp3", "audio/mpeg", 10, 900)).IsValid); + Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("png", "image/png", "png", "image/png")).IsValid); + Assert.False(validator.Validate(new RequestTrackUploadUrlsRequest("mp3", "audio/mpeg", "mp3", "audio/mpeg")).IsValid); + } +} diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs index 5f181b3..013402f 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListTracksQueryHandlerTests.cs @@ -2,6 +2,7 @@ using ErrorOr; using Musify.Application.PlayLists; using Musify.Application.Tests.TestSupport; using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.PlayLists; @@ -22,7 +23,7 @@ public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 }, new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 }); - var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal(["First", "Second"], result.Value.Items.Select(track => track.Title)); @@ -35,4 +36,28 @@ public sealed class GetPlayListTracksQueryHandlerTests : HandlerTestBase Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } + + [Fact] + public async Task Handle_PrivatePlayListOfAnotherUser_ReturnsNotFound() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: PlaylistVisibility.Private); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Fact] + public async Task Handle_PrivatePlayListOfViewer_ReturnsTracks() + { + var owner = TestEntities.User(); + var playList = TestEntities.PlayList(owner.Id, visibility: PlaylistVisibility.Private); + await SeedAsync(owner, playList); + + var result = await CreateHandler().Handle(new GetPlayListTracksQuery(playList.Id, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + } } diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs index 9623518..51eba29 100644 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsByUserIdQueryHandlerTests.cs @@ -20,7 +20,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase TestEntities.PlayList(owner.Id, "Being removed", lifeCycleStatus: LifeCycleStatus.Removing), TestEntities.PlayList(other.Id, "Theirs")); - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); var playList = Assert.Single(result.Value.Items); @@ -28,7 +28,7 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase } [Fact] - public async Task Handle_OnlyPublic_ReturnsOnlyPublicPlayLists() + public async Task Handle_AnotherViewer_ReturnsOnlyPublicPlayLists() { var owner = TestEntities.User(); await SeedAsync( @@ -36,20 +36,35 @@ public sealed class GetPlayListsByUserIdQueryHandlerTests : HandlerTestBase TestEntities.PlayList(owner.Id, "Public one", visibility: PlaylistVisibility.Public), TestEntities.PlayList(owner.Id, "Private one", visibility: PlaylistVisibility.Private)); - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, OnlyPublic: true), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: 999), TestContext.Current.CancellationToken); Assert.False(result.IsError); var playList = Assert.Single(result.Value.Items); Assert.Equal("Public one", playList.Name); } + [Fact] + public async Task Handle_Owner_SeesPrivatePlayLists() + { + var owner = TestEntities.User(); + await SeedAsync( + owner, + TestEntities.PlayList(owner.Id, "Public one", visibility: PlaylistVisibility.Public), + TestEntities.PlayList(owner.Id, "Private one", visibility: PlaylistVisibility.Private)); + + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.Items.Count); + } + [Fact] public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists() { var owner = TestEntities.User(); await SeedAsync(owner, TestEntities.PlayList(owner.Id, "Road Trip"), TestEntities.PlayList(owner.Id, "Study Focus")); - var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); + var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: "road", PageNumber: 1, PageSize: 10, ViewerId: owner.Id), TestContext.Current.CancellationToken); Assert.False(result.IsError); var playList = Assert.Single(result.Value.Items); diff --git a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs deleted file mode 100644 index 3c8b364..0000000 --- a/backend/Tests/Musify.Application.Tests/PlayLists/GetPlayListsQueryHandlerTests.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Musify.Application.PlayLists; -using Musify.Application.Tests.TestSupport; -using Xunit; - -namespace Musify.Application.Tests.PlayLists; - -public sealed class GetPlayListsQueryHandlerTests : HandlerTestBase -{ - private GetPlayListsQueryHandler CreateHandler() => new(Database); - - [Fact] - public async Task Handle_ReturnsEveryPlayListPaged() - { - var owner = TestEntities.User(); - await SeedAsync(owner, TestEntities.PlayList(owner.Id, "One"), TestEntities.PlayList(owner.Id, "Two")); - - var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal(2, result.Value.TotalItemCount); - } - - [Fact] - public async Task Handle_NoPlayLists_ReturnsEmptyPage() - { - var result = await CreateHandler().Handle(new GetPlayListsQuery(PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Empty(result.Value.Items); - } -} diff --git a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs index e526555..ec75365 100644 --- a/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/UploadIntentValidatorTests.cs @@ -25,16 +25,27 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id, bucket: "bucket", key: "temp/object.webp"); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.False(result.IsError); Assert.Equal(intent.Id, result.Value.Id); } + [Fact] + public async Task ValidateAndLoadAsync_IntentIssuedForAnotherPurpose_ReturnsValidationError() + { + var intent = TestEntities.UploadIntent(owner.Id, UploadIntentPurpose.TrackAudio); + await SeedAsync(owner, intent); + + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + [Fact] public async Task ValidateAndLoadAsync_IntentMissing_ReturnsNotFound() { - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), Guid.NewGuid(), owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -46,7 +57,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, stranger, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, stranger.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -57,7 +68,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id, status: UploadIntentStatus.Consumed); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Conflict, result.FirstError.Type); } @@ -68,7 +79,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id, expiresAt: DateTime.UtcNow.AddMinutes(-1)); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type); } @@ -83,7 +94,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(TestConfigurations.UploadIntent(), intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } @@ -101,7 +112,7 @@ public sealed class UploadIntentValidatorTests : HandlerTestBase var intent = TestEntities.UploadIntent(owner.Id); await SeedAsync(owner, intent); - var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, TestContext.Current.CancellationToken); + var result = await CreateValidator().ValidateAndLoadAsync(config, intent.Id, owner.Id, UploadIntentPurpose.TrackPicture, TestContext.Current.CancellationToken); Assert.Equal(ErrorType.Validation, result.FirstError.Type); } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs index 76a1ea8..dda3226 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/CreateTrackCommandHandlerTests.cs @@ -35,6 +35,19 @@ public sealed class CreateTrackCommandHandlerTests : HandlerTestBase (TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackPicture, objectName: "cover.webp"), TestEntities.UploadIntent(userId, UploadIntentPurpose.TrackAudio, objectName: "song.mp3")); + [Fact] + public async Task Handle_SameIntentForPictureAndAudio_ReturnsValidationError() + { + var user = TestEntities.User(); + var (picture, audio) = SeedIntents(user.Id); + await SeedAsync(user, picture, audio); + + var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, picture.Id, [Genre.Pop]); + var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + [Fact] public async Task Handle_ValidIntents_CreatesTrackAndPublishesEvent() { diff --git a/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs deleted file mode 100644 index 1dd4718..0000000 --- a/backend/Tests/Musify.Application.Tests/Users/CreateUserCommandHandlerTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using ErrorOr; -using Musify.Application.Tests.TestSupport; -using Musify.Application.Users; -using Xunit; - -namespace Musify.Application.Tests.Users; - -public sealed class CreateUserCommandHandlerTests : HandlerTestBase -{ - private CreateUserCommandHandler CreateHandler() => new(Database, NoOpLogger()); - - [Fact] - public async Task Handle_NewId_CreatesUser() - { - var command = new CreateUserCommand(1, "Jane Doe", "Jane", "Doe"); - - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.False(result.IsError); - Assert.Equal("Jane Doe", result.Value.Name); - Assert.Equal(1, result.Value.Id); - - var stored = await Database.Users.FindAsync([1L], TestContext.Current.CancellationToken); - Assert.NotNull(stored); - Assert.Equal("JANE DOE", stored.NormalizedName); - } - - [Fact] - public async Task Handle_IdAlreadyExists_ReturnsConflict() - { - var existing = TestEntities.User(1); - await SeedAsync(existing); - - var command = new CreateUserCommand(1, "Someone Else", null, null); - - var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken); - - Assert.Equal(ErrorType.Conflict, result.FirstError.Type); - } -} diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs index 08c4381..caedf74 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs @@ -216,4 +216,37 @@ public sealed class TicketValidationMiddlewareTests : IDisposable Assert.True(wasNextCalled()); Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString()); } + + [Theory] + [InlineData("bytes=0-10,20-30")] + [InlineData("garbage")] + [InlineData("items=0-10")] + public async Task InvokeAsync_TicketWithMaxBytes_MultiOrInvalidRange_ReplacesItWithBoundedRange(string requestedRange) + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = requestedRange; + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); + } + + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_SuffixRange_ReturnsRangeNotSatisfiable() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken(maxBytes: 480_000)}"); + context.Request.Headers.Range = "bytes=-500"; + + await middleware.InvokeAsync(context); + + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); + } } diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 064a333..7fe2d25 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -279,24 +279,6 @@ export interface paths { patch?: never; trace?: never; }; - "/playlists": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Paginated PlayLists. */ - get: operations["GetPlayLists"]; - put?: never; - /** Create A New PlayList. */ - post: operations["CreatePlayList"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/playlists/{id}": { parameters: { query?: never; @@ -321,7 +303,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated PlayLists For A User. */ + /** Get Paginated PlayLists For A User. Private PlayLists Are Only Returned To Their Owner. */ get: operations["GetPlayListsByUserId"]; put?: never; post?: never; @@ -366,6 +348,23 @@ export interface paths { patch?: never; trace?: never; }; + "/playlists": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create A New PlayList. */ + post: operations["CreatePlayList"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/playlists/{playlistId}": { parameters: { query?: never; @@ -565,8 +564,7 @@ export interface paths { /** Get Paginated Users. */ get: operations["GetUsers"]; put?: never; - /** Create A New User. */ - post: operations["CreateUser"]; + post?: never; delete?: never; options?: never; head?: never; @@ -810,13 +808,6 @@ export interface components { /** @default false */ isExplicit: boolean; }; - CreateUserRequest: { - /** Format: int64 */ - id: number | string; - name: string; - firstName: null | string; - secondName: null | string; - }; /** @enum {unknown} */ Genre: "Pop" | "Rock" | "HipHop" | "RnB" | "Jazz" | "Blues" | "Classical" | "Electronic" | "House" | "Techno" | "Trance" | "Dubstep" | "DrumAndBass" | "Metal" | "Punk" | "Reggae" | "Reggaeton" | "Country" | "Folk" | "Indie" | "KPop" | "Latin" | "Soul" | "Funk" | "Ambient" | "Lofi"; GenreResponse: { @@ -914,19 +905,6 @@ export interface components { hasNextPage: boolean; hasPreviousPage: boolean; }; - PaginatedResponseOfUserApplicationResponse: { - items: components["schemas"]["UserApplicationResponse"][]; - /** Format: int32 */ - pageNumber: number | string; - /** Format: int32 */ - pageSize: number | string; - /** Format: int32 */ - pageCount: number | string; - /** Format: int32 */ - totalItemCount: number | string; - hasNextPage: boolean; - hasPreviousPage: boolean; - }; PlaybackPublicConfigResponse: { allowAnonymousListening: boolean; /** Format: int32 */ @@ -1729,76 +1707,6 @@ export interface operations { }; }; }; - GetPlayLists: { - parameters: { - query?: { - pageNumber?: number | string; - pageSize?: number | string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["PaginatedResponseOfPlayListApplicationResponse"]; - }; - }; - }; - }; - CreatePlayList: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreatePlayListRequest"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["PlayListApplicationResponse"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Not Found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; GetPlayListById: { parameters: { query?: never; @@ -1834,7 +1742,6 @@ export interface operations { name?: string; pageNumber?: number | string; pageSize?: number | string; - onlyPublic?: boolean; }; header?: never; path: { @@ -1963,6 +1870,53 @@ export interface operations { }; }; }; + CreatePlayList: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreatePlayListRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PlayListApplicationResponse"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; UpdatePlayList: { parameters: { query?: never; @@ -2493,46 +2447,6 @@ export interface operations { }; }; }; - CreateUser: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateUserRequest"]; - }; - }; - responses: { - /** @description Created */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["UserApplicationResponse"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; - }; - }; - /** @description Conflict */ - 409: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; GetUserById: { parameters: { query?: never; @@ -2611,6 +2525,20 @@ export interface operations { "application/json": components["schemas"]["ListeningStatsResponse"]; }; }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; GetLastTrackListenedByUserId: { diff --git a/web-player/src/routes/(app)/user/[id]/+page.server.ts b/web-player/src/routes/(app)/user/[id]/+page.server.ts index 9675eaa..2760ce2 100644 --- a/web-player/src/routes/(app)/user/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/user/[id]/+page.server.ts @@ -14,7 +14,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent api.GET('/playlists/users/{userId}', { params: { path: { userId: params.id }, - query: { pageNumber: 1, pageSize: PROFILE_PLAYLISTS_PAGE_SIZE, onlyPublic: true } + query: { pageNumber: 1, pageSize: PROFILE_PLAYLISTS_PAGE_SIZE } } }) ]);