From b197a2d9cb8d9f49c3463a4a534e9a0368604ced Mon Sep 17 00:00:00 2001 From: soup Date: Sat, 11 Oct 2025 20:06:46 +0200 Subject: [PATCH] Fix: Validate release/push download client configuration --- .../Indexers/ReleasePushControllerFixture.cs | 173 ++++++++++++++++++ .../Indexers/ReleasePushController.cs | 78 +++++++- 2 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 src/NzbDrone.Api.Test/v3/Indexers/ReleasePushControllerFixture.cs diff --git a/src/NzbDrone.Api.Test/v3/Indexers/ReleasePushControllerFixture.cs b/src/NzbDrone.Api.Test/v3/Indexers/ReleasePushControllerFixture.cs new file mode 100644 index 000000000..948a1b5a9 --- /dev/null +++ b/src/NzbDrone.Api.Test/v3/Indexers/ReleasePushControllerFixture.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using Moq; +using NLog; +using NUnit.Framework; +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Download; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; +using NzbDrone.Test.Common; +using Sonarr.Api.V3.Indexers; + +namespace NzbDrone.Api.Test.v3.Indexers; + +[TestFixture] +public class ReleasePushControllerFixture : TestBase +{ + [SetUp] + public void SetupController() + { + var qualityProfile = new QualityProfile + { + Items = new List + { + new() + { + Allowed = true, + Quality = Quality.Bluray720p + } + } + }; + + Mocker.SetConstant(LogManager.GetLogger(nameof(ReleasePushControllerFixture))); + + Mocker.GetMock() + .Setup(x => x.GetDefaultProfile(It.IsAny())) + .Returns(qualityProfile); + } + + private ReleaseResource BuildRelease(Action configure = null) + { + var resource = new ReleaseResource + { + Title = "Test Release", + DownloadUrl = "https://example.com/release.torrent", + PublishDate = DateTime.UtcNow, + Protocol = DownloadProtocol.Torrent + }; + + configure?.Invoke(resource); + + return resource; + } + + [Test] + public void should_fail_when_download_client_name_unknown() + { + Mocker.GetMock() + .Setup(x => x.All()) + .Returns(new List()); + + var release = BuildRelease(r => r.DownloadClient = "missing-client"); + + var exception = Assert.Throws(() => Subject.Create(release)); + + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Errors.Select(e => e.PropertyName), Does.Contain("DownloadClient")); + } + + [Test] + public void should_fail_when_download_client_name_disabled() + { + var disabledClient = new DownloadClientDefinition + { + Id = 5, + Name = "Disabled Client", + Enable = false + }; + + Mocker.GetMock() + .Setup(x => x.All()) + .Returns(new List { disabledClient }); + + var release = BuildRelease(r => r.DownloadClient = "Disabled Client"); + + var exception = Assert.Throws(() => Subject.Create(release)); + + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Errors.Select(e => e.PropertyName), Does.Contain("DownloadClient")); + } + + [Test] + public void should_fail_when_download_client_id_unknown() + { + const int requestedId = 42; + + Mocker.GetMock() + .Setup(x => x.Get(requestedId)) + .Throws(new ModelNotFoundException(typeof(DownloadClientDefinition), requestedId)); + + var release = BuildRelease(r => r.DownloadClientId = requestedId); + + var exception = Assert.Throws(() => Subject.Create(release)); + + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Errors.Select(e => e.PropertyName), Does.Contain("DownloadClientId")); + } + + [Test] + public void should_fail_when_download_client_id_disabled() + { + const int requestedId = 11; + + var disabledClient = new DownloadClientDefinition + { + Id = requestedId, + Name = "Disabled Client", + Enable = false + }; + + Mocker.GetMock() + .Setup(x => x.Get(requestedId)) + .Returns(disabledClient); + + var release = BuildRelease(r => r.DownloadClientId = requestedId); + + var exception = Assert.Throws(() => Subject.Create(release)); + + Assert.That(exception, Is.Not.Null); + Assert.That(exception!.Errors.Select(e => e.PropertyName), Does.Contain("DownloadClientId")); + } + + [Test] + public void should_fail_when_download_client_name_and_id_mismatch() + { + const int requestedId = 7; + var definitionByName = new DownloadClientDefinition + { + Id = 21, + Name = "Known Client", + Enable = true + }; + var definitionById = new DownloadClientDefinition + { + Id = requestedId, + Name = "Different Client", + Enable = true + }; + + Mocker.GetMock() + .Setup(x => x.All()) + .Returns(new List { definitionByName }); + + Mocker.GetMock() + .Setup(x => x.Get(requestedId)) + .Returns(definitionById); + + var release = BuildRelease(r => + { + r.DownloadClient = "Known Client"; + r.DownloadClientId = requestedId; + }); + + var exception = Assert.Throws(() => Subject.Create(release)); + + Assert.That(exception, Is.Not.Null); + var properties = exception!.Errors.Select(e => e.PropertyName).ToList(); + Assert.That(properties, Does.Contain("DownloadClient")); + Assert.That(properties, Does.Contain("DownloadClientId")); + } +} diff --git a/src/Sonarr.Api.V3/Indexers/ReleasePushController.cs b/src/Sonarr.Api.V3/Indexers/ReleasePushController.cs index b5ad89cc7..920a6b633 100644 --- a/src/Sonarr.Api.V3/Indexers/ReleasePushController.cs +++ b/src/Sonarr.Api.V3/Indexers/ReleasePushController.cs @@ -120,20 +120,84 @@ private void ResolveIndexer(ReleaseInfo release) private int? ResolveDownloadClientId(ReleaseResource release) { - var downloadClientId = release.DownloadClientId.GetValueOrDefault(); + var requestedId = release.DownloadClientId.GetValueOrDefault(); + var requestedName = release.DownloadClient?.Trim(); + var resolvedClientByName = default(DownloadClientDefinition); + var resolvedClient = default(DownloadClientDefinition); - if (downloadClientId == 0 && release.DownloadClient.IsNotNullOrWhiteSpace()) + if (requestedName.IsNotNullOrWhiteSpace()) { - var downloadClient = _downloadClientFactory.All().FirstOrDefault(v => v.Name.EqualsIgnoreCase(release.DownloadClient)); + resolvedClientByName = _downloadClientFactory.All() + .FirstOrDefault(v => v.Name.EqualsIgnoreCase(requestedName)); - if (downloadClient != null) + if (resolvedClientByName != null) { - _logger.Debug("Push Release {0} associated with download client {1} - {2}.", release.Title, downloadClientId, release.DownloadClient); + if (!resolvedClientByName.Enable) + { + throw new ValidationException(new List + { + new("DownloadClient", "Download client is disabled.", requestedName) + }); + } - return downloadClient.Id; + release.DownloadClient = resolvedClientByName.Name; + resolvedClient = resolvedClientByName; + } + else if (requestedId == 0) + { + throw new ValidationException(new List + { + new("DownloadClient", "Download client does not exist.", requestedName) + }); + } + } + + if (requestedId != 0) + { + DownloadClientDefinition clientById; + + try + { + clientById = _downloadClientFactory.Get(requestedId); + } + catch (ModelNotFoundException) + { + throw new ValidationException(new List + { + new("DownloadClientId", "Download client does not exist.", requestedId.ToString()) + }); } - _logger.Debug("Push Release {0} not associated with known download client {1}.", release.Title, release.DownloadClient); + if (resolvedClientByName != null && clientById.Id != resolvedClientByName.Id) + { + throw new ValidationException(new List + { + new("DownloadClientId", "Download client id does not match the provided name.", requestedId.ToString()), + new("DownloadClient", "Download client name does not match the provided id.", requestedName) + }); + } + + if (!clientById.Enable) + { + throw new ValidationException(new List + { + new("DownloadClientId", "Download client is disabled.", requestedId.ToString()) + }); + } + + if (resolvedClientByName == null && requestedName.IsNotNullOrWhiteSpace()) + { + release.DownloadClient = clientById.Name; + } + + resolvedClient = clientById; + } + + if (resolvedClient != null) + { + _logger.Debug("Push Release {0} associated with download client {1} - {2}.", release.Title, resolvedClient.Id, resolvedClient.Name); + + return resolvedClient.Id; } return release.DownloadClientId;