Files
bookorbit/server/test/reader-format-delivery.e2e-spec.ts
T
neon 198d36ae56 fix(reader): serve PDF and comic files correctly to every client
Fix the server and web gaps found while porting the PDF and comic
readers and offline downloads to Android, checked against the iOS app:

- KOReader: answer a pull for a PDF or comic with the page number when a
  BookOrbit client wrote last, instead of a percentage with no page
- comics: key the zip offset, RAR and CB7 caches by file id and
  modification time, so pages are not served from stale offsets after a
  metadata write-back rewrites the archive
- comics: answer an unreadable archive with 422 rather than a page count
  of 0, and extract CB7 with its folders so same-named pages in
  different folders no longer overwrite each other
- book files: send an ETag and Last-Modified on serve and download and
  honour If-None-Match and If-Range, clamp a range that runs past the
  end, answer suffix ranges with 206, and honour Range on download,
  which advertised it but sent the whole file
- book files: leave raw file routes uncompressed, so FB2 and unknown
  formats keep their Content-Length
- progress: reject a fractional or negative page number with 400 rather
  than failing with 500 at the database
- types: declare the comic page-count response shared by the web and
  the apps
- web: open a percentage-only PDF position at its estimated page, and
  flush pending PDF and comic progress before the reading session ends
2026-10-13 16:33:11 +00:00

422 lines
20 KiB
TypeScript

import { randomUUID } from 'crypto';
import { and, eq } from 'drizzle-orm';
import * as schema from '../src/db/schema';
import {
authHeader,
closeReaderStateIsolationE2EContext,
createLibraryWithFolder,
createReaderStateIsolationE2EContext,
createUserAndLogin,
grantLibraryAccess,
locateBookByAbsolutePath,
triggerAndWaitForLibraryScan,
type LocatedBookFile,
type ReaderStateIsolationE2EContext,
type TestUserSession,
} from './e2e/reader-state-isolation/reader-state-isolation-harness';
import { createEpubFixture, createZipArchiveFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
type InjectResponse = Awaited<ReturnType<ReaderStateIsolationE2EContext['app']['inject']>>;
const SCENARIO_TIMEOUT_MS = 120_000;
const EPUB_STYLESHEET = 'body { background: #f5f1e8; color: #1f2937; }\nimg { max-width: 100%; }';
const EPUB_BOOKMARKS = 'last-read=OPS/chapter.xhtml#intro';
const PAGE_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+cQpUAAAAASUVORK5CYII=', 'base64');
const PAGE_JPEG = Buffer.from(
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBxAQEBAQEA8QEA8QDw8QEA8PDw8QFREWFhURFRUYHSggGBolGxUVITEhJSkrLi4uFx8zODMsNygtLisBCgoKDg0OGhAQGy0lICUtLS8tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLf/AABEIAAEAAgMBEQACEQEDEQH/xAAXAAEBAQEAAAAAAAAAAAAAAAABAgME/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEAMQAAAB6A//xAAYEAEAAwEAAAAAAAAAAAAAAAABABEhMf/aAAgBAQABBQJXJZ//xAAVEQEBAAAAAAAAAAAAAAAAAAABAP/aAAgBAwEBPwGn/8QAFREBAQAAAAAAAAAAAAAAAAAAARD/2gAIAQIBAT8Bp//EABgQAQADAQAAAAAAAAAAAAAAAAEAESEx/9oACAEBAAY/AhGQx//EABsQAQABBQEAAAAAAAAAAAAAAAERACExQVFh/9oACAEBAAE/IV2K4zGq4Jm1q//aAAwDAQACAAMAAAAQ8//EABcRAQEBAQAAAAAAAAAAAAAAAAEREDH/2gAIAQMBAT8Qw0f/xAAWEQEBAQAAAAAAAAAAAAAAAAABEBH/2gAIAQIBAT8QkL//xAAbEAEBAQADAQEAAAAAAAAAAAABEQAhMUFhcf/aAAgBAQABPxC4oLQ1M8JrIoYNewc19hXtOD87mpy4V/mQJu1WDVYj1WFJsbgx5caX//Z',
'base64',
);
function responseMessage(response: { message?: string | string[] }): string {
if (Array.isArray(response.message)) return response.message.join(' ');
return String(response.message ?? '');
}
function expectError(response: InjectResponse, status: number, messageFragment?: string): void {
expect(response.statusCode).toBe(status);
if (!messageFragment) return;
expect(responseMessage(response.json() as { message?: string | string[] })).toContain(messageFragment);
}
function responseBuffer(response: InjectResponse): Buffer {
const rawPayload = (response as unknown as { rawPayload?: unknown }).rawPayload;
if (Buffer.isBuffer(rawPayload)) return rawPayload;
return Buffer.from(response.body, 'binary');
}
function escapeXml(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
}
async function createReaderDeliveryEpub(rootPath: string, relativePath: string, title: string): Promise<string> {
const identifier = `urn:uuid:${randomUUID()}`;
const author = 'Reader Fixture Author';
const publisher = 'Reader Fixture Publisher';
const description = 'Reader delivery fixture description';
const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml" />
</rootfiles>
</container>`;
const opfXml = `<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" unique-identifier="uid" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">${escapeXml(identifier)}</dc:identifier>
<dc:title>${escapeXml(title)}</dc:title>
<dc:creator>${escapeXml(author)}</dc:creator>
<dc:language>en</dc:language>
<dc:publisher>${escapeXml(publisher)}</dc:publisher>
<dc:description>${escapeXml(description)}</dc:description>
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
</metadata>
<manifest>
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" />
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav" />
<item id="style" href="styles/reader style.css" media-type="text/css" />
<item id="cover" href="images/cover image.png" media-type="image/png" properties="cover-image" />
</manifest>
<spine>
<itemref idref="chapter" />
</spine>
</package>`;
const chapterXml = `<html xmlns="http://www.w3.org/1999/xhtml"><head><title>${escapeXml(title)}</title><link rel="stylesheet" type="text/css" href="styles/reader style.css" /></head><body><h1 id="intro">${escapeXml(title)}</h1><p>fixture chapter</p><img src="images/cover image.png" alt="cover" /></body></html>`;
const navXml = `<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>${escapeXml(
title,
)}</title></head><body><nav epub:type="toc"><ol><li><a href="chapter.xhtml#intro">Start Reading</a></li></ol></nav></body></html>`;
return createZipArchiveFixture(rootPath, relativePath, [
{ path: 'mimetype', content: 'application/epub+zip', store: true },
{ path: 'META-INF/container.xml', content: containerXml },
{ path: 'META-INF/calibre_bookmarks.txt', content: EPUB_BOOKMARKS },
{ path: 'OPS/content.opf', content: opfXml },
{ path: 'OPS/chapter.xhtml', content: chapterXml },
{ path: 'OPS/nav.xhtml', content: navXml },
{ path: 'OPS/styles/reader style.css', content: EPUB_STYLESHEET },
{ path: 'OPS/images/cover image.png', content: PAGE_PNG },
{ path: 'OPS/private.txt', content: 'hidden-unmanifested-entry' },
]);
}
async function createReaderDeliveryCbz(rootPath: string, relativePath: string, title: string): Promise<string> {
const comicInfoXml = `<?xml version="1.0" encoding="UTF-8"?>
<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Title>${escapeXml(title)}</Title>
<Writer>Reader Fixture Cartoonist</Writer>
<Publisher>Reader Fixture Press</Publisher>
<Year>2026</Year>
</ComicInfo>`;
return createZipArchiveFixture(rootPath, relativePath, [
{ path: 'ComicInfo.xml', content: comicInfoXml },
{ path: 'pages/001-cover.png', content: PAGE_PNG },
{ path: 'pages/002-spread.jpg', content: PAGE_JPEG },
{ path: '.hidden/003-secret.png', content: PAGE_PNG },
{ path: 'notes/readme.txt', content: 'not a page' },
]);
}
describe('Reader format delivery (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
let ctx!: ReaderStateIsolationE2EContext;
let sharedLibrary!: Awaited<ReturnType<typeof createLibraryWithFolder>>;
let hiddenLibrary!: Awaited<ReturnType<typeof createLibraryWithFolder>>;
let sharedEpub!: LocatedBookFile;
let sharedCbz!: LocatedBookFile;
let hiddenEpub!: LocatedBookFile;
let viewer!: TestUserSession;
let crossLibraryUser!: TestUserSession;
let outsider!: TestUserSession;
beforeAll(async () => {
ctx = await createReaderStateIsolationE2EContext();
sharedLibrary = await createLibraryWithFolder(ctx, { name: `reader-delivery-shared-${randomUUID()}` });
hiddenLibrary = await createLibraryWithFolder(ctx, { name: `reader-delivery-hidden-${randomUUID()}` });
const sharedEpubPath = await createReaderDeliveryEpub(sharedLibrary.folderPath, 'delivery/reader-delivery.epub', 'Reader Delivery EPUB');
const sharedCbzPath = await createReaderDeliveryCbz(sharedLibrary.folderPath, 'delivery/reader-delivery.cbz', 'Reader Delivery Comic');
const hiddenEpubPath = await createEpubFixture(hiddenLibrary.folderPath, 'restricted/hidden-reader.epub', {
title: 'Hidden Reader EPUB',
});
await triggerAndWaitForLibraryScan(ctx, sharedLibrary.libraryId);
await triggerAndWaitForLibraryScan(ctx, hiddenLibrary.libraryId);
sharedEpub = await locateBookByAbsolutePath(ctx, sharedEpubPath);
sharedCbz = await locateBookByAbsolutePath(ctx, sharedCbzPath);
hiddenEpub = await locateBookByAbsolutePath(ctx, hiddenEpubPath);
viewer = await createUserAndLogin(ctx);
crossLibraryUser = await createUserAndLogin(ctx);
outsider = await createUserAndLogin(ctx);
await grantLibraryAccess(ctx, viewer.userId, sharedLibrary.libraryId, 'viewer');
await grantLibraryAccess(ctx, crossLibraryUser.userId, sharedLibrary.libraryId, 'viewer');
await grantLibraryAccess(ctx, crossLibraryUser.userId, hiddenLibrary.libraryId, 'viewer');
}, 60_000);
afterAll(async () => {
if (ctx) {
await closeReaderStateIsolationE2EContext(ctx);
}
});
describe('EPUB info and asset delivery', () => {
it('returns the EPUB info shape the reader expects and serves manifest and optional files', async () => {
const infoResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${sharedEpub.bookFileId}`,
headers: authHeader(viewer.accessToken),
});
expect(infoResponse.statusCode).toBe(200);
const info = infoResponse.json() as {
containerPath: string;
rootPath: string;
coverPath: string | null;
optionalFiles?: string[];
metadata: Record<string, unknown>;
spine: Array<{ idref: string; href: string; mediaType: string; linear: boolean }>;
manifest: Array<{ id: string; href: string; mediaType: string; size: number; properties?: string[] }>;
toc: { label: string; children?: Array<{ label: string; href?: string }> } | null;
};
expect(info).toMatchObject({
containerPath: 'OPS/content.opf',
rootPath: 'OPS/',
coverPath: 'OPS/images/cover image.png',
optionalFiles: ['META-INF/calibre_bookmarks.txt'],
metadata: {
title: 'Reader Delivery EPUB',
creator: 'Reader Fixture Author',
language: 'en',
publisher: 'Reader Fixture Publisher',
description: 'Reader delivery fixture description',
identifier: expect.stringContaining('urn:uuid:'),
},
spine: [
{
idref: 'chapter',
href: 'OPS/chapter.xhtml',
mediaType: 'application/xhtml+xml',
linear: true,
},
],
toc: {
label: 'Table of Contents',
children: [{ label: 'Start Reading', href: 'OPS/chapter.xhtml#intro' }],
},
});
expect(info.manifest).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'chapter', href: 'OPS/chapter.xhtml', mediaType: 'application/xhtml+xml', size: expect.any(Number) }),
expect.objectContaining({
id: 'nav',
href: 'OPS/nav.xhtml',
mediaType: 'application/xhtml+xml',
size: expect.any(Number),
properties: ['nav'],
}),
expect.objectContaining({ id: 'style', href: 'OPS/styles/reader style.css', mediaType: 'text/css', size: expect.any(Number) }),
expect.objectContaining({
id: 'cover',
href: 'OPS/images/cover image.png',
mediaType: 'image/png',
size: expect.any(Number),
properties: ['cover-image'],
}),
]),
);
const defaultFileInfoResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/epub/${sharedEpub.bookId}/info`,
headers: authHeader(viewer.accessToken),
});
expect(defaultFileInfoResponse.statusCode).toBe(200);
const defaultInfo = defaultFileInfoResponse.json() as { containerPath: string; coverPath: string | null };
expect(defaultInfo.containerPath).toBe('OPS/content.opf');
expect(defaultInfo.coverPath).toBe('OPS/images/cover image.png');
const chapterResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/epub/${sharedEpub.bookId}/file/OPS/chapter.xhtml`,
headers: authHeader(viewer.accessToken),
});
expect(chapterResponse.statusCode).toBe(200);
expect(chapterResponse.headers['content-type']).toContain('application/xhtml+xml');
expect(chapterResponse.headers['cache-control']).toBe('public, max-age=3600');
expect(chapterResponse.body).toContain('id="intro"');
const stylesheetResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/epub/${sharedEpub.bookId}/file/OPS/styles/reader%20style.css?fileId=${sharedEpub.bookFileId}`,
headers: authHeader(viewer.accessToken),
});
expect(stylesheetResponse.statusCode).toBe(200);
expect(stylesheetResponse.headers['content-type']).toContain('text/css');
expect(stylesheetResponse.headers['content-length']).toBe(String(Buffer.byteLength(EPUB_STYLESHEET)));
expect(stylesheetResponse.headers['cache-control']).toBe('public, max-age=3600');
expect(stylesheetResponse.body).toBe(EPUB_STYLESHEET);
const optionalFileResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/epub/${sharedEpub.bookId}/file/META-INF/calibre_bookmarks.txt?fileId=${sharedEpub.bookFileId}`,
headers: authHeader(viewer.accessToken),
});
expect(optionalFileResponse.statusCode).toBe(200);
expect(optionalFileResponse.headers['content-type']).toContain('application/octet-stream');
expect(optionalFileResponse.body).toBe(EPUB_BOOKMARKS);
});
});
describe('EPUB path guards and user scoping', () => {
it('blocks traversal and unmanifested assets while enforcing library access boundaries', async () => {
const hiddenInfoForbidden = await ctx.app.inject({
method: 'GET',
url: `/api/v1/epub/${hiddenEpub.bookId}/info?fileId=${hiddenEpub.bookFileId}`,
headers: authHeader(viewer.accessToken),
});
expectError(hiddenInfoForbidden, 403, 'No access to this library');
const hiddenInfoAllowed = await ctx.app.inject({
method: 'GET',
url: `/api/v1/epub/${hiddenEpub.bookId}/info?fileId=${hiddenEpub.bookFileId}`,
headers: authHeader(crossLibraryUser.accessToken),
});
expect(hiddenInfoAllowed.statusCode).toBe(200);
expect((hiddenInfoAllowed.json() as { metadata: { title?: string } }).metadata.title).toBe('Hidden Reader EPUB');
const unmanifestedAssetResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/epub/${sharedEpub.bookId}/file/OPS/private.txt?fileId=${sharedEpub.bookFileId}`,
headers: authHeader(viewer.accessToken),
});
expectError(unmanifestedAssetResponse, 404, 'Entry not in archive: OPS/private.txt');
const traversalResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/epub/${sharedEpub.bookId}/file/%252E%252E/OPS/chapter.xhtml?fileId=${sharedEpub.bookFileId}`,
headers: authHeader(viewer.accessToken),
});
expectError(traversalResponse, 403, 'Invalid path');
const mismatchedFileResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${hiddenEpub.bookFileId}`,
headers: authHeader(crossLibraryUser.accessToken),
});
expectError(mismatchedFileResponse, 404, `File ${hiddenEpub.bookFileId} not found for book ${sharedEpub.bookId}`);
const outsiderResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${sharedEpub.bookFileId}`,
headers: authHeader(outsider.accessToken),
});
expectError(outsiderResponse, 403, 'No access to this library');
});
});
describe('CBZ page delivery contract', () => {
it('returns page counts, streams ordered pages, and rejects invalid page requests and unsupported formats', async () => {
const pagesResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages`,
headers: authHeader(viewer.accessToken),
});
expect(pagesResponse.statusCode).toBe(200);
expect(pagesResponse.json()).toEqual({ pageCount: 2 });
const firstPageResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages/0`,
headers: authHeader(viewer.accessToken),
});
expect(firstPageResponse.statusCode).toBe(200);
expect(firstPageResponse.headers['content-type']).toContain('image/png');
expect(firstPageResponse.headers['cache-control']).toBe('private, no-cache');
expect(firstPageResponse.headers.etag).toEqual(expect.stringMatching(/^".+"$/));
expect(responseBuffer(firstPageResponse)).toEqual(PAGE_PNG);
const revalidatedPageResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages/0`,
headers: { ...authHeader(viewer.accessToken), 'if-none-match': String(firstPageResponse.headers.etag) },
});
expect(revalidatedPageResponse.statusCode).toBe(304);
expect(revalidatedPageResponse.headers.etag).toBe(firstPageResponse.headers.etag);
const secondPageResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages/1`,
headers: authHeader(viewer.accessToken),
});
expect(secondPageResponse.statusCode).toBe(200);
expect(secondPageResponse.headers['content-type']).toContain('image/jpeg');
expect(responseBuffer(secondPageResponse)).toEqual(PAGE_JPEG);
const negativePageResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages/-1`,
headers: authHeader(viewer.accessToken),
});
expectError(negativePageResponse, 404, 'Page -1 out of range');
const overflowPageResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages/2`,
headers: authHeader(viewer.accessToken),
});
expectError(overflowPageResponse, 404, 'Page 2 out of range');
const unsupportedFormatResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/cbz/files/${sharedEpub.bookFileId}/pages`,
headers: authHeader(viewer.accessToken),
});
expectError(unsupportedFormatResponse, 404, 'Unsupported comic format: epub');
});
});
describe('reader access revocation', () => {
it('applies library access changes immediately while leaving other users isolated', async () => {
await ctx.db
.delete(schema.userLibraryAccess)
.where(and(eq(schema.userLibraryAccess.userId, viewer.userId), eq(schema.userLibraryAccess.libraryId, sharedLibrary.libraryId)));
const revokedEpubInfo = await ctx.app.inject({
method: 'GET',
url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${sharedEpub.bookFileId}`,
headers: authHeader(viewer.accessToken),
});
expectError(revokedEpubInfo, 403, 'No access to this library');
const revokedCbzPages = await ctx.app.inject({
method: 'GET',
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages`,
headers: authHeader(viewer.accessToken),
});
expectError(revokedCbzPages, 403, 'No access to this library');
const unaffectedUserEpubInfo = await ctx.app.inject({
method: 'GET',
url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${sharedEpub.bookFileId}`,
headers: authHeader(crossLibraryUser.accessToken),
});
expect(unaffectedUserEpubInfo.statusCode).toBe(200);
const unaffectedUserCbzPages = await ctx.app.inject({
method: 'GET',
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages`,
headers: authHeader(crossLibraryUser.accessToken),
});
expect(unaffectedUserCbzPages.statusCode).toBe(200);
expect(unaffectedUserCbzPages.json()).toEqual({ pageCount: 2 });
});
});
});