fix(opds): expose public collections to other users

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
This commit is contained in:
neon
2026-10-06 22:16:21 +00:00
parent 1abf2f58ca
commit 019c432262
11 changed files with 356 additions and 50 deletions
@@ -646,6 +646,30 @@ describe('KoreaderCatalogService', () => {
);
});
it('uses viewer-scoped aggregate collection counts without recounting each collection', async () => {
const { service, opdsBookService } = makeService();
const user = makeUser({ id: 7 });
opdsBookService.getUserCollections.mockResolvedValue([
{ id: 2, name: 'Favorites', bookCount: 3 },
{ id: 9, name: 'Public empty', bookCount: 0 },
]);
const section = await service.getSectionEntries(user, 'collections');
expect(opdsBookService.getUserCollections).toHaveBeenCalledWith(7, false, user.contentFilters);
expect(opdsBookService.countBooks).not.toHaveBeenCalled();
expect(section.items).toEqual([
{ id: '2', title: 'Favorites', section: 'collections', count: 3, booksHref: '/api/v1/koreader/plugin/catalog/books?sort=title&collectionId=2' },
{
id: '9',
title: 'Public empty',
section: 'collections',
count: 0,
booksHref: '/api/v1/koreader/plugin/catalog/books?sort=title&collectionId=9',
},
]);
});
it('rejects unknown section names', async () => {
const { service } = makeService();
await expect(service.getSectionEntries(makeUser(), 'bogus')).rejects.toThrow(BadRequestException);
@@ -559,19 +559,14 @@ export class KoreaderCatalogService {
}
private async getCollectionEntries(user: RequestUser): Promise<KoreaderCatalogEntry[]> {
const rows = await this.opdsBookService.getUserCollections(user.id);
return Promise.all(
rows.map(async (row) => {
const count = await this.countBooks(user, { collectionId: row.id });
return {
id: String(row.id),
title: row.name,
section: 'collections',
count,
booksHref: this.booksHref({ collectionId: row.id, sort: 'title' }),
};
}),
);
const rows = await this.opdsBookService.getUserCollections(user.id, user.isSuperuser, user.contentFilters);
return rows.map((row) => ({
id: String(row.id),
title: row.name,
section: 'collections',
count: row.bookCount,
booksHref: this.booksHref({ collectionId: row.id, sort: 'title' }),
}));
}
private async getSmartScopeEntries(user: RequestUser): Promise<KoreaderCatalogEntry[]> {
@@ -1,7 +1,9 @@
import { ForbiddenException } from '@nestjs/common';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
import { bookSeries, bookSeriesMemberships } from '../../../db/schema';
import { OpdsBookService } from '../opds-book.service';
import { OpdsCollectionRepository } from '../opds-collection.repository';
type BookPageResult = { entries: unknown[]; total: number };
@@ -49,8 +51,9 @@ function makeService(selectQueue: unknown[] = [], queryBuilderOverrides: Record<
buildWhere: vi.fn().mockReturnValue(undefined),
...queryBuilderOverrides,
};
const service = new OpdsBookService(db as never, queryBuilder as never);
return { service, db, queryBuilder };
const collectionRepository = new OpdsCollectionRepository(db as never);
const service = new OpdsBookService(db as never, queryBuilder as never, collectionRepository);
return { service, db, queryBuilder, collectionRepository };
}
function collectValues(value: unknown, seen = new WeakSet<object>()): unknown[] {
@@ -69,16 +72,16 @@ function collectValues(value: unknown, seen = new WeakSet<object>()): unknown[]
describe('OpdsBookService', () => {
it('returns accessible library ids for superusers and regular users', async () => {
const superDb = makeDb([[{ id: 1 }, { id: 4 }]]);
const superService = new OpdsBookService(superDb as never, {} as never);
const superService = new OpdsBookService(superDb as never, {} as never, new OpdsCollectionRepository(superDb as never));
await expect(superService.getAccessibleLibraryIds(7, true)).resolves.toEqual([1, 4]);
const userDb = makeDb([[{ libraryId: 2 }, { libraryId: 3 }]]);
const userService = new OpdsBookService(userDb as never, {} as never);
const userService = new OpdsBookService(userDb as never, {} as never, new OpdsCollectionRepository(userDb as never));
await expect(userService.getAccessibleLibraryIds(7, false)).resolves.toEqual([2, 3]);
});
it('handles getBooksPage access checks and smartScope delegation', async () => {
const { service } = makeService([[{ userId: 999 }], [{ userId: 7 }]]);
const { service } = makeService([[{ userId: 999, isPublic: false, mediaType: 'books' }], [{ userId: 7, isPublic: false, mediaType: 'books' }]]);
const accessSpy = vi.spyOn(service, 'getAccessibleLibraryIds');
const privateService = testable(service);
const smartScopeSpy = vi.spyOn(privateService, 'buildSmartScopeWhere');
@@ -227,14 +230,47 @@ describe('OpdsBookService', () => {
expect(seriesChain.innerJoin).toHaveBeenCalledWith(bookSeriesMemberships, expect.anything());
});
it('returns user collections and smartScopes', async () => {
const { service, db } = makeService([[{ id: 4, name: 'Favorites', bookCount: 1 }], [{ id: 7, name: 'Unread', icon: 'sparkles' }]]);
it.each([
{ userId: 7, isPublic: false, mediaType: 'books', allowed: true },
{ userId: 999, isPublic: true, mediaType: 'books', allowed: true },
{ userId: 999, isPublic: false, mediaType: 'books', allowed: false },
{ userId: 7, isPublic: true, mediaType: 'podcasts', allowed: false },
undefined,
])('checks collection access for both catalog pages and counts: %j', async (collection) => {
const { service, collectionRepository } = makeService();
vi.spyOn(service, 'getAccessibleLibraryIds').mockResolvedValue([1]);
vi.spyOn(collectionRepository, 'findById').mockResolvedValue(collection ? [collection] : []);
const pageSpy = vi.spyOn(testable(service), 'paginatedBookQuery').mockResolvedValue({ entries: [], total: 0 });
if (collection?.allowed) {
await expect(service.getBooksPage(7, 'recent', 1, 20, { collectionId: 11 })).resolves.toEqual({ entries: [], total: 0 });
await expect(service.countBooks(7, { collectionId: 11 })).resolves.toBe(0);
expect(pageSpy).toHaveBeenCalledOnce();
} else {
await expect(service.getBooksPage(7, 'recent', 1, 20, { collectionId: 11 })).rejects.toThrow(ForbiddenException);
await expect(service.countBooks(7, { collectionId: 11 })).rejects.toThrow(ForbiddenException);
expect(pageSpy).not.toHaveBeenCalled();
}
});
it('passes viewer permissions to collection counts', async () => {
const { service, collectionRepository } = makeService();
const filters = { ...EMPTY_CONTENT_FILTER_RULES, excludeTagIds: [12] };
const listSpy = vi.spyOn(collectionRepository, 'findVisibleForUser').mockResolvedValue([{ id: 4, name: 'Favorites', bookCount: 1 }]);
const countSpy = vi.spyOn(collectionRepository, 'countVisibleForUser').mockResolvedValue(3);
await expect(service.getUserCollections(8, false, filters)).resolves.toEqual([{ id: 4, name: 'Favorites', bookCount: 1 }]);
expect(listSpy).toHaveBeenCalledWith(8, false, filters);
await expect(service.countUserCollections(8)).resolves.toBe(3);
expect(countSpy).toHaveBeenCalledWith(8);
});
it('returns user smartScopes', async () => {
const { service, db } = makeService([[{ id: 7, name: 'Unread', icon: 'sparkles' }]]);
await expect(service.getUserCollections(8)).resolves.toEqual([{ id: 4, name: 'Favorites', bookCount: 1 }]);
await expect(service.getUserSmartScopes(8)).resolves.toEqual([{ id: 7, name: 'Unread', icon: 'sparkles' }]);
const chains = (db.select as ReturnType<typeof vi.fn>).mock.results.map((r) => r.value as Record<string, unknown>);
const smartScopeChain = chains[1]!;
const smartScopeChain = chains[0]!;
const whereClause = (smartScopeChain.where as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
expect(collectValues(whereClause)).toContain(8);
expect(collectValues(whereClause)).toContain(true);
+7 -24
View File
@@ -13,7 +13,6 @@ import {
bookSeries,
bookSeriesMemberships,
books,
collections,
collectionBooks,
smartScopes,
libraries,
@@ -25,6 +24,7 @@ import { isAudioFormat, type ContentFilterRules, type GroupRule } from '@bookorb
import { rankFileRowsByBook, rankFilesByFormatPriority } from '../../common/utils/primary-file-selection.utils';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
import { seriesIndexOrderBy } from '../../common/utils/series-index-sql.utils';
import { OpdsCollectionRepository } from './opds-collection.repository';
type Db = NodePgDatabase<typeof schema>;
@@ -153,6 +153,7 @@ export class OpdsBookService {
constructor(
@Inject(DB) private readonly db: Db,
private readonly queryBuilder: BookQueryBuilder,
private readonly collectionRepository: OpdsCollectionRepository,
) {}
async getAccessibleLibraryIds(userId: number, isSuperuser = false): Promise<number[]> {
@@ -223,12 +224,8 @@ export class OpdsBookService {
}
if (filters?.collectionId) {
const [collection] = await this.db
.select({ userId: collections.userId })
.from(collections)
.where(eq(collections.id, filters.collectionId))
.limit(1);
if (!collection || collection.userId !== userId) {
const [collection] = await this.collectionRepository.findById(filters.collectionId);
if (!collection || collection.mediaType !== 'books' || (collection.userId !== userId && !collection.isPublic)) {
throw new ForbiddenException('No access to this collection');
}
}
@@ -597,29 +594,15 @@ export class OpdsBookService {
return { items: hasNext ? rows.slice(0, opts.limit) : rows, hasNext };
}
async getUserCollections(userId: number) {
return this.db
.select({
id: collections.id,
name: collections.name,
bookCount: sql<number>`count(${collectionBooks.bookId})::int`,
})
.from(collections)
.leftJoin(collectionBooks, eq(collectionBooks.collectionId, collections.id))
.where(and(eq(collections.userId, userId), eq(collections.mediaType, 'books')))
.groupBy(collections.id)
.orderBy(collections.name);
async getUserCollections(userId: number, isSuperuser = false, contentFilters?: ContentFilterRules) {
return this.collectionRepository.findVisibleForUser(userId, isSuperuser, contentFilters);
}
// Badge counts only need the totals, so these skip the per-entity book counts
// their list siblings compute. Counting collections through getUserCollections
// would aggregate over collection_books just to read the row count back.
async countUserCollections(userId: number): Promise<number> {
const [row] = await this.db
.select({ total: count() })
.from(collections)
.where(and(eq(collections.userId, userId), eq(collections.mediaType, 'books')));
return Number(row?.total ?? 0);
return this.collectionRepository.countVisibleForUser(userId);
}
async countUserSmartScopes(userId: number): Promise<number> {
@@ -0,0 +1,89 @@
import { Test } from '@nestjs/testing';
import { drizzle } from 'drizzle-orm/node-postgres';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { OpdsCollectionRepository } from './opds-collection.repository';
describe('OpdsCollectionRepository', () => {
let repository: OpdsCollectionRepository;
const query = vi.fn();
beforeEach(async () => {
query.mockReset().mockResolvedValue({ rows: [] });
const db = drizzle({ client: { query } as never, schema });
const module = await Test.createTestingModule({
providers: [OpdsCollectionRepository, { provide: DB, useValue: db }],
}).compile();
repository = module.get(OpdsCollectionRepository);
});
it('counts only present books in the viewer libraries in one aggregate query', async () => {
query.mockResolvedValueOnce({
rows: [
[4, 'Favorites', 1],
[9, 'Empty public', 0],
],
});
await expect(repository.findVisibleForUser(7, false)).resolves.toEqual([
{ id: 4, name: 'Favorites', bookCount: 1 },
{ id: 9, name: 'Empty public', bookCount: 0 },
]);
expect(query).toHaveBeenCalledOnce();
const [{ text }, params] = query.mock.calls[0] as [{ text: string }, unknown[]];
expect(text).toContain('count("books"."id")::int');
expect(text).toContain('left join "books" on');
expect(text).toContain('"books"."status" = $1');
expect(text).toContain('exists (select 1 from "user_library_access"');
expect(text).toContain('"user_library_access"."library_id" = "books"."library_id"');
expect(text).toContain('("collections"."user_id" = $3 or "collections"."is_public" = $4)');
expect(params).toEqual(['present', 7, 7, true, 'books']);
});
it('applies include and exclude content filters inside the book join', () => {
const filters = {
...EMPTY_CONTENT_FILTER_RULES,
includeTagIds: [11],
includeGenreIds: [12],
excludeTagIds: [13],
excludeGenreIds: [14],
};
const { sql, params } = repository.findVisibleForUser(7, false, filters).toSQL();
const bookJoin = sql.slice(sql.indexOf('left join "books"'), sql.indexOf(' where (('));
expect(bookJoin).toContain('"books"."status"');
expect(bookJoin).toContain('exists (select 1 from "book_tags"');
expect(bookJoin).toContain('exists (select 1 from "book_genres"');
expect(bookJoin).toContain(' or exists');
expect(bookJoin).toContain('not exists (select 1 from "book_tags"');
expect(bookJoin).toContain('not exists (select 1 from "book_genres"');
expect(params).toEqual(['present', 7, 11, 12, 13, 14, 7, true, 'books']);
});
it('lets superusers count every library but still excludes foreign private and podcast collections', () => {
const filters = { ...EMPTY_CONTENT_FILTER_RULES, excludeTagIds: [13] };
const { sql, params } = repository.findVisibleForUser(7, true, filters).toSQL();
expect(sql).not.toContain('user_library_access');
expect(sql).not.toContain('book_tags');
expect(sql).toContain('"books"."status"');
expect(sql).toContain('"collections"."user_id"');
expect(sql).toContain('"collections"."is_public"');
expect(sql).toContain('"collections"."media_type"');
expect(params).toEqual(['present', 7, true, 'books']);
});
it('uses the same visibility for dashboard totals without joining memberships', async () => {
query.mockResolvedValueOnce({ rows: [['3']] });
await expect(repository.countVisibleForUser(7)).resolves.toBe(3);
const [{ text }, params] = query.mock.calls[0] as [{ text: string }, unknown[]];
expect(text).toContain('("collections"."user_id" = $1 or "collections"."is_public" = $2)');
expect(text).not.toContain('join');
expect(params).toEqual([7, true, 'books']);
});
});
@@ -0,0 +1,54 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, count, eq, exists, or, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { ContentFilterRules } from '@bookorbit/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { books, collectionBooks, collections, userLibraryAccess } from '../../db/schema';
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
type Db = NodePgDatabase<typeof schema>;
@Injectable()
export class OpdsCollectionRepository {
constructor(@Inject(DB) private readonly db: Db) {}
findById(id: number) {
return this.db
.select({ userId: collections.userId, isPublic: collections.isPublic, mediaType: collections.mediaType })
.from(collections)
.where(eq(collections.id, id))
.limit(1);
}
findVisibleForUser(userId: number, isSuperuser: boolean, contentFilters?: ContentFilterRules) {
const libraryAccess = isSuperuser
? undefined
: exists(
this.db
.select({ one: sql`1` })
.from(userLibraryAccess)
.where(and(eq(userLibraryAccess.userId, userId), eq(userLibraryAccess.libraryId, books.libraryId))),
);
const filterClauses = !isSuperuser && contentFilters ? buildContentFilterClauses(contentFilters, this.db) : [];
return this.db
.select({ id: collections.id, name: collections.name, bookCount: sql<number>`count(${books.id})::int` })
.from(collections)
.leftJoin(collectionBooks, eq(collectionBooks.collectionId, collections.id))
.leftJoin(books, and(eq(books.id, collectionBooks.bookId), eq(books.status, 'present'), libraryAccess, ...filterClauses))
.where(this.visibleForUser(userId))
.groupBy(collections.id)
.orderBy(collections.name);
}
async countVisibleForUser(userId: number): Promise<number> {
const [row] = await this.db.select({ total: count() }).from(collections).where(this.visibleForUser(userId));
return Number(row?.total ?? 0);
}
private visibleForUser(userId: number) {
return and(or(eq(collections.userId, userId), eq(collections.isPublic, true)), eq(collections.mediaType, 'books'));
}
}
@@ -105,7 +105,7 @@ describe('OpdsController', () => {
await controller.series(user, makeReply());
expect(opdsBookService.getAccessibleLibraries).toHaveBeenCalledWith(8, false);
expect(opdsBookService.getUserCollections).toHaveBeenCalledWith(8);
expect(opdsBookService.getUserCollections).toHaveBeenCalledWith(8, false, undefined);
expect(opdsBookService.getUserSmartScopes).toHaveBeenCalledWith(8);
expect(opdsBookService.getDistinctAuthors).toHaveBeenCalledWith(8, false, undefined);
expect(opdsBookService.getDistinctSeries).toHaveBeenCalledWith(8, false, undefined);
+1 -1
View File
@@ -70,7 +70,7 @@ export class OpdsController {
@Get('collections')
async collections(@OpdsUser() user: OpdsRequestUser, @Res() reply: FastifyReply) {
const cols = await this.opdsBookService.getUserCollections(user.userId);
const cols = await this.opdsBookService.getUserCollections(user.userId, user.isSuperuser, user.contentFilters);
const xml = this.opdsService.generateCollectionsNavigation(cols);
this.sendXml(reply, xml, OPDS_MIME_NAV);
}
@@ -9,6 +9,7 @@ import { BookCoverStoreModule } from '../book-cover-store/book-cover-store.modul
import { UserModule } from '../user/user.module';
import { OpdsAuthGuard } from './opds-auth.guard';
import { OpdsBookService } from './opds-book.service';
import { OpdsCollectionRepository } from './opds-collection.repository';
import { OpdsController } from './opds.controller';
import { OpdsEnabledGuard } from './opds-enabled.guard';
import { OpdsModule } from './opds.module';
@@ -29,6 +30,7 @@ describe('OpdsModule', () => {
expect(Reflect.getMetadata(MODULE_METADATA.PROVIDERS, OpdsModule)).toEqual([
OpdsService,
OpdsBookService,
OpdsCollectionRepository,
OpdsUserService,
OpdsAuthGuard,
OpdsEnabledGuard,
+2 -1
View File
@@ -7,6 +7,7 @@ import { UserModule } from '../user/user.module';
import { CommonModule } from '../../common/common.module';
import { OpdsAuthGuard } from './opds-auth.guard';
import { OpdsBookService } from './opds-book.service';
import { OpdsCollectionRepository } from './opds-collection.repository';
import { OpdsController } from './opds.controller';
import { OpdsEnabledGuard } from './opds-enabled.guard';
import { OpdsService } from './opds.service';
@@ -16,7 +17,7 @@ import { OpdsUserService } from './opds-user.service';
@Module({
imports: [AppSettingsModule, BookModule, BookCoverStoreModule, UserModule, CommonModule],
controllers: [OpdsController, OpdsUserController],
providers: [OpdsService, OpdsBookService, OpdsUserService, OpdsAuthGuard, OpdsEnabledGuard],
providers: [OpdsService, OpdsBookService, OpdsCollectionRepository, OpdsUserService, OpdsAuthGuard, OpdsEnabledGuard],
exports: [OpdsBookService],
})
export class OpdsModule {}
+123 -1
View File
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
import { mkdir, readFile, stat, writeFile } from 'fs/promises';
import { dirname, join, relative } from 'path';
import { eq } from 'drizzle-orm';
import { eq, inArray } from 'drizzle-orm';
import { Permission } from '@bookorbit/types';
import { ConfigService } from '@nestjs/config';
@@ -488,6 +488,128 @@ describe('OPDS auth and catalog (e2e)', { timeout: 120_000 }, () => {
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);