import { randomUUID } from 'crypto'; import { access, mkdir, readFile, stat, utimes, writeFile } from 'fs/promises'; import { basename, join } from 'path'; import { and, eq, sql } from 'drizzle-orm'; import { Permission } from '@bookorbit/types'; import type { AddedAtRecomputeJob } from '@bookorbit/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 } from './e2e/authorization-matrix/authorization-matrix-fixture-builder'; import { createSlotCoverArtifacts } from './e2e/slot-cover-artifacts'; 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 = `----bookorbit-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); }); it.each([ { organizationMode: 'book_per_file', autoScanCronExpression: '0 2 * * *', watch: true, fileWriteEnabled: true }, { organizationMode: 'book_per_folder', autoScanCronExpression: null, watch: false, fileWriteEnabled: false }, ])('returns saved library summary settings to non-superusers: $organizationMode', async (savedSettings) => { const library = await createLibraryWithFolder(ctx); const hiddenLibrary = await createLibraryWithFolder(ctx); await ctx.db.update(schema.libraries).set(savedSettings).where(eq(schema.libraries.id, library.libraryId)); await grantLibraryAccess(ctx, manager.userId, library.libraryId, 'viewer'); const detailResponse = await ctx.app.inject({ method: 'GET', url: `/api/v1/libraries/${library.libraryId}`, headers: authHeader(manager.accessToken), }); expect(detailResponse.statusCode).toBe(200); expect(detailResponse.json()).toMatchObject(savedSettings); const superuserResponse = await ctx.app.inject({ method: 'GET', url: '/api/v1/libraries', headers: authHeader(ctx.adminToken), }); expect(superuserResponse.statusCode).toBe(200); const superuserLibraries = superuserResponse.json() as Array<{ id: number }>; expect(superuserLibraries.find(({ id }) => id === library.libraryId)).toMatchObject(savedSettings); const scopedResponse = await ctx.app.inject({ method: 'GET', url: '/api/v1/libraries', headers: authHeader(manager.accessToken), }); expect(scopedResponse.statusCode).toBe(200); const scopedLibraries = scopedResponse.json() as Array<{ id: number }>; expect(scopedLibraries.map(({ id }) => id)).not.toContain(hiddenLibrary.libraryId); expect(scopedLibraries.find(({ id }) => id === library.libraryId)).toMatchObject({ ...savedSettings, accessLevel: 'viewer', }); }); it('does not grant management or file-write actions to viewers who can read automation settings', async () => { const library = await createLibraryWithFolder(ctx); const savedSettings = { watch: true, autoScanCronExpression: '0 2 * * *', fileWriteEnabled: true }; await ctx.db.update(schema.libraries).set(savedSettings).where(eq(schema.libraries.id, library.libraryId)); await grantLibraryAccess(ctx, scopedUser.userId, library.libraryId, 'viewer'); const listResponse = await ctx.app.inject({ method: 'GET', url: '/api/v1/libraries', headers: authHeader(scopedUser.accessToken), }); expect(listResponse.statusCode).toBe(200); const visibleLibraries = listResponse.json() as Array<{ id: number }>; expect(visibleLibraries.find(({ id }) => id === library.libraryId)).toMatchObject(savedSettings); for (const payload of [{ watch: false }, { autoScanCronExpression: null }, { fileWriteEnabled: false }]) { const response = await ctx.app.inject({ method: 'PATCH', url: `/api/v1/libraries/${library.libraryId}`, headers: authHeader(scopedUser.accessToken), payload, }); expectError(response, 403, 'Missing permission: manage_libraries'); } for (const action of ['scan', 'refresh-covers']) { const response = await ctx.app.inject({ method: 'POST', url: `/api/v1/scanner/libraries/${library.libraryId}/${action}`, headers: authHeader(scopedUser.accessToken), }); expectError(response, 403, 'Missing permission: manage_libraries'); } const writeUrl = `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`; expectError( await ctx.app.inject({ method: 'POST', url: writeUrl, headers: authHeader(scopedUser.accessToken) }), 403, 'Missing permission: library_edit_metadata', ); const metadataEditor = await createUserAndLogin(ctx, { permissions: [Permission.LibraryEditMetadata] }); await grantLibraryAccess(ctx, metadataEditor.userId, library.libraryId, 'viewer'); expectError( await ctx.app.inject({ method: 'POST', url: writeUrl, headers: authHeader(metadataEditor.accessToken) }), 403, 'Insufficient library access level', ); const [storedLibrary] = await ctx.db.select().from(schema.libraries).where(eq(schema.libraries.id, library.libraryId)); expect(storedLibrary).toMatchObject(savedSettings); }); describe('date added recompute', () => { async function finishRecompute(libraryId: number): Promise { let result: AddedAtRecomputeJob; await waitForCondition(async () => { const response = await ctx.app.inject({ method: 'GET', url: `/api/v1/libraries/${libraryId}/recompute-added-at`, headers: authHeader(manager.accessToken), }); expect(response.statusCode).toBe(200); result = response.json() as AddedAtRecomputeJob; expect(result.status).not.toBe('running'); }, 15_000); return result!; } it('enforces both permission and library access and updates multiple batches with precise database timestamps', async () => { const library = await createLibraryWithFolder(ctx); const url = `/api/v1/libraries/${library.libraryId}/recompute-added-at`; for (const method of ['POST', 'GET'] as const) { expect((await ctx.app.inject({ method, url, headers: authHeader(manager.accessToken) })).statusCode).toBe(403); } await grantLibraryAccess(ctx, manager.userId, library.libraryId, 'viewer'); expect((await ctx.app.inject({ method: 'POST', url, headers: authHeader(manager.accessToken) })).statusCode).toBe(403); await grantLibraryAccess(ctx, manager.userId, library.libraryId, 'editor'); await grantLibraryAccess(ctx, scopedUser.userId, library.libraryId, 'editor'); for (const method of ['POST', 'GET'] as const) { expect((await ctx.app.inject({ method, url, headers: authHeader(scopedUser.accessToken) })).statusCode).toBe(403); } const imported = await ctx.app.inject({ method: 'POST', url, headers: authHeader(manager.accessToken) }); expect(imported.statusCode).toBe(400); expect(imported.json().errorCode).toBe('ADDED_AT_SOURCE_IMPORTED'); const update = await ctx.app.inject({ method: 'PATCH', url: `/api/v1/libraries/${library.libraryId}`, headers: authHeader(manager.accessToken), payload: { addedAtSource: 'file_modified', icon: 'BookOpen' }, }); expect(update.statusCode, update.body).toBe(200); expect(update.json().addedAtSource).toBe('file_modified'); const inserted = await ctx.db .insert(schema.books) .values( Array.from({ length: 105 }, (_, i) => ({ libraryId: library.libraryId, libraryFolderId: library.libraryFolderId, folderPath: join(library.folderPath, String(i)), addedAt: sql`'2026-01-01 00:00:00.000123+00'::timestamptz`, })), ) .returning({ id: schema.books.id }); const expectedTime = new Date('2019-01-01T00:00:00Z'); await ctx.db.insert(schema.bookFiles).values( inserted.slice(0, 104).flatMap(({ id }) => [ { bookId: id, libraryFolderId: library.libraryFolderId, absolutePath: join(library.folderPath, `${id}.epub`), ino: BigInt(id), mtime: expectedTime, role: 'content', }, { bookId: id, libraryFolderId: library.libraryFolderId, absolutePath: join(library.folderPath, `${id}.pdf`), ino: BigInt(id + 100000), mtime: new Date(0), role: 'content', }, { bookId: id, libraryFolderId: library.libraryFolderId, absolutePath: join(library.folderPath, `${id}.jpg`), ino: BigInt(id + 200000), mtime: new Date('2010-01-01'), role: 'cover', }, ]), ); const otherLibrary = await createLibraryWithFolder(ctx); const [other] = await ctx.db .insert(schema.books) .values({ libraryId: otherLibrary.libraryId, libraryFolderId: otherLibrary.libraryFolderId, folderPath: otherLibrary.folderPath }) .returning(); const started = await ctx.app.inject({ method: 'POST', url, headers: authHeader(manager.accessToken) }); expect(started.statusCode).toBe(202); expect(started.json().status).toBe('running'); expect(await finishRecompute(library.libraryId)).toMatchObject({ total: 105, processed: 105, updated: 104, skipped: 1, failed: 0 }); const [updated] = await ctx.db.select().from(schema.books).where(eq(schema.books.id, inserted[0].id)); expect(updated.addedAt).toEqual(expectedTime); const [untouched] = await ctx.db.select().from(schema.books).where(eq(schema.books.id, other.id)); expect(untouched.addedAt).toEqual(other.addedAt); expect((await ctx.app.inject({ method: 'POST', url, headers: authHeader(manager.accessToken) })).statusCode).toBe(202); expect(await finishRecompute(library.libraryId)).toMatchObject({ updated: 0, unchanged: 104, skipped: 1 }); }); it('uses real birthtimes and preserves dates when any content file is missing', async () => { const library = await createLibraryWithFolder(ctx); await grantLibraryAccess(ctx, manager.userId, library.libraryId, 'editor'); await ctx.db.update(schema.libraries).set({ addedAtSource: 'file_created' }).where(eq(schema.libraries.id, library.libraryId)); const original = new Date('2020-01-01T00:00:00Z'); const inserted = await ctx.db .insert(schema.books) .values( [0, 1].map((i) => ({ libraryId: library.libraryId, libraryFolderId: library.libraryFolderId, folderPath: join(library.folderPath, String(i)), addedAt: original, })), ) .returning(); const present = join(library.folderPath, 'present.epub'); await writeFile(present, 'timestamp fixture'); const info = await stat(present); await ctx.db.insert(schema.bookFiles).values([ { bookId: inserted[0].id, libraryFolderId: library.libraryFolderId, absolutePath: present, ino: 1n }, { bookId: inserted[1].id, libraryFolderId: library.libraryFolderId, absolutePath: join(library.folderPath, 'missing.epub'), ino: 2n }, ]); const response = await ctx.app.inject({ method: 'POST', url: `/api/v1/libraries/${library.libraryId}/recompute-added-at`, headers: authHeader(manager.accessToken), }); expect(response.statusCode).toBe(202); const result = await finishRecompute(library.libraryId); expect(result).toMatchObject({ processed: 2, updated: 1, failed: 1, failureSamples: [{ bookId: inserted[1].id, code: 'file_unavailable' }] }); expect(JSON.stringify(result)).not.toContain(library.folderPath); const rows = await ctx.db.select().from(schema.books).where(eq(schema.books.libraryId, library.libraryId)).orderBy(schema.books.id); expect(rows[0].addedAt).toEqual(info.birthtime.getTime() > 1000 ? info.birthtime : info.mtime); expect(rows[1].addedAt).toEqual(original); }); it('applies a saved file source to new scanner imports', async () => { const library = await createLibraryWithFolder(ctx); await ctx.db.update(schema.libraries).set({ addedAtSource: 'file_modified' }).where(eq(schema.libraries.id, library.libraryId)); const path = await createEpubFixture(library.folderPath, 'dated.epub', { title: 'Dated Book' }); const timestamp = new Date('2018-06-01T00:00:00Z'); await utimes(path, timestamp, timestamp); await startLibraryScanAs(ctx, ctx.adminToken, library.libraryId); const [book] = await ctx.db.select().from(schema.books).where(eq(schema.books.libraryId, library.libraryId)); expect(book.addedAt).toEqual(timestamp); }); }); 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 editPrescanResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/libraries/prescan', headers: authHeader(manager.accessToken), payload: { paths: [overlapLibrary.folderPath], libraryId: overlapLibrary.libraryId }, }); expect(editPrescanResponse.statusCode).toBe(201); expect(editPrescanResponse.json()).toEqual({ totalFiles: 0, paths: [ { path: overlapLibrary.folderPath, accessible: true, fileCount: 0, }, ], }); 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('persists fractional finished thresholds through create and update', async () => { const { body: createdLibrary } = await createLibraryViaApi(ctx, manager.accessToken, { markAsFinishedPercentComplete: 99.95, }); expect(createdLibrary.markAsFinishedPercentComplete).toBe(99.95); await grantLibraryAccess(ctx, manager.userId, createdLibrary.id, 'owner'); const update = await ctx.app.inject({ method: 'PATCH', url: `/api/v1/libraries/${createdLibrary.id}`, headers: authHeader(manager.accessToken), payload: { markAsFinishedPercentComplete: 98.05 }, }); expect(update.statusCode, update.body).toBe(200); expect(update.json().markAsFinishedPercentComplete).toBe(98.05); const read = await ctx.app.inject({ method: 'GET', url: `/api/v1/libraries/${createdLibrary.id}`, headers: authHeader(manager.accessToken), }); expect(read.statusCode, read.body).toBe(200); expect(read.json().markAsFinishedPercentComplete).toBe(98.05); }); 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'); // The default upload pattern files a book under its first author, and this fixture names none. expect(uploadedBookFile?.relPath).toBe('Unknown Author/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 createSlotCoverArtifacts(ctx, uploadedBookId, { coverContent: Buffer.from('cover', 'utf8'), thumbnailContent: 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', }); }); }); });