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
This commit is contained in:
neon
2026-10-03 16:45:15 +00:00
parent 60fcbd9410
commit df926fdf0f
3 changed files with 101 additions and 1 deletions
@@ -74,7 +74,7 @@ describe('LibraryRepository', () => {
expect(where).toHaveBeenCalledWith({ op: 'eq', left: libraries.type, right: 'books' });
});
it('findAllForUser includes file rename eligibility and counts only present books', async () => {
it('findAllForUser includes organization and automation settings and counts only present books', async () => {
const orderBy = vi.fn().mockResolvedValue([]);
const groupBy = vi.fn().mockReturnValue({ orderBy });
const where = vi.fn().mockReturnValue({ groupBy });
@@ -87,6 +87,10 @@ describe('LibraryRepository', () => {
expect(db.select).toHaveBeenCalledWith(
expect.objectContaining({
organizationMode: libraries.organizationMode,
autoScanCronExpression: libraries.autoScanCronExpression,
watch: libraries.watch,
fileWriteEnabled: libraries.fileWriteEnabled,
fileRenameEnabled: libraries.fileRenameEnabled,
}),
);
@@ -65,7 +65,11 @@ export class LibraryRepository {
icon: libraries.icon,
displayOrder: libraries.displayOrder,
coverAspectRatio: libraries.coverAspectRatio,
watch: libraries.watch,
autoScanCronExpression: libraries.autoScanCronExpression,
organizationMode: libraries.organizationMode,
scanMode: libraries.scanMode,
fileWriteEnabled: libraries.fileWriteEnabled,
fileRenameEnabled: libraries.fileRenameEnabled,
createdAt: libraries.createdAt,
updatedAt: libraries.updatedAt,
@@ -210,6 +210,98 @@ describe('Library admin workflows (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () =
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;