test(opds): add auth catalog end-to-end tests

This commit is contained in:
neon
2026-04-02 00:05:00 -06:00
parent 914f8a3440
commit 64abfa8c41
14 changed files with 1256 additions and 14 deletions
@@ -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
+13 -2
View File
@@ -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 -- <suite-id>
@@ -180,11 +190,12 @@ pnpm run test:e2e -- <suite-id>
| 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`.
+1
View File
@@ -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",
+8
View File
@@ -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() {
@@ -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<string, string> = {};
if (tokenParam) query.t = tokenParam;
const request: Record<string, unknown> = { headers: { authorization: authHeader }, query };
const request: Record<string, unknown> = { 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'));
@@ -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({
+18 -1
View File
@@ -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<FastifyRequest>();
const reply = context.switchToHttp().getResponse<FastifyReply>();
const tokenParam = (request.query as Record<string, string | undefined>).t;
const tokenQuery = (request.query as Record<string, string | string[] | undefined>).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<string>('auth.jwtSecret')!;
const userId = parseCoverToken(tokenParam, secret);
if (!userId) throw new UnauthorizedException('Invalid token');
@@ -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);
}
+12 -1
View File
@@ -10,6 +10,17 @@ import { UpdateOpdsUserDto } from './dto/update-opds-user.dto';
type Db = NodePgDatabase<typeof schema>;
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;
+33 -4
View File
@@ -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<string, string | number> = {};
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;
}
}
+16 -3
View File
@@ -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<string>('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<string>('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');
});
});
@@ -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<void>;
}
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<OpdsFixtureRoot> {
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<string> {
assertRelativePath(relativePath);
const absolutePath = join(rootPath, relativePath);
await mkdir(dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content);
return absolutePath;
}
+407
View File
@@ -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<typeof schema>;
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<string, string> {
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<OpdsE2EContext> {
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<NestFastifyApplication>(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>(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<void> {
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<CreatedLibrary> {
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<typeof schema.scanJobs.$inferSelect> {
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<LocatedBookFile> {
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<void> {
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<TestUserSession> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const watcher = app.get(StagingWatcherService);
await watcher.onModuleDestroy();
}
async function getAdminToken(app: NestFastifyApplication, db: Db): Promise<string> {
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<string | null> {
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<void> {
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 };
+607
View File
@@ -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<void> {
const reportDir = process.env.JUNIT_OUTPUT ? dirname(process.env.JUNIT_OUTPUT) : join(process.cwd(), '..', 'test-results', 'server');
await mkdir(reportDir, { recursive: true });
const reportPath = join(reportDir, 'opds-auth-catalog-e2e-scenarios.json');
await writeFile(
reportPath,
JSON.stringify(
{
generatedAt: new Date().toISOString(),
total: results.length,
passed: results.filter((result) => result.status === 'passed').length,
failed: results.filter((result) => result.status === 'failed').length,
results,
},
null,
2,
),
);
}
describe('OPDS auth and catalog (e2e)', { timeout: 120_000 }, () => {
let ctx!: OpdsE2EContext;
const scenarioResults: ScenarioRunResult[] = [];
let scenarioStartedAt = 0;
let owner!: TestUserSession;
let intruder!: TestUserSession;
let disabledParent!: TestUserSession;
let revokedParent!: TestUserSession;
let noOpdsPermissionUser!: TestUserSession;
let 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<string>('auth.jwtSecret');
const token = createCoverToken(owner.userId, jwtSecret);
const response = await opdsGet(`/api/v1/opds/catalog?t=${token}`);
expectError(response, 401, 'Basic authentication required');
expect(response.headers['www-authenticate']).toContain('Basic realm=');
});
it('rejects invalid token auth on image routes', async () => {
const response = await opdsGet(`/api/v1/opds/${visibleBookAlpha.bookId}/cover?t=not-a-valid-token`);
expectError(response, 401, 'Invalid token');
});
});
describe('catalog/feed contract', () => {
it('serves root navigation and search description with expected MIME and links', async () => {
const rootResponse = await opdsGet('/api/v1/opds', ownerCredentials);
expect(rootResponse.statusCode).toBe(200);
expect(rootResponse.headers['content-type']).toContain('application/atom+xml;profile=opds-catalog');
expect(rootResponse.body).toContain('/api/v1/opds/catalog');
expect(rootResponse.body).toContain('/api/v1/opds/recent');
expect(rootResponse.body).toContain('/api/v1/opds/surprise');
expect(rootResponse.body).toContain('/api/v1/opds/libraries');
expect(rootResponse.body).toContain('/api/v1/opds/search.opds');
const searchResponse = await opdsGet('/api/v1/opds/search.opds', ownerCredentials);
expect(searchResponse.statusCode).toBe(200);
expect(searchResponse.headers['content-type']).toContain('application/opensearchdescription+xml');
expect(searchResponse.body).toContain('/api/v1/opds/catalog?q={searchTerms}');
});
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('<opensearch:totalResults>2</opensearch:totalResults>');
expect(page1Response.body).toContain('rel="next"');
expect(page1Response.body).not.toContain('rel="previous"');
const page2Response = await opdsGet('/api/v1/opds/catalog?page=2&size=1', ownerCredentials);
expect(page2Response.statusCode).toBe(200);
expect(page2Response.body).toContain('rel="previous"');
const ownerLibraryResponse = await opdsGet(`/api/v1/opds/catalog?libraryId=${visibleLibrary.libraryId}`, ownerCredentials);
expect(ownerLibraryResponse.statusCode).toBe(200);
expect(ownerLibraryResponse.body).toContain('Visible Alpha');
const forbiddenLibraryResponse = await opdsGet(`/api/v1/opds/catalog?libraryId=${hiddenLibrary.libraryId}`, ownerCredentials);
expectError(forbiddenLibraryResponse, 403, 'No access to this library');
const invalidLibraryFilterResponse = await opdsGet('/api/v1/opds/catalog?libraryId=abc', ownerCredentials);
expectError(invalidLibraryFilterResponse, 400, 'libraryId must be a positive integer');
const invalidCollectionFilterResponse = await opdsGet('/api/v1/opds/catalog?collectionId=oops', ownerCredentials);
expectError(invalidCollectionFilterResponse, 400, 'collectionId must be a positive integer');
const 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('<opensearch:totalResults>2</opensearch:totalResults>');
const foreignLensResponse = await opdsGet(`/api/v1/opds/catalog?lensId=${foreignLensId}`, ownerCredentials);
expect(foreignLensResponse.statusCode).toBe(200);
expect(foreignLensResponse.body).toContain('<opensearch:totalResults>0</opensearch:totalResults>');
const searchResponse = await opdsGet('/api/v1/opds/catalog?q=Visible Alpha', ownerCredentials);
expect(searchResponse.statusCode).toBe(200);
expect(searchResponse.body).toContain('Visible Alpha');
expect(searchResponse.body).not.toContain('Visible Beta');
});
});
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<void> {
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<void> {
const inserted = await ctx.db.insert(schema.authors).values({ name: authorName }).onConflictDoNothing().returning({ id: schema.authors.id });
let authorId = inserted[0]?.id;
if (!authorId) {
const [existing] = await ctx.db.select({ id: schema.authors.id }).from(schema.authors).where(eq(schema.authors.name, authorName)).limit(1);
if (!existing) throw new Error(`Author lookup failed for ${authorName}`);
authorId = existing.id;
}
await ctx.db.insert(schema.bookAuthors).values({ bookId, authorId, displayOrder: 0 }).onConflictDoNothing();
}
async function opdsGet(path: string, credentials?: OpdsCredentials) {
return ctx.app.inject({
method: 'GET',
url: path,
headers: credentials ? { authorization: basicAuth(credentials.username, credentials.password) } : undefined,
});
}
function extractCoverToken(feedXml: string, bookId: number): string {
const tokenMatch = feedXml.match(new RegExp(`/api/v1/opds/${bookId}/cover\\?t=([^"&<]+)`));
if (!tokenMatch?.[1]) {
throw new Error(`Expected to find feed-issued cover token for book ${bookId}`);
}
return decodeURIComponent(tokenMatch[1]);
}
function expectError(response: Awaited<ReturnType<OpdsE2EContext['app']['inject']>>, statusCode: number, messageFragment: string): void {
expect(response.statusCode).toBe(statusCode);
const body = parseBody(response);
if (body && typeof body === 'object') {
expect(body).toEqual(
expect.objectContaining({
statusCode,
}),
);
}
expect(extractMessage(response)).toContain(messageFragment);
}
function parseBody(response: Awaited<ReturnType<OpdsE2EContext['app']['inject']>>): Record<string, unknown> | null {
try {
return response.json() as Record<string, unknown>;
} catch {
return null;
}
}
function extractMessage(response: Awaited<ReturnType<OpdsE2EContext['app']['inject']>>): string {
const body = parseBody(response);
if (!body) return response.body;
const message = body.message;
if (typeof message === 'string') return message;
if (Array.isArray(message)) {
return message.filter((item): item is string => typeof item === 'string').join('; ');
}
return '';
}
});