diff --git a/package.json b/package.json index b8a078772..8e77a4984 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "dev:client": "cd client && npm run dev -- --host", "dev:server": "cd server && npm run start:dev", "verify:fast": "pnpm run lint:check && pnpm run typecheck && pnpm run test", - "verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test && pnpm run e2e:run -- app-smoke", - "verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test && pnpm run e2e:run -- app-smoke", + "verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test", + "verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test", "build:client": "pnpm --filter client build", "build:server": "pnpm --filter server build", "typecheck": "pnpm run typecheck:server && pnpm run typecheck:client", diff --git a/scripts/test/e2e-suites.mjs b/scripts/test/e2e-suites.mjs index 258b4c2c9..51f860642 100644 --- a/scripts/test/e2e-suites.mjs +++ b/scripts/test/e2e-suites.mjs @@ -1,13 +1,13 @@ const TEST_RESULTS_DIR = "../test-results/server"; export const E2E_SUITES = Object.freeze({ - "app-smoke": { - id: "app-smoke", - name: "App Smoke", + "guard-mechanics": { + id: "guard-mechanics", + name: "Guard Mechanics", timeout: 30, - description: "App smoke suite", - vitestTarget: "test/app-smoke.e2e-spec.ts", - junitOutput: `${TEST_RESULTS_DIR}/app-smoke-e2e-junit.xml`, + description: "Guard mechanics suite - verifies @Public/@RequirePermission wiring without a real DB", + vitestTarget: "test/guard-mechanics.e2e-spec.ts", + junitOutput: `${TEST_RESULTS_DIR}/guard-mechanics-e2e-junit.xml`, prepareDedicatedDatabase: false, useDedicatedDatabase: false, }, @@ -101,6 +101,46 @@ export const E2E_SUITES = Object.freeze({ prepareDedicatedDatabase: true, useDedicatedDatabase: true, }, + "book-api-contract": { + id: "book-api-contract", + name: "Book API Contract", + timeout: 60, + description: "Book API contract suite", + vitestTarget: "test/book-api-contract.e2e-spec.ts", + junitOutput: `${TEST_RESULTS_DIR}/book-api-contract-e2e-junit.xml`, + prepareDedicatedDatabase: true, + useDedicatedDatabase: true, + }, + "app-settings-oidc-contract": { + id: "app-settings-oidc-contract", + name: "App Settings OIDC Contract", + timeout: 60, + description: "App settings OIDC contract suite", + vitestTarget: "test/app-settings-oidc-contract.e2e-spec.ts", + junitOutput: `${TEST_RESULTS_DIR}/app-settings-oidc-contract-e2e-junit.xml`, + prepareDedicatedDatabase: true, + useDedicatedDatabase: true, + }, + "library-admin-workflows": { + id: "library-admin-workflows", + name: "Library Admin Workflows", + timeout: 60, + description: "Library admin workflows suite", + vitestTarget: "test/library-admin-workflows.e2e-spec.ts", + junitOutput: `${TEST_RESULTS_DIR}/library-admin-workflows-e2e-junit.xml`, + prepareDedicatedDatabase: true, + useDedicatedDatabase: true, + }, + "reader-format-delivery": { + id: "reader-format-delivery", + name: "Reader Format Delivery", + timeout: 60, + description: "Reader format delivery suite", + vitestTarget: "test/reader-format-delivery.e2e-spec.ts", + junitOutput: `${TEST_RESULTS_DIR}/reader-format-delivery-e2e-junit.xml`, + prepareDedicatedDatabase: true, + useDedicatedDatabase: true, + }, "opds-auth-catalog": { id: "opds-auth-catalog", name: "OPDS Auth Catalog", diff --git a/server/src/modules/reader/epub/epub.service.ts b/server/src/modules/reader/epub/epub.service.ts index 5cfee3dd6..73eb86354 100644 --- a/server/src/modules/reader/epub/epub.service.ts +++ b/server/src/modules/reader/epub/epub.service.ts @@ -303,6 +303,9 @@ export class EpubService { const epubPath = await this.resolveEpubPath(bookId, fileId, user); const cached = await this.getCachedEntry(epubPath); + if (!cached.validPaths.has(normalizedPath)) { + throw new NotFoundException(`Entry not in archive: ${normalizedPath}`); + } const zip = await unzipper.Open.file(epubPath); const entry = findInZip(zip.files, normalizedPath); if (!entry) throw new NotFoundException(`Entry not in archive: ${normalizedPath}`); @@ -343,6 +346,7 @@ export class EpubService { const validPaths = new Set(['META-INF/container.xml', normalizeZipPath(info.containerPath)]); for (const item of info.manifest) validPaths.add(normalizeZipPath(item.href)); + for (const path of info.optionalFiles ?? []) validPaths.add(normalizeZipPath(path)); const entry: CacheEntry = { info, mtime: mtimeMs, validPaths, lastAccessed: Date.now() }; this.evict(); diff --git a/server/src/modules/upload/upload.service.test.ts b/server/src/modules/upload/upload.service.test.ts index 166d3e42c..9f7bb90c2 100644 --- a/server/src/modules/upload/upload.service.test.ts +++ b/server/src/modules/upload/upload.service.test.ts @@ -134,7 +134,7 @@ describe('UploadService', () => { expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book/book.epub'); }); - it('throws ConflictException when destination already exists and cleans both temp and destination paths', async () => { + it('throws ConflictException when destination already exists and cleans temp path', async () => { db.select .mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }])) .mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }])); @@ -142,7 +142,7 @@ describe('UploadService', () => { await expect(service.upload(1, 2, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(ConflictException); expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin'); - expect(storage.cleanup).toHaveBeenCalledWith('/library/book/book.epub'); + expect(storage.cleanup).not.toHaveBeenCalledWith('/library/book/book.epub'); expect(storage.moveToPath).not.toHaveBeenCalled(); }); diff --git a/server/src/modules/upload/upload.service.ts b/server/src/modules/upload/upload.service.ts index b8505d295..0d88ffcfb 100644 --- a/server/src/modules/upload/upload.service.ts +++ b/server/src/modules/upload/upload.service.ts @@ -14,7 +14,7 @@ import { LibraryService } from '../library/library.service'; import { UploadValidatorService } from './upload-validator.service'; import { UploadStorageService } from './upload-storage.service'; import { UploadProcessorService } from './upload-processor.service'; -import { resolveUploadPath } from '@projectx/types'; +import { resolveDownloadFilename, resolveUploadPath } from '@projectx/types'; import type { UploadResult } from '@projectx/types'; import { extractEpubMetadata } from '../metadata/lib/epub'; import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata'; @@ -54,18 +54,20 @@ export class UploadService { const { tempPath, sizeBytes } = await this.storage.streamToTemp(fileStream); let destinationPath: string | null = null; + let shouldCleanupDestination = false; try { - const { absolutePath, folderPath, relPath } = await this.resolveDestination(library, folder.path, tempPath, filename, format); + const { absolutePath, bookFolderPath, relPath } = await this.resolveDestination(library, folder.path, tempPath, filename, format); destinationPath = absolutePath; if (await this.destinationExists(absolutePath)) { throw new ConflictException(`A file named "${basename(absolutePath)}" already exists at the target location`); } + shouldCleanupDestination = true; await this.storage.moveToPath(tempPath, absolutePath); - const { bookId } = await this.processor.createBookRecord(libraryId, folder.id, folderPath, absolutePath, relPath, format, sizeBytes); + const { bookId } = await this.processor.createBookRecord(libraryId, folder.id, bookFolderPath, absolutePath, relPath, format, sizeBytes); this.processor.extractMetadataAsync(bookId, absolutePath, format); @@ -78,38 +80,62 @@ export class UploadService { this.logger.error( `[${event}] [fail] libraryId=${libraryId} userId=${user.id} folderId=${folder.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - upload failed`, ); - await Promise.allSettled([this.storage.cleanup(tempPath), destinationPath ? this.storage.cleanup(destinationPath) : Promise.resolve()]); + await Promise.allSettled([ + this.storage.cleanup(tempPath), + shouldCleanupDestination && destinationPath ? this.storage.cleanup(destinationPath) : Promise.resolve(), + ]); throw err; } } private async resolveDestination( - library: { fileNamingPattern?: string | null }, + library: { fileNamingPattern?: string | null; organizationMode?: string | null }, libraryFolderPath: string, tempPath: string, filename: string, format: string, - ): Promise<{ absolutePath: string; folderPath: string; relPath: string }> { + ): Promise<{ absolutePath: string; bookFolderPath: string; relPath: string }> { const pattern = library.fileNamingPattern ?? (await this.appSettings.getUploadPattern()); if (pattern) { const stem = basename(filename, extname(filename)); const tokens = await this.buildPatternTokens(tempPath, format, stem); - const resolved = resolveUploadPath(pattern, tokens, format); + if (library.organizationMode === 'book_per_file') { + const resolvedFilename = resolveDownloadFilename(pattern, tokens, format); + if (resolvedFilename) { + const absolutePath = join(libraryFolderPath, resolvedFilename); + return { + absolutePath, + bookFolderPath: absolutePath, + relPath: resolvedFilename, + }; + } + } else { + const resolved = resolveUploadPath(pattern, tokens, format); - if (resolved) { - const absolutePath = join(libraryFolderPath, resolved); - const folderPath = dirname(absolutePath); - const relPath = resolved; - return { absolutePath, folderPath, relPath }; + if (resolved) { + const absolutePath = join(libraryFolderPath, resolved); + const relPath = resolved; + const bookFolderPath = dirname(absolutePath); + return { absolutePath, bookFolderPath, relPath }; + } } } + if (library.organizationMode === 'book_per_file') { + const absolutePath = join(libraryFolderPath, filename); + return { + absolutePath, + bookFolderPath: absolutePath, + relPath: filename, + }; + } + const stem = basename(filename, extname(filename)); - const folderPath = join(libraryFolderPath, stem); - const absolutePath = join(folderPath, filename); + const bookFolderPath = join(libraryFolderPath, stem); + const absolutePath = join(bookFolderPath, filename); const relPath = join(stem, filename); - return { absolutePath, folderPath, relPath }; + return { absolutePath, bookFolderPath, relPath }; } private async buildPatternTokens(tempPath: string, format: string, stem: string): Promise> { diff --git a/server/test/app-settings-oidc-contract.e2e-spec.ts b/server/test/app-settings-oidc-contract.e2e-spec.ts new file mode 100644 index 000000000..246916b2b --- /dev/null +++ b/server/test/app-settings-oidc-contract.e2e-spec.ts @@ -0,0 +1,833 @@ +import { randomUUID } from 'crypto'; + +import fastifyCookie from '@fastify/cookie'; +import { ValidationPipe } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { and, eq, gt, isNull } from 'drizzle-orm'; +import { hash } from 'bcryptjs'; +import { Permission } from '@projectx/types'; + +import { AppModule } from '../src/app.module'; +import { GlobalExceptionFilter } from '../src/common/filters/http-exception.filter'; +import { DB } from '../src/db'; +import * as schema from '../src/db/schema'; +import { MetadataService } from '../src/modules/metadata/metadata.service'; +import { OidcDiscoveryService } from '../src/modules/auth/oidc/oidc-discovery.service'; +import { OidcTokenClientService } from '../src/modules/auth/oidc/oidc-token-client.service'; +import { OidcTokenValidatorService } from '../src/modules/auth/oidc/oidc-token-validator.service'; +import { makeMetadataNoopMock, type Db } from './e2e/app-harness'; + +type CookieJar = Map; +type InjectResponse = Awaited>; + +const ADMIN_SETUP_DTO = { + username: 'app-settings-oidc-e2e-admin', + name: 'App Settings OIDC E2E Admin', + email: 'app-settings-oidc-e2e-admin@example.com', + password: 'AppSettingsOidcAdmin123', +}; + +const SCENARIO_TIMEOUT_MS = 60_000; +const DEFAULT_ISSUER = 'https://issuer.example'; + +interface OidcContractContext { + app: NestFastifyApplication; + db: Db; + adminToken: string; + fetchMock: ReturnType>; + oidcDiscoveryMock: { + getDiscoveryDoc: ReturnType; + }; + oidcTokenClientMock: { + exchangeCode: ReturnType; + fetchUserInfo: ReturnType; + }; + oidcTokenValidatorMock: { + validateIdToken: ReturnType; + validateLogoutToken: ReturnType; + }; +} + +type OidcConfigInput = { + enabled: boolean; + providerName: string; + issuerUri: string; + clientId: string; + clientSecret: string; + scopes: string; + claimMapping: { + username: string; + name: string; + email: string; + groups: string; + }; + autoProvision: { + enabled: boolean; + allowLocalLinking: boolean; + defaultPermissionNames: string[]; + }; +}; + +interface LocalUserCredentials { + userId: number; + username: string; + password: string; + email: string; +} + +interface LoginResult { + userId: number; + accessToken: string; + refreshToken: string; + jar: CookieJar; +} + +function authHeader(token: string): Record { + return { authorization: `Bearer ${token}` }; +} + +function buildDiscoveryDoc(issuerUri = DEFAULT_ISSUER) { + const normalized = issuerUri.replace(/\/$/, ''); + return { + issuer: normalized, + authorizationEndpoint: `${normalized}/auth`, + tokenEndpoint: `${normalized}/token`, + jwksUri: `${normalized}/jwks`, + userinfoEndpoint: `${normalized}/userinfo`, + endSessionEndpoint: `${normalized}/logout`, + backchannelLogoutSupported: true, + }; +} + +function buildDiscoveryResponse(issuerUri = DEFAULT_ISSUER) { + const normalized = issuerUri.replace(/\/$/, ''); + return { + issuer: normalized, + authorization_endpoint: `${normalized}/auth`, + token_endpoint: `${normalized}/token`, + jwks_uri: `${normalized}/jwks`, + userinfo_endpoint: `${normalized}/userinfo`, + end_session_endpoint: `${normalized}/logout`, + backchannel_logout_supported: true, + }; +} + +function buildOidcConfig(overrides: Partial = {}): OidcConfigInput { + return { + enabled: true, + providerName: 'Example OIDC', + issuerUri: DEFAULT_ISSUER, + clientId: 'client-id', + clientSecret: 'client-secret', + scopes: 'openid profile email', + claimMapping: { + username: 'preferred_username', + name: 'name', + email: 'email', + groups: 'groups', + ...(overrides.claimMapping ?? {}), + }, + autoProvision: { + enabled: true, + allowLocalLinking: true, + defaultPermissionNames: [], + ...(overrides.autoProvision ?? {}), + }, + ...overrides, + }; +} + +function responseMessage(response: { message?: string | string[] }): string { + if (Array.isArray(response.message)) return response.message.join(' '); + return String(response.message ?? ''); +} + +function expectError(response: InjectResponse, status: number, messageFragment?: string): void { + expect(response.statusCode).toBe(status); + if (!messageFragment) return; + expect(responseMessage(response.json() as { message?: string | string[] })).toContain(messageFragment); +} + +function getSetCookieLines(headers: Record): string[] { + const raw = headers['set-cookie']; + if (!raw) return []; + return Array.isArray(raw) ? raw : [raw]; +} + +function parseCookiePair(setCookieLine: string): { name: string; value: string } | null { + const firstSegment = setCookieLine.split(';', 1)[0]; + if (!firstSegment) return null; + const eqIndex = firstSegment.indexOf('='); + if (eqIndex <= 0) return null; + return { + name: firstSegment.slice(0, eqIndex), + value: firstSegment.slice(eqIndex + 1), + }; +} + +function findCookieLine(setCookieLines: string[], cookieName: string): string | undefined { + return setCookieLines.find((line) => line.startsWith(`${cookieName}=`)); +} + +function cookieValue(setCookieLines: string[], cookieName: string): string | null { + const line = findCookieLine(setCookieLines, cookieName); + if (!line) return null; + return parseCookiePair(line)?.value ?? null; +} + +function cookieAttribute(setCookieLine: string, attribute: string): string | null { + const segments = setCookieLine.split(';').map((segment) => segment.trim()); + const target = attribute.toLowerCase(); + for (const segment of segments.slice(1)) { + const [key, ...rest] = segment.split('='); + if (key.toLowerCase() !== target) continue; + return rest.length > 0 ? rest.join('=') : ''; + } + return null; +} + +function mergeCookieJar(jar: CookieJar, setCookieLines: string[]): void { + for (const line of setCookieLines) { + const parsed = parseCookiePair(line); + if (!parsed) continue; + if (parsed.value === '') { + jar.delete(parsed.name); + } else { + jar.set(parsed.name, parsed.value); + } + } +} + +function mockDiscoveryFetch(input: string | URL | Request): Promise { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.endsWith('/.well-known/openid-configuration')) { + const issuerUri = url.slice(0, -'/.well-known/openid-configuration'.length); + return Promise.resolve( + new Response(JSON.stringify(buildDiscoveryResponse(issuerUri)), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + } + return Promise.reject(new Error(`Unexpected fetch call: ${url}`)); +} + +async function createOidcContractContext(): Promise { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(mockDiscoveryFetch); + + const oidcDiscoveryMock = { + getDiscoveryDoc: vi.fn().mockResolvedValue(buildDiscoveryDoc()), + }; + const oidcTokenClientMock = { + exchangeCode: vi.fn().mockResolvedValue({ accessToken: 'oidc-access-token', idToken: 'oidc-id-token' }), + fetchUserInfo: vi.fn().mockResolvedValue({}), + }; + const oidcTokenValidatorMock = { + validateIdToken: vi.fn().mockResolvedValue({ + sub: 'oidc-default-subject', + sid: 'oidc-default-sid', + preferred_username: 'oidc-default-user', + name: 'OIDC Default User', + email: 'oidc-default-user@example.com', + }), + validateLogoutToken: vi.fn().mockResolvedValue({ + sub: 'oidc-default-subject', + sid: 'oidc-default-sid', + jti: `logout-${randomUUID()}`, + }), + }; + + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(MetadataService) + .useValue(makeMetadataNoopMock()) + .overrideProvider(OidcDiscoveryService) + .useValue(oidcDiscoveryMock) + .overrideProvider(OidcTokenClientService) + .useValue(oidcTokenClientMock) + .overrideProvider(OidcTokenValidatorService) + .useValue(oidcTokenValidatorMock) + .compile(); + + const app = moduleFixture.createNestApplication(new FastifyAdapter()); + app.setGlobalPrefix('api/v1'); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + app.useGlobalFilters(new GlobalExceptionFilter()); + await app.register(fastifyCookie as never); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + + const setupResponse = await app.inject({ + method: 'POST', + url: '/api/v1/auth/setup', + payload: ADMIN_SETUP_DTO, + }); + expect(setupResponse.statusCode).toBe(201); + const setupBody = setupResponse.json() as { accessToken: string }; + + const db = app.get(DB); + return { + app, + db, + adminToken: setupBody.accessToken, + fetchMock, + oidcDiscoveryMock, + oidcTokenClientMock, + oidcTokenValidatorMock, + }; +} + +async function createLocalUser( + db: Db, + options: { + username?: string; + password?: string; + active?: boolean; + email?: string; + } = {}, +): Promise { + const suffix = randomUUID().replace(/-/g, ''); + const username = options.username ?? `oidc-contract-${suffix}`; + const password = options.password ?? 'OidcContractUser123'; + const email = options.email ?? `${username}@example.com`; + const passwordHash = await hash(password, 12); + + const [created] = await db + .insert(schema.users) + .values({ + username, + name: `OIDC Contract ${suffix}`, + email, + passwordHash, + active: options.active ?? true, + isDefaultPassword: false, + provisioningMethod: 'local', + }) + .returning({ id: schema.users.id }); + + return { + userId: created.id, + username, + password, + email, + }; +} + +async function login(app: NestFastifyApplication, username: string, password: string): Promise { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { username, password }, + }); + expect(response.statusCode).toBe(200); + + const body = response.json() as { accessToken: string; user: { id: number } }; + const setCookieLines = getSetCookieLines(response.headers); + const refreshToken = cookieValue(setCookieLines, 'refresh_token'); + expect(refreshToken).toBeTruthy(); + + const jar: CookieJar = new Map(); + mergeCookieJar(jar, setCookieLines); + + return { + userId: body.user.id, + accessToken: body.accessToken, + refreshToken: refreshToken!, + jar, + }; +} + +async function putOidcConfig(ctx: OidcContractContext, config: OidcConfigInput): Promise { + const response = await ctx.app.inject({ + method: 'PUT', + url: '/api/v1/app-settings/oidc', + headers: authHeader(ctx.adminToken), + payload: config, + }); + expect(response.statusCode).toBe(200); +} + +async function issueState(app: NestFastifyApplication): Promise { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/oidc/state', + }); + expect(response.statusCode).toBe(200); + const body = response.json() as { state: string }; + expect(body.state).toEqual(expect.any(String)); + return body.state; +} + +async function activeSessionCount(db: Db, userId: number): Promise { + const rows = await db + .select({ id: schema.refreshTokens.id }) + .from(schema.refreshTokens) + .where(and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt), gt(schema.refreshTokens.expiresAt, new Date()))); + return rows.length; +} + +describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => { + let ctx!: OidcContractContext; + + beforeAll(async () => { + ctx = await createOidcContractContext(); + }); + + beforeEach(() => { + ctx.fetchMock.mockReset(); + ctx.fetchMock.mockImplementation(mockDiscoveryFetch); + ctx.oidcDiscoveryMock.getDiscoveryDoc.mockReset(); + ctx.oidcDiscoveryMock.getDiscoveryDoc.mockResolvedValue(buildDiscoveryDoc()); + ctx.oidcTokenClientMock.exchangeCode.mockReset(); + ctx.oidcTokenClientMock.exchangeCode.mockResolvedValue({ accessToken: 'oidc-access-token', idToken: 'oidc-id-token' }); + ctx.oidcTokenClientMock.fetchUserInfo.mockReset(); + ctx.oidcTokenClientMock.fetchUserInfo.mockResolvedValue({}); + ctx.oidcTokenValidatorMock.validateIdToken.mockReset(); + ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValue({ + sub: 'oidc-default-subject', + sid: 'oidc-default-sid', + preferred_username: 'oidc-default-user', + name: 'OIDC Default User', + email: 'oidc-default-user@example.com', + }); + ctx.oidcTokenValidatorMock.validateLogoutToken.mockReset(); + ctx.oidcTokenValidatorMock.validateLogoutToken.mockResolvedValue({ + sub: 'oidc-default-subject', + sid: 'oidc-default-sid', + jti: `logout-${randomUUID()}`, + }); + }); + + afterAll(async () => { + ctx.fetchMock.mockRestore(); + await ctx.app.close(); + }); + + describe('OIDC app-settings contract', () => { + it('stores admin config, exposes the public subset, masks the secret, and tests discovery', async () => { + const limitedUser = await createLocalUser(ctx.db); + const limitedSession = await login(ctx.app, limitedUser.username, limitedUser.password); + const config = buildOidcConfig({ + providerName: 'Contract OIDC', + autoProvision: { + enabled: true, + allowLocalLinking: true, + defaultPermissionNames: [Permission.KoboSync], + }, + }); + + await putOidcConfig(ctx, config); + + const publicResponse = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/app-settings/oidc/public', + }); + expect(publicResponse.statusCode).toBe(200); + expect(publicResponse.json()).toEqual({ + enabled: true, + providerName: 'Contract OIDC', + issuerUri: DEFAULT_ISSUER, + clientId: 'client-id', + scopes: 'openid profile email', + }); + + const forbiddenResponse = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/app-settings/oidc', + headers: authHeader(limitedSession.accessToken), + }); + expect(forbiddenResponse.statusCode).toBe(403); + + const adminResponse = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/app-settings/oidc', + headers: authHeader(ctx.adminToken), + }); + expect(adminResponse.statusCode).toBe(200); + expect(adminResponse.json()).toEqual({ + ...config, + clientSecret: '***', + }); + + const testResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/app-settings/oidc/test', + headers: authHeader(ctx.adminToken), + }); + expect(testResponse.statusCode).toBe(200); + expect(testResponse.json()).toEqual({ + success: true, + issuer: DEFAULT_ISSUER, + authorizationEndpoint: `${DEFAULT_ISSUER}/auth`, + }); + }); + }); + + describe('OIDC callback contract', () => { + it('issues state, provisions a new OIDC user, sets auth cookies, and consumes state once', async () => { + const suffix = randomUUID().replace(/-/g, ''); + const username = `oidc-provisioned-${suffix}`; + const email = `${username}@example.com`; + + await putOidcConfig( + ctx, + buildOidcConfig({ + autoProvision: { + enabled: true, + allowLocalLinking: false, + defaultPermissionNames: [Permission.KoboSync], + }, + }), + ); + + ctx.oidcTokenClientMock.exchangeCode.mockResolvedValueOnce({ + accessToken: 'oidc-access-provisioned', + idToken: 'oidc-id-provisioned', + }); + ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({ + sub: 'oidc-provisioned-subject', + sid: 'oidc-provisioned-sid', + preferred_username: username, + name: 'OIDC Provisioned User', + email, + }); + ctx.oidcTokenClientMock.fetchUserInfo.mockResolvedValueOnce({ + picture: 'https://issuer.example/avatars/provisioned.png', + }); + + const state = await issueState(ctx.app); + const callbackPayload = { + code: 'authorization-code-provisioned', + codeVerifier: 'verifier-provisioned', + redirectUri: 'http://localhost:5173/oauth2-callback', + nonce: 'nonce-provisioned', + state, + }; + + const callbackResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/oidc/callback', + payload: callbackPayload, + }); + + expect(callbackResponse.statusCode).toBe(200); + const callbackBody = callbackResponse.json() as { + accessToken: string; + user: { + id: number; + username: string; + email: string; + provisioningMethod: string; + avatarUrl: string | null; + permissions: string[]; + }; + }; + expect(callbackBody.user).toMatchObject({ + username, + email, + provisioningMethod: 'oidc', + avatarUrl: 'https://issuer.example/avatars/provisioned.png', + }); + expect(callbackBody.user.permissions).toContain(Permission.KoboSync); + + const callbackCookies = getSetCookieLines(callbackResponse.headers); + const refreshCookieLine = findCookieLine(callbackCookies, 'refresh_token'); + const accessCookieLine = findCookieLine(callbackCookies, 'access_token'); + expect(refreshCookieLine).toBeDefined(); + expect(accessCookieLine).toBeDefined(); + expect(cookieAttribute(refreshCookieLine!, 'path')).toBe('/api/v1/auth'); + expect(cookieAttribute(refreshCookieLine!, 'httponly')).toBe(''); + expect(cookieAttribute(accessCookieLine!, 'path')).toBe('/api'); + expect(cookieAttribute(accessCookieLine!, 'httponly')).toBe(''); + + const createdUser = await ctx.db.query.users.findFirst({ + where: eq(schema.users.id, callbackBody.user.id), + }); + expect(createdUser).toMatchObject({ + username, + email, + oidcSubject: 'oidc-provisioned-subject', + oidcIssuer: DEFAULT_ISSUER, + provisioningMethod: 'oidc', + }); + + const createdPermissions = await ctx.db.query.userPermissions.findMany({ + where: eq(schema.userPermissions.userId, callbackBody.user.id), + }); + expect(createdPermissions.map((row) => row.permissionName)).toContain(Permission.KoboSync); + + const oidcSession = await ctx.db.query.oidcSessions.findFirst({ + where: eq(schema.oidcSessions.userId, callbackBody.user.id), + }); + expect(oidcSession).toMatchObject({ + oidcSubject: 'oidc-provisioned-subject', + oidcIssuer: DEFAULT_ISSUER, + oidcSessionId: 'oidc-provisioned-sid', + idTokenHint: 'oidc-id-provisioned', + revoked: false, + }); + + const reusedState = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/oidc/callback', + payload: callbackPayload, + }); + expectError(reusedState, 401, 'Invalid or expired state'); + }); + + it('links an existing local user and backchannel logout revokes active sessions only for the matched OIDC identity', async () => { + const suffix = randomUUID().replace(/-/g, ''); + const linkedUser = await createLocalUser(ctx.db, { + username: `oidc-linked-${suffix}`, + }); + + await putOidcConfig( + ctx, + buildOidcConfig({ + autoProvision: { + enabled: false, + allowLocalLinking: true, + defaultPermissionNames: [], + }, + }), + ); + + ctx.oidcTokenClientMock.exchangeCode.mockResolvedValueOnce({ + accessToken: 'oidc-access-linked', + idToken: 'oidc-id-linked', + }); + ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({ + sub: 'oidc-linked-subject', + sid: 'oidc-linked-sid', + preferred_username: linkedUser.username, + name: 'Linked Local User', + email: linkedUser.email, + }); + + const state = await issueState(ctx.app); + const callbackResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/oidc/callback', + payload: { + code: 'authorization-code-linked', + codeVerifier: 'verifier-linked', + redirectUri: 'http://localhost:5173/oauth2-callback', + nonce: 'nonce-linked', + state, + }, + }); + + expect(callbackResponse.statusCode).toBe(200); + const callbackBody = callbackResponse.json() as { + accessToken: string; + user: { + id: number; + provisioningMethod: string; + }; + }; + expect(callbackBody.user).toMatchObject({ + id: linkedUser.userId, + provisioningMethod: 'local', + }); + + const setCookieLines = getSetCookieLines(callbackResponse.headers); + const refreshToken = cookieValue(setCookieLines, 'refresh_token'); + expect(refreshToken).toBeTruthy(); + + const linkedUserAfter = await ctx.db.query.users.findFirst({ + where: eq(schema.users.id, linkedUser.userId), + }); + expect(linkedUserAfter).toMatchObject({ + oidcSubject: 'oidc-linked-subject', + oidcIssuer: DEFAULT_ISSUER, + provisioningMethod: 'local', + }); + + const sessionBeforeUnknownLogout = await activeSessionCount(ctx.db, linkedUser.userId); + expect(sessionBeforeUnknownLogout).toBe(1); + + ctx.oidcTokenValidatorMock.validateLogoutToken.mockResolvedValueOnce({ + sub: `oidc-unknown-${randomUUID()}`, + jti: `logout-unknown-${randomUUID()}`, + }); + + const unknownLogoutResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/oidc/backchannel-logout', + payload: { logout_token: 'unknown-logout-token' }, + }); + expect(unknownLogoutResponse.statusCode).toBe(200); + expect(await activeSessionCount(ctx.db, linkedUser.userId)).toBe(1); + + const meBeforeMatchedLogout = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/auth/me', + headers: authHeader(callbackBody.accessToken), + }); + expect(meBeforeMatchedLogout.statusCode).toBe(200); + + ctx.oidcTokenValidatorMock.validateLogoutToken.mockResolvedValueOnce({ + sub: 'oidc-linked-subject', + sid: 'oidc-linked-sid', + jti: `logout-linked-${randomUUID()}`, + }); + + const matchedLogoutResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/oidc/backchannel-logout', + payload: { logout_token: 'matched-logout-token' }, + }); + expect(matchedLogoutResponse.statusCode).toBe(200); + + const oidcSessionAfter = await ctx.db.query.oidcSessions.findFirst({ + where: eq(schema.oidcSessions.userId, linkedUser.userId), + }); + expect(oidcSessionAfter?.revoked).toBe(true); + expect(await activeSessionCount(ctx.db, linkedUser.userId)).toBe(0); + + const refreshAfterLogout = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/refresh', + headers: { + cookie: `refresh_token=${refreshToken}`, + }, + }); + expect(refreshAfterLogout.statusCode).toBe(401); + + const meAfterLogout = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/auth/me', + headers: authHeader(callbackBody.accessToken), + }); + expect(meAfterLogout.statusCode).toBe(401); + }); + + it('rejects invalid state and missing subject claims', async () => { + await putOidcConfig( + ctx, + buildOidcConfig({ + autoProvision: { + enabled: true, + allowLocalLinking: false, + defaultPermissionNames: [], + }, + }), + ); + + const invalidStateResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/oidc/callback', + payload: { + code: 'authorization-code-invalid-state', + codeVerifier: 'verifier-invalid-state', + redirectUri: 'http://localhost:5173/oauth2-callback', + nonce: 'nonce-invalid-state', + state: `invalid-${randomUUID()}`, + }, + }); + expectError(invalidStateResponse, 401, 'Invalid or expired state'); + expect(ctx.oidcTokenClientMock.exchangeCode).not.toHaveBeenCalled(); + + const state = await issueState(ctx.app); + ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({ + sid: 'oidc-missing-subject-sid', + preferred_username: `missing-subject-${randomUUID()}`, + name: 'Missing Subject', + email: 'missing-subject@example.com', + }); + + const missingSubjectResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/oidc/callback', + payload: { + code: 'authorization-code-missing-subject', + codeVerifier: 'verifier-missing-subject', + redirectUri: 'http://localhost:5173/oauth2-callback', + nonce: 'nonce-missing-subject', + state, + }, + }); + expectError(missingSubjectResponse, 401, 'Invalid ID token: missing subject claim'); + }); + + it('rejects callback when OIDC is disabled or the linked account is deactivated', async () => { + await putOidcConfig( + ctx, + buildOidcConfig({ + enabled: false, + autoProvision: { + enabled: true, + allowLocalLinking: true, + defaultPermissionNames: [], + }, + }), + ); + + const disabledState = await issueState(ctx.app); + const disabledCallbackResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/oidc/callback', + payload: { + code: 'authorization-code-disabled', + codeVerifier: 'verifier-disabled', + redirectUri: 'http://localhost:5173/oauth2-callback', + nonce: 'nonce-disabled', + state: disabledState, + }, + }); + expectError(disabledCallbackResponse, 401, 'OIDC is not enabled'); + + const inactiveUser = await createLocalUser(ctx.db, { + username: `oidc-inactive-${randomUUID().replace(/-/g, '')}`, + active: false, + }); + + await putOidcConfig( + ctx, + buildOidcConfig({ + enabled: true, + autoProvision: { + enabled: false, + allowLocalLinking: true, + defaultPermissionNames: [], + }, + }), + ); + + const inactiveState = await issueState(ctx.app); + ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({ + sub: 'oidc-inactive-subject', + sid: 'oidc-inactive-sid', + preferred_username: inactiveUser.username, + name: 'Inactive Linked User', + email: inactiveUser.email, + }); + + const inactiveCallbackResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/auth/oidc/callback', + payload: { + code: 'authorization-code-inactive', + codeVerifier: 'verifier-inactive', + redirectUri: 'http://localhost:5173/oauth2-callback', + nonce: 'nonce-inactive', + state: inactiveState, + }, + }); + expectError(inactiveCallbackResponse, 401, 'Account is deactivated'); + + const inactiveUserSession = await ctx.db.query.oidcSessions.findFirst({ + where: eq(schema.oidcSessions.userId, inactiveUser.userId), + }); + expect(inactiveUserSession).toBeUndefined(); + }); + }); +}); diff --git a/server/test/book-api-contract.e2e-spec.ts b/server/test/book-api-contract.e2e-spec.ts new file mode 100644 index 000000000..7a8c4e076 --- /dev/null +++ b/server/test/book-api-contract.e2e-spec.ts @@ -0,0 +1,610 @@ +import { randomUUID } from 'crypto'; +import { rm } from 'fs/promises'; +import { join } from 'path'; + +import * as unzipper from 'unzipper'; +import { Permission } from '@projectx/types'; + +import { + authHeader, + closeMetadataWriteE2EContext, + createLibraryWithFolder, + createMetadataWriteE2EContext, + createUserAndLogin, + grantLibraryAccess, + locateBookFileByRelPath, + triggerAndWaitForLibraryScan, + type LocatedBookFile, + type MetadataWriteE2EContext, + type TestUserSession, +} from './e2e/metadata-write/metadata-write-harness'; +import { createCbzFixture, createEpubFixture, createPdfFixture, writeFixtureFile } from './e2e/metadata-write/metadata-write-fixture-builder'; + +type InjectResponse = Awaited>; + +const SCENARIO_TIMEOUT_MS = 120_000; + +function responseMessage(response: { message?: string | string[] }): string { + if (Array.isArray(response.message)) return response.message.join(' '); + return String(response.message ?? ''); +} + +function expectError(response: InjectResponse, status: number, messageFragment?: string): void { + expect(response.statusCode).toBe(status); + if (!messageFragment) return; + expect(responseMessage(response.json() as { message?: string | string[] })).toContain(messageFragment); +} + +function zipBuffer(response: InjectResponse): Buffer { + const rawPayload = (response as unknown as { rawPayload?: unknown }).rawPayload; + if (Buffer.isBuffer(rawPayload)) return rawPayload; + return Buffer.from(response.body, 'binary'); +} + +async function listZipEntries(response: InjectResponse): Promise { + const zip = await unzipper.Open.buffer(zipBuffer(response)); + return zip.files.map((file) => file.path).sort(); +} + +async function createBookCoverArtifacts( + ctx: MetadataWriteE2EContext, + bookId: number, + options: { + coverExtension?: 'jpg' | 'png'; + coverContent?: Buffer; + thumbnailContent?: Buffer; + } = {}, +): Promise { + const coverExtension = options.coverExtension ?? 'jpg'; + await writeFixtureFile( + ctx.fixture.booksPath, + `covers/${bookId}/cover_custom.${coverExtension}`, + options.coverContent ?? Buffer.from(`cover-${bookId}`, 'utf8'), + ); + await writeFixtureFile( + ctx.fixture.booksPath, + `covers/${bookId}/thumbnail.jpg`, + options.thumbnailContent ?? Buffer.from(`thumbnail-${bookId}`, 'utf8'), + ); +} + +describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => { + let ctx!: MetadataWriteE2EContext; + + let visibleLibrary!: Awaited>; + let hiddenLibrary!: Awaited>; + let visibleLibraryName!: string; + let hiddenLibraryName!: string; + + let limitedUser!: TestUserSession; + let crossLibraryUser!: TestUserSession; + let downloadUser!: TestUserSession; + + let visibleEpub!: LocatedBookFile; + let visiblePdf!: LocatedBookFile; + let visibleCbz!: LocatedBookFile; + let hiddenEpub!: LocatedBookFile; + + beforeAll(async () => { + ctx = await createMetadataWriteE2EContext(); + + visibleLibraryName = `book-api-visible-${randomUUID()}`; + hiddenLibraryName = `book-api-hidden-${randomUUID()}`; + + visibleLibrary = await createLibraryWithFolder(ctx, { + name: visibleLibraryName, + }); + hiddenLibrary = await createLibraryWithFolder(ctx, { + name: hiddenLibraryName, + }); + + await createEpubFixture(visibleLibrary.folderPath, 'contracts/alpha-contract.epub', { + title: 'Alpha Contract EPUB', + }); + await createPdfFixture(visibleLibrary.folderPath, 'contracts/beta-contract.pdf', 'Beta Contract PDF'); + await createCbzFixture(visibleLibrary.folderPath, 'contracts/gamma-contract.cbz', { + title: 'Gamma Contract Comic', + author: 'Gamma Cartoonist', + }); + await createEpubFixture(hiddenLibrary.folderPath, 'restricted/omega-hidden.epub', { + title: 'Omega Hidden EPUB', + }); + + await triggerAndWaitForLibraryScan(ctx, visibleLibrary.libraryId); + await triggerAndWaitForLibraryScan(ctx, hiddenLibrary.libraryId); + + visibleEpub = await locateBookFileByRelPath(ctx, visibleLibrary.libraryId, 'contracts/alpha-contract.epub'); + visiblePdf = await locateBookFileByRelPath(ctx, visibleLibrary.libraryId, 'contracts/beta-contract.pdf'); + visibleCbz = await locateBookFileByRelPath(ctx, visibleLibrary.libraryId, 'contracts/gamma-contract.cbz'); + hiddenEpub = await locateBookFileByRelPath(ctx, hiddenLibrary.libraryId, 'restricted/omega-hidden.epub'); + + limitedUser = await createUserAndLogin(ctx); + crossLibraryUser = await createUserAndLogin(ctx); + downloadUser = await createUserAndLogin(ctx, { + permissions: [Permission.LibraryDownload], + }); + + await grantLibraryAccess(ctx, limitedUser.userId, visibleLibrary.libraryId, 'viewer'); + await grantLibraryAccess(ctx, crossLibraryUser.userId, visibleLibrary.libraryId, 'viewer'); + await grantLibraryAccess(ctx, crossLibraryUser.userId, hiddenLibrary.libraryId, 'viewer'); + await grantLibraryAccess(ctx, downloadUser.userId, visibleLibrary.libraryId, 'viewer'); + + await createBookCoverArtifacts(ctx, visibleEpub.bookId, { + coverExtension: 'png', + coverContent: Buffer.from('cover-png', 'utf8'), + thumbnailContent: Buffer.from('thumbnail-jpg', 'utf8'), + }); + }, 60_000); + + afterAll(async () => { + await closeMetadataWriteE2EContext(ctx); + }); + + describe('query and search', () => { + it('returns scoped global and library query results with filters, sorting, and pagination', async () => { + const filteredQuery = { + filter: { + type: 'group', + join: 'AND', + rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Contract' }], + }, + sort: [{ field: 'title', dir: 'asc' }], + pagination: { page: 0, size: 2 }, + }; + + const limitedGlobalQuery = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/books/query', + headers: authHeader(limitedUser.accessToken), + payload: filteredQuery, + }); + + expect(limitedGlobalQuery.statusCode).toBe(201); + expect(limitedGlobalQuery.json()).toMatchObject({ + total: 3, + page: 0, + size: 2, + items: [ + { + id: visibleEpub.bookId, + title: 'Alpha Contract EPUB', + files: [{ id: visibleEpub.bookFileId, format: 'epub', role: 'primary' }], + }, + { + id: visiblePdf.bookId, + title: 'Beta Contract PDF', + files: [{ id: visiblePdf.bookFileId, format: 'pdf', role: 'primary' }], + }, + ], + }); + + const libraryQuery = await ctx.app.inject({ + method: 'POST', + url: `/api/v1/libraries/${visibleLibrary.libraryId}/books`, + headers: authHeader(limitedUser.accessToken), + payload: { + filter: { + type: 'group', + join: 'AND', + rules: [{ type: 'rule', field: 'format', operator: 'includesAny', value: ['cbz'] }], + }, + sort: [{ field: 'title', dir: 'asc' }], + pagination: { page: 0, size: 10 }, + }, + }); + + expect(libraryQuery.statusCode).toBe(201); + expect(libraryQuery.json()).toMatchObject({ + total: 1, + items: [{ id: visibleCbz.bookId, title: 'Gamma Contract Comic' }], + }); + + const inaccessibleLibraryQuery = await ctx.app.inject({ + method: 'POST', + url: `/api/v1/libraries/${hiddenLibrary.libraryId}/books`, + headers: authHeader(limitedUser.accessToken), + payload: filteredQuery, + }); + + expect(inaccessibleLibraryQuery.statusCode).toBe(403); + + const crossLibraryQuery = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/books/query', + headers: authHeader(crossLibraryUser.accessToken), + payload: { + filter: { + type: 'group', + join: 'AND', + rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'EPUB' }], + }, + sort: [{ field: 'title', dir: 'asc' }], + pagination: { page: 0, size: 10 }, + }, + }); + + expect(crossLibraryQuery.statusCode).toBe(201); + expect(crossLibraryQuery.json()).toMatchObject({ + total: 2, + items: [ + { id: visibleEpub.bookId, title: 'Alpha Contract EPUB' }, + { id: hiddenEpub.bookId, title: 'Omega Hidden EPUB' }, + ], + }); + }); + + it('returns scoped search results and validates search params', async () => { + const limitedSearch = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/books/search?q=Contract&limit=20', + headers: authHeader(limitedUser.accessToken), + }); + + expect(limitedSearch.statusCode).toBe(200); + expect(limitedSearch.json()).toEqual([ + { + id: visibleEpub.bookId, + title: 'Alpha Contract EPUB', + seriesName: null, + authors: [], + libraryId: visibleLibrary.libraryId, + libraryName: visibleLibraryName, + formats: ['epub'], + }, + { + id: visiblePdf.bookId, + title: 'Beta Contract PDF', + seriesName: null, + authors: [], + libraryId: visibleLibrary.libraryId, + libraryName: visibleLibraryName, + formats: ['pdf'], + }, + { + id: visibleCbz.bookId, + title: 'Gamma Contract Comic', + seriesName: null, + authors: ['Gamma Cartoonist'], + libraryId: visibleLibrary.libraryId, + libraryName: visibleLibraryName, + formats: ['cbz'], + }, + ]); + + const authorSearch = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/books/search?q=Cartoonist&limit=20', + headers: authHeader(limitedUser.accessToken), + }); + + expect(authorSearch.statusCode).toBe(200); + expect(authorSearch.json()).toEqual([ + expect.objectContaining({ + id: visibleCbz.bookId, + title: 'Gamma Contract Comic', + authors: ['Gamma Cartoonist'], + }), + ]); + + const crossLibrarySearch = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/books/search?q=Omega&limit=20', + headers: authHeader(crossLibraryUser.accessToken), + }); + + expect(crossLibrarySearch.statusCode).toBe(200); + expect(crossLibrarySearch.json()).toEqual([ + expect.objectContaining({ + id: hiddenEpub.bookId, + title: 'Omega Hidden EPUB', + libraryId: hiddenLibrary.libraryId, + libraryName: hiddenLibraryName, + }), + ]); + + const invalidLimit = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/books/search?q=Contract&limit=25', + headers: authHeader(limitedUser.accessToken), + }); + + expectError(invalidLimit, 400, 'limit must not be greater than 20'); + }); + }); + + describe('detail and reader state', () => { + it('returns detail payload fields and keeps progress and status isolated per user', async () => { + const saveProgress = await ctx.app.inject({ + method: 'POST', + url: `/api/v1/books/files/${visibleEpub.bookFileId}/progress`, + headers: authHeader(limitedUser.accessToken), + payload: { + cfi: 'epubcfi(/6/2!/4/1:0)', + percentage: 42.5, + positionSeconds: 90, + }, + }); + + expect(saveProgress.statusCode).toBe(201); + + const setStatus = await ctx.app.inject({ + method: 'PATCH', + url: `/api/v1/books/${visibleEpub.bookId}/status`, + headers: authHeader(limitedUser.accessToken), + payload: { status: 'reading' }, + }); + + expect(setStatus.statusCode).toBe(204); + + const getFileProgress = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/files/${visibleEpub.bookFileId}/progress`, + headers: authHeader(limitedUser.accessToken), + }); + + expect(getFileProgress.statusCode).toBe(200); + expect(getFileProgress.json()).toMatchObject({ + cfi: 'epubcfi(/6/2!/4/1:0)', + pageNumber: null, + percentage: 42.5, + }); + + const otherUserFileProgress = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/files/${visibleEpub.bookFileId}/progress`, + headers: authHeader(crossLibraryUser.accessToken), + }); + + expect(otherUserFileProgress.statusCode).toBe(200); + expect(otherUserFileProgress.json()).toEqual({ + cfi: null, + pageNumber: null, + percentage: 0, + }); + + const limitedBookProgress = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/${visibleEpub.bookId}/progress`, + headers: authHeader(limitedUser.accessToken), + }); + + expect(limitedBookProgress.statusCode).toBe(200); + expect(limitedBookProgress.json()).toEqual([ + expect.objectContaining({ + fileId: visibleEpub.bookFileId, + cfi: 'epubcfi(/6/2!/4/1:0)', + pageNumber: null, + percentage: 42.5, + }), + ]); + + const otherUserBookProgress = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/${visibleEpub.bookId}/progress`, + headers: authHeader(crossLibraryUser.accessToken), + }); + + expect(otherUserBookProgress.statusCode).toBe(200); + expect(otherUserBookProgress.json()).toEqual([ + { + fileId: visibleEpub.bookFileId, + cfi: null, + pageNumber: null, + percentage: 0, + updatedAt: null, + }, + ]); + + const limitedDetail = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/${visibleEpub.bookId}`, + headers: authHeader(limitedUser.accessToken), + }); + + expect(limitedDetail.statusCode).toBe(200); + expect(limitedDetail.json()).toMatchObject({ + id: visibleEpub.bookId, + libraryId: visibleLibrary.libraryId, + libraryName: visibleLibraryName, + title: 'Alpha Contract EPUB', + coverSource: null, + files: [ + { + id: visibleEpub.bookFileId, + format: 'epub', + role: 'primary', + filename: 'alpha-contract.epub', + }, + ], + collections: [], + readStatus: { + status: 'reading', + source: 'manual', + }, + }); + + const crossLibraryDetail = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/${visibleEpub.bookId}`, + headers: authHeader(crossLibraryUser.accessToken), + }); + + expect(crossLibraryDetail.statusCode).toBe(200); + expect(crossLibraryDetail.json()).toMatchObject({ + id: visibleEpub.bookId, + title: 'Alpha Contract EPUB', + readStatus: null, + }); + + const inaccessibleDetail = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/${hiddenEpub.bookId}`, + headers: authHeader(limitedUser.accessToken), + }); + + expect(inaccessibleDetail.statusCode).toBe(403); + }); + }); + + describe('file delivery and export', () => { + it('serves files inline and as downloads, enforces range semantics, and rejects inaccessible files', async () => { + const inlinePdf = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/files/${visiblePdf.bookFileId}/serve`, + headers: authHeader(limitedUser.accessToken), + }); + + expect(inlinePdf.statusCode).toBe(200); + expect(inlinePdf.headers['content-type']).toContain('application/pdf'); + expect(inlinePdf.headers['accept-ranges']).toBe('bytes'); + expect(Number(inlinePdf.headers['content-length'])).toBeGreaterThan(0); + expect(String(inlinePdf.headers['content-disposition'])).toContain('inline;'); + + const rangedPdf = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/files/${visiblePdf.bookFileId}/serve`, + headers: { + ...authHeader(limitedUser.accessToken), + range: 'bytes=0-9', + }, + }); + + expect(rangedPdf.statusCode).toBe(206); + expect(rangedPdf.headers['content-range']).toBe(`bytes 0-9/${inlinePdf.headers['content-length']}`); + expect(rangedPdf.headers['content-length']).toBe('10'); + + const invalidRange = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/files/${visiblePdf.bookFileId}/serve`, + headers: { + ...authHeader(limitedUser.accessToken), + range: `bytes=${Number(inlinePdf.headers['content-length']) + 10}-${Number(inlinePdf.headers['content-length']) + 20}`, + }, + }); + + expect(invalidRange.statusCode).toBe(416); + expect(invalidRange.headers['content-range']).toBe(`bytes */${inlinePdf.headers['content-length']}`); + + const downloadEpub = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/files/${visibleEpub.bookFileId}/serve?download=1`, + headers: authHeader(limitedUser.accessToken), + }); + + expect(downloadEpub.statusCode).toBe(200); + expect(downloadEpub.headers['content-type']).toContain('application/epub+zip'); + expect(String(downloadEpub.headers['content-disposition'])).toContain('attachment;'); + expect(String(downloadEpub.headers['content-disposition'])).toContain('alpha-contract.epub'); + + const inaccessibleServe = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/files/${hiddenEpub.bookFileId}/serve`, + headers: authHeader(limitedUser.accessToken), + }); + + expect(inaccessibleServe.statusCode).toBe(403); + }); + + it('exports selected books as a zip and rejects unauthorized selections', async () => { + const exportResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/books/export', + headers: authHeader(downloadUser.accessToken), + payload: { + bookIds: [visibleEpub.bookId, visiblePdf.bookId], + allFormats: false, + }, + }); + + expect(exportResponse.statusCode).toBe(201); + expect(exportResponse.headers['content-type']).toContain('application/zip'); + expect(exportResponse.headers['content-disposition']).toBe('attachment; filename="books.zip"'); + + const zipEntries = await listZipEntries(exportResponse); + expect(zipEntries).toEqual(['alpha-contract.epub', 'beta-contract.pdf']); + + const unauthorizedExport = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/books/export', + headers: authHeader(downloadUser.accessToken), + payload: { + bookIds: [visibleEpub.bookId, hiddenEpub.bookId], + allFormats: false, + }, + }); + + expect(unauthorizedExport.statusCode).toBe(403); + }); + }); + + describe('cover and thumbnail caching', () => { + it('serves cover and thumbnail with ETags and returns 404 when artifacts are missing', async () => { + const cover = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/${visibleEpub.bookId}/cover`, + headers: authHeader(limitedUser.accessToken), + }); + + expect(cover.statusCode).toBe(200); + expect(cover.headers['content-type']).toContain('image/png'); + expect(cover.headers.etag).toBeTruthy(); + expect(cover.headers['cache-control']).toBe('private, max-age=86400'); + + const cachedCover = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/${visibleEpub.bookId}/cover`, + headers: { + ...authHeader(limitedUser.accessToken), + 'if-none-match': String(cover.headers.etag), + }, + }); + + expect(cachedCover.statusCode).toBe(304); + expect(cachedCover.headers.etag).toBe(cover.headers.etag); + + const thumbnail = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/${visibleEpub.bookId}/thumbnail`, + headers: authHeader(limitedUser.accessToken), + }); + + expect(thumbnail.statusCode).toBe(200); + expect(thumbnail.headers['content-type']).toContain('image/jpeg'); + expect(thumbnail.headers.etag).toBeTruthy(); + expect(thumbnail.headers['cache-control']).toBe('private, max-age=86400'); + + const cachedThumbnail = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/${visibleEpub.bookId}/thumbnail`, + headers: { + ...authHeader(limitedUser.accessToken), + 'if-none-match': String(thumbnail.headers.etag), + }, + }); + + expect(cachedThumbnail.statusCode).toBe(304); + expect(cachedThumbnail.headers.etag).toBe(thumbnail.headers.etag); + + await rm(join(ctx.fixture.booksPath, 'covers', String(visibleEpub.bookId)), { + recursive: true, + force: true, + }); + + const missingCover = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/${visibleEpub.bookId}/cover`, + headers: authHeader(limitedUser.accessToken), + }); + + expectError(missingCover, 404, `No cover for book ${visibleEpub.bookId}`); + + const missingThumbnail = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/books/${visibleEpub.bookId}/thumbnail`, + headers: authHeader(limitedUser.accessToken), + }); + + expectError(missingThumbnail, 404, `No thumbnail for book ${visibleEpub.bookId}`); + }); + }); +}); diff --git a/server/test/e2e/app-harness.ts b/server/test/e2e/app-harness.ts index 00e36f3da..b596fc07f 100644 --- a/server/test/e2e/app-harness.ts +++ b/server/test/e2e/app-harness.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto'; -import { expect } from 'vitest'; +import { expect, vi } from 'vitest'; import fastifyCookie from '@fastify/cookie'; import { count, eq, inArray, isNull, sql } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; @@ -17,6 +17,13 @@ import { MetadataService } from '../../src/modules/metadata/metadata.service'; export type Db = NodePgDatabase; type OrganizationMode = 'book_per_file' | 'book_per_folder'; +export type MetadataNoopMock = { + extractAndSave: ReturnType; + refreshCoverForBook: ReturnType; + extractAudioFileDuration: ReturnType; + aggregateAudioDuration: ReturnType; + extractAudioChaptersAndNarrators: ReturnType; +}; const ADMIN_SETUP_DTO = { username: 'scanner-e2e-admin', @@ -29,16 +36,13 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -export function makeMetadataNoopMock(): Pick< - MetadataService, - 'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators' -> { +export function makeMetadataNoopMock(): MetadataNoopMock { return { - extractAndSave: () => Promise.resolve(undefined), - refreshCoverForBook: () => Promise.resolve(false), - extractAudioFileDuration: () => Promise.resolve(undefined), - aggregateAudioDuration: () => Promise.resolve(undefined), - extractAudioChaptersAndNarrators: () => Promise.resolve(undefined), + extractAndSave: vi.fn().mockResolvedValue(undefined), + refreshCoverForBook: vi.fn().mockResolvedValue(false), + extractAudioFileDuration: vi.fn().mockResolvedValue(undefined), + aggregateAudioDuration: vi.fn().mockResolvedValue(undefined), + extractAudioChaptersAndNarrators: vi.fn().mockResolvedValue(undefined), }; } diff --git a/server/test/e2e/authorization-matrix/authorization-matrix-harness.ts b/server/test/e2e/authorization-matrix/authorization-matrix-harness.ts index ac81b3249..aa1f7e155 100644 --- a/server/test/e2e/authorization-matrix/authorization-matrix-harness.ts +++ b/server/test/e2e/authorization-matrix/authorization-matrix-harness.ts @@ -16,7 +16,7 @@ import { DB } from '../../../src/db'; import * as schema from '../../../src/db/schema'; import { MetadataService } from '../../../src/modules/metadata/metadata.service'; import { BookBucketWatcherService } from '../../../src/modules/book-bucket/book-bucket-watcher.service'; -import { seedLibrary, waitForScanCompletion } from '../app-harness'; +import { makeMetadataNoopMock, seedLibrary, waitForScanCompletion, type MetadataNoopMock } from '../app-harness'; import { buildFb2Fixture, createAuthorizationFixtureRoot, @@ -45,6 +45,7 @@ export interface AuthorizationMatrixE2EContext { adminToken: string; fixture: AuthorizationMatrixFixtureRoot; envSnapshot: EnvSnapshot; + metadataMock: MetadataNoopMock; } export interface CreatedLibrary { @@ -108,11 +109,12 @@ export async function createAuthorizationMatrixE2EContext(): Promise(new FastifyAdapter()); @@ -144,6 +146,7 @@ export async function createAuthorizationMatrixE2EContext(): Promise { - return { - extractAndSave: () => Promise.resolve(undefined), - refreshCoverForBook: () => Promise.resolve(false), - extractAudioFileDuration: () => Promise.resolve(undefined), - aggregateAudioDuration: () => Promise.resolve(undefined), - extractAudioChaptersAndNarrators: () => Promise.resolve(undefined), - }; -} - async function stopBookBucketWatcher(app: NestFastifyApplication): Promise { const watcher = app.get(BookBucketWatcherService); await watcher.onModuleDestroy(); diff --git a/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts b/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts index 3daa84b0b..66c96223d 100644 --- a/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts +++ b/server/test/e2e/metadata-write/metadata-write-fixture-builder.ts @@ -14,7 +14,7 @@ export interface MetadataWriteFixtureRoot { cleanup: () => Promise; } -interface ZipEntryInput { +export interface ZipEntryInput { path: string; content: string | Buffer; store?: boolean; @@ -136,6 +136,14 @@ export async function createCb7Fixture(rootPath: string, relativePath: string, i return writeFixtureFile(rootPath, relativePath, archiveBytes); } +export async function createZipArchiveFixture(rootPath: string, relativePath: string, entries: ZipEntryInput[]): Promise { + assertRelativePath(relativePath); + const absolutePath = join(rootPath, relativePath); + await mkdir(dirname(absolutePath), { recursive: true }); + await writeZipArchive(absolutePath, entries); + return absolutePath; +} + async function writeZipArchive(absolutePath: string, entries: ZipEntryInput[]): Promise { const output = createWriteStream(absolutePath); const archive = archiver('zip', { zlib: { level: 6 } }); diff --git a/server/test/e2e/reader-state-isolation/reader-state-isolation-fixture-builder.ts b/server/test/e2e/reader-state-isolation/reader-state-isolation-fixture-builder.ts index d8b23f672..82ff30ae2 100644 --- a/server/test/e2e/reader-state-isolation/reader-state-isolation-fixture-builder.ts +++ b/server/test/e2e/reader-state-isolation/reader-state-isolation-fixture-builder.ts @@ -2,7 +2,7 @@ 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 { createCbzFixture, createEpubFixture, createZipArchiveFixture } from '../metadata-write/metadata-write-fixture-builder'; export interface ReaderStateIsolationFixtureRoot { rootPath: string; diff --git a/server/test/e2e/reader-state-isolation/reader-state-isolation-harness.ts b/server/test/e2e/reader-state-isolation/reader-state-isolation-harness.ts index a3d2e03f7..2e554bbbd 100644 --- a/server/test/e2e/reader-state-isolation/reader-state-isolation-harness.ts +++ b/server/test/e2e/reader-state-isolation/reader-state-isolation-harness.ts @@ -10,11 +10,12 @@ import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { DEFAULT_FORMAT_PRIORITY, type Permission } from '@projectx/types'; import { AppModule } from '../../../src/app.module'; +import { GlobalExceptionFilter } from '../../../src/common/filters/http-exception.filter'; import { DB } from '../../../src/db'; import * as schema from '../../../src/db/schema'; import { MetadataService } from '../../../src/modules/metadata/metadata.service'; import { BookBucketWatcherService } from '../../../src/modules/book-bucket/book-bucket-watcher.service'; -import { seedLibrary, waitForScanCompletion } from '../app-harness'; +import { makeMetadataNoopMock, seedLibrary, waitForScanCompletion } from '../app-harness'; import { createReaderStateIsolationFixtureRoot, type ReaderStateIsolationFixtureRoot } from './reader-state-isolation-fixture-builder'; type Db = NodePgDatabase; @@ -91,6 +92,7 @@ export async function createReaderStateIsolationE2EContext(): Promise { - return { - extractAndSave: () => Promise.resolve(undefined), - refreshCoverForBook: () => Promise.resolve(false), - extractAudioFileDuration: () => Promise.resolve(undefined), - aggregateAudioDuration: () => Promise.resolve(undefined), - extractAudioChaptersAndNarrators: () => Promise.resolve(undefined), - }; -} - function restoreEnv(snapshot: EnvSnapshot): void { if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH; else process.env.BOOKS_PATH = snapshot.booksPath; diff --git a/server/test/app-smoke.e2e-spec.ts b/server/test/guard-mechanics.e2e-spec.ts similarity index 99% rename from server/test/app-smoke.e2e-spec.ts rename to server/test/guard-mechanics.e2e-spec.ts index 669577482..31f30eed5 100644 --- a/server/test/app-smoke.e2e-spec.ts +++ b/server/test/guard-mechanics.e2e-spec.ts @@ -116,7 +116,7 @@ class SmokeProtectedController { }) class SmokeAppModule {} -describe('API smoke (e2e)', () => { +describe('Guard mechanics (e2e)', () => { let app: NestFastifyApplication; beforeAll(async () => { diff --git a/server/test/library-admin-workflows.e2e-spec.ts b/server/test/library-admin-workflows.e2e-spec.ts new file mode 100644 index 000000000..c6f56ed0e --- /dev/null +++ b/server/test/library-admin-workflows.e2e-spec.ts @@ -0,0 +1,780 @@ +import { randomUUID } from 'crypto'; +import { access, mkdir, readFile } from 'fs/promises'; +import { basename, join } from 'path'; + +import { and, eq } from 'drizzle-orm'; +import { Permission } from '@projectx/types'; + +import * as schema from '../src/db/schema'; +import { waitForCondition, waitForScanCompletion } from './e2e/app-harness'; +import { + authHeader, + closeAuthorizationMatrixE2EContext, + createAuthorizationMatrixE2EContext, + createLibraryWithFolder, + createUserAndLogin, + grantLibraryAccess, + locateBookByAbsolutePath, + uploadLibraryFile, + type AuthorizationMatrixE2EContext, + type TestUserSession, +} from './e2e/authorization-matrix/authorization-matrix-harness'; +import { createEpubFixture, writeFixtureFile } from './e2e/authorization-matrix/authorization-matrix-fixture-builder'; + +type InjectResponse = Awaited>; + +const SCENARIO_TIMEOUT_MS = 120_000; + +function responseMessage(response: { message?: string | string[] }): string { + if (Array.isArray(response.message)) return response.message.join(' '); + return String(response.message ?? ''); +} + +function expectError(response: InjectResponse, status: number, messageFragment?: string): void { + expect(response.statusCode).toBe(status); + if (!messageFragment) return; + expect(responseMessage(response.json() as { message?: string | string[] })).toContain(messageFragment); +} + +function buildMultipartBody(file?: { fileName: string; content: Buffer; contentType?: string }): { body: Buffer; boundary: string } { + const boundary = `----projectx-library-admin-${randomUUID()}`; + if (!file) { + return { + boundary, + body: Buffer.from(`--${boundary}--\r\n`, 'utf8'), + }; + } + + const preamble = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.fileName}"\r\nContent-Type: ${ + file.contentType ?? 'application/octet-stream' + }\r\n\r\n`, + 'utf8', + ); + const closing = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8'); + return { + boundary, + body: Buffer.concat([preamble, file.content, closing]), + }; +} + +async function uploadLibraryFileWithRawQuery( + ctx: AuthorizationMatrixE2EContext, + input: { + token: string; + libraryId: number; + query?: string; + file?: { + fileName: string; + content: Buffer; + contentType?: string; + }; + }, +): Promise { + const { body, boundary } = buildMultipartBody(input.file); + return ctx.app.inject({ + method: 'POST', + url: `/api/v1/libraries/${input.libraryId}/upload${input.query ?? ''}`, + headers: { + ...authHeader(input.token), + 'content-type': `multipart/form-data; boundary=${boundary}`, + 'content-length': String(body.length), + }, + payload: body, + }); +} + +async function createUploadFixtureBytes(ctx: AuthorizationMatrixE2EContext, relativePath: string, title: string): Promise { + const absolutePath = await createEpubFixture(ctx.fixture.rootPath, relativePath, { title }); + return readFile(absolutePath); +} + +async function waitForNoRunningScans(ctx: AuthorizationMatrixE2EContext, libraryId: number): Promise { + await waitForCondition(async () => { + const running = await ctx.db + .select({ id: schema.scanJobs.id }) + .from(schema.scanJobs) + .where(and(eq(schema.scanJobs.libraryId, libraryId), eq(schema.scanJobs.status, 'running'))); + expect(running).toHaveLength(0); + }, 15_000); +} + +async function startLibraryScanAs( + ctx: AuthorizationMatrixE2EContext, + token: string, + libraryId: number, +): Promise { + const response = await ctx.app.inject({ + method: 'POST', + url: `/api/v1/scanner/libraries/${libraryId}/scan`, + headers: authHeader(token), + }); + expect(response.statusCode).toBe(202); + + const body = response.json() as { jobId?: number }; + expect(body.jobId).toEqual(expect.any(Number)); + return waitForScanCompletion(ctx.db, body.jobId!, 45_000); +} + +async function waitForCoverRefreshCalls(ctx: AuthorizationMatrixE2EContext, expectedCount: number): Promise> { + await waitForCondition(() => { + expect(ctx.metadataMock.refreshCoverForBook).toHaveBeenCalledTimes(expectedCount); + }, 10_000); + + return ctx.metadataMock.refreshCoverForBook.mock.calls as Array<[number, string, string]>; +} + +async function createLibraryViaApi( + ctx: AuthorizationMatrixE2EContext, + token: string, + input?: Partial<{ + name: string; + folders: string[]; + icon: string; + watch: boolean; + allowedFormats: string[]; + organizationMode: 'book_per_file' | 'book_per_folder'; + excludePatterns: string[]; + readingThreshold: number; + markAsFinishedPercentComplete: number; + fileWriteEnabled: boolean; + }>, +): Promise<{ + response: InjectResponse; + body: { + id: number; + name: string; + folders: Array<{ id: number; path: string }>; + } & Record; +}> { + const libraryFolderPath = input?.folders?.[0] ?? join(ctx.fixture.booksPath, `api-library-${randomUUID()}`); + await mkdir(libraryFolderPath, { recursive: true }); + + const response = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/libraries', + headers: authHeader(token), + payload: { + name: input?.name ?? `Library Admin ${randomUUID()}`, + icon: input?.icon ?? 'library', + folders: [libraryFolderPath], + watch: input?.watch ?? false, + allowedFormats: input?.allowedFormats ?? ['epub'], + organizationMode: input?.organizationMode ?? 'book_per_file', + excludePatterns: input?.excludePatterns ?? ['*.tmp'], + readingThreshold: input?.readingThreshold ?? 0.4, + markAsFinishedPercentComplete: input?.markAsFinishedPercentComplete ?? 95, + fileWriteEnabled: input?.fileWriteEnabled ?? false, + }, + }); + expect(response.statusCode).toBe(201); + + return { + response, + body: response.json() as { + id: number; + name: string; + folders: Array<{ id: number; path: string }>; + } & Record, + }; +} + +describe('Library admin workflows (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => { + let ctx!: AuthorizationMatrixE2EContext; + let manager!: TestUserSession; + let scopedUser!: TestUserSession; + let uploadOnlyUser!: TestUserSession; + let noPermissionUser!: TestUserSession; + + beforeAll(async () => { + ctx = await createAuthorizationMatrixE2EContext(); + + manager = await createUserAndLogin(ctx, { + permissions: [Permission.ManageLibraries, Permission.LibraryUpload], + }); + scopedUser = await createUserAndLogin(ctx); + uploadOnlyUser = await createUserAndLogin(ctx, { + permissions: [Permission.LibraryUpload], + }); + noPermissionUser = await createUserAndLogin(ctx); + }); + + beforeEach(() => { + ctx.metadataMock.extractAndSave.mockClear(); + ctx.metadataMock.refreshCoverForBook.mockClear(); + }); + + afterAll(async () => { + await closeAuthorizationMatrixE2EContext(ctx); + }); + + describe('path and prescan', () => { + it('lists valid directories, hides blocked or hidden entries, and returns prescan details for valid, overlapping, and missing paths', async () => { + const overlapLibraryName = `library-overlap-${randomUUID()}`; + const overlapLibrary = await createLibraryWithFolder(ctx, { name: overlapLibraryName }); + const visibleDir = join(ctx.fixture.booksPath, `path-visible-${randomUUID()}`); + const hiddenDir = join(ctx.fixture.booksPath, `.path-hidden-${randomUUID()}`); + await mkdir(visibleDir, { recursive: true }); + await mkdir(hiddenDir, { recursive: true }); + + const prescanValidDir = join(ctx.fixture.booksPath, `prescan-valid-${randomUUID()}`); + await mkdir(prescanValidDir, { recursive: true }); + await createEpubFixture(prescanValidDir, 'prescan-book.epub', { title: 'Prescan Valid Title' }); + + const pathResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/path?path=${encodeURIComponent(ctx.fixture.booksPath)}`, + headers: authHeader(manager.accessToken), + }); + + expect(pathResponse.statusCode).toBe(200); + const pathBody = pathResponse.json() as Array<{ name: string; path: string }>; + expect(pathBody).toEqual( + expect.arrayContaining([ + { + name: basename(overlapLibrary.folderPath), + path: overlapLibrary.folderPath, + }, + { + name: basename(visibleDir), + path: visibleDir, + }, + { + name: basename(prescanValidDir), + path: prescanValidDir, + }, + ]), + ); + expect(pathBody.some((entry) => entry.name === basename(hiddenDir))).toBe(false); + + const blockedPathResponse = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/path?path=/proc', + headers: authHeader(manager.accessToken), + }); + expect(blockedPathResponse.statusCode).toBe(200); + expect(blockedPathResponse.json()).toEqual([]); + + const forbiddenPathResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/path?path=${encodeURIComponent(ctx.fixture.booksPath)}`, + headers: authHeader(noPermissionUser.accessToken), + }); + expect(forbiddenPathResponse.statusCode).toBe(403); + + const prescanResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/libraries/prescan', + headers: authHeader(manager.accessToken), + payload: { + paths: [prescanValidDir, overlapLibrary.folderPath, join(ctx.fixture.booksPath, `missing-${randomUUID()}`)], + }, + }); + + expect(prescanResponse.statusCode).toBe(201); + const prescanBody = prescanResponse.json() as { + totalFiles: number; + paths: Array<{ + path: string; + accessible: boolean; + fileCount: number; + overlapLibrary?: string; + error?: string; + }>; + }; + + expect(prescanBody.totalFiles).toBe(1); + expect(prescanBody.paths).toEqual([ + { + path: prescanValidDir, + accessible: true, + fileCount: 1, + overlapLibrary: undefined, + error: undefined, + }, + { + path: overlapLibrary.folderPath, + accessible: true, + fileCount: 0, + overlapLibrary: overlapLibraryName, + error: undefined, + }, + { + path: expect.stringContaining('/missing-'), + accessible: false, + fileCount: 0, + overlapLibrary: undefined, + error: 'Path does not exist', + }, + ]); + + const forbiddenPrescanResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/libraries/prescan', + headers: authHeader(noPermissionUser.accessToken), + payload: { paths: [prescanValidDir] }, + }); + expect(forbiddenPrescanResponse.statusCode).toBe(403); + }); + }); + + describe('create library and manage access', () => { + it('creates a library with the requested settings, requires explicit access rows, and reflects grant/update/revoke changes', async () => { + const { body: createdLibrary } = await createLibraryViaApi(ctx, manager.accessToken, { + name: `library-contract-${randomUUID()}`, + }); + + expect(createdLibrary).toMatchObject({ + id: expect.any(Number), + name: expect.stringContaining('library-contract-'), + icon: 'library', + watch: false, + allowedFormats: ['epub'], + organizationMode: 'book_per_file', + excludePatterns: ['*.tmp'], + readingThreshold: 0.4, + markAsFinishedPercentComplete: 95, + fileWriteEnabled: false, + }); + expect(createdLibrary.folders).toHaveLength(1); + expect(createdLibrary.folders[0]).toMatchObject({ + id: expect.any(Number), + path: expect.any(String), + }); + + await waitForNoRunningScans(ctx, createdLibrary.id); + + const managerLibrariesBeforeGrant = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/libraries', + headers: authHeader(manager.accessToken), + }); + expect(managerLibrariesBeforeGrant.statusCode).toBe(200); + const visibleLibraryIdsBeforeGrant = (managerLibrariesBeforeGrant.json() as Array<{ id: number }>).map((library) => library.id); + expect(visibleLibraryIdsBeforeGrant).not.toContain(createdLibrary.id); + + const managerStatsBeforeGrant = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/libraries/${createdLibrary.id}/stats`, + headers: authHeader(manager.accessToken), + }); + expectError(managerStatsBeforeGrant, 403, 'No library access'); + + const forbiddenAccessLookup = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/libraries/${createdLibrary.id}/access`, + headers: authHeader(noPermissionUser.accessToken), + }); + expect(forbiddenAccessLookup.statusCode).toBe(403); + + const selfGrantResponse = await ctx.app.inject({ + method: 'POST', + url: `/api/v1/libraries/${createdLibrary.id}/access`, + headers: authHeader(manager.accessToken), + payload: { + userId: manager.userId, + accessLevel: 'owner', + }, + }); + expect(selfGrantResponse.statusCode).toBe(201); + + const scopedGrantResponse = await ctx.app.inject({ + method: 'POST', + url: `/api/v1/libraries/${createdLibrary.id}/access`, + headers: authHeader(manager.accessToken), + payload: { + userId: scopedUser.userId, + accessLevel: 'viewer', + }, + }); + expect(scopedGrantResponse.statusCode).toBe(201); + + const accessResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/libraries/${createdLibrary.id}/access`, + headers: authHeader(manager.accessToken), + }); + expect(accessResponse.statusCode).toBe(200); + expect(accessResponse.json()).toEqual( + expect.arrayContaining([ + { + userId: manager.userId, + accessLevel: 'owner', + username: manager.username, + name: expect.any(String), + }, + { + userId: scopedUser.userId, + accessLevel: 'viewer', + username: scopedUser.username, + name: expect.any(String), + }, + ]), + ); + + const updateAccessResponse = await ctx.app.inject({ + method: 'PATCH', + url: `/api/v1/libraries/${createdLibrary.id}/access/${scopedUser.userId}`, + headers: authHeader(manager.accessToken), + payload: { + accessLevel: 'editor', + }, + }); + expect(updateAccessResponse.statusCode).toBe(200); + + const accessAfterUpdate = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/libraries/${createdLibrary.id}/access`, + headers: authHeader(manager.accessToken), + }); + expect(accessAfterUpdate.statusCode).toBe(200); + expect(accessAfterUpdate.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + userId: scopedUser.userId, + accessLevel: 'editor', + }), + ]), + ); + + const managerLibrariesAfterGrant = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/libraries', + headers: authHeader(manager.accessToken), + }); + expect(managerLibrariesAfterGrant.statusCode).toBe(200); + expect((managerLibrariesAfterGrant.json() as Array<{ id: number }>).map((library) => library.id)).toContain(createdLibrary.id); + + const revokeAccessResponse = await ctx.app.inject({ + method: 'DELETE', + url: `/api/v1/libraries/${createdLibrary.id}/access/${scopedUser.userId}`, + headers: authHeader(manager.accessToken), + }); + expect(revokeAccessResponse.statusCode).toBe(204); + + const revokedLibraryResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/libraries/${createdLibrary.id}`, + headers: authHeader(scopedUser.accessToken), + }); + expectError(revokedLibraryResponse, 403, 'No library access'); + }); + }); + + describe('upload, scan, and stats', () => { + it('enforces upload boundaries, persists uploaded files, scans direct file additions, and isolates stats after access revocation', async () => { + const { body: createdLibrary } = await createLibraryViaApi(ctx, manager.accessToken, { + name: `library-upload-${randomUUID()}`, + }); + await waitForNoRunningScans(ctx, createdLibrary.id); + + const libraryFolder = createdLibrary.folders[0]!; + const otherLibrary = await createLibraryWithFolder(ctx, { name: `other-folder-${randomUUID()}` }); + + await grantLibraryAccess(ctx, manager.userId, createdLibrary.id, 'owner'); + await grantLibraryAccess(ctx, scopedUser.userId, createdLibrary.id, 'viewer'); + await grantLibraryAccess(ctx, noPermissionUser.userId, createdLibrary.id, 'viewer'); + + const uploadBytes = await createUploadFixtureBytes(ctx, `upload-fixtures/${randomUUID()}/uploaded-contract.epub`, 'Uploaded Contract Title'); + + const uploadForbiddenByAccess = await uploadLibraryFile(ctx, { + token: uploadOnlyUser.accessToken, + libraryId: createdLibrary.id, + folderId: libraryFolder.id, + fileName: 'uploaded-contract.epub', + content: uploadBytes, + contentType: 'application/epub+zip', + }); + expectError(uploadForbiddenByAccess, 403, 'No access to this library'); + + const uploadForbiddenByPermission = await uploadLibraryFile(ctx, { + token: noPermissionUser.accessToken, + libraryId: createdLibrary.id, + folderId: libraryFolder.id, + fileName: 'uploaded-contract.epub', + content: uploadBytes, + contentType: 'application/epub+zip', + }); + expect(uploadForbiddenByPermission.statusCode).toBe(403); + + const invalidFolderIdUpload = await uploadLibraryFileWithRawQuery(ctx, { + token: manager.accessToken, + libraryId: createdLibrary.id, + query: '?folderId=not-a-number', + file: { + fileName: 'invalid-folder.epub', + content: uploadBytes, + contentType: 'application/epub+zip', + }, + }); + expectError(invalidFolderIdUpload, 400, 'Invalid folderId'); + + const wrongFolderUpload = await uploadLibraryFile(ctx, { + token: manager.accessToken, + libraryId: createdLibrary.id, + folderId: otherLibrary.libraryFolderId, + fileName: 'wrong-folder.epub', + content: uploadBytes, + contentType: 'application/epub+zip', + }); + expectError(wrongFolderUpload, 400, 'Folder does not belong to this library'); + + const missingFileUpload = await uploadLibraryFileWithRawQuery(ctx, { + token: manager.accessToken, + libraryId: createdLibrary.id, + }); + expectError(missingFileUpload, 400, 'No file provided'); + + const successfulUpload = await uploadLibraryFile(ctx, { + token: manager.accessToken, + libraryId: createdLibrary.id, + folderId: libraryFolder.id, + fileName: 'uploaded-contract.epub', + content: uploadBytes, + contentType: 'application/epub+zip', + }); + expect(successfulUpload.statusCode).toBe(201); + const uploadBody = successfulUpload.json() as { + bookId: number; + filename: string; + format: string; + sizeBytes: number; + }; + expect(uploadBody).toEqual({ + bookId: expect.any(Number), + filename: 'Uploaded Contract Title.epub', + format: 'epub', + sizeBytes: uploadBytes.length, + }); + + await waitForCondition(() => { + expect(ctx.metadataMock.extractAndSave).toHaveBeenCalledWith(uploadBody.bookId, expect.any(String), 'epub'); + }, 10_000); + + const uploadedBook = await ctx.db.query.books.findFirst({ + where: eq(schema.books.id, uploadBody.bookId), + }); + expect(uploadedBook).toMatchObject({ + libraryId: createdLibrary.id, + libraryFolderId: libraryFolder.id, + status: 'present', + }); + + const uploadedBookFile = await ctx.db.query.bookFiles.findFirst({ + where: eq(schema.bookFiles.bookId, uploadBody.bookId), + }); + expect(uploadedBookFile).toMatchObject({ + libraryFolderId: libraryFolder.id, + format: 'epub', + }); + expect(uploadedBookFile?.absolutePath).toContain('Uploaded Contract Title.epub'); + expect(uploadedBookFile?.relPath).toBe('Uploaded Contract Title.epub'); + + const uploadedMetadataRow = await ctx.db.query.bookMetadata.findFirst({ + where: eq(schema.bookMetadata.bookId, uploadBody.bookId), + }); + expect(uploadedMetadataRow).toBeDefined(); + + const duplicateUpload = await uploadLibraryFile(ctx, { + token: manager.accessToken, + libraryId: createdLibrary.id, + folderId: libraryFolder.id, + fileName: 'uploaded-contract.epub', + content: uploadBytes, + contentType: 'application/epub+zip', + }); + expectError(duplicateUpload, 409, 'already exists at the target location'); + + const scannedFilePath = await createEpubFixture(libraryFolder.path, 'scanned-discovered.epub', { + title: 'Scanned Discovered Title', + }); + + const scanResponseForbidden = await ctx.app.inject({ + method: 'POST', + url: `/api/v1/scanner/libraries/${createdLibrary.id}/scan`, + headers: authHeader(noPermissionUser.accessToken), + }); + expect(scanResponseForbidden.statusCode).toBe(403); + + const scanJob = await startLibraryScanAs(ctx, manager.accessToken, createdLibrary.id); + expect(scanJob.status).toBe('completed'); + await waitForNoRunningScans(ctx, createdLibrary.id); + + await waitForCondition(async () => { + const presentBooks = await ctx.db.query.books.findMany({ + where: and(eq(schema.books.libraryId, createdLibrary.id), eq(schema.books.status, 'present')), + }); + expect(presentBooks).toHaveLength(2); + }, 10_000); + + const scannedBook = await locateBookByAbsolutePath(ctx, scannedFilePath); + expect(scannedBook.libraryId).toBe(createdLibrary.id); + expect(scannedBook.format).toBe('epub'); + + const statsResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/libraries/${createdLibrary.id}/stats`, + headers: authHeader(scopedUser.accessToken), + }); + expect(statsResponse.statusCode).toBe(200); + + const primaryFiles = await ctx.db + .select({ + sizeBytes: schema.bookFiles.sizeBytes, + }) + .from(schema.books) + .innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId)) + .where(and(eq(schema.books.libraryId, createdLibrary.id), eq(schema.books.status, 'present'))); + const expectedTotalSize = primaryFiles.reduce((sum, row) => sum + Number(row.sizeBytes ?? 0), 0); + + expect(statsResponse.json()).toEqual({ + totalBooks: 2, + totalSizeBytes: expectedTotalSize, + formatCounts: { + epub: 2, + }, + }); + + const revokeScopedAccess = await ctx.app.inject({ + method: 'DELETE', + url: `/api/v1/libraries/${createdLibrary.id}/access/${scopedUser.userId}`, + headers: authHeader(manager.accessToken), + }); + expect(revokeScopedAccess.statusCode).toBe(204); + + const revokedStatsResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/libraries/${createdLibrary.id}/stats`, + headers: authHeader(scopedUser.accessToken), + }); + expectError(revokedStatsResponse, 403, 'No library access'); + }); + }); + + describe('refresh covers, reorder, and delete', () => { + it('queues cover refresh for each primary file, reorders libraries, and deletes a populated library while cleaning cover artifacts', async () => { + const { body: createdLibrary } = await createLibraryViaApi(ctx, manager.accessToken, { + name: `library-refresh-${randomUUID()}`, + }); + await waitForNoRunningScans(ctx, createdLibrary.id); + await grantLibraryAccess(ctx, manager.userId, createdLibrary.id, 'owner'); + + const reorderPeerLibrary = await createLibraryWithFolder(ctx, { name: `library-peer-${randomUUID()}` }); + const peerCoverLibrary = await createLibraryWithFolder(ctx, { name: `library-cover-peer-${randomUUID()}` }); + await grantLibraryAccess(ctx, manager.userId, reorderPeerLibrary.libraryId, 'owner'); + await grantLibraryAccess(ctx, manager.userId, peerCoverLibrary.libraryId, 'owner'); + + const uploadBytes = await createUploadFixtureBytes(ctx, `refresh-fixtures/${randomUUID()}/refresh-upload.epub`, 'Refresh Upload Title'); + const libraryFolder = createdLibrary.folders[0]!; + + const uploadResponse = await uploadLibraryFile(ctx, { + token: manager.accessToken, + libraryId: createdLibrary.id, + folderId: libraryFolder.id, + fileName: 'refresh-upload.epub', + content: uploadBytes, + contentType: 'application/epub+zip', + }); + expect(uploadResponse.statusCode).toBe(201); + const uploadedBookId = (uploadResponse.json() as { bookId: number }).bookId; + + const scannedFilePath = await createEpubFixture(libraryFolder.path, 'refresh-scanned.epub', { + title: 'Refresh Scanned Title', + }); + await startLibraryScanAs(ctx, manager.accessToken, createdLibrary.id); + const scannedBook = await locateBookByAbsolutePath(ctx, scannedFilePath); + + ctx.metadataMock.refreshCoverForBook.mockClear(); + const refreshCoversResponse = await ctx.app.inject({ + method: 'POST', + url: `/api/v1/scanner/libraries/${createdLibrary.id}/refresh-covers`, + headers: authHeader(manager.accessToken), + }); + expect(refreshCoversResponse.statusCode).toBe(202); + expect(refreshCoversResponse.json()).toEqual({ queued: 2 }); + + const refreshCalls = await waitForCoverRefreshCalls(ctx, 2); + const normalizedCalls = refreshCalls + .map(([bookId, absolutePath, format]) => ({ + bookId, + absolutePath, + format, + })) + .sort((left, right) => left.bookId - right.bookId); + expect(normalizedCalls).toEqual([ + { + bookId: uploadedBookId, + absolutePath: expect.any(String), + format: 'epub', + }, + { + bookId: scannedBook.bookId, + absolutePath: expect.any(String), + format: 'epub', + }, + ]); + + await writeFixtureFile(ctx.fixture.booksPath, `covers/${uploadedBookId}/cover_custom.jpg`, Buffer.from('cover', 'utf8')); + await writeFixtureFile(ctx.fixture.booksPath, `covers/${uploadedBookId}/thumbnail.jpg`, Buffer.from('thumb', 'utf8')); + + const reorderResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/libraries/reorder', + headers: authHeader(manager.accessToken), + payload: { + order: [ + { id: createdLibrary.id, displayOrder: 9 }, + { id: reorderPeerLibrary.libraryId, displayOrder: 1 }, + { id: peerCoverLibrary.libraryId, displayOrder: 2 }, + ], + }, + }); + expect(reorderResponse.statusCode).toBe(204); + + const adminLibrariesResponse = await ctx.app.inject({ + method: 'GET', + url: '/api/v1/libraries', + headers: authHeader(ctx.adminToken), + }); + expect(adminLibrariesResponse.statusCode).toBe(200); + const orderedLibraries = (adminLibrariesResponse.json() as Array<{ id: number; displayOrder: number }>).filter((library) => + [createdLibrary.id, reorderPeerLibrary.libraryId, peerCoverLibrary.libraryId].includes(library.id), + ); + expect(orderedLibraries.map((library) => ({ id: library.id, displayOrder: library.displayOrder }))).toEqual([ + { id: reorderPeerLibrary.libraryId, displayOrder: 1 }, + { id: peerCoverLibrary.libraryId, displayOrder: 2 }, + { id: createdLibrary.id, displayOrder: 9 }, + ]); + + const deleteResponse = await ctx.app.inject({ + method: 'DELETE', + url: `/api/v1/libraries/${createdLibrary.id}`, + headers: authHeader(manager.accessToken), + }); + expect(deleteResponse.statusCode).toBe(204); + + const deletedLibraryResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/libraries/${createdLibrary.id}`, + headers: authHeader(ctx.adminToken), + }); + expectError(deletedLibraryResponse, 404, 'Library not found'); + + const deletedLibraryBooks = await ctx.db + .select({ id: schema.books.id }) + .from(schema.books) + .where(eq(schema.books.libraryId, createdLibrary.id)); + expect(deletedLibraryBooks).toHaveLength(0); + + const deletedAccessRows = await ctx.db + .select({ userId: schema.userLibraryAccess.userId }) + .from(schema.userLibraryAccess) + .where(eq(schema.userLibraryAccess.libraryId, createdLibrary.id)); + expect(deletedAccessRows).toHaveLength(0); + + await expect(access(join(ctx.fixture.booksPath, 'covers', String(uploadedBookId)))).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + }); +}); diff --git a/server/test/reader-format-delivery.e2e-spec.ts b/server/test/reader-format-delivery.e2e-spec.ts new file mode 100644 index 000000000..cdc0c3103 --- /dev/null +++ b/server/test/reader-format-delivery.e2e-spec.ts @@ -0,0 +1,412 @@ +import { randomUUID } from 'crypto'; + +import { and, eq } from 'drizzle-orm'; + +import * as schema from '../src/db/schema'; +import { + authHeader, + closeReaderStateIsolationE2EContext, + createLibraryWithFolder, + createReaderStateIsolationE2EContext, + createUserAndLogin, + grantLibraryAccess, + locateBookByAbsolutePath, + triggerAndWaitForLibraryScan, + type LocatedBookFile, + type ReaderStateIsolationE2EContext, + type TestUserSession, +} from './e2e/reader-state-isolation/reader-state-isolation-harness'; +import { createEpubFixture, createZipArchiveFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder'; + +type InjectResponse = Awaited>; + +const SCENARIO_TIMEOUT_MS = 120_000; +const EPUB_STYLESHEET = 'body { background: #f5f1e8; color: #1f2937; }\nimg { max-width: 100%; }'; +const EPUB_BOOKMARKS = 'last-read=OPS/chapter.xhtml#intro'; +const PAGE_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+cQpUAAAAASUVORK5CYII=', 'base64'); +const PAGE_JPEG = Buffer.from( + '/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBxAQEBAQEA8QEA8QDw8QEA8PDw8QFREWFhURFRUYHSggGBolGxUVITEhJSkrLi4uFx8zODMsNygtLisBCgoKDg0OGhAQGy0lICUtLS8tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLf/AABEIAAEAAgMBEQACEQEDEQH/xAAXAAEBAQEAAAAAAAAAAAAAAAABAgME/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEAMQAAAB6A//xAAYEAEAAwEAAAAAAAAAAAAAAAABABEhMf/aAAgBAQABBQJXJZ//xAAVEQEBAAAAAAAAAAAAAAAAAAABAP/aAAgBAwEBPwGn/8QAFREBAQAAAAAAAAAAAAAAAAAAARD/2gAIAQIBAT8Bp//EABgQAQADAQAAAAAAAAAAAAAAAAEAESEx/9oACAEBAAY/AhGQx//EABsQAQABBQEAAAAAAAAAAAAAAAERACExQVFh/9oACAEBAAE/IV2K4zGq4Jm1q//aAAwDAQACAAMAAAAQ8//EABcRAQEBAQAAAAAAAAAAAAAAAAEREDH/2gAIAQMBAT8Qw0f/xAAWEQEBAQAAAAAAAAAAAAAAAAABEBH/2gAIAQIBAT8QkL//xAAbEAEBAQADAQEAAAAAAAAAAAABEQAhMUFhcf/aAAgBAQABPxC4oLQ1M8JrIoYNewc19hXtOD87mpy4V/mQJu1WDVYj1WFJsbgx5caX//Z', + 'base64', +); + +function responseMessage(response: { message?: string | string[] }): string { + if (Array.isArray(response.message)) return response.message.join(' '); + return String(response.message ?? ''); +} + +function expectError(response: InjectResponse, status: number, messageFragment?: string): void { + expect(response.statusCode).toBe(status); + if (!messageFragment) return; + expect(responseMessage(response.json() as { message?: string | string[] })).toContain(messageFragment); +} + +function responseBuffer(response: InjectResponse): Buffer { + const rawPayload = (response as unknown as { rawPayload?: unknown }).rawPayload; + if (Buffer.isBuffer(rawPayload)) return rawPayload; + return Buffer.from(response.body, 'binary'); +} + +function escapeXml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} + +async function createReaderDeliveryEpub(rootPath: string, relativePath: string, title: string): Promise { + const identifier = `urn:uuid:${randomUUID()}`; + const author = 'Reader Fixture Author'; + const publisher = 'Reader Fixture Publisher'; + const description = 'Reader delivery fixture description'; + const containerXml = ` + + + + +`; + const opfXml = ` + + + ${escapeXml(identifier)} + ${escapeXml(title)} + ${escapeXml(author)} + en + ${escapeXml(publisher)} + ${escapeXml(description)} + 2026-01-01T00:00:00Z + + + + + + + + + + +`; + const chapterXml = `${escapeXml(title)}

${escapeXml(title)}

fixture chapter

cover`; + const navXml = `${escapeXml( + title, + )}`; + + return createZipArchiveFixture(rootPath, relativePath, [ + { path: 'mimetype', content: 'application/epub+zip', store: true }, + { path: 'META-INF/container.xml', content: containerXml }, + { path: 'META-INF/calibre_bookmarks.txt', content: EPUB_BOOKMARKS }, + { path: 'OPS/content.opf', content: opfXml }, + { path: 'OPS/chapter.xhtml', content: chapterXml }, + { path: 'OPS/nav.xhtml', content: navXml }, + { path: 'OPS/styles/reader style.css', content: EPUB_STYLESHEET }, + { path: 'OPS/images/cover image.png', content: PAGE_PNG }, + { path: 'OPS/private.txt', content: 'hidden-unmanifested-entry' }, + ]); +} + +async function createReaderDeliveryCbz(rootPath: string, relativePath: string, title: string): Promise { + const comicInfoXml = ` + + ${escapeXml(title)} + Reader Fixture Cartoonist + Reader Fixture Press + 2026 +`; + + return createZipArchiveFixture(rootPath, relativePath, [ + { path: 'ComicInfo.xml', content: comicInfoXml }, + { path: 'pages/001-cover.png', content: PAGE_PNG }, + { path: 'pages/002-spread.jpg', content: PAGE_JPEG }, + { path: '.hidden/003-secret.png', content: PAGE_PNG }, + { path: 'notes/readme.txt', content: 'not a page' }, + ]); +} + +describe('Reader format delivery (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => { + let ctx!: ReaderStateIsolationE2EContext; + + let sharedLibrary!: Awaited>; + let hiddenLibrary!: Awaited>; + + let sharedEpub!: LocatedBookFile; + let sharedCbz!: LocatedBookFile; + let hiddenEpub!: LocatedBookFile; + + let viewer!: TestUserSession; + let crossLibraryUser!: TestUserSession; + let outsider!: TestUserSession; + + beforeAll(async () => { + ctx = await createReaderStateIsolationE2EContext(); + + sharedLibrary = await createLibraryWithFolder(ctx, { name: `reader-delivery-shared-${randomUUID()}` }); + hiddenLibrary = await createLibraryWithFolder(ctx, { name: `reader-delivery-hidden-${randomUUID()}` }); + + const sharedEpubPath = await createReaderDeliveryEpub(sharedLibrary.folderPath, 'delivery/reader-delivery.epub', 'Reader Delivery EPUB'); + const sharedCbzPath = await createReaderDeliveryCbz(sharedLibrary.folderPath, 'delivery/reader-delivery.cbz', 'Reader Delivery Comic'); + const hiddenEpubPath = await createEpubFixture(hiddenLibrary.folderPath, 'restricted/hidden-reader.epub', { + title: 'Hidden Reader EPUB', + }); + + await triggerAndWaitForLibraryScan(ctx, sharedLibrary.libraryId); + await triggerAndWaitForLibraryScan(ctx, hiddenLibrary.libraryId); + + sharedEpub = await locateBookByAbsolutePath(ctx, sharedEpubPath); + sharedCbz = await locateBookByAbsolutePath(ctx, sharedCbzPath); + hiddenEpub = await locateBookByAbsolutePath(ctx, hiddenEpubPath); + + viewer = await createUserAndLogin(ctx); + crossLibraryUser = await createUserAndLogin(ctx); + outsider = await createUserAndLogin(ctx); + + await grantLibraryAccess(ctx, viewer.userId, sharedLibrary.libraryId, 'viewer'); + await grantLibraryAccess(ctx, crossLibraryUser.userId, sharedLibrary.libraryId, 'viewer'); + await grantLibraryAccess(ctx, crossLibraryUser.userId, hiddenLibrary.libraryId, 'viewer'); + }, 60_000); + + afterAll(async () => { + if (ctx) { + await closeReaderStateIsolationE2EContext(ctx); + } + }); + + describe('EPUB info and asset delivery', () => { + it('returns the EPUB info shape the reader expects and serves manifest and optional files', async () => { + const infoResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${sharedEpub.bookFileId}`, + headers: authHeader(viewer.accessToken), + }); + expect(infoResponse.statusCode).toBe(200); + + const info = infoResponse.json() as { + containerPath: string; + rootPath: string; + coverPath: string | null; + optionalFiles?: string[]; + metadata: Record; + spine: Array<{ idref: string; href: string; mediaType: string; linear: boolean }>; + manifest: Array<{ id: string; href: string; mediaType: string; size: number; properties?: string[] }>; + toc: { label: string; children?: Array<{ label: string; href?: string }> } | null; + }; + expect(info).toMatchObject({ + containerPath: 'OPS/content.opf', + rootPath: 'OPS/', + coverPath: 'OPS/images/cover image.png', + optionalFiles: ['META-INF/calibre_bookmarks.txt'], + metadata: { + title: 'Reader Delivery EPUB', + creator: 'Reader Fixture Author', + language: 'en', + publisher: 'Reader Fixture Publisher', + description: 'Reader delivery fixture description', + identifier: expect.stringContaining('urn:uuid:'), + }, + spine: [ + { + idref: 'chapter', + href: 'OPS/chapter.xhtml', + mediaType: 'application/xhtml+xml', + linear: true, + }, + ], + toc: { + label: 'Table of Contents', + children: [{ label: 'Start Reading', href: 'OPS/chapter.xhtml#intro' }], + }, + }); + expect(info.manifest).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'chapter', href: 'OPS/chapter.xhtml', mediaType: 'application/xhtml+xml', size: expect.any(Number) }), + expect.objectContaining({ + id: 'nav', + href: 'OPS/nav.xhtml', + mediaType: 'application/xhtml+xml', + size: expect.any(Number), + properties: ['nav'], + }), + expect.objectContaining({ id: 'style', href: 'OPS/styles/reader style.css', mediaType: 'text/css', size: expect.any(Number) }), + expect.objectContaining({ + id: 'cover', + href: 'OPS/images/cover image.png', + mediaType: 'image/png', + size: expect.any(Number), + properties: ['cover-image'], + }), + ]), + ); + + const defaultFileInfoResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/epub/${sharedEpub.bookId}/info`, + headers: authHeader(viewer.accessToken), + }); + expect(defaultFileInfoResponse.statusCode).toBe(200); + const defaultInfo = defaultFileInfoResponse.json() as { containerPath: string; coverPath: string | null }; + expect(defaultInfo.containerPath).toBe('OPS/content.opf'); + expect(defaultInfo.coverPath).toBe('OPS/images/cover image.png'); + + const chapterResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/epub/${sharedEpub.bookId}/file/OPS/chapter.xhtml`, + headers: authHeader(viewer.accessToken), + }); + expect(chapterResponse.statusCode).toBe(200); + expect(chapterResponse.headers['content-type']).toContain('application/xhtml+xml'); + expect(chapterResponse.headers['cache-control']).toBe('public, max-age=3600'); + expect(chapterResponse.body).toContain('id="intro"'); + + const stylesheetResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/epub/${sharedEpub.bookId}/file/OPS/styles/reader%20style.css?fileId=${sharedEpub.bookFileId}`, + headers: authHeader(viewer.accessToken), + }); + expect(stylesheetResponse.statusCode).toBe(200); + expect(stylesheetResponse.headers['content-type']).toContain('text/css'); + expect(stylesheetResponse.headers['content-length']).toBe(String(Buffer.byteLength(EPUB_STYLESHEET))); + expect(stylesheetResponse.headers['cache-control']).toBe('public, max-age=3600'); + expect(stylesheetResponse.body).toBe(EPUB_STYLESHEET); + + const optionalFileResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/epub/${sharedEpub.bookId}/file/META-INF/calibre_bookmarks.txt?fileId=${sharedEpub.bookFileId}`, + headers: authHeader(viewer.accessToken), + }); + expect(optionalFileResponse.statusCode).toBe(200); + expect(optionalFileResponse.headers['content-type']).toContain('application/octet-stream'); + expect(optionalFileResponse.body).toBe(EPUB_BOOKMARKS); + }); + }); + + describe('EPUB path guards and user scoping', () => { + it('blocks traversal and unmanifested assets while enforcing library access boundaries', async () => { + const hiddenInfoForbidden = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/epub/${hiddenEpub.bookId}/info?fileId=${hiddenEpub.bookFileId}`, + headers: authHeader(viewer.accessToken), + }); + expectError(hiddenInfoForbidden, 403, 'No access to this library'); + + const hiddenInfoAllowed = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/epub/${hiddenEpub.bookId}/info?fileId=${hiddenEpub.bookFileId}`, + headers: authHeader(crossLibraryUser.accessToken), + }); + expect(hiddenInfoAllowed.statusCode).toBe(200); + expect((hiddenInfoAllowed.json() as { metadata: { title?: string } }).metadata.title).toBe('Hidden Reader EPUB'); + + const unmanifestedAssetResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/epub/${sharedEpub.bookId}/file/OPS/private.txt?fileId=${sharedEpub.bookFileId}`, + headers: authHeader(viewer.accessToken), + }); + expectError(unmanifestedAssetResponse, 404, 'Entry not in archive: OPS/private.txt'); + + const traversalResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/epub/${sharedEpub.bookId}/file/%252E%252E/OPS/chapter.xhtml?fileId=${sharedEpub.bookFileId}`, + headers: authHeader(viewer.accessToken), + }); + expectError(traversalResponse, 403, 'Invalid path'); + + const mismatchedFileResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${hiddenEpub.bookFileId}`, + headers: authHeader(crossLibraryUser.accessToken), + }); + expectError(mismatchedFileResponse, 404, `File ${hiddenEpub.bookFileId} not found for book ${sharedEpub.bookId}`); + + const outsiderResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${sharedEpub.bookFileId}`, + headers: authHeader(outsider.accessToken), + }); + expectError(outsiderResponse, 403, 'No access to this library'); + }); + }); + + describe('CBZ page delivery contract', () => { + it('returns page counts, streams ordered pages, and rejects invalid page requests and unsupported formats', async () => { + const pagesResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages`, + headers: authHeader(viewer.accessToken), + }); + expect(pagesResponse.statusCode).toBe(200); + expect(pagesResponse.json()).toEqual({ pageCount: 2 }); + + const firstPageResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages/0`, + headers: authHeader(viewer.accessToken), + }); + expect(firstPageResponse.statusCode).toBe(200); + expect(firstPageResponse.headers['content-type']).toContain('image/png'); + expect(firstPageResponse.headers['cache-control']).toBe('public, max-age=31536000, immutable'); + expect(responseBuffer(firstPageResponse)).toEqual(PAGE_PNG); + + const secondPageResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages/1`, + headers: authHeader(viewer.accessToken), + }); + expect(secondPageResponse.statusCode).toBe(200); + expect(secondPageResponse.headers['content-type']).toContain('image/jpeg'); + expect(responseBuffer(secondPageResponse)).toEqual(PAGE_JPEG); + + const negativePageResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages/-1`, + headers: authHeader(viewer.accessToken), + }); + expectError(negativePageResponse, 404, 'Page -1 out of range'); + + const overflowPageResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages/2`, + headers: authHeader(viewer.accessToken), + }); + expectError(overflowPageResponse, 404, 'Page 2 out of range'); + + const unsupportedFormatResponse = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/cbz/files/${sharedEpub.bookFileId}/pages`, + headers: authHeader(viewer.accessToken), + }); + expectError(unsupportedFormatResponse, 404, 'Unsupported comic format: epub'); + }); + }); + + describe('reader access revocation', () => { + it('applies library access changes immediately while leaving other users isolated', async () => { + await ctx.db + .delete(schema.userLibraryAccess) + .where(and(eq(schema.userLibraryAccess.userId, viewer.userId), eq(schema.userLibraryAccess.libraryId, sharedLibrary.libraryId))); + + const revokedEpubInfo = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${sharedEpub.bookFileId}`, + headers: authHeader(viewer.accessToken), + }); + expectError(revokedEpubInfo, 403, 'No access to this library'); + + const revokedCbzPages = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages`, + headers: authHeader(viewer.accessToken), + }); + expectError(revokedCbzPages, 403, 'No access to this library'); + + const unaffectedUserEpubInfo = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${sharedEpub.bookFileId}`, + headers: authHeader(crossLibraryUser.accessToken), + }); + expect(unaffectedUserEpubInfo.statusCode).toBe(200); + + const unaffectedUserCbzPages = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages`, + headers: authHeader(crossLibraryUser.accessToken), + }); + expect(unaffectedUserCbzPages.statusCode).toBe(200); + expect(unaffectedUserCbzPages.json()).toEqual({ pageCount: 2 }); + }); + }); +});