diff --git a/.github/workflows/e2e-opds-auth-catalog.yml b/.github/workflows/e2e-opds-auth-catalog.yml new file mode 100644 index 000000000..5478fd06b --- /dev/null +++ b/.github/workflows/e2e-opds-auth-catalog.yml @@ -0,0 +1,56 @@ +name: OPDS Auth Catalog E2E +run-name: ${{ format('OPDS Auth Catalog E2E - {0}', github.ref_name) }} + +on: + workflow_dispatch: + push: + paths: + - "server/src/modules/opds/**" + - "server/src/modules/book/**" + - "server/src/modules/library/**" + - "server/src/modules/collection/**" + - "server/src/modules/lens/**" + - "server/src/modules/user/**" + - "server/src/modules/auth/**" + - "server/src/modules/app-settings/**" + - "server/test/opds-auth-catalog.e2e-spec.ts" + - "server/test/e2e/opds/**" + - "server/test/e2e/**" + - "server/src/db/schema/**" + - "packages/types/src/**" + - "scripts/test/**" + - "scripts/db/**" + pull_request: + paths: + - "server/src/modules/opds/**" + - "server/src/modules/book/**" + - "server/src/modules/library/**" + - "server/src/modules/collection/**" + - "server/src/modules/lens/**" + - "server/src/modules/user/**" + - "server/src/modules/auth/**" + - "server/src/modules/app-settings/**" + - "server/test/opds-auth-catalog.e2e-spec.ts" + - "server/test/e2e/opds/**" + - "server/test/e2e/**" + - "server/src/db/schema/**" + - "packages/types/src/**" + - "scripts/test/**" + - "scripts/db/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + checks: write + +jobs: + opds-auth-catalog-e2e: + uses: ./.github/workflows/e2e-runner.yml + with: + suite-id: opds-auth-catalog + check-name: Server OPDS Auth Catalog E2E + artifact-name: opds-auth-catalog-e2e-report + job-timeout-minutes: 45 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 64e222e74..a8bee9e5f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -119,7 +119,9 @@ pnpm run test:e2e -- smoke # e2e smoke suite (default DB) pnpm run test:e2e -- scanner # scanner e2e suite (dedicated projectx_e2e DB) pnpm run test:e2e -- scanner-file-ops # scanner file operation e2e suite (dedicated projectx_e2e DB) pnpm run test:e2e -- staging-ingest-finalize # staging ingest/finalize e2e suite (dedicated projectx_e2e DB) +pnpm run test:e2e -- opds-auth-catalog # OPDS auth/catalog e2e suite (dedicated projectx_e2e DB) pnpm run test:e2e:all # alias for "pnpm run test:e2e -- all" +pnpm run test:e2e:opds-auth-catalog # alias for "pnpm run test:e2e -- opds-auth-catalog" pnpm run test:e2e:list # list all supported e2e suite ids ``` @@ -167,9 +169,17 @@ pnpm run test:e2e -- scanner --testNamePattern=book-per-folder-disc-folder-flatt - `staging-ingest-finalize-e2e-junit.xml` - `staging-ingest-finalize-e2e-scenarios.json` +### OPDS auth/catalog e2e details + +- `pnpm run test:e2e -- opds-auth-catalog` prepares and migrates the dedicated e2e database (`projectx_e2e`) before running. +- Local runs auto-start PostgreSQL with `docker-compose.dev.yml` if needed. +- Results are written to `test-results/server/`: + - `opds-auth-catalog-e2e-junit.xml` + - `opds-auth-catalog-e2e-scenarios.json` + ### E2E in CI (how to trigger) -Both workflows call the reusable `E2E Runner (reusable)` workflow, which runs: +All suite workflows call the reusable `E2E Runner (reusable)` workflow, which runs: ```bash pnpm run test:e2e -- @@ -180,11 +190,12 @@ pnpm run test:e2e -- | Scanner E2E | `scanner` | `workflow_dispatch`, `push` (scanner-related paths), `pull_request` (same paths) | | Scanner File Ops E2E | `scanner-file-ops` | `workflow_dispatch`, nightly schedule (`0 4 * * *`), `push`, `pull_request` | | Staging Ingest Finalize E2E | `staging-ingest-finalize` | `workflow_dispatch`, `push` (staging-related paths), `pull_request` (same paths) | +| OPDS Auth Catalog E2E | `opds-auth-catalog` | `workflow_dispatch`, `push` (opds-related paths), `pull_request` (same paths) | Manual trigger steps: 1. Open GitHub Actions. -2. Select **Scanner E2E**, **Scanner File Ops E2E**, or **Staging Ingest Finalize E2E**. +2. Select **Scanner E2E**, **Scanner File Ops E2E**, **Staging Ingest Finalize E2E**, or **OPDS Auth Catalog E2E**. 3. Click **Run workflow**. Each run uploads `test-results/server/` as an artifact and publishes JUnit annotations from `test-results/server/*-e2e-junit.xml`. diff --git a/package.json b/package.json index 5bbfcd137..02c53a161 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:e2e": "node ./scripts/test/run-e2e-suite.mjs", "test:e2e:all": "node ./scripts/test/run-e2e-suite.mjs all", "test:e2e:metadata-write": "node ./scripts/test/run-e2e-suite.mjs metadata-write", + "test:e2e:opds-auth-catalog": "node ./scripts/test/run-e2e-suite.mjs opds-auth-catalog", "test:e2e:metadata-write:atomicity": "node ./scripts/test/run-e2e-suite.mjs metadata-write --testNamePattern=\"atomicity failpoints\"", "test:e2e:metadata-write:concurrency": "node ./scripts/test/run-e2e-suite.mjs metadata-write --testNamePattern=\"concurrency hardening\"", "test:e2e:list": "node ./scripts/test/run-e2e-suite.mjs --list", diff --git a/scripts/test/e2e-suites.mjs b/scripts/test/e2e-suites.mjs index 30d183e29..57bcfa686 100644 --- a/scripts/test/e2e-suites.mjs +++ b/scripts/test/e2e-suites.mjs @@ -65,6 +65,14 @@ export const E2E_SUITES = Object.freeze({ prepareDedicatedDatabase: true, useDedicatedDatabase: true, }, + "opds-auth-catalog": { + id: "opds-auth-catalog", + description: "OPDS auth and catalog suite", + vitestTarget: "test/opds-auth-catalog.e2e-spec.ts", + junitOutput: `${TEST_RESULTS_DIR}/opds-auth-catalog-e2e-junit.xml`, + prepareDedicatedDatabase: true, + useDedicatedDatabase: true, + }, }); export function listE2ESuites() { diff --git a/server/src/modules/opds/__tests__/opds-auth.guard.test.ts b/server/src/modules/opds/__tests__/opds-auth.guard.test.ts index a92d9a5aa..bbb82a54e 100644 --- a/server/src/modules/opds/__tests__/opds-auth.guard.test.ts +++ b/server/src/modules/opds/__tests__/opds-auth.guard.test.ts @@ -2,7 +2,7 @@ import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@ne import type { ConfigService } from '@nestjs/config'; import { Permission } from '@projectx/types'; -import { OpdsAuthGuard } from '../opds-auth.guard'; +import { createCoverToken, OpdsAuthGuard } from '../opds-auth.guard'; import type { OpdsRequestUser } from '../opds-auth.guard'; import type { OpdsUserService } from '../opds-user.service'; import type { UserService } from '../../user/user.service'; @@ -10,10 +10,10 @@ import type { PermissionService } from '../../../common/services/permission.serv const TEST_SECRET = 'test-jwt-secret'; -function mockContext(authHeader?: string, tokenParam?: string) { +function mockContext(authHeader?: string, tokenParam?: string, url = '/api/v1/opds/libraries') { const query: Record = {}; if (tokenParam) query.t = tokenParam; - const request: Record = { headers: { authorization: authHeader }, query }; + const request: Record = { headers: { authorization: authHeader }, query, url }; const reply = { header: vi.fn() }; return { request, @@ -125,6 +125,23 @@ describe('OpdsAuthGuard', () => { await expect(guard.canActivate(context)).rejects.toThrow(ForbiddenException); }); + it('rejects token auth on non-image routes with Basic challenge', async () => { + const guard = makeGuard(); + const token = createCoverToken(1, TEST_SECRET); + const { context, reply } = mockContext(undefined, token, '/api/v1/opds/libraries'); + await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException); + expect(reply.header).toHaveBeenCalledWith('WWW-Authenticate', 'Basic realm="projectx OPDS"'); + }); + + it('accepts token auth on image routes', async () => { + const guard = makeGuard(); + const token = createCoverToken(1, TEST_SECRET); + const { context, request } = mockContext(undefined, token, '/api/v1/opds/42/cover'); + const result = await guard.canActivate(context); + expect(result).toBe(true); + expect((request.opdsUser as OpdsRequestUser).userId).toBe(1); + }); + it('attaches opdsUser to request on success', async () => { const guard = makeGuard(); const { context, request } = mockContext(basicHeader('reader', 'pass')); diff --git a/server/src/modules/opds/__tests__/opds-user.service.test.ts b/server/src/modules/opds/__tests__/opds-user.service.test.ts index f0f3da22e..3755a0e25 100644 --- a/server/src/modules/opds/__tests__/opds-user.service.test.ts +++ b/server/src/modules/opds/__tests__/opds-user.service.test.ts @@ -83,6 +83,17 @@ describe('OpdsUserService', () => { await expect(service.create(5, { username: 'duplicate', password: 'password123' })).rejects.toThrow(ConflictException); }); + it('throws ConflictException when duplicate violation is wrapped by DrizzleQueryError', async () => { + const wrapped = new Error('failed query', { + cause: { code: '23505' }, + }); + mockValues.mockReturnValue({ + returning: vi.fn().mockRejectedValue(wrapped), + }); + + await expect(service.create(5, { username: 'duplicate', password: 'password123' })).rejects.toThrow(ConflictException); + }); + it('rethrows non-unique-violation errors', async () => { const genericError = new Error('connection lost'); mockValues.mockReturnValue({ diff --git a/server/src/modules/opds/opds-auth.guard.ts b/server/src/modules/opds/opds-auth.guard.ts index 43c1474ad..9212577f1 100644 --- a/server/src/modules/opds/opds-auth.guard.ts +++ b/server/src/modules/opds/opds-auth.guard.ts @@ -23,6 +23,16 @@ export function createCoverToken(userId: number, secret: string): string { return `${userId}.${sig}`; } +function stripQuery(url: string): string { + const queryIndex = url.indexOf('?'); + if (queryIndex === -1) return url; + return url.slice(0, queryIndex); +} + +function isTokenImagePath(requestPath: string): boolean { + return /(?:^|\/)(?:api\/v1\/)?opds\/\d+\/(cover|thumbnail)$/.test(requestPath.replace(/^\//, '')); +} + function parseCoverToken(token: string, secret: string): number | null { const dot = token.indexOf('.'); if (dot === -1) return null; @@ -46,8 +56,15 @@ export class OpdsAuthGuard implements CanActivate { const request = context.switchToHttp().getRequest(); const reply = context.switchToHttp().getResponse(); - const tokenParam = (request.query as Record).t; + const tokenQuery = (request.query as Record).t; + const tokenParam = Array.isArray(tokenQuery) ? tokenQuery[0] : tokenQuery; if (tokenParam) { + const requestPath = stripQuery(request.url ?? ''); + if (!isTokenImagePath(requestPath)) { + reply.header('WWW-Authenticate', 'Basic realm="projectx OPDS"'); + throw new UnauthorizedException('Basic authentication required'); + } + const secret = this.config.get('auth.jwtSecret')!; const userId = parseCoverToken(tokenParam, secret); if (!userId) throw new UnauthorizedException('Invalid token'); diff --git a/server/src/modules/opds/opds-book.service.ts b/server/src/modules/opds/opds-book.service.ts index 8c988e8d2..f07b830c1 100644 --- a/server/src/modules/opds/opds-book.service.ts +++ b/server/src/modules/opds/opds-book.service.ts @@ -106,6 +106,17 @@ export class OpdsBookService { throw new ForbiddenException('No access to this library'); } + 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) { + throw new ForbiddenException('No access to this collection'); + } + } + if (filters?.lensId) { return this.getBooksByLens(userId, filters.lensId, accessibleIds, sortOrder, page, size); } diff --git a/server/src/modules/opds/opds-user.service.ts b/server/src/modules/opds/opds-user.service.ts index 3b0268b5d..1714a2a7b 100644 --- a/server/src/modules/opds/opds-user.service.ts +++ b/server/src/modules/opds/opds-user.service.ts @@ -10,6 +10,17 @@ import { UpdateOpdsUserDto } from './dto/update-opds-user.dto'; type Db = NodePgDatabase; +function isUniqueViolation(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + + const directCode = (error as { code?: unknown }).code; + if (directCode === '23505') return true; + + if (!(error instanceof Error)) return false; + const causeCode = (error.cause as { code?: unknown } | undefined)?.code; + return causeCode === '23505'; +} + @Injectable() export class OpdsUserService { constructor(@Inject(DB) private readonly db: Db) {} @@ -48,7 +59,7 @@ export class OpdsUserService { }); return created; } catch (err: unknown) { - if (err instanceof Error && 'code' in err && (err as { code: string }).code === '23505') { + if (isUniqueViolation(err)) { throw new ConflictException('An OPDS user with this username already exists'); } throw err; diff --git a/server/src/modules/opds/opds.controller.ts b/server/src/modules/opds/opds.controller.ts index 7143531cc..dc1fdc0e5 100644 --- a/server/src/modules/opds/opds.controller.ts +++ b/server/src/modules/opds/opds.controller.ts @@ -1,4 +1,16 @@ -import { Controller, DefaultValuePipe, Get, Headers, NotFoundException, Param, ParseIntPipe, Query, Res, UseGuards } from '@nestjs/common'; +import { + BadRequestException, + Controller, + DefaultValuePipe, + Get, + Headers, + NotFoundException, + Param, + ParseIntPipe, + Query, + Res, + UseGuards, +} from '@nestjs/common'; import { createReadStream } from 'fs'; import { readdir, stat } from 'fs/promises'; import { join } from 'path'; @@ -86,9 +98,13 @@ export class OpdsController { const clampedPage = Math.max(page, 1); const filters: Record = {}; - if (libraryIdStr) filters.libraryId = parseInt(libraryIdStr, 10); - if (collectionIdStr) filters.collectionId = parseInt(collectionIdStr, 10); - if (lensIdStr) filters.lensId = parseInt(lensIdStr, 10); + const libraryId = this.parseOptionalPositiveInt('libraryId', libraryIdStr); + const collectionId = this.parseOptionalPositiveInt('collectionId', collectionIdStr); + const lensId = this.parseOptionalPositiveInt('lensId', lensIdStr); + + if (libraryId !== undefined) filters.libraryId = libraryId; + if (collectionId !== undefined) filters.collectionId = collectionId; + if (lensId !== undefined) filters.lensId = lensId; if (author) filters.author = author; if (series) filters.series = series; if (q) filters.q = q; @@ -254,4 +270,17 @@ export class OpdsController { private sendXml(reply: FastifyReply, xml: string, mimeType: string) { reply.type(`${mimeType}; charset=utf-8`).send(xml); } + + private parseOptionalPositiveInt(name: string, value?: string): number | undefined { + if (value === undefined) return undefined; + if (!/^\d+$/.test(value)) { + throw new BadRequestException(`${name} must be a positive integer`); + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new BadRequestException(`${name} must be a positive integer`); + } + return parsed; + } } diff --git a/server/test/authorization-matrix.e2e-spec.ts b/server/test/authorization-matrix.e2e-spec.ts index dd94251c2..a47fed829 100644 --- a/server/test/authorization-matrix.e2e-spec.ts +++ b/server/test/authorization-matrix.e2e-spec.ts @@ -596,12 +596,12 @@ describe('Authorization matrix (e2e)', () => { it('rejects invalid OPDS token auth', async () => { const response = await ctx.app.inject({ method: 'GET', - url: '/api/v1/opds/libraries?t=invalid-token', + url: `/api/v1/opds/${bookA.bookId}/cover?t=invalid-token`, }); expectError(response, 401, 'Invalid token'); }); - it('allows OPDS token auth when token and permission are valid', async () => { + it('rejects OPDS token auth on non-image routes', async () => { const config = ctx.app.get(ConfigService); const jwtSecret = config.getOrThrow('auth.jwtSecret'); const token = createCoverToken(personas.opdsOwner.userId, jwtSecret); @@ -610,8 +610,21 @@ describe('Authorization matrix (e2e)', () => { method: 'GET', url: `/api/v1/opds/libraries?t=${token}`, }); + expectError(response, 401, 'Basic authentication required'); + expect(response.headers['www-authenticate']).toContain('Basic realm='); + }); + + it('allows OPDS token auth on image routes when token and permission are valid', async () => { + const config = ctx.app.get(ConfigService); + const jwtSecret = config.getOrThrow('auth.jwtSecret'); + const token = createCoverToken(personas.opdsOwner.userId, jwtSecret); + + const response = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/opds/${bookA.bookId}/cover?t=${token}`, + }); expect(response.statusCode).toBe(200); - expect(response.headers['content-type']).toContain('application/atom+xml'); + expect(response.headers['content-type']).toContain('image/jpeg'); }); }); diff --git a/server/test/e2e/opds/opds-fixture-builder.ts b/server/test/e2e/opds/opds-fixture-builder.ts new file mode 100644 index 000000000..57f53952d --- /dev/null +++ b/server/test/e2e/opds/opds-fixture-builder.ts @@ -0,0 +1,43 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { dirname, join } from 'path'; + +export { createEpubFixture } from '../metadata-write/metadata-write-fixture-builder'; + +export interface OpdsFixtureRoot { + rootPath: string; + booksPath: string; + stagingPath: string; + cleanup: () => Promise; +} + +function assertRelativePath(path: string): void { + if (path.startsWith('/')) { + throw new Error(`Fixture paths must be relative. Received "${path}"`); + } +} + +export async function createOpdsFixtureRoot(prefix = 'opds-e2e-'): Promise { + const rootPath = await mkdtemp(join(tmpdir(), prefix)); + const booksPath = join(rootPath, 'books'); + const stagingPath = join(booksPath, 'staging'); + await mkdir(stagingPath, { recursive: true }); + + return { + rootPath, + booksPath, + stagingPath, + cleanup: async () => { + await rm(rootPath, { recursive: true, force: true }); + }, + }; +} + +export async function writeFixtureFile(rootPath: string, relativePath: string, content: string | Buffer): Promise { + assertRelativePath(relativePath); + + const absolutePath = join(rootPath, relativePath); + await mkdir(dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, content); + return absolutePath; +} diff --git a/server/test/e2e/opds/opds-harness.ts b/server/test/e2e/opds/opds-harness.ts new file mode 100644 index 000000000..6a59539ab --- /dev/null +++ b/server/test/e2e/opds/opds-harness.ts @@ -0,0 +1,407 @@ +import { randomUUID } from 'crypto'; +import { hash } from 'bcryptjs'; +import fastifyCookie from '@fastify/cookie'; +import { Test } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { mkdir } from 'fs/promises'; +import { and, eq } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { DEFAULT_FORMAT_PRIORITY, type Permission } from '@projectx/types'; + +import { AppModule } from '../../../src/app.module'; +import { DB } from '../../../src/db'; +import * as schema from '../../../src/db/schema'; +import { MetadataService } from '../../../src/modules/metadata/metadata.service'; +import { StagingWatcherService } from '../../../src/modules/staging/staging-watcher.service'; +import { seedLibrary, waitForScanCompletion } from '../app-harness'; +import { createOpdsFixtureRoot, type OpdsFixtureRoot, writeFixtureFile } from './opds-fixture-builder'; + +type Db = NodePgDatabase; + +const ADMIN_SETUP_DTO = { + username: 'opds-e2e-admin', + name: 'OPDS E2E Admin', + email: 'opds-e2e-admin@example.com', + password: 'OpdsE2EAdmin123', +}; + +interface EnvSnapshot { + booksPath: string | undefined; + stagingPath: string | undefined; +} + +export interface OpdsE2EContext { + app: NestFastifyApplication; + db: Db; + adminToken: string; + fixture: OpdsFixtureRoot; + envSnapshot: EnvSnapshot; +} + +export interface CreatedLibrary { + libraryId: number; + libraryFolderId: number; + folderPath: string; +} + +export interface TestUserSession { + userId: number; + username: string; + password: string; + accessToken: string; +} + +export interface LocatedBookFile { + libraryId: number; + bookId: number; + bookFileId: number; + absolutePath: string; + relPath: string | null; + format: string | null; +} + +export function authHeader(token: string): Record { + return { authorization: `Bearer ${token}` }; +} + +export function basicAuth(username: string, password: string): string { + const value = Buffer.from(`${username}:${password}`, 'utf8').toString('base64'); + return `Basic ${value}`; +} + +export async function createOpdsE2EContext(): Promise { + const fixture = await createOpdsFixtureRoot(); + const envSnapshot: EnvSnapshot = { + booksPath: process.env.BOOKS_PATH, + stagingPath: process.env.STAGING_PATH, + }; + + process.env.BOOKS_PATH = fixture.booksPath; + process.env.STAGING_PATH = fixture.stagingPath; + + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(MetadataService) + .useValue(makeMetadataNoopMock()) + .compile(); + + const app = moduleFixture.createNestApplication(new FastifyAdapter()); + app.setGlobalPrefix('api/v1'); + await app.register(fastifyCookie as never); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + + await stopStagingWatcher(app); + + const db = app.get(DB); + const adminToken = await getAdminToken(app, db); + await setSettingValue(db, 'opds_enabled', 'true'); + + return { + app, + db, + adminToken, + fixture, + envSnapshot, + }; +} + +export async function closeOpdsE2EContext(ctx: OpdsE2EContext): Promise { + await ctx.app.close(); + await ctx.fixture.cleanup(); + restoreEnv(ctx.envSnapshot); +} + +export async function createLibraryWithFolder( + ctx: OpdsE2EContext, + options: { + mode?: 'book_per_file' | 'book_per_folder'; + allowedFormats?: string[]; + name?: string; + } = {}, +): Promise { + const folderPath = `${ctx.fixture.booksPath}/library-${randomUUID()}`; + await mkdir(folderPath, { recursive: true }); + + const [library] = await ctx.db + .insert(schema.libraries) + .values({ + name: options.name ?? `opds-e2e-library-${randomUUID()}`, + watch: false, + organizationMode: options.mode ?? 'book_per_file', + allowedFormats: options.allowedFormats ?? [], + excludePatterns: [], + formatPriority: [...DEFAULT_FORMAT_PRIORITY], + }) + .returning({ id: schema.libraries.id }); + + const [libraryFolder] = await ctx.db + .insert(schema.libraryFolders) + .values({ + libraryId: library.id, + path: folderPath, + }) + .returning({ id: schema.libraryFolders.id }); + + return { + libraryId: library.id, + libraryFolderId: libraryFolder.id, + folderPath, + }; +} + +export async function triggerAndWaitForLibraryScan( + ctx: OpdsE2EContext, + libraryId: number, + timeoutMs = 45_000, +): Promise { + const response = await ctx.app.inject({ + method: 'POST', + url: `/api/v1/scanner/libraries/${libraryId}/scan`, + headers: authHeader(ctx.adminToken), + }); + + if (response.statusCode !== 202) { + throw new Error(`Scan endpoint failed: ${response.statusCode} ${response.body}`); + } + + const body = response.json() as { jobId?: number }; + if (!body.jobId) { + throw new Error(`Scan endpoint returned no jobId: ${response.body}`); + } + + return waitForScanCompletion(ctx.db, body.jobId, timeoutMs); +} + +export async function locateBookByAbsolutePath(ctx: OpdsE2EContext, absolutePath: string): Promise { + const [row] = await ctx.db + .select({ + libraryId: schema.books.libraryId, + bookId: schema.books.id, + bookFileId: schema.bookFiles.id, + absolutePath: schema.bookFiles.absolutePath, + relPath: schema.bookFiles.relPath, + format: schema.bookFiles.format, + }) + .from(schema.bookFiles) + .innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId)) + .where(and(eq(schema.books.status, 'present'), eq(schema.bookFiles.absolutePath, absolutePath))) + .limit(1); + + if (!row) { + throw new Error(`No present book file found for path "${absolutePath}"`); + } + + return row; +} + +export async function createBookCoverArtifacts( + ctx: OpdsE2EContext, + bookId: number, + options: { + coverExtension?: 'jpg' | 'png'; + coverContent?: Buffer; + thumbnailContent?: Buffer; + } = {}, +): Promise { + const coverExtension = options.coverExtension ?? 'jpg'; + const coverContent = options.coverContent ?? Buffer.from(`cover-${bookId}`, 'utf8'); + const thumbnailContent = options.thumbnailContent ?? Buffer.from(`thumbnail-${bookId}`, 'utf8'); + + await writeFixtureFile(ctx.fixture.booksPath, `covers/${bookId}/cover.${coverExtension}`, coverContent); + await writeFixtureFile(ctx.fixture.booksPath, `covers/${bookId}/thumbnail.jpg`, thumbnailContent); +} + +export async function createUserAndLogin( + ctx: OpdsE2EContext, + options: { + permissions?: Permission[]; + isSuperuser?: boolean; + isDefaultPassword?: boolean; + active?: boolean; + username?: string; + password?: string; + email?: string; + } = {}, +): Promise { + const suffix = randomUUID().replaceAll('-', ''); + const username = options.username ?? `opds-e2e-user-${suffix}`; + const password = options.password ?? 'OpdsE2EUser123'; + const email = options.email ?? `${username}@example.com`; + const passwordHash = await hash(password, 12); + + const [created] = await ctx.db + .insert(schema.users) + .values({ + username, + name: `OPDS E2E User ${suffix}`, + email, + passwordHash, + active: options.active ?? true, + isSuperuser: options.isSuperuser ?? false, + isDefaultPassword: options.isDefaultPassword ?? false, + provisioningMethod: 'local', + }) + .returning({ id: schema.users.id }); + + const permissions = options.permissions ?? []; + if (permissions.length > 0) { + await ctx.db.insert(schema.userPermissions).values(permissions.map((permissionName) => ({ userId: created.id, permissionName }))); + } + + const accessToken = await loginForToken(ctx.app, username, password); + if (!accessToken) { + throw new Error(`Login failed for ${username}`); + } + + return { + userId: created.id, + username, + password, + accessToken, + }; +} + +export async function grantLibraryAccess( + ctx: OpdsE2EContext, + userId: number, + libraryId: number, + accessLevel: 'viewer' | 'editor' | 'owner' = 'viewer', +): Promise { + await ctx.db + .insert(schema.userLibraryAccess) + .values({ userId, libraryId, accessLevel }) + .onConflictDoUpdate({ + target: [schema.userLibraryAccess.userId, schema.userLibraryAccess.libraryId], + set: { accessLevel }, + }); +} + +export async function replaceUserPermissions(ctx: OpdsE2EContext, userId: number, permissions: Permission[]): Promise { + await ctx.db.delete(schema.userPermissions).where(eq(schema.userPermissions.userId, userId)); + if (permissions.length === 0) return; + await ctx.db.insert(schema.userPermissions).values(permissions.map((permissionName) => ({ userId, permissionName }))); +} + +export async function setUserActive(ctx: OpdsE2EContext, userId: number, active: boolean): Promise { + await ctx.db.update(schema.users).set({ active }).where(eq(schema.users.id, userId)); +} + +export async function setSetting(ctx: OpdsE2EContext, key: string, value: string): Promise { + await setSettingValue(ctx.db, key, value); +} + +export async function createOpdsUserCredential( + ctx: OpdsE2EContext, + input: { + userId: number; + username?: string; + password?: string; + sortOrder?: typeof schema.opdsUsers.$inferInsert.sortOrder; + }, +): Promise<{ row: typeof schema.opdsUsers.$inferSelect; password: string }> { + const username = input.username ?? `opds-${randomUUID().replaceAll('-', '').slice(0, 12)}`; + const password = input.password ?? 'OpdsCredential123'; + const passwordHash = await hash(password, 12); + + const [row] = await ctx.db + .insert(schema.opdsUsers) + .values({ + userId: input.userId, + username, + passwordHash, + sortOrder: input.sortOrder ?? 'recent', + }) + .returning(); + + return { row, password }; +} + +function makeMetadataNoopMock(): Pick< + MetadataService, + 'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators' +> { + return { + extractAndSave: () => Promise.resolve(undefined), + refreshCoverForBook: () => Promise.resolve(false), + extractAudioFileDuration: () => Promise.resolve(undefined), + aggregateAudioDuration: () => Promise.resolve(undefined), + extractAudioChaptersAndNarrators: () => Promise.resolve(undefined), + }; +} + +async function stopStagingWatcher(app: NestFastifyApplication): Promise { + const watcher = app.get(StagingWatcherService); + await watcher.onModuleDestroy(); +} + +async function getAdminToken(app: NestFastifyApplication, db: Db): Promise { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/setup', + payload: ADMIN_SETUP_DTO, + }); + + if (response.statusCode === 409) { + const existingToken = await loginForToken(app, ADMIN_SETUP_DTO.username, ADMIN_SETUP_DTO.password); + if (existingToken) return existingToken; + + const suffix = randomUUID().replaceAll('-', ''); + const fallbackUsername = `opds-e2e-admin-${suffix}`; + const passwordHash = await hash(ADMIN_SETUP_DTO.password, 12); + + await db.insert(schema.users).values({ + username: fallbackUsername, + name: 'OPDS E2E Admin', + email: `${fallbackUsername}@example.com`, + passwordHash, + isSuperuser: true, + isDefaultPassword: false, + provisioningMethod: 'local', + }); + + const fallbackToken = await loginForToken(app, fallbackUsername, ADMIN_SETUP_DTO.password); + if (fallbackToken) return fallbackToken; + throw new Error('Setup is already complete and fallback admin login failed'); + } + + if (response.statusCode !== 201) { + throw new Error(`Unable to complete setup: ${response.statusCode} ${response.body}`); + } + + const body = response.json() as { accessToken?: string }; + if (!body.accessToken) { + throw new Error('Setup succeeded but accessToken is missing'); + } + + return body.accessToken; +} + +async function loginForToken(app: NestFastifyApplication, username: string, password: string): Promise { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { username, password }, + }); + if (response.statusCode !== 200) return null; + const body = response.json() as { accessToken?: string }; + return body.accessToken ?? null; +} + +async function setSettingValue(db: Db, key: string, value: string): Promise { + await db.insert(schema.appSettings).values({ key, value }).onConflictDoUpdate({ + target: schema.appSettings.key, + set: { value }, + }); +} + +function restoreEnv(snapshot: EnvSnapshot): void { + if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH; + else process.env.BOOKS_PATH = snapshot.booksPath; + + if (snapshot.stagingPath === undefined) delete process.env.STAGING_PATH; + else process.env.STAGING_PATH = snapshot.stagingPath; +} + +export { seedLibrary }; diff --git a/server/test/opds-auth-catalog.e2e-spec.ts b/server/test/opds-auth-catalog.e2e-spec.ts new file mode 100644 index 000000000..161c25f4f --- /dev/null +++ b/server/test/opds-auth-catalog.e2e-spec.ts @@ -0,0 +1,607 @@ +import { randomUUID } from 'crypto'; +import { mkdir, writeFile } from 'fs/promises'; +import { dirname, join } from 'path'; + +import { eq } from 'drizzle-orm'; +import { Permission } from '@projectx/types'; +import { ConfigService } from '@nestjs/config'; + +import { createCoverToken } from '../src/modules/opds/opds-auth.guard'; +import * as schema from '../src/db/schema'; +import { createEpubFixture } 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 { + 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 visibleLibrary!: CreatedLibrary; + let hiddenLibrary!: CreatedLibrary; + + let visibleBookAlpha!: LocatedBookFile; + let visibleBookBeta!: LocatedBookFile; + let hiddenBook!: LocatedBookFile; + + let ownerCredentials!: OpdsCredentials; + let disabledCredentials!: OpdsCredentials; + let revokedCredentials!: OpdsCredentials; + + let ownerCollectionId!: number; + let foreignCollectionId!: number; + let ownerLensId!: number; + let foreignLensId!: number; + + let visibleAuthorName!: string; + let hiddenAuthorName!: string; + let visibleSeriesName!: string; + let hiddenSeriesName!: string; + + beforeAll(async () => { + ctx = await createOpdsE2EContext(); + + visibleLibrary = await createLibraryWithFolder(ctx, { name: `opds-visible-library-${randomUUID()}` }); + hiddenLibrary = await createLibraryWithFolder(ctx, { name: `opds-hidden-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' }); + + await triggerAndWaitForLibraryScan(ctx, visibleLibrary.libraryId); + await triggerAndWaitForLibraryScan(ctx, hiddenLibrary.libraryId); + + visibleBookAlpha = await locateBookByAbsolutePath(ctx, visibleAlphaPath); + visibleBookBeta = await locateBookByAbsolutePath(ctx, visibleBetaPath); + hiddenBook = await locateBookByAbsolutePath(ctx, hiddenGammaPath); + + 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); + await seedBookMetadata(visibleBookBeta.bookId, 'Visible Beta', visibleSeriesName, 2); + await seedBookMetadata(hiddenBook.bookId, 'Hidden Gamma', hiddenSeriesName, 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); + + 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'); + + 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', + }); + + ownerCredentials = { username: ownerOpds.row.username, password: ownerOpds.password }; + disabledCredentials = { username: disabledOpds.row.username, password: disabledOpds.password }; + revokedCredentials = { username: revokedOpds.row.username, password: revokedOpds.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 [ownerLens] = await ctx.db + .insert(schema.lenses) + .values({ + userId: owner.userId, + name: `owner-lens-${randomUUID().slice(0, 8)}`, + filter: null, + isPublic: false, + }) + .returning({ id: schema.lenses.id }); + ownerLensId = ownerLens.id; + + const [foreignLens] = await ctx.db + .insert(schema.lenses) + .values({ + userId: intruder.userId, + name: `foreign-lens-${randomUUID().slice(0, 8)}`, + filter: null, + isPublic: false, + }) + .returning({ id: schema.lenses.id }); + foreignLensId = foreignLens.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('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}'); + }); + + it('scopes libraries, collections, lenses, 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 lensesResponse = await opdsGet('/api/v1/opds/lenses', ownerCredentials); + expect(lensesResponse.statusCode).toBe(200); + expect(lensesResponse.body).toContain(`lensId=${ownerLensId}`); + expect(lensesResponse.body).not.toContain(`lensId=${foreignLensId}`); + + 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('2'); + 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 invalidLensFilterResponse = await opdsGet('/api/v1/opds/catalog?lensId=bad', ownerCredentials); + expectError(invalidLensFilterResponse, 400, 'lensId 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 ownerLensResponse = await opdsGet(`/api/v1/opds/catalog?lensId=${ownerLensId}`, ownerCredentials); + expect(ownerLensResponse.statusCode).toBe(200); + expect(ownerLensResponse.body).toContain('2'); + + const foreignLensResponse = await opdsGet(`/api/v1/opds/catalog?lensId=${foreignLensId}`, ownerCredentials); + expect(foreignLensResponse.statusCode).toBe(200); + expect(foreignLensResponse.body).toContain('0'); + + 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'); + }); + }); + + 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'); + }); + }); + + async function seedBookMetadata(bookId: number, title: string, seriesName: string, seriesIndex: number): Promise { + await ctx.db.insert(schema.bookMetadata).values({ bookId, title, seriesName, seriesIndex }).onConflictDoUpdate({ + target: schema.bookMetadata.bookId, + set: { title, seriesName, seriesIndex }, + }); + } + + async function linkBookAuthor(bookId: number, authorName: string): Promise { + 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>, 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>): Record | null { + try { + return response.json() as Record; + } catch { + return null; + } + } + + function extractMessage(response: Awaited>): 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 ''; + } +});