mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
019c432262
Align collection visibility across OPDS and KOReader with the web catalog. Count only books the viewer can access in one aggregate query, and cover public access, private revocation, and filtered counts with regression tests. Closes #1701
913 lines
42 KiB
TypeScript
913 lines
42 KiB
TypeScript
import { randomUUID } from 'crypto';
|
|
import { mkdir, readFile, stat, writeFile } from 'fs/promises';
|
|
import { dirname, join, relative } from 'path';
|
|
|
|
import { eq, inArray } 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('exposes public collections across OPDS and KOReader with viewer-scoped counts and access', async () => {
|
|
const reader = await createUserAndLogin(ctx, { permissions: [Permission.OpdsAccess, Permission.KoreaderSync] });
|
|
const noLibraryReader = await createUserAndLogin(ctx, { permissions: [Permission.OpdsAccess] });
|
|
const readerOpds = await createOpdsUserCredential(ctx, { userId: reader.userId });
|
|
const noLibraryOpds = await createOpdsUserCredential(ctx, { userId: noLibraryReader.userId });
|
|
const credentials = { username: readerOpds.row.username, password: readerOpds.password };
|
|
const koreaderHeaders = { 'x-auth-user': reader.username, 'x-auth-key': reader.password };
|
|
await grantLibraryAccess(ctx, reader.userId, visibleLibrary.libraryId);
|
|
const credentialResponse = await ctx.app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/koreader/credentials',
|
|
headers: authHeader(reader.accessToken),
|
|
payload: { username: reader.username, password: reader.password },
|
|
});
|
|
expect([200, 201]).toContain(credentialResponse.statusCode);
|
|
|
|
const [excludedTag] = await ctx.db
|
|
.insert(schema.tags)
|
|
.values({ name: `opds-excluded-${randomUUID()}` })
|
|
.returning();
|
|
await ctx.db.insert(schema.bookTags).values({ bookId: visibleBookBeta.bookId, tagId: excludedTag.id });
|
|
await ctx.db.insert(schema.userContentFilterTags).values({ userId: reader.userId, tagId: excludedTag.id, filterType: 'exclude' });
|
|
const unavailableBooks = await ctx.db
|
|
.insert(schema.books)
|
|
.values(
|
|
(['missing', 'processing'] as const).map((status) => ({
|
|
libraryId: visibleLibrary.libraryId,
|
|
libraryFolderId: visibleLibrary.libraryFolderId,
|
|
folderPath: join(visibleLibrary.folderPath, `unavailable-${status}-${randomUUID()}`),
|
|
status,
|
|
})),
|
|
)
|
|
.returning({ id: schema.books.id });
|
|
const [publicCollection, ownCollection, emptyCollection, podcastCollection] = await ctx.db
|
|
.insert(schema.collections)
|
|
.values([
|
|
{ userId: intruder.userId, name: `public-collection-${randomUUID()}`, isPublic: true },
|
|
{ userId: reader.userId, name: `private-own-${randomUUID()}` },
|
|
{ userId: intruder.userId, name: `public-empty-${randomUUID()}`, isPublic: true },
|
|
{ userId: intruder.userId, name: `public-podcast-${randomUUID()}`, isPublic: true, mediaType: 'podcasts' as const },
|
|
])
|
|
.returning();
|
|
await ctx.db.insert(schema.collectionBooks).values(
|
|
[visibleBookAlpha.bookId, visibleBookBeta.bookId, hiddenBook.bookId, ...unavailableBooks.map((book) => book.id)].map((bookId) => ({
|
|
collectionId: publicCollection.id,
|
|
bookId,
|
|
})),
|
|
);
|
|
|
|
const koreaderGet = (url: string) => ctx.app.inject({ method: 'GET', url, headers: koreaderHeaders });
|
|
try {
|
|
const web = await ctx.app.inject({ method: 'GET', url: '/api/v1/collections', headers: authHeader(reader.accessToken) });
|
|
expect(web.statusCode).toBe(200);
|
|
expect(web.json()).toEqual(expect.arrayContaining([expect.objectContaining({ id: publicCollection.id })]));
|
|
|
|
const navigation = await opdsGet('/api/v1/opds/collections', credentials);
|
|
expect(navigation.statusCode).toBe(200);
|
|
expect(navigation.body).toContain(`collectionId=${publicCollection.id}`);
|
|
expect(navigation.body).toContain('<content type="text">1 books</content>');
|
|
expect(navigation.body).not.toContain(`collectionId=${foreignCollectionId}`);
|
|
expect(navigation.body).not.toContain(`collectionId=${podcastCollection.id}`);
|
|
|
|
const section = await koreaderGet('/api/v1/koreader/plugin/catalog/sections/collections');
|
|
expect(section.statusCode).toBe(200);
|
|
expect(section.json().items).toEqual([
|
|
expect.objectContaining({ id: String(ownCollection.id), count: 0 }),
|
|
expect.objectContaining({ id: String(publicCollection.id), count: 1 }),
|
|
expect.objectContaining({ id: String(emptyCollection.id), count: 0 }),
|
|
]);
|
|
const dashboard = await koreaderGet('/api/v1/koreader/plugin/catalog/dashboard');
|
|
expect(dashboard.statusCode).toBe(200);
|
|
expect(dashboard.json().browseCounts.collections).toBe(3);
|
|
|
|
const catalog = await opdsGet(`/api/v1/opds/catalog?collectionId=${publicCollection.id}`, credentials);
|
|
expect(catalog.statusCode).toBe(200);
|
|
expect(catalog.body).toContain('<opensearch:totalResults>1</opensearch:totalResults>');
|
|
expect(catalog.body).toContain('Visible Alpha');
|
|
expect(catalog.body).not.toContain('Visible Beta');
|
|
expect(catalog.body).not.toContain('Hidden Gamma');
|
|
for (const route of ['books', 'manifest']) {
|
|
const response = await koreaderGet(`/api/v1/koreader/plugin/catalog/${route}?collectionId=${publicCollection.id}`);
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json().items.map((book: { id: number }) => book.id)).toEqual([visibleBookAlpha.bookId]);
|
|
}
|
|
const privateCatalog = await koreaderGet(`/api/v1/koreader/plugin/catalog/books?collectionId=${foreignCollectionId}`);
|
|
expect(privateCatalog.statusCode).toBe(403);
|
|
const podcastCatalog = await opdsGet(`/api/v1/opds/catalog?collectionId=${podcastCollection.id}`, credentials);
|
|
expect(podcastCatalog.statusCode).toBe(403);
|
|
|
|
const emptyNavigation = await opdsGet('/api/v1/opds/collections', {
|
|
username: noLibraryOpds.row.username,
|
|
password: noLibraryOpds.password,
|
|
});
|
|
expect(emptyNavigation.statusCode).toBe(200);
|
|
expect(emptyNavigation.body).toContain(`collectionId=${publicCollection.id}`);
|
|
expect(emptyNavigation.body).toContain('<content type="text">0 books</content>');
|
|
expect(emptyNavigation.body).not.toMatch(/<content type="text">[1-9]\d* books<\/content>/);
|
|
|
|
await ctx.db.update(schema.collections).set({ isPublic: false }).where(eq(schema.collections.id, publicCollection.id));
|
|
const revokedNavigation = await opdsGet('/api/v1/opds/collections', credentials);
|
|
expect(revokedNavigation.body).not.toContain(`collectionId=${publicCollection.id}`);
|
|
const revokedCatalog = await opdsGet(`/api/v1/opds/catalog?collectionId=${publicCollection.id}`, credentials);
|
|
expect(revokedCatalog.statusCode).toBe(403);
|
|
const revokedManifest = await koreaderGet(`/api/v1/koreader/plugin/catalog/manifest?collectionId=${publicCollection.id}`);
|
|
expect(revokedManifest.statusCode).toBe(403);
|
|
const revokedDashboard = await koreaderGet('/api/v1/koreader/plugin/catalog/dashboard');
|
|
expect(revokedDashboard.json().browseCounts.collections).toBe(2);
|
|
} finally {
|
|
await ctx.db
|
|
.delete(schema.collections)
|
|
.where(inArray(schema.collections.id, [publicCollection.id, ownCollection.id, emptyCollection.id, podcastCollection.id]));
|
|
await ctx.db.delete(schema.books).where(
|
|
inArray(
|
|
schema.books.id,
|
|
unavailableBooks.map((book) => book.id),
|
|
),
|
|
);
|
|
await ctx.db.delete(schema.tags).where(eq(schema.tags.id, excludedTag.id));
|
|
await ctx.db.delete(schema.users).where(inArray(schema.users.id, [reader.userId, noLibraryReader.userId]));
|
|
}
|
|
});
|
|
|
|
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 '';
|
|
}
|
|
});
|