diff --git a/ErsatzTV.Application/Channels/Commands/ChannelValidations.cs b/ErsatzTV.Application/Channels/Commands/ChannelValidations.cs new file mode 100644 index 000000000..2aaf5745f --- /dev/null +++ b/ErsatzTV.Application/Channels/Commands/ChannelValidations.cs @@ -0,0 +1,60 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Channels; + +/// +/// Validation rules shared by and +/// . These were previously enforced only by the Blazor page +/// (ChannelEditViewModelValidator); porting them into the handlers makes them apply to +/// the REST API as well. Failures map to HTTP 422. +/// +internal static class ChannelValidations +{ + /// + /// A channel must belong to a non-empty group; the value is used as the M3U + /// group-title. Mirrors the Blazor rule RuleFor(x => x.Group).NotEmpty(). + /// + internal static Validation ValidateGroup(string group) + { + // Use explicit returns (not a ternary): BaseError has an implicit string conversion, so a + // ternary would collapse both branches to BaseError and always produce a Fail. + if (string.IsNullOrWhiteSpace(group)) + { + return BaseError.New("Channel group is required"); + } + + return group; + } + + /// A disabled channel may not be shown in the EPG. + internal static Validation ValidateShowInEpg(bool isEnabled, bool showInEpg) + { + if (!isEnabled && showInEpg) + { + return BaseError.New("Disabled channels cannot be shown in EPG"); + } + + return showInEpg; + } + + /// + /// A logo path that is an absolute URI must be a valid external (http/https) url. + /// Relative/local logo paths and empty values are allowed. + /// + internal static Validation ValidateLogo(string logoPath) + { + if (string.IsNullOrWhiteSpace(logoPath)) + { + return string.Empty; + } + + bool isAbsoluteUri = Uri.TryCreate(logoPath, UriKind.Absolute, out _); + if (isAbsoluteUri && !Artwork.IsExternalUrl(logoPath)) + { + return BaseError.New("External logo url is invalid"); + } + + return logoPath; + } +} diff --git a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs index dac4b9b56..98909e1be 100644 --- a/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs @@ -8,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.Channels.ChannelValidations; using Channel = ErsatzTV.Core.Domain.Channel; namespace ErsatzTV.Application.Channels; @@ -36,18 +37,23 @@ public class CreateChannelHandler( return new CreateChannelResult(channel.Id); } - private static async Task> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken) => - (ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken), + private static async Task> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken) + { + Validation channelValidation = (ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken), await FFmpegProfileMustExist(dbContext, request, cancellationToken), await WatermarkMustExist(dbContext, request, cancellationToken), await FillerPresetMustExist(dbContext, request, cancellationToken), - await MirrorSourceMustBeValid(dbContext, request, cancellationToken)) + await MirrorSourceMustBeValid(dbContext, request, cancellationToken), + ValidateShowInEpg(request.IsEnabled, request.ShowInEpg), + ValidateLogo(request.Logo?.Path)) .Apply(( name, number, ffmpegProfileId, watermarkId, fillerPresetId, + _, + _, _) => { var artwork = new List(); @@ -125,6 +131,11 @@ public class CreateChannelHandler( return channel; }); + // combine the page-only Group rule with the channel validation (keeps tuple arity within + // LanguageExt's supported applicative range while still accumulating all errors) + return (ValidateGroup(request.Group), channelValidation).Apply((_, channel) => channel); + } + private static Validation ValidateName(CreateChannel createChannel) => createChannel.NotEmpty(c => c.Name) .Bind(_ => createChannel.NotLongerThan(50)(c => c.Name)); @@ -149,16 +160,20 @@ public class CreateChannelHandler( }); } - private static Task> FFmpegProfileMustExist( + private static async Task> FFmpegProfileMustExist( TvContext dbContext, CreateChannel createChannel, - CancellationToken cancellationToken) => - dbContext.FFmpegProfiles - .CountAsync(p => p.Id == createChannel.FFmpegProfileId, cancellationToken) - .Map(Optional) - .Filter(c => c > 0) - .MapT(_ => createChannel.FFmpegProfileId) - .Map(o => o.ToValidation($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist.")); + CancellationToken cancellationToken) + { + bool exists = await dbContext.FFmpegProfiles + .AnyAsync(p => p.Id == createChannel.FFmpegProfileId, cancellationToken); + if (exists) + { + return createChannel.FFmpegProfileId; + } + + return BaseError.New($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist."); + } private static async Task>> WatermarkMustExist( TvContext dbContext, @@ -170,12 +185,14 @@ public class CreateChannelHandler( return Option.None; } - return await dbContext.ChannelWatermarks - .CountAsync(w => w.Id == createChannel.WatermarkId, cancellationToken) - .Map(Optional) - .Filter(c => c > 0) - .MapT(_ => Optional(createChannel.WatermarkId)) - .Map(o => o.ToValidation($"Watermark {createChannel.WatermarkId} does not exist.")); + bool exists = await dbContext.ChannelWatermarks + .AnyAsync(w => w.Id == createChannel.WatermarkId, cancellationToken); + if (exists) + { + return Optional(createChannel.WatermarkId); + } + + return BaseError.New($"Watermark {createChannel.WatermarkId} does not exist."); } private static async Task>> FillerPresetMustExist( @@ -188,14 +205,15 @@ public class CreateChannelHandler( return Option.None; } - return await dbContext.FillerPresets + bool exists = await dbContext.FillerPresets .Filter(fp => fp.FillerKind == FillerKind.Fallback) - .CountAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken) - .Map(Optional) - .Filter(c => c > 0) - .MapT(_ => Optional(createChannel.FallbackFillerId)) - .Map(o => o.ToValidation( - $"Fallback filler {createChannel.FallbackFillerId} does not exist.")); + .AnyAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken); + if (exists) + { + return Optional(createChannel.FallbackFillerId); + } + + return BaseError.New($"Fallback filler {createChannel.FallbackFillerId} does not exist."); } private static async Task> MirrorSourceMustBeValid( diff --git a/ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs index a91ca1706..0363cecab 100644 --- a/ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs @@ -1,6 +1,7 @@ using System.IO.Abstractions; using System.Threading.Channels; using ErsatzTV.Core; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; @@ -31,8 +32,17 @@ public class DeleteChannelHandler : IRequestHandler> Handle(DeleteChannel request, CancellationToken cancellationToken) { await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await ChannelMustExist(dbContext, request, cancellationToken); - return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken)); + Option maybeChannel = await dbContext.Channels + .SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken); + + return await maybeChannel.Match( + Some: async channel => + { + await DoDeletion(dbContext, channel, cancellationToken); + return Right(Unit.Default); + }, + None: () => Task.FromResult( + Left(new NotFoundError($"Channel {request.ChannelId} does not exist.")))); } private async Task DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken) @@ -54,14 +64,4 @@ public class DeleteChannelHandler : IRequestHandler> ChannelMustExist( - TvContext dbContext, - DeleteChannel deleteChannel, - CancellationToken cancellationToken) - { - Option maybeChannel = await dbContext.Channels - .SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId, cancellationToken); - return maybeChannel.ToValidation($"Channel {deleteChannel.ChannelId} does not exist."); - } } diff --git a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs index d3a7f35b5..bf3039a4e 100644 --- a/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs +++ b/ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs @@ -4,10 +4,13 @@ using System.Threading.Channels; using ErsatzTV.Application.Subtitles; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Domain.Filler; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.Channels.ChannelValidations; using static ErsatzTV.Application.Channels.Mapper; using Channel = ErsatzTV.Core.Domain.Channel; @@ -24,8 +27,23 @@ public class UpdateChannelHandler( CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - Validation validation = await Validate(dbContext, request, cancellationToken); - return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken)); + + Option maybeChannel = await dbContext.Channels + .Include(c => c.Artwork) + .Include(c => c.Watermark) + .Include(c => c.Playouts) + .SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken); + + return await maybeChannel.Match( + Some: async channel => + { + Validation validation = + await Validate(dbContext, request, channel, cancellationToken); + return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken)); + }, + None: () => Task.FromResult( + Left( + new NotFoundError($"Channel {request.ChannelId} does not exist.")))); } private async Task ApplyUpdateRequest( @@ -162,23 +180,82 @@ public class UpdateChannelHandler( private static async Task> Validate( TvContext dbContext, UpdateChannel request, - CancellationToken cancellationToken) => - (await ChannelMustExist(dbContext, request, cancellationToken), - ValidateName(request), + Channel channel, + CancellationToken cancellationToken) + { + Validation channelValidation = (ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken), - await MirrorSourceMustBeValid(dbContext, request, cancellationToken)) - .Apply((channelToUpdate, _, _, _) => channelToUpdate); + await MirrorSourceMustBeValid(dbContext, request, cancellationToken), + ValidateShowInEpg(request.IsEnabled, request.ShowInEpg), + ValidateLogo(request.Logo?.Path)) + .Apply((_, _, _, _, _) => channel); - private static Task> ChannelMustExist( + // combine the page-only Group rule plus the FK existence checks (FFmpeg profile / watermark / + // fallback filler) with the channel validation; splitting keeps tuple arity within + // LanguageExt's supported applicative range while still accumulating all errors + return (ValidateGroup(request.Group), + await FFmpegProfileMustExist(dbContext, request, cancellationToken), + await WatermarkMustExist(dbContext, request, cancellationToken), + await FillerPresetMustExist(dbContext, request, cancellationToken), + channelValidation) + .Apply((_, _, _, _, c) => c); + } + + private static async Task> FFmpegProfileMustExist( TvContext dbContext, - UpdateChannel updateChannel, - CancellationToken cancellationToken) => - dbContext.Channels - .Include(c => c.Artwork) - .Include(c => c.Watermark) - .Include(c => c.Playouts) - .SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId, cancellationToken) - .Map(o => o.ToValidation("Channel does not exist.")); + UpdateChannel request, + CancellationToken cancellationToken) + { + bool exists = await dbContext.FFmpegProfiles + .AnyAsync(p => p.Id == request.FFmpegProfileId, cancellationToken); + if (exists) + { + return request.FFmpegProfileId; + } + + return BaseError.New($"FFmpegProfile {request.FFmpegProfileId} does not exist."); + } + + private static async Task>> WatermarkMustExist( + TvContext dbContext, + UpdateChannel request, + CancellationToken cancellationToken) + { + if (request.WatermarkId is null) + { + return Option.None; + } + + bool exists = await dbContext.ChannelWatermarks + .AnyAsync(w => w.Id == request.WatermarkId, cancellationToken); + if (exists) + { + return Optional(request.WatermarkId); + } + + return BaseError.New($"Watermark {request.WatermarkId} does not exist."); + } + + private static async Task>> FillerPresetMustExist( + TvContext dbContext, + UpdateChannel request, + CancellationToken cancellationToken) + { + if (request.FallbackFillerId is null) + { + return Option.None; + } + + bool exists = await dbContext.FillerPresets + .Filter(fp => fp.FillerKind == FillerKind.Fallback) + .AnyAsync(w => w.Id == request.FallbackFillerId, cancellationToken); + if (exists) + { + return Optional(request.FallbackFillerId); + } + + return BaseError.New($"Fallback filler {request.FallbackFillerId} does not exist."); + } private static async Task> MirrorSourceMustBeValid( TvContext dbContext, diff --git a/ErsatzTV.Core/Errors/NotFoundError.cs b/ErsatzTV.Core/Errors/NotFoundError.cs new file mode 100644 index 000000000..3d15e3caf --- /dev/null +++ b/ErsatzTV.Core/Errors/NotFoundError.cs @@ -0,0 +1,12 @@ +namespace ErsatzTV.Core.Errors; + +/// +/// A that indicates the addressed resource does not exist. +/// REST endpoints map this to HTTP 404; other values map to 422. +/// +public class NotFoundError : BaseError +{ + public NotFoundError(string value) : base(value) + { + } +} diff --git a/ErsatzTV.Tests/Application/Channels/CreateChannelHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/CreateChannelHandlerTests.cs new file mode 100644 index 000000000..7579f78b6 --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/CreateChannelHandlerTests.cs @@ -0,0 +1,104 @@ +using ErsatzTV.Application.Channels; +using ErsatzTV.Core; +using LanguageExt; +using ErsatzTV.Core.Errors; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using Microsoft.EntityFrameworkCore; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Channels; + +[TestFixture] +public class CreateChannelHandlerTests : ChannelHandlerTestBase +{ + private CreateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets); + + [Test] + public async Task Should_Create_Channel_When_Valid() + { + await SeedFFmpegProfile(); + + Either result = + await MakeHandler().Handle(MakeCreate(number: "7", name: "News"), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + + await using TvContext context = Db.CreateContext(); + bool exists = await context.Channels.AnyAsync(c => c.Number == "7" && c.Name == "News"); + exists.ShouldBeTrue(); + } + + [Test] + public async Task Should_Reject_Duplicate_Number_With_422_Error() + { + await SeedFFmpegProfile(); + await SeedChannel(1, "5"); + + Either result = + await MakeHandler().Handle(MakeCreate(number: "5"), CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("unique"); + } + + [Test] + public async Task Should_Reject_ShowInEpg_When_Disabled() + { + await SeedFFmpegProfile(); + + Either result = + await MakeHandler().Handle( + MakeCreate(number: "8", isEnabled: false, showInEpg: true), + CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("EPG"); + } + + [Test] + public async Task Should_Reject_Invalid_External_Logo_Url() + { + await SeedFFmpegProfile(); + + Either result = + await MakeHandler().Handle( + MakeCreate(number: "9", logoPath: "ftp://example.com/logo.png"), + CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("logo"); + } + + [Test] + public async Task Should_Reject_Empty_Group() + { + await SeedFFmpegProfile(); + + Either result = + await MakeHandler().Handle(MakeCreate(number: "11", group: " "), CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("group"); + } + + [Test] + public async Task Should_Reject_Nonexistent_FFmpegProfile() + { + // intentionally do not seed an FFmpegProfile + Either result = + await MakeHandler().Handle(MakeCreate(number: "12", ffmpegProfileId: 999), CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("FFmpegProfile"); + } + + private static BaseError LeftOf(Either either) => + either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); +} diff --git a/ErsatzTV.Tests/Application/Channels/DeleteChannelHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/DeleteChannelHandlerTests.cs new file mode 100644 index 000000000..3fbb057dc --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/DeleteChannelHandlerTests.cs @@ -0,0 +1,44 @@ +using ErsatzTV.Application.Channels; +using ErsatzTV.Core; +using LanguageExt; +using ErsatzTV.Core.Errors; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using Microsoft.EntityFrameworkCore; +using NUnit.Framework; +using Shouldly; +using Testably.Abstractions.Testing; + +namespace ErsatzTV.Tests.Application.Channels; + +[TestFixture] +public class DeleteChannelHandlerTests : ChannelHandlerTestBase +{ + private DeleteChannelHandler MakeHandler() => new(Worker, Db.Factory, new MockFileSystem(), SearchTargets); + + [Test] + public async Task Should_Return_NotFoundError_When_Channel_Missing() + { + Either result = await MakeHandler().Handle(new DeleteChannel(999), CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldBeOfType(); + } + + [Test] + public async Task Should_Delete_Existing_Channel() + { + await SeedChannel(1, "5"); + + Either result = await MakeHandler().Handle(new DeleteChannel(1), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + + await using TvContext context = Db.CreateContext(); + bool exists = await context.Channels.AnyAsync(c => c.Id == 1); + exists.ShouldBeFalse(); + } + + private static BaseError LeftOf(Either either) => + either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); +} diff --git a/ErsatzTV.Tests/Application/Channels/UpdateChannelHandlerTests.cs b/ErsatzTV.Tests/Application/Channels/UpdateChannelHandlerTests.cs new file mode 100644 index 000000000..1df79be1b --- /dev/null +++ b/ErsatzTV.Tests/Application/Channels/UpdateChannelHandlerTests.cs @@ -0,0 +1,119 @@ +using ErsatzTV.Application.Channels; +using ErsatzTV.Core; +using LanguageExt; +using ErsatzTV.Core.Errors; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Tests.Support; +using Microsoft.EntityFrameworkCore; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.Channels; + +[TestFixture] +public class UpdateChannelHandlerTests : ChannelHandlerTestBase +{ + private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets); + + [Test] + public async Task Should_Return_NotFoundError_When_Channel_Missing() + { + Either result = + await MakeHandler().Handle(MakeUpdate(999, number: "5"), CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldBeOfType(); + } + + [Test] + public async Task Should_Update_Existing_Channel() + { + await SeedFFmpegProfile(); + await SeedChannel(1, "5", "Old Name"); + + Either result = + await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "New Name"), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + + await using TvContext context = Db.CreateContext(); + var channel = await context.Channels.SingleAsync(c => c.Id == 1); + channel.Name.ShouldBe("New Name"); + } + + [Test] + public async Task Should_Allow_Keeping_Own_Number() + { + await SeedFFmpegProfile(); + await SeedChannel(1, "5", "Old Name"); + + Either result = + await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "Renamed"), CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + } + + [Test] + public async Task Should_Reject_Number_Used_By_Another_Channel() + { + await SeedFFmpegProfile(); + await SeedChannel(1, "5"); + await SeedChannel(2, "6"); + + Either result = + await MakeHandler().Handle(MakeUpdate(1, number: "6"), CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("unique"); + } + + [Test] + public async Task Should_Reject_ShowInEpg_When_Disabled() + { + await SeedFFmpegProfile(); + await SeedChannel(1, "5"); + + Either result = + await MakeHandler().Handle( + MakeUpdate(1, number: "5", isEnabled: false, showInEpg: true), + CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("EPG"); + } + + [Test] + public async Task Should_Reject_Empty_Group() + { + await SeedFFmpegProfile(); + await SeedChannel(1, "5"); + + Either result = + await MakeHandler().Handle(MakeUpdate(1, number: "5", group: ""), CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("group"); + } + + [Test] + public async Task Should_Reject_Nonexistent_FFmpegProfile() + { + await SeedFFmpegProfile(); + await SeedChannel(1, "5"); + + Either result = + await MakeHandler().Handle( + MakeUpdate(1, number: "5", ffmpegProfileId: 999), + CancellationToken.None); + + BaseError error = LeftOf(result); + error.ShouldNotBeOfType(); + error.Value.ShouldContain("FFmpegProfile"); + } + + private static BaseError LeftOf(Either either) => + either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result")); +} diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerSecurityTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerSecurityTests.cs new file mode 100644 index 000000000..444051220 --- /dev/null +++ b/ErsatzTV.Tests/Controllers/ChannelControllerSecurityTests.cs @@ -0,0 +1,62 @@ +using System.Linq; +using System.Reflection; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Filters; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Controllers; + +/// +/// Guards the API-key write-path contract for : every mutating +/// action (POST/PUT/PATCH/DELETE) must be covered by . +/// The filter is applied at the controller level, so this also protects any future write endpoint +/// added to the controller (regression net for the ResetPlayout bypass). +/// +[TestFixture] +public class ChannelControllerSecurityTests +{ + [Test] + public void Controller_Should_Apply_ApiKeyAuthorizationFilter() + { + ServiceFilterAttribute? filter = typeof(ChannelController) + .GetCustomAttributes(inherit: true) + .SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); + + filter.ShouldNotBeNull("ChannelController must carry the ApiKeyAuthorizationFilter at the class level"); + } + + [Test] + public void Every_Mutating_Action_Should_Be_Protected() + { + MethodInfo[] actions = typeof(ChannelController) + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + + bool controllerHasFilter = typeof(ChannelController) + .GetCustomAttributes(inherit: true) + .Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); + + foreach (MethodInfo action in actions) + { + bool isMutating = action + .GetCustomAttributes(inherit: true) + .SelectMany(a => a.HttpMethods) + .Any(m => + m is "POST" or "PUT" or "PATCH" or "DELETE"); + + if (!isMutating) + { + continue; + } + + bool actionHasFilter = action + .GetCustomAttributes(inherit: true) + .Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); + + (controllerHasFilter || actionHasFilter) + .ShouldBeTrue($"Mutating action {action.Name} is not protected by ApiKeyAuthorizationFilter"); + } + } +} diff --git a/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs new file mode 100644 index 000000000..f6eed998d --- /dev/null +++ b/ErsatzTV.Tests/Controllers/ChannelControllerTests.cs @@ -0,0 +1,241 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Artworks; +using ErsatzTV.Application.Channels; +using ErsatzTV.Controllers.Api; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using LanguageExt; +using static LanguageExt.Prelude; +using MediatR; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Controllers; + +[TestFixture] +public class ChannelControllerTests +{ + private IMediator _mediator = null!; + private ChannelController _controller = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + ChannelWriter writer = + System.Threading.Channels.Channel.CreateUnbounded().Writer; + _controller = new ChannelController(writer, _mediator); + } + + [Test] + public async Task Create_Should_Return_201_With_Location_And_Body() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new CreateChannelResult(5))); + ChannelViewModel vm = MakeVm(5); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.Create(MakeCreateRequest(number: "5"), CancellationToken.None); + + var created = result.ShouldBeOfType(); + created.StatusCode.ShouldBe(201); + created.Location.ShouldBe("/api/channels/5"); + created.Value.ShouldBe(vm); + } + + [Test] + public async Task Create_Should_Map_Request_To_Command() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(new CreateChannelResult(5))); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(MakeVm(5))); + + await _controller.Create(MakeCreateRequest(number: "12", name: "Movies"), CancellationToken.None); + + await _mediator.Received(1).Send( + Arg.Is(c => c.Number == "12" && c.Name == "Movies"), + Arg.Any()); + } + + [Test] + public async Task Create_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Update_Should_Return_200_And_Map_Route_Id() + { + ChannelViewModel vm = MakeVm(7); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(vm)); + + IActionResult result = await _controller.Update(7, MakeUpdateRequest(number: "5"), CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(vm); + await _mediator.Received(1).Send( + Arg.Is(c => c.ChannelId == 7), + Arg.Any()); + } + + [Test] + public async Task Update_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Update(99, MakeUpdateRequest(), CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_204_On_Success() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + + IActionResult result = await _controller.Delete(3, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task Delete_Should_Return_404_For_NotFoundError() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(new NotFoundError("missing"))); + + IActionResult result = await _controller.Delete(99, CancellationToken.None); + + result.ShouldBeOfType(); + } + + [Test] + public async Task GetById_Should_Return_200_For_Some() + { + ChannelViewModel vm = MakeVm(4); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.Some(vm)); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(vm); + } + + [Test] + public async Task GetById_Should_Return_404_For_None() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + IActionResult result = await _controller.GetById(4, CancellationToken.None); + + result.ShouldBeOfType(); + } + + private static ChannelViewModel MakeVm(int id) => + new( + id, + "5", + "Test", + "ErsatzTV", + string.Empty, + 1, + null, + ArtworkContentTypeModel.None, + ChannelStreamSelectorMode.Default, + string.Empty, + string.Empty, + string.Empty, + ChannelPlayoutSource.Generated, + ChannelPlayoutMode.Continuous, + null, + null, + StreamingMode.TransportStreamHybrid, + null, + null, + 0, + string.Empty, + ChannelSubtitleMode.None, + ChannelMusicVideoCreditsMode.None, + string.Empty, + ChannelSongVideoMode.Default, + ChannelTranscodeMode.OnDemand, + ChannelIdleBehavior.StopOnDisconnect, + true, + false); + + private static CreateChannelRequest MakeCreateRequest(string number = "5", string name = "Test") => + new( + name, + number, + "ErsatzTV", + string.Empty, + 1, + null, + ArtworkContentTypeModel.None, + ChannelStreamSelectorMode.Default, + string.Empty, + string.Empty, + string.Empty, + ChannelPlayoutSource.Generated, + ChannelPlayoutMode.Continuous, + null, + null, + StreamingMode.TransportStreamHybrid, + null, + null, + string.Empty, + ChannelSubtitleMode.None, + ChannelMusicVideoCreditsMode.None, + string.Empty, + ChannelSongVideoMode.Default, + ChannelTranscodeMode.OnDemand, + ChannelIdleBehavior.StopOnDisconnect, + true, + false); + + private static UpdateChannelRequest MakeUpdateRequest(string number = "5", string name = "Test") => + new( + name, + number, + "ErsatzTV", + string.Empty, + 1, + null, + ArtworkContentTypeModel.None, + ChannelStreamSelectorMode.Default, + string.Empty, + string.Empty, + string.Empty, + ChannelPlayoutSource.Generated, + ChannelPlayoutMode.Continuous, + null, + null, + StreamingMode.TransportStreamHybrid, + null, + null, + string.Empty, + ChannelSubtitleMode.None, + ChannelMusicVideoCreditsMode.None, + string.Empty, + ChannelSongVideoMode.Default, + ChannelTranscodeMode.OnDemand, + ChannelIdleBehavior.StopOnDisconnect, + true, + false); +} diff --git a/ErsatzTV.Tests/ErsatzTV.Tests.csproj b/ErsatzTV.Tests/ErsatzTV.Tests.csproj index 5581d1eaf..76c49cf5a 100644 --- a/ErsatzTV.Tests/ErsatzTV.Tests.csproj +++ b/ErsatzTV.Tests/ErsatzTV.Tests.csproj @@ -11,13 +11,16 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/ErsatzTV.Tests/Extensions/ApiResultsTests.cs b/ErsatzTV.Tests/Extensions/ApiResultsTests.cs new file mode 100644 index 000000000..9170eeaac --- /dev/null +++ b/ErsatzTV.Tests/Extensions/ApiResultsTests.cs @@ -0,0 +1,115 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Errors; +using ErsatzTV.Extensions; +using LanguageExt; +using static LanguageExt.Prelude; +using Microsoft.AspNetCore.Mvc; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Extensions; + +[TestFixture] +public class ApiResultsTests +{ + [Test] + public void ToErrorResult_Should_Map_NotFoundError_To_404() + { + IActionResult result = new NotFoundError("missing").ToErrorResult(); + result.ShouldBeOfType().StatusCode.ShouldBe(404); + } + + [Test] + public void ToErrorResult_Should_Map_Other_Error_To_422() + { + IActionResult result = BaseError.New("bad").ToErrorResult(); + result.ShouldBeOfType().StatusCode.ShouldBe(422); + } + + [Test] + public void ToCreatedResult_Should_Return_201_With_Location_And_Body() + { + Either either = Right(5); + + IActionResult result = either.ToCreatedResult(id => $"/api/channels/{id}", id => $"body-{id}"); + + var created = result.ShouldBeOfType(); + created.StatusCode.ShouldBe(201); + created.Location.ShouldBe("/api/channels/5"); + created.Value.ShouldBe("body-5"); + } + + [Test] + public void ToCreatedResult_Should_Map_NotFoundError_To_404() + { + Either either = Left(new NotFoundError("nope")); + + IActionResult result = either.ToCreatedResult(id => $"/api/channels/{id}", id => id); + + result.ShouldBeOfType(); + } + + [Test] + public void ToUpdatedResult_Should_Return_200_On_Right() + { + Either either = Right("vm"); + IActionResult result = either.ToUpdatedResult(); + + var ok = result.ShouldBeOfType(); + ok.Value.ShouldBe("vm"); + } + + [Test] + public void ToUpdatedResult_Should_Return_404_For_NotFoundError() + { + Either either = Left(new NotFoundError("missing")); + IActionResult result = either.ToUpdatedResult(); + + result.ShouldBeOfType(); + } + + [Test] + public void ToUpdatedResult_Should_Return_422_For_Other_Error() + { + Either either = Left(BaseError.New("bad")); + IActionResult result = either.ToUpdatedResult(); + + result.ShouldBeOfType(); + } + + [Test] + public void ToDeletedResult_Should_Return_204_On_Right() + { + Either either = Right(Unit.Default); + IActionResult result = either.ToDeletedResult(); + + result.ShouldBeOfType().StatusCode.ShouldBe(204); + } + + [Test] + public void ToDeletedResult_Should_Return_404_For_NotFoundError() + { + Either either = Left(new NotFoundError("missing")); + IActionResult result = either.ToDeletedResult(); + + result.ShouldBeOfType(); + } + + [Test] + public void ToGetResult_Should_Return_200_For_Some() + { + Option option = "value"; + IActionResult result = option.ToGetResult(); + + result.ShouldBeOfType().Value.ShouldBe("value"); + } + + [Test] + public void ToGetResult_Should_Return_404_For_None() + { + Option option = Option.None; + IActionResult result = option.ToGetResult(); + + result.ShouldBeOfType(); + } +} diff --git a/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs b/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs new file mode 100644 index 000000000..3699e5256 --- /dev/null +++ b/ErsatzTV.Tests/Filters/ApiKeyAuthorizationFilterTests.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using ErsatzTV.Filters; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Filters; + +[TestFixture] +public class ApiKeyAuthorizationFilterTests +{ + private static AuthorizationFilterContext MakeContext(string method, string? apiKeyHeader) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Method = method; + if (apiKeyHeader is not null) + { + httpContext.Request.Headers[ApiKeyAuthorizationFilter.HeaderName] = apiKeyHeader; + } + + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + return new AuthorizationFilterContext(actionContext, new List()); + } + + private static ApiKeyAuthorizationFilter MakeFilter(string? configuredKey) + { + var settings = new Dictionary(); + if (configuredKey is not null) + { + settings[ApiKeyAuthorizationFilter.ConfigurationKey] = configuredKey; + } + + IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + return new ApiKeyAuthorizationFilter(configuration); + } + + [Test] + public void Should_Allow_When_Key_Not_Configured() + { + AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null); + MakeFilter(configuredKey: null).OnAuthorization(context); + context.Result.ShouldBeNull(); + } + + [Test] + public void Should_Allow_When_Key_Configured_Empty() + { + AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null); + MakeFilter(configuredKey: string.Empty).OnAuthorization(context); + context.Result.ShouldBeNull(); + } + + [Test] + public void Should_Reject_Mutating_Request_When_Key_Configured_And_Header_Missing() + { + AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null); + MakeFilter(configuredKey: "secret").OnAuthorization(context); + context.Result.ShouldBeOfType(); + } + + [Test] + public void Should_Reject_Mutating_Request_When_Key_Wrong() + { + AuthorizationFilterContext context = MakeContext("DELETE", apiKeyHeader: "wrong"); + MakeFilter(configuredKey: "secret").OnAuthorization(context); + context.Result.ShouldBeOfType(); + } + + [Test] + public void Should_Allow_Mutating_Request_When_Key_Correct() + { + AuthorizationFilterContext context = MakeContext("PUT", apiKeyHeader: "secret"); + MakeFilter(configuredKey: "secret").OnAuthorization(context); + context.Result.ShouldBeNull(); + } + + [Test] + public void Should_Allow_Get_Request_Even_When_Key_Configured() + { + AuthorizationFilterContext context = MakeContext("GET", apiKeyHeader: null); + MakeFilter(configuredKey: "secret").OnAuthorization(context); + context.Result.ShouldBeNull(); + } +} diff --git a/ErsatzTV.Tests/Integration/ChannelLifecycleIntegrationTests.cs b/ErsatzTV.Tests/Integration/ChannelLifecycleIntegrationTests.cs new file mode 100644 index 000000000..be0edd769 --- /dev/null +++ b/ErsatzTV.Tests/Integration/ChannelLifecycleIntegrationTests.cs @@ -0,0 +1,52 @@ +using ErsatzTV.Application.Channels; +using ErsatzTV.Core; +using LanguageExt; +using ErsatzTV.Infrastructure.Data.Repositories; +using ErsatzTV.Tests.Support; +using NUnit.Framework; +using Shouldly; +using Testably.Abstractions.Testing; + +namespace ErsatzTV.Tests.Integration; + +/// +/// End-to-end create -> read (get-by-id) -> delete against the in-memory SQLite harness, +/// exercising the real EF Core handlers and repository. +/// +[TestFixture] +public class ChannelLifecycleIntegrationTests : ChannelHandlerTestBase +{ + [Test] + public async Task Create_Then_Read_Then_Delete() + { + await SeedFFmpegProfile(); + + var createHandler = new CreateChannelHandler(Worker, Db.Factory, SearchTargets); + Either created = + await createHandler.Handle(MakeCreate(number: "42", name: "Integration"), CancellationToken.None); + + int channelId = created.Match(Left: _ => throw new AssertionException("create failed"), Right: r => r.ChannelId); + channelId.ShouldBeGreaterThan(0); + + var getHandler = new GetChannelByIdHandler(new ChannelRepository(Db.Factory)); + Option afterCreate = + await getHandler.Handle(new GetChannelById(channelId), CancellationToken.None); + afterCreate.IsSome.ShouldBeTrue(); + afterCreate.Match( + Some: vm => + { + vm.Number.ShouldBe("42"); + vm.Name.ShouldBe("Integration"); + }, + None: () => throw new AssertionException("expected channel to exist")); + + var deleteHandler = new DeleteChannelHandler(Worker, Db.Factory, new MockFileSystem(), SearchTargets); + Either deleted = + await deleteHandler.Handle(new DeleteChannel(channelId), CancellationToken.None); + deleted.IsRight.ShouldBeTrue(); + + Option afterDelete = + await getHandler.Handle(new GetChannelById(channelId), CancellationToken.None); + afterDelete.IsNone.ShouldBeTrue(); + } +} diff --git a/ErsatzTV.Tests/Support/ChannelHandlerTestBase.cs b/ErsatzTV.Tests/Support/ChannelHandlerTestBase.cs new file mode 100644 index 000000000..be790eed5 --- /dev/null +++ b/ErsatzTV.Tests/Support/ChannelHandlerTestBase.cs @@ -0,0 +1,137 @@ +using System.Threading.Channels; +using ErsatzTV.Application; +using ErsatzTV.Application.Artworks; +using ErsatzTV.Application.Channels; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Infrastructure.Data; +using NSubstitute; +using NUnit.Framework; +using DomainChannel = ErsatzTV.Core.Domain.Channel; + +namespace ErsatzTV.Tests.Support; + +public abstract class ChannelHandlerTestBase +{ + protected InMemoryTvContext Db = null!; + protected ChannelWriter Worker = null!; + protected ISearchTargets SearchTargets = null!; + + [SetUp] + public async Task BaseSetUp() + { + Db = await InMemoryTvContext.CreateAsync(); + Worker = System.Threading.Channels.Channel.CreateUnbounded().Writer; + SearchTargets = Substitute.For(); + } + + [TearDown] + public async Task BaseTearDown() => await Db.DisposeAsync(); + + protected async Task SeedFFmpegProfile(int id = 1) + { + await using TvContext context = Db.CreateContext(); + context.FFmpegProfiles.Add(new FFmpegProfile { Id = id, Name = $"profile-{id}" }); + await context.SaveChangesAsync(); + } + + protected async Task SeedChannel(int id, string number, string name = "Test", int ffmpegProfileId = 1) + { + await using TvContext context = Db.CreateContext(); + context.Channels.Add( + new DomainChannel(Guid.NewGuid()) + { + Id = id, + Number = number, + Name = name, + Group = "ErsatzTV", + Categories = string.Empty, + FFmpegProfileId = ffmpegProfileId, + StreamSelector = string.Empty, + PreferredAudioLanguageCode = string.Empty, + PreferredAudioTitle = string.Empty, + PreferredSubtitleLanguageCode = string.Empty, + MusicVideoCreditsTemplate = string.Empty, + StreamingMode = StreamingMode.TransportStreamHybrid, + PlayoutSource = ChannelPlayoutSource.Generated, + PlayoutMode = ChannelPlayoutMode.Continuous + }); + await context.SaveChangesAsync(); + } + + protected static CreateChannel MakeCreate( + string number = "5", + int ffmpegProfileId = 1, + bool isEnabled = true, + bool showInEpg = false, + string logoPath = "", + string name = "Test", + string group = "ErsatzTV") => + new( + name, + number, + group, + string.Empty, + ffmpegProfileId, + null, + new ArtworkContentTypeModel(logoPath, string.Empty), + ChannelStreamSelectorMode.Default, + string.Empty, + string.Empty, + string.Empty, + ChannelPlayoutSource.Generated, + ChannelPlayoutMode.Continuous, + null, + null, + StreamingMode.TransportStreamHybrid, + null, + null, + string.Empty, + ChannelSubtitleMode.None, + ChannelMusicVideoCreditsMode.None, + string.Empty, + ChannelSongVideoMode.Default, + ChannelTranscodeMode.OnDemand, + ChannelIdleBehavior.StopOnDisconnect, + isEnabled, + showInEpg); + + protected static UpdateChannel MakeUpdate( + int channelId, + string number = "5", + int ffmpegProfileId = 1, + bool isEnabled = true, + bool showInEpg = false, + string logoPath = "", + string name = "Test", + string group = "ErsatzTV") => + new( + channelId, + name, + number, + group, + string.Empty, + ffmpegProfileId, + null, + new ArtworkContentTypeModel(logoPath, string.Empty), + ChannelStreamSelectorMode.Default, + string.Empty, + string.Empty, + string.Empty, + ChannelPlayoutSource.Generated, + ChannelPlayoutMode.Continuous, + null, + null, + StreamingMode.TransportStreamHybrid, + null, + null, + string.Empty, + ChannelSubtitleMode.None, + ChannelMusicVideoCreditsMode.None, + string.Empty, + ChannelSongVideoMode.Default, + ChannelTranscodeMode.OnDemand, + ChannelIdleBehavior.StopOnDisconnect, + isEnabled, + showInEpg); +} diff --git a/ErsatzTV.Tests/Support/InMemoryTvContext.cs b/ErsatzTV.Tests/Support/InMemoryTvContext.cs new file mode 100644 index 000000000..93def6448 --- /dev/null +++ b/ErsatzTV.Tests/Support/InMemoryTvContext.cs @@ -0,0 +1,66 @@ +using ErsatzTV.Infrastructure; +using ErsatzTV.Infrastructure.Data; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ErsatzTV.Tests.Support; + +/// +/// In-memory SQLite harness for handler/integration tests. A single +/// is kept open for the lifetime of the harness so the schema (built via +/// ) and data persist across the multiple +/// instances created by an . +/// Foreign keys are disabled so partial graphs can be seeded without satisfying every FK. +/// +public sealed class InMemoryTvContext : IAsyncDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + + private InMemoryTvContext(SqliteConnection connection, DbContextOptions options) + { + _connection = connection; + _options = options; + } + + public IDbContextFactory Factory => new TestDbContextFactory(_options); + + public static async Task CreateAsync() + { + TvContext.IsSqlite = true; + + var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=False"); + await connection.OpenAsync(); + + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using (TvContext context = Create(options)) + { + await context.Database.EnsureCreatedAsync(); + await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys=OFF"); + } + + return new InMemoryTvContext(connection, options); + } + + public TvContext CreateContext() => Create(_options); + + public async ValueTask DisposeAsync() + { + await _connection.DisposeAsync(); + } + + private static TvContext Create(DbContextOptions options) => + new( + options, + NullLoggerFactory.Instance, + new SlowQueryInterceptor(NullLogger.Instance)); + + private sealed class TestDbContextFactory(DbContextOptions options) : IDbContextFactory + { + public TvContext CreateDbContext() => Create(options); + } +} diff --git a/ErsatzTV/Controllers/Api/ChannelController.cs b/ErsatzTV/Controllers/Api/ChannelController.cs index baa1edb5d..2ff273549 100644 --- a/ErsatzTV/Controllers/Api/ChannelController.cs +++ b/ErsatzTV/Controllers/Api/ChannelController.cs @@ -1,9 +1,13 @@ -using System.Threading.Channels; +using System.ComponentModel.DataAnnotations; +using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Channels; using ErsatzTV.Application.Playouts; +using ErsatzTV.Controllers.Api.Requests; +using ErsatzTV.Core; using ErsatzTV.Core.Api.Channels; using ErsatzTV.Core.Scheduling; +using ErsatzTV.Extensions; using ErsatzTV.Filters; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -11,12 +15,72 @@ using Microsoft.AspNetCore.Mvc; namespace ErsatzTV.Controllers.Api; [ApiController] +// Apply the optional API-key control at the controller level so that EVERY mutating action +// (including future ones) is covered by default; the filter no-ops on read methods (GET) and +// when Api:WriteKey is unset, preserving the open LAN behavior. This is fail-safe: a developer +// adding a new write endpoint here cannot accidentally leave it unauthenticated. +[ServiceFilter(typeof(ApiKeyAuthorizationFilter))] public class ChannelController(ChannelWriter workerChannel, IMediator mediator) { [HttpGet("/api/channels")] [EndpointGroupName("general")] public async Task> GetAll() => await mediator.Send(new GetAllChannelsForApi()); + [HttpGet("/api/channels/{id:int}", Name = "GetChannelById")] + [Tags("Channels")] + [EndpointSummary("Get a channel by id")] + [EndpointGroupName("general")] + public async Task GetById(int id, CancellationToken cancellationToken) + { + Option result = await mediator.Send(new GetChannelById(id), cancellationToken); + return result.ToGetResult(); + } + + [HttpPost("/api/channels")] + [Tags("Channels")] + [EndpointSummary("Create a channel")] + [EndpointGroupName("general")] + public async Task Create( + [Required] [FromBody] CreateChannelRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(request.ToCommand(), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async created => + { + Option channel = + await mediator.Send(new GetChannelById(created.ChannelId), cancellationToken); + return channel.Match( + Some: vm => (IActionResult)new CreatedResult($"/api/channels/{vm.Id}", vm), + None: () => new NotFoundResult()); + }); + } + + [HttpPut("/api/channels/{id:int}")] + [Tags("Channels")] + [EndpointSummary("Update a channel")] + [EndpointGroupName("general")] + public async Task Update( + int id, + [Required] [FromBody] UpdateChannelRequest request, + CancellationToken cancellationToken) + { + Either result = + await mediator.Send(request.ToCommand(id), cancellationToken); + return result.ToUpdatedResult(); + } + + [HttpDelete("/api/channels/{id:int}")] + [Tags("Channels")] + [EndpointSummary("Delete a channel")] + [EndpointGroupName("general")] + public async Task Delete(int id, CancellationToken cancellationToken) + { + Either result = await mediator.Send(new DeleteChannel(id), cancellationToken); + return result.ToDeletedResult(); + } + [HttpPost("/api/channels/{channelNumber}/playout/reset")] [Tags("Channels")] [EndpointSummary("Reset channel playout")] @@ -32,24 +96,4 @@ public class ChannelController(ChannelWriter workerCh return new NotFoundResult(); } - - // for debugging by fast-forwarding a playout - // [HttpPost("/api/channels/{channelNumber}/playout/continue")] - // public async Task ContinuePlayout(string channelNumber, [FromQuery] int days = 1) - // { - // Option maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber)); - // foreach (int playoutId in maybePlayoutId) - // { - // DateTimeOffset start = DateTimeOffset.Now; - // for (int i = 0; i < 24 * days; i++) - // { - // await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue, start)); - // start += TimeSpan.FromHours(1); - // } - // - // return new OkResult(); - // } - // - // return new NotFoundResult(); - // } } diff --git a/ErsatzTV/Controllers/Api/Requests/CreateChannelRequest.cs b/ErsatzTV/Controllers/Api/Requests/CreateChannelRequest.cs new file mode 100644 index 000000000..c1fafb073 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/CreateChannelRequest.cs @@ -0,0 +1,69 @@ +using ErsatzTV.Application.Artworks; +using ErsatzTV.Application.Channels; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Controllers.Api.Requests; + +/// +/// JSON request body for creating a channel. Mirrors ; decoupling the +/// API contract from the MediatR command keeps the wire format stable as the command evolves. +/// +public record CreateChannelRequest( + string Name, + string Number, + string Group, + string Categories, + int FFmpegProfileId, + double? SlugSeconds, + ArtworkContentTypeModel Logo, + ChannelStreamSelectorMode StreamSelectorMode, + string StreamSelector, + string PreferredAudioLanguageCode, + string PreferredAudioTitle, + ChannelPlayoutSource PlayoutSource, + ChannelPlayoutMode PlayoutMode, + int? MirrorSourceChannelId, + TimeSpan? PlayoutOffset, + StreamingMode StreamingMode, + int? WatermarkId, + int? FallbackFillerId, + string PreferredSubtitleLanguageCode, + ChannelSubtitleMode SubtitleMode, + ChannelMusicVideoCreditsMode MusicVideoCreditsMode, + string MusicVideoCreditsTemplate, + ChannelSongVideoMode SongVideoMode, + ChannelTranscodeMode TranscodeMode, + ChannelIdleBehavior IdleBehavior, + bool IsEnabled, + bool ShowInEpg) +{ + public CreateChannel ToCommand() => + new( + Name, + Number, + Group, + Categories, + FFmpegProfileId, + SlugSeconds, + Logo, + StreamSelectorMode, + StreamSelector, + PreferredAudioLanguageCode, + PreferredAudioTitle, + PlayoutSource, + PlayoutMode, + MirrorSourceChannelId, + PlayoutOffset, + StreamingMode, + WatermarkId, + FallbackFillerId, + PreferredSubtitleLanguageCode, + SubtitleMode, + MusicVideoCreditsMode, + MusicVideoCreditsTemplate, + SongVideoMode, + TranscodeMode, + IdleBehavior, + IsEnabled, + ShowInEpg); +} diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs new file mode 100644 index 000000000..f028084e6 --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateChannelRequest.cs @@ -0,0 +1,70 @@ +using ErsatzTV.Application.Artworks; +using ErsatzTV.Application.Channels; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Controllers.Api.Requests; + +/// +/// JSON request body for updating a channel. The channel id comes from the route, not the body; +/// all other fields mirror . +/// +public record UpdateChannelRequest( + string Name, + string Number, + string Group, + string Categories, + int FFmpegProfileId, + double? SlugSeconds, + ArtworkContentTypeModel Logo, + ChannelStreamSelectorMode StreamSelectorMode, + string StreamSelector, + string PreferredAudioLanguageCode, + string PreferredAudioTitle, + ChannelPlayoutSource PlayoutSource, + ChannelPlayoutMode PlayoutMode, + int? MirrorSourceChannelId, + TimeSpan? PlayoutOffset, + StreamingMode StreamingMode, + int? WatermarkId, + int? FallbackFillerId, + string PreferredSubtitleLanguageCode, + ChannelSubtitleMode SubtitleMode, + ChannelMusicVideoCreditsMode MusicVideoCreditsMode, + string MusicVideoCreditsTemplate, + ChannelSongVideoMode SongVideoMode, + ChannelTranscodeMode TranscodeMode, + ChannelIdleBehavior IdleBehavior, + bool IsEnabled, + bool ShowInEpg) +{ + public UpdateChannel ToCommand(int channelId) => + new( + channelId, + Name, + Number, + Group, + Categories, + FFmpegProfileId, + SlugSeconds, + Logo, + StreamSelectorMode, + StreamSelector, + PreferredAudioLanguageCode, + PreferredAudioTitle, + PlayoutSource, + PlayoutMode, + MirrorSourceChannelId, + PlayoutOffset, + StreamingMode, + WatermarkId, + FallbackFillerId, + PreferredSubtitleLanguageCode, + SubtitleMode, + MusicVideoCreditsMode, + MusicVideoCreditsTemplate, + SongVideoMode, + TranscodeMode, + IdleBehavior, + IsEnabled, + ShowInEpg); +} diff --git a/ErsatzTV/Extensions/ApiResults.cs b/ErsatzTV/Extensions/ApiResults.cs new file mode 100644 index 000000000..8bd85137c --- /dev/null +++ b/ErsatzTV/Extensions/ApiResults.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; +using ErsatzTV.Core; +using ErsatzTV.Core.Errors; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Extensions; + +/// +/// REST status-code mapping helpers for the JSON write/read API (slice #2a). +/// These are additive and must not change the behavior of the existing +/// / helpers, +/// which IptvController and other read endpoints depend on. +/// +[SuppressMessage("ReSharper", "VSTHRD003")] +public static class ApiResults +{ + /// Maps a failure to 404 when it is a , otherwise 422. + public static IActionResult ToErrorResult(this BaseError error) => + error is NotFoundError + ? new NotFoundObjectResult(error.Value) + : new UnprocessableEntityObjectResult(error.Value); + + /// Right: 201 Created with a Location header and body; Left: 404 (NotFound) or 422. + public static IActionResult ToCreatedResult( + this Either either, + Func location, + Func body) => + either.Match( + Left: error => error.ToErrorResult(), + Right: value => new CreatedResult(location(value), body(value))); + + /// Right: 200 with body; Left: 404 (NotFound) or 422. + public static IActionResult ToUpdatedResult(this Either either) => + either.Match( + Left: error => error.ToErrorResult(), + Right: value => (IActionResult)new OkObjectResult(value)); + + /// Right(Unit): 204 No Content; Left: 404 (NotFound) or 422. + public static IActionResult ToDeletedResult(this Either either) => + either.Match( + Left: error => error.ToErrorResult(), + Right: _ => (IActionResult)new NoContentResult()); + + /// Some: 200 with body; None: 404. + public static IActionResult ToGetResult(this Option option) => + option.Match( + Some: value => (IActionResult)new OkObjectResult(value), + None: () => new NotFoundResult()); +} diff --git a/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs b/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs new file mode 100644 index 000000000..f737b9cbd --- /dev/null +++ b/ErsatzTV/Filters/ApiKeyAuthorizationFilter.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Primitives; + +namespace ErsatzTV.Filters; + +/// +/// Optional API-key authorization for mutating JSON API endpoints (slice #2a). +/// Reads the configured key from Api:WriteKey. When that key is empty the filter is +/// a no-op (preserving the current open LAN behavior); when it is set, mutating requests +/// (POST/PUT/PATCH/DELETE) must present a matching X-Api-Key header or receive 401. +/// This is fully independent of and only applies to the actions it +/// decorates — it never affects /iptv/* or any read endpoint. +/// +public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthorizationFilter +{ + public const string HeaderName = "X-Api-Key"; + public const string ConfigurationKey = "Api:WriteKey"; + + public void OnAuthorization(AuthorizationFilterContext context) + { + string configuredKey = configuration[ConfigurationKey]; + + // empty key => API-key auth disabled, endpoint is open + if (string.IsNullOrEmpty(configuredKey)) + { + return; + } + + string method = context.HttpContext.Request.Method; + bool isMutating = HttpMethods.IsPost(method) + || HttpMethods.IsPut(method) + || HttpMethods.IsPatch(method) + || HttpMethods.IsDelete(method); + + if (!isMutating) + { + return; + } + + if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out StringValues provided) + || !string.Equals(provided.ToString(), configuredKey, StringComparison.Ordinal)) + { + context.Result = new UnauthorizedResult(); + } + } +} diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 2c8f13bb7..8ffa20081 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -309,6 +309,9 @@ public class Startup services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme")); + // optional API-key authorization for mutating JSON API endpoints (independent of JWT/OIDC) + services.AddScoped(); + services.AddFluentValidationAutoValidation(); services.AddValidatorsFromAssemblyContaining(); diff --git a/docs/rest-api.md b/docs/rest-api.md new file mode 100644 index 000000000..08ff709a8 --- /dev/null +++ b/docs/rest-api.md @@ -0,0 +1,144 @@ +# REST API Design — ersatztv#2 + +**Status:** Phase 1 signed off → Phase 2 in progress. +**Tracker:** ersatztv#2. **Sub-issues:** #34 (#2a), #35 (#2b), #36 (#2c), #37 (#2d), #38 (#2e). **Handoff:** `docs/handoffs/rest-api.md`. + +## 1. Goals + +1. **Programmatic CRUD** for channels / collections / schedules / playouts via REST — enabling **MCP** and direct API automation. +2. **Foundation for a new UI** built on top of the API (it replaces read paths too, not just writes). +3. **Retire fragile direct-SQLite writes.** All mutations route through EF Core + existing domain validation, returning proper HTTP codes — no more TPT/enum/NOT-NULL footguns, no container-stop-for-writes. + +These goals push three things to the front: **OpenAPI is in-scope** (MCP tool-gen + UI codegen), **stable DTO contracts**, and a **programmatic auth** story. + +## 2. What the investigation found (code-grounded) + +### 2.1 No migration needed +Pure CRUD over existing tables. The full schema — including the four `ProgramScheduleItem` **TPT subtype tables** (`ProgramScheduleOneItem`, `ProgramScheduleMultipleItem`, `ProgramScheduleFloodItem`, `ProgramScheduleDurationItem`, via `.UseTptMappingStrategy()`) — already exists. The `migrations` CI job will stay green; no `scripts/add-migration.sh` run is required for any slice. + +### 2.2 Every command already exists +All four resources already have MediatR Create/Update/Delete (+ item ops) handlers, **almost all returning `Either`** — which maps cleanly to HTTP. The API is mostly **thin controllers over existing handlers**, not new business logic. + +| Resource | Create | Update | Delete | Item ops | +|---|---|---|---|---| +| **Channels** | `CreateChannel` → `Either<_, CreateChannelResult>` | `UpdateChannel` → `Either<_, ChannelViewModel>` | `DeleteChannel` → `Either<_, Unit>` | — | +| **Collections** | `CreateCollection` → `Either<_, MediaCollectionViewModel>` | `UpdateCollection` → `Either<_, Unit>` | `DeleteCollection` → `Either<_, Unit>` | `AddItemsToCollection`, `RemoveItemsFromCollection` | +| **Schedules** | `CreateProgramSchedule` → `Either<_, CreateProgramScheduleResult>` | `UpdateProgramSchedule` → `Either<_, UpdateProgramScheduleResult>` | `DeleteProgramSchedule` → `Either<_, Unit>` | `AddProgramScheduleItem`, `ReplaceProgramScheduleItems` (bulk) | +| **Playouts** | `CreateClassic/Block/Sequential/Scripted/ExternalJsonPlayout` → `Either<_, CreatePlayoutResponse>` | `UpdatePlayout` → `Either<_, PlayoutNameViewModel>` | `DeletePlayout` → `Either<_, Unit>` | reset (exists, via worker channel) | + +Net-new command work is essentially **zero** for the happy paths. Effort is in controllers, DTOs, status-code mapping, auth, and tests. + +### 2.3 Validation already lives in the handlers (mostly) +The substantive rules are in-handler, so the API inherits them: +- Channel **number uniqueness** (`CreateChannelHandler.cs:137`, `UpdateChannelHandler.cs:234` excludes self), name/FK-exists, mirror-source validity. +- Collection / schedule **name uniqueness** + length. +- Playout business rules: **"Channel already has one playout"** (`CreateClassicPlayoutHandler.cs:77`), **"Program schedule must have items"** (`:93`). + +A few **format/UX checks are page-only** (FluentValidation in `.razor` / `Validators/`) and would NOT reach the API — port these into the handlers (with test-before/after): +- Channel: `ShowInEpg` must be false when `!IsEnabled`; external-logo-URL format. (`ChannelEditViewModelValidator.cs`) +- Playout: required-field shape per kind. (`PlayoutEditViewModelValidator.cs`) — mostly redundant with handler checks; confirm parity. + +### 2.4 Existing controller conventions (match these) +- Controllers in `ErsatzTV/Controllers/Api/`, base `ControllerBase`, **routes declared per-action** (full path, no controller `[Route]` prefix). +- JSON: **Newtonsoft**, **camelCase** (`CustomNamingStrategy`, with `FFmpegProfileId` special-cased), **enums as strings** (`StringEnumConverter`), `NullValueHandling.Ignore` (`Startup.cs:294`). +- **OpenAPI already wired**: `AddOpenApi("v1")` + **Scalar UI at `/docs`** (`Startup.cs:136`, `:664`); endpoints opt in via `[EndpointGroupName("general")]`. +- `.ToActionResult()` extensions map: `Either` Left→**400**, Right→**200**; `Option` None→**404**, Some→**200**; `Validation` Failure→**400**. **No 201/422 today.** + +### 2.5 Two existing CRUD controllers are non-idiomatic → standardize +`FFmpegProfileController` and `SmartCollectionController` use verb-in-path (`/new`, `/update`, `/delete/{id}`). Per the modernization decision, retrofit them to idiomatic REST as we work the related slices, gated by characterization tests. + +### 2.6 Auth today +A **JWT bearer scheme** exists (`JwtHelper`, `JwtOnlyScheme` policy, `access_token` query-param support, 1-day tokens) but is wired **only to `IptvController`** via `ConditionalIptvAuthorizeFilter` (enforced only when `JWT:IssuerSigningKey` is set). `/api/*` currently has **no auth**; CORS is **AllowAll**. + +## 3. Conventions (decided) + +### 3.1 Routing — idiomatic REST +``` +POST /api/channels GET /api/channels GET /api/channels/{id} +PUT /api/channels/{id} DELETE /api/channels/{id} + +POST /api/collections GET /api/collections GET /api/collections/{id} +PUT /api/collections/{id} DELETE /api/collections/{id} +POST /api/collections/{id}/items +DELETE /api/collections/{id}/items/{mediaItemId} + +POST /api/schedules GET /api/schedules GET /api/schedules/{id} +PUT /api/schedules/{id} DELETE /api/schedules/{id} +GET /api/schedules/{id}/items +POST /api/schedules/{id}/items +PUT /api/schedules/{id}/items # bulk replace (maps to ReplaceProgramScheduleItems) +DELETE /api/schedules/{id}/items/{itemId} + +POST /api/playouts GET /api/playouts GET /api/playouts/{id} +DELETE /api/playouts/{id} +POST /api/channels/{number}/playout/reset # exists; keep +``` +`{id}` = numeric entity id. Channel reset keeps its existing `{number}` route (already public). + +### 3.2 Status codes +| Outcome | Code | +|---|---| +| Create success | **201 Created** + `Location` header + body | +| Update success | **200 OK** + body | +| Delete success | **204 No Content** | +| Read success | **200 OK** | +| Resource not found | **404** | +| Validation failure (bad field, uniqueness, business rule) | **422 Unprocessable Entity** + error body | +| Malformed request (unparseable JSON, missing required) | **400** | + +The current `.ToActionResult()` only yields 200/400/404, so we add **richer mapping helpers** (a deliberate, stated deviation — proper HTTP semantics matter for MCP/UI consumers). **Foundation decision (#2a):** to distinguish 404 from 422, handlers fold "does not exist" into `BaseError` today (flat). Introduce a lightweight typed error (e.g. `NotFoundError : BaseError`) in `ErsatzTV.Core`; mapping helper returns 404 for it, 422 for other `BaseError`s. This is a Core/error-layer change (not an EF model change) — test-before/after applies where it touches existing handlers. If it proves to sprawl, fall back to per-controller existence checks and backlog the typed-error refactor. + +### 3.3 DTOs +- **Requests:** dedicated request DTOs (bound from JSON) → mapped to existing Commands. Stable external contract; decouples the wire shape from internal command records. +- **Responses:** **reuse existing `*ViewModel`s** (already the camelCase read contract used by current GETs). One contract, already proven. + +### 3.4 Validation +Reuse handler validation. Port the page-only checks in §2.3 into handlers so the API reaches parity with the Blazor UI. No validation logic in controllers. + +### 3.5 Auth — dedicated API key for writes (decoupled from IPTV) +**Mechanism:** a dedicated **API key** for mutations — a new config key (e.g. `Api__WriteKey`) checked by a filter applied **only to POST/PUT/DELETE on `/api/*`**. Enforced only when the key is configured (LAN-open default preserved); reads and all `/iptv/*` stay open. + +**Why not reuse the JWT scheme (important):** JWT is gated by a single global toggle, `JwtHelper.IsEnabled` ← `JWT:IssuerSigningKey` (`Startup.cs:166`). That **same toggle also gates the IPTV endpoints** (`/iptv/channels.m3u`, `/iptv/xmltv.xml`, streams — `ConditionalIptvAuthorizeFilter:18`) which **Jellyfin and Dispatcharr consume**. Enabling JWT to protect writes would force token auth onto those media feeds — and JWT tokens **expire in 1 day** (`JwtHelper.cs:27`), unsuitable for a standing tuner URL. A dedicated API key **decouples write-auth from media-consumer auth**: turning it on changes **nothing** for Jellyfin/Dispatcharr. + +**Properties:** long-lived credential (no 1-day churn), fit for MCP / new-UI write clients; net-new but small (one filter + one config key); a standard machine-API pattern. + +**Backlog:** tighten CORS for mutation routes if writes are exposed beyond LAN. + +### 3.6 OpenAPI / discoverability +New endpoints carry `[EndpointGroupName("general")]` → appear in the existing `v1` doc + Scalar `/docs`. This *is* the MCP/UI enabler (tool-gen + client codegen). Keep summaries/`[ProducesResponseType]` accurate per action. + +### 3.7 Other conventions +- **Absolute URLs** in responses (logos/streams) use the **request-derived host**, same as M3U/XMLTV (`docs/m3u-xmltv.md`) — never bake a host. (Carries the #1 lesson forward.) +- **List endpoints:** add simple pagination where the underlying query supports it; defer rich filtering/sorting (backlog — the new UI will want it). +- **DELETE idempotency:** return 404 if the entity is already absent (clear over silently-204). EF cascade handles dependents (e.g. Channel→Playout cascade, ProgramSchedule→Items cascade) — integration tests prove this per slice. + +## 4. Standardization scope + +- Retrofit `FFmpegProfileController` + `SmartCollectionController` to idiomatic REST, each gated by **characterization tests** (capture current behavior → change → prove green). +- Opportunistic-fix policy: backlog/document unrelated issues found en route; fix-in-place only when limited-scope + useful-now, or when deferring would force rework of the new code. + +## 5. Increment plan (sub-issues under #2 as tracker) + +One slice = one branch = one PR. PR runs `test` + `migrations` (both required); merge to `main` adds `build` + smoke/E2E. Tests with **NUnit + Shouldly + NSubstitute**, run locally `TZ=UTC`. + +- **#2a (#34) — API foundation + Channels (pattern-setter).** Shared conventions: idiomatic routing, status-code mapping helpers (incl. `NotFoundError` typed error + 201/422), request-DTO pattern, response = ViewModels, `[EndpointGroupName("general")]`, API-key mutation auth filter (decoupled from the IPTV JWT toggle). Then Channels CRUD (POST/PUT/DELETE, confirm/standardize GET list + add GET `/{id}`). Port page-only channel validations into handlers. Tests: handler unit (success + 404/422), controller status-mapping, create→read→delete EF integration. +- **#2b (#35) — Collections** CRUD + add/remove items; **retrofit `SmartCollectionController`** to idiomatic (characterization tests first). +- **#2c (#36) — Schedules** CRUD + schedule items (**TPT-heavy** — the one to budget for). Design the item DTO around the `PlayoutMode` discriminator (One/Multiple/Flood/Duration); reuse `AddProgramScheduleItem` / `ReplaceProgramScheduleItems`. Integration test proving the correct TPT subtype rows are written. +- **#2d (#37) — Playouts** create (Classic + 4 kinds via discriminated DTO) / delete; keep existing reset. Validation already strong. +- **#2e (#38) — Standardization cleanup.** Retrofit `FFmpegProfileController`; OpenAPI/doc polish; CORS review for mutations; sweep for any other non-idiomatic `/api` endpoints; fold in backlog items gathered during #2a–#2d. + +**Sequencing:** #2a first (sets every convention the others copy), then #2b–#2d in parallel-able order, #2e last. Each slice ships its own read endpoints so the new UI gains coverage incrementally. + +## 6. Testing strategy (per slice) +- Handler unit tests: success + each failure (not-found→404, validation→422). +- Controller tests: status-code/`Location`/DTO mapping. +- EF integration test (real SQLite): create→read→delete proving TPT subtype creation + cascade correctness. +- Characterization tests before any existing-controller retrofit. +- Lean on existing nets: architecture tests (#12 — controllers in `ErsatzTV`, logic in `ErsatzTV.Application`), M3U goldens (#11) if a change touches `ToM3U`. + +## 7. Open items / backlog seeds +- CORS tightening for mutation routes (if exposed beyond LAN). +- List-endpoint filtering/sorting/pagination depth (new-UI driven). +- API-key provisioning UX for MCP/UI write clients (how a caller obtains/sets `Api__WriteKey`). +- Decide whether `NotFoundError` typed-error becomes a repo-wide convention or stays API-local. +```