mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Support auth-optional mode for qBittorrent
Add auth-optional support when both username and password are blank. Introduce authOptional flag and authHeaders() helper to omit Cookie when unauthenticated; make login() a no-op in auth-optional mode and avoid pointless re-login on 403. Adjust many API calls to respect auth-optional behavior and update testConnection/testConnectionWithCredentials to probe /app/version for connectivity in auth-optional scenarios and return clearer errors. Add unit tests covering the new auth-optional flows and header behavior.
This commit is contained in:
@@ -108,6 +108,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
private username: string;
|
private username: string;
|
||||||
private password: string;
|
private password: string;
|
||||||
private cookie?: string;
|
private cookie?: string;
|
||||||
|
private authOptional: boolean;
|
||||||
private defaultSavePath: string;
|
private defaultSavePath: string;
|
||||||
private defaultCategory: string;
|
private defaultCategory: string;
|
||||||
private disableSSLVerify: boolean;
|
private disableSSLVerify: boolean;
|
||||||
@@ -126,11 +127,16 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||||
this.username = username;
|
this.username = username;
|
||||||
this.password = password;
|
this.password = password;
|
||||||
|
this.authOptional = !username && !password;
|
||||||
this.defaultSavePath = defaultSavePath;
|
this.defaultSavePath = defaultSavePath;
|
||||||
this.defaultCategory = defaultCategory;
|
this.defaultCategory = defaultCategory;
|
||||||
this.disableSSLVerify = disableSSLVerify;
|
this.disableSSLVerify = disableSSLVerify;
|
||||||
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
|
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
|
||||||
|
|
||||||
|
if (this.authOptional) {
|
||||||
|
logger.info('[QBittorrent] No credentials configured — running in auth-optional mode (suitable for IP-whitelisted qBittorrent or auth-less proxies like Decypharr)');
|
||||||
|
}
|
||||||
|
|
||||||
// Create HTTPS agent if SSL verification is disabled
|
// Create HTTPS agent if SSL verification is disabled
|
||||||
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
|
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
|
||||||
this.httpsAgent = new https.Agent({
|
this.httpsAgent = new https.Agent({
|
||||||
@@ -152,9 +158,23 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate and establish session
|
* Build request headers including the session cookie when one exists.
|
||||||
|
* In auth-optional mode no cookie is set and the Cookie header is omitted.
|
||||||
|
*/
|
||||||
|
private authHeaders(): Record<string, string> {
|
||||||
|
return this.cookie ? { Cookie: this.cookie } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate and establish session.
|
||||||
|
* In auth-optional mode (no username/password configured) this is a no-op.
|
||||||
*/
|
*/
|
||||||
async login(): Promise<void> {
|
async login(): Promise<void> {
|
||||||
|
if (this.authOptional) {
|
||||||
|
logger.debug('[QBittorrent] Skipping login — auth-optional mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const loginUrl = `${this.baseUrl}/api/v2/auth/login`;
|
const loginUrl = `${this.baseUrl}/api/v2/auth/login`;
|
||||||
|
|
||||||
logger.debug('[QBittorrent] Attempting login', {
|
logger.debug('[QBittorrent] Attempting login', {
|
||||||
@@ -241,7 +261,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure we're authenticated
|
// Ensure we're authenticated
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,8 +280,10 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
return await this.addTorrentFile(url, category, options);
|
return await this.addTorrentFile(url, category, options);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Try re-authenticating once if we get a 403
|
// Try re-authenticating once if we get a 403 — only meaningful when credentials are configured.
|
||||||
if (!retried && axios.isAxiosError(error) && error.response?.status === 403) {
|
// In auth-optional mode a 403 means the server actually wants auth (e.g. IP no longer whitelisted),
|
||||||
|
// so retrying login is pointless and would mask the real error.
|
||||||
|
if (!retried && !this.authOptional && axios.isAxiosError(error) && error.response?.status === 403) {
|
||||||
logger.info('[QBittorrent] Session expired, re-authenticating...');
|
logger.info('[QBittorrent] Session expired, re-authenticating...');
|
||||||
await this.login();
|
await this.login();
|
||||||
return this.addTorrent(url, options, true);
|
return this.addTorrent(url, options, true);
|
||||||
@@ -322,7 +344,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
|
|
||||||
const response = await this.client.post('/torrents/add', form, {
|
const response = await this.client.post('/torrents/add', form, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -470,7 +492,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
|
|
||||||
const response = await this.client.post('/torrents/add', formData, {
|
const response = await this.client.post('/torrents/add', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
...formData.getHeaders(),
|
...formData.getHeaders(),
|
||||||
},
|
},
|
||||||
maxBodyLength: Infinity,
|
maxBodyLength: Infinity,
|
||||||
@@ -491,7 +513,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Applies reverse path mapping (local → remote) for remote seedbox scenarios
|
* Applies reverse path mapping (local → remote) for remote seedbox scenarios
|
||||||
*/
|
*/
|
||||||
protected async ensureCategory(category: string): Promise<void> {
|
protected async ensureCategory(category: string): Promise<void> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,7 +523,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
try {
|
try {
|
||||||
// First, get all categories to check if it exists and what save path it has
|
// First, get all categories to check if it exists and what save path it has
|
||||||
const categoriesResponse = await this.client.get('/torrents/categories', {
|
const categoriesResponse = await this.client.get('/torrents/categories', {
|
||||||
headers: { Cookie: this.cookie },
|
headers: this.authHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const categories = categoriesResponse.data;
|
const categories = categoriesResponse.data;
|
||||||
@@ -519,7 +541,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -541,7 +563,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -572,13 +594,13 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Get torrent status and progress
|
* Get torrent status and progress
|
||||||
*/
|
*/
|
||||||
async getTorrent(hash: string): Promise<TorrentInfo> {
|
async getTorrent(hash: string): Promise<TorrentInfo> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.client.get('/torrents/info', {
|
const response = await this.client.get('/torrents/info', {
|
||||||
headers: { Cookie: this.cookie },
|
headers: this.authHeaders(),
|
||||||
params: { hashes: hash },
|
params: { hashes: hash },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -610,7 +632,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Get all torrents (optionally filtered by category)
|
* Get all torrents (optionally filtered by category)
|
||||||
*/
|
*/
|
||||||
async getTorrents(category?: string): Promise<TorrentInfo[]> {
|
async getTorrents(category?: string): Promise<TorrentInfo[]> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,7 +643,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.client.get('/torrents/info', {
|
const response = await this.client.get('/torrents/info', {
|
||||||
headers: { Cookie: this.cookie },
|
headers: this.authHeaders(),
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -636,7 +658,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Pause torrent
|
* Pause torrent
|
||||||
*/
|
*/
|
||||||
async pauseTorrent(hash: string): Promise<void> {
|
async pauseTorrent(hash: string): Promise<void> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,7 +668,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
new URLSearchParams({ hashes: hash }),
|
new URLSearchParams({ hashes: hash }),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -663,7 +685,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Resume torrent
|
* Resume torrent
|
||||||
*/
|
*/
|
||||||
async resumeTorrent(hash: string): Promise<void> {
|
async resumeTorrent(hash: string): Promise<void> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,7 +695,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
new URLSearchParams({ hashes: hash }),
|
new URLSearchParams({ hashes: hash }),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -690,7 +712,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Delete torrent
|
* Delete torrent
|
||||||
*/
|
*/
|
||||||
async deleteTorrent(hash: string, deleteFiles: boolean = false): Promise<void> {
|
async deleteTorrent(hash: string, deleteFiles: boolean = false): Promise<void> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,7 +725,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -720,13 +742,13 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Get files in torrent
|
* Get files in torrent
|
||||||
*/
|
*/
|
||||||
async getFiles(hash: string): Promise<TorrentFile[]> {
|
async getFiles(hash: string): Promise<TorrentFile[]> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.client.get('/torrents/files', {
|
const response = await this.client.get('/torrents/files', {
|
||||||
headers: { Cookie: this.cookie },
|
headers: this.authHeaders(),
|
||||||
params: { hash },
|
params: { hash },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -741,13 +763,13 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Get all configured categories from qBittorrent
|
* Get all configured categories from qBittorrent
|
||||||
*/
|
*/
|
||||||
async getCategories(): Promise<string[]> {
|
async getCategories(): Promise<string[]> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.client.get('/torrents/categories', {
|
const response = await this.client.get('/torrents/categories', {
|
||||||
headers: { Cookie: this.cookie },
|
headers: this.authHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
return Object.keys(response.data || {});
|
return Object.keys(response.data || {});
|
||||||
@@ -761,7 +783,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
* Set category for torrent
|
* Set category for torrent
|
||||||
*/
|
*/
|
||||||
async setCategory(hash: string, category: string): Promise<void> {
|
async setCategory(hash: string, category: string): Promise<void> {
|
||||||
if (!this.cookie) {
|
if (!this.cookie && !this.authOptional) {
|
||||||
await this.login();
|
await this.login();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,7 +796,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Cookie: this.cookie,
|
...this.authHeaders(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -788,26 +810,36 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test connection to qBittorrent
|
* Test connection to qBittorrent.
|
||||||
|
* In auth-optional mode the /app/version probe IS the connectivity check, so it must succeed.
|
||||||
|
* In credentialed mode login() is the connectivity check and version is best-effort.
|
||||||
*/
|
*/
|
||||||
async testConnection(): Promise<ConnectionTestResult> {
|
async testConnection(): Promise<ConnectionTestResult> {
|
||||||
try {
|
try {
|
||||||
await this.login();
|
await this.login(); // no-op when authOptional; throws on real auth failure
|
||||||
|
|
||||||
// Fetch version after successful login
|
|
||||||
let version: string | undefined;
|
|
||||||
try {
|
try {
|
||||||
const versionResponse = await this.client.get('/app/version', {
|
const versionResponse = await this.client.get('/app/version', {
|
||||||
headers: { Cookie: this.cookie },
|
headers: this.authHeaders(),
|
||||||
});
|
});
|
||||||
const raw = versionResponse.data || '';
|
const raw = versionResponse.data || '';
|
||||||
version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
|
const version = typeof raw === 'string' ? raw.replace(/^v/i, '') : undefined;
|
||||||
} catch {
|
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
|
||||||
// Version fetch is non-critical - connection is still valid
|
} catch (versionError) {
|
||||||
|
if (this.authOptional) {
|
||||||
|
// No login happened — version probe was our only connectivity signal.
|
||||||
|
const status = axios.isAxiosError(versionError) ? versionError.response?.status : undefined;
|
||||||
|
const baseMessage = versionError instanceof Error ? versionError.message : 'Connection failed';
|
||||||
|
const message = status === 401 || status === 403
|
||||||
|
? `qBittorrent requires authentication (HTTP ${status}). Provide username/password or whitelist this app's IP in qBittorrent.`
|
||||||
|
: `Failed to reach qBittorrent: ${baseMessage}`;
|
||||||
|
logger.error('[QBittorrent] Auth-optional connection probe failed', { status, message: baseMessage });
|
||||||
|
return { success: false, message };
|
||||||
|
}
|
||||||
|
// Credentialed path: login already succeeded, version is nice-to-have.
|
||||||
logger.debug('Could not fetch qBittorrent version');
|
logger.debug('Could not fetch qBittorrent version');
|
||||||
|
return { success: true, message: 'Connected to qBittorrent' };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, version, message: `Connected to qBittorrent${version ? ` ${version}` : ''}` };
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Connection failed';
|
const message = error instanceof Error ? error.message : 'Connection failed';
|
||||||
logger.error('Connection test failed', { error: message });
|
logger.error('Connection test failed', { error: message });
|
||||||
@@ -826,6 +858,7 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const baseUrl = url.replace(/\/$/, '');
|
const baseUrl = url.replace(/\/$/, '');
|
||||||
const loginUrl = `${baseUrl}/api/v2/auth/login`;
|
const loginUrl = `${baseUrl}/api/v2/auth/login`;
|
||||||
|
const authOptional = !username && !password;
|
||||||
|
|
||||||
// Create HTTPS agent if SSL verification is disabled
|
// Create HTTPS agent if SSL verification is disabled
|
||||||
let httpsAgent: https.Agent | undefined;
|
let httpsAgent: https.Agent | undefined;
|
||||||
@@ -844,9 +877,25 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
passwordLength: password?.length,
|
passwordLength: password?.length,
|
||||||
sslVerifyDisabled: disableSSLVerify,
|
sslVerifyDisabled: disableSSLVerify,
|
||||||
hasHttpsAgent: !!httpsAgent,
|
hasHttpsAgent: !!httpsAgent,
|
||||||
|
authOptional,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (authOptional) {
|
||||||
|
// No credentials provided — skip /auth/login and probe /app/version directly.
|
||||||
|
// Works for IP-whitelisted qBittorrent and auth-less qBit-compatible proxies (e.g. Decypharr).
|
||||||
|
logger.info('[QBittorrent] No credentials provided, probing /app/version directly');
|
||||||
|
const versionResponse = await axios.get(`${baseUrl}/api/v2/app/version`, {
|
||||||
|
httpsAgent,
|
||||||
|
timeout: DOWNLOAD_CLIENT_TIMEOUT,
|
||||||
|
});
|
||||||
|
logger.info('[QBittorrent] Auth-optional version check successful', {
|
||||||
|
version: versionResponse.data,
|
||||||
|
});
|
||||||
|
const rawVersion = versionResponse.data || '';
|
||||||
|
return typeof rawVersion === 'string' ? rawVersion.replace(/^v/i, '') || 'Connected' : 'Connected';
|
||||||
|
}
|
||||||
|
|
||||||
const requestBody = new URLSearchParams({ username, password });
|
const requestBody = new URLSearchParams({ username, password });
|
||||||
const requestHeaders = {
|
const requestHeaders = {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
@@ -980,6 +1029,11 @@ export class QBittorrentService implements IDownloadClient {
|
|||||||
|
|
||||||
// HTTP status errors
|
// HTTP status errors
|
||||||
if (status === 401 || status === 403) {
|
if (status === 401 || status === 403) {
|
||||||
|
if (authOptional) {
|
||||||
|
throw new Error(
|
||||||
|
`qBittorrent requires authentication (HTTP ${status}). Provide username/password, or whitelist this app's IP in qBittorrent's Web UI settings.`
|
||||||
|
);
|
||||||
|
}
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Authentication failed (HTTP ${status}). Check your username and password.`
|
`Authentication failed (HTTP ${status}). Check your username and password.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1217,4 +1217,124 @@ describe('QBittorrentService', () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(loginSpy).toHaveBeenCalled();
|
expect(loginSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('auth-optional mode (blank credentials)', () => {
|
||||||
|
it('flags service as auth-optional when both credentials are blank', () => {
|
||||||
|
const service = new QBittorrentService('http://qb', '', '');
|
||||||
|
expect((service as any).authOptional).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags service as credentialed when any credential is provided', () => {
|
||||||
|
const withUser = new QBittorrentService('http://qb', 'user', '');
|
||||||
|
const withPass = new QBittorrentService('http://qb', '', 'pass');
|
||||||
|
expect((withUser as any).authOptional).toBe(false);
|
||||||
|
expect((withPass as any).authOptional).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('login() is a no-op when auth-optional', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', '', '');
|
||||||
|
await service.login();
|
||||||
|
expect(axiosMock.post).not.toHaveBeenCalled();
|
||||||
|
expect((service as any).cookie).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testConnection() succeeds when /app/version returns a version (auth-optional)', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', '', '');
|
||||||
|
clientMock.get.mockResolvedValueOnce({ data: 'v4.6.0' });
|
||||||
|
|
||||||
|
const result = await service.testConnection();
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.version).toBe('4.6.0');
|
||||||
|
expect(axiosMock.post).not.toHaveBeenCalled();
|
||||||
|
expect(clientMock.get).toHaveBeenCalledWith('/app/version', expect.objectContaining({
|
||||||
|
headers: {},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testConnection() returns failure when /app/version returns 401 (auth-optional)', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', '', '');
|
||||||
|
clientMock.get.mockRejectedValueOnce({
|
||||||
|
isAxiosError: true,
|
||||||
|
response: { status: 401 },
|
||||||
|
message: 'Unauthorized',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.testConnection();
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toMatch(/requires authentication/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testConnection() returns failure when /app/version is unreachable (auth-optional)', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', '', '');
|
||||||
|
clientMock.get.mockRejectedValueOnce({
|
||||||
|
isAxiosError: true,
|
||||||
|
code: 'ECONNREFUSED',
|
||||||
|
message: 'connect ECONNREFUSED',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.testConnection();
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toMatch(/Failed to reach qBittorrent/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testConnectionWithCredentials() probes /app/version directly when both creds blank', async () => {
|
||||||
|
axiosMock.get.mockResolvedValueOnce({ data: 'v4.6.0' });
|
||||||
|
|
||||||
|
const version = await QBittorrentService.testConnectionWithCredentials('http://qb', '', '');
|
||||||
|
|
||||||
|
expect(version).toBe('4.6.0');
|
||||||
|
expect(axiosMock.post).not.toHaveBeenCalled();
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
|
'http://qb/api/v2/app/version',
|
||||||
|
expect.objectContaining({ httpsAgent: undefined })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testConnectionWithCredentials() reports auth-required when blank creds get 401', async () => {
|
||||||
|
axiosMock.get.mockRejectedValueOnce({
|
||||||
|
isAxiosError: true,
|
||||||
|
response: { status: 401 },
|
||||||
|
message: 'Unauthorized',
|
||||||
|
config: { url: 'http://qb/api/v2/app/version' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
QBittorrentService.testConnectionWithCredentials('http://qb', '', '')
|
||||||
|
).rejects.toThrow(/requires authentication/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addTorrent does not attempt re-login on 403 when auth-optional', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', '', '');
|
||||||
|
vi.spyOn(service as any, 'ensureCategory').mockResolvedValue(undefined);
|
||||||
|
const loginSpy = vi.spyOn(service, 'login');
|
||||||
|
vi.spyOn(service as any, 'addMagnetLink').mockRejectedValueOnce({
|
||||||
|
isAxiosError: true,
|
||||||
|
response: { status: 403 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.addTorrent('magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567')
|
||||||
|
).rejects.toThrow('Failed to add torrent');
|
||||||
|
|
||||||
|
expect(loginSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits Cookie header on requests when auth-optional', async () => {
|
||||||
|
const service = new QBittorrentService('http://qb', '', '');
|
||||||
|
vi.spyOn(service as any, 'getTorrent').mockRejectedValue(new Error('not found'));
|
||||||
|
clientMock.post.mockResolvedValue({ data: 'Ok.' });
|
||||||
|
|
||||||
|
await (service as any).addMagnetLink(
|
||||||
|
'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567',
|
||||||
|
'readmeabook'
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = clientMock.post.mock.calls[0][2].headers;
|
||||||
|
expect(headers.Cookie).toBeUndefined();
|
||||||
|
expect(headers['Content-Type']).toBe('application/x-www-form-urlencoded');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user