Files
shelfmark/tests/prowlarr/test_qbittorrent_settings.py
T
Adam Vigneaux 0d02c6db47 Add qBittorrent API key authentication (#1143)
[qBittorrent

5.2.0](https://www.qbittorrent.org/news#sun-may-03rd-2026---qbittorrent-v5.2.0-release)
(May 2026) added support for API key-based authentication in addition to
the existing username/password-based authentication.

This commit adds support for qBittorrent API key authentication to
Shelfmark, configurable via environment variable or settings UI. If an
API key is set at the same time as the username/password, API key will
be preferred for authentication.

Requires `qbittorrent-api` 2026.5.3, the version that added the
`api_key` argument, or newer. `403 Forbidden` responses are not retried
with API key authentication because a retry has no chance of succeeding.

Tested end-to-end with my live qBittorrent 5.2.3 instance (WebAPI
v2.15.1).

<img width="785" height="616" alt="image"
src="https://github.com/user-attachments/assets/8064e10e-9a7f-49f0-804e-4c441d23a1fc"
/>
2026-07-28 00:54:19 -04:00

113 lines
3.7 KiB
Python

"""qBittorrent download client settings fields and test-connection callback."""
import types
from unittest.mock import patch
import pytest
from qbittorrentapi import LoginFailed
from shelfmark.core.settings_registry import PasswordField
API_KEY = "qbt_0123456789abcdefghijklmnopqr"
def make_config_getter(values):
"""Create a config.get function that returns values from a dict."""
def getter(key, default=""):
return values.get(key, default)
return getter
def _get_field(fields, key):
"""Find a field by key."""
return next((f for f in fields if f.key == key), None)
def fake_qbittorrentapi(*, web_api_version="2.15.1", reject_auth=False, captured=None):
"""Build a stand-in qbittorrentapi module recording Client kwargs into `captured`."""
class FakeClient:
def __init__(self, **kwargs):
if captured is not None:
captured.update(kwargs)
self.app = types.SimpleNamespace(web_api_version=web_api_version)
def auth_log_in(self):
if reject_auth:
raise LoginFailed
module = types.ModuleType("qbittorrentapi")
module.Client = FakeClient
return module
def _run_test_connection(monkeypatch, current_values, fake_module):
"""Invoke the Test Connection callback against a stubbed qbittorrentapi."""
from shelfmark.core.config import config as config_obj
from shelfmark.download.clients import settings as settings_module
monkeypatch.setattr(config_obj, "get", make_config_getter(current_values))
monkeypatch.setattr(settings_module, "get_ssl_verify", lambda _url: True)
with patch.dict("sys.modules", {"qbittorrentapi": fake_module}):
return settings_module._test_qbittorrent_connection(current_values=current_values)
def test_api_key_field_is_registered():
"""The API key is offered alongside the other qBittorrent credentials."""
from shelfmark.download.clients.settings import prowlarr_clients_settings
field = _get_field(prowlarr_clients_settings(), "QBITTORRENT_API_KEY")
assert isinstance(field, PasswordField)
assert field.show_when == {"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}
@pytest.mark.parametrize(
("api_key", "expected_kwarg", "expected_suffix"),
[(API_KEY, API_KEY, " using the API key"), ("", None, "")],
)
def test_settings_test_connection_forwards_api_key(
monkeypatch, api_key, expected_kwarg, expected_suffix
):
"""The Test Connection button authenticates with the key when set, and says which it used."""
captured = {}
result = _run_test_connection(
monkeypatch,
{
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_API_KEY": api_key,
},
fake_qbittorrentapi(captured=captured),
)
assert captured["api_key"] == expected_kwarg
assert result == {
"success": True,
"message": f"Connected to qBittorrent (API v2.15.1){expected_suffix}",
}
@pytest.mark.parametrize(
("api_key", "rejected"),
[(API_KEY, "API key"), ("", "username or password")],
)
def test_settings_test_connection_names_rejected_credential(monkeypatch, api_key, rejected):
"""LoginFailed carries no message of its own, so the callback names the credential."""
result = _run_test_connection(
monkeypatch,
{
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "password",
"QBITTORRENT_API_KEY": api_key,
},
fake_qbittorrentapi(reject_auth=True),
)
assert result == {"success": False, "message": f"qBittorrent rejected the {rejected}"}