mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
test(opds): add auth catalog end-to-end tests
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 '';
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user