diff --git a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs index 41e0185..d143bc7 100644 --- a/backend/Core/Musify.Application/ApplicationDependencyInjection.cs +++ b/backend/Core/Musify.Application/ApplicationDependencyInjection.cs @@ -20,6 +20,7 @@ public static class ApplicationDependencyInjection services.AddValidatedOptions(configuration, MixConfiguration.SectionName); services.AddValidatedOptions(configuration, StreamGatewayConfiguration.SectionName); services.AddValidatedOptions(configuration, UploadIntentConfiguration.SectionName); + services.AddValidatedOptions(configuration, PlaybackConfiguration.SectionName); return services; } diff --git a/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs b/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs new file mode 100644 index 0000000..a6c5219 --- /dev/null +++ b/backend/Core/Musify.Application/Configuration/PlaybackConfiguration.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Application.Configuration +{ + /// Controls whether listeners without a session can stream music at all, and if so, + /// how much of a track they get to hear before being cut off (a Spotify-style preview). + public sealed class PlaybackConfiguration + { + public const string SectionName = "Playback"; + + /// When false (the default), every stream endpoint requires a signed-in user, exactly + /// like before this setting existed. When true, an anonymous request is issued a stream ticket + /// too, capped by if that is greater than zero. + public bool AllowAnonymousListening { get; set; } + + /// How many seconds of a track an anonymous listener may hear, enforced by the + /// Streaming Gateway clamping the byte range it will serve for that ticket. Zero (the default) + /// means no cap: anonymous listeners hear the full track, same as a signed-in user. Ignored + /// when is false. + [Range(0, 3600)] + public int AnonymousFragmentSeconds { get; set; } + + /// Bytes per second of the transcoded audio, used to turn + /// into a byte cap for the stream ticket. Defaults to the app's own transcoder output (128 kbps + /// AAC, AudioTranscoder:Ffmpeg:AudioBitrate) — update this alongside that setting if it + /// ever changes, since the two aren't wired together. + [Range(1, int.MaxValue)] + public int EstimatedAudioBytesPerSecond { get; set; } = 16_000; + } +} diff --git a/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs b/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs index f72282b..083931c 100644 --- a/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs +++ b/backend/Core/Musify.Application/Contracts/IStreamTicketService.cs @@ -2,7 +2,12 @@ namespace Musify.Application.Contracts { public interface IStreamTicketService { - StreamTicket IssueTicket(long userId, string keyPrefix); + /// Null for an anonymous listener — the ticket is issued without a + /// sub claim. + /// The storage key (prefix) the ticket authorizes reading from. + /// When set, the Streaming Gateway clamps the byte range it will serve + /// for this ticket to [0, maxBytes) — used for the anonymous preview fragment. + StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null); } public record StreamTicket(string Token, int ExpiresInSeconds); diff --git a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs index db08450..e3986e5 100644 --- a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs +++ b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs @@ -9,18 +9,20 @@ public sealed class TrackStreamIssuer( IDatabase database, IStreamTicketService ticketService, TrackConfiguration trackConfiguration, - StreamGatewayConfiguration streamGatewayConfiguration) + StreamGatewayConfiguration streamGatewayConfiguration, + PlaybackConfiguration playbackConfiguration) { public async Task IssueAsync( Guid trackId, string audioFolderName, - long userId, + long? userId, CancellationToken cancellationToken) { var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName); var keyPrefix = $"{folderPath}/"; - var ticket = ticketService.IssueTicket(userId, keyPrefix); + var maxBytes = AnonymousFragmentBytes(userId); + var ticket = ticketService.IssueTicket(userId, keyPrefix, maxBytes); var manifestUrl = string.Join('/', streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), @@ -28,14 +30,27 @@ public sealed class TrackStreamIssuer( folderPath, streamGatewayConfiguration.AudioFileName); - var newListeningHistory = new ListeningHistory + // Anonymous listeners have no User row to satisfy ListeningHistory's foreign key against, and + // their plays aren't "history" anyone can look back on anyway, so there's nothing to record. + if (userId is not null) { - UserId = userId, - TrackId = trackId, - }; - await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken); - await database.SaveChangesAsync(cancellationToken); + var newListeningHistory = new ListeningHistory + { + UserId = userId.Value, + TrackId = trackId, + }; + await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + } return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds); } + + private long? AnonymousFragmentBytes(long? userId) + { + if (userId is not null || playbackConfiguration.AnonymousFragmentSeconds <= 0) + return null; + + return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond; + } } diff --git a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs index 6643033..e0da954 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackStream.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackStream.cs @@ -2,23 +2,30 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Application.Tracks.Responses; namespace Musify.Application.Tracks { - public record GetTrackStreamQuery(Guid TrackId, long UserId) + /// Null for an anonymous request — allowed only when + /// is enabled. + public record GetTrackStreamQuery(Guid TrackId, long? UserId) : IQuery>; public class GetTrackStreamQueryHandler( IDatabase database, TrackStreamIssuer streamIssuer, + PlaybackConfiguration playbackConfiguration, ILogger logger) : IQueryHandler> { public async ValueTask> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken) { + if (request.UserId is null && !playbackConfiguration.AllowAnonymousListening) + return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening."); + var track = await database.Tracks.AsNoTracking() .Where(t => t.Id == request.TrackId) .Select(t => new { t.Id, t.Audio }) @@ -35,7 +42,7 @@ namespace Musify.Application.Tracks var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken); - logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId); + logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString() ?? "(anonymous)"); return response; } diff --git a/backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs b/backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs index 2967a33..8ee269c 100644 --- a/backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs +++ b/backend/Core/Musify.Application/YouTube/ResolveYouTubeTrackStream.cs @@ -2,6 +2,7 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Application.YouTube.Responses; @@ -9,7 +10,9 @@ using Musify.Domain.Entities; namespace Musify.Application.YouTube { - public record ResolveYouTubeTrackStreamCommand(string VideoId, long UserId) + /// Null for an anonymous request — allowed only when + /// is enabled. + public record ResolveYouTubeTrackStreamCommand(string VideoId, long? UserId) : ICommand>; public class ResolveYouTubeTrackStreamCommandHandler( @@ -17,14 +20,19 @@ namespace Musify.Application.YouTube IYouTubeMusicService youTubeMusicService, YouTubeTrackProvisioner provisioner, TrackStreamIssuer streamIssuer, + PlaybackConfiguration playbackConfiguration, ILogger logger) : ICommandHandler> { public async ValueTask> Handle(ResolveYouTubeTrackStreamCommand request, CancellationToken cancellationToken) { + var isAnonymous = request.UserId is null; + if (isAnonymous && !playbackConfiguration.AllowAnonymousListening) + return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening."); + var track = await provisioner.FindAsync(request.VideoId, cancellationToken); - if (track != null&& track.Audio.IsProcessed) + if (track != null && track.Audio.IsProcessed) { var serverStream = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken); return new YouTubeStreamResponse( @@ -35,6 +43,13 @@ namespace Musify.Application.YouTube serverStream.ExpiresInSeconds); } + // Below this point the only option left is streaming straight from YouTube's own CDN, + // which bypasses our Gateway (and its stream tickets) entirely — there's no way to cap that + // to the configured anonymous fragment, so an anonymous listener simply doesn't get it: they + // can retry once the track has been provisioned server-side, where the cap above applies. + if (isAnonymous && playbackConfiguration.AnonymousFragmentSeconds > 0) + return Error.Conflict(description: "This track isn't available for anonymous preview yet. Try again in a moment."); + var streamInfo = await youTubeMusicService.GetAudioStreamAsync(request.VideoId, cancellationToken); if (streamInfo.IsError) return streamInfo.Errors; @@ -48,11 +63,11 @@ namespace Musify.Application.YouTube logger.LogWarning("Could not provision YouTube track {VideoId}: {Error}", request.VideoId, provisionResult.FirstError.Description); } - if (track != null) + if (track != null && request.UserId is not null) { await database.ListeningHistories.AddAsync(new ListeningHistory { - UserId = request.UserId, + UserId = request.UserId.Value, TrackId = track.Id, }, cancellationToken); diff --git a/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs b/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs index 10f26d4..70454de 100644 --- a/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs +++ b/backend/Core/Musify.Infrastructure/Services/StreamTicketService.cs @@ -24,10 +24,16 @@ namespace Musify.Infrastructure.Services signingCredentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); } - public StreamTicket IssueTicket(long userId, string keyPrefix) + public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null) { var now = DateTimeOffset.UtcNow; + var claims = new Dictionary { ["prefix"] = keyPrefix }; + if (userId is not null) + claims["sub"] = userId.Value.ToString(); + if (maxBytes is not null) + claims["maxBytes"] = maxBytes.Value.ToString(); + var descriptor = new SecurityTokenDescriptor { Issuer = configuration.Issuer, @@ -35,11 +41,7 @@ namespace Musify.Infrastructure.Services IssuedAt = now.UtcDateTime, NotBefore = now.UtcDateTime, Expires = now.AddSeconds(configuration.TicketTtlSeconds).UtcDateTime, - Claims = new Dictionary - { - ["sub"] = userId.ToString(), - ["prefix"] = keyPrefix, - }, + Claims = claims, SigningCredentials = signingCredentials, }; diff --git a/backend/Hosts/Musify.Api/AppSettings.Development.json b/backend/Hosts/Musify.Api/AppSettings.Development.json index 89f8ab0..e81392b 100644 --- a/backend/Hosts/Musify.Api/AppSettings.Development.json +++ b/backend/Hosts/Musify.Api/AppSettings.Development.json @@ -31,7 +31,7 @@ "PublicBaseUrl": "http://localhost:8081" }, "StreamTicket": { - "PrivateKeyPath": "../../deploy/keys/stream_private.pem" + "PrivateKeyPath": "../../../deploy/keys/stream_private.pem" }, "OpenTelemetry": { "OtlpEndpoint": "http://localhost:4317" @@ -41,7 +41,7 @@ }, "Workers": { "Routes": { - "TemporaryFilesDirectory": "../../deploy/.tmp" + "TemporaryFilesDirectory": "../../../deploy/.tmp" } } } diff --git a/backend/Hosts/Musify.Api/AppSettings.json b/backend/Hosts/Musify.Api/AppSettings.json index 217bf14..4378189 100644 --- a/backend/Hosts/Musify.Api/AppSettings.json +++ b/backend/Hosts/Musify.Api/AppSettings.json @@ -48,6 +48,11 @@ "OpenTelemetry": { "OtlpEndpoint": "" }, + "Playback": { + "AllowAnonymousListening": false, + "AnonymousFragmentSeconds": 0, + "EstimatedAudioBytesPerSecond": 16000 + }, "YouTube": { "Enabled": true, "GeographicalLocation": "US", diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index 25ef92e..c506f75 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -36,8 +36,7 @@ public static class TrackEndpoints group.MapGet("/{id}/stream", GetTrackStream) .WithName("GetTrackStream") - .WithSummary("Get A Streaming Manifest URL And Ticket For A Track.") - .RequireAuthorization() + .WithSummary("Get A Streaming Manifest URL And Ticket For A Track. Works Anonymously When The Playback Configuration Allows It.") .Produces() .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); @@ -126,7 +125,7 @@ public static class TrackEndpoints Guid id, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.RequiredId), cancellationToken); + var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.Id), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Hosts/Musify.Api/Endpoints/YouTubeEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/YouTubeEndpoints.cs index cf4b901..1a326a2 100644 --- a/backend/Hosts/Musify.Api/Endpoints/YouTubeEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/YouTubeEndpoints.cs @@ -32,11 +32,11 @@ public static class YouTubeEndpoints group.MapGet("/tracks/{videoId}/stream", GetTrackStream) .WithName("GetYouTubeTrackStream") - .WithSummary("Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise.") - .RequireAuthorization() + .WithSummary("Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise. Works Anonymously When The Playback Configuration Allows It.") .Produces() .Produces(StatusCodes.Status401Unauthorized) - .Produces(StatusCodes.Status404NotFound); + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status409Conflict); return app; } @@ -48,7 +48,7 @@ public static class YouTubeEndpoints CancellationToken cancellationToken) { var result = await mediator.Send( - new ResolveYouTubeTrackStreamCommand(videoId, currentUser.RequiredId), + new ResolveYouTubeTrackStreamCommand(videoId, currentUser.Id), cancellationToken); return result.ToHttpResult(); diff --git a/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs b/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs index 765f057..8e0689f 100644 --- a/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs +++ b/backend/Hosts/Musify.StreamingGateway/Authentication/TicketValidator.cs @@ -6,6 +6,17 @@ using Musify.StreamingGateway.Configuration; namespace Musify.StreamingGateway.Authentication; +/// The storage key prefix the ticket authorizes reading from. Empty means the +/// ticket was missing, malformed, expired, or otherwise invalid. +/// When set, the caller is limited to the first MaxBytes bytes of the +/// object — the anonymous preview fragment cap carried by the maxBytes claim. +public sealed record TicketValidationResult(string Prefix, long? MaxBytes) +{ + public static readonly TicketValidationResult Invalid = new(string.Empty, null); + + public bool IsValid => Prefix.Length > 0; +} + public sealed class TicketValidator : IDisposable { private readonly StreamTicketValidationOptions _options; @@ -34,17 +45,23 @@ public sealed class TicketValidator : IDisposable }; } - public async Task ValidateTicketAsync(string? token) + public async Task ValidateTicketAsync(string? token) { if (string.IsNullOrWhiteSpace(token)) - return string.Empty; + return TicketValidationResult.Invalid; var result = await _handler.ValidateTokenAsync(token, _validationParameters); if (!result.IsValid) - return string.Empty; + return TicketValidationResult.Invalid; var prefix = result.ClaimsIdentity?.FindFirst("prefix")?.Value; - return string.IsNullOrEmpty(prefix)? string.Empty : prefix; + if (string.IsNullOrEmpty(prefix)) + return TicketValidationResult.Invalid; + + var maxBytesClaim = result.ClaimsIdentity?.FindFirst("maxBytes")?.Value; + var maxBytes = long.TryParse(maxBytesClaim, out var parsed) ? parsed : (long?)null; + + return new TicketValidationResult(prefix, maxBytes); } public void Dispose() => _rsa.Dispose(); diff --git a/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs index 39df49a..35047ec 100644 --- a/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs +++ b/backend/Hosts/Musify.StreamingGateway/Middleware/TicketValidationMiddleware.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; using Musify.StreamingGateway.Authentication; using Musify.StreamingGateway.Configuration; @@ -28,15 +30,16 @@ public sealed class TicketValidationMiddleware( token = context.Request.Headers[options.HeaderName].ToString(); } - var prefix = await ticketValidator.ValidateTicketAsync(token); + var validation = await ticketValidator.ValidateTicketAsync(token); - if (string.IsNullOrEmpty(prefix)) + if (!validation.IsValid) { logger.LogWarning("Rejected media request {Path}: missing or invalid ticket", path); context.Response.StatusCode = StatusCodes.Status401Unauthorized; return; } + var prefix = validation.Prefix; var objectKey = remaining.Value?.TrimStart('/') ?? string.Empty; if (HasTraversalSegment(objectKey)) @@ -55,10 +58,48 @@ public sealed class TicketValidationMiddleware( return; } + if (validation.MaxBytes is { } maxBytes && !TryClampRange(context, maxBytes)) + { + logger.LogInformation( + "Rejected media request {ObjectKey}: requested range starts beyond the {MaxBytes}-byte preview limit", + objectKey, maxBytes); + context.Response.StatusCode = StatusCodes.Status416RangeNotSatisfiable; + context.Response.Headers[HeaderNames.ContentRange] = $"bytes */{maxBytes}"; + return; + } + StripQueryParameter(context, options.QueryParameterName); await next(context); } + /// Rewrites the request's Range header so nothing past + /// is ever asked of the filer — this is what actually enforces the anonymous preview length, + /// downstream of the ticket carrying that cap. A request with no Range header (a plain, + /// whole-file GET) gets one synthesized, so the filer always responds with a bounded 206 instead of + /// streaming the full object. Returns false when the requested range starts at or past the cap, in + /// which case the caller should answer 416 directly instead of proxying anything. + private static bool TryClampRange(HttpContext context, long maxBytes) + { + var rangeHeader = context.Request.Headers.Range; + if (StringValues.IsNullOrEmpty(rangeHeader)) + { + context.Request.Headers.Range = $"bytes=0-{maxBytes - 1}"; + return true; + } + + if (!RangeHeaderValue.TryParse(rangeHeader.ToString(), out var range) || range.Ranges.Count != 1) + return true; + + var requested = range.Ranges.Single(); + var start = requested.From ?? 0; + if (start >= maxBytes) + return false; + + var end = requested.To is { } requestedEnd ? Math.Min(requestedEnd, maxBytes - 1) : maxBytes - 1; + context.Request.Headers.Range = $"bytes={start}-{end}"; + return true; + } + private static bool HasTraversalSegment(string objectKey) => objectKey.Split('/').Any(segment => segment is ".." or "."); diff --git a/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json b/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json index 7000da9..0bac6c4 100644 --- a/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json +++ b/backend/Hosts/Musify.StreamingGateway/appsettings.Development.json @@ -1,5 +1,5 @@ { "StreamTicket": { - "PublicKeyPath": "../../deploy/keys/stream_public.pem" + "PublicKeyPath": "../../../deploy/keys/stream_public.pem" } } diff --git a/backend/Hosts/Musify.Worker/AppSettings.Development.json b/backend/Hosts/Musify.Worker/AppSettings.Development.json index fc1cf37..4b42d48 100644 --- a/backend/Hosts/Musify.Worker/AppSettings.Development.json +++ b/backend/Hosts/Musify.Worker/AppSettings.Development.json @@ -31,12 +31,12 @@ }, "AudioTranscoder": { "Routes": { - "WorkingDirectory": "../../deploy/.tmp" + "WorkingDirectory": "../../../deploy/.tmp" } }, "Workers": { "Routes": { - "TemporaryFilesDirectory": "../../deploy/.tmp" + "TemporaryFilesDirectory": "../../../deploy/.tmp" } } } diff --git a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs index 91dd417..b421bb9 100644 --- a/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Services/TrackStreamIssuerTests.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Application.Tests.TestSupport; @@ -11,13 +12,17 @@ namespace Musify.Application.Tests.Services { private readonly IStreamTicketService ticketService = Substitute.For(); - private TrackStreamIssuer CreateIssuer() => new( - Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway("https://stream.musify.test/")); + private TrackStreamIssuer CreateIssuer(PlaybackConfiguration? playback = null) => new( + Database, + ticketService, + TestConfigurations.Track(), + TestConfigurations.StreamGateway("https://stream.musify.test/"), + playback ?? TestConfigurations.Playback()); [Fact] public async Task IssueAsync_BuildsManifestUrlAndRecordsListeningHistory() { - ticketService.IssueTicket(7, Arg.Any()).Returns(new StreamTicket("signed-token", 120)); + ticketService.IssueTicket(7, Arg.Any(), Arg.Any()).Returns(new StreamTicket("signed-token", 120)); var owner = TestEntities.User(7); var track = TestEntities.LocalTrack(owner); await SeedAsync(owner, track); @@ -38,14 +43,57 @@ namespace Musify.Application.Tests.Services [Fact] public async Task IssueAsync_PassesTheProcessedAudioKeyPrefixToTheTicketService() { - ticketService.IssueTicket(Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); var owner = TestEntities.User(1); var track = TestEntities.LocalTrack(owner); await SeedAsync(owner, track); await CreateIssuer().IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); - ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/"); + ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); + } + + [Fact] + public async Task IssueAsync_AnonymousUser_SkipsListeningHistoryAndNeverThrows() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + var owner = TestEntities.User(1); + var track = TestEntities.LocalTrack(owner); + await SeedAsync(owner, track); + + var response = await CreateIssuer(TestConfigurations.Playback(allowAnonymousListening: true)) + .IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken); + + Assert.Equal("token", response.Ticket); + Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task IssueAsync_AnonymousUserWithFragmentConfigured_PassesTheByteCapToTheTicketService() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + var owner = TestEntities.User(1); + var track = TestEntities.LocalTrack(owner); + await SeedAsync(owner, track); + + var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: null, TestContext.Current.CancellationToken); + + ticketService.Received(1).IssueTicket(null, "Tracks/ProcessedAudios/folder-42/", 30L * playback.EstimatedAudioBytesPerSecond); + } + + [Fact] + public async Task IssueAsync_AuthenticatedUser_IgnoresTheAnonymousFragmentCap() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + var owner = TestEntities.User(1); + var track = TestEntities.LocalTrack(owner); + await SeedAsync(owner, track); + + var playback = TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30); + await CreateIssuer(playback).IssueAsync(track.Id, "folder-42", userId: 1, TestContext.Current.CancellationToken); + + ticketService.Received(1).IssueTicket(1, "Tracks/ProcessedAudios/folder-42/", null); } } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs index 3fedba1..8365163 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestConfigurations.cs @@ -17,5 +17,8 @@ namespace Musify.Application.Tests.TestSupport public static StreamGatewayConfiguration StreamGateway(string publicBaseUrl = "https://stream.musify.test") => new() { PublicBaseUrl = publicBaseUrl }; + + public static PlaybackConfiguration Playback(bool allowAnonymousListening = false, int anonymousFragmentSeconds = 0) => + new() { AllowAnonymousListening = allowAnonymousListening, AnonymousFragmentSeconds = anonymousFragmentSeconds }; } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs index ccfdc4b..b3f30de 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs @@ -1,5 +1,6 @@ using ErrorOr; using Microsoft.EntityFrameworkCore; +using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Application.Tests.TestSupport; @@ -13,17 +14,18 @@ namespace Musify.Application.Tests.Tracks { private readonly IStreamTicketService ticketService = Substitute.For(); - private GetTrackStreamQueryHandler CreateHandler() + private GetTrackStreamQueryHandler CreateHandler(PlaybackConfiguration? playback = null) { - ticketService.IssueTicket(Arg.Any(), Arg.Any()).Returns(new StreamTicket("ticket-token", 60)); + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("ticket-token", 60)); var issuer = new TrackStreamIssuer( Database, ticketService, TestConfigurations.Track(), - TestConfigurations.StreamGateway("https://stream.musify.test")); + TestConfigurations.StreamGateway("https://stream.musify.test"), + playback ?? TestConfigurations.Playback()); - return new GetTrackStreamQueryHandler(Database, issuer, NoOpLogger()); + return new GetTrackStreamQueryHandler(Database, issuer, playback ?? TestConfigurations.Playback(), NoOpLogger()); } [Fact] @@ -64,5 +66,35 @@ namespace Musify.Application.Tests.Tracks Assert.Equal(ErrorType.Conflict, result.FirstError.Type); } + + [Fact] + public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized() + { + var owner = TestEntities.User(); + var track = TestEntities.LocalTrack(owner); + await SeedAsync(owner, track); + + var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); + + var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + } + + [Fact] + public async Task Handle_AnonymousUser_AnonymousListeningEnabled_IssuesTicketWithoutRecordingHistory() + { + var owner = TestEntities.User(); + var track = TestEntities.LocalTrack(owner); + await SeedAsync(owner, track); + + var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); + + var result = await handler.Handle(new GetTrackStreamQuery(track.Id, UserId: null), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal("ticket-token", result.Value.Ticket); + Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs index 300f64b..b47cbc5 100644 --- a/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/YouTube/ResolveYouTubeTrackStreamCommandHandlerTests.cs @@ -1,4 +1,6 @@ +using ErrorOr; using Microsoft.EntityFrameworkCore; +using Musify.Application.Configuration; using Musify.Application.Contracts; using Musify.Application.Services; using Musify.Application.Tests.TestSupport; @@ -14,21 +16,22 @@ namespace Musify.Application.Tests.YouTube private readonly IYouTubeMusicService youTubeMusicService = Substitute.For(); private readonly IStreamTicketService ticketService = Substitute.For(); - private ResolveYouTubeTrackStreamCommandHandler CreateHandler() + private ResolveYouTubeTrackStreamCommandHandler CreateHandler(PlaybackConfiguration? playback = null) { + playback ??= TestConfigurations.Playback(); var provisioner = new YouTubeTrackProvisioner( Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger()); var issuer = new TrackStreamIssuer( - Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway()); + Database, ticketService, TestConfigurations.Track(), TestConfigurations.StreamGateway(), playback); return new ResolveYouTubeTrackStreamCommandHandler( - Database, youTubeMusicService, provisioner, issuer, NoOpLogger()); + Database, youTubeMusicService, provisioner, issuer, playback, NoOpLogger()); } [Fact] public async Task Handle_AlreadyProcessedTrack_IssuesServerStreamWithoutCallingYouTubeForAudio() { - ticketService.IssueTicket(Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); var user = TestEntities.User(1); var track = TestEntities.ExternalTrack(externalId: "abc123"); @@ -62,5 +65,61 @@ namespace Musify.Application.Tests.YouTube var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); Assert.Equal(42, history.UserId); } + + [Fact] + public async Task Handle_AnonymousUser_AnonymousListeningDisabled_ReturnsUnauthorized() + { + var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: false)); + + var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("abc123", UserId: null), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); + await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Handle_AnonymousUser_AlreadyProcessedTrack_IssuesServerStreamWithoutRecordingHistory() + { + ticketService.IssueTicket(Arg.Any(), Arg.Any(), Arg.Any()).Returns(new StreamTicket("token", 60)); + var track = TestEntities.ExternalTrack(externalId: "abc123"); + await SeedAsync(track); + + var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true)); + + var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("abc123", UserId: null), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(YouTubeStreamMode.Server, result.Value.Mode); + Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Handle_AnonymousUser_FragmentCapConfigured_UnprovisionedTrack_ReturnsConflictWithoutCallingYouTube() + { + var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 30)); + + var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("not-provisioned-yet", UserId: null), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Conflict, result.FirstError.Type); + await youTubeMusicService.DidNotReceiveWithAnyArgs().GetAudioStreamAsync(default!, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Handle_AnonymousUser_NoFragmentCap_UnprovisionedTrack_FallsBackToDirectYouTubeStream() + { + youTubeMusicService.GetAudioStreamAsync("new-video", Arg.Any()) + .Returns(new YouTubeStreamInfo("https://youtube.example/stream", 300)); + youTubeMusicService.GetSongAsync("new-video", Arg.Any()) + .Returns(new YouTubeSongResult("new-video", "Title", "Artist", "Album", 200, "https://img/thumb.jpg", false, [])); + youTubeMusicService.ResolveArtworkUrl(Arg.Any()).Returns(callInfo => callInfo.Arg()); + + var handler = CreateHandler(TestConfigurations.Playback(allowAnonymousListening: true, anonymousFragmentSeconds: 0)); + + var result = await handler.Handle(new ResolveYouTubeTrackStreamCommand("new-video", UserId: null), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(YouTubeStreamMode.YouTube, result.Value.Mode); + Assert.Empty(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); + } } } diff --git a/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs b/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs index f233fd3..d16e298 100644 --- a/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs +++ b/backend/Tests/Musify.Infrastructure.Tests/Services/StreamTicketServiceTests.cs @@ -45,6 +45,43 @@ namespace Musify.Infrastructure.Tests.Services Assert.Contains("media-gateway", jwt.Audiences); } + [Fact] + public void IssueTicket_AnonymousUser_OmitsTheSubClaim() + { + using var service = CreateService(); + + var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/"); + + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken(ticket.Token); + Assert.DoesNotContain(jwt.Claims, c => c.Type == "sub"); + Assert.Equal("Tracks/ProcessedAudios/abc/", jwt.GetClaim("prefix").Value); + } + + [Fact] + public void IssueTicket_WithMaxBytes_IncludesTheMaxBytesClaim() + { + using var service = CreateService(); + + var ticket = service.IssueTicket(userId: null, keyPrefix: "Tracks/ProcessedAudios/abc/", maxBytes: 480_000); + + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken(ticket.Token); + Assert.Equal("480000", jwt.GetClaim("maxBytes").Value); + } + + [Fact] + public void IssueTicket_WithoutMaxBytes_OmitsTheMaxBytesClaim() + { + using var service = CreateService(); + + var ticket = service.IssueTicket(userId: 1, keyPrefix: "Tracks/ProcessedAudios/abc/"); + + var handler = new JsonWebTokenHandler(); + var jwt = handler.ReadJsonWebToken(ticket.Token); + Assert.DoesNotContain(jwt.Claims, c => c.Type == "maxBytes"); + } + [Fact] public async Task IssueTicket_TokenValidatesAgainstTheMatchingPublicKey() { diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs index d907ed4..c84b094 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Authentication/TicketValidatorTests.cs @@ -33,15 +33,19 @@ namespace Musify.StreamingGateway.Tests.Authentication })); private string IssueToken(string prefix = "Tracks/ProcessedAudios/abc/", string issuer = Issuer, string audience = Audience, - DateTime? expires = null, DateTime? notBefore = null) + DateTime? expires = null, DateTime? notBefore = null, long? maxBytes = null) { + var claims = new Dictionary { ["prefix"] = prefix }; + if (maxBytes is not null) + claims["maxBytes"] = maxBytes.Value.ToString(); + 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 }, + Claims = claims, SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), }; return tokenHandler.CreateToken(descriptor); @@ -52,62 +56,76 @@ namespace Musify.StreamingGateway.Tests.Authentication { var token = IssueToken(prefix: "Tracks/ProcessedAudios/xyz/"); - var prefix = await CreateValidator().ValidateTicketAsync(token); + var result = await CreateValidator().ValidateTicketAsync(token); - Assert.Equal("Tracks/ProcessedAudios/xyz/", prefix); + Assert.True(result.IsValid); + Assert.Equal("Tracks/ProcessedAudios/xyz/", result.Prefix); + Assert.Null(result.MaxBytes); + } + + [Fact] + public async Task ValidateTicketAsync_TokenWithMaxBytesClaim_ReturnsIt() + { + var token = IssueToken(maxBytes: 480_000); + + var result = await CreateValidator().ValidateTicketAsync(token); + + Assert.True(result.IsValid); + Assert.Equal(480_000, result.MaxBytes); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] - public async Task ValidateTicketAsync_BlankToken_ReturnsEmptyString(string? token) + public async Task ValidateTicketAsync_BlankToken_ReturnsInvalid(string? token) { - var prefix = await CreateValidator().ValidateTicketAsync(token); + var result = await CreateValidator().ValidateTicketAsync(token); - Assert.Equal(string.Empty, prefix); + Assert.False(result.IsValid); + Assert.Equal(string.Empty, result.Prefix); } [Fact] - public async Task ValidateTicketAsync_MalformedToken_ReturnsEmptyString() + public async Task ValidateTicketAsync_MalformedToken_ReturnsInvalid() { - var prefix = await CreateValidator().ValidateTicketAsync("not-a-real-jwt"); + var result = await CreateValidator().ValidateTicketAsync("not-a-real-jwt"); - Assert.Equal(string.Empty, prefix); + Assert.False(result.IsValid); } [Fact] - public async Task ValidateTicketAsync_ExpiredToken_ReturnsEmptyString() + public async Task ValidateTicketAsync_ExpiredToken_ReturnsInvalid() { var token = IssueToken(expires: DateTime.UtcNow.AddMinutes(-40), notBefore: DateTime.UtcNow.AddMinutes(-50)); - var prefix = await CreateValidator().ValidateTicketAsync(token); + var result = await CreateValidator().ValidateTicketAsync(token); - Assert.Equal(string.Empty, prefix); + Assert.False(result.IsValid); } [Fact] - public async Task ValidateTicketAsync_WrongIssuer_ReturnsEmptyString() + public async Task ValidateTicketAsync_WrongIssuer_ReturnsInvalid() { var token = IssueToken(issuer: "someone-else"); - var prefix = await CreateValidator().ValidateTicketAsync(token); + var result = await CreateValidator().ValidateTicketAsync(token); - Assert.Equal(string.Empty, prefix); + Assert.False(result.IsValid); } [Fact] - public async Task ValidateTicketAsync_WrongAudience_ReturnsEmptyString() + public async Task ValidateTicketAsync_WrongAudience_ReturnsInvalid() { var token = IssueToken(audience: "someone-else"); - var prefix = await CreateValidator().ValidateTicketAsync(token); + var result = await CreateValidator().ValidateTicketAsync(token); - Assert.Equal(string.Empty, prefix); + Assert.False(result.IsValid); } [Fact] - public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsEmptyString() + public async Task ValidateTicketAsync_SignedWithADifferentKey_ReturnsInvalid() { using var otherKey = RSA.Create(2048); var descriptor = new SecurityTokenDescriptor @@ -121,13 +139,13 @@ namespace Musify.StreamingGateway.Tests.Authentication }; var token = tokenHandler.CreateToken(descriptor); - var prefix = await CreateValidator().ValidateTicketAsync(token); + var result = await CreateValidator().ValidateTicketAsync(token); - Assert.Equal(string.Empty, prefix); + Assert.False(result.IsValid); } [Fact] - public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsEmptyString() + public async Task ValidateTicketAsync_MissingPrefixClaim_ReturnsInvalid() { var descriptor = new SecurityTokenDescriptor { @@ -139,9 +157,9 @@ namespace Musify.StreamingGateway.Tests.Authentication }; var token = tokenHandler.CreateToken(descriptor); - var prefix = await CreateValidator().ValidateTicketAsync(token); + var result = await CreateValidator().ValidateTicketAsync(token); - Assert.Equal(string.Empty, prefix); + Assert.False(result.IsValid); } } } diff --git a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs index 00bebdc..fb8ab99 100644 --- a/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs +++ b/backend/Tests/Musify.StreamingGateway.Tests/Middleware/TicketValidationMiddlewareTests.cs @@ -36,15 +36,22 @@ namespace Musify.StreamingGateway.Tests.Middleware File.Delete(publicKeyPath); } - private string IssueValidToken(string prefix = Prefix) => new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + private string IssueValidToken(string prefix = Prefix, long? maxBytes = null) { - 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), - }); + var claims = new Dictionary { ["prefix"] = prefix }; + if (maxBytes is not null) + claims["maxBytes"] = maxBytes.Value.ToString(); + + return new JsonWebTokenHandler().CreateToken(new SecurityTokenDescriptor + { + Issuer = options.Issuer, + Audience = options.Audience, + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(5), + Claims = claims, + SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256), + }); + } private (TicketValidationMiddleware Middleware, Func WasNextCalled) CreateMiddleware() { @@ -150,5 +157,64 @@ namespace Musify.StreamingGateway.Tests.Middleware Assert.True(wasNextCalled()); } + + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_NoRangeRequested_SynthesizesABoundedRange() + { + 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)}"); + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + Assert.Equal("bytes=0-479999", context.Request.Headers.Range.ToString()); + } + + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_RangeWithinCap_ClampsTheEndOnly() + { + 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=1000-"; + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + Assert.Equal("bytes=1000-479999", context.Request.Headers.Range.ToString()); + } + + [Fact] + public async Task InvokeAsync_TicketWithMaxBytes_RangeStartsAtOrPastTheCap_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=480000-"; + + await middleware.InvokeAsync(context); + + Assert.False(wasNextCalled()); + Assert.Equal(StatusCodes.Status416RangeNotSatisfiable, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_TicketWithoutMaxBytes_LeavesTheRequestedRangeUntouched() + { + var (middleware, wasNextCalled) = CreateMiddleware(); + var context = new DefaultHttpContext(); + context.Request.Path = $"/media/{Prefix}/audio.m4a"; + context.Request.QueryString = new QueryString($"?t={IssueValidToken()}"); + context.Request.Headers.Range = "bytes=1000-2000"; + + await middleware.InvokeAsync(context); + + Assert.True(wasNextCalled()); + Assert.Equal("bytes=1000-2000", context.Request.Headers.Range.ToString()); + } } }