diff --git a/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs index 5252446d..90095284 100644 --- a/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs +++ b/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs @@ -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( + () => _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(); + 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().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().StatusCode.ShouldBe(400); + _downloadServiceFactory.DidNotReceive().GetDownloadService(Arg.Any()); + } + [Fact] public async Task TestDownloadClient_NewApiKeyWithPlaceholderPassword_UsesNewApiKeyAndStoredPassword() { diff --git a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs index 20f25547..cb50d513 100644 --- a/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs +++ b/code/backend/Cleanuparr.Api/Features/DownloadClient/Contracts/Requests/TestDownloadClientRequest.cs @@ -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) diff --git a/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeNameExtensions.cs b/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeNameExtensions.cs index b5301ee4..e97f9d27 100644 --- a/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeNameExtensions.cs +++ b/code/backend/Cleanuparr.Domain/Enums/DownloadClientTypeNameExtensions.cs @@ -4,7 +4,7 @@ public static class DownloadClientTypeNameExtensions { private static readonly Dictionary 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; + /// + /// True when the client type accepts an API key alongside a username or password, so the two must not be mixed. + /// + 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, diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs index badcdcda..04633e26 100644 --- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/QBitServiceTests.cs @@ -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 .SetFilePriorityAsync(Arg.Any(), Arg.Any(), Arg.Any()); } } + + 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()).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()).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(), Arg.Any()); + } + + [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(), Arg.Any()); + } + + [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(), Arg.Any()); + } + } } diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs index 7b3bef4f..cc73fb24 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/QBittorrent/QBitService.cs @@ -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); diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs index 95a66d32..37995105 100644 --- a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientConfigTests.cs @@ -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(() => 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(() => 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 diff --git a/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientTypeNameExtensionsTests.cs b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientTypeNameExtensionsTests.cs new file mode 100644 index 00000000..3f4b100e --- /dev/null +++ b/code/backend/Cleanuparr.Persistence.Tests/Models/Configuration/DownloadClientTypeNameExtensionsTests.cs @@ -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(); + } +} diff --git a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs index cff610ee..333492fa 100644 --- a/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs +++ b/code/backend/Cleanuparr.Persistence/Models/Configuration/DownloadClientConfig.cs @@ -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"); + } } } diff --git a/code/frontend/src/app/core/feature-badges/feature-registry.ts b/code/frontend/src/app/core/feature-badges/feature-registry.ts index 6b499f9a..e8f69f2e 100644 --- a/code/frontend/src/app/core/feature-badges/feature-registry.ts +++ b/code/frontend/src/app/core/feature-badges/feature-registry.ts @@ -50,4 +50,5 @@ export const NEW_FEATURES: NewFeature[] = [ { id: 'change-username' }, { id: 'force-import' }, { id: 'usenet-queue-cleaner' }, + { id: 'qbit-api-key' }, ]; diff --git a/code/frontend/src/app/features/settings/download-clients/download-clients.component.html b/code/frontend/src/app/features/settings/download-clients/download-clients.component.html index 99999d4e..58b0d29c 100644 --- a/code/frontend/src/app/features/settings/download-clients/download-clients.component.html +++ b/code/frontend/src/app/features/settings/download-clients/download-clients.component.html @@ -83,8 +83,9 @@ } @if (showApiKeyField()) { } { 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(); diff --git a/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts b/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts index 79a3bd86..c53fd22a 100644 --- a/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts +++ b/code/frontend/src/app/features/settings/download-clients/download-clients.component.ts @@ -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, diff --git a/docs/docs/configuration/download-client/index.mdx b/docs/docs/configuration/download-client/index.mdx index 4b1d53c0..87a351bb 100644 --- a/docs/docs/configuration/download-client/index.mdx +++ b/docs/docs/configuration/download-client/index.mdx @@ -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. + diff --git a/e2e/scripts/setup-test-data.sh b/e2e/scripts/setup-test-data.sh index c03447a8..94de5241 100644 --- a/e2e/scripts/setup-test-data.sh +++ b/e2e/scripts/setup-test-data.sh @@ -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 diff --git a/e2e/tests/download-client/download-client-crud.api.spec.ts b/e2e/tests/download-client/download-client-crud.api.spec.ts index 96935bd4..ac07d04d 100644 --- a/e2e/tests/download-client/download-client-crud.api.spec.ts +++ b/e2e/tests/download-client/download-client-crud.api.spec.ts @@ -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', { diff --git a/e2e/tests/download-client/download-client-test-connection.api.spec.ts b/e2e/tests/download-client/download-client-test-connection.api.spec.ts index b80ec59b..305958c2 100644 --- a/e2e/tests/download-client/download-client-test-connection.api.spec.ts +++ b/e2e/tests/download-client/download-client-test-connection.api.spec.ts @@ -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); + }); +}); diff --git a/e2e/tests/helpers/mocks/download-client-stubs.ts b/e2e/tests/helpers/mocks/download-client-stubs.ts index 19c293e9..57408763 100644 --- a/e2e/tests/helpers/mocks/download-client-stubs.ts +++ b/e2e/tests/helpers/mocks/download-client-stubs.ts @@ -30,6 +30,30 @@ export function qbitTorrentsStub(torrents: Array> = []): }; } +/** + * 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: { diff --git a/e2e/tests/helpers/test-config.ts b/e2e/tests/helpers/test-config.ts index 314d03aa..fb8649a5 100644 --- a/e2e/tests/helpers/test-config.ts +++ b/e2e/tests/helpers/test-config.ts @@ -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: { diff --git a/e2e/tests/malware-blocker/removal-queue.spec.ts b/e2e/tests/malware-blocker/removal-queue.spec.ts index c7e47d46..6d4cd43e 100644 --- a/e2e/tests/malware-blocker/removal-queue.spec.ts +++ b/e2e/tests/malware-blocker/removal-queue.spec.ts @@ -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(); + }); });