Add support for qBittorrent API key (#832)

This commit is contained in:
Flaminel authored and GitHub committed 2026-10-12 16:22:46 +03:00
1 parent 85e956dd92
commit 705957759e
19 files changed
+770 -9

No files matched your search

@@ -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()
{
@@ -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,
@@ -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>());
}
}
}
@@ -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);
@@ -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
@@ -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' },
];
@@ -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"
@@ -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();
@@ -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,