Files
bookorbit/server/test/library-admin-workflows.e2e-spec.ts
T
neon df926fdf0f fix(library): include saved settings in scoped library summaries
Return saved organization and automation settings in the user-scoped library list so
non-superuser summaries reflect the saved configuration.

Cover enabled and disabled settings, library isolation, and permission denials with
API regression tests.

Closes #1664
2026-10-03 16:45:15 +00:00

1068 lines
42 KiB
TypeScript

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<ReturnType<AuthorizationMatrixE2EContext['app']['inject']>>;
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<InjectResponse> {
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<Buffer> {
const absolutePath = await createEpubFixture(ctx.fixture.rootPath, relativePath, { title });
return readFile(absolutePath);
}
async function waitForNoRunningScans(ctx: AuthorizationMatrixE2EContext, libraryId: number): Promise<void> {
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<typeof schema.scanJobs.$inferSelect> {
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<Array<[number, string, string]>> {
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<string, unknown>;
}> {
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<string, unknown>,
};
}
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<AddedAtRecomputeJob> {
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',
});
});
});
});