mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
Add support for qBittorrent API key (#832)
This commit is contained in:
1 parent
85e956dd92
commit
705957759e
19 files changed
+770
-9
No files matched your search
+101
@@ -101,6 +101,49 @@ public class DownloadClientControllerTests : IDisposable
|
||||
(await _dataContext.DownloadClients.CountAsync()).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDownloadClientConfig_QBittorrentWithApiKey_PersistsApiKey()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "qbit-key-client",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Host = "http://localhost:8080",
|
||||
ApiKey = "qbt_abcdefghijklmnopqrstuvwxyz12",
|
||||
};
|
||||
|
||||
// Act
|
||||
await _controller.CreateDownloadClientConfig(request);
|
||||
|
||||
// Assert
|
||||
var saved = await _dataContext.DownloadClients.AsNoTracking().FirstAsync(c => c.Name == "qbit-key-client");
|
||||
saved.ApiKey.ShouldBe("qbt_abcdefghijklmnopqrstuvwxyz12");
|
||||
saved.Username.ShouldBeNull();
|
||||
saved.Password.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDownloadClientConfig_QBittorrentWithApiKeyAndPassword_PropagatesValidationException()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CreateDownloadClientRequest
|
||||
{
|
||||
Enabled = true,
|
||||
Name = "qbit-conflict-client",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Host = "http://localhost:8080",
|
||||
ApiKey = "qbt_abcdefghijklmnopqrstuvwxyz12",
|
||||
Password = "pass",
|
||||
};
|
||||
|
||||
// Act / Assert
|
||||
Cleanuparr.Domain.Exceptions.ValidationException exception = await Should.ThrowAsync<Cleanuparr.Domain.Exceptions.ValidationException>(
|
||||
() => _controller.CreateDownloadClientConfig(request));
|
||||
exception.Message.ShouldBe("Use either an API key or a username and password, not both");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateDownloadClientConfig_SabnzbdWithNoType_StoresUsenet()
|
||||
{
|
||||
@@ -241,6 +284,44 @@ public class DownloadClientControllerTests : IDisposable
|
||||
saved.Host!.ToString().ShouldContain("newhost");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDownloadClientConfig_SwitchFromCredentialsToApiKey_ClearingUsernameAndPassword_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
DownloadClientConfig client = new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "qbit-client",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
Username = "admin",
|
||||
Password = "admin",
|
||||
};
|
||||
_dataContext.DownloadClients.Add(client);
|
||||
await _dataContext.SaveChangesAsync();
|
||||
|
||||
UpdateDownloadClientRequest request = new()
|
||||
{
|
||||
Name = "qbit-client",
|
||||
Host = "http://localhost:8080",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Username = null,
|
||||
Password = null,
|
||||
ApiKey = "qbt_abcdefghijklmnopqrstuvwxyz12",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.UpdateDownloadClientConfig(client.Id, request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
DownloadClientConfig saved = await _dataContext.DownloadClients.AsNoTracking().FirstAsync(c => c.Id == client.Id);
|
||||
saved.ApiKey.ShouldBe("qbt_abcdefghijklmnopqrstuvwxyz12");
|
||||
saved.Username.ShouldBeNull();
|
||||
saved.Password.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateDownloadClientConfig_PlaceholderApiKeyOnClientWithNoStoredKey_PropagatesValidationException()
|
||||
{
|
||||
@@ -406,6 +487,26 @@ public class DownloadClientControllerTests : IDisposable
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestDownloadClient_QBittorrentWithApiKeyAndPassword_ReturnsBadRequest()
|
||||
{
|
||||
// Arrange
|
||||
var request = new TestDownloadClientRequest
|
||||
{
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Host = "http://localhost:8080",
|
||||
ApiKey = "qbt_abcdefghijklmnopqrstuvwxyz12",
|
||||
Password = "pass",
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _controller.TestDownloadClient(request);
|
||||
|
||||
// Assert
|
||||
result.ShouldBeOfType<ObjectResult>().StatusCode.ShouldBe(400);
|
||||
_downloadServiceFactory.DidNotReceive().GetDownloadService(Arg.Any<DownloadClientConfig>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestDownloadClient_NewApiKeyWithPlaceholderPassword_UsesNewApiKeyAndStoredPassword()
|
||||
{
|
||||
|
||||
+6
@@ -44,6 +44,12 @@ public sealed record TestDownloadClientRequest
|
||||
{
|
||||
throw new ValidationException("API key cannot be empty");
|
||||
}
|
||||
|
||||
if (TypeName.SupportsApiKeyAndCredentials() && !string.IsNullOrWhiteSpace(ApiKey) &&
|
||||
(!string.IsNullOrWhiteSpace(Username) || !string.IsNullOrWhiteSpace(Password)))
|
||||
{
|
||||
throw new ValidationException("Use either an API key or a username and password, not both");
|
||||
}
|
||||
}
|
||||
|
||||
public DownloadClientConfig ToTestConfig(string? resolvedPassword = null, string? resolvedApiKey = null)
|
||||
|
||||
@@ -4,7 +4,7 @@ public static class DownloadClientTypeNameExtensions
|
||||
{
|
||||
private static readonly Dictionary<DownloadClientTypeName, DownloadClientAuthField[]> AuthFields = new()
|
||||
{
|
||||
[DownloadClientTypeName.qBittorrent] = [DownloadClientAuthField.Username, DownloadClientAuthField.Password],
|
||||
[DownloadClientTypeName.qBittorrent] = [DownloadClientAuthField.Username, DownloadClientAuthField.Password, DownloadClientAuthField.ApiKey],
|
||||
[DownloadClientTypeName.Deluge] = [DownloadClientAuthField.Password],
|
||||
[DownloadClientTypeName.Transmission] = [DownloadClientAuthField.Username, DownloadClientAuthField.Password],
|
||||
[DownloadClientTypeName.uTorrent] = [DownloadClientAuthField.Username, DownloadClientAuthField.Password],
|
||||
@@ -38,6 +38,13 @@ public static class DownloadClientTypeNameExtensions
|
||||
public static bool RequiresAuthField(this DownloadClientTypeName typeName, DownloadClientAuthField field) =>
|
||||
AuthFields.TryGetValue(typeName, out var fields) && fields is [var only] && only == field;
|
||||
|
||||
/// <summary>
|
||||
/// True when the client type accepts an API key alongside a username or password, so the two must not be mixed.
|
||||
/// </summary>
|
||||
public static bool SupportsApiKeyAndCredentials(this DownloadClientTypeName typeName) =>
|
||||
typeName.SupportsAuthField(DownloadClientAuthField.ApiKey) &&
|
||||
(typeName.SupportsAuthField(DownloadClientAuthField.Username) || typeName.SupportsAuthField(DownloadClientAuthField.Password));
|
||||
|
||||
private static readonly DownloadClientCapability[] BaseTorrentCapabilities =
|
||||
[
|
||||
DownloadClientCapability.QueueCheck,
|
||||
|
||||
+196
@@ -1,10 +1,12 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Cleanuparr.Domain.Entities;
|
||||
using Cleanuparr.Domain.Entities.HealthCheck;
|
||||
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;
|
||||
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
|
||||
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
|
||||
using NSubstitute;
|
||||
@@ -1699,4 +1701,198 @@ public class QBitServiceTests : IClassFixture<QBitServiceFixture>
|
||||
.SetFilePriorityAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<TorrentContentPriority>());
|
||||
}
|
||||
}
|
||||
|
||||
public class ApiKeyAuthScenarios : QBitServiceTests
|
||||
{
|
||||
public ApiKeyAuthScenarios(QBitServiceFixture fixture) : base(fixture)
|
||||
{
|
||||
}
|
||||
|
||||
private static DownloadClientConfig CreateApiKeyConfig() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Client",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Enabled = true,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
ApiKey = "qbt_abcdefghijklmnopqrstuvwxyz12",
|
||||
UrlBase = ""
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ApiKeyConfigured_SetsBearerAuthorizationHeader()
|
||||
{
|
||||
// Arrange
|
||||
HttpClient httpClient = new();
|
||||
_fixture.HttpClientProvider.CreateClient(Arg.Any<DownloadClientConfig>()).Returns(httpClient);
|
||||
DownloadClientConfig config = CreateApiKeyConfig();
|
||||
|
||||
// Act
|
||||
using QBitService sut = new(
|
||||
_fixture.Logger,
|
||||
_fixture.FilenameEvaluator,
|
||||
_fixture.Striker,
|
||||
_fixture.DryRunInterceptor,
|
||||
_fixture.HardLinkFileService,
|
||||
_fixture.HttpClientProvider,
|
||||
_fixture.EventPublisher,
|
||||
_fixture.BlocklistProvider,
|
||||
config,
|
||||
_fixture.RuleEvaluator,
|
||||
_fixture.SeedingRuleEvaluator,
|
||||
TimeProvider.System
|
||||
);
|
||||
|
||||
// Assert
|
||||
httpClient.DefaultRequestHeaders.Authorization?.Scheme.ShouldBe("Bearer");
|
||||
httpClient.DefaultRequestHeaders.Authorization?.Parameter.ShouldBe(config.ApiKey);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WhitespaceApiKey_DoesNotSetAuthorizationHeader()
|
||||
{
|
||||
// Arrange
|
||||
HttpClient httpClient = new();
|
||||
_fixture.HttpClientProvider.CreateClient(Arg.Any<DownloadClientConfig>()).Returns(httpClient);
|
||||
DownloadClientConfig config = CreateApiKeyConfig();
|
||||
config.ApiKey = " ";
|
||||
|
||||
// Act
|
||||
using QBitService sut = new(
|
||||
_fixture.Logger,
|
||||
_fixture.FilenameEvaluator,
|
||||
_fixture.Striker,
|
||||
_fixture.DryRunInterceptor,
|
||||
_fixture.HardLinkFileService,
|
||||
_fixture.HttpClientProvider,
|
||||
_fixture.EventPublisher,
|
||||
_fixture.BlocklistProvider,
|
||||
config,
|
||||
_fixture.RuleEvaluator,
|
||||
_fixture.SeedingRuleEvaluator,
|
||||
TimeProvider.System
|
||||
);
|
||||
|
||||
// Assert
|
||||
httpClient.DefaultRequestHeaders.Authorization.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoginAsync_CredentialsConfigured_LogsIn()
|
||||
{
|
||||
// Arrange
|
||||
DownloadClientConfig config = CreateApiKeyConfig();
|
||||
config.ApiKey = null;
|
||||
config.Username = "admin";
|
||||
config.Password = "admin";
|
||||
QBitService sut = _fixture.CreateSut(config);
|
||||
|
||||
// Act
|
||||
await sut.LoginAsync();
|
||||
|
||||
// Assert
|
||||
await _fixture.ClientWrapper
|
||||
.Received(1)
|
||||
.LoginAsync(config.Username, config.Password);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheckAsync_CredentialsConfigured_LogsInAndReportsHealthy()
|
||||
{
|
||||
// Arrange
|
||||
DownloadClientConfig config = CreateApiKeyConfig();
|
||||
config.ApiKey = null;
|
||||
config.Username = "admin";
|
||||
config.Password = "admin";
|
||||
QBitService sut = _fixture.CreateSut(config);
|
||||
|
||||
// Act
|
||||
HealthCheckResult result = await sut.HealthCheckAsync();
|
||||
|
||||
// Assert
|
||||
result.IsHealthy.ShouldBeTrue();
|
||||
await _fixture.ClientWrapper
|
||||
.Received(1)
|
||||
.LoginAsync(config.Username, config.Password);
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.GetAlternativeSpeedLimitsEnabledAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheckAsync_WhitespaceApiKey_UsesCredentials()
|
||||
{
|
||||
// Arrange
|
||||
DownloadClientConfig config = CreateApiKeyConfig();
|
||||
config.ApiKey = " ";
|
||||
config.Username = "admin";
|
||||
config.Password = "admin";
|
||||
QBitService sut = _fixture.CreateSut(config);
|
||||
|
||||
// Act
|
||||
HealthCheckResult result = await sut.HealthCheckAsync();
|
||||
|
||||
// Assert
|
||||
result.IsHealthy.ShouldBeTrue();
|
||||
await _fixture.ClientWrapper
|
||||
.Received(1)
|
||||
.LoginAsync(config.Username, config.Password);
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.GetAlternativeSpeedLimitsEnabledAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoginAsync_ApiKeyConfigured_SkipsLogin()
|
||||
{
|
||||
// Arrange
|
||||
QBitService sut = _fixture.CreateSut(CreateApiKeyConfig());
|
||||
|
||||
// Act
|
||||
await sut.LoginAsync();
|
||||
|
||||
// Assert
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.LoginAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheckAsync_ApiKeyConfigured_ChecksAlternativeSpeedLimitsEndpoint()
|
||||
{
|
||||
// Arrange
|
||||
QBitService sut = _fixture.CreateSut(CreateApiKeyConfig());
|
||||
_fixture.ClientWrapper.GetAlternativeSpeedLimitsEnabledAsync().Returns(false);
|
||||
|
||||
// Act
|
||||
var result = await sut.HealthCheckAsync();
|
||||
|
||||
// Assert
|
||||
result.IsHealthy.ShouldBeTrue();
|
||||
await _fixture.ClientWrapper.Received(1).GetAlternativeSpeedLimitsEnabledAsync();
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.LoginAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheckAsync_ApiKeyConfigured_EndpointThrows_ReportsUnhealthy()
|
||||
{
|
||||
// Arrange
|
||||
QBitService sut = _fixture.CreateSut(CreateApiKeyConfig());
|
||||
_fixture.ClientWrapper
|
||||
.GetAlternativeSpeedLimitsEnabledAsync()
|
||||
.ThrowsAsync(new HttpRequestException("403 Forbidden"));
|
||||
|
||||
// Act
|
||||
var result = await sut.HealthCheckAsync();
|
||||
|
||||
// Assert
|
||||
result.IsHealthy.ShouldBeFalse();
|
||||
await _fixture.ClientWrapper
|
||||
.DidNotReceive()
|
||||
.LoginAsync(Arg.Any<string>(), Arg.Any<string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-2
@@ -1,3 +1,4 @@
|
||||
using System.Net.Http.Headers;
|
||||
using Cleanuparr.Domain.Entities.HealthCheck;
|
||||
using Cleanuparr.Infrastructure.Events;
|
||||
using Cleanuparr.Infrastructure.Events.Interfaces;
|
||||
@@ -40,6 +41,11 @@ public partial class QBitService : TorrentDownloadService, IDeadTorrentCapable
|
||||
httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, queueRuleEvaluator, seedingRuleEvaluator, timeProvider
|
||||
)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(downloadClientConfig.ApiKey))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", downloadClientConfig.ApiKey);
|
||||
}
|
||||
|
||||
var qBittorrentClient = new QBittorrentClient(_httpClient, downloadClientConfig.Url);
|
||||
_client = new QBittorrentClientWrapper(qBittorrentClient);
|
||||
}
|
||||
@@ -69,6 +75,12 @@ public partial class QBitService : TorrentDownloadService, IDeadTorrentCapable
|
||||
|
||||
public override async Task LoginAsync()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_downloadClientConfig.ApiKey))
|
||||
{
|
||||
_logger.LogDebug("API key configured for client {ClientId}, skipping login", _downloadClientConfig.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_downloadClientConfig.Username) && string.IsNullOrEmpty(_downloadClientConfig.Password))
|
||||
{
|
||||
_logger.LogDebug("No credentials configured for client {ClientId}, skipping login", _downloadClientConfig.Id);
|
||||
@@ -93,10 +105,16 @@ public partial class QBitService : TorrentDownloadService, IDeadTorrentCapable
|
||||
|
||||
try
|
||||
{
|
||||
bool hasCredentials = !string.IsNullOrEmpty(_downloadClientConfig.Username) ||
|
||||
bool hasCredentials = !string.IsNullOrEmpty(_downloadClientConfig.Username) ||
|
||||
!string.IsNullOrEmpty(_downloadClientConfig.Password);
|
||||
|
||||
if (hasCredentials)
|
||||
if (!string.IsNullOrWhiteSpace(_downloadClientConfig.ApiKey))
|
||||
{
|
||||
// login with an API key returns 403, so probe an auth-protected endpoint
|
||||
await _client.GetAlternativeSpeedLimitsEnabledAsync();
|
||||
_logger.LogDebug("Health check: Successfully authenticated to qBittorrent client {ClientId}", _downloadClientConfig.Id);
|
||||
}
|
||||
else if (hasCredentials)
|
||||
{
|
||||
// If credentials are provided, we must be able to log in for the service to be healthy
|
||||
await _client.LoginAsync(_downloadClientConfig.Username, _downloadClientConfig.Password);
|
||||
|
||||
+65
@@ -226,6 +226,71 @@ public sealed class DownloadClientConfigTests
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_QBittorrentWithApiKeyOnly_DoesNotThrow()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "My qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
ApiKey = "qbt_abcdefghijklmnopqrstuvwxyz12"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_QBittorrentWithApiKeyAndPassword_ThrowsValidationException()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "My qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
ApiKey = "qbt_abcdefghijklmnopqrstuvwxyz12",
|
||||
Password = "pass"
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("Use either an API key or a username and password, not both");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_QBittorrentWithApiKeyAndUsername_ThrowsValidationException()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "My qBittorrent",
|
||||
TypeName = DownloadClientTypeName.qBittorrent,
|
||||
Type = DownloadClientType.Torrent,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
ApiKey = "qbt_abcdefghijklmnopqrstuvwxyz12",
|
||||
Username = "admin"
|
||||
};
|
||||
|
||||
var exception = Should.Throw<ValidationException>(() => config.Validate());
|
||||
exception.Message.ShouldBe("Use either an API key or a username and password, not both");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SabnzbdWithApiKeyAndPassword_DoesNotThrow()
|
||||
{
|
||||
var config = new DownloadClientConfig
|
||||
{
|
||||
Name = "My Sabnzbd",
|
||||
TypeName = DownloadClientTypeName.Sabnzbd,
|
||||
Type = DownloadClientType.Usenet,
|
||||
Host = new Uri("http://localhost:8080"),
|
||||
ApiKey = "a-real-key",
|
||||
Password = "unused"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Url Property Tests
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace Cleanuparr.Persistence.Tests.Models.Configuration;
|
||||
|
||||
public sealed class DownloadClientTypeNameExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void SupportsAuthField_QBittorrent_SupportsApiKey()
|
||||
{
|
||||
DownloadClientTypeName.qBittorrent.SupportsAuthField(DownloadClientAuthField.ApiKey).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SupportsAuthField_QBittorrent_StillSupportsUsernameAndPassword()
|
||||
{
|
||||
DownloadClientTypeName.qBittorrent.SupportsAuthField(DownloadClientAuthField.Username).ShouldBeTrue();
|
||||
DownloadClientTypeName.qBittorrent.SupportsAuthField(DownloadClientAuthField.Password).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RequiresAuthField_QBittorrentApiKey_IsFalse()
|
||||
{
|
||||
DownloadClientTypeName.qBittorrent.RequiresAuthField(DownloadClientAuthField.ApiKey).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SupportsApiKeyAndCredentials_QBittorrent_IsTrue()
|
||||
{
|
||||
DownloadClientTypeName.qBittorrent.SupportsApiKeyAndCredentials().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SupportsApiKeyAndCredentials_Sabnzbd_IsFalse()
|
||||
{
|
||||
DownloadClientTypeName.Sabnzbd.SupportsApiKeyAndCredentials().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SupportsApiKeyAndCredentials_Transmission_IsFalse()
|
||||
{
|
||||
DownloadClientTypeName.Transmission.SupportsApiKeyAndCredentials().ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -120,5 +120,11 @@ public sealed record DownloadClientConfig
|
||||
{
|
||||
throw new ValidationException("API key cannot be empty");
|
||||
}
|
||||
|
||||
if (TypeName.SupportsApiKeyAndCredentials() && !string.IsNullOrWhiteSpace(ApiKey) &&
|
||||
(!string.IsNullOrWhiteSpace(Username) || !string.IsNullOrWhiteSpace(Password)))
|
||||
{
|
||||
throw new ValidationException("Use either an API key or a username and password, not both");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,4 +50,5 @@ export const NEW_FEATURES: NewFeature[] = [
|
||||
{ id: 'change-username' },
|
||||
{ id: 'force-import' },
|
||||
{ id: 'usenet-queue-cleaner' },
|
||||
{ id: 'qbit-api-key' },
|
||||
];
|
||||
+2
-1
@@ -83,8 +83,9 @@
|
||||
}
|
||||
@if (showApiKeyField()) {
|
||||
<app-input label="API Key" placeholder="Enter API key" type="password" [revealable]="false" [formField]="clientForm.apiKey"
|
||||
[hint]="apiKeyHint"
|
||||
[hint]="apiKeyHint()"
|
||||
[error]="clientForm.apiKey().errors()[0]?.message"
|
||||
[featureId]="apiKeyFeatureId()"
|
||||
helpKey="download-client:apiKey" />
|
||||
}
|
||||
<app-input label="URL Base" placeholder="/api/v2" [formField]="clientForm.urlBase"
|
||||
|
||||
+109
-2
@@ -42,7 +42,9 @@ const TYPES: DownloadClientTypesResponse = {
|
||||
types: [
|
||||
{
|
||||
typeName: DownloadClientTypeName.qBittorrent,
|
||||
authFields: [DownloadClientAuthField.Username, DownloadClientAuthField.Password],
|
||||
authFields: [
|
||||
DownloadClientAuthField.Username, DownloadClientAuthField.Password, DownloadClientAuthField.ApiKey,
|
||||
],
|
||||
capabilities: [DownloadClientCapability.SeedingCleanup, DownloadClientCapability.TagFiltering],
|
||||
},
|
||||
{
|
||||
@@ -253,6 +255,10 @@ describe('DownloadClientsComponent', () => {
|
||||
expect(fieldLabels(fixture)).not.toContain('Username');
|
||||
expect(fieldLabels(fixture)).not.toContain('Password');
|
||||
expect(fieldLabels(fixture)).toContain('API Key');
|
||||
|
||||
component.clientForm.apiKey().value.set('sab-key');
|
||||
fixture.detectChanges();
|
||||
expect(hintFor(fixture, 'API Key')).toBe('API key from SABnzbd > Config > General');
|
||||
});
|
||||
|
||||
it('blocks saving a SABnzbd client with an empty API key and shows the error', () => {
|
||||
@@ -315,11 +321,112 @@ describe('DownloadClientsComponent', () => {
|
||||
component.openAddModal();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.clientModel().typeName).toBe(DownloadClientTypeName.qBittorrent);
|
||||
chooseClientType(fixture, 'Deluge');
|
||||
|
||||
expect(component.clientModel().typeName).toBe(DownloadClientTypeName.Deluge);
|
||||
expect(component.showApiKeyField()).toBe(false);
|
||||
expect(fieldLabels(fixture)).not.toContain('API Key');
|
||||
});
|
||||
|
||||
it('shows the API key field for qBittorrent alongside username and password', () => {
|
||||
const { fixture, component } = setup();
|
||||
|
||||
component.openAddModal();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.clientModel().typeName).toBe(DownloadClientTypeName.qBittorrent);
|
||||
expect(component.showUsernameField()).toBe(true);
|
||||
expect(component.showPasswordField()).toBe(true);
|
||||
expect(component.showApiKeyField()).toBe(true);
|
||||
expect(fieldLabels(fixture)).toContain('API Key');
|
||||
expect(hintFor(fixture, 'API Key'))
|
||||
.toBe('qBittorrent 5.2+: Options > WebUI > API key. Leave username and password empty.');
|
||||
});
|
||||
|
||||
it('blocks saving a qBittorrent client with both an API key and a password', () => {
|
||||
const { fixture, component, api } = setup();
|
||||
|
||||
component.openAddModal();
|
||||
component.clientForm.name().value.set('qbit box');
|
||||
component.clientForm.host().value.set('http://localhost:8080');
|
||||
component.clientForm.password().value.set('secret');
|
||||
component.clientForm.apiKey().value.set('qbit-key');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.hasModalErrors()).toBe(true);
|
||||
expect(component.clientForm.apiKey().errors()[0].message)
|
||||
.toBe('Use either an API key or a username and password, not both');
|
||||
|
||||
component.saveClient();
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves a qBittorrent client with only an API key', () => {
|
||||
const { fixture, component, api } = setup();
|
||||
|
||||
component.openAddModal();
|
||||
component.clientForm.name().value.set('qbit box');
|
||||
component.clientForm.host().value.set('http://localhost:8080');
|
||||
component.clientForm.apiKey().value.set('qbit-key');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.hasModalErrors()).toBe(false);
|
||||
|
||||
component.saveClient();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
typeName: DownloadClientTypeName.qBittorrent,
|
||||
apiKey: 'qbit-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks saving a qBittorrent client with both an API key and a username', () => {
|
||||
const { fixture, component, api } = setup();
|
||||
|
||||
component.openAddModal();
|
||||
component.clientForm.name().value.set('qbit box');
|
||||
component.clientForm.host().value.set('http://localhost:8080');
|
||||
component.clientForm.username().value.set('admin');
|
||||
component.clientForm.apiKey().value.set('qbit-key');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.hasModalErrors()).toBe(true);
|
||||
expect(component.clientForm.apiKey().errors()[0].message)
|
||||
.toBe('Use either an API key or a username and password, not both');
|
||||
|
||||
component.saveClient();
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('edits a qBittorrent client with a null username and an API key placeholder without showing the exclusive error', () => {
|
||||
const qbitApiKeyOnly: ClientConfig = {
|
||||
...QBIT,
|
||||
username: null as unknown as string,
|
||||
password: undefined,
|
||||
apiKey: 'qbit-placeholder-key',
|
||||
};
|
||||
const { fixture, component, api } = setup(createApi({ clients: [qbitApiKeyOnly] }));
|
||||
|
||||
component.openEditModal(qbitApiKeyOnly);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.hasModalErrors()).toBe(false);
|
||||
|
||||
component.clientForm.name().value.set('qbit box renamed');
|
||||
fixture.detectChanges();
|
||||
|
||||
component.saveClient();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(api.update).toHaveBeenCalledWith(
|
||||
qbitApiKeyOnly.id,
|
||||
expect.objectContaining({ name: 'qbit box renamed', apiKey: 'qbit-placeholder-key' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('saves a SABnzbd client with the Usenet client type', () => {
|
||||
const { fixture, component, api } = setup();
|
||||
|
||||
|
||||
+20
-3
@@ -1,6 +1,6 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed } from '@angular/core';
|
||||
import { forkJoin } from 'rxjs';
|
||||
import { form, required, disabled, FormField } from '@angular/forms/signals';
|
||||
import { form, required, disabled, validate, FormField } from '@angular/forms/signals';
|
||||
import { PageHeaderComponent } from '@layout/page-header/page-header.component';
|
||||
import {
|
||||
CardComponent, ButtonComponent, InputComponent, ToggleComponent,
|
||||
@@ -114,6 +114,15 @@ export class DownloadClientsComponent implements HasPendingChanges {
|
||||
when: ({ valueOf }) => valueOf(p.typeName) === DownloadClientTypeName.Sabnzbd,
|
||||
message: 'API key is required',
|
||||
});
|
||||
validate(p.apiKey, () => {
|
||||
const m = this.clientModel();
|
||||
const fields = this.authFieldsByType()[m.typeName] ?? [];
|
||||
const supportsBoth = fields.includes('apiKey') && (fields.includes('username') || fields.includes('password'));
|
||||
const has = (v: string | null | undefined): boolean => !!v && v.trim() !== '';
|
||||
return supportsBoth && has(m.apiKey) && (has(m.username) || has(m.password))
|
||||
? { kind: 'exclusive', message: 'Use either an API key or a username and password, not both' }
|
||||
: undefined;
|
||||
});
|
||||
disabled(p.typeName, () => this.editingClient() !== null);
|
||||
});
|
||||
|
||||
@@ -133,7 +142,15 @@ export class DownloadClientsComponent implements HasPendingChanges {
|
||||
readonly showApiKeyField = computed(() =>
|
||||
(this.authFieldsByType()[this.clientModel().typeName] ?? []).includes('apiKey'));
|
||||
|
||||
readonly apiKeyHint = 'API key from SABnzbd > Config > General';
|
||||
readonly apiKeyHint = computed(() => {
|
||||
if (this.clientModel().typeName === DownloadClientTypeName.qBittorrent) {
|
||||
return 'qBittorrent 5.2+: Options > WebUI > API key. Leave username and password empty.';
|
||||
}
|
||||
return 'API key from SABnzbd > Config > General';
|
||||
});
|
||||
|
||||
readonly apiKeyFeatureId = computed(() =>
|
||||
this.clientModel().typeName === DownloadClientTypeName.qBittorrent ? 'qbit-api-key' : undefined);
|
||||
|
||||
readonly usernameHint = computed(() => {
|
||||
if (this.clientModel().typeName === DownloadClientTypeName.rTorrent) {
|
||||
@@ -204,7 +221,7 @@ export class DownloadClientsComponent implements HasPendingChanges {
|
||||
name: client.name,
|
||||
typeName: client.typeName,
|
||||
host: client.host,
|
||||
username: client.username,
|
||||
username: client.username ?? '',
|
||||
password: client.password ?? '',
|
||||
apiKey: client.apiKey ?? '',
|
||||
urlBase: client.urlBase,
|
||||
|
||||
@@ -143,6 +143,8 @@ Password for download client authentication.
|
||||
|
||||
API key for download client authentication, for clients that use one instead of a username/password. Required for SABnzbd; found under SABnzbd's own Config > General page.
|
||||
|
||||
qBittorrent 5.2+ also supports API key auth: generate one under Options > WebUI. Use either the API key or a username/password, not both.
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@ WebUI\HostHeaderValidation=false
|
||||
WebUI\LocalHostAuth=false
|
||||
WebUI\AuthSubnetWhitelistEnabled=true
|
||||
WebUI\AuthSubnetWhitelist=127.0.0.0/8, ::1/128
|
||||
WebUI\APIKey=qbt_0000000000000000000000000e2e
|
||||
WebUI\Username=admin
|
||||
WebUI\Password_PBKDF2="@ByteArray(ARQ77eY1NUZ366igo9pHIQ==:Bn3qWLqOY3qE6Z+sCx2NoO5q4nhgxhUL3eRD4Zw3+5p9C7+RmrI20bzAjcwHKqcWa+5z6QBQGckCB8sFCnVTGw==)"
|
||||
Downloads\SavePath=/downloads
|
||||
|
||||
@@ -158,6 +158,58 @@ test.describe('DownloadClient: invalid type validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('DownloadClient: qBittorrent API key', () => {
|
||||
test('create with an api key alone masks it in the response', async ({ api }) => {
|
||||
const payload = buildDownloadClientPayload('qbittorrent', {
|
||||
name: `qb-key-e2e-${Date.now()}`,
|
||||
host: TEST_CONFIG.mocks.downloadClientUrl,
|
||||
apiKey: 'qb-create-key',
|
||||
});
|
||||
const created = await (await api.downloadClient.create(payload)).json();
|
||||
expect(created.apiKey).toBe('••••••••');
|
||||
|
||||
await api.downloadClient.delete(created.id);
|
||||
});
|
||||
|
||||
test('create with an api key and a password returns 400', async ({ api }) => {
|
||||
const payload = buildDownloadClientPayload('qbittorrent', {
|
||||
name: 'qb-key-plus-password',
|
||||
host: TEST_CONFIG.mocks.downloadClientUrl,
|
||||
apiKey: 'qb-create-key',
|
||||
password: 'admin',
|
||||
});
|
||||
const res = await api.downloadClient.create(payload);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('update a username/password client to an api key with credentials cleared succeeds', async ({ api }) => {
|
||||
const created = await (
|
||||
await api.downloadClient.create(
|
||||
buildDownloadClientPayload('qbittorrent', {
|
||||
name: 'qb-key-update',
|
||||
host: TEST_CONFIG.mocks.downloadClientUrl,
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
}),
|
||||
)
|
||||
).json();
|
||||
|
||||
const update = await api.downloadClient.update(created.id, {
|
||||
name: 'qb-key-update',
|
||||
typeName: 'qBittorrent',
|
||||
host: TEST_CONFIG.mocks.downloadClientUrl,
|
||||
apiKey: 'qb-update-key',
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
expect(update.ok).toBe(true);
|
||||
const updated = await update.json();
|
||||
expect(updated.apiKey).toBe('••••••••');
|
||||
|
||||
await api.downloadClient.delete(created.id);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('DownloadClient: API key validation', () => {
|
||||
test('create SABnzbd with an empty API key returns 400', async ({ api }) => {
|
||||
const payload = buildDownloadClientPayload('sabnzbd', {
|
||||
|
||||
@@ -22,6 +22,43 @@ test.describe('DownloadClient — test connection', () => {
|
||||
// stateful retries that don't model cleanly with WireMock. Both failure
|
||||
// branches are exercised by the generic "host unreachable" case below.
|
||||
|
||||
// An API key client skips login and probes an auth-protected endpoint instead.
|
||||
test('qbittorrent: an api key alone succeeds', async ({ api, mocks }) => {
|
||||
await mocks.downloadClient.stub(DownloadClientStubs.qbitSpeedLimitsModeStub('right-key'));
|
||||
const res = await api.downloadClient.test(
|
||||
buildDownloadClientPayload('qbittorrent', {
|
||||
name: 'qb-key-conn',
|
||||
host: TEST_CONFIG.mocks.downloadClientUrl,
|
||||
apiKey: 'right-key',
|
||||
}),
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('qbittorrent: a wrong api key fails test-connection', async ({ api, mocks }) => {
|
||||
await mocks.downloadClient.stub(DownloadClientStubs.qbitRejectedApiKeyStub());
|
||||
const res = await api.downloadClient.test(
|
||||
buildDownloadClientPayload('qbittorrent', {
|
||||
name: 'qb-key-conn-wrong',
|
||||
host: TEST_CONFIG.mocks.downloadClientUrl,
|
||||
apiKey: 'wrong-key',
|
||||
}),
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('qbittorrent: an api key combined with a password is rejected', async ({ api }) => {
|
||||
const res = await api.downloadClient.test(
|
||||
buildDownloadClientPayload('qbittorrent', {
|
||||
name: 'qb-key-plus-password',
|
||||
host: TEST_CONFIG.mocks.downloadClientUrl,
|
||||
apiKey: 'right-key',
|
||||
password: 'admin',
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('deluge: success on auth.login', async ({ api, mocks }) => {
|
||||
await mocks.downloadClient.stub(DownloadClientStubs.delugeLoginStub());
|
||||
const res = await api.downloadClient.test(
|
||||
@@ -153,3 +190,33 @@ test.describe('DownloadClient — test connection', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Against the real qBittorrent container rather than WireMock, since the api-key auth
|
||||
* path (AuthController::apiKeySessionInitialize) bypasses isAuthNeeded()'s LocalHostAuth /
|
||||
* AuthSubnetWhitelist check entirely: a wrong key gets no session and a real 403, even from
|
||||
* a request originating on 127.0.0.0/8 under this stack's network_mode: host.
|
||||
*/
|
||||
test.describe('DownloadClient: test connection (real qBittorrent container)', () => {
|
||||
test('qbittorrent: the seeded api key succeeds', async ({ api }) => {
|
||||
const res = await api.downloadClient.test(
|
||||
buildDownloadClientPayload('qbittorrent', {
|
||||
name: 'qb-live-key-conn',
|
||||
host: 'http://localhost:8090',
|
||||
apiKey: TEST_CONFIG.liveQbittorrent.apiKey,
|
||||
}),
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('qbittorrent: a wrong api key fails', async ({ api }) => {
|
||||
const res = await api.downloadClient.test(
|
||||
buildDownloadClientPayload('qbittorrent', {
|
||||
name: 'qb-live-key-conn-wrong',
|
||||
host: 'http://localhost:8090',
|
||||
apiKey: 'qbt_1111111111111111111111111111',
|
||||
}),
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,30 @@ export function qbitTorrentsStub(torrents: Array<Record<string, unknown>> = []):
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The health check probes this endpoint for an API-key client, since logging in with an
|
||||
* API key returns 403. Real qBittorrent 5.2 answers plain text "0"/"1".
|
||||
*/
|
||||
export function qbitSpeedLimitsModeStub(apiKey: string): Mapping {
|
||||
return {
|
||||
request: {
|
||||
method: 'GET',
|
||||
urlPath: '/api/v2/transfer/speedLimitsMode',
|
||||
headers: { Authorization: { equalTo: `Bearer ${apiKey}` } },
|
||||
},
|
||||
response: { status: 200, body: '0', headers: { 'Content-Type': 'text/plain' } },
|
||||
priority: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Real qBittorrent rejects a Bearer token that doesn't match the configured API key with 403. */
|
||||
export function qbitRejectedApiKeyStub(): Mapping {
|
||||
return {
|
||||
request: { method: 'GET', urlPath: '/api/v2/transfer/speedLimitsMode' },
|
||||
response: { status: 403, body: 'Forbidden' },
|
||||
};
|
||||
}
|
||||
|
||||
export function delugeLoginStub(sessionCookie = 'test-deluge'): Mapping {
|
||||
return {
|
||||
request: {
|
||||
|
||||
@@ -37,6 +37,12 @@ export const TEST_CONFIG = {
|
||||
seededMovieTitle: 'F1',
|
||||
},
|
||||
|
||||
// Real qBittorrent container: the API key seeded into
|
||||
// test-data/qbittorrent-config/qBittorrent/qBittorrent.conf by setup-test-data.sh.
|
||||
liveQbittorrent: {
|
||||
apiKey: 'qbt_0000000000000000000000000e2e',
|
||||
},
|
||||
|
||||
// The real LazyLibrarian container.
|
||||
// Its api keys and library come from the committed seed in e2e/lazylibrarian-seed.
|
||||
liveLazyLibrarian: {
|
||||
|
||||
@@ -418,4 +418,43 @@ test.describe.serial('Malware blocker — removal queue', () => {
|
||||
await api.general.patch({ dryRun: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Proves Bearer auth end to end: the qBittorrent client is reconfigured with an API
|
||||
// key alone, and the job still has to list and remove a torrent through it.
|
||||
test('a removal still works when the qBittorrent client uses an api key alone', async () => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
if (clientId) {
|
||||
await deleteDownloadClient(token, clientId);
|
||||
clientId = undefined;
|
||||
}
|
||||
|
||||
const item: BlockedItem = { id: 9101, title: '', infoHash: '', seriesId: 131, episodeId: 231 };
|
||||
const seeded = await seedBlockedTorrent(driver, hostScanDir, `apikey-${item.id}.exe`);
|
||||
item.infoHash = seeded.infoHash;
|
||||
item.title = seeded.name;
|
||||
|
||||
const keyClientRes = await createDownloadClient(token, {
|
||||
enabled: true,
|
||||
name: 'qBittorrent api-key removal-queue e2e',
|
||||
typeName: driver.typeName,
|
||||
type: 'Torrent',
|
||||
host: driver.cleanuparrHost,
|
||||
apiKey: TEST_CONFIG.liveQbittorrent.apiKey,
|
||||
});
|
||||
expect(keyClientRes.status).toBeGreaterThanOrEqual(200);
|
||||
expect(keyClientRes.status).toBeLessThan(300);
|
||||
clientId = (await keyClientRes.json()).id;
|
||||
|
||||
await arr.stubMany([arrHealthStub(), arrQueueStub(queueRecordsFor([item])), arrCatchAllMapping()]);
|
||||
|
||||
const trig = await triggerJob(token, 'MalwareBlocker');
|
||||
expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true);
|
||||
|
||||
await waitUntilDeleted(arr, [item.id]);
|
||||
|
||||
const matches = await waitForEvents(token, 'QueueItemDeleted', item.title);
|
||||
const match = matches.find((e) => e.itemHash.toLowerCase() === item.infoHash.toLowerCase());
|
||||
expect(match, `expected a QueueItemDeleted event for ${item.infoHash}`).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user