Skip to content

Commit f1aa83f

Browse files
fix(qbittorrent): rewrite cookie name on Request objects for qBT 5.x (#208)
qBT 5.x renames the session cookie from `SID` to `QBT_SID_<port>`. The previous wrapper captured the new cookie name on login but only rewrote outgoing Cookie headers when fetch was called as (url, init) — the generated SDK actually passes a pre-built Request, so the rewrite branch never ran and every authenticated call went out with `Cookie: SID=…` and got 403. Handle both call shapes: when input is a Request, clone it with a rewritten Cookie header; otherwise fall back to the init.headers path. Verified end-to-end against a real qBT 5.2.0 container. Closes #205
1 parent c61ae56 commit f1aa83f

2 files changed

Lines changed: 71 additions & 6 deletions

File tree

src/clients/qbittorrent.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,14 +67,27 @@ export class QBittorrentClient {
6767
input: RequestInfo | URL,
6868
init?: RequestInit
6969
): Promise<Response> {
70-
if (!init?.headers || this.cookieName === 'SID') {
70+
if (this.cookieName === 'SID') {
7171
return baseFetch(input, init);
7272
}
73-
const headers = new Headers(init.headers);
74-
const cookie = headers.get('cookie');
75-
if (cookie?.includes('SID=')) {
76-
headers.set('cookie', cookie.replace(/(^|;\s*)SID=/, `$1${this.cookieName}=`));
77-
return baseFetch(input, { ...init, headers });
73+
// The generated SDK may pass either (url, init) or a pre-built Request.
74+
// Rewrite the Cookie header in whichever form actually carries it.
75+
if (input instanceof Request) {
76+
const cookie = input.headers.get('cookie');
77+
if (cookie?.includes('SID=')) {
78+
const headers = new Headers(input.headers);
79+
headers.set('cookie', cookie.replace(/(^|;\s*)SID=/, `$1${this.cookieName}=`));
80+
return baseFetch(new Request(input, { headers }), init);
81+
}
82+
return baseFetch(input, init);
83+
}
84+
if (init?.headers) {
85+
const headers = new Headers(init.headers);
86+
const cookie = headers.get('cookie');
87+
if (cookie?.includes('SID=')) {
88+
headers.set('cookie', cookie.replace(/(^|;\s*)SID=/, `$1${this.cookieName}=`));
89+
return baseFetch(input, { ...init, headers });
90+
}
7891
}
7992
return baseFetch(input, init);
8093
}

tests/clients.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,56 @@ describe('Tsarr Client Tests', () => {
156156
expect(typeof qbit.deleteTorrents).toBe('function');
157157
});
158158
});
159+
160+
describe('QBittorrentClient auth flow', () => {
161+
type LoginResponse = () => Response;
162+
163+
async function captureCookieOnApiCall(loginResponse: LoginResponse): Promise<string | null> {
164+
const calls: { url: string; cookie: string | null }[] = [];
165+
const originalFetch = globalThis.fetch;
166+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
167+
const url = input instanceof Request ? input.url : input.toString();
168+
const cookie =
169+
input instanceof Request
170+
? input.headers.get('cookie')
171+
: init?.headers
172+
? new Headers(init.headers).get('cookie')
173+
: null;
174+
calls.push({ url, cookie });
175+
if (url.endsWith('/api/v2/auth/login')) return loginResponse();
176+
if (url.endsWith('/api/v2/app/version')) return new Response('v5.0.0', { status: 200 });
177+
return new Response('not mocked', { status: 500 });
178+
}) as typeof globalThis.fetch;
179+
try {
180+
const client = new QBittorrentClient({
181+
baseUrl: 'http://localhost:8080',
182+
username: 'admin',
183+
password: 'adminadmin',
184+
});
185+
await client.getAppVersion();
186+
return calls.find(c => c.url.endsWith('/api/v2/app/version'))?.cookie ?? null;
187+
} finally {
188+
globalThis.fetch = originalFetch;
189+
}
190+
}
191+
192+
it('sends SID cookie against qBT 4.x (200 + "Ok." + SID=)', async () => {
193+
const cookie = await captureCookieOnApiCall(
194+
() => new Response('Ok.', { status: 200, headers: { 'set-cookie': 'SID=abc123; path=/' } })
195+
);
196+
expect(cookie).toContain('SID=abc123');
197+
});
198+
199+
it('sends QBT_SID_<port> cookie against qBT 5.x (204 + empty + QBT_SID_8080=)', async () => {
200+
const cookie = await captureCookieOnApiCall(
201+
() =>
202+
new Response(null, {
203+
status: 204,
204+
headers: { 'set-cookie': 'QBT_SID_8080=xyz789; path=/; HttpOnly' },
205+
})
206+
);
207+
expect(cookie).toContain('QBT_SID_8080=xyz789');
208+
expect(cookie).not.toContain('SID=xyz789');
209+
});
210+
});
159211
});

0 commit comments

Comments
 (0)