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
+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 '';
}
});