mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
208d04d218
Moon+ Reader and CrossPoint never fetch the OpenSearch description document, so they
detected no search capability and hid the search affordance entirely. Both look only for a
rel="search" link whose href already carries {searchTerms}, and they match the link type by
equality rather than by substring.
Emit that templated link alongside the existing OpenSearch pointer, keeping the pointer
first so KOReader's has_opensearch guard still wins and its behaviour is unchanged. The
type stays bare application/atom+xml and the href stays relative like every other href in
the feed, so nothing has to reconstruct an external base URL behind a reverse proxy.
Calibre-Web ships the same two-link pairing.
Add the pair to the libraries, collections, smart-scopes, authors and series feeds too.
Those carried only a self link, so search disappeared once a client descended into a
sub-folder even when it did support OpenSearch.
Closes #860
791 lines
35 KiB
TypeScript
791 lines
35 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
import { mkdir, readFile, stat, writeFile } from 'fs/promises';
|
|
import { dirname, join, relative } from 'path';
|
|
|
|
import { eq } from 'drizzle-orm';
|
|
import { Permission } from '@bookorbit/types';
|
|
import { ConfigService } from '@nestjs/config';
|
|
|
|
import { normalizeMetadataTextKey } from '../src/common/utils/metadata-text-normalize.utils';
|
|
import { createCoverToken } from '../src/modules/opds/opds-auth.guard';
|
|
import * as schema from '../src/db/schema';
|
|
import { createEpubFixture, createFb2Fixture, writeFixtureFile } from './e2e/opds/opds-fixture-builder';
|
|
import {
|
|
authHeader,
|
|
basicAuth,
|
|
closeOpdsE2EContext,
|
|
createBookCoverArtifacts,
|
|
createLibraryWithFolder,
|
|
createOpdsE2EContext,
|
|
createOpdsUserCredential,
|
|
createUserAndLogin,
|
|
grantLibraryAccess,
|
|
locateBookByAbsolutePath,
|
|
replaceUserPermissions,
|
|
setSetting,
|
|
setUserActive,
|
|
triggerAndWaitForLibraryScan,
|
|
type CreatedLibrary,
|
|
type LocatedBookFile,
|
|
type OpdsE2EContext,
|
|
type TestUserSession,
|
|
} from './e2e/opds/opds-harness';
|
|
|
|
interface ScenarioRunResult {
|
|
id: string;
|
|
status: 'passed' | 'failed';
|
|
durationMs: number;
|
|
error?: string;
|
|
}
|
|
|
|
interface OpdsCredentials {
|
|
username: string;
|
|
password: string;
|
|
}
|
|
|
|
async function writeScenarioReport(results: ScenarioRunResult[]): Promise<void> {
|
|
const reportDir = process.env.JUNIT_OUTPUT ? dirname(process.env.JUNIT_OUTPUT) : join(process.cwd(), '..', 'test-results', 'server');
|
|
await mkdir(reportDir, { recursive: true });
|
|
const reportPath = join(reportDir, 'opds-auth-catalog-e2e-scenarios.json');
|
|
await writeFile(
|
|
reportPath,
|
|
JSON.stringify(
|
|
{
|
|
generatedAt: new Date().toISOString(),
|
|
total: results.length,
|
|
passed: results.filter((result) => result.status === 'passed').length,
|
|
failed: results.filter((result) => result.status === 'failed').length,
|
|
results,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
}
|
|
|
|
describe('OPDS auth and catalog (e2e)', { timeout: 120_000 }, () => {
|
|
let ctx!: OpdsE2EContext;
|
|
const scenarioResults: ScenarioRunResult[] = [];
|
|
let scenarioStartedAt = 0;
|
|
|
|
let owner!: TestUserSession;
|
|
let intruder!: TestUserSession;
|
|
let disabledParent!: TestUserSession;
|
|
let revokedParent!: TestUserSession;
|
|
let noOpdsPermissionUser!: TestUserSession;
|
|
let fb2Reader!: TestUserSession;
|
|
|
|
let visibleLibrary!: CreatedLibrary;
|
|
let hiddenLibrary!: CreatedLibrary;
|
|
let fb2Library!: CreatedLibrary;
|
|
|
|
let visibleBookAlpha!: LocatedBookFile;
|
|
let visibleBookBeta!: LocatedBookFile;
|
|
let hiddenBook!: LocatedBookFile;
|
|
let rawFb2Book!: LocatedBookFile;
|
|
let rawFb2FixtureContent!: string;
|
|
let visibleAlphaAlternativeFileIds!: Record<'mobi' | 'azw3' | 'fb2', number>;
|
|
|
|
let ownerCredentials!: OpdsCredentials;
|
|
let disabledCredentials!: OpdsCredentials;
|
|
let revokedCredentials!: OpdsCredentials;
|
|
let fb2Credentials!: OpdsCredentials;
|
|
|
|
let ownerCollectionId!: number;
|
|
let foreignCollectionId!: number;
|
|
let ownerSmartScopeId!: number;
|
|
let foreignSmartScopeId!: number;
|
|
|
|
let visibleAuthorName!: string;
|
|
let hiddenAuthorName!: string;
|
|
let visibleSeriesName!: string;
|
|
let hiddenSeriesName!: string;
|
|
const rawFb2Title = 'Visible Raw FB2';
|
|
|
|
beforeAll(async () => {
|
|
ctx = await createOpdsE2EContext();
|
|
|
|
visibleLibrary = await createLibraryWithFolder(ctx, { name: `opds-visible-library-${randomUUID()}` });
|
|
hiddenLibrary = await createLibraryWithFolder(ctx, { name: `opds-hidden-library-${randomUUID()}` });
|
|
fb2Library = await createLibraryWithFolder(ctx, { name: `opds-fb2-library-${randomUUID()}` });
|
|
|
|
const visibleAlphaPath = await createEpubFixture(visibleLibrary.folderPath, 'visible-alpha.epub', { title: 'Visible Alpha' });
|
|
const visibleBetaPath = await createEpubFixture(visibleLibrary.folderPath, 'visible-beta.epub', { title: 'Visible Beta' });
|
|
const hiddenGammaPath = await createEpubFixture(hiddenLibrary.folderPath, 'hidden-gamma.epub', { title: 'Hidden Gamma' });
|
|
const rawFb2Path = await createFb2Fixture(fb2Library.folderPath, 'visible-raw.fb2', {
|
|
title: rawFb2Title,
|
|
authors: ['OPDS FB2 Author'],
|
|
description: 'OPDS raw FB2 download fixture',
|
|
});
|
|
rawFb2FixtureContent = await readFile(rawFb2Path, 'utf8');
|
|
|
|
await triggerAndWaitForLibraryScan(ctx, visibleLibrary.libraryId);
|
|
await triggerAndWaitForLibraryScan(ctx, hiddenLibrary.libraryId);
|
|
await triggerAndWaitForLibraryScan(ctx, fb2Library.libraryId);
|
|
|
|
visibleBookAlpha = await locateBookByAbsolutePath(ctx, visibleAlphaPath);
|
|
visibleBookBeta = await locateBookByAbsolutePath(ctx, visibleBetaPath);
|
|
hiddenBook = await locateBookByAbsolutePath(ctx, hiddenGammaPath);
|
|
rawFb2Book = await locateBookByAbsolutePath(ctx, rawFb2Path);
|
|
visibleAlphaAlternativeFileIds = {
|
|
mobi: await attachContentFileToBook(
|
|
visibleBookAlpha.bookId,
|
|
visibleLibrary,
|
|
await writeFixtureFile(visibleLibrary.folderPath, 'visible-alpha.mobi', 'mock mobi content'),
|
|
'mobi',
|
|
1,
|
|
),
|
|
azw3: await attachContentFileToBook(
|
|
visibleBookAlpha.bookId,
|
|
visibleLibrary,
|
|
await writeFixtureFile(visibleLibrary.folderPath, 'visible-alpha.azw3', 'mock azw3 content'),
|
|
'azw3',
|
|
2,
|
|
),
|
|
fb2: await attachContentFileToBook(
|
|
visibleBookAlpha.bookId,
|
|
visibleLibrary,
|
|
await createFb2Fixture(visibleLibrary.folderPath, 'visible-alpha.fb2', { title: 'Visible Alpha' }),
|
|
'fb2',
|
|
3,
|
|
),
|
|
};
|
|
|
|
await createBookCoverArtifacts(ctx, visibleBookAlpha.bookId);
|
|
await createBookCoverArtifacts(ctx, visibleBookBeta.bookId);
|
|
await createBookCoverArtifacts(ctx, hiddenBook.bookId);
|
|
|
|
visibleAuthorName = `Visible Author ${randomUUID().slice(0, 8)}`;
|
|
hiddenAuthorName = `Hidden Author ${randomUUID().slice(0, 8)}`;
|
|
visibleSeriesName = `Visible Series ${randomUUID().slice(0, 8)}`;
|
|
hiddenSeriesName = `Hidden Series ${randomUUID().slice(0, 8)}`;
|
|
|
|
await seedBookMetadata(visibleBookAlpha.bookId, 'Visible Alpha', visibleSeriesName, 1, '9780141187761');
|
|
await seedBookMetadata(visibleBookBeta.bookId, 'Visible Beta', visibleSeriesName, 2);
|
|
await seedBookMetadata(hiddenBook.bookId, 'Hidden Gamma', hiddenSeriesName, 1, '9780300000000');
|
|
await seedBookMetadata(rawFb2Book.bookId, rawFb2Title, `Raw FB2 Series ${randomUUID().slice(0, 8)}`, 1);
|
|
|
|
await linkBookAuthor(visibleBookAlpha.bookId, visibleAuthorName);
|
|
await linkBookAuthor(visibleBookBeta.bookId, visibleAuthorName);
|
|
await linkBookAuthor(hiddenBook.bookId, hiddenAuthorName);
|
|
|
|
owner = await createUserAndLogin(ctx, { permissions: [Permission.OpdsAccess] });
|
|
intruder = await createUserAndLogin(ctx, { permissions: [Permission.OpdsAccess] });
|
|
disabledParent = await createUserAndLogin(ctx, { permissions: [Permission.OpdsAccess] });
|
|
revokedParent = await createUserAndLogin(ctx, { permissions: [Permission.OpdsAccess] });
|
|
noOpdsPermissionUser = await createUserAndLogin(ctx);
|
|
fb2Reader = await createUserAndLogin(ctx, { permissions: [Permission.OpdsAccess] });
|
|
|
|
await grantLibraryAccess(ctx, owner.userId, visibleLibrary.libraryId, 'viewer');
|
|
await grantLibraryAccess(ctx, intruder.userId, visibleLibrary.libraryId, 'viewer');
|
|
await grantLibraryAccess(ctx, disabledParent.userId, visibleLibrary.libraryId, 'viewer');
|
|
await grantLibraryAccess(ctx, revokedParent.userId, visibleLibrary.libraryId, 'viewer');
|
|
await grantLibraryAccess(ctx, fb2Reader.userId, fb2Library.libraryId, 'viewer');
|
|
|
|
const ownerOpds = await createOpdsUserCredential(ctx, {
|
|
userId: owner.userId,
|
|
username: `opds-owner-${randomUUID().slice(0, 8)}`,
|
|
password: 'OwnerOpdsPass123',
|
|
});
|
|
const disabledOpds = await createOpdsUserCredential(ctx, {
|
|
userId: disabledParent.userId,
|
|
username: `opds-disabled-${randomUUID().slice(0, 8)}`,
|
|
password: 'DisabledOpdsPass123',
|
|
});
|
|
const revokedOpds = await createOpdsUserCredential(ctx, {
|
|
userId: revokedParent.userId,
|
|
username: `opds-revoked-${randomUUID().slice(0, 8)}`,
|
|
password: 'RevokedOpdsPass123',
|
|
});
|
|
const fb2Opds = await createOpdsUserCredential(ctx, {
|
|
userId: fb2Reader.userId,
|
|
username: `opds-fb2-${randomUUID().slice(0, 8)}`,
|
|
password: 'Fb2OpdsPass123',
|
|
});
|
|
|
|
ownerCredentials = { username: ownerOpds.row.username, password: ownerOpds.password };
|
|
disabledCredentials = { username: disabledOpds.row.username, password: disabledOpds.password };
|
|
revokedCredentials = { username: revokedOpds.row.username, password: revokedOpds.password };
|
|
fb2Credentials = { username: fb2Opds.row.username, password: fb2Opds.password };
|
|
|
|
await setUserActive(ctx, disabledParent.userId, false);
|
|
await replaceUserPermissions(ctx, revokedParent.userId, []);
|
|
|
|
const [ownerCollection] = await ctx.db
|
|
.insert(schema.collections)
|
|
.values({
|
|
userId: owner.userId,
|
|
name: `owner-collection-${randomUUID().slice(0, 8)}`,
|
|
})
|
|
.returning({ id: schema.collections.id });
|
|
ownerCollectionId = ownerCollection.id;
|
|
|
|
await ctx.db.insert(schema.collectionBooks).values([
|
|
{ collectionId: ownerCollectionId, bookId: visibleBookAlpha.bookId },
|
|
{ collectionId: ownerCollectionId, bookId: visibleBookBeta.bookId },
|
|
]);
|
|
|
|
const [foreignCollection] = await ctx.db
|
|
.insert(schema.collections)
|
|
.values({
|
|
userId: intruder.userId,
|
|
name: `foreign-collection-${randomUUID().slice(0, 8)}`,
|
|
})
|
|
.returning({ id: schema.collections.id });
|
|
foreignCollectionId = foreignCollection.id;
|
|
|
|
await ctx.db.insert(schema.collectionBooks).values({ collectionId: foreignCollectionId, bookId: visibleBookAlpha.bookId });
|
|
|
|
const [ownerSmartScope] = await ctx.db
|
|
.insert(schema.smartScopes)
|
|
.values({
|
|
userId: owner.userId,
|
|
name: `owner-smartScope-${randomUUID().slice(0, 8)}`,
|
|
filter: null,
|
|
isPublic: false,
|
|
})
|
|
.returning({ id: schema.smartScopes.id });
|
|
ownerSmartScopeId = ownerSmartScope.id;
|
|
|
|
const [foreignSmartScope] = await ctx.db
|
|
.insert(schema.smartScopes)
|
|
.values({
|
|
userId: intruder.userId,
|
|
name: `foreign-smartScope-${randomUUID().slice(0, 8)}`,
|
|
filter: null,
|
|
isPublic: false,
|
|
})
|
|
.returning({ id: schema.smartScopes.id });
|
|
foreignSmartScopeId = foreignSmartScope.id;
|
|
|
|
await setSetting(ctx, 'opds_enabled', 'true');
|
|
}, 120_000);
|
|
|
|
beforeEach(async () => {
|
|
scenarioStartedAt = Date.now();
|
|
await setSetting(ctx, 'opds_enabled', 'true');
|
|
});
|
|
|
|
afterEach((taskContext) => {
|
|
const result = taskContext.task.result;
|
|
if (!result) return;
|
|
|
|
const state = result.state === 'pass' ? 'passed' : 'failed';
|
|
const error = result.errors?.[0]?.message;
|
|
scenarioResults.push({
|
|
id: taskContext.task.name,
|
|
status: state,
|
|
durationMs: Math.max(0, Date.now() - scenarioStartedAt),
|
|
...(error ? { error } : {}),
|
|
});
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await writeScenarioReport(scenarioResults);
|
|
if (ctx) {
|
|
await closeOpdsE2EContext(ctx);
|
|
}
|
|
});
|
|
|
|
describe('opds-users lifecycle contract', () => {
|
|
it('supports create list update delete and enforces owner boundaries', async () => {
|
|
const username = `opds-lifecycle-${randomUUID().slice(0, 8)}`;
|
|
const createResponse = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/opds-users',
|
|
headers: authHeader(owner.accessToken),
|
|
payload: {
|
|
username,
|
|
password: 'LifecyclePass123',
|
|
sortOrder: 'recent',
|
|
},
|
|
});
|
|
expect(createResponse.statusCode).toBe(201);
|
|
const created = createResponse.json() as { id: number; username: string; sortOrder: string };
|
|
expect(created).toMatchObject({ username, sortOrder: 'recent' });
|
|
|
|
const listResponse = await ctx.app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/opds-users',
|
|
headers: authHeader(owner.accessToken),
|
|
});
|
|
expect(listResponse.statusCode).toBe(200);
|
|
expect(listResponse.json()).toEqual(expect.arrayContaining([expect.objectContaining({ id: created.id, username })]));
|
|
|
|
const updateResponse = await ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/v1/opds-users/${created.id}`,
|
|
headers: authHeader(owner.accessToken),
|
|
payload: { sortOrder: 'title_asc' },
|
|
});
|
|
expect(updateResponse.statusCode).toBe(200);
|
|
expect(updateResponse.json()).toEqual(expect.objectContaining({ id: created.id, sortOrder: 'title_asc' }));
|
|
|
|
const foreignUpdateResponse = await ctx.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/v1/opds-users/${created.id}`,
|
|
headers: authHeader(intruder.accessToken),
|
|
payload: { sortOrder: 'title_desc' },
|
|
});
|
|
expectError(foreignUpdateResponse, 403, 'Not the owner');
|
|
|
|
const foreignDeleteResponse = await ctx.app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/v1/opds-users/${created.id}`,
|
|
headers: authHeader(intruder.accessToken),
|
|
});
|
|
expectError(foreignDeleteResponse, 403, 'Not the owner');
|
|
|
|
const deleteResponse = await ctx.app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/v1/opds-users/${created.id}`,
|
|
headers: authHeader(owner.accessToken),
|
|
});
|
|
expect(deleteResponse.statusCode).toBe(204);
|
|
|
|
const afterDeleteListResponse = await ctx.app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/opds-users',
|
|
headers: authHeader(owner.accessToken),
|
|
});
|
|
expect(afterDeleteListResponse.statusCode).toBe(200);
|
|
expect(afterDeleteListResponse.json()).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: created.id })]));
|
|
});
|
|
|
|
it('requires opds_access permission for opds-users management', async () => {
|
|
const response = await ctx.app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/opds-users',
|
|
headers: authHeader(noOpdsPermissionUser.accessToken),
|
|
});
|
|
expect(response.statusCode).toBe(403);
|
|
});
|
|
|
|
it('returns conflict for duplicate OPDS username', async () => {
|
|
const response = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/opds-users',
|
|
headers: authHeader(owner.accessToken),
|
|
payload: {
|
|
username: ownerCredentials.username,
|
|
password: 'DuplicatePass123',
|
|
},
|
|
});
|
|
expectError(response, 409, 'already exists');
|
|
});
|
|
});
|
|
|
|
describe('basic auth and guard behavior', () => {
|
|
it('rejects all OPDS requests when OPDS is disabled', async () => {
|
|
await setSetting(ctx, 'opds_enabled', 'false');
|
|
|
|
const response = await opdsGet('/api/v1/opds/libraries', ownerCredentials);
|
|
expectError(response, 403, 'disabled');
|
|
});
|
|
|
|
it('requires Basic auth and returns WWW-Authenticate challenge', async () => {
|
|
const response = await opdsGet('/api/v1/opds/libraries');
|
|
expectError(response, 401, 'Basic authentication required');
|
|
expect(response.headers['www-authenticate']).toContain('Basic realm=');
|
|
});
|
|
|
|
it('rejects invalid Basic credentials with WWW-Authenticate challenge', async () => {
|
|
const response = await opdsGet('/api/v1/opds/libraries', {
|
|
username: ownerCredentials.username,
|
|
password: 'WrongPassword123',
|
|
});
|
|
expectError(response, 401, 'Invalid credentials');
|
|
expect(response.headers['www-authenticate']).toContain('Basic realm=');
|
|
});
|
|
|
|
it('rejects OPDS auth for disabled parent users', async () => {
|
|
const response = await opdsGet('/api/v1/opds/libraries', disabledCredentials);
|
|
expectError(response, 401, 'disabled');
|
|
});
|
|
|
|
it('rejects OPDS auth when parent opds_access is revoked', async () => {
|
|
const response = await opdsGet('/api/v1/opds/libraries', revokedCredentials);
|
|
expectError(response, 403, 'OPDS access revoked');
|
|
});
|
|
|
|
it('accepts valid Basic credentials and serves OPDS XML', async () => {
|
|
const response = await opdsGet('/api/v1/opds', ownerCredentials);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.headers['content-type']).toContain('application/atom+xml;profile=opds-catalog');
|
|
});
|
|
|
|
it('rejects token auth on non-image routes with Basic challenge', async () => {
|
|
const jwtSecret = ctx.app.get(ConfigService).getOrThrow<string>('auth.jwtSecret');
|
|
const token = createCoverToken(owner.userId, jwtSecret);
|
|
|
|
const response = await opdsGet(`/api/v1/opds/catalog?t=${token}`);
|
|
expectError(response, 401, 'Basic authentication required');
|
|
expect(response.headers['www-authenticate']).toContain('Basic realm=');
|
|
});
|
|
|
|
it('rejects invalid token auth on image routes', async () => {
|
|
const response = await opdsGet(`/api/v1/opds/${visibleBookAlpha.bookId}/cover?t=not-a-valid-token`);
|
|
expectError(response, 401, 'Invalid token');
|
|
});
|
|
});
|
|
|
|
describe('catalog/feed contract', () => {
|
|
it('serves root navigation and search description with expected MIME and links', async () => {
|
|
const rootResponse = await opdsGet('/api/v1/opds', ownerCredentials);
|
|
expect(rootResponse.statusCode).toBe(200);
|
|
expect(rootResponse.headers['content-type']).toContain('application/atom+xml;profile=opds-catalog');
|
|
expect(rootResponse.body).toContain('/api/v1/opds/catalog');
|
|
expect(rootResponse.body).toContain('/api/v1/opds/recent');
|
|
expect(rootResponse.body).toContain('/api/v1/opds/surprise');
|
|
expect(rootResponse.body).toContain('/api/v1/opds/libraries');
|
|
expect(rootResponse.body).toContain('/api/v1/opds/search.opds');
|
|
|
|
const searchResponse = await opdsGet('/api/v1/opds/search.opds', ownerCredentials);
|
|
expect(searchResponse.statusCode).toBe(200);
|
|
expect(searchResponse.headers['content-type']).toContain('application/opensearchdescription+xml');
|
|
expect(searchResponse.body).toContain('/api/v1/opds/catalog?q={searchTerms}');
|
|
});
|
|
|
|
// Clients that skip OpenSearch discovery need the templated link inline, on every
|
|
// feed they can land on, or they show no search affordance at all (issue #860).
|
|
it('advertises the inline templated search link on every navigable feed', async () => {
|
|
const paths = ['/api/v1/opds', '/api/v1/opds/libraries', '/api/v1/opds/collections', '/api/v1/opds/smart-scopes'];
|
|
const acquisitionPaths = ['/api/v1/opds/authors', '/api/v1/opds/series', '/api/v1/opds/catalog', '/api/v1/opds/recent'];
|
|
|
|
for (const path of [...paths, ...acquisitionPaths]) {
|
|
const response = await opdsGet(path, ownerCredentials);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.body).toContain(
|
|
'<link rel="search" href="/api/v1/opds/catalog?q={searchTerms}" type="application/atom+xml" title="Search"/>',
|
|
);
|
|
}
|
|
});
|
|
|
|
it('scopes libraries, collections, smartScopes, authors, and series to the authenticated parent user', async () => {
|
|
const librariesResponse = await opdsGet('/api/v1/opds/libraries', ownerCredentials);
|
|
expect(librariesResponse.statusCode).toBe(200);
|
|
expect(librariesResponse.body).toContain(`libraryId=${visibleLibrary.libraryId}`);
|
|
expect(librariesResponse.body).not.toContain(`libraryId=${hiddenLibrary.libraryId}`);
|
|
|
|
const collectionsResponse = await opdsGet('/api/v1/opds/collections', ownerCredentials);
|
|
expect(collectionsResponse.statusCode).toBe(200);
|
|
expect(collectionsResponse.body).toContain(`collectionId=${ownerCollectionId}`);
|
|
expect(collectionsResponse.body).not.toContain(`collectionId=${foreignCollectionId}`);
|
|
|
|
const smartScopesResponse = await opdsGet('/api/v1/opds/smart-scopes', ownerCredentials);
|
|
expect(smartScopesResponse.statusCode).toBe(200);
|
|
expect(smartScopesResponse.body).toContain(`smartScopeId=${ownerSmartScopeId}`);
|
|
expect(smartScopesResponse.body).not.toContain(`smartScopeId=${foreignSmartScopeId}`);
|
|
|
|
const authorsResponse = await opdsGet('/api/v1/opds/authors', ownerCredentials);
|
|
expect(authorsResponse.statusCode).toBe(200);
|
|
expect(authorsResponse.body).toContain(visibleAuthorName);
|
|
expect(authorsResponse.body).not.toContain(hiddenAuthorName);
|
|
|
|
const seriesResponse = await opdsGet('/api/v1/opds/series', ownerCredentials);
|
|
expect(seriesResponse.statusCode).toBe(200);
|
|
expect(seriesResponse.body).toContain(visibleSeriesName);
|
|
expect(seriesResponse.body).not.toContain(hiddenSeriesName);
|
|
});
|
|
|
|
it('enforces catalog pagination, filters, and strict query validation', async () => {
|
|
const page1Response = await opdsGet('/api/v1/opds/catalog?page=1&size=1', ownerCredentials);
|
|
expect(page1Response.statusCode).toBe(200);
|
|
expect(page1Response.headers['content-type']).toContain('application/atom+xml;profile=opds-catalog');
|
|
expect(page1Response.body).toContain('<opensearch:totalResults>2</opensearch:totalResults>');
|
|
expect(page1Response.body).toContain('rel="next"');
|
|
expect(page1Response.body).not.toContain('rel="previous"');
|
|
|
|
const page2Response = await opdsGet('/api/v1/opds/catalog?page=2&size=1', ownerCredentials);
|
|
expect(page2Response.statusCode).toBe(200);
|
|
expect(page2Response.body).toContain('rel="previous"');
|
|
|
|
const ownerLibraryResponse = await opdsGet(`/api/v1/opds/catalog?libraryId=${visibleLibrary.libraryId}`, ownerCredentials);
|
|
expect(ownerLibraryResponse.statusCode).toBe(200);
|
|
expect(ownerLibraryResponse.body).toContain('Visible Alpha');
|
|
|
|
const forbiddenLibraryResponse = await opdsGet(`/api/v1/opds/catalog?libraryId=${hiddenLibrary.libraryId}`, ownerCredentials);
|
|
expectError(forbiddenLibraryResponse, 403, 'No access to this library');
|
|
|
|
const invalidLibraryFilterResponse = await opdsGet('/api/v1/opds/catalog?libraryId=abc', ownerCredentials);
|
|
expectError(invalidLibraryFilterResponse, 400, 'libraryId must be a positive integer');
|
|
|
|
const invalidCollectionFilterResponse = await opdsGet('/api/v1/opds/catalog?collectionId=oops', ownerCredentials);
|
|
expectError(invalidCollectionFilterResponse, 400, 'collectionId must be a positive integer');
|
|
|
|
const invalidSmartScopeFilterResponse = await opdsGet('/api/v1/opds/catalog?smartScopeId=bad', ownerCredentials);
|
|
expectError(invalidSmartScopeFilterResponse, 400, 'smartScopeId must be a positive integer');
|
|
|
|
const foreignCollectionResponse = await opdsGet(`/api/v1/opds/catalog?collectionId=${foreignCollectionId}`, ownerCredentials);
|
|
expectError(foreignCollectionResponse, 403, 'No access to this collection');
|
|
|
|
const ownerCollectionResponse = await opdsGet(`/api/v1/opds/catalog?collectionId=${ownerCollectionId}`, ownerCredentials);
|
|
expect(ownerCollectionResponse.statusCode).toBe(200);
|
|
expect(ownerCollectionResponse.body).toContain('Visible Alpha');
|
|
|
|
const ownerSmartScopeResponse = await opdsGet(`/api/v1/opds/catalog?smartScopeId=${ownerSmartScopeId}`, ownerCredentials);
|
|
expect(ownerSmartScopeResponse.statusCode).toBe(200);
|
|
expect(ownerSmartScopeResponse.body).toContain('<opensearch:totalResults>2</opensearch:totalResults>');
|
|
|
|
const foreignSmartScopeResponse = await opdsGet(`/api/v1/opds/catalog?smartScopeId=${foreignSmartScopeId}`, ownerCredentials);
|
|
expect(foreignSmartScopeResponse.statusCode).toBe(200);
|
|
expect(foreignSmartScopeResponse.body).toContain('<opensearch:totalResults>0</opensearch:totalResults>');
|
|
|
|
const searchResponse = await opdsGet('/api/v1/opds/catalog?q=Visible Alpha', ownerCredentials);
|
|
expect(searchResponse.statusCode).toBe(200);
|
|
expect(searchResponse.body).toContain('Visible Alpha');
|
|
expect(searchResponse.body).not.toContain('Visible Beta');
|
|
|
|
const authorSearchResponse = await opdsGet(`/api/v1/opds/catalog?q=${encodeURIComponent(visibleAuthorName)}`, ownerCredentials);
|
|
expect(authorSearchResponse.statusCode).toBe(200);
|
|
expect(authorSearchResponse.body).toContain('Visible Alpha');
|
|
expect(authorSearchResponse.body).toContain('Visible Beta');
|
|
expect(authorSearchResponse.body).not.toContain('Hidden Gamma');
|
|
|
|
const seriesSearchResponse = await opdsGet(`/api/v1/opds/catalog?q=${encodeURIComponent(visibleSeriesName)}`, ownerCredentials);
|
|
expect(seriesSearchResponse.statusCode).toBe(200);
|
|
expect(seriesSearchResponse.body).toContain('Visible Alpha');
|
|
expect(seriesSearchResponse.body).toContain('Visible Beta');
|
|
expect(seriesSearchResponse.body).not.toContain('Hidden Gamma');
|
|
|
|
const isbnSearchResponse = await opdsGet('/api/v1/opds/catalog?q=978-0%20141187761', ownerCredentials);
|
|
expect(isbnSearchResponse.statusCode).toBe(200);
|
|
expect(isbnSearchResponse.body).toContain('Visible Alpha');
|
|
expect(isbnSearchResponse.body).not.toContain('Visible Beta');
|
|
expect(isbnSearchResponse.body).not.toContain('Hidden Gamma');
|
|
});
|
|
});
|
|
|
|
describe('tokenized image access and download behavior', () => {
|
|
it('allows feed-issued token access for cover and thumbnail with ETag handling', async () => {
|
|
const catalogResponse = await opdsGet('/api/v1/opds/catalog?page=1&size=10', ownerCredentials);
|
|
expect(catalogResponse.statusCode).toBe(200);
|
|
const coverToken = extractCoverToken(catalogResponse.body, visibleBookAlpha.bookId);
|
|
|
|
const coverResponse = await opdsGet(`/api/v1/opds/${visibleBookAlpha.bookId}/cover?t=${encodeURIComponent(coverToken)}`);
|
|
expect(coverResponse.statusCode).toBe(200);
|
|
expect(coverResponse.headers['content-type']).toContain('image/jpeg');
|
|
expect(coverResponse.headers.etag).toBeTruthy();
|
|
|
|
const notModifiedCoverResponse = await ctx.app.inject({
|
|
method: 'GET',
|
|
url: `/api/v1/opds/${visibleBookAlpha.bookId}/cover?t=${encodeURIComponent(coverToken)}`,
|
|
headers: {
|
|
'if-none-match': String(coverResponse.headers.etag),
|
|
},
|
|
});
|
|
expect(notModifiedCoverResponse.statusCode).toBe(304);
|
|
|
|
const thumbnailResponse = await opdsGet(`/api/v1/opds/${visibleBookAlpha.bookId}/thumbnail?t=${encodeURIComponent(coverToken)}`);
|
|
expect(thumbnailResponse.statusCode).toBe(200);
|
|
expect(thumbnailResponse.headers['content-type']).toContain('image/jpeg');
|
|
expect(thumbnailResponse.headers.etag).toBeTruthy();
|
|
});
|
|
|
|
it('keeps tokenized image access scoped to the parent user library access', async () => {
|
|
const catalogResponse = await opdsGet('/api/v1/opds/catalog?page=1&size=10', ownerCredentials);
|
|
expect(catalogResponse.statusCode).toBe(200);
|
|
const coverToken = extractCoverToken(catalogResponse.body, visibleBookAlpha.bookId);
|
|
|
|
const hiddenCoverResponse = await opdsGet(`/api/v1/opds/${hiddenBook.bookId}/cover?t=${encodeURIComponent(coverToken)}`);
|
|
expectError(hiddenCoverResponse, 403, 'No access to this book');
|
|
});
|
|
|
|
it('requires Basic auth for downloads and enforces file/book access boundaries', async () => {
|
|
const catalogResponse = await opdsGet('/api/v1/opds/catalog?page=1&size=10', ownerCredentials);
|
|
expect(catalogResponse.statusCode).toBe(200);
|
|
const coverToken = extractCoverToken(catalogResponse.body, visibleBookAlpha.bookId);
|
|
|
|
const tokenDownloadResponse = await opdsGet(`/api/v1/opds/${visibleBookAlpha.bookId}/download?t=${encodeURIComponent(coverToken)}`);
|
|
expectError(tokenDownloadResponse, 401, 'Basic authentication required');
|
|
expect(tokenDownloadResponse.headers['www-authenticate']).toContain('Basic realm=');
|
|
|
|
const validDownloadResponse = await opdsGet(
|
|
`/api/v1/opds/${visibleBookAlpha.bookId}/download?fileId=${visibleBookAlpha.bookFileId}`,
|
|
ownerCredentials,
|
|
);
|
|
expect(validDownloadResponse.statusCode).toBe(200);
|
|
expect(validDownloadResponse.headers['content-type']).toContain('application/epub+zip');
|
|
expect(validDownloadResponse.headers['content-disposition']).toContain('attachment;');
|
|
|
|
const badFileIdResponse = await opdsGet(`/api/v1/opds/${visibleBookAlpha.bookId}/download?fileId=999999`, ownerCredentials);
|
|
expectError(badFileIdResponse, 404, 'File not found');
|
|
|
|
const forbiddenBookDownloadResponse = await opdsGet(
|
|
`/api/v1/opds/${hiddenBook.bookId}/download?fileId=${hiddenBook.bookFileId}`,
|
|
ownerCredentials,
|
|
);
|
|
expectError(forbiddenBookDownloadResponse, 403, 'No access to this book');
|
|
});
|
|
|
|
it('exposes every content file for multi-format books', async () => {
|
|
const catalogResponse = await opdsGet('/api/v1/opds/catalog?page=1&size=10', ownerCredentials);
|
|
|
|
expect(catalogResponse.statusCode).toBe(200);
|
|
expect(catalogResponse.body).toContain('Visible Alpha');
|
|
|
|
const expectedLinks = [
|
|
{ fileId: visibleBookAlpha.bookFileId, mime: 'application/epub+zip', title: 'EPUB' },
|
|
{ fileId: visibleAlphaAlternativeFileIds.mobi, mime: 'application/x-mobipocket-ebook', title: 'MOBI' },
|
|
{ fileId: visibleAlphaAlternativeFileIds.azw3, mime: 'application/vnd.amazon.ebook', title: 'AZW3' },
|
|
{ fileId: visibleAlphaAlternativeFileIds.fb2, mime: 'application/x-fictionbook+xml', title: 'FB2' },
|
|
];
|
|
|
|
for (const link of expectedLinks) {
|
|
expect(catalogResponse.body).toContain(`/api/v1/opds/${visibleBookAlpha.bookId}/download?fileId=${link.fileId}`);
|
|
expect(catalogResponse.body).toContain(`type="${link.mime}"`);
|
|
expect(catalogResponse.body).toContain(`title="${link.title}"`);
|
|
}
|
|
|
|
const mobiDownloadResponse = await opdsGet(
|
|
`/api/v1/opds/${visibleBookAlpha.bookId}/download?fileId=${visibleAlphaAlternativeFileIds.mobi}`,
|
|
ownerCredentials,
|
|
);
|
|
expect(mobiDownloadResponse.statusCode).toBe(200);
|
|
expect(mobiDownloadResponse.headers['content-type']).toContain('application/x-mobipocket-ebook');
|
|
expect(mobiDownloadResponse.headers['content-disposition']).toBe(
|
|
'attachment; filename="visible-alpha.mobi"; filename*=UTF-8\'\'visible-alpha.mobi',
|
|
);
|
|
});
|
|
|
|
it('exposes and downloads raw FB2 acquisition files', async () => {
|
|
const downloadHref = `/api/v1/opds/${rawFb2Book.bookId}/download?fileId=${rawFb2Book.bookFileId}`;
|
|
const catalogResponse = await opdsGet('/api/v1/opds/catalog?page=1&size=10', fb2Credentials);
|
|
|
|
expect(catalogResponse.statusCode).toBe(200);
|
|
expect(catalogResponse.body).toContain(rawFb2Title);
|
|
expect(catalogResponse.body).toContain('rel="http://opds-spec.org/acquisition"');
|
|
expect(catalogResponse.body).toContain(`href="${downloadHref}"`);
|
|
expect(catalogResponse.body).toContain('type="application/x-fictionbook+xml"');
|
|
expect(catalogResponse.body).toContain('title="FB2"');
|
|
|
|
const downloadResponse = await opdsGet(downloadHref, fb2Credentials);
|
|
expect(downloadResponse.statusCode).toBe(200);
|
|
expect(downloadResponse.headers['content-type']).toContain('application/x-fictionbook+xml');
|
|
expect(downloadResponse.headers['content-disposition']).toContain('attachment;');
|
|
expect(downloadResponse.headers['content-disposition']).toBe('attachment; filename="visible-raw.fb2"; filename*=UTF-8\'\'visible-raw.fb2');
|
|
expect(downloadResponse.body).toBe(rawFb2FixtureContent);
|
|
});
|
|
});
|
|
|
|
async function attachContentFileToBook(
|
|
bookId: number,
|
|
library: CreatedLibrary,
|
|
absolutePath: string,
|
|
format: string,
|
|
sortOrder: number,
|
|
): Promise<number> {
|
|
const fileStat = await stat(absolutePath);
|
|
const [file] = await ctx.db
|
|
.insert(schema.bookFiles)
|
|
.values({
|
|
bookId,
|
|
libraryFolderId: library.libraryFolderId,
|
|
absolutePath,
|
|
relPath: relative(library.folderPath, absolutePath),
|
|
ino: fileStat.ino,
|
|
sizeBytes: fileStat.size,
|
|
mtime: fileStat.mtime,
|
|
format,
|
|
role: 'content',
|
|
sortOrder,
|
|
})
|
|
.returning({ id: schema.bookFiles.id });
|
|
|
|
if (!file) throw new Error(`Failed to attach ${format} file to book ${bookId}`);
|
|
return file.id;
|
|
}
|
|
|
|
async function seedBookMetadata(bookId: number, title: string, seriesName: string, seriesIndex: number, isbn13?: string): Promise<void> {
|
|
const normalizedName = normalizeMetadataTextKey(seriesName);
|
|
if (!normalizedName) throw new Error(`Series normalization failed for ${seriesName}`);
|
|
|
|
const [series] = await ctx.db
|
|
.insert(schema.bookSeries)
|
|
.values({ name: seriesName, normalizedName })
|
|
.onConflictDoUpdate({
|
|
target: schema.bookSeries.normalizedName,
|
|
set: { name: seriesName, updatedAt: new Date() },
|
|
})
|
|
.returning({ id: schema.bookSeries.id });
|
|
if (!series) throw new Error(`Series upsert failed for ${seriesName}`);
|
|
|
|
const values = { bookId, title, seriesId: series.id, seriesName, seriesIndex, ...(isbn13 !== undefined ? { isbn13 } : {}) };
|
|
const set = { title, seriesId: series.id, seriesName, seriesIndex, ...(isbn13 !== undefined ? { isbn13 } : {}) };
|
|
|
|
await ctx.db.insert(schema.bookMetadata).values(values).onConflictDoUpdate({
|
|
target: schema.bookMetadata.bookId,
|
|
set,
|
|
});
|
|
|
|
await ctx.db
|
|
.insert(schema.bookSeriesMemberships)
|
|
.values({ bookId, seriesId: series.id, seriesIndex, displayOrder: 0 })
|
|
.onConflictDoUpdate({
|
|
target: [schema.bookSeriesMemberships.bookId, schema.bookSeriesMemberships.seriesId],
|
|
set: { seriesIndex, displayOrder: 0, updatedAt: new Date() },
|
|
});
|
|
}
|
|
|
|
async function linkBookAuthor(bookId: number, authorName: string): Promise<void> {
|
|
const inserted = await ctx.db.insert(schema.authors).values({ name: authorName }).onConflictDoNothing().returning({ id: schema.authors.id });
|
|
|
|
let authorId = inserted[0]?.id;
|
|
if (!authorId) {
|
|
const [existing] = await ctx.db.select({ id: schema.authors.id }).from(schema.authors).where(eq(schema.authors.name, authorName)).limit(1);
|
|
if (!existing) throw new Error(`Author lookup failed for ${authorName}`);
|
|
authorId = existing.id;
|
|
}
|
|
|
|
await ctx.db.insert(schema.bookAuthors).values({ bookId, authorId, displayOrder: 0 }).onConflictDoNothing();
|
|
}
|
|
|
|
async function opdsGet(path: string, credentials?: OpdsCredentials) {
|
|
return ctx.app.inject({
|
|
method: 'GET',
|
|
url: path,
|
|
headers: credentials ? { authorization: basicAuth(credentials.username, credentials.password) } : undefined,
|
|
});
|
|
}
|
|
|
|
function extractCoverToken(feedXml: string, bookId: number): string {
|
|
const tokenMatch = feedXml.match(new RegExp(`/api/v1/opds/${bookId}/cover\\?t=([^"&<]+)`));
|
|
if (!tokenMatch?.[1]) {
|
|
throw new Error(`Expected to find feed-issued cover token for book ${bookId}`);
|
|
}
|
|
return decodeURIComponent(tokenMatch[1]);
|
|
}
|
|
|
|
function expectError(response: Awaited<ReturnType<OpdsE2EContext['app']['inject']>>, statusCode: number, messageFragment: string): void {
|
|
expect(response.statusCode).toBe(statusCode);
|
|
const body = parseBody(response);
|
|
if (body && typeof body === 'object') {
|
|
expect(body).toEqual(
|
|
expect.objectContaining({
|
|
statusCode,
|
|
}),
|
|
);
|
|
}
|
|
expect(extractMessage(response)).toContain(messageFragment);
|
|
}
|
|
|
|
function parseBody(response: Awaited<ReturnType<OpdsE2EContext['app']['inject']>>): Record<string, unknown> | null {
|
|
try {
|
|
return response.json() as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function extractMessage(response: Awaited<ReturnType<OpdsE2EContext['app']['inject']>>): string {
|
|
const body = parseBody(response);
|
|
if (!body) return response.body;
|
|
const message = body.message;
|
|
if (typeof message === 'string') return message;
|
|
if (Array.isArray(message)) {
|
|
return message.filter((item): item is string => typeof item === 'string').join('; ');
|
|
}
|
|
return '';
|
|
}
|
|
});
|