forked from akai/readest
83607d14ea
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>
200 lines
7.4 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|