readest/apps/readest-app/src/__tests__/services/opds-auto-download.test.ts
Huang Xin 883dae36aa
fix(opds): auth negotiation and auto-download fixes for self-hosted catalogs (#5002)
* fix(opds): allow LAN catalogs through the proxy in development

The SSRF host blocklist added in #4638 unconditionally rejected private
addresses, so the dev proxy returned 400 for LAN OPDS catalogs even though
next dev runs on the developer's own machine where reaching the LAN is the
normal use case. Skip the blocklist when NODE_ENV is development, matching
the existing CatalogManager gate that only forbids LAN URLs in production.
Production and test behavior are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(opds): negotiate Digest auth when the server rejects preemptive Basic with 400

Calibre's content server in digest mode, or auto mode over http, answers a
Basic Authorization header with 400 Unsupported authentication method
instead of a 401 challenge. The preemptive Basic header introduced in #4206
therefore dead-ended the request, since the auth retry only fired on 401 or
403, and digest catalogs failed with Failed to load OPDS feed: 400 Bad
Request on every platform. When a request that carried preemptive Basic
comes back 400, re-issue it once without credentials to surface the
WWW-Authenticate challenge and let the existing negotiation pick the scheme
the server actually wants. Verified end to end against a live Calibre
server on web and Android.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(opds): skip SSL verification in auto-download like the manual download path

The native download_file command validates TLS with rustls, which ignores
the OS trust store, so downloads from self-signed or private-CA OPDS
servers fail in the TLS handshake before any request reaches the server.
The manual download path has passed skipSslVerification since #2900 as the
workaround for #2871, but the auto-download path never did, so subscribed
shelves failed to sync while feed browsing and manual downloads of the same
books worked. Pass the same flag in downloadAndImport.

Closes #4988

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:47:26 +02:00

286 lines
9.6 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { OPDSCatalog } from '@/types/opds';
import type { AppService } from '@/types/system';
import type { OPDSSubscriptionState, PendingItem } from '@/services/opds/types';
vi.mock('@/services/environment', () => ({
isWebAppPlatform: vi.fn(() => false),
isTauriAppPlatform: vi.fn(() => true),
getAPIBaseUrl: () => '/api',
getNodeAPIBaseUrl: () => '/node-api',
}));
vi.mock('@tauri-apps/plugin-http', () => ({
fetch: vi.fn(),
}));
vi.mock('@/libs/storage', () => ({
downloadFile: vi.fn().mockResolvedValue({ 'content-disposition': '' }),
}));
vi.mock('@/app/opds/utils/opdsReq', () => ({
fetchWithAuth: vi.fn(),
probeAuth: vi.fn().mockResolvedValue(null),
needsProxy: vi.fn(() => false),
getProxiedURL: vi.fn((url: string) => url),
probeFilename: vi.fn().mockResolvedValue(''),
}));
vi.mock('@/services/opds/feedChecker', () => ({
checkFeedForNewItems: vi.fn().mockResolvedValue([]),
}));
vi.mock('@/services/opds/sourceMap', () => ({
upsertOPDSSourceMapping: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/services/opds/subscriptionState', () => ({
loadSubscriptionState: vi.fn().mockResolvedValue({
catalogId: 'cat-1',
lastCheckedAt: 0,
knownEntryIds: [],
failedEntries: [],
}),
saveSubscriptionState: vi.fn().mockResolvedValue(undefined),
pruneKnownEntryIds: vi.fn((ids: string[]) => ids),
emptyState: vi.fn((id: string) => ({
catalogId: id,
lastCheckedAt: 0,
knownEntryIds: [],
failedEntries: [],
})),
}));
import { syncSubscribedCatalogs } from '@/services/opds/autoDownload';
import { checkFeedForNewItems } from '@/services/opds/feedChecker';
import { saveSubscriptionState, loadSubscriptionState } from '@/services/opds/subscriptionState';
import { upsertOPDSSourceMapping } from '@/services/opds/sourceMap';
import { downloadFile } from '@/libs/storage';
const createMockAppService = () =>
({
resolveFilePath: vi.fn(async (path: string) => `/cache/${path}`),
importBook: vi.fn(async () => ({
hash: 'abc123',
format: 'EPUB',
title: 'Test Book',
author: 'Author',
createdAt: Date.now(),
updatedAt: Date.now(),
})),
copyFile: vi.fn(async () => {}),
deleteFile: vi.fn(async () => {}),
exists: vi.fn(async () => false),
readFile: vi.fn(async () => '{}'),
writeFile: vi.fn(async () => {}),
createDir: vi.fn(async () => {}),
}) as unknown as AppService;
describe('OPDS auto-download orchestrator', () => {
let appService: AppService;
beforeEach(() => {
vi.clearAllMocks();
appService = createMockAppService();
});
it('skips catalogs without autoDownload enabled', async () => {
const catalogs: OPDSCatalog[] = [
{ id: 'cat-1', name: 'Test', url: 'https://example.com/opds' },
];
const result = await syncSubscribedCatalogs(catalogs, appService, []);
expect(result.totalNewBooks).toBe(0);
expect(checkFeedForNewItems).not.toHaveBeenCalled();
});
it('skips disabled catalogs even with autoDownload', async () => {
const catalogs: OPDSCatalog[] = [
{
id: 'cat-1',
name: 'Test',
url: 'https://example.com/opds',
autoDownload: true,
disabled: true,
},
];
const result = await syncSubscribedCatalogs(catalogs, appService, []);
expect(result.totalNewBooks).toBe(0);
});
it('downloads new items and returns them', async () => {
const catalogs: OPDSCatalog[] = [
{ id: 'cat-1', name: 'Shelf', url: 'https://shelf.example.com/opds', autoDownload: true },
];
const pendingItems: PendingItem[] = [
{
entryId: 'urn:shelf:1',
title: 'Issue 1',
acquisitionHref: '/dl/1.epub',
mimeType: 'application/epub+zip',
baseURL: 'https://shelf.example.com/opds',
},
];
vi.mocked(checkFeedForNewItems).mockResolvedValue(pendingItems);
const result = await syncSubscribedCatalogs(catalogs, appService, []);
expect(result.totalNewBooks).toBe(1);
expect(result.newBooks).toHaveLength(1);
expect(saveSubscriptionState).toHaveBeenCalled();
const savedState = vi.mocked(saveSubscriptionState).mock.calls[0]![1] as OPDSSubscriptionState;
expect(savedState.knownEntryIds).toContain('urn:shelf:1');
expect(savedState.lastCheckedAt).toBeGreaterThan(0);
expect(upsertOPDSSourceMapping).toHaveBeenCalledWith(appService, {
catalogId: 'cat-1',
sourceUrl: 'https://shelf.example.com/dl/1.epub',
bookHash: 'abc123',
});
});
it('downloads with skipSslVerification like the manual download path', async () => {
// The manual OPDS download (page.tsx handleDownload) passes
// skipSslVerification as a workaround for self-signed/private-CA OPDS
// servers (#2871): the native download_file validates TLS with rustls,
// which ignores the OS trust store, while the feed fetch and auth probe
// go through the http plugin with acceptInvalidCerts. Without the same
// flag here, auto-download dies in the TLS handshake on servers where
// manual download works (#4988).
const catalogs: OPDSCatalog[] = [
{ id: 'cat-1', name: 'Shelf', url: 'https://shelf.example.com/opds', autoDownload: true },
];
vi.mocked(checkFeedForNewItems).mockResolvedValue([
{
entryId: 'urn:shelf:1',
title: 'Issue 1',
acquisitionHref: '/dl/1.epub',
mimeType: 'application/epub+zip',
baseURL: 'https://shelf.example.com/opds',
},
]);
await syncSubscribedCatalogs(catalogs, appService, []);
expect(downloadFile).toHaveBeenCalledTimes(1);
expect(vi.mocked(downloadFile).mock.calls[0]![0]).toMatchObject({
skipSslVerification: true,
});
});
it('handles import failure by adding to failedEntries', async () => {
const catalogs: OPDSCatalog[] = [
{ id: 'cat-1', name: 'Test', url: 'https://example.com/opds', autoDownload: true },
];
vi.mocked(checkFeedForNewItems).mockResolvedValue([
{
entryId: 'urn:fail:1',
title: 'Bad Book',
acquisitionHref: '/dl/bad.epub',
mimeType: 'application/epub+zip',
baseURL: 'https://example.com',
},
]);
(appService.importBook as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error('corrupt file'),
);
const result = await syncSubscribedCatalogs(catalogs, appService, []);
expect(result.totalNewBooks).toBe(0);
const savedState = vi.mocked(saveSubscriptionState).mock.calls[0]![1] as OPDSSubscriptionState;
expect(savedState.failedEntries).toHaveLength(1);
expect(savedState.failedEntries[0]!.entryId).toBe('urn:fail:1');
expect(savedState.failedEntries[0]!.attempts).toBe(1);
expect(savedState.knownEntryIds).not.toContain('urn:fail:1');
});
it('returns empty result when no catalogs have autoDownload', async () => {
const result = await syncSubscribedCatalogs([], appService, []);
expect(result).toEqual({ newBooks: [], totalNewBooks: 0, errors: [] });
});
it('does not re-attempt or duplicate an in-backoff failed entry that reappears in the feed', async () => {
const catalogs: OPDSCatalog[] = [
{ id: 'cat-1', name: 'Test', url: 'https://example.com/opds', autoDownload: true },
];
// Entry X failed once, very recently — well within the backoff window,
// so isRetryEligible() returns false.
vi.mocked(loadSubscriptionState).mockResolvedValueOnce({
catalogId: 'cat-1',
lastCheckedAt: 0,
knownEntryIds: [],
failedEntries: [
{
entryId: 'urn:backoff:1',
href: '/dl/x.epub',
title: 'Backed-off Book',
attempts: 1,
lastAttemptAt: Date.now(), // freshly attempted, still in backoff
},
],
});
// The entry is still in the feed (not in knownEntryIds), so discovery
// returns it again.
vi.mocked(checkFeedForNewItems).mockResolvedValue([
{
entryId: 'urn:backoff:1',
title: 'Backed-off Book',
acquisitionHref: '/dl/x.epub',
mimeType: 'application/epub+zip',
baseURL: 'https://example.com/opds',
},
]);
await syncSubscribedCatalogs(catalogs, appService, []);
// No download should have been attempted while in backoff.
expect(downloadFile).not.toHaveBeenCalled();
// And the saved state must not contain duplicate failedEntries for the
// same entryId.
const savedState = vi
.mocked(saveSubscriptionState)
.mock.calls.at(-1)![1] as OPDSSubscriptionState;
const ids = savedState.failedEntries.map((fe) => fe.entryId);
expect(ids).toEqual(Array.from(new Set(ids)));
expect(savedState.failedEntries.filter((fe) => fe.entryId === 'urn:backoff:1')).toHaveLength(1);
});
it('does not download the same entry twice when it is both pending and a retry-eligible failure', async () => {
const catalogs: OPDSCatalog[] = [
{ id: 'cat-1', name: 'Test', url: 'https://example.com/opds', autoDownload: true },
];
vi.mocked(loadSubscriptionState).mockResolvedValueOnce({
catalogId: 'cat-1',
lastCheckedAt: 0,
knownEntryIds: [],
failedEntries: [
{
entryId: 'urn:dup:1',
href: '/dl/dup.epub',
title: 'Dup Book',
attempts: 1,
lastAttemptAt: 0, // far in the past, retry eligible
},
],
});
vi.mocked(checkFeedForNewItems).mockResolvedValue([
{
entryId: 'urn:dup:1',
title: 'Dup Book',
acquisitionHref: '/dl/dup.epub',
mimeType: 'application/epub+zip',
baseURL: 'https://example.com/opds',
},
]);
await syncSubscribedCatalogs(catalogs, appService, []);
expect(downloadFile).toHaveBeenCalledTimes(1);
});
});