diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 83f064c7..a876c8e2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -27,6 +27,17 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + suite: + - name: api + make-target: up-api + - name: download-clients + make-target: up-dc + + name: e2e (${{ matrix.suite.name }}) + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -44,7 +55,7 @@ jobs: - name: Start services working-directory: e2e - run: make up + run: make ${{ matrix.suite.make-target }} env: PACKAGES_USERNAME: ${{ github.repository_owner }} PACKAGES_PAT: ${{ env.PACKAGES_PAT }} @@ -76,13 +87,13 @@ jobs: - name: Run E2E tests working-directory: e2e - run: npx playwright test + run: npx playwright test --project=${{ matrix.suite.name }} - name: Upload test results uses: actions/upload-artifact@v4 if: always() with: - name: e2e-test-results + name: e2e-test-results-${{ matrix.suite.name }} path: | e2e/playwright-report/ e2e/test-results/ diff --git a/code/backend/Cleanuparr.Api/Features/MalwareBlocker/Contracts/Requests/UpdateMalwareBlockerConfigRequest.cs b/code/backend/Cleanuparr.Api/Features/MalwareBlocker/Contracts/Requests/UpdateMalwareBlockerConfigRequest.cs index 5f2c32d2..ffd660ac 100644 --- a/code/backend/Cleanuparr.Api/Features/MalwareBlocker/Contracts/Requests/UpdateMalwareBlockerConfigRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/MalwareBlocker/Contracts/Requests/UpdateMalwareBlockerConfigRequest.cs @@ -18,6 +18,8 @@ public sealed record UpdateMalwareBlockerConfigRequest public bool ProcessNoContentId { get; init; } + public bool DeleteIfAnyFileBlocked { get; init; } + public BlocklistSettings Sonarr { get; init; } = new(); public BlocklistSettings Radarr { get; init; } = new(); @@ -38,6 +40,7 @@ public sealed record UpdateMalwareBlockerConfigRequest config.IgnorePrivate = IgnorePrivate; config.DeletePrivate = DeletePrivate; config.ProcessNoContentId = ProcessNoContentId; + config.DeleteIfAnyFileBlocked = DeleteIfAnyFileBlocked; config.Sonarr = Sonarr; config.Radarr = Radarr; config.Lidarr = Lidarr; diff --git a/code/backend/Cleanuparr.Domain/Enums/DeleteReason.cs b/code/backend/Cleanuparr.Domain/Enums/DeleteReason.cs index af3a9394..25e5edd4 100644 --- a/code/backend/Cleanuparr.Domain/Enums/DeleteReason.cs +++ b/code/backend/Cleanuparr.Domain/Enums/DeleteReason.cs @@ -11,4 +11,5 @@ public enum DeleteReason AllFilesSkipped, AllFilesSkippedByQBit, AllFilesBlocked, + AtLeastOneFileBlocked, } \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs index 3247dd79..1386100c 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/DelugeServiceTests.cs @@ -553,9 +553,9 @@ public class DelugeServiceTests : IClassFixture { } - private void SetMalwareBlockerContext() + private void SetMalwareBlockerContext(ContentBlockerConfig? config = null) { - ContextProvider.Set(new ContentBlockerConfig()); + ContextProvider.Set(config ?? new ContentBlockerConfig()); ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr); _fixture.BlocklistProvider @@ -655,5 +655,45 @@ public class DelugeServiceTests : IClassFixture .Received(1) .ChangeFilesPriority(hash, Arg.Is>(p => p.Count == 2 && p[0] == 1 && p[1] == 0)); } + + [Fact] + public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsChangeFilesPriority() + { + const string hash = "partial-malware-any-hash"; + DelugeService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true }); + + _fixture.ClientWrapper + .GetTorrentStatus(hash) + .Returns(MakeDownloadStatus(hash)); + + _fixture.ClientWrapper + .GetTorrentFiles(hash) + .Returns(new DelugeContents + { + Contents = new Dictionary + { + { "movie.mkv", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 0, Path = "movie.mkv" } }, + { "malware.exe", new DelugeFileOrDirectory { Type = "file", Priority = 1, Index = 1, Path = "malware.exe" } }, + }, + }); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("malware.exe")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked); + + await _fixture.ClientWrapper + .DidNotReceive() + .ChangeFilesPriority(Arg.Any(), Arg.Any>()); + } } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs index 6a24e4b9..3c1c83ce 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs @@ -1,7 +1,10 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.DownloadClient.QBittorrent; +using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using NSubstitute; using Newtonsoft.Json.Linq; @@ -1128,4 +1131,218 @@ public class QBitServiceTests : IClassFixture result.DeleteReason.ShouldBe(DeleteReason.None); } } + + public class BlockUnwantedFilesAsyncScenarios : QBitServiceTests + { + public BlockUnwantedFilesAsyncScenarios(QBitServiceFixture fixture) : base(fixture) + { + } + + private void SetMalwareBlockerContext(ContentBlockerConfig? config = null) + { + ContextProvider.Set(config ?? new ContentBlockerConfig()); + ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr); + + _fixture.BlocklistProvider + .GetBlocklistType(Arg.Any()) + .Returns(BlocklistType.Blacklist); + _fixture.BlocklistProvider + .GetPatterns(Arg.Any()) + .Returns(new ConcurrentBag()); + _fixture.BlocklistProvider + .GetRegexes(Arg.Any()) + .Returns(new ConcurrentBag()); + } + + private static TorrentInfo MakeTorrentInfo(string hash) => new() + { + Hash = hash, + Name = "Malware Torrent", + State = TorrentState.Downloading, + DownloadSpeed = 1000, + }; + + private static TorrentProperties MakeTorrentProperties(bool isPrivate = false) => new() + { + AdditionalData = new Dictionary + { + { "is_private", JToken.FromObject(isPrivate) }, + }, + }; + + private void StubClient(string hash, IReadOnlyList files, bool isPrivate = false) + { + _fixture.ClientWrapper + .GetTorrentListAsync(Arg.Is(q => q.Hashes != null && q.Hashes.Contains(hash))) + .Returns(new[] { MakeTorrentInfo(hash) }); + + _fixture.ClientWrapper + .GetTorrentTrackersAsync(hash) + .Returns(Array.Empty()); + + _fixture.ClientWrapper + .GetTorrentPropertiesAsync(hash) + .Returns(MakeTorrentProperties(isPrivate)); + + _fixture.ClientWrapper + .GetTorrentContentsAsync(hash) + .Returns(files); + } + + [Fact] + public async Task AllFilesAreMalware_MarksForRemoval_WithAllFilesBlockedReason() + { + const string hash = "all-malware-hash"; + QBitService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, + [ + new TorrentContent { Name = "malware.exe", Index = 0, Priority = TorrentContentPriority.Normal }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + } + + [Fact] + public async Task PartialMalware_CallsSetFilePriority_AndDoesNotMarkForRemoval() + { + const string hash = "partial-malware-hash"; + QBitService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, + [ + new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal }, + new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Normal }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("installer.exe")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + + await _fixture.ClientWrapper + .Received(1) + .SetFilePriorityAsync(hash, 1, TorrentContentPriority.Skip); + } + + [Fact] + public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsSetFilePriority() + { + const string hash = "partial-malware-any-hash"; + QBitService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true }); + + StubClient(hash, + [ + new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal }, + new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Normal }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("installer.exe")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked); + + await _fixture.ClientWrapper + .DidNotReceive() + .SetFilePriorityAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task NoUnwantedFiles_DoesNotMarkForRemoval() + { + const string hash = "clean-hash"; + QBitService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, + [ + new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + + await _fixture.ClientWrapper + .DidNotReceive() + .SetFilePriorityAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task AlreadySkippedFile_DoesNotTriggerEarlyReturn_WhenDeleteIfAnyFileBlocked() + { + const string hash = "already-skipped-hash"; + QBitService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true }); + + StubClient(hash, + [ + new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Normal }, + new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Skip }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + } + + [Fact] + public async Task AllFilesAlreadySkipped_NoNewMalware_DoesNotMarkForRemoval() + { + const string hash = "all-skipped-hash"; + QBitService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, + [ + new TorrentContent { Name = "movie.mkv", Index = 0, Priority = TorrentContentPriority.Skip }, + new TorrentContent { Name = "installer.exe", Index = 1, Priority = TorrentContentPriority.Skip }, + ]); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + } + } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs index ef0ac2d4..2db31eb7 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/RTorrentServiceTests.cs @@ -1,6 +1,11 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; using Cleanuparr.Domain.Entities.RTorrent.Response; using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.DownloadClient.RTorrent; +using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; using NSubstitute; using NSubstitute.ExceptionExtensions; using Shouldly; @@ -768,4 +773,176 @@ public class RTorrentServiceTests : IClassFixture result.DeleteReason.ShouldBe(DeleteReason.None); } } + + public class BlockUnwantedFilesAsyncScenarios : RTorrentServiceTests + { + public BlockUnwantedFilesAsyncScenarios(RTorrentServiceFixture fixture) : base(fixture) + { + } + + private void SetMalwareBlockerContext(ContentBlockerConfig? config = null) + { + ContextProvider.Set(config ?? new ContentBlockerConfig()); + ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr); + + _fixture.BlocklistProvider + .GetBlocklistType(Arg.Any()) + .Returns(BlocklistType.Blacklist); + _fixture.BlocklistProvider + .GetPatterns(Arg.Any()) + .Returns(new ConcurrentBag()); + _fixture.BlocklistProvider + .GetRegexes(Arg.Any()) + .Returns(new ConcurrentBag()); + } + + private static RTorrentTorrent MakeDownload(string hash, bool isPrivate = false) => new() + { + Hash = hash, + Name = "Malware Torrent", + IsPrivate = isPrivate ? 1 : 0, + State = 1, + Complete = 0, + DownRate = 1000, + SizeBytes = 1000, + CompletedBytes = 500, + }; + + private void StubClient(string hash, IReadOnlyList files, bool isPrivate = false) + { + _fixture.ClientWrapper.GetTorrentAsync(hash).Returns(MakeDownload(hash, isPrivate)); + _fixture.ClientWrapper.GetTrackersAsync(hash).Returns(new List()); + _fixture.ClientWrapper.GetTorrentFilesAsync(hash).Returns(files.ToList()); + } + + [Fact] + public async Task AllFilesAreMalware_MarksForRemoval_WithAllFilesBlockedReason() + { + const string hash = "ALL-MALWARE-HASH"; + RTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [new RTorrentFile { Index = 0, Path = "malware.exe", Priority = 1 }]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + } + + [Fact] + public async Task PartialMalware_CallsSetFilePriority_AndDoesNotMarkForRemoval() + { + const string hash = "PARTIAL-MALWARE-HASH"; + RTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, + [ + new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 }, + new RTorrentFile { Index = 1, Path = "installer.exe", Priority = 1 }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("installer.exe")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + + await _fixture.ClientWrapper + .Received(1) + .SetFilePriorityAsync(hash, 1, 0); + } + + [Fact] + public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsSetFilePriority() + { + const string hash = "PARTIAL-MALWARE-ANY-HASH"; + RTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true }); + + StubClient(hash, + [ + new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 }, + new RTorrentFile { Index = 1, Path = "installer.exe", Priority = 1 }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("installer.exe")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked); + + await _fixture.ClientWrapper + .DidNotReceive() + .SetFilePriorityAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task NoUnwantedFiles_DoesNotMarkForRemoval() + { + const string hash = "CLEAN-HASH"; + RTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 }]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + + await _fixture.ClientWrapper + .DidNotReceive() + .SetFilePriorityAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task AlreadySkippedFile_DoesNotTriggerEarlyReturn_WhenDeleteIfAnyFileBlocked() + { + const string hash = "ALREADY-SKIPPED-HASH"; + RTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true }); + + StubClient(hash, + [ + new RTorrentFile { Index = 0, Path = "movie.mkv", Priority = 1 }, + new RTorrentFile { Index = 1, Path = "installer.exe", Priority = 0 }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + } + } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs index 2fb730f2..0fe6234f 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/TransmissionServiceTests.cs @@ -1,7 +1,12 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Context; using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.DownloadClient.Transmission; +using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; using NSubstitute; +using Transmission.API.RPC.Arguments; using Transmission.API.RPC.Entity; using Shouldly; using Xunit; @@ -528,4 +533,163 @@ public class TransmissionServiceTests : IClassFixture()) + .Returns(BlocklistType.Blacklist); + _fixture.BlocklistProvider + .GetPatterns(Arg.Any()) + .Returns(new ConcurrentBag()); + _fixture.BlocklistProvider + .GetRegexes(Arg.Any()) + .Returns(new ConcurrentBag()); + } + + private void StubClient(string hash, (string Name, bool Wanted)[] files, bool isPrivate = false) + { + TorrentInfo torrentInfo = new() + { + Id = 42, + HashString = hash, + Name = "Malware Torrent", + Status = 4, + IsPrivate = isPrivate, + Files = files.Select(f => new TransmissionTorrentFiles { Name = f.Name }).ToArray(), + FileStats = files.Select(f => new TransmissionTorrentFileStats { Wanted = f.Wanted }).ToArray(), + }; + + _fixture.ClientWrapper + .TorrentGetAsync(Arg.Any(), hash) + .Returns(new TransmissionTorrents { Torrents = new[] { torrentInfo } }); + } + + [Fact] + public async Task AllFilesAreMalware_MarksForRemoval_WithAllFilesBlockedReason() + { + const string hash = "all-malware-hash"; + TransmissionService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [("malware.exe", true)]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + } + + [Fact] + public async Task PartialMalware_CallsTorrentSet_AndDoesNotMarkForRemoval() + { + const string hash = "partial-malware-hash"; + TransmissionService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [("movie.mkv", true), ("installer.exe", true)]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("installer.exe")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + + await _fixture.ClientWrapper + .Received(1) + .TorrentSetAsync(Arg.Any()); + } + + [Fact] + public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsTorrentSet() + { + const string hash = "partial-malware-any-hash"; + TransmissionService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true }); + + StubClient(hash, [("movie.mkv", true), ("installer.exe", true)]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("installer.exe")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked); + + await _fixture.ClientWrapper + .DidNotReceive() + .TorrentSetAsync(Arg.Any()); + } + + [Fact] + public async Task NoUnwantedFiles_DoesNotMarkForRemoval() + { + const string hash = "clean-hash"; + TransmissionService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [("movie.mkv", true)]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + + await _fixture.ClientWrapper + .DidNotReceive() + .TorrentSetAsync(Arg.Any()); + } + + [Fact] + public async Task AlreadyUnwantedFile_DoesNotTriggerEarlyReturn_WhenDeleteIfAnyFileBlocked() + { + const string hash = "already-skipped-hash"; + TransmissionService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true }); + + StubClient(hash, [("movie.mkv", true), ("installer.exe", false)]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + } + } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs index d8bc671e..5a9208b7 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrentServiceTests.cs @@ -1,6 +1,11 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; using Cleanuparr.Domain.Entities.UTorrent.Response; using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.DownloadClient; using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; +using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; using NSubstitute; using NSubstitute.ExceptionExtensions; using Shouldly; @@ -711,4 +716,178 @@ public class UTorrentServiceTests : IClassFixture result.ChangeCategory.ShouldBeTrue(); } } + + public class BlockUnwantedFilesAsyncScenarios : UTorrentServiceTests + { + public BlockUnwantedFilesAsyncScenarios(UTorrentServiceFixture fixture) : base(fixture) + { + } + + private void SetMalwareBlockerContext(ContentBlockerConfig? config = null) + { + ContextProvider.Set(config ?? new ContentBlockerConfig()); + ContextProvider.Set(nameof(InstanceType), (object)InstanceType.Sonarr); + + _fixture.BlocklistProvider + .GetBlocklistType(Arg.Any()) + .Returns(BlocklistType.Blacklist); + _fixture.BlocklistProvider + .GetPatterns(Arg.Any()) + .Returns(new ConcurrentBag()); + _fixture.BlocklistProvider + .GetRegexes(Arg.Any()) + .Returns(new ConcurrentBag()); + } + + private void StubClient(string hash, IReadOnlyList files, bool isPrivate = false) + { + UTorrentItem item = new() + { + Hash = hash, + Name = "Malware Torrent", + Status = 9, + DownloadSpeed = 1000, + }; + UTorrentProperties properties = new() + { + Hash = hash, + Pex = isPrivate ? -1 : 0, + Trackers = string.Empty, + }; + + _fixture.ClientWrapper.GetTorrentAsync(hash).Returns(item); + _fixture.ClientWrapper.GetTorrentPropertiesAsync(hash).Returns(properties); + _fixture.ClientWrapper.GetTorrentFilesAsync(hash).Returns(files.ToList()); + } + + [Fact] + public async Task AllFilesAreMalware_MarksForRemoval_WithAllFilesBlockedReason() + { + const string hash = "all-malware-hash"; + UTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [new UTorrentFile { Name = "malware.exe", Index = 0, Priority = 2, Size = 1024, Downloaded = 1024 }]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked); + } + + [Fact] + public async Task PartialMalware_CallsSetFilesPriority_AndDoesNotMarkForRemoval() + { + const string hash = "partial-malware-hash"; + UTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, + [ + new UTorrentFile { Name = "movie.mkv", Index = 0, Priority = 2, Size = 32_768, Downloaded = 32_768 }, + new UTorrentFile { Name = "installer.exe", Index = 1, Priority = 2, Size = 1024, Downloaded = 1024 }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("installer.exe")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + + await _fixture.ClientWrapper + .Received(1) + .SetFilesPriorityAsync(hash, Arg.Is>(idx => idx.Count == 1 && idx[0] == 1), 0); + } + + [Fact] + public async Task PartialMalware_WithDeleteIfAnyFileBlocked_MarksForRemoval_AndSkipsSetFilesPriority() + { + const string hash = "partial-malware-any-hash"; + UTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true }); + + StubClient(hash, + [ + new UTorrentFile { Name = "movie.mkv", Index = 0, Priority = 2, Size = 32_768, Downloaded = 32_768 }, + new UTorrentFile { Name = "installer.exe", Index = 1, Priority = 2, Size = 1024, Downloaded = 1024 }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("installer.exe")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(false); + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeTrue(); + result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked); + + await _fixture.ClientWrapper + .DidNotReceive() + .SetFilesPriorityAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task NoUnwantedFiles_DoesNotMarkForRemoval() + { + const string hash = "clean-hash"; + UTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(); + + StubClient(hash, [new UTorrentFile { Name = "movie.mkv", Index = 0, Priority = 2, Size = 32_768, Downloaded = 32_768 }]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + + await _fixture.ClientWrapper + .DidNotReceive() + .SetFilesPriorityAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task AlreadySkippedFile_DoesNotTriggerEarlyReturn_WhenDeleteIfAnyFileBlocked() + { + const string hash = "already-skipped-hash"; + UTorrentService sut = _fixture.CreateSut(); + SetMalwareBlockerContext(new ContentBlockerConfig { DeleteIfAnyFileBlocked = true }); + + StubClient(hash, + [ + new UTorrentFile { Name = "movie.mkv", Index = 0, Priority = 2, Size = 32_768, Downloaded = 32_768 }, + new UTorrentFile { Name = "installer.exe", Index = 1, Priority = 0, Size = 1024, Downloaded = 1024 }, + ]); + + _fixture.FilenameEvaluator + .IsValid(Arg.Is(name => name.EndsWith("movie.mkv")), Arg.Any(), Arg.Any>(), Arg.Any>()) + .Returns(true); + + BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty()); + + result.Found.ShouldBeTrue(); + result.ShouldRemove.ShouldBeFalse(); + result.DeleteReason.ShouldBe(DeleteReason.None); + } + } } diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs index b7889fc7..844b5d31 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs @@ -362,6 +362,76 @@ public class MalwareBlockerTests : IDisposable ); } + [Fact] + public async Task ProcessInstanceAsync_WhenShouldRemoveWithAtLeastOneFileBlocked_PublishesRemoveRequest() + { + // Arrange + TestDataContextFactory.AddDownloadClient(_fixture.DataContext); + EnableSonarrBlocklist(); + TestDataContextFactory.AddSonarrInstance(_fixture.DataContext); + + IArrClient mockArrClient = Substitute.For(); + mockArrClient.IsRecordValid(Arg.Any()).Returns(true); + mockArrClient.HasContentId(Arg.Any()).Returns(true); + + _fixture.ArrClientFactory + .GetClient(InstanceType.Sonarr, Arg.Any()) + .Returns(mockArrClient); + + QueueRecord queueRecord = new() + { + Id = 1, + DownloadId = "any-file-blocked-download-id", + Title = "Mixed Malware Download", + Protocol = "torrent", + SeriesId = 1, + EpisodeId = 1 + }; + + _fixture.ArrQueueIterator + .Iterate( + Arg.Any(), + Arg.Any(), + Arg.Any, Task>>() + ) + .Returns(ci => + { + var callback = ci.ArgAt, Task>>(2); + return callback([queueRecord]); + }); + + IDownloadService mockDownloadService = _fixture.CreateMockDownloadService(); + mockDownloadService + .BlockUnwantedFilesAsync( + Arg.Any(), + Arg.Any>() + ) + .Returns(new BlockFilesResult + { + Found = true, + ShouldRemove = true, + IsPrivate = false, + DeleteReason = DeleteReason.AtLeastOneFileBlocked + }); + + _fixture.DownloadServiceFactory + .GetDownloadService(Arg.Any()) + .Returns(mockDownloadService); + + MalwareBlockerJob sut = CreateSut(); + + // Act + await sut.ExecuteAsync(); + + // Assert + await _fixture.MessageBus.Received(1).Publish( + Arg.Is>(r => + r.DeleteReason == DeleteReason.AtLeastOneFileBlocked + ), + Arg.Any() + ); + } + [Fact] public async Task ProcessInstanceAsync_WhenPrivateAndDeletePrivateFalse_DoesNotRemoveFromClient() { diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs index eb43734d..5693ae60 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Deluge/DelugeServiceCB.cs @@ -92,6 +92,14 @@ public partial class DelugeService priority = 0; hasPriorityUpdates = true; _logger.LogInformation("unwanted file found | {file}", file.Path); + + if (malwareBlockerConfig.DeleteIfAnyFileBlocked) + { + _logger.LogDebug("at least one file is blocked for {name}", download.Name); + result.ShouldRemove = true; + result.DeleteReason = DeleteReason.AtLeastOneFileBlocked; + return; + } } _logger.LogTrace("File is valid | {file}", file.Path); diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs index 0cddcf90..76dbd406 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitServiceCB.cs @@ -99,6 +99,15 @@ public partial class QBitService } _logger.LogInformation("unwanted file found | {file}", file.Name); + + if (malwareBlockerConfig.DeleteIfAnyFileBlocked) + { + _logger.LogDebug("at least one file is blocked for {name}", download.Name); + result.ShouldRemove = true; + result.DeleteReason = DeleteReason.AtLeastOneFileBlocked; + return result; + } + unwantedFiles.Add(file.Index.Value); totalUnwantedFiles++; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs index 46e3d041..9f184eaf 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/RTorrent/RTorrentServiceCB.cs @@ -98,6 +98,15 @@ public partial class RTorrentService hasPriorityUpdates = true; priorityUpdates.Add((file.Index, 0)); _logger.LogInformation("unwanted file found | {file}", file.Path); + + if (malwareBlockerConfig.DeleteIfAnyFileBlocked) + { + _logger.LogDebug("at least one file is blocked for {name}", download.Name); + result.ShouldRemove = true; + result.DeleteReason = DeleteReason.AtLeastOneFileBlocked; + return result; + } + continue; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs index 92e0cf14..c3dcbbad 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/Transmission/TransmissionServiceCB.cs @@ -82,6 +82,15 @@ public partial class TransmissionService } _logger.LogInformation("unwanted file found | {file}", download.Files[i].Name); + + if (malwareBlockerConfig.DeleteIfAnyFileBlocked) + { + _logger.LogDebug("at least one file is blocked for {name}", download.Name); + result.ShouldRemove = true; + result.DeleteReason = DeleteReason.AtLeastOneFileBlocked; + return result; + } + unwantedFiles.Add(i); totalUnwantedFiles++; } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs index 6a9db4b0..daf86822 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/UTorrent/UTorrentServiceCB.cs @@ -78,6 +78,14 @@ public partial class UTorrentService totalUnwantedFiles++; fileIndexes.Add(i); _logger.LogInformation("unwanted file found | {file}", file.Name); + + if (malwareBlockerConfig.DeleteIfAnyFileBlocked) + { + _logger.LogDebug("at least one file is blocked for {name}", download.Name); + result.ShouldRemove = true; + result.DeleteReason = DeleteReason.AtLeastOneFileBlocked; + return result; + } } } diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260528201320_AddDeleteIfAnyFileBlocked.Designer.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260528201320_AddDeleteIfAnyFileBlocked.Designer.cs new file mode 100644 index 00000000..373e53f8 --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260528201320_AddDeleteIfAnyFileBlocked.Designer.cs @@ -0,0 +1,2200 @@ +// +using System; +using System.Collections.Generic; +using Cleanuparr.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + [DbContext(typeof(DataContext))] + [Migration("20260528201320_AddDeleteIfAnyFileBlocked")] + partial class AddDeleteIfAnyFileBlocked + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("FailedImportMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_arr_configs"); + + b.ToTable("arr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ArrConfigId") + .HasColumnType("TEXT") + .HasColumnName("arr_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.Property("Version") + .HasColumnType("REAL") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pk_arr_instances"); + + b.HasIndex("ArrConfigId") + .HasDatabaseName("ix_arr_instances_arr_config_id"); + + b.ToTable("arr_instances", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.BlacklistSync.BlacklistSyncConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BlacklistPath") + .HasColumnType("TEXT") + .HasColumnName("blacklist_path"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_configs"); + + b.ToTable("blacklist_sync_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_deluge_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_deluge_seeding_rules_download_client_config_id"); + + b.ToTable("deluge_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DownloadCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.HasKey("Id") + .HasName("pk_download_cleaner_configs"); + + b.ToTable("download_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExcludePatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("exclude_patterns"); + + b.Property("MinFileAgeHours") + .HasColumnType("INTEGER") + .HasColumnName("min_file_age_hours"); + + b.Property("OrphanedDirectory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("orphaned_directory"); + + b.Property("PurgeAfterHours") + .HasColumnType("INTEGER") + .HasColumnName("purge_after_hours"); + + b.Property("ScanDirectories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("scan_directories"); + + b.HasKey("Id") + .HasName("pk_orphaned_files_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_orphaned_files_configs_download_client_config_id"); + + b.ToTable("orphaned_files_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_q_bit_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_q_bit_seeding_rules_download_client_config_id"); + + b.ToTable("q_bit_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_r_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_r_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("r_torrent_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TagsAll") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_all"); + + b.Property("TagsAny") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags_any"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_transmission_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_transmission_seeding_rules_download_client_config_id"); + + b.ToTable("transmission_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DeleteSourceFiles") + .HasColumnType("INTEGER") + .HasColumnName("delete_source_files"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("MaxRatio") + .HasColumnType("REAL") + .HasColumnName("max_ratio"); + + b.Property("MaxSeedTime") + .HasColumnType("REAL") + .HasColumnName("max_seed_time"); + + b.Property("MinSeedTime") + .HasColumnType("REAL") + .HasColumnName("min_seed_time"); + + b.Property("MinSeeders") + .HasColumnType("INTEGER") + .HasColumnName("min_seeders"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("TrackerPatterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tracker_patterns"); + + b.HasKey("Id") + .HasName("pk_u_torrent_seeding_rules"); + + b.HasIndex("DownloadClientConfigId") + .HasDatabaseName("ix_u_torrent_seeding_rules_download_client_config_id"); + + b.ToTable("u_torrent_seeding_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.PrimitiveCollection("Categories") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("categories"); + + b.Property("DownloadClientConfigId") + .HasColumnType("TEXT") + .HasColumnName("download_client_config_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredRootDirs") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_root_dirs"); + + b.Property("TargetCategory") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("target_category"); + + b.Property("UseTag") + .HasColumnType("INTEGER") + .HasColumnName("use_tag"); + + b.HasKey("Id") + .HasName("pk_unlinked_configs"); + + b.HasIndex("DownloadClientConfigId") + .IsUnique() + .HasDatabaseName("ix_unlinked_configs_download_client_config_id"); + + b.ToTable("unlinked_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadDirectorySource") + .HasColumnType("TEXT") + .HasColumnName("download_directory_source"); + + b.Property("DownloadDirectoryTarget") + .HasColumnType("TEXT") + .HasColumnName("download_directory_target"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("ExternalUrl") + .HasColumnType("TEXT") + .HasColumnName("external_url"); + + b.Property("Host") + .HasColumnType("TEXT") + .HasColumnName("host"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Password") + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("TypeName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type_name"); + + b.Property("UrlBase") + .HasColumnType("TEXT") + .HasColumnName("url_base"); + + b.Property("Username") + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_download_clients"); + + b.ToTable("download_clients", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DisplaySupportBanner") + .HasColumnType("INTEGER") + .HasColumnName("display_support_banner"); + + b.Property("DryRun") + .HasColumnType("INTEGER") + .HasColumnName("dry_run"); + + b.Property("EncryptionKey") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("encryption_key"); + + b.Property("HttpCertificateValidation") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("http_certificate_validation"); + + b.Property("HttpMaxRetries") + .HasColumnType("INTEGER") + .HasColumnName("http_max_retries"); + + b.Property("HttpTimeout") + .HasColumnType("INTEGER") + .HasColumnName("http_timeout"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("StatusCheckEnabled") + .HasColumnType("INTEGER") + .HasColumnName("status_check_enabled"); + + b.Property("StrikeInactivityWindowHours") + .HasColumnType("INTEGER") + .HasColumnName("strike_inactivity_window_hours"); + + b.ComplexProperty(typeof(Dictionary), "Auth", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Auth#AuthConfig", b1 => + { + b1.IsRequired(); + + b1.Property("DisableAuthForLocalAddresses") + .HasColumnType("INTEGER") + .HasColumnName("auth_disable_auth_for_local_addresses"); + + b1.Property("TrustForwardedHeaders") + .HasColumnType("INTEGER") + .HasColumnName("auth_trust_forwarded_headers"); + + b1.Property("TrustedNetworks") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("auth_trusted_networks"); + }); + + b.ComplexProperty(typeof(Dictionary), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ArchiveEnabled") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_enabled"); + + b1.Property("ArchiveRetainedCount") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_retained_count"); + + b1.Property("ArchiveTimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_archive_time_limit_hours"); + + b1.Property("Level") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("log_level"); + + b1.Property("RetainedFileCount") + .HasColumnType("INTEGER") + .HasColumnName("log_retained_file_count"); + + b1.Property("RollingSizeMB") + .HasColumnType("INTEGER") + .HasColumnName("log_rolling_size_mb"); + + b1.Property("TimeLimitHours") + .HasColumnType("INTEGER") + .HasColumnName("log_time_limit_hours"); + }); + + b.HasKey("Id") + .HasName("pk_general_configs"); + + b.ToTable("general_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DeleteIfAnyFileBlocked") + .HasColumnType("INTEGER") + .HasColumnName("delete_if_any_file_blocked"); + + b.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("delete_private"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("ignore_private"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("lidarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("lidarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("radarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("radarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("readarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("readarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_path"); + + b1.Property("BlocklistType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("sonarr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("sonarr_enabled"); + }); + + b.ComplexProperty(typeof(Dictionary), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 => + { + b1.IsRequired(); + + b1.Property("BlocklistPath") + .HasColumnType("TEXT") + .HasColumnName("whisparr_blocklist_path"); + + b1.Property("BlocklistType") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_blocklist_type"); + + b1.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("whisparr_enabled"); + }); + + b.HasKey("Id") + .HasName("pk_content_blocker_configs"); + + b.ToTable("content_blocker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("key"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("mode"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("ServiceUrls") + .HasMaxLength(4000) + .HasColumnType("TEXT") + .HasColumnName("service_urls"); + + b.Property("Tags") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("url"); + + b.HasKey("Id") + .HasName("pk_apprise_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_apprise_configs_notification_config_id"); + + b.ToTable("apprise_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AvatarUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("avatar_url"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.Property("WebhookUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("webhook_url"); + + b.HasKey("Id") + .HasName("pk_discord_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_discord_configs_notification_config_id"); + + b.ToTable("discord_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApplicationToken") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT") + .HasColumnName("application_token"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .HasColumnType("INTEGER") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.HasKey("Id") + .HasName("pk_gotify_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_gotify_configs_notification_config_id"); + + b.ToTable("gotify_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("api_key"); + + b.Property("ChannelId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("channel_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.HasKey("Id") + .HasName("pk_notifiarr_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_notifiarr_configs_notification_config_id"); + + b.ToTable("notifiarr_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER") + .HasColumnName("is_enabled"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("OnCategoryChanged") + .HasColumnType("INTEGER") + .HasColumnName("on_category_changed"); + + b.Property("OnDownloadCleaned") + .HasColumnType("INTEGER") + .HasColumnName("on_download_cleaned"); + + b.Property("OnFailedImportStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_failed_import_strike"); + + b.Property("OnQueueItemDeleted") + .HasColumnType("INTEGER") + .HasColumnName("on_queue_item_deleted"); + + b.Property("OnSearchItemGrabbed") + .HasColumnType("INTEGER") + .HasColumnName("on_search_item_grabbed"); + + b.Property("OnSearchTriggered") + .HasColumnType("INTEGER") + .HasColumnName("on_search_triggered"); + + b.Property("OnSlowStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_slow_strike"); + + b.Property("OnStalledStrike") + .HasColumnType("INTEGER") + .HasColumnName("on_stalled_strike"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_configs"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_notification_configs_name"); + + b.ToTable("notification_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AccessToken") + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("access_token"); + + b.Property("AuthenticationType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("authentication_type"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Password") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("password"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("ServerUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.PrimitiveCollection("Topics") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("topics"); + + b.Property("Username") + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_ntfy_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_ntfy_configs_notification_config_id"); + + b.ToTable("ntfy_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ApiToken") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("api_token"); + + b.Property("Devices") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("devices"); + + b.Property("Expire") + .HasColumnType("INTEGER") + .HasColumnName("expire"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("Priority") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("priority"); + + b.Property("Retry") + .HasColumnType("INTEGER") + .HasColumnName("retry"); + + b.Property("Sound") + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("sound"); + + b.Property("Tags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("tags"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasColumnName("user_key"); + + b.HasKey("Id") + .HasName("pk_pushover_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_pushover_configs_notification_config_id"); + + b.ToTable("pushover_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BotToken") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .HasColumnName("bot_token"); + + b.Property("ChatId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("chat_id"); + + b.Property("NotificationConfigId") + .HasColumnType("TEXT") + .HasColumnName("notification_config_id"); + + b.Property("SendSilently") + .HasColumnType("INTEGER") + .HasColumnName("send_silently"); + + b.Property("TopicId") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("topic_id"); + + b.HasKey("Id") + .HasName("pk_telegram_configs"); + + b.HasIndex("NotificationConfigId") + .IsUnique() + .HasDatabaseName("ix_telegram_configs_notification_config_id"); + + b.ToTable("telegram_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CronExpression") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("cron_expression"); + + b.Property("DownloadingMetadataMaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("downloading_metadata_max_strikes"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.PrimitiveCollection("IgnoredDownloads") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ignored_downloads"); + + b.Property("ProcessNoContentId") + .HasColumnType("INTEGER") + .HasColumnName("process_no_content_id"); + + b.Property("UseAdvancedScheduling") + .HasColumnType("INTEGER") + .HasColumnName("use_advanced_scheduling"); + + b.ComplexProperty(typeof(Dictionary), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 => + { + b1.IsRequired(); + + b1.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_change_category"); + + b1.Property("DeletePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_delete_private"); + + b1.Property("IgnorePrivate") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_ignore_private"); + + b1.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_max_strikes"); + + b1.Property("PatternMode") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_pattern_mode"); + + b1.PrimitiveCollection("Patterns") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("failed_import_patterns"); + + b1.Property("SkipIfNotFoundInClient") + .HasColumnType("INTEGER") + .HasColumnName("failed_import_skip_if_not_found_in_client"); + }); + + b.HasKey("Id") + .HasName("pk_queue_cleaner_configs"); + + b.ToTable("queue_cleaner_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("change_category"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("IgnoreAboveSize") + .HasColumnType("TEXT") + .HasColumnName("ignore_above_size"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MaxTimeHours") + .HasColumnType("REAL") + .HasColumnName("max_time_hours"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinSpeed") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("min_speed"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_slow_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_slow_rules_queue_cleaner_config_id"); + + b.ToTable("slow_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ChangeCategory") + .HasColumnType("INTEGER") + .HasColumnName("change_category"); + + b.Property("DeletePrivateTorrentsFromClient") + .HasColumnType("INTEGER") + .HasColumnName("delete_private_torrents_from_client"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("MaxCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("max_completion_percentage"); + + b.Property("MaxStrikes") + .HasColumnType("INTEGER") + .HasColumnName("max_strikes"); + + b.Property("MinCompletionPercentage") + .HasColumnType("INTEGER") + .HasColumnName("min_completion_percentage"); + + b.Property("MinimumProgress") + .HasColumnType("TEXT") + .HasColumnName("minimum_progress"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("PrivacyType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("privacy_type"); + + b.Property("QueueCleanerConfigId") + .HasColumnType("TEXT") + .HasColumnName("queue_cleaner_config_id"); + + b.Property("ResetStrikesOnProgress") + .HasColumnType("INTEGER") + .HasColumnName("reset_strikes_on_progress"); + + b.HasKey("Id") + .HasName("pk_stall_rules"); + + b.HasIndex("QueueCleanerConfigId") + .HasDatabaseName("ix_stall_rules_queue_cleaner_config_id"); + + b.ToTable("stall_rules", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("PostReleaseGraceHours") + .HasColumnType("INTEGER") + .HasColumnName("post_release_grace_hours"); + + b.Property("ProactiveSearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("proactive_search_enabled"); + + b.Property("SearchEnabled") + .HasColumnType("INTEGER") + .HasColumnName("search_enabled"); + + b.Property("SearchInterval") + .HasColumnType("INTEGER") + .HasColumnName("search_interval"); + + b.Property("SelectionStrategy") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("selection_strategy"); + + b.Property("UseRoundRobin") + .HasColumnType("INTEGER") + .HasColumnName("use_round_robin"); + + b.HasKey("Id") + .HasName("pk_seeker_configs"); + + b.ToTable("seeker_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ActiveDownloadLimit") + .HasColumnType("INTEGER") + .HasColumnName("active_download_limit"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentCycleId") + .HasColumnType("TEXT") + .HasColumnName("current_cycle_id"); + + b.Property("Enabled") + .HasColumnType("INTEGER") + .HasColumnName("enabled"); + + b.Property("LastProcessedAt") + .HasColumnType("TEXT") + .HasColumnName("last_processed_at"); + + b.Property("MinCycleTimeDays") + .HasColumnType("INTEGER") + .HasColumnName("min_cycle_time_days"); + + b.Property("MonitoredOnly") + .HasColumnType("INTEGER") + .HasColumnName("monitored_only"); + + b.PrimitiveCollection("SkipTags") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("skip_tags"); + + b.Property("TotalEligibleItems") + .HasColumnType("INTEGER") + .HasColumnName("total_eligible_items"); + + b.Property("UseCustomFormatScore") + .HasColumnType("INTEGER") + .HasColumnName("use_custom_format_score"); + + b.Property("UseCutoff") + .HasColumnType("INTEGER") + .HasColumnName("use_cutoff"); + + b.HasKey("Id") + .HasName("pk_seeker_instance_configs"); + + b.HasIndex("ArrInstanceId") + .IsUnique() + .HasDatabaseName("ix_seeker_instance_configs_arr_instance_id"); + + b.ToTable("seeker_instance_configs", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT") + .HasColumnName("download_client_id"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("hash"); + + b.HasKey("Id") + .HasName("pk_blacklist_sync_history"); + + b.HasIndex("DownloadClientId") + .HasDatabaseName("ix_blacklist_sync_history_download_client_id"); + + b.HasIndex("Hash") + .HasDatabaseName("ix_blacklist_sync_history_hash"); + + b.HasIndex("Hash", "DownloadClientId") + .IsUnique() + .HasDatabaseName("ix_blacklist_sync_history_hash_download_client_id"); + + b.ToTable("blacklist_sync_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CurrentScore") + .HasColumnType("INTEGER") + .HasColumnName("current_score"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("FileId") + .HasColumnType("INTEGER") + .HasColumnName("file_id"); + + b.Property("IsMonitored") + .HasColumnType("INTEGER") + .HasColumnName("is_monitored"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT") + .HasColumnName("last_synced_at"); + + b.Property("LastUpgradedAt") + .HasColumnType("TEXT") + .HasColumnName("last_upgraded_at"); + + b.Property("QualityProfileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("quality_profile_name"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_entries"); + + b.HasIndex("LastUpgradedAt") + .HasDatabaseName("ix_custom_format_score_entries_last_upgraded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .IsUnique() + .HasDatabaseName("ix_custom_format_score_entries_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_entries", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CutoffScore") + .HasColumnType("INTEGER") + .HasColumnName("cutoff_score"); + + b.Property("EpisodeId") + .HasColumnType("INTEGER") + .HasColumnName("episode_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("RecordedAt") + .HasColumnType("TEXT") + .HasColumnName("recorded_at"); + + b.Property("Score") + .HasColumnType("INTEGER") + .HasColumnName("score"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_custom_format_score_history"); + + b.HasIndex("RecordedAt") + .HasDatabaseName("ix_custom_format_score_history_recorded_at"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "EpisodeId") + .HasDatabaseName("ix_custom_format_score_history_arr_instance_id_external_item_id_episode_id"); + + b.ToTable("custom_format_score_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("ItemId") + .HasColumnType("INTEGER") + .HasColumnName("item_id"); + + b.Property("SearchType") + .HasColumnType("TEXT") + .HasColumnName("search_type"); + + b.Property("SeriesId") + .HasColumnType("INTEGER") + .HasColumnName("series_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id") + .HasName("pk_search_queue"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_search_queue_arr_instance_id"); + + b.ToTable("search_queue", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CommandId") + .HasColumnType("INTEGER") + .HasColumnName("command_id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("EventId") + .HasColumnType("TEXT") + .HasColumnName("event_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_seeker_command_trackers"); + + b.HasIndex("ArrInstanceId") + .HasDatabaseName("ix_seeker_command_trackers_arr_instance_id"); + + b.ToTable("seeker_command_trackers", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ArrInstanceId") + .HasColumnType("TEXT") + .HasColumnName("arr_instance_id"); + + b.Property("CycleId") + .HasColumnType("TEXT") + .HasColumnName("cycle_id"); + + b.Property("ExternalItemId") + .HasColumnType("INTEGER") + .HasColumnName("external_item_id"); + + b.Property("IsDryRun") + .HasColumnType("INTEGER") + .HasColumnName("is_dry_run"); + + b.Property("ItemTitle") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_title"); + + b.Property("ItemType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("item_type"); + + b.Property("LastSearchedAt") + .HasColumnType("TEXT") + .HasColumnName("last_searched_at"); + + b.Property("SearchCount") + .HasColumnType("INTEGER") + .HasColumnName("search_count"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER") + .HasColumnName("season_number"); + + b.HasKey("Id") + .HasName("pk_seeker_history"); + + b.HasIndex("ArrInstanceId", "ExternalItemId", "ItemType", "SeasonNumber", "CycleId") + .IsUnique() + .HasDatabaseName("ix_seeker_history_arr_instance_id_external_item_id_item_type_season_number_cycle_id"); + + b.ToTable("seeker_history", (string)null); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", "ArrConfig") + .WithMany("Instances") + .HasForeignKey("ArrConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_arr_instances_arr_configs_arr_config_id"); + + b.Navigation("ArrConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.DelugeSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_deluge_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.OrphanedFilesConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_orphaned_files_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.QBitSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_q_bit_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.RTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_r_torrent_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.TransmissionSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_transmission_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UTorrentSeedingRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_u_torrent_seeding_rules_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.DownloadCleaner.UnlinkedConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClientConfig") + .WithMany() + .HasForeignKey("DownloadClientConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_unlinked_configs_download_clients_download_client_config_id"); + + b.Navigation("DownloadClientConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("AppriseConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.AppriseConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_apprise_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("DiscordConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_discord_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("GotifyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gotify_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NotifiarrConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifiarr_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("NtfyConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.NtfyConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ntfy_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("PushoverConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.PushoverConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pushover_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig") + .WithOne("TelegramConfiguration") + .HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.TelegramConfig", "NotificationConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_telegram_configs_notification_configs_notification_config_id"); + + b.Navigation("NotificationConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.SlowRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("SlowRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_slow_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.StallRule", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", "QueueCleanerConfig") + .WithMany("StallRules") + .HasForeignKey("QueueCleanerConfigId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_stall_rules_queue_cleaner_configs_queue_cleaner_config_id"); + + b.Navigation("QueueCleanerConfig"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Seeker.SeekerInstanceConfig", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_instance_configs_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.BlacklistSyncHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.DownloadClientConfig", "DownloadClient") + .WithMany() + .HasForeignKey("DownloadClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_blacklist_sync_history_download_clients_download_client_id"); + + b.Navigation("DownloadClient"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreEntry", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_entries_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.CustomFormatScoreHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_custom_format_score_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SearchQueueItem", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_search_queue_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerCommandTracker", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_command_trackers_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.State.SeekerHistory", b => + { + b.HasOne("Cleanuparr.Persistence.Models.Configuration.Arr.ArrInstance", "ArrInstance") + .WithMany() + .HasForeignKey("ArrInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_seeker_history_arr_instances_arr_instance_id"); + + b.Navigation("ArrInstance"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b => + { + b.Navigation("Instances"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", b => + { + b.Navigation("AppriseConfiguration"); + + b.Navigation("DiscordConfiguration"); + + b.Navigation("GotifyConfiguration"); + + b.Navigation("NotifiarrConfiguration"); + + b.Navigation("NtfyConfiguration"); + + b.Navigation("PushoverConfiguration"); + + b.Navigation("TelegramConfiguration"); + }); + + modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig", b => + { + b.Navigation("SlowRules"); + + b.Navigation("StallRules"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/20260528201320_AddDeleteIfAnyFileBlocked.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260528201320_AddDeleteIfAnyFileBlocked.cs new file mode 100644 index 00000000..7fafae3b --- /dev/null +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/20260528201320_AddDeleteIfAnyFileBlocked.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Cleanuparr.Persistence.Migrations.Data +{ + /// + public partial class AddDeleteIfAnyFileBlocked : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "delete_if_any_file_blocked", + table: "content_blocker_configs", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "delete_if_any_file_blocked", + table: "content_blocker_configs"); + } + } +} diff --git a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs index 73426cbd..b43751f6 100644 --- a/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs +++ b/code/backend/Cleanuparr.Persistence/Migrations/Data/DataContextModelSnapshot.cs @@ -748,6 +748,10 @@ namespace Cleanuparr.Persistence.Migrations.Data .HasColumnType("TEXT") .HasColumnName("cron_expression"); + b.Property("DeleteIfAnyFileBlocked") + .HasColumnType("INTEGER") + .HasColumnName("delete_if_any_file_blocked"); + b.Property("DeletePrivate") .HasColumnType("INTEGER") .HasColumnName("delete_private"); diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/MalwareBlocker/ContentBlockerConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/MalwareBlocker/ContentBlockerConfig.cs index 7c1572ed..216f3d52 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/MalwareBlocker/ContentBlockerConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/MalwareBlocker/ContentBlockerConfig.cs @@ -22,6 +22,8 @@ public sealed record ContentBlockerConfig : IJobConfig public bool ProcessNoContentId { get; set; } + public bool DeleteIfAnyFileBlocked { get; set; } + public BlocklistSettings Sonarr { get; set; } = new(); public BlocklistSettings Radarr { get; set; } = new(); diff --git a/code/frontend/src/app/core/services/documentation.service.ts b/code/frontend/src/app/core/services/documentation.service.ts index 960e3b5b..ff3edd0b 100644 --- a/code/frontend/src/app/core/services/documentation.service.ts +++ b/code/frontend/src/app/core/services/documentation.service.ts @@ -105,6 +105,7 @@ export class DocumentationService { 'ignorePrivate': 'ignore-private', 'deletePrivate': 'delete-private', 'processNoContentId': 'process-downloads-with-no-content-id', + 'deleteIfAnyFileBlocked': 'delete-if-any-file-is-blocked', 'sonarr.enabled': 'enable-blocklist', 'sonarr.blocklistPath': 'blocklist-path', 'sonarr.blocklistType': 'blocklist-type', diff --git a/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.html b/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.html index b5782842..ee53a1d3 100644 --- a/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.html +++ b/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.html @@ -33,6 +33,9 @@ +
diff --git a/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.ts b/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.ts index 357c834c..2c891922 100644 --- a/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.ts +++ b/code/frontend/src/app/features/settings/malware-blocker/malware-blocker.component.ts @@ -63,6 +63,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges { readonly ignorePrivate = signal(false); readonly deletePrivate = signal(false); readonly processNoContentId = signal(false); + readonly deleteIfAnyFileBlocked = signal(false); readonly arrExpanded = signal(false); readonly scheduleIntervalOptions = computed(() => { @@ -165,6 +166,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges { this.ignorePrivate.set(config.ignorePrivate); this.deletePrivate.set(config.deletePrivate); this.processNoContentId.set(config.processNoContentId); + this.deleteIfAnyFileBlocked.set(config.deleteIfAnyFileBlocked); const blocklists: Record = {}; for (const name of ARR_NAMES) { @@ -221,6 +223,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges { ignorePrivate: this.ignorePrivate(), deletePrivate: this.deletePrivate(), processNoContentId: this.processNoContentId(), + deleteIfAnyFileBlocked: this.deleteIfAnyFileBlocked(), sonarr: { enabled: blocklists['sonarr'].enabled, blocklistPath: blocklists['sonarr'].blocklistPath, blocklistType: blocklists['sonarr'].blocklistType as BlocklistType }, radarr: { enabled: blocklists['radarr'].enabled, blocklistPath: blocklists['radarr'].blocklistPath, blocklistType: blocklists['radarr'].blocklistType as BlocklistType }, lidarr: { enabled: blocklists['lidarr'].enabled, blocklistPath: blocklists['lidarr'].blocklistPath, blocklistType: blocklists['lidarr'].blocklistType as BlocklistType }, @@ -257,6 +260,7 @@ export class MalwareBlockerComponent implements OnInit, HasPendingChanges { ignorePrivate: this.ignorePrivate(), deletePrivate: this.deletePrivate(), processNoContentId: this.processNoContentId(), + deleteIfAnyFileBlocked: this.deleteIfAnyFileBlocked(), arrBlocklists: this.arrBlocklists(), }); } diff --git a/code/frontend/src/app/shared/models/malware-blocker-config.model.ts b/code/frontend/src/app/shared/models/malware-blocker-config.model.ts index fa52252a..5c829d1d 100644 --- a/code/frontend/src/app/shared/models/malware-blocker-config.model.ts +++ b/code/frontend/src/app/shared/models/malware-blocker-config.model.ts @@ -22,6 +22,7 @@ export interface MalwareBlockerConfig { ignorePrivate: boolean; deletePrivate: boolean; processNoContentId: boolean; + deleteIfAnyFileBlocked: boolean; sonarr: BlocklistSettings; radarr: BlocklistSettings; lidarr: BlocklistSettings; diff --git a/docs/contributing/e2e-testing.md b/docs/contributing/e2e-testing.md index 312d7acc..e0630913 100644 --- a/docs/contributing/e2e-testing.md +++ b/docs/contributing/e2e-testing.md @@ -38,11 +38,11 @@ docker compose -f docker-compose.e2e.yml down ## How It Works 1. **Docker Compose** starts Keycloak (with a pre-configured realm) and the Cleanuparr app -2. **Playwright `globalSetup`** (`tests/global-setup.ts`) automatically waits for both services, creates an admin account, and configures OIDC settings via the API -3. **`01-oidc-link.spec.ts`** logs in with local credentials, navigates to settings, and links the account to the Keycloak user (this sets `AuthorizedSubject`) -4. **`02-oidc-login.spec.ts`** verifies the full OIDC login flow — clicking "Sign in with Keycloak" on the login page, authenticating at Keycloak, and landing on the dashboard +2. **Playwright `globalSetup`** (`tests/global-setup.ts`) automatically waits for both services and creates the admin account. OIDC is left disabled by default — each OIDC spec configures the state it needs in its own `beforeAll` and restores it in `afterAll` +3. **`oidc-link.spec.ts`** logs in with local credentials, navigates to settings, and verifies the UI link flow against Keycloak +4. **`oidc-login.spec.ts`** verifies the full OIDC login flow — clicking "Sign in with Keycloak" on the login page, authenticating at Keycloak, and landing on the dashboard. It establishes its own linked subject via the shared `linkOidcViaBrowser` helper in `beforeAll` -The link test must run before the login test because the OIDC login button only appears after `AuthorizedSubject` is set. +OIDC specs are independent of each other; the previous numeric-prefix ordering was removed once each spec became self-contained. ## CI diff --git a/docs/docs/configuration/malware-blocker/index.mdx b/docs/docs/configuration/malware-blocker/index.mdx index 6508e4a2..7f076102 100644 --- a/docs/docs/configuration/malware-blocker/index.mdx +++ b/docs/docs/configuration/malware-blocker/index.mdx @@ -114,6 +114,20 @@ This setting will enable the processing of items that appear in the logs with th + + +When enabled, the entire download is removed as soon as **any** file inside it matches the blocklist. When disabled (the default), a download is only removed when **all** of its files are blocked — otherwise only the matching files are marked as skipped in the download client. + +Use this when your blocklist targets malware extensions (for example `.exe`) and you do not want to keep the rest of a mixed torrent that contains a blocked file alongside legitimate content. + + +`Ignore Private` and `Delete Private` still apply on top of this setting — private torrents continue to be skipped or kept in the client according to those options. + + + +
diff --git a/e2e/Makefile b/e2e/Makefile index bd46ac1c..a0c9d5b3 100644 --- a/e2e/Makefile +++ b/e2e/Makefile @@ -1,11 +1,17 @@ -.PHONY: up down test install setup +.PHONY: up up-api up-dc down test install setup setup: bash ./scripts/setup-test-data.sh -up: setup +up: down setup docker compose -f docker-compose.e2e.yml up -d --build --remove-orphans +up-api: down setup + docker compose -f docker-compose.e2e.yml up -d --build --remove-orphans app keycloak nginx + +up-dc: down setup + docker compose -f docker-compose.e2e.yml up -d --build --remove-orphans app keycloak nginx qbittorrent transmission deluge utorrent rutorrent + down: docker compose -f docker-compose.e2e.yml down -v diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml index 62723b4f..369b9dea 100644 --- a/e2e/docker-compose.e2e.yml +++ b/e2e/docker-compose.e2e.yml @@ -30,6 +30,8 @@ services: depends_on: keycloak: condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" environment: PORT: "5000" HTTP_PORTS: "5000" diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 4856585b..40a264c4 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -14,7 +14,13 @@ export default defineConfig({ }, projects: [ { - name: 'chromium', + name: 'api', + testIgnore: /(?:^|[\\/])(?:orphaned-files-cleanup|orphaned-files-behaviors|malware-blocker)\.spec\.ts$/, + use: { browserName: 'chromium' }, + }, + { + name: 'download-clients', + testMatch: /(?:^|[\\/])(?:orphaned-files-cleanup|orphaned-files-behaviors|malware-blocker)\.spec\.ts$/, use: { browserName: 'chromium' }, }, ], diff --git a/e2e/tests/14-cors-wildcard-bug.spec.ts b/e2e/tests/cors-wildcard-bug.spec.ts similarity index 100% rename from e2e/tests/14-cors-wildcard-bug.spec.ts rename to e2e/tests/cors-wildcard-bug.spec.ts diff --git a/e2e/tests/11-download-cleaner-api.spec.ts b/e2e/tests/download-cleaner-api.spec.ts similarity index 100% rename from e2e/tests/11-download-cleaner-api.spec.ts rename to e2e/tests/download-cleaner-api.spec.ts diff --git a/e2e/tests/global-setup.ts b/e2e/tests/global-setup.ts index e0de41d4..79eb5f59 100644 --- a/e2e/tests/global-setup.ts +++ b/e2e/tests/global-setup.ts @@ -2,8 +2,6 @@ import { waitForKeycloak } from './helpers/keycloak'; import { waitForApp, createAccountAndSetup, - loginAndGetToken, - configureOidc, } from './helpers/app-api'; async function globalSetup() { @@ -18,9 +16,6 @@ async function globalSetup() { console.log('Creating admin account and completing setup...'); await createAccountAndSetup(); - console.log('Configuring OIDC...'); - const token = await loginAndGetToken(); - await configureOidc(token); console.log('Global setup complete.'); } diff --git a/e2e/tests/helpers/app-api.ts b/e2e/tests/helpers/app-api.ts index 895065cf..36e66856 100644 --- a/e2e/tests/helpers/app-api.ts +++ b/e2e/tests/helpers/app-api.ts @@ -341,6 +341,28 @@ export async function updateOrphanedFilesConfig( }); } +// --- Malware Blocker helpers --- + +export async function getMalwareBlockerConfig(accessToken: string): Promise { + return fetch(`${API}/api/configuration/malware_blocker`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); +} + +export async function updateMalwareBlockerConfig( + accessToken: string, + config: Record, +): Promise { + return fetch(`${API}/api/configuration/malware_blocker`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(config), + }); +} + // --- Job triggering --- export async function triggerJob( @@ -425,3 +447,53 @@ export async function configureOidc(accessToken: string): Promise { throw new Error(`Failed to configure OIDC: ${putRes.status} ${body}`); } } + +export interface OidcConfigSnapshot { + enabled: boolean; + issuerUrl: string; + clientId: string; + clientSecret: string; + scopes: string; + providerName: string; + redirectUrl: string; + exclusiveMode: boolean; +} + +export async function getOidcConfig(accessToken: string): Promise { + const res = await fetch(`${API}/api/account/oidc`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) { + throw new Error(`Failed to GET OIDC config: ${res.status}`); + } + return res.json(); +} + +export async function setOidcConfig( + accessToken: string, + config: OidcConfigSnapshot, +): Promise { + const res = await fetch(`${API}/api/account/oidc`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(config), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Failed to PUT OIDC config: ${res.status} ${body}`); + } +} + +export async function clearOidcLink(accessToken: string): Promise { + const res = await fetch(`${API}/api/account/oidc/link`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok && res.status !== 404) { + const body = await res.text(); + throw new Error(`Failed to clear OIDC link: ${res.status} ${body}`); + } +} diff --git a/e2e/tests/helpers/arr-stub.ts b/e2e/tests/helpers/arr-stub.ts new file mode 100644 index 00000000..378a4398 --- /dev/null +++ b/e2e/tests/helpers/arr-stub.ts @@ -0,0 +1,196 @@ +import { createServer, IncomingMessage, Server, ServerResponse } from 'node:http'; +import { AddressInfo } from 'node:net'; + +/** + * Minimal Sonarr/Radarr stub for e2e tests. + * + * The Playwright runner hosts this stub on the test machine. The Cleanuparr + * app container reaches it via `host.docker.internal` — Docker Desktop adds + * that name automatically, and Linux CI gets it through the `extra_hosts: + * host.docker.internal:host-gateway` entry in docker-compose.e2e.yml. The + * stub only models the three endpoints the MalwareBlocker job touches: + * + * - `GET /api/v3/system/status` — minimal version payload + * - `GET /api/v3/queue?page=N&pageSize=M` — returns whatever records the + * test set via `setQueue(...)`; supports paging only enough to satisfy + * the iterator (`totalRecords` + `records` for page 1) + * - `DELETE /api/v3/queue/{id}` — records the id so the test can assert + * the arr-side removal happened, then returns 200 + * + * Any other request returns 200 with an empty body to avoid spurious errors + * if Cleanuparr probes endpoints we haven't modeled. + */ +export interface StubQueueRecord { + id: number; + downloadId: string; + title: string; + protocol: 'torrent' | 'usenet'; + seriesId: number; + episodeId: number; + seasonNumber?: number; + status?: string; + trackedDownloadStatus?: string; + trackedDownloadState?: string; + sizeLeft?: number; +} + +export interface StubDeleteCall { + id: number; + removeFromClient: boolean; + blocklist: boolean; + changeCategory: boolean; +} + +export class ArrStubServer { + private server: Server | null = null; + private queue: StubQueueRecord[] = []; + private deletes: StubDeleteCall[] = []; + private queueRequestCount = 0; + private listenPort = 0; + + async start(port = 9100): Promise { + if (this.server) { + throw new Error('ArrStubServer already started'); + } + + this.server = createServer((req, res) => this.handle(req, res)); + + await new Promise((resolve, reject) => { + const onError = (err: Error) => reject(err); + this.server!.once('error', onError); + // Bind on all interfaces so the cleanuparr container can reach us + // via `host.docker.internal`. 127.0.0.1 would only accept local + // connections, which Docker Desktop cannot route to. + this.server!.listen(port, '0.0.0.0', () => { + this.server!.off('error', onError); + const addr = this.server!.address() as AddressInfo; + this.listenPort = addr.port; + resolve(); + }); + }); + } + + async stop(): Promise { + if (!this.server) { + return; + } + await new Promise((resolve, reject) => { + this.server!.close((err) => (err ? reject(err) : resolve())); + }); + this.server = null; + } + + get port(): number { + return this.listenPort; + } + + get url(): string { + return `http://127.0.0.1:${this.listenPort}`; + } + + /** URL the cleanuparr container should use to reach this stub. */ + get containerUrl(): string { + return `http://host.docker.internal:${this.listenPort}`; + } + + setQueue(records: StubQueueRecord[]): void { + this.queue = records; + } + + resetCounters(): void { + this.deletes = []; + this.queueRequestCount = 0; + } + + getDeletes(): StubDeleteCall[] { + return [...this.deletes]; + } + + /** + * Resolves once the stub has received at least one `GET /api/v3/queue` + * request since the last {@link resetCounters} — i.e. once a MalwareBlocker + * iteration has actually run against this stub. + */ + async waitForQueueRequest(timeoutMs = 15_000): Promise { + return this.waitForQueueRequestCount(1, timeoutMs); + } + + /** + * Resolves once the stub has received at least `n` queue requests since the + * last {@link resetCounters}. Useful for negative assertions: waiting for + * the second iteration to start proves the first one finished end-to-end + * (Quartz won't fire the next cron tick until the previous run completes). + */ + async waitForQueueRequestCount(n: number, timeoutMs = 30_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (this.queueRequestCount >= n) { + return true; + } + await new Promise((r) => setTimeout(r, 200)); + } + return false; + } + + private handle(req: IncomingMessage, res: ServerResponse): void { + const url = req.url ?? ''; + const method = req.method ?? 'GET'; + + if (method === 'GET' && url.startsWith('/api/v3/system/status')) { + this.sendJson(res, 200, { version: '4.0.0.0', appName: 'Sonarr' }); + return; + } + + if (method === 'GET' && url.startsWith('/api/v3/queue')) { + this.queueRequestCount++; + this.sendJson(res, 200, { + page: 1, + pageSize: this.queue.length || 10, + totalRecords: this.queue.length, + records: this.queue.map((r) => ({ + id: r.id, + downloadId: r.downloadId, + title: r.title, + protocol: r.protocol, + seriesId: r.seriesId, + episodeId: r.episodeId, + seasonNumber: r.seasonNumber ?? 1, + status: r.status ?? 'downloading', + trackedDownloadStatus: r.trackedDownloadStatus ?? 'ok', + trackedDownloadState: r.trackedDownloadState ?? 'downloading', + statusMessages: [], + sizeLeft: r.sizeLeft ?? 0, + })), + }); + return; + } + + const deleteMatch = method === 'DELETE' && /^\/api\/v3\/queue\/(\d+)(?:\?|$)/.exec(url); + if (deleteMatch) { + const id = Number(deleteMatch[1]); + const query = url.includes('?') ? url.slice(url.indexOf('?') + 1) : ''; + const params = new URLSearchParams(query); + this.deletes.push({ + id, + removeFromClient: params.get('removeFromClient') === 'true', + blocklist: params.get('blocklist') === 'true', + changeCategory: params.get('changeCategory') === 'true', + }); + this.queue = this.queue.filter((r) => r.id !== id); + res.statusCode = 200; + res.end(); + return; + } + + res.statusCode = 200; + res.end(); + } + + private sendJson(res: ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body); + res.statusCode = status; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Length', Buffer.byteLength(payload).toString()); + res.end(payload); + } +} diff --git a/e2e/tests/helpers/oidc.ts b/e2e/tests/helpers/oidc.ts new file mode 100644 index 00000000..b1fb4c93 --- /dev/null +++ b/e2e/tests/helpers/oidc.ts @@ -0,0 +1,40 @@ +import { expect, Page } from '@playwright/test'; +import { TEST_CONFIG } from './test-config'; + +/** + * Drives the browser through the OIDC link flow: + * local login → settings/account → Link Account → Keycloak login → callback success. + * + * Leaves the page on /settings/account?oidc_link=success. + */ +export async function linkOidcViaBrowser(page: Page): Promise { + await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); + + await page + .getByRole('textbox', { name: 'Username' }) + .fill(TEST_CONFIG.adminUsername); + await page + .getByRole('textbox', { name: 'Password' }) + .fill(TEST_CONFIG.adminPassword); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10_000 }); + + await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); + await page.getByText('OIDC / SSO').click(); + + const linkButton = page.getByRole('button', { name: /link account|re-link/i }); + await expect(linkButton).toBeVisible({ timeout: 5_000 }); + await linkButton.click(); + + await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); + + await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); + await page.locator('#username').fill(TEST_CONFIG.oidcUsername); + await page.locator('#password').fill(TEST_CONFIG.oidcPassword); + await page.locator('#kc-login').click(); + + await expect(page).toHaveURL(/settings\/account\?oidc_link=success/, { + timeout: 15_000, + }); +} diff --git a/e2e/tests/helpers/torrent-fixtures.ts b/e2e/tests/helpers/torrent-fixtures.ts index 052eb370..560c7cbb 100644 --- a/e2e/tests/helpers/torrent-fixtures.ts +++ b/e2e/tests/helpers/torrent-fixtures.ts @@ -108,6 +108,72 @@ export function buildFolderTorrent(savePath: string, name: string, sizeBytes = 3 return { metainfo, infoHash, name, contentPath }; } +/** + * Build a multi-file torrent (BEP-3 multi-file mode). Each entry in `files` + * is written as `//` with deterministic content + * derived from the torrent name + filename. The pieces are SHA-1 hashes of + * the concatenated file bytes in declared order, which is how clients verify + * multi-file torrents. + */ +export function buildMultiFileTorrent( + savePath: string, + name: string, + files: ReadonlyArray<{ filename: string; sizeBytes?: number }>, +): GeneratedTorrent { + if (files.length === 0) { + throw new Error('buildMultiFileTorrent: files must be non-empty'); + } + + const contentPath = join(savePath, name); + mkdirSync(contentPath, { recursive: true }); + chmodIgnoringEPERM(contentPath, 0o777); + + const fileBuffers: Buffer[] = []; + const fileEntries: Array<{ length: number; path: string[] }> = []; + + for (const { filename, sizeBytes = 16384 } of files) { + const seed = createHash('sha256').update(`cleanuparr-e2e:${name}:${filename}`).digest(); + const buf = Buffer.alloc(sizeBytes); + let offset = 0; + let counter = 0; + while (offset < sizeBytes) { + const block = createHash('sha256').update(seed).update(Buffer.from([counter & 0xff, (counter >> 8) & 0xff])).digest(); + block.copy(buf, offset, 0, Math.min(block.length, sizeBytes - offset)); + offset += block.length; + counter++; + } + writeFileSync(join(contentPath, filename), buf); + fileBuffers.push(buf); + fileEntries.push({ length: buf.length, path: [filename] }); + } + + const concatenated = Buffer.concat(fileBuffers); + const pieceLength = 16384; + const pieces: Buffer[] = []; + for (let i = 0; i < concatenated.length; i += pieceLength) { + const piece = concatenated.subarray(i, Math.min(i + pieceLength, concatenated.length)); + pieces.push(createHash('sha1').update(piece).digest()); + } + const piecesConcat = Buffer.concat(pieces); + + const info = { + name, + 'piece length': pieceLength, + pieces: piecesConcat, + files: fileEntries, + private: 1, + }; + const metainfo = bencode({ + announce: 'http://tracker.invalid/announce', + 'created by': 'cleanuparr-e2e', + 'creation date': 0, + info, + }); + const infoHash = createHash('sha1').update(bencode(info)).digest('hex'); + + return { metainfo, infoHash, name, contentPath }; +} + /** * `chmodSync` that tolerates EPERM. The torrent-client bind mounts * (`test-data/downloads/`) are chowned to PUID=1000 by diff --git a/e2e/tests/malware-blocker.spec.ts b/e2e/tests/malware-blocker.spec.ts new file mode 100644 index 00000000..1da1214a --- /dev/null +++ b/e2e/tests/malware-blocker.spec.ts @@ -0,0 +1,316 @@ +import { test, expect } from '@playwright/test'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { + loginAndGetToken, + createDownloadClient, + listDownloadClients, + deleteDownloadClient, + createArrInstance, + deleteArrInstance, + getMalwareBlockerConfig, + updateMalwareBlockerConfig, + triggerJob, +} from './helpers/app-api'; +import { ALL_CLIENTS, TorrentClientFixture } from './helpers/torrent-clients'; +import { buildMultiFileTorrent, chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures'; +import { ArrStubServer } from './helpers/arr-stub'; + +/** + * End-to-end coverage for the `DeleteIfAnyFileBlocked` setting on the + * Malware Blocker. + * + * Each download client gets a pair of torrents containing both a benign + * `movie.mkv` and a `installer.exe` that matches the test blocklist + * pattern `*.exe`. With the toggle off, today's behavior must keep the + * torrent in the client (only some files unwanted → not removed). With + * the toggle on, the entire torrent must be removed and the corresponding + * Sonarr queue record must be deleted via DELETE /api/v3/queue/{id}. + * + * Sonarr is stubbed in-process via {@link ArrStubServer}; the cleanuparr + * app container reaches the stub through `network_mode: host`. + * + * Distinct torrent names per scenario (`off-*` vs `on-*`) give each test + * its own hash and therefore independent client state — without that, + * the OFF test marks `installer.exe` as Skip in the client and the + * subsequent ON test would short-circuit on the "file already skipped" + * branch before reaching the new code path. + */ + +const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads'); +const CLIENT_DOWNLOADS = '/downloads'; +const APP_DOWNLOADS = '/e2e-downloads'; +const BLOCKLIST_REL_PATH = 'malware-blocker/blocklist.txt'; +const APP_BLOCKLIST_PATH = `${APP_DOWNLOADS}/${BLOCKLIST_REL_PATH}`; +const STUB_PORT = 9100; +const POLL_TIMEOUT_MS = 30_000; + +function farFutureCron(): string { + const minutes = (new Date().getUTCMinutes() + 30) % 60; + return `0 ${minutes} * * * ?`; +} + +const TRIGGER_SETTLE_MS = 4_000; + +const SLUG_BY_TYPE: Record = { + qBittorrent: 'qbittorrent', + Transmission: 'transmission', + Deluge: 'deluge', + uTorrent: 'utorrent', + rTorrent: 'rtorrent', +}; + +interface TorrentMeta { + name: string; + infoHash: string; +} + +async function waitForTorrents( + driver: { listTorrents(): Promise> }, + expectedHashes: string[], + timeoutMs = 15_000, +): Promise { + const want = new Set(expectedHashes.map((h) => h.toLowerCase())); + const start = Date.now(); + let last: Set = new Set(); + while (Date.now() - start < timeoutMs) { + const list = await driver.listTorrents(); + last = new Set(list.map((t) => t.hash.toLowerCase())); + if ([...want].every((h) => last.has(h))) { + return; + } + await new Promise((r) => setTimeout(r, 500)); + } + const missing = [...want].filter((h) => !last.has(h)); + throw new Error(`Torrents missing after ${timeoutMs}ms: ${missing.join(', ')}`); +} + +async function torrentPresent( + driver: { listTorrents(): Promise> }, + hash: string, +): Promise { + const target = hash.toLowerCase(); + const list = await driver.listTorrents(); + return list.some((t) => t.hash.toLowerCase() === target); +} + +async function setMalwareBlocker( + token: string, + overrides: Record, +): Promise { + const currentRes = await getMalwareBlockerConfig(token); + expect(currentRes.status).toBe(200); + const current = await currentRes.json(); + const merged = { + ...current, + ...overrides, + sonarr: { ...(current.sonarr ?? {}), ...(overrides.sonarr as Record ?? {}) }, + }; + const updateRes = await updateMalwareBlockerConfig(token, merged); + if (updateRes.status >= 300) { + throw new Error(`update malware blocker config: ${updateRes.status} ${await updateRes.text()}`); + } +} + +test.describe.serial('Malware blocker — DeleteIfAnyFileBlocked', () => { + let token: string; + const stub = new ArrStubServer(); + let sonarrId: string | undefined; + + test.beforeAll(async () => { + token = await loginAndGetToken(); + + for (const client of await listDownloadClients(token)) { + await deleteDownloadClient(token, client.id); + } + + mkdirSync(join(HOST_DOWNLOADS, 'malware-blocker'), { recursive: true }); + chmodIgnoringEPERM(join(HOST_DOWNLOADS, 'malware-blocker'), 0o777); + writeFileSync(join(HOST_DOWNLOADS, BLOCKLIST_REL_PATH), '*.exe\n'); + + await stub.start(STUB_PORT); + + const sonarrRes = await createArrInstance(token, 'sonarr', { + name: 'Malware Blocker Stub', + url: stub.containerUrl, + apiKey: 'malware-blocker-e2e', + version: 4, + }); + expect(sonarrRes.status).toBe(201); + const sonarrBody = await sonarrRes.json(); + sonarrId = sonarrBody.id; + }); + + test.afterAll(async () => { + if (sonarrId) { + await deleteArrInstance(token, 'sonarr', sonarrId); + } + await stub.stop(); + }); + + for (const fixture of ALL_CLIENTS) { + runClientScenarios(fixture, () => ({ token, stub })); + } +}); + +function runClientScenarios( + fixture: TorrentClientFixture, + getCtx: () => { token: string; stub: ArrStubServer }, +): void { + const { driver } = fixture; + const slug = SLUG_BY_TYPE[driver.typeName]; + const describeFn = fixture.enabled ? test.describe.serial : test.describe.skip; + + describeFn(`${driver.typeName}`, () => { + let clientId: string; + let offTorrent: TorrentMeta; + let onTorrent: TorrentMeta; + const hostScanDir = join(HOST_DOWNLOADS, slug); + + test.beforeAll(async () => { + const { token } = getCtx(); + + resetDirectory(hostScanDir); + await driver.ready(); + await driver.clearAllTorrents(); + + const runId = Date.now().toString(36); + const offFx = buildMultiFileTorrent(hostScanDir, `off-mixed-${slug}-${runId}`, [ + { filename: 'movie.mkv', sizeBytes: 32_768 }, + { filename: 'installer.exe', sizeBytes: 1024 }, + ]); + const onFx = buildMultiFileTorrent(hostScanDir, `on-mixed-${slug}-${runId}`, [ + { filename: 'movie.mkv', sizeBytes: 32_768 }, + { filename: 'installer.exe', sizeBytes: 1024 }, + ]); + offTorrent = { name: offFx.name, infoHash: offFx.infoHash }; + onTorrent = { name: onFx.name, infoHash: onFx.infoHash }; + + const createRes = await createDownloadClient(token, { + enabled: true, + name: `${driver.typeName} malware-blocker e2e`, + typeName: driver.typeName, + type: 'Torrent', + host: driver.cleanuparrHost, + username: driver.username ?? '', + password: driver.password ?? '', + }); + expect(createRes.status, `createDownloadClient: ${createRes.status}`).toBeGreaterThanOrEqual(200); + expect(createRes.status).toBeLessThan(300); + clientId = (await createRes.json()).id; + + await driver.addTorrent({ + metainfo: offFx.metainfo, + savePath: CLIENT_DOWNLOADS, + name: offFx.name, + infoHash: offFx.infoHash, + }); + await driver.addTorrent({ + metainfo: onFx.metainfo, + savePath: CLIENT_DOWNLOADS, + name: onFx.name, + infoHash: onFx.infoHash, + }); + await waitForTorrents(driver, [offTorrent.infoHash, onTorrent.infoHash]); + }); + + test.afterAll(async () => { + const { token } = getCtx(); + if (clientId) { + await deleteDownloadClient(token, clientId); + } + }); + + test('toggle OFF keeps the torrent in the client', async () => { + test.setTimeout(120_000); + const { token, stub } = getCtx(); + + await setMalwareBlocker(token, { + enabled: true, + useAdvancedScheduling: true, + cronExpression: farFutureCron(), + deleteIfAnyFileBlocked: false, + ignorePrivate: false, + deletePrivate: true, + processNoContentId: false, + sonarr: { + enabled: true, + blocklistPath: APP_BLOCKLIST_PATH, + blocklistType: 'Blacklist', + }, + }); + + stub.setQueue([ + { + id: 1001, + downloadId: offTorrent.infoHash, + title: offTorrent.name, + protocol: 'torrent', + seriesId: 1, + episodeId: 1, + }, + ]); + stub.resetCounters(); + + const trig = await triggerJob(token, 'MalwareBlocker'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + const ran = await stub.waitForQueueRequest(POLL_TIMEOUT_MS); + expect(ran, 'malware blocker should have called the sonarr stub /api/v3/queue').toBe(true); + + await new Promise((r) => setTimeout(r, TRIGGER_SETTLE_MS)); + + expect(stub.getDeletes(), 'no sonarr queue items should be deleted when toggle is off').toEqual([]); + expect(await torrentPresent(driver, offTorrent.infoHash), `expected ${offTorrent.infoHash} to remain in ${driver.typeName} (toggle off)`).toBe(true); + }); + + test('toggle ON removes the torrent and notifies sonarr', async () => { + test.setTimeout(120_000); + const { token, stub } = getCtx(); + + await setMalwareBlocker(token, { + enabled: true, + useAdvancedScheduling: true, + cronExpression: farFutureCron(), + deleteIfAnyFileBlocked: true, + ignorePrivate: false, + deletePrivate: true, + processNoContentId: false, + sonarr: { + enabled: true, + blocklistPath: APP_BLOCKLIST_PATH, + blocklistType: 'Blacklist', + }, + }); + + stub.setQueue([ + { + id: 1002, + downloadId: onTorrent.infoHash, + title: onTorrent.name, + protocol: 'torrent', + seriesId: 2, + episodeId: 2, + }, + ]); + stub.resetCounters(); + + const trig = await triggerJob(token, 'MalwareBlocker'); + expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); + + const start = Date.now(); + while (Date.now() - start < POLL_TIMEOUT_MS) { + if (stub.getDeletes().some((d) => d.id === 1002)) { + break; + } + await new Promise((r) => setTimeout(r, 500)); + } + + const deletes = stub.getDeletes(); + const onDelete = deletes.find((d) => d.id === 1002); + expect(onDelete, `sonarr queue item 1002 should have been deleted (saw ${JSON.stringify(deletes)})`).toBeDefined(); + expect(onDelete!.removeFromClient, 'malware blocker should ask sonarr to remove the torrent from the client').toBe(true); + expect(onDelete!.blocklist, 'malware blocker should ask sonarr to blocklist the release').toBe(true); + }); + }); +} diff --git a/e2e/tests/05-oidc-config-changes.spec.ts b/e2e/tests/oidc-config-changes.spec.ts similarity index 74% rename from e2e/tests/05-oidc-config-changes.spec.ts rename to e2e/tests/oidc-config-changes.spec.ts index 0e6ab11f..fca98c44 100644 --- a/e2e/tests/05-oidc-config-changes.spec.ts +++ b/e2e/tests/oidc-config-changes.spec.ts @@ -1,10 +1,32 @@ import { test, expect } from '@playwright/test'; import { TEST_CONFIG } from './helpers/test-config'; -import { loginAndGetToken, updateOidcConfig } from './helpers/app-api'; +import { + clearOidcLink, + configureOidc, + getOidcConfig, + loginAndGetToken, + OidcConfigSnapshot, + setOidcConfig, + updateOidcConfig, +} from './helpers/app-api'; test.describe.serial('OIDC Configuration Changes', () => { + let token: string; + let snapshot: OidcConfigSnapshot; + + test.beforeAll(async () => { + token = await loginAndGetToken(); + snapshot = await getOidcConfig(token); + await configureOidc(token); + await clearOidcLink(token); + }); + + test.afterAll(async () => { + await clearOidcLink(token); + await setOidcConfig(token, snapshot); + }); + test('disabling OIDC hides the login button', async ({ page }) => { - const token = await loginAndGetToken(); await updateOidcConfig(token, { enabled: false }); await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); @@ -12,7 +34,6 @@ test.describe.serial('OIDC Configuration Changes', () => { const oidcButton = page.locator('.oidc-login-btn'); await expect(oidcButton).not.toBeVisible({ timeout: 5_000 }); - // The "or" divider should also be hidden when no external logins are available const divider = page.locator('.divider'); await expect(divider).not.toBeVisible(); }); @@ -20,7 +41,6 @@ test.describe.serial('OIDC Configuration Changes', () => { test('changing provider name updates the login button text', async ({ page, }) => { - const token = await loginAndGetToken(); await updateOidcConfig(token, { enabled: true, providerName: 'MyCustomIdP', @@ -36,7 +56,6 @@ test.describe.serial('OIDC Configuration Changes', () => { test('re-enabling with original provider name restores the button', async ({ page, }) => { - const token = await loginAndGetToken(); await updateOidcConfig(token, { enabled: true, providerName: TEST_CONFIG.oidcProviderName, diff --git a/e2e/tests/03-oidc-error-display.spec.ts b/e2e/tests/oidc-error-display.spec.ts similarity index 79% rename from e2e/tests/03-oidc-error-display.spec.ts rename to e2e/tests/oidc-error-display.spec.ts index 302da3f7..f6ea7989 100644 --- a/e2e/tests/03-oidc-error-display.spec.ts +++ b/e2e/tests/oidc-error-display.spec.ts @@ -1,7 +1,30 @@ import { test, expect } from '@playwright/test'; import { TEST_CONFIG } from './helpers/test-config'; +import { + clearOidcLink, + configureOidc, + getOidcConfig, + loginAndGetToken, + OidcConfigSnapshot, + setOidcConfig, +} from './helpers/app-api'; + +test.describe('OIDC Error Display', () => { + let token: string; + let snapshot: OidcConfigSnapshot; + + test.beforeAll(async () => { + token = await loginAndGetToken(); + snapshot = await getOidcConfig(token); + await configureOidc(token); + await clearOidcLink(token); + }); + + test.afterAll(async () => { + await clearOidcLink(token); + await setOidcConfig(token, snapshot); + }); -test.describe.serial('OIDC Error Display', () => { test('callback page shows error for missing code and redirects to login', async ({ page, }) => { diff --git a/e2e/tests/08-oidc-exclusive-mode.spec.ts b/e2e/tests/oidc-exclusive-mode.spec.ts similarity index 84% rename from e2e/tests/08-oidc-exclusive-mode.spec.ts rename to e2e/tests/oidc-exclusive-mode.spec.ts index b6634942..0e4b89e1 100644 --- a/e2e/tests/08-oidc-exclusive-mode.spec.ts +++ b/e2e/tests/oidc-exclusive-mode.spec.ts @@ -1,25 +1,47 @@ import { test, expect } from '@playwright/test'; import { TEST_CONFIG } from './helpers/test-config'; -import { loginAndGetToken, updateOidcConfig } from './helpers/app-api'; +import { + clearOidcLink, + configureOidc, + getOidcConfig, + loginAndGetToken, + OidcConfigSnapshot, + setOidcConfig, + updateOidcConfig, +} from './helpers/app-api'; +import { linkOidcViaBrowser } from './helpers/oidc'; const API = TEST_CONFIG.appUrl; test.describe.serial('OIDC Exclusive Mode', () => { // Token obtained BEFORE enabling exclusive mode (password login will be blocked) let adminToken: string; + let snapshot: OidcConfigSnapshot; - test.beforeAll(async () => { + test.beforeAll(async ({ browser }) => { adminToken = await loginAndGetToken(); + snapshot = await getOidcConfig(adminToken); + await configureOidc(adminToken); + await clearOidcLink(adminToken); + + const setupPage = await browser.newPage(); + try { + await linkOidcViaBrowser(setupPage); + } finally { + await setupPage.close(); + } + await updateOidcConfig(adminToken, { exclusiveMode: true }); }); test.afterAll(async () => { - // Ensure exclusive mode is disabled for any subsequent test reruns try { await updateOidcConfig(adminToken, { exclusiveMode: false }); } catch { - // best effort cleanup + // best effort — snapshot restore below will fix it anyway } + await clearOidcLink(adminToken); + await setOidcConfig(adminToken, snapshot); }); test('login page shows only OIDC button when exclusive mode is active', async ({ @@ -27,12 +49,10 @@ test.describe.serial('OIDC Exclusive Mode', () => { }) => { await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - // OIDC button should be visible const oidcButton = page.locator('.oidc-login-btn'); await expect(oidcButton).toBeVisible({ timeout: 10_000 }); await expect(oidcButton).toContainText(TEST_CONFIG.oidcProviderName); - // Credentials form, divider, and Plex button should NOT be visible const loginForm = page.locator('.login-form'); await expect(loginForm).not.toBeVisible(); @@ -48,19 +68,15 @@ test.describe.serial('OIDC Exclusive Mode', () => { await page.locator('.oidc-login-btn').click(); - // Should redirect to Keycloak await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); - // Fill Keycloak login form await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); await page.locator('#username').fill(TEST_CONFIG.oidcUsername); await page.locator('#password').fill(TEST_CONFIG.oidcPassword); await page.locator('#kc-login').click(); - // Full flow: Keycloak → /api/auth/oidc/callback → /auth/oidc/callback?code=... → /dashboard await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); - // Verify authenticated await expect(page.locator('body')).not.toContainText('Sign In', { timeout: 5_000, }); @@ -90,7 +106,6 @@ test.describe.serial('OIDC Exclusive Mode', () => { test('settings page shows warning notices and disabled controls', async ({ page, }) => { - // Login via OIDC since password login is blocked await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); await page.locator('.oidc-login-btn').click(); await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); @@ -100,10 +115,8 @@ test.describe.serial('OIDC Exclusive Mode', () => { await page.locator('#kc-login').click(); await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); - // Navigate to account settings await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); - // Warning notices should be visible on Password and Plex cards await expect( page.getByText('Password login is disabled while OIDC exclusive mode is active.'), ).toBeVisible({ timeout: 5_000 }); @@ -111,7 +124,6 @@ test.describe.serial('OIDC Exclusive Mode', () => { page.getByText('Plex login is disabled while OIDC exclusive mode is active.'), ).toBeVisible({ timeout: 5_000 }); - // Expand OIDC accordion and verify exclusive mode toggle is visible await page.getByText('OIDC / SSO').click(); const exclusiveToggle = page.getByText('Exclusive Mode', { exact: true }); await expect(exclusiveToggle).toBeVisible({ timeout: 5_000 }); @@ -124,15 +136,12 @@ test.describe.serial('OIDC Exclusive Mode', () => { await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - // Credentials form should be visible again const loginForm = page.locator('.login-form'); await expect(loginForm).toBeVisible({ timeout: 10_000 }); - // OIDC button should still be visible const oidcButton = page.locator('.oidc-login-btn'); await expect(oidcButton).toBeVisible(); - // Divider should be visible const divider = page.locator('.divider'); await expect(divider).toBeVisible(); }); diff --git a/e2e/tests/01-oidc-link.spec.ts b/e2e/tests/oidc-link.spec.ts similarity index 71% rename from e2e/tests/01-oidc-link.spec.ts rename to e2e/tests/oidc-link.spec.ts index a9870330..88c8c560 100644 --- a/e2e/tests/01-oidc-link.spec.ts +++ b/e2e/tests/oidc-link.spec.ts @@ -1,11 +1,33 @@ import { test, expect } from '@playwright/test'; import { TEST_CONFIG } from './helpers/test-config'; +import { + clearOidcLink, + configureOidc, + getOidcConfig, + loginAndGetToken, + OidcConfigSnapshot, + setOidcConfig, +} from './helpers/app-api'; + +test.describe('OIDC Account Linking', () => { + let token: string; + let snapshot: OidcConfigSnapshot; + + test.beforeAll(async () => { + token = await loginAndGetToken(); + snapshot = await getOidcConfig(token); + await configureOidc(token); + await clearOidcLink(token); + }); + + test.afterAll(async () => { + await clearOidcLink(token); + await setOidcConfig(token, snapshot); + }); -test.describe.serial('OIDC Account Linking', () => { test('authenticated user can link OIDC account via settings', async ({ page, }) => { - // Log in with local credentials await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); await page @@ -20,7 +42,6 @@ test.describe.serial('OIDC Account Linking', () => { await expect(page).toHaveURL(/\/dashboard/, { timeout: 10_000 }); - // Navigate to settings and expand the OIDC accordion await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); await expect(page).toHaveURL(/\/settings\/account/); @@ -30,15 +51,12 @@ test.describe.serial('OIDC Account Linking', () => { await expect(linkButton).toBeVisible({ timeout: 5_000 }); await linkButton.click(); - // Should redirect to Keycloak await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); - // Fill Keycloak login form await page.locator('#username').fill(TEST_CONFIG.oidcUsername); await page.locator('#password').fill(TEST_CONFIG.oidcPassword); await page.locator('#kc-login').click(); - // Should redirect back to settings with success await expect(page).toHaveURL(/settings\/account\?oidc_link=success/, { timeout: 15_000, }); diff --git a/e2e/tests/07-oidc-login-persists.spec.ts b/e2e/tests/oidc-login-persists.spec.ts similarity index 57% rename from e2e/tests/07-oidc-login-persists.spec.ts rename to e2e/tests/oidc-login-persists.spec.ts index f42aa52e..d43a01f9 100644 --- a/e2e/tests/07-oidc-login-persists.spec.ts +++ b/e2e/tests/oidc-login-persists.spec.ts @@ -1,7 +1,38 @@ import { test, expect } from '@playwright/test'; import { TEST_CONFIG } from './helpers/test-config'; +import { + clearOidcLink, + configureOidc, + getOidcConfig, + loginAndGetToken, + OidcConfigSnapshot, + setOidcConfig, +} from './helpers/app-api'; +import { linkOidcViaBrowser } from './helpers/oidc'; + +test.describe('OIDC Login Persistence', () => { + let token: string; + let snapshot: OidcConfigSnapshot; + + test.beforeAll(async ({ browser }) => { + token = await loginAndGetToken(); + snapshot = await getOidcConfig(token); + await configureOidc(token); + await clearOidcLink(token); + + const setupPage = await browser.newPage(); + try { + await linkOidcViaBrowser(setupPage); + } finally { + await setupPage.close(); + } + }); + + test.afterAll(async () => { + await clearOidcLink(token); + await setOidcConfig(token, snapshot); + }); -test.describe.serial('OIDC Login Persistence', () => { test('OIDC login still works after configuration changes', async ({ page, }) => { @@ -13,19 +44,15 @@ test.describe.serial('OIDC Login Persistence', () => { await oidcButton.click(); - // Should redirect to Keycloak await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); - // Fill Keycloak login form (each test gets a fresh browser context, so always required) await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); await page.locator('#username').fill(TEST_CONFIG.oidcUsername); await page.locator('#password').fill(TEST_CONFIG.oidcPassword); await page.locator('#kc-login').click(); - // Full flow: Keycloak → /api/auth/oidc/callback → /auth/oidc/callback?code=... → /dashboard await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); - // Verify we're authenticated await expect(page.locator('body')).not.toContainText('Sign In', { timeout: 5_000, }); diff --git a/e2e/tests/00-oidc-login-unlinked.spec.ts b/e2e/tests/oidc-login-unlinked.spec.ts similarity index 67% rename from e2e/tests/00-oidc-login-unlinked.spec.ts rename to e2e/tests/oidc-login-unlinked.spec.ts index e0526659..cbfd365e 100644 --- a/e2e/tests/00-oidc-login-unlinked.spec.ts +++ b/e2e/tests/oidc-login-unlinked.spec.ts @@ -1,10 +1,31 @@ import { test, expect } from '@playwright/test'; import { TEST_CONFIG } from './helpers/test-config'; +import { + clearOidcLink, + configureOidc, + getOidcConfig, + loginAndGetToken, + OidcConfigSnapshot, + setOidcConfig, +} from './helpers/app-api'; + +test.describe('OIDC Login Without Linked Subject', () => { + let token: string; + let snapshot: OidcConfigSnapshot; + + test.beforeAll(async () => { + token = await loginAndGetToken(); + snapshot = await getOidcConfig(token); + await configureOidc(token); + await clearOidcLink(token); + }); + + test.afterAll(async () => { + await clearOidcLink(token); + await setOidcConfig(token, snapshot); + }); -test.describe.serial('OIDC Login Without Linked Subject', () => { test('OIDC button is visible without a linked subject', async ({ page }) => { - // After global setup, OIDC is configured (IssuerUrl + ClientId) but no account is linked. - // The button should still appear because the IdP controls access. await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); const oidcButton = page.getByRole('button', { name: /sign in with/i }); @@ -17,16 +38,13 @@ test.describe.serial('OIDC Login Without Linked Subject', () => { await page.getByRole('button', { name: /sign in with/i }).click(); - // Should redirect to Keycloak await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); - // Fill Keycloak login form await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); await page.locator('#username').fill(TEST_CONFIG.oidcUsername); await page.locator('#password').fill(TEST_CONFIG.oidcPassword); await page.locator('#kc-login').click(); - // Should authenticate and redirect to dashboard await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); await expect(page.locator('body')).not.toContainText('Sign In', { diff --git a/e2e/tests/02-oidc-login.spec.ts b/e2e/tests/oidc-login.spec.ts similarity index 61% rename from e2e/tests/02-oidc-login.spec.ts rename to e2e/tests/oidc-login.spec.ts index f10a30a0..7634a18a 100644 --- a/e2e/tests/02-oidc-login.spec.ts +++ b/e2e/tests/oidc-login.spec.ts @@ -1,13 +1,43 @@ import { test, expect } from '@playwright/test'; import { TEST_CONFIG } from './helpers/test-config'; +import { + clearOidcLink, + configureOidc, + getOidcConfig, + loginAndGetToken, + OidcConfigSnapshot, + setOidcConfig, +} from './helpers/app-api'; +import { linkOidcViaBrowser } from './helpers/oidc'; + +test.describe('OIDC Login', () => { + let token: string; + let snapshot: OidcConfigSnapshot; + + test.beforeAll(async ({ browser }) => { + token = await loginAndGetToken(); + snapshot = await getOidcConfig(token); + await configureOidc(token); + await clearOidcLink(token); + + const setupPage = await browser.newPage(); + try { + await linkOidcViaBrowser(setupPage); + } finally { + await setupPage.close(); + } + }); + + test.afterAll(async () => { + await clearOidcLink(token); + await setOidcConfig(token, snapshot); + }); -test.describe.serial('OIDC Login', () => { test('OIDC login button is visible after account linking', async ({ page, }) => { await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - // The button should now be visible since AuthorizedSubject was set by the link test const oidcButton = page.getByRole('button', { name: /sign in with/i }); await expect(oidcButton).toBeVisible({ timeout: 10_000 }); await expect(oidcButton).toContainText(TEST_CONFIG.oidcProviderName); @@ -20,19 +50,15 @@ test.describe.serial('OIDC Login', () => { await page.getByRole('button', { name: /sign in with/i }).click(); - // Should redirect to Keycloak await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); - // Fill Keycloak login form (each test gets a fresh browser context, so always required) await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); await page.locator('#username').fill(TEST_CONFIG.oidcUsername); await page.locator('#password').fill(TEST_CONFIG.oidcPassword); await page.locator('#kc-login').click(); - // Full flow: Keycloak → /api/auth/oidc/callback → /auth/oidc/callback?code=... → /dashboard await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); - // Verify we're authenticated — dashboard content visible, not redirected to login await expect(page.locator('body')).not.toContainText('Sign In', { timeout: 5_000, }); diff --git a/e2e/tests/15-oidc-save-without-link-warning.spec.ts b/e2e/tests/oidc-save-without-link-warning.spec.ts similarity index 65% rename from e2e/tests/15-oidc-save-without-link-warning.spec.ts rename to e2e/tests/oidc-save-without-link-warning.spec.ts index 4a86b731..3668e2c3 100644 --- a/e2e/tests/15-oidc-save-without-link-warning.spec.ts +++ b/e2e/tests/oidc-save-without-link-warning.spec.ts @@ -1,6 +1,14 @@ import { test, expect, Page } from '@playwright/test'; import { TEST_CONFIG } from './helpers/test-config'; -import { loginAndGetToken } from './helpers/app-api'; +import { + clearOidcLink, + configureOidc, + getOidcConfig, + loginAndGetToken, + OidcConfigSnapshot, + setOidcConfig, + updateOidcConfig, +} from './helpers/app-api'; const API = TEST_CONFIG.appUrl; @@ -9,45 +17,18 @@ const API = TEST_CONFIG.appUrl; test.describe.serial('OIDC Save without Link Warning', () => { let adminToken: string; + let snapshot: OidcConfigSnapshot; test.beforeAll(async () => { adminToken = await loginAndGetToken(); - // Ensure OIDC is enabled (idempotent — no-op if already enabled). - const oidcConfigResponse = await fetch(`${API}/api/account/oidc`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${adminToken}`, - }, - body: JSON.stringify({ - enabled: true, - providerName: TEST_CONFIG.oidcProviderName, - issuerUrl: `${TEST_CONFIG.keycloakUrl}/realms/${TEST_CONFIG.realm}`, - clientId: TEST_CONFIG.clientId, - clientSecret: TEST_CONFIG.clientSecret, - scopes: 'openid profile email', - redirectUrl: '', - exclusiveMode: false, - }), - }); - if (!oidcConfigResponse.ok) { - const body = await oidcConfigResponse.text().catch(() => ''); - throw new Error( - `Failed to configure OIDC in beforeAll (PUT /api/account/oidc): status=${oidcConfigResponse.status} ${oidcConfigResponse.statusText}, body=${body}`, - ); - } + snapshot = await getOidcConfig(adminToken); + await configureOidc(adminToken); + await clearOidcLink(adminToken); + }); - // Clear any linked subject so the dangerous-state save warning is reachable. - const clearLinkResponse = await fetch(`${API}/api/account/oidc/link`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${adminToken}` }, - }); - if (!clearLinkResponse.ok) { - const body = await clearLinkResponse.text().catch(() => ''); - throw new Error( - `Failed to clear linked OIDC subject in beforeAll (DELETE /api/account/oidc/link): status=${clearLinkResponse.status} ${clearLinkResponse.statusText}, body=${body}`, - ); - } + test.afterAll(async () => { + await clearOidcLink(adminToken); + await setOidcConfig(adminToken, snapshot); }); async function loginUI(page: Page) { @@ -67,7 +48,6 @@ test.describe.serial('OIDC Save without Link Warning', () => { async function openOidcSettings(page: Page) { await page.goto(`${API}/settings/account`); await page.getByText('OIDC / SSO').click(); - // Expansion happens client-side; wait for an interior element. await expect(page.getByRole('button', { name: 'Save OIDC Settings' })).toBeVisible({ timeout: 5_000, }); @@ -79,7 +59,6 @@ test.describe.serial('OIDC Save without Link Warning', () => { await loginUI(page); await openOidcSettings(page); - // Sanity: subject is empty await expect(page.locator('.oidc-link-section__subject')).not.toBeVisible(); await page.getByRole('button', { name: 'Save OIDC Settings' }).click(); @@ -92,7 +71,6 @@ test.describe.serial('OIDC Save without Link Warning', () => { dialog.getByRole('button', { name: 'Enable anyway' }), ).toBeVisible(); - // Cancel — leave the next test in a clean state. await dialog.getByRole('button', { name: 'Cancel' }).click(); await expect(dialog).not.toBeVisible({ timeout: 5_000 }); }); @@ -116,7 +94,6 @@ test.describe.serial('OIDC Save without Link Warning', () => { expect(putRequested).toBe(false); - // No success toast either. await expect(page.getByText('OIDC settings saved')).not.toBeVisible(); }); @@ -139,43 +116,22 @@ test.describe.serial('OIDC Save without Link Warning', () => { await loginUI(page); await openOidcSettings(page); - // Toggle Enable OIDC off await page.getByRole('switch', { name: 'Enable OIDC' }).click(); await page.getByRole('button', { name: 'Save OIDC Settings' }).click(); - // No dialog should appear. await expect(page.getByRole('alertdialog', { name: 'Enable OIDC without a linked account' })).not.toBeVisible({ timeout: 1_000 }); - // Save toast should appear (no confirmation needed). await expect(page.getByText('OIDC settings saved')).toBeVisible({ timeout: 5_000, }); - // Restore enabled=true via API for any subsequent tests. - await fetch(`${API}/api/account/oidc`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${adminToken}`, - }, - body: JSON.stringify({ - enabled: true, - providerName: TEST_CONFIG.oidcProviderName, - issuerUrl: `${TEST_CONFIG.keycloakUrl}/realms/${TEST_CONFIG.realm}`, - clientId: TEST_CONFIG.clientId, - clientSecret: TEST_CONFIG.clientSecret, - scopes: 'openid profile email', - redirectUrl: '', - exclusiveMode: false, - }), - }); + await updateOidcConfig(adminToken, { enabled: true }); }); test('Saving with a linked subject does not show the warning', async ({ page, }) => { - // Run the OIDC link flow once so the next save is in the linked state. await loginUI(page); await page.goto(`${API}/settings/account`); await page.getByText('OIDC / SSO').click(); @@ -184,20 +140,17 @@ test.describe.serial('OIDC Save without Link Warning', () => { await expect(linkButton).toBeVisible({ timeout: 5_000 }); await linkButton.click(); - // Authenticate at Keycloak. await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); await page.locator('#username').fill(TEST_CONFIG.oidcUsername); await page.locator('#password').fill(TEST_CONFIG.oidcPassword); await page.locator('#kc-login').click(); - // Land back on settings with the linked subject visible. await expect(page).toHaveURL(/\/settings\/account/, { timeout: 15_000 }); await expect(page.locator('.oidc-link-section__subject')).toBeVisible({ timeout: 5_000, }); - // Now save — no dialog should appear. await page.getByRole('button', { name: 'Save OIDC Settings' }).click(); await expect(page.getByRole('alertdialog', { name: 'Enable OIDC without a linked account' })).not.toBeVisible({ timeout: 1_000 }); await expect(page.getByText('OIDC settings saved')).toBeVisible({ diff --git a/e2e/tests/06-oidc-settings-ui.spec.ts b/e2e/tests/oidc-settings-ui.spec.ts similarity index 77% rename from e2e/tests/06-oidc-settings-ui.spec.ts rename to e2e/tests/oidc-settings-ui.spec.ts index 2eb85076..a6bf9d85 100644 --- a/e2e/tests/06-oidc-settings-ui.spec.ts +++ b/e2e/tests/oidc-settings-ui.spec.ts @@ -1,8 +1,39 @@ import { test, expect } from '@playwright/test'; import { TEST_CONFIG } from './helpers/test-config'; +import { + clearOidcLink, + configureOidc, + getOidcConfig, + loginAndGetToken, + OidcConfigSnapshot, + setOidcConfig, +} from './helpers/app-api'; +import { linkOidcViaBrowser } from './helpers/oidc'; import { getSubjectForUser } from './helpers/keycloak'; -test.describe.serial('OIDC Settings UI', () => { +test.describe('OIDC Settings UI', () => { + let token: string; + let snapshot: OidcConfigSnapshot; + + test.beforeAll(async ({ browser }) => { + token = await loginAndGetToken(); + snapshot = await getOidcConfig(token); + await configureOidc(token); + await clearOidcLink(token); + + const setupPage = await browser.newPage(); + try { + await linkOidcViaBrowser(setupPage); + } finally { + await setupPage.close(); + } + }); + + test.afterAll(async () => { + await clearOidcLink(token); + await setOidcConfig(token, snapshot); + }); + async function loginAndGoToSettings(page: import('@playwright/test').Page) { await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); await page @@ -21,13 +52,11 @@ test.describe.serial('OIDC Settings UI', () => { await loginAndGoToSettings(page); await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); - // Expand OIDC accordion await page.getByText('OIDC / SSO').click(); const subjectEl = page.locator('.oidc-link-section__subject'); await expect(subjectEl).toBeVisible({ timeout: 5_000 }); - // Verify the displayed subject matches the actual Keycloak user ID const expectedSubject = await getSubjectForUser(TEST_CONFIG.oidcUsername); await expect(subjectEl).toHaveText(expectedSubject); }); @@ -52,12 +81,10 @@ test.describe.serial('OIDC Settings UI', () => { `${TEST_CONFIG.appUrl}/settings/account?oidc_link=success`, ); - // Toast should appear with success message await expect(page.getByText('OIDC account linked successfully')).toBeVisible({ timeout: 5_000, }); - // OIDC accordion should be auto-expanded — the linked subject should be visible const subjectEl = page.locator('.oidc-link-section__subject'); await expect(subjectEl).toBeVisible({ timeout: 5_000 }); }); @@ -70,12 +97,10 @@ test.describe.serial('OIDC Settings UI', () => { `${TEST_CONFIG.appUrl}/settings/account?oidc_link_error=failed`, ); - // Toast should appear with error message await expect(page.getByText('Failed to link OIDC account')).toBeVisible({ timeout: 5_000, }); - // OIDC accordion should be auto-expanded const subjectEl = page.locator('.oidc-link-section__subject'); await expect(subjectEl).toBeVisible({ timeout: 5_000 }); }); diff --git a/e2e/tests/04-oidc-subject-mismatch.spec.ts b/e2e/tests/oidc-subject-mismatch.spec.ts similarity index 62% rename from e2e/tests/04-oidc-subject-mismatch.spec.ts rename to e2e/tests/oidc-subject-mismatch.spec.ts index 324f469a..e1a1ff3e 100644 --- a/e2e/tests/04-oidc-subject-mismatch.spec.ts +++ b/e2e/tests/oidc-subject-mismatch.spec.ts @@ -1,5 +1,14 @@ import { test, expect } from '@playwright/test'; import { TEST_CONFIG } from './helpers/test-config'; +import { + clearOidcLink, + configureOidc, + getOidcConfig, + loginAndGetToken, + OidcConfigSnapshot, + setOidcConfig, +} from './helpers/app-api'; +import { linkOidcViaBrowser } from './helpers/oidc'; import { createKeycloakUser, deleteKeycloakUser, @@ -9,13 +18,30 @@ const WRONG_USER = 'wronguser'; const WRONG_PASS = 'wrongpass'; const WRONG_EMAIL = 'wronguser@example.com'; -test.describe.serial('OIDC Subject Mismatch', () => { - test.beforeAll(async () => { +test.describe('OIDC Subject Mismatch', () => { + let token: string; + let snapshot: OidcConfigSnapshot; + + test.beforeAll(async ({ browser }) => { + token = await loginAndGetToken(); + snapshot = await getOidcConfig(token); + await configureOidc(token); + await clearOidcLink(token); + + const setupPage = await browser.newPage(); + try { + await linkOidcViaBrowser(setupPage); + } finally { + await setupPage.close(); + } + await createKeycloakUser(WRONG_USER, WRONG_PASS, WRONG_EMAIL); }); test.afterAll(async () => { await deleteKeycloakUser(WRONG_USER); + await clearOidcLink(token); + await setOidcConfig(token, snapshot); }); test('OIDC login with wrong Keycloak user shows unauthorized error', async ({ @@ -23,19 +49,15 @@ test.describe.serial('OIDC Subject Mismatch', () => { }) => { await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - // Click OIDC login button await page.getByRole('button', { name: /sign in with/i }).click(); - // Should redirect to Keycloak await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); - // Wait for Keycloak login form to render, then log in as the wrong user await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); await page.locator('#username').fill(WRONG_USER); await page.locator('#password').fill(WRONG_PASS); await page.locator('#kc-login').click(); - // Backend detects subject mismatch and redirects to login with error await expect(page).toHaveURL(/oidc_error=unauthorized/, { timeout: 15_000, }); diff --git a/e2e/tests/09-oidc-unlink-allows-any-user.spec.ts b/e2e/tests/oidc-unlink-allows-any-user.spec.ts similarity index 84% rename from e2e/tests/09-oidc-unlink-allows-any-user.spec.ts rename to e2e/tests/oidc-unlink-allows-any-user.spec.ts index 16cb50c9..a172f61e 100644 --- a/e2e/tests/09-oidc-unlink-allows-any-user.spec.ts +++ b/e2e/tests/oidc-unlink-allows-any-user.spec.ts @@ -1,6 +1,14 @@ import { test, expect } from '@playwright/test'; import { TEST_CONFIG } from './helpers/test-config'; -import { loginAndGetToken } from './helpers/app-api'; +import { + clearOidcLink, + configureOidc, + getOidcConfig, + loginAndGetToken, + OidcConfigSnapshot, + setOidcConfig, +} from './helpers/app-api'; +import { linkOidcViaBrowser } from './helpers/oidc'; import { createKeycloakUser, deleteKeycloakUser, @@ -10,22 +18,33 @@ const ANOTHER_USER = 'anotheruser'; const ANOTHER_PASS = 'anotherpass'; const ANOTHER_EMAIL = 'anotheruser@example.com'; -const API = TEST_CONFIG.appUrl; - test.describe.serial('OIDC Unlink Allows Any User', () => { let adminToken: string; + let snapshot: OidcConfigSnapshot; - test.beforeAll(async () => { + test.beforeAll(async ({ browser }) => { adminToken = await loginAndGetToken(); + snapshot = await getOidcConfig(adminToken); + await configureOidc(adminToken); + await clearOidcLink(adminToken); + + const setupPage = await browser.newPage(); + try { + await linkOidcViaBrowser(setupPage); + } finally { + await setupPage.close(); + } + await createKeycloakUser(ANOTHER_USER, ANOTHER_PASS, ANOTHER_EMAIL); }); test.afterAll(async () => { await deleteKeycloakUser(ANOTHER_USER); + await clearOidcLink(adminToken); + await setOidcConfig(adminToken, snapshot); }); test('unlinking OIDC subject via UI succeeds', async ({ page }) => { - // Log in with local credentials await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); await page .getByRole('textbox', { name: 'Username' }) @@ -38,33 +57,26 @@ test.describe.serial('OIDC Unlink Allows Any User', () => { .click(); await expect(page).toHaveURL(/\/dashboard/, { timeout: 10_000 }); - // Navigate to account settings and expand OIDC card await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); await page.getByText('OIDC / SSO').click(); - // Verify subject is currently linked const subjectEl = page.locator('.oidc-link-section__subject'); await expect(subjectEl).toBeVisible({ timeout: 5_000 }); - // Click the Unlink button const unlinkButton = page.getByRole('button', { name: 'Unlink' }); await expect(unlinkButton).toBeVisible({ timeout: 5_000 }); await unlinkButton.click(); - // Confirm the destructive dialog const confirmButton = page.getByRole('alertdialog').getByRole('button', { name: 'Unlink' }); await expect(confirmButton).toBeVisible({ timeout: 5_000 }); await confirmButton.click(); - // Verify success toast await expect(page.getByText('OIDC account unlinked')).toBeVisible({ timeout: 5_000, }); - // Subject should no longer be displayed await expect(subjectEl).not.toBeVisible({ timeout: 5_000 }); - // Button should now say "Link Account" instead of "Re-link" const linkButton = page.getByRole('button', { name: 'Link Account' }); await expect(linkButton).toBeVisible({ timeout: 5_000 }); }); @@ -72,7 +84,6 @@ test.describe.serial('OIDC Unlink Allows Any User', () => { test('OIDC login still works after unlinking', async ({ page }) => { await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - // Button should still be visible (OIDC is configured, just no linked subject) await page.getByRole('button', { name: /sign in with/i }).click(); await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); @@ -99,7 +110,6 @@ test.describe.serial('OIDC Unlink Allows Any User', () => { await page.locator('#password').fill(ANOTHER_PASS); await page.locator('#kc-login').click(); - // Should succeed — no subject restriction when unlinked await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); await expect(page.locator('body')).not.toContainText('Sign In', { diff --git a/e2e/tests/17-orphaned-files-behaviors.spec.ts b/e2e/tests/orphaned-files-behaviors.spec.ts similarity index 99% rename from e2e/tests/17-orphaned-files-behaviors.spec.ts rename to e2e/tests/orphaned-files-behaviors.spec.ts index 5660b433..244c1355 100644 --- a/e2e/tests/17-orphaned-files-behaviors.spec.ts +++ b/e2e/tests/orphaned-files-behaviors.spec.ts @@ -20,7 +20,7 @@ import { chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures'; /** * Behavior-level coverage for the orphaned files cleaner that isn't * client-specific. The per-client integration matrix lives in - * `16-orphaned-files-cleanup.spec.ts`; this file picks qBittorrent as the + * `orphaned-files-cleanup.spec.ts`; this file picks qBittorrent as the * single backing client and exercises configuration knobs: * * - PurgeAfterHours (deletes aged, leaves recent, null = never purge) diff --git a/e2e/tests/16-orphaned-files-cleanup.spec.ts b/e2e/tests/orphaned-files-cleanup.spec.ts similarity index 100% rename from e2e/tests/16-orphaned-files-cleanup.spec.ts rename to e2e/tests/orphaned-files-cleanup.spec.ts diff --git a/e2e/tests/12-seeding-rules-api.spec.ts b/e2e/tests/seeding-rules-api.spec.ts similarity index 100% rename from e2e/tests/12-seeding-rules-api.spec.ts rename to e2e/tests/seeding-rules-api.spec.ts diff --git a/e2e/tests/10-seeker-config-api.spec.ts b/e2e/tests/seeker-config-api.spec.ts similarity index 100% rename from e2e/tests/10-seeker-config-api.spec.ts rename to e2e/tests/seeker-config-api.spec.ts diff --git a/e2e/tests/13-trusted-network-xff-bug.spec.ts b/e2e/tests/trusted-network-xff-bug.spec.ts similarity index 100% rename from e2e/tests/13-trusted-network-xff-bug.spec.ts rename to e2e/tests/trusted-network-xff-bug.spec.ts