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,
@@ -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 &gt; General page.
qBittorrent 5.2+ also supports API key auth: generate one under Options &gt; WebUI. Use either the API key or a username/password, not both.
</ConfigSection>
</div>
+1
View File
@@ -54,6 +54,7 @@ WebUI\HostHeaderValidation=false
WebUI\LocalHostAuth=false
WebUI\AuthSubnetWhitelistEnabled=true
WebUI\AuthSubnetWhitelist=127.0.0.0/8, ::1/128
WebUI\APIKey=qbt_0000000000000000000000000e2e
WebUI\Username=admin
WebUI\Password_PBKDF2="@ByteArray(ARQ77eY1NUZ366igo9pHIQ==:Bn3qWLqOY3qE6Z+sCx2NoO5q4nhgxhUL3eRD4Zw3+5p9C7+RmrI20bzAjcwHKqcWa+5z6QBQGckCB8sFCnVTGw==)"
Downloads\SavePath=/downloads
@@ -158,6 +158,58 @@ test.describe('DownloadClient: invalid type validation', () => {
});
});
test.describe('DownloadClient: qBittorrent API key', () => {
test('create with an api key alone masks it in the response', async ({ api }) => {
const payload = buildDownloadClientPayload('qbittorrent', {
name: `qb-key-e2e-${Date.now()}`,
host: TEST_CONFIG.mocks.downloadClientUrl,
apiKey: 'qb-create-key',
});
const created = await (await api.downloadClient.create(payload)).json();
expect(created.apiKey).toBe('••••••••');
await api.downloadClient.delete(created.id);
});
test('create with an api key and a password returns 400', async ({ api }) => {
const payload = buildDownloadClientPayload('qbittorrent', {
name: 'qb-key-plus-password',
host: TEST_CONFIG.mocks.downloadClientUrl,
apiKey: 'qb-create-key',
password: 'admin',
});
const res = await api.downloadClient.create(payload);
expect(res.status).toBe(400);
});
test('update a username/password client to an api key with credentials cleared succeeds', async ({ api }) => {
const created = await (
await api.downloadClient.create(
buildDownloadClientPayload('qbittorrent', {
name: 'qb-key-update',
host: TEST_CONFIG.mocks.downloadClientUrl,
username: 'admin',
password: 'admin',
}),
)
).json();
const update = await api.downloadClient.update(created.id, {
name: 'qb-key-update',
typeName: 'qBittorrent',
host: TEST_CONFIG.mocks.downloadClientUrl,
apiKey: 'qb-update-key',
username: '',
password: '',
});
expect(update.ok).toBe(true);
const updated = await update.json();
expect(updated.apiKey).toBe('••••••••');
await api.downloadClient.delete(created.id);
});
});
test.describe('DownloadClient: API key validation', () => {
test('create SABnzbd with an empty API key returns 400', async ({ api }) => {
const payload = buildDownloadClientPayload('sabnzbd', {
@@ -22,6 +22,43 @@ test.describe('DownloadClient — test connection', () => {
// stateful retries that don't model cleanly with WireMock. Both failure
// branches are exercised by the generic "host unreachable" case below.
// An API key client skips login and probes an auth-protected endpoint instead.
test('qbittorrent: an api key alone succeeds', async ({ api, mocks }) => {
await mocks.downloadClient.stub(DownloadClientStubs.qbitSpeedLimitsModeStub('right-key'));
const res = await api.downloadClient.test(
buildDownloadClientPayload('qbittorrent', {
name: 'qb-key-conn',
host: TEST_CONFIG.mocks.downloadClientUrl,
apiKey: 'right-key',
}),
);
expect(res.ok).toBe(true);
});
test('qbittorrent: a wrong api key fails test-connection', async ({ api, mocks }) => {
await mocks.downloadClient.stub(DownloadClientStubs.qbitRejectedApiKeyStub());
const res = await api.downloadClient.test(
buildDownloadClientPayload('qbittorrent', {
name: 'qb-key-conn-wrong',
host: TEST_CONFIG.mocks.downloadClientUrl,
apiKey: 'wrong-key',
}),
);
expect(res.ok).toBe(false);
});
test('qbittorrent: an api key combined with a password is rejected', async ({ api }) => {
const res = await api.downloadClient.test(
buildDownloadClientPayload('qbittorrent', {
name: 'qb-key-plus-password',
host: TEST_CONFIG.mocks.downloadClientUrl,
apiKey: 'right-key',
password: 'admin',
}),
);
expect(res.status).toBe(400);
});
test('deluge: success on auth.login', async ({ api, mocks }) => {
await mocks.downloadClient.stub(DownloadClientStubs.delugeLoginStub());
const res = await api.downloadClient.test(
@@ -153,3 +190,33 @@ test.describe('DownloadClient — test connection', () => {
expect(res.status).toBe(400);
});
});
/**
* Against the real qBittorrent container rather than WireMock, since the api-key auth
* path (AuthController::apiKeySessionInitialize) bypasses isAuthNeeded()'s LocalHostAuth /
* AuthSubnetWhitelist check entirely: a wrong key gets no session and a real 403, even from
* a request originating on 127.0.0.0/8 under this stack's network_mode: host.
*/
test.describe('DownloadClient: test connection (real qBittorrent container)', () => {
test('qbittorrent: the seeded api key succeeds', async ({ api }) => {
const res = await api.downloadClient.test(
buildDownloadClientPayload('qbittorrent', {
name: 'qb-live-key-conn',
host: 'http://localhost:8090',
apiKey: TEST_CONFIG.liveQbittorrent.apiKey,
}),
);
expect(res.ok).toBe(true);
});
test('qbittorrent: a wrong api key fails', async ({ api }) => {
const res = await api.downloadClient.test(
buildDownloadClientPayload('qbittorrent', {
name: 'qb-live-key-conn-wrong',
host: 'http://localhost:8090',
apiKey: 'qbt_1111111111111111111111111111',
}),
);
expect(res.ok).toBe(false);
});
});
@@ -30,6 +30,30 @@ export function qbitTorrentsStub(torrents: Array<Record<string, unknown>> = []):
};
}
/**
* The health check probes this endpoint for an API-key client, since logging in with an
* API key returns 403. Real qBittorrent 5.2 answers plain text "0"/"1".
*/
export function qbitSpeedLimitsModeStub(apiKey: string): Mapping {
return {
request: {
method: 'GET',
urlPath: '/api/v2/transfer/speedLimitsMode',
headers: { Authorization: { equalTo: `Bearer ${apiKey}` } },
},
response: { status: 200, body: '0', headers: { 'Content-Type': 'text/plain' } },
priority: 1,
};
}
/** Real qBittorrent rejects a Bearer token that doesn't match the configured API key with 403. */
export function qbitRejectedApiKeyStub(): Mapping {
return {
request: { method: 'GET', urlPath: '/api/v2/transfer/speedLimitsMode' },
response: { status: 403, body: 'Forbidden' },
};
}
export function delugeLoginStub(sessionCookie = 'test-deluge'): Mapping {
return {
request: {
+6
View File
@@ -37,6 +37,12 @@ export const TEST_CONFIG = {
seededMovieTitle: 'F1',
},
// Real qBittorrent container: the API key seeded into
// test-data/qbittorrent-config/qBittorrent/qBittorrent.conf by setup-test-data.sh.
liveQbittorrent: {
apiKey: 'qbt_0000000000000000000000000e2e',
},
// The real LazyLibrarian container.
// Its api keys and library come from the committed seed in e2e/lazylibrarian-seed.
liveLazyLibrarian: {
@@ -418,4 +418,43 @@ test.describe.serial('Malware blocker — removal queue', () => {
await api.general.patch({ dryRun: false });
}
});
// Proves Bearer auth end to end: the qBittorrent client is reconfigured with an API
// key alone, and the job still has to list and remove a torrent through it.
test('a removal still works when the qBittorrent client uses an api key alone', async () => {
test.setTimeout(120_000);
if (clientId) {
await deleteDownloadClient(token, clientId);
clientId = undefined;
}
const item: BlockedItem = { id: 9101, title: '', infoHash: '', seriesId: 131, episodeId: 231 };
const seeded = await seedBlockedTorrent(driver, hostScanDir, `apikey-${item.id}.exe`);
item.infoHash = seeded.infoHash;
item.title = seeded.name;
const keyClientRes = await createDownloadClient(token, {
enabled: true,
name: 'qBittorrent api-key removal-queue e2e',
typeName: driver.typeName,
type: 'Torrent',
host: driver.cleanuparrHost,
apiKey: TEST_CONFIG.liveQbittorrent.apiKey,
});
expect(keyClientRes.status).toBeGreaterThanOrEqual(200);
expect(keyClientRes.status).toBeLessThan(300);
clientId = (await keyClientRes.json()).id;
await arr.stubMany([arrHealthStub(), arrQueueStub(queueRecordsFor([item])), arrCatchAllMapping()]);
const trig = await triggerJob(token, 'MalwareBlocker');
expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true);
await waitUntilDeleted(arr, [item.id]);
const matches = await waitForEvents(token, 'QueueItemDeleted', item.title);
const match = matches.find((e) => e.itemHash.toLowerCase() === item.infoHash.toLowerCase());
expect(match, `expected a QueueItemDeleted event for ${item.infoHash}`).toBeDefined();
});
});