mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 02:38:55 -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,
|
||||
|
||||
Reference in new issue
Block a user