mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-06-09 14:25:43 -04:00
Add option to remove malware if any file is blocked (#621)
This commit is contained in:
17
.github/workflows/e2e.yml
vendored
17
.github/workflows/e2e.yml
vendored
@@ -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/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,4 +11,5 @@ public enum DeleteReason
|
||||
AllFilesSkipped,
|
||||
AllFilesSkippedByQBit,
|
||||
AllFilesBlocked,
|
||||
AtLeastOneFileBlocked,
|
||||
}
|
||||
@@ -553,9 +553,9 @@ public class DelugeServiceTests : IClassFixture<DelugeServiceFixture>
|
||||
{
|
||||
}
|
||||
|
||||
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<DelugeServiceFixture>
|
||||
.Received(1)
|
||||
.ChangeFilesPriority(hash, Arg.Is<List<int>>(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<string, DelugeFileOrDirectory>
|
||||
{
|
||||
{ "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<string>(name => name.EndsWith("malware.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(false);
|
||||
_fixture.FilenameEvaluator
|
||||
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
|
||||
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.ChangeFilesPriority(Arg.Any<string>(), Arg.Any<List<int>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<QBitServiceFixture>
|
||||
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<InstanceType>())
|
||||
.Returns(BlocklistType.Blacklist);
|
||||
_fixture.BlocklistProvider
|
||||
.GetPatterns(Arg.Any<InstanceType>())
|
||||
.Returns(new ConcurrentBag<string>());
|
||||
_fixture.BlocklistProvider
|
||||
.GetRegexes(Arg.Any<InstanceType>())
|
||||
.Returns(new ConcurrentBag<Regex>());
|
||||
}
|
||||
|
||||
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<string, JToken>
|
||||
{
|
||||
{ "is_private", JToken.FromObject(isPrivate) },
|
||||
},
|
||||
};
|
||||
|
||||
private void StubClient(string hash, IReadOnlyList<TorrentContent> files, bool isPrivate = false)
|
||||
{
|
||||
_fixture.ClientWrapper
|
||||
.GetTorrentListAsync(Arg.Is<TorrentListQuery>(q => q.Hashes != null && q.Hashes.Contains(hash)))
|
||||
.Returns(new[] { MakeTorrentInfo(hash) });
|
||||
|
||||
_fixture.ClientWrapper
|
||||
.GetTorrentTrackersAsync(hash)
|
||||
.Returns(Array.Empty<TorrentTracker>());
|
||||
|
||||
_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<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(false);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
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<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(false);
|
||||
_fixture.FilenameEvaluator
|
||||
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
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<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(false);
|
||||
_fixture.FilenameEvaluator
|
||||
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
|
||||
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<TorrentContentPriority>());
|
||||
}
|
||||
|
||||
[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<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<TorrentContentPriority>());
|
||||
}
|
||||
|
||||
[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<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
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<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RTorrentServiceFixture>
|
||||
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<InstanceType>())
|
||||
.Returns(BlocklistType.Blacklist);
|
||||
_fixture.BlocklistProvider
|
||||
.GetPatterns(Arg.Any<InstanceType>())
|
||||
.Returns(new ConcurrentBag<string>());
|
||||
_fixture.BlocklistProvider
|
||||
.GetRegexes(Arg.Any<InstanceType>())
|
||||
.Returns(new ConcurrentBag<Regex>());
|
||||
}
|
||||
|
||||
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<RTorrentFile> files, bool isPrivate = false)
|
||||
{
|
||||
_fixture.ClientWrapper.GetTorrentAsync(hash).Returns(MakeDownload(hash, isPrivate));
|
||||
_fixture.ClientWrapper.GetTrackersAsync(hash).Returns(new List<string>());
|
||||
_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<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(false);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
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<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(false);
|
||||
_fixture.FilenameEvaluator
|
||||
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
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<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(false);
|
||||
_fixture.FilenameEvaluator
|
||||
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
|
||||
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>());
|
||||
}
|
||||
|
||||
[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<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>());
|
||||
}
|
||||
|
||||
[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<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TransmissionServiceFixture
|
||||
result.ChangeCategory.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
public class BlockUnwantedFilesAsyncScenarios : TransmissionServiceTests
|
||||
{
|
||||
public BlockUnwantedFilesAsyncScenarios(TransmissionServiceFixture 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<InstanceType>())
|
||||
.Returns(BlocklistType.Blacklist);
|
||||
_fixture.BlocklistProvider
|
||||
.GetPatterns(Arg.Any<InstanceType>())
|
||||
.Returns(new ConcurrentBag<string>());
|
||||
_fixture.BlocklistProvider
|
||||
.GetRegexes(Arg.Any<InstanceType>())
|
||||
.Returns(new ConcurrentBag<Regex>());
|
||||
}
|
||||
|
||||
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<string[]>(), 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<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(false);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
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<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(false);
|
||||
_fixture.FilenameEvaluator
|
||||
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
|
||||
await _fixture.ClientWrapper
|
||||
.Received(1)
|
||||
.TorrentSetAsync(Arg.Any<TorrentSettings>());
|
||||
}
|
||||
|
||||
[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<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(false);
|
||||
_fixture.FilenameEvaluator
|
||||
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
|
||||
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.TorrentSetAsync(Arg.Any<TorrentSettings>());
|
||||
}
|
||||
|
||||
[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<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.TorrentSetAsync(Arg.Any<TorrentSettings>());
|
||||
}
|
||||
|
||||
[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<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UTorrentServiceFixture>
|
||||
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<InstanceType>())
|
||||
.Returns(BlocklistType.Blacklist);
|
||||
_fixture.BlocklistProvider
|
||||
.GetPatterns(Arg.Any<InstanceType>())
|
||||
.Returns(new ConcurrentBag<string>());
|
||||
_fixture.BlocklistProvider
|
||||
.GetRegexes(Arg.Any<InstanceType>())
|
||||
.Returns(new ConcurrentBag<Regex>());
|
||||
}
|
||||
|
||||
private void StubClient(string hash, IReadOnlyList<UTorrentFile> 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<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(false);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
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<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(false);
|
||||
_fixture.FilenameEvaluator
|
||||
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
|
||||
await _fixture.ClientWrapper
|
||||
.Received(1)
|
||||
.SetFilesPriorityAsync(hash, Arg.Is<List<int>>(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<string>(name => name.EndsWith("installer.exe")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(false);
|
||||
_fixture.FilenameEvaluator
|
||||
.IsValid(Arg.Is<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeTrue();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
|
||||
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.SetFilesPriorityAsync(Arg.Any<string>(), Arg.Any<List<int>>(), Arg.Any<int>());
|
||||
}
|
||||
|
||||
[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<string>(), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.SetFilesPriorityAsync(Arg.Any<string>(), Arg.Any<List<int>>(), Arg.Any<int>());
|
||||
}
|
||||
|
||||
[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<string>(name => name.EndsWith("movie.mkv")), Arg.Any<BlocklistType>(), Arg.Any<ConcurrentBag<string>>(), Arg.Any<ConcurrentBag<Regex>>())
|
||||
.Returns(true);
|
||||
|
||||
BlockFilesResult result = await sut.BlockUnwantedFilesAsync(hash, Array.Empty<string>());
|
||||
|
||||
result.Found.ShouldBeTrue();
|
||||
result.ShouldRemove.ShouldBeFalse();
|
||||
result.DeleteReason.ShouldBe(DeleteReason.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IArrClient>();
|
||||
mockArrClient.IsRecordValid(Arg.Any<QueueRecord>()).Returns(true);
|
||||
mockArrClient.HasContentId(Arg.Any<QueueRecord>()).Returns(true);
|
||||
|
||||
_fixture.ArrClientFactory
|
||||
.GetClient(InstanceType.Sonarr, Arg.Any<float>())
|
||||
.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<IArrClient>(),
|
||||
Arg.Any<ArrInstance>(),
|
||||
Arg.Any<Func<IReadOnlyList<QueueRecord>, Task>>()
|
||||
)
|
||||
.Returns(ci =>
|
||||
{
|
||||
var callback = ci.ArgAt<Func<IReadOnlyList<QueueRecord>, Task>>(2);
|
||||
return callback([queueRecord]);
|
||||
});
|
||||
|
||||
IDownloadService mockDownloadService = _fixture.CreateMockDownloadService();
|
||||
mockDownloadService
|
||||
.BlockUnwantedFilesAsync(
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<List<string>>()
|
||||
)
|
||||
.Returns(new BlockFilesResult
|
||||
{
|
||||
Found = true,
|
||||
ShouldRemove = true,
|
||||
IsPrivate = false,
|
||||
DeleteReason = DeleteReason.AtLeastOneFileBlocked
|
||||
});
|
||||
|
||||
_fixture.DownloadServiceFactory
|
||||
.GetDownloadService(Arg.Any<DownloadClientConfig>())
|
||||
.Returns(mockDownloadService);
|
||||
|
||||
MalwareBlockerJob sut = CreateSut();
|
||||
|
||||
// Act
|
||||
await sut.ExecuteAsync();
|
||||
|
||||
// Assert
|
||||
await _fixture.MessageBus.Received(1).Publish(
|
||||
Arg.Is<QueueItemRemoveRequest<SeriesSearchItem>>(r =>
|
||||
r.DeleteReason == DeleteReason.AtLeastOneFileBlocked
|
||||
),
|
||||
Arg.Any<CancellationToken>()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessInstanceAsync_WhenPrivateAndDeletePrivateFalse_DoesNotRemoveFromClient()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
2200
code/backend/Cleanuparr.Persistence/Migrations/Data/20260528201320_AddDeleteIfAnyFileBlocked.Designer.cs
generated
Normal file
2200
code/backend/Cleanuparr.Persistence/Migrations/Data/20260528201320_AddDeleteIfAnyFileBlocked.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDeleteIfAnyFileBlocked : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "delete_if_any_file_blocked",
|
||||
table: "content_blocker_configs",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "delete_if_any_file_blocked",
|
||||
table: "content_blocker_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -748,6 +748,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cron_expression");
|
||||
|
||||
b.Property<bool>("DeleteIfAnyFileBlocked")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_if_any_file_blocked");
|
||||
|
||||
b.Property<bool>("DeletePrivate")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("delete_private");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
<app-toggle label="Process downloads with no content ID" [(checked)]="processNoContentId"
|
||||
hint="Process downloads from the queue that are not linked to any content in the arr app. Cleanuparr will not be able to trigger a search for a replacement when this happens."
|
||||
helpKey="malware-blocker:processNoContentId" />
|
||||
<app-toggle label="Delete if any file is blocked" [(checked)]="deleteIfAnyFileBlocked"
|
||||
hint="When enabled, the entire download will be removed if any file in it matches the blocklist. When disabled, the download is only removed when all of its files match."
|
||||
helpKey="malware-blocker:deleteIfAnyFileBlocked" />
|
||||
|
||||
<div class="form-divider"></div>
|
||||
|
||||
|
||||
@@ -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<string, any> = {};
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface MalwareBlockerConfig {
|
||||
ignorePrivate: boolean;
|
||||
deletePrivate: boolean;
|
||||
processNoContentId: boolean;
|
||||
deleteIfAnyFileBlocked: boolean;
|
||||
sonarr: BlocklistSettings;
|
||||
radarr: BlocklistSettings;
|
||||
lidarr: BlocklistSettings;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -114,6 +114,20 @@ This setting will enable the processing of items that appear in the logs with th
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Delete if any file is blocked"
|
||||
>
|
||||
|
||||
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.
|
||||
|
||||
<Note>
|
||||
`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.
|
||||
</Note>
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
|
||||
10
e2e/Makefile
10
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
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ services:
|
||||
depends_on:
|
||||
keycloak:
|
||||
condition: service_healthy
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
PORT: "5000"
|
||||
HTTP_PORTS: "5000"
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -341,6 +341,28 @@ export async function updateOrphanedFilesConfig(
|
||||
});
|
||||
}
|
||||
|
||||
// --- Malware Blocker helpers ---
|
||||
|
||||
export async function getMalwareBlockerConfig(accessToken: string): Promise<Response> {
|
||||
return fetch(`${API}/api/configuration/malware_blocker`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateMalwareBlockerConfig(
|
||||
accessToken: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
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<void> {
|
||||
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<OidcConfigSnapshot> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
196
e2e/tests/helpers/arr-stub.ts
Normal file
196
e2e/tests/helpers/arr-stub.ts
Normal file
@@ -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<void> {
|
||||
if (this.server) {
|
||||
throw new Error('ArrStubServer already started');
|
||||
}
|
||||
|
||||
this.server = createServer((req, res) => this.handle(req, res));
|
||||
|
||||
await new Promise<void>((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<void> {
|
||||
if (!this.server) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((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<boolean> {
|
||||
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<boolean> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
40
e2e/tests/helpers/oidc.ts
Normal file
40
e2e/tests/helpers/oidc.ts
Normal file
@@ -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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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 `<savePath>/<name>/<filename>` 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/<client>`) are chowned to PUID=1000 by
|
||||
|
||||
316
e2e/tests/malware-blocker.spec.ts
Normal file
316
e2e/tests/malware-blocker.spec.ts
Normal file
@@ -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<string, string> = {
|
||||
qBittorrent: 'qbittorrent',
|
||||
Transmission: 'transmission',
|
||||
Deluge: 'deluge',
|
||||
uTorrent: 'utorrent',
|
||||
rTorrent: 'rtorrent',
|
||||
};
|
||||
|
||||
interface TorrentMeta {
|
||||
name: string;
|
||||
infoHash: string;
|
||||
}
|
||||
|
||||
async function waitForTorrents(
|
||||
driver: { listTorrents(): Promise<Array<{ hash: string }>> },
|
||||
expectedHashes: string[],
|
||||
timeoutMs = 15_000,
|
||||
): Promise<void> {
|
||||
const want = new Set(expectedHashes.map((h) => h.toLowerCase()));
|
||||
const start = Date.now();
|
||||
let last: Set<string> = 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<Array<{ hash: string }>> },
|
||||
hash: string,
|
||||
): Promise<boolean> {
|
||||
const target = hash.toLowerCase();
|
||||
const list = await driver.listTorrents();
|
||||
return list.some((t) => t.hash.toLowerCase() === target);
|
||||
}
|
||||
|
||||
async function setMalwareBlocker(
|
||||
token: string,
|
||||
overrides: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
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<string, unknown> ?? {}) },
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
}) => {
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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', {
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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(() => '<failed to read response body>');
|
||||
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(() => '<failed to read response body>');
|
||||
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({
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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', {
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user