Add option to remove malware if any file is blocked (#621)

This commit is contained in:
Flaminel
2026-05-30 01:33:51 +03:00
committed by GitHub
parent 26b76908eb
commit 084f83efca
51 changed files with 4168 additions and 156 deletions

View File

@@ -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/

View File

@@ -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;

View File

@@ -11,4 +11,5 @@ public enum DeleteReason
AllFilesSkipped,
AllFilesSkippedByQBit,
AllFilesBlocked,
AtLeastOneFileBlocked,
}

View File

@@ -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>>());
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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()
{

View File

@@ -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);

View File

@@ -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++;
}

View File

@@ -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;
}

View File

@@ -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++;
}

View File

@@ -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;
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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");

View File

@@ -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();

View File

@@ -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',

View File

@@ -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>

View File

@@ -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(),
});
}

View File

@@ -22,6 +22,7 @@ export interface MalwareBlockerConfig {
ignorePrivate: boolean;
deletePrivate: boolean;
processNoContentId: boolean;
deleteIfAnyFileBlocked: boolean;
sonarr: BlocklistSettings;
radarr: BlocklistSettings;
lidarr: BlocklistSettings;

View File

@@ -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

View File

@@ -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}>

View File

@@ -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

View File

@@ -30,6 +30,8 @@ services:
depends_on:
keycloak:
condition: service_healthy
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
PORT: "5000"
HTTP_PORTS: "5000"

View File

@@ -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' },
},
],

View File

@@ -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.');
}

View File

@@ -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}`);
}
}

View 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
View 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,
});
}

View File

@@ -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

View 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);
});
});
}

View File

@@ -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,

View File

@@ -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,
}) => {

View File

@@ -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();
});

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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', {

View File

@@ -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,
});

View File

@@ -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({

View File

@@ -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 });
});

View File

@@ -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,
});

View File

@@ -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', {

View File

@@ -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)