Files
readest/apps/readest-app/src/__tests__/utils/opds-req.test.ts
T
Huang Xin 83607d14ea fix(opds): send Basic auth preemptively for optional-auth servers (#4206)
OPDS servers that allow anonymous access (e.g. Calibre-Web) return 200
without a WWW-Authenticate challenge. `fetchWithAuth` only attached
credentials on a 401/403 retry, so a user who configured valid login
details kept seeing guest-only content (own shelves missing).

Send a Basic Authorization header on the first request whenever
credentials are available. Digest auth still falls through to the
challenge-driven retry since it can't be sent preemptively, and the
retry is skipped when it would just repeat the preemptive Basic header.

Fixes #4202

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:07:15 +02:00

200 lines
7.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { deserializeOPDSCustomHeaders } from '@/app/opds/utils/customHeaders';
// Mock environment for web platform
vi.mock('@/services/environment', () => ({
isWebAppPlatform: vi.fn(() => true),
isTauriAppPlatform: vi.fn(() => false),
getAPIBaseUrl: () => '/api',
getNodeAPIBaseUrl: () => '/node-api',
getBaseUrl: () => 'https://web.readest.com',
getNodeBaseUrl: () => 'https://node.readest.com',
isWebDevMode: () => true,
}));
// Mock tauriFetch to avoid import errors
vi.mock('@tauri-apps/plugin-http', () => ({
fetch: vi.fn(),
}));
type FakeResponseInit = {
status?: number;
body?: string;
wwwAuthenticate?: string;
};
const makeResponse = ({ status = 200, body = '', wwwAuthenticate }: FakeResponseInit = {}) => ({
ok: status >= 200 && status < 300,
status,
headers: {
get: (name: string) =>
name.toLowerCase() === 'www-authenticate' ? (wwwAuthenticate ?? null) : null,
},
text: async () => body,
});
describe('opdsReq', () => {
let needsProxy: typeof import('@/app/opds/utils/opdsReq').needsProxy;
let getProxiedURL: typeof import('@/app/opds/utils/opdsReq').getProxiedURL;
let isWebAppPlatform: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.resetModules();
const envModule = await import('@/services/environment');
isWebAppPlatform = envModule.isWebAppPlatform as ReturnType<typeof vi.fn>;
const opdsReq = await import('@/app/opds/utils/opdsReq');
needsProxy = opdsReq.needsProxy;
getProxiedURL = opdsReq.getProxiedURL;
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('needsProxy', () => {
it('should return true for HTTP URLs on web platform', () => {
isWebAppPlatform.mockReturnValue(true);
expect(needsProxy('http://my-opds-server.local/covers/book.jpg')).toBe(true);
});
it('should return true for HTTPS URLs on web platform', () => {
isWebAppPlatform.mockReturnValue(true);
expect(needsProxy('https://opds.example.com/feed')).toBe(true);
});
it('should return false on native/Tauri platform', () => {
isWebAppPlatform.mockReturnValue(false);
expect(needsProxy('http://my-opds-server.local/covers/book.jpg')).toBe(false);
});
it('should return false for non-HTTP URLs', () => {
isWebAppPlatform.mockReturnValue(true);
expect(needsProxy('/local/path/image.jpg')).toBe(false);
expect(needsProxy('data:image/png;base64,...')).toBe(false);
});
});
describe('getProxiedURL', () => {
it('should generate proxy URL for image requests without auth', () => {
const imageUrl = 'http://my-opds-server.local/covers/book.jpg';
const proxied = getProxiedURL(imageUrl, '', true);
expect(proxied).toContain('/api/opds/proxy');
expect(proxied).toContain('url=' + encodeURIComponent(imageUrl));
expect(proxied).toContain('stream=true');
});
it('should generate proxy URL with auth parameter', () => {
const imageUrl = 'http://my-opds-server.local/covers/book.jpg';
const auth = 'Basic dXNlcjpwYXNz';
const proxied = getProxiedURL(imageUrl, auth, true);
// URLSearchParams encodes spaces as '+' rather than '%20'
expect(proxied).toContain('auth=Basic+dXNlcjpwYXNz');
});
it('should include serialized custom headers in the proxy URL', () => {
const imageUrl = 'http://my-opds-server.local/covers/book.jpg';
const proxied = getProxiedURL(imageUrl, '', true, {
'CF-Access-Client-Id': 'client-id',
'CF-Access-Client-Secret': 'secret',
});
const params = new URL(proxied, 'https://web.readest.com').searchParams;
expect(deserializeOPDSCustomHeaders(params.get('headers'))).toEqual({
'CF-Access-Client-Id': 'client-id',
'CF-Access-Client-Secret': 'secret',
});
});
it('should strip credentials from URL before proxying', () => {
const imageUrl = 'http://user:pass@my-opds-server.local/covers/book.jpg';
const proxied = getProxiedURL(imageUrl);
expect(proxied).not.toContain('user:pass');
expect(proxied).toContain(
'url=' + encodeURIComponent('http://my-opds-server.local/covers/book.jpg'),
);
});
it('should return non-HTTP URLs unchanged', () => {
const localPath = '/local/path/image.jpg';
expect(getProxiedURL(localPath)).toBe(localPath);
});
it('should use node proxy for standardebooks domain', () => {
const url = 'https://standardebooks.org/opds/all';
const proxied = getProxiedURL(url);
expect(proxied).toContain('/node-api/opds/proxy');
});
});
describe('fetchWithAuth', () => {
let fetchWithAuth: typeof import('@/app/opds/utils/opdsReq').fetchWithAuth;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
const opdsReq = await import('@/app/opds/utils/opdsReq');
fetchWithAuth = opdsReq.fetchWithAuth;
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('sends Basic auth on the first request when credentials are provided', async () => {
// Servers that allow anonymous access return 200 without a challenge.
// The credentials must be sent preemptively or the user keeps seeing
// guest content (issue #4202).
fetchMock.mockResolvedValue(makeResponse({ status: 200, body: '<feed/>' }));
await fetchWithAuth('https://opds.example.com/feed', 'alice', 's3cret', false);
expect(fetchMock).toHaveBeenCalledTimes(1);
const init = fetchMock.mock.calls[0]![1] as RequestInit;
const headers = init.headers as Record<string, string>;
expect(headers['Authorization']).toBe(`Basic ${btoa('alice:s3cret')}`);
});
it('does not send an Authorization header when no credentials are provided', async () => {
fetchMock.mockResolvedValue(makeResponse({ status: 200, body: '<feed/>' }));
await fetchWithAuth('https://opds.example.com/feed', undefined, undefined, false);
expect(fetchMock).toHaveBeenCalledTimes(1);
const init = fetchMock.mock.calls[0]![1] as RequestInit;
const headers = init.headers as Record<string, string>;
expect(headers['Authorization']).toBeUndefined();
});
it('passes preemptive auth through the proxy URL when useProxy is true', async () => {
fetchMock.mockResolvedValue(makeResponse({ status: 200, body: '<feed/>' }));
await fetchWithAuth('https://opds.example.com/feed', 'alice', 's3cret', true);
expect(fetchMock).toHaveBeenCalledTimes(1);
const proxyUrl = fetchMock.mock.calls[0]![0] as string;
const auth = new URL(proxyUrl, 'https://web.readest.com').searchParams.get('auth');
expect(auth).toBe(`Basic ${btoa('alice:s3cret')}`);
});
it('retries with Digest auth when the server issues a Digest challenge', async () => {
fetchMock
.mockResolvedValueOnce(
makeResponse({
status: 401,
wwwAuthenticate: 'Digest realm="opds", nonce="abc123", qop="auth"',
}),
)
.mockResolvedValueOnce(makeResponse({ status: 200, body: '<feed/>' }));
const res = await fetchWithAuth('https://opds.example.com/feed', 'alice', 's3cret', false);
expect(fetchMock).toHaveBeenCalledTimes(2);
const init = fetchMock.mock.calls[1]![1] as RequestInit;
const headers = init.headers as Record<string, string>;
expect(headers['Authorization']).toMatch(/^Digest /);
expect(res.status).toBe(200);
});
});
});