Files
bookorbit/server/test/authorization-matrix.e2e-spec.ts
neon b8ba579336 refactor: remove podcast support
Podcasts shipped switched off in every release and are now removed from the server,
web client and shared types. Migration 0132 drops the podcast tables and the media-type,
library-type and folder-role columns. Released iOS and Android builds read the removed
response fields as optional, so the API change stays compatible.
2026-10-30 15:04:14 +00:00

1801 lines
71 KiB
TypeScript

import { randomUUID } from 'crypto';
import { readFileSync } from 'fs';
import { access, chmod } from 'fs/promises';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { ConfigService } from '@nestjs/config';
import { Permission } from '@bookorbit/types';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { and, eq, inArray } from 'drizzle-orm';
import { createCoverToken } from '../src/modules/opds/opds-auth.guard';
import * as schema from '../src/db/schema';
import {
authHeader,
closeAuthorizationMatrixE2EContext,
createAuthorizationMatrixE2EContext,
createBookCoverArtifacts,
createKoboDevice,
createLibraryWithFolder,
createOpdsUser,
createReadingSession,
createBookDockRow,
createUserAndLogin,
grantLibraryAccess,
locateBookByAbsolutePath,
replaceUserPermissions,
setSetting,
setUserActive,
triggerAndWaitForLibraryScan,
uploadLibraryFile,
type AuthorizationMatrixE2EContext,
type CreatedLibrary,
type LocatedBookFile,
type TestUserSession,
} from './e2e/authorization-matrix/authorization-matrix-harness';
import { createEpubFixture } from './e2e/authorization-matrix/authorization-matrix-fixture-builder';
type SupportedHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
type InjectResponse = Awaited<ReturnType<NestFastifyApplication['inject']>>;
interface RouteInventoryRoute {
file: string;
className: string;
methodName: string;
httpMethod: string;
path: string;
permissions: string[];
/** `any` means one of `permissions` is enough; absent means every one of them is required. */
permissionMode?: 'any';
libraryAccess: string[];
isPublic: boolean;
allowDefault: boolean;
useGuards: string[];
}
interface RouteInventory {
totalRoutes: number;
byPermission: Record<string, number>;
byLibraryAccess: Record<string, number>;
routes: RouteInventoryRoute[];
}
interface RouteInventoryManifest {
totalRoutes: number;
byPermission: Record<string, number>;
byLibraryAccess: Record<string, number>;
routeChunks: string[];
}
interface MatrixFailure {
route: string;
status: number;
reason: string;
message: string;
}
interface Personas {
basicUser: TestUserSession;
defaultPwdUser: TestUserSession;
allPermsUser: TestUserSession;
permsNoLibraryUser: TestUserSession;
manageUsersAdmin: TestUserSession;
appSettingsAdmin: TestUserSession;
metadataEditor: TestUserSession;
opdsOwner: TestUserSession;
opdsIntruder: TestUserSession;
opdsDisabled: TestUserSession;
opdsRevoked: TestUserSession;
koboActive: TestUserSession;
koboDisabled: TestUserSession;
koboRevoked: TestUserSession;
bookDockUser: TestUserSession;
uploadUser: TestUserSession;
readAloudUser: TestUserSession;
downloadOnlyUser: TestUserSession;
ownerUser: TestUserSession;
otherUser: TestUserSession;
targetSuperuser: TestUserSession;
}
const currentDir = dirname(fileURLToPath(import.meta.url));
const routeInventory = loadRouteInventory();
const uncoveredRoutes = loadUncoveredRoutes();
const supportedMethods = new Set<SupportedHttpMethod>(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
function isSupportedMethod(method: string): method is SupportedHttpMethod {
return supportedMethods.has(method as SupportedHttpMethod);
}
function loadRouteInventory(): RouteInventory {
const inventoryDir = join(currentDir, 'e2e/authorization-matrix/route-inventory');
const manifest = JSON.parse(readFileSync(join(inventoryDir, 'manifest.json'), 'utf8')) as RouteInventoryManifest;
const routes = manifest.routeChunks.flatMap(
(chunkFile) => JSON.parse(readFileSync(join(inventoryDir, chunkFile), 'utf8')) as RouteInventoryRoute[],
);
return {
totalRoutes: manifest.totalRoutes,
byPermission: manifest.byPermission,
byLibraryAccess: manifest.byLibraryAccess,
routes,
};
}
/**
* Routes the hand-maintained inventory does not describe yet, and which every other test in this
* file therefore never probes. A frozen baseline rather than an allow-list: it may shrink as
* entries are written, never grow, so a route added from today on has to be inventoried.
*/
function loadUncoveredRoutes(): string[] {
const file = join(currentDir, 'e2e/authorization-matrix/route-inventory/uncovered-routes.json');
return JSON.parse(readFileSync(file, 'utf8')) as string[];
}
function liveRouteLabels(app: NestFastifyApplication): string[] {
const allMethodPaths = new Set(routeInventory.routes.filter((route) => route.httpMethod === 'ALL').map((route) => route.path));
const labels = new Set<string>();
const printedRoutes = app.getHttpAdapter().getInstance().printRoutes({ commonPrefix: false });
const pathAtDepth: string[] = [];
// This test-only parser intentionally follows Fastify's printRoutes tree layout.
// A Fastify formatting change should fail parity and be updated here explicitly.
for (const line of printedRoutes.split('\n')) {
const match = line.match(/^((?:│ {3}| {4})*)[├└]── (.+?)(?: \(([^)]+)\))?$/);
if (!match) continue;
const [, indentation = '', pathPart, rawMethods] = match;
const depth = indentation.length / 4;
pathAtDepth.length = depth;
pathAtDepth.push(pathPart ?? '');
if (!rawMethods) continue;
const prefixedPath = pathAtDepth.join('');
if (!prefixedPath?.startsWith('/api/v1')) continue;
const printedPath = prefixedPath.slice('/api/v1'.length) || '/';
const path = printedPath === '/epub/:bookId/media-overlay*' ? '/epub/:bookId/media-overlay/file/*' : printedPath;
if (allMethodPaths.has(path)) continue;
for (const method of rawMethods?.split(', ') ?? []) {
if (isSupportedMethod(method)) labels.add(`${method} ${path}`);
}
}
return [...labels].sort();
}
describe('Authorization matrix (e2e)', () => {
let ctx: AuthorizationMatrixE2EContext;
let libraryA: CreatedLibrary;
let libraryB: CreatedLibrary;
let libraryC: CreatedLibrary;
let bookA: LocatedBookFile;
let bookB: LocatedBookFile;
let bookC: LocatedBookFile;
let personas: Personas;
let authorOnlyBId: number;
let authorMixedId: number;
let inaccessibleSessionId: number;
let koboActiveDeviceToken: string;
let koboDisabledDeviceToken: string;
let koboRevokedDeviceToken: string;
let opdsValidCreds: { username: string; password: string };
let opdsDisabledCreds: { username: string; password: string };
let opdsRevokedCreds: { username: string; password: string };
beforeAll(async () => {
ctx = await createAuthorizationMatrixE2EContext();
libraryA = await createLibraryWithFolder(ctx, { name: `authz-lib-a-${randomUUID()}` });
libraryB = await createLibraryWithFolder(ctx, { name: `authz-lib-b-${randomUUID()}` });
libraryC = await createLibraryWithFolder(ctx, { name: `authz-lib-c-${randomUUID()}` });
const fileAPath = await createEpubFixture(libraryA.folderPath, 'book-a.epub', { title: 'Authorization Matrix Library A' });
const fileBPath = await createEpubFixture(libraryB.folderPath, 'book-b.epub', { title: 'Authorization Matrix Library B' });
const fileCPath = await createEpubFixture(libraryC.folderPath, 'book-c.epub', { title: 'Authorization Matrix Library C' });
await triggerAndWaitForLibraryScan(ctx, libraryA.libraryId);
await triggerAndWaitForLibraryScan(ctx, libraryB.libraryId);
await triggerAndWaitForLibraryScan(ctx, libraryC.libraryId);
bookA = await locateBookByAbsolutePath(ctx, fileAPath);
bookB = await locateBookByAbsolutePath(ctx, fileBPath);
bookC = await locateBookByAbsolutePath(ctx, fileCPath);
await createBookCoverArtifacts(ctx, bookA.bookId);
await createBookCoverArtifacts(ctx, bookB.bookId);
await createBookCoverArtifacts(ctx, bookC.bookId);
const allPermissions = Object.values(Permission);
personas = {
basicUser: await createUserAndLogin(ctx),
defaultPwdUser: await createUserAndLogin(ctx, {
password: 'DefaultPassword123',
isDefaultPassword: true,
}),
allPermsUser: await createUserAndLogin(ctx, { permissions: allPermissions }),
permsNoLibraryUser: await createUserAndLogin(ctx, { permissions: allPermissions }),
manageUsersAdmin: await createUserAndLogin(ctx, { permissions: [Permission.ManageUsers] }),
appSettingsAdmin: await createUserAndLogin(ctx, { permissions: [Permission.ManageAppSettings] }),
metadataEditor: await createUserAndLogin(ctx, { permissions: [Permission.LibraryEditMetadata] }),
opdsOwner: await createUserAndLogin(ctx, { permissions: [Permission.OpdsAccess] }),
opdsIntruder: await createUserAndLogin(ctx, { permissions: [Permission.OpdsAccess] }),
opdsDisabled: await createUserAndLogin(ctx, { permissions: [Permission.OpdsAccess] }),
opdsRevoked: await createUserAndLogin(ctx, { permissions: [Permission.OpdsAccess] }),
koboActive: await createUserAndLogin(ctx, { permissions: [Permission.KoboSync] }),
koboDisabled: await createUserAndLogin(ctx, { permissions: [Permission.KoboSync] }),
koboRevoked: await createUserAndLogin(ctx, { permissions: [Permission.KoboSync] }),
bookDockUser: await createUserAndLogin(ctx, { permissions: [Permission.BookDockAccess, Permission.LibraryUpload] }),
uploadUser: await createUserAndLogin(ctx, { permissions: [Permission.LibraryUpload] }),
readAloudUser: await createUserAndLogin(ctx, {
permissions: [Permission.LibraryUpload, Permission.LibraryGenerateReadAloud],
}),
downloadOnlyUser: await createUserAndLogin(ctx, { permissions: [Permission.LibraryDownload] }),
ownerUser: await createUserAndLogin(ctx),
otherUser: await createUserAndLogin(ctx),
targetSuperuser: await createUserAndLogin(ctx, { isSuperuser: true }),
};
await Promise.all([
grantLibraryAccess(ctx, personas.allPermsUser.userId, libraryA.libraryId, 'owner'),
grantLibraryAccess(ctx, personas.allPermsUser.userId, libraryB.libraryId, 'owner'),
grantLibraryAccess(ctx, personas.metadataEditor.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.bookDockUser.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.readAloudUser.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.downloadOnlyUser.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.ownerUser.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.opdsOwner.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.opdsIntruder.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.opdsDisabled.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.opdsRevoked.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.koboActive.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.koboDisabled.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.koboRevoked.userId, libraryA.libraryId, 'viewer'),
]);
await setUserActive(ctx, personas.opdsDisabled.userId, false);
await replaceUserPermissions(ctx, personas.opdsRevoked.userId, []);
await setUserActive(ctx, personas.koboDisabled.userId, false);
await replaceUserPermissions(ctx, personas.koboRevoked.userId, []);
const [authorOnlyB, authorMixed] = await ctx.db
.insert(schema.authors)
.values([{ name: `authz-author-only-b-${randomUUID()}` }, { name: `authz-author-mixed-${randomUUID()}` }])
.returning({ id: schema.authors.id });
authorOnlyBId = authorOnlyB.id;
authorMixedId = authorMixed.id;
await ctx.db.insert(schema.bookAuthors).values([
{ bookId: bookB.bookId, authorId: authorOnlyBId, displayOrder: 0 },
{ bookId: bookA.bookId, authorId: authorMixedId, displayOrder: 0 },
{ bookId: bookB.bookId, authorId: authorMixedId, displayOrder: 0 },
]);
await ctx.db.insert(schema.userBookStatus).values({
userId: personas.ownerUser.userId,
bookId: bookA.bookId,
status: 'reading',
source: 'manual',
startedAt: new Date('2026-01-01T00:00:00.000Z'),
finishedAt: null,
});
const session = await createReadingSession(ctx, {
userId: personas.ownerUser.userId,
bookId: bookB.bookId,
bookFileId: bookB.bookFileId,
startedAt: new Date('2026-01-01T10:00:00.000Z'),
endedAt: new Date('2026-01-01T10:30:00.000Z'),
progressDelta: 12.5,
endProgress: 33,
});
inaccessibleSessionId = session.id;
koboActiveDeviceToken = (await createKoboDevice(ctx, personas.koboActive.userId, 'authz-kobo-active')).token;
koboDisabledDeviceToken = (await createKoboDevice(ctx, personas.koboDisabled.userId, 'authz-kobo-disabled')).token;
koboRevokedDeviceToken = (await createKoboDevice(ctx, personas.koboRevoked.userId, 'authz-kobo-revoked')).token;
const [koboSyncCollection] = await ctx.db
.insert(schema.collections)
.values({
userId: personas.koboActive.userId,
name: `authz-kobo-sync-${randomUUID()}`,
syncToKobo: true,
})
.returning({ id: schema.collections.id });
await ctx.db.insert(schema.collectionBooks).values([
{ collectionId: koboSyncCollection.id, bookId: bookA.bookId },
{ collectionId: koboSyncCollection.id, bookId: bookB.bookId },
]);
const validOpds = await createOpdsUser(ctx, {
userId: personas.opdsOwner.userId,
username: `authz-opds-valid-${randomUUID().slice(0, 8)}`,
password: 'OpdsValidPass123',
});
const disabledOpds = await createOpdsUser(ctx, {
userId: personas.opdsDisabled.userId,
username: `authz-opds-disabled-${randomUUID().slice(0, 8)}`,
password: 'OpdsDisabledPass123',
});
const revokedOpds = await createOpdsUser(ctx, {
userId: personas.opdsRevoked.userId,
username: `authz-opds-revoked-${randomUUID().slice(0, 8)}`,
password: 'OpdsRevokedPass123',
});
opdsValidCreds = { username: validOpds.row.username, password: validOpds.password };
opdsDisabledCreds = { username: disabledOpds.row.username, password: disabledOpds.password };
opdsRevokedCreds = { username: revokedOpds.row.username, password: revokedOpds.password };
}, 240_000);
afterAll(async () => {
await closeAuthorizationMatrixE2EContext(ctx);
});
describe('shared reading insights privacy contract', () => {
it('enforces permission, consent, session snapshots, revocation, and one audit event per profile view', async () => {
const subjectId = personas.basicUser.userId;
const rawGenreName = `private-label-${randomUUID()}`;
const duplicateBroadGenreNames = [`fantasy-${randomUUID()}`, `fantastik-${randomUUID()}`];
const insertedGenres = await ctx.db
.insert(schema.genres)
.values([rawGenreName, ...duplicateBroadGenreNames].map((name) => ({ name })))
.returning({ id: schema.genres.id, name: schema.genres.name });
await ctx.db.insert(schema.bookGenres).values(insertedGenres.map((genre) => ({ bookId: bookA.bookId, genreId: genre.id })));
await ctx.db.insert(schema.readingSessions).values({
userId: subjectId,
bookId: bookA.bookId,
bookFileId: bookA.bookFileId,
sessionId: `shared-insights-${randomUUID()}`,
source: 'web',
startedAt: new Date(Date.now() - 20 * 60 * 1000),
endedAt: new Date(Date.now() - 5 * 60 * 1000),
durationSeconds: 15 * 60,
});
const privateAsSuperuser = await ctx.app.inject({
method: 'POST',
url: `/api/v1/shared-reading-insights/${subjectId}/view-sessions`,
headers: authHeader(personas.targetSuperuser.accessToken),
});
expect(privateAsSuperuser.statusCode).toBe(403);
expect(privateAsSuperuser.json()).toEqual(expect.objectContaining({ errorCode: 'READING_INSIGHTS_PRIVATE' }));
const enableSummary = await ctx.app.inject({
method: 'PATCH',
url: '/api/v1/reading-insights-sharing/me',
headers: authHeader(personas.basicUser.accessToken),
payload: { sharingLevel: 'summary' },
});
expect(enableSummary.statusCode).toBe(200);
const consentWithoutPermission = await ctx.app.inject({
method: 'POST',
url: `/api/v1/shared-reading-insights/${subjectId}/view-sessions`,
headers: authHeader(personas.otherUser.accessToken),
});
expectError(consentWithoutPermission, 403, 'Missing permission: view_user_activity');
const summarySessionResponse = await ctx.app.inject({
method: 'POST',
url: `/api/v1/shared-reading-insights/${subjectId}/view-sessions`,
headers: authHeader(personas.allPermsUser.accessToken),
});
expect(summarySessionResponse.statusCode).toBe(201);
const summarySession = summarySessionResponse.json() as { viewSessionId: string; sharingLevel: string };
expect(summarySession.sharingLevel).toBe('summary');
const sessionHeaders = {
...authHeader(personas.allPermsUser.accessToken),
'x-reading-insights-view-session': summarySession.viewSessionId,
};
const summaryResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/shared-reading-insights/${subjectId}/summary?days=90`,
headers: sessionHeaders,
});
expect(summaryResponse.statusCode).toBe(200);
const summaryBody = summaryResponse.json() as Record<string, unknown>;
expect(summaryBody).not.toHaveProperty('topBooks');
expect(summaryBody).not.toHaveProperty('recentBooks');
expect(summaryBody).not.toHaveProperty('bookId');
expect(summaryBody).not.toHaveProperty('title');
expect(summaryBody.genreDistribution).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'other' })]));
expect(summaryBody.genreDistribution).toEqual(expect.arrayContaining([expect.objectContaining({ name: 'fantasy', readingSeconds: 15 * 60 })]));
expect(JSON.stringify(summaryBody)).not.toContain(rawGenreName);
const summaryOnlyDetail = await ctx.app.inject({
method: 'GET',
url: `/api/v1/shared-reading-insights/${subjectId}/detailed?days=90`,
headers: sessionHeaders,
});
expect(summaryOnlyDetail.statusCode).toBe(403);
expect(summaryOnlyDetail.json()).toEqual(expect.objectContaining({ errorCode: 'READING_INSIGHTS_SUMMARY_ONLY' }));
await ctx.app.inject({
method: 'GET',
url: `/api/v1/shared-reading-insights/${subjectId}/summary?days=30`,
headers: sessionHeaders,
});
const firstHistory = await ctx.app.inject({
method: 'GET',
url: '/api/v1/reading-insights-sharing/me/access-history?page=1&pageSize=20',
headers: authHeader(personas.basicUser.accessToken),
});
expect(firstHistory.statusCode).toBe(200);
expect(firstHistory.json()).toEqual(expect.objectContaining({ total: 1, items: [expect.objectContaining({ sharingLevel: 'summary' })] }));
const enableDetailed = await ctx.app.inject({
method: 'PATCH',
url: '/api/v1/reading-insights-sharing/me',
headers: authHeader(personas.basicUser.accessToken),
payload: { sharingLevel: 'detailed' },
});
expect(enableDetailed.statusCode).toBe(200);
const oldSessionAfterUpgrade = await ctx.app.inject({
method: 'GET',
url: `/api/v1/shared-reading-insights/${subjectId}/detailed?days=90`,
headers: sessionHeaders,
});
expect(oldSessionAfterUpgrade.statusCode).toBe(403);
expect(oldSessionAfterUpgrade.json()).toEqual(expect.objectContaining({ errorCode: 'READING_INSIGHTS_SUMMARY_ONLY' }));
const detailedSessionResponse = await ctx.app.inject({
method: 'POST',
url: `/api/v1/shared-reading-insights/${subjectId}/view-sessions`,
headers: authHeader(personas.allPermsUser.accessToken),
});
expect(detailedSessionResponse.statusCode).toBe(201);
const detailedSession = detailedSessionResponse.json() as { viewSessionId: string };
const detailedResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/shared-reading-insights/${subjectId}/detailed?days=90`,
headers: {
...authHeader(personas.allPermsUser.accessToken),
'x-reading-insights-view-session': detailedSession.viewSessionId,
},
});
expect(detailedResponse.statusCode).toBe(200);
const auditResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/audit-log?action=reading_insights.profile.view&userId=${personas.allPermsUser.userId}`,
headers: authHeader(personas.allPermsUser.accessToken),
});
expect(auditResponse.statusCode).toBe(200);
expect(auditResponse.json()).toEqual(expect.objectContaining({ total: 2 }));
const withdraw = await ctx.app.inject({
method: 'PATCH',
url: '/api/v1/reading-insights-sharing/me',
headers: authHeader(personas.basicUser.accessToken),
payload: { sharingLevel: 'private' },
});
expect(withdraw.statusCode).toBe(200);
const afterWithdrawal = await ctx.app.inject({
method: 'GET',
url: `/api/v1/shared-reading-insights/${subjectId}/summary?days=90`,
headers: {
...authHeader(personas.allPermsUser.accessToken),
'x-reading-insights-view-session': detailedSession.viewSessionId,
},
});
expect(afterWithdrawal.statusCode).toBe(403);
expect(afterWithdrawal.json()).toEqual(expect.objectContaining({ errorCode: 'READING_INSIGHTS_PRIVATE' }));
const otherHistory = await ctx.app.inject({
method: 'GET',
url: '/api/v1/reading-insights-sharing/me/access-history?page=1&pageSize=20',
headers: authHeader(personas.otherUser.accessToken),
});
expect(otherHistory.statusCode).toBe(200);
expect(otherHistory.json()).toEqual(expect.objectContaining({ total: 0, items: [] }));
});
});
describe('guard matrix - jwt/permission/library/default-password', () => {
it('blocks per-file mutations for a download-only viewer without changing the file record', async () => {
const renameResponse = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/files/${bookA.bookFileId}`,
headers: authHeader(personas.downloadOnlyUser.accessToken),
payload: { filename: 'unauthorized-rename.epub' },
});
expectError(renameResponse, 403, 'Missing permission: library_edit_metadata');
const deleteResponse = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/books/files/${bookA.bookFileId}`,
headers: authHeader(personas.downloadOnlyUser.accessToken),
});
expectError(deleteResponse, 403, 'Missing permission: library_delete_books');
await expect(access(bookA.absolutePath)).resolves.toBeUndefined();
const [fileRow] = await ctx.db
.select({ id: schema.bookFiles.id, absolutePath: schema.bookFiles.absolutePath })
.from(schema.bookFiles)
.where(eq(schema.bookFiles.id, bookA.bookFileId));
expect(fileRow).toEqual({ id: bookA.bookFileId, absolutePath: bookA.absolutePath });
});
it('preserves the file record when physical deletion fails', async () => {
const failurePath = await createEpubFixture(libraryA.folderPath, `locked-${randomUUID()}/delete-failure.epub`);
await triggerAndWaitForLibraryScan(ctx, libraryA.libraryId);
const failureTarget = await locateBookByAbsolutePath(ctx, failurePath);
const lockedDirectory = dirname(failurePath);
await chmod(lockedDirectory, 0o555);
try {
const response = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/books/files/${failureTarget.bookFileId}`,
headers: authHeader(personas.allPermsUser.accessToken),
});
expectError(response, 500, 'Failed to delete file from disk');
await expect(access(failurePath)).resolves.toBeUndefined();
const [fileRow] = await ctx.db
.select({ id: schema.bookFiles.id, absolutePath: schema.bookFiles.absolutePath })
.from(schema.bookFiles)
.where(eq(schema.bookFiles.id, failureTarget.bookFileId));
expect(fileRow).toEqual({ id: failureTarget.bookFileId, absolutePath: failurePath });
} finally {
await chmod(lockedDirectory, 0o755);
}
});
it('keeps the dashboard scroller inventory in parity with the live application', () => {
expect(routeInventory.routes).toHaveLength(routeInventory.totalRoutes);
const inventoryLabels = routeInventory.routes
.filter((route) => isSupportedMethod(route.httpMethod) && route.path.startsWith('/dashboard/scrollers'))
.map(routeLabel)
.sort();
const liveLabels = liveRouteLabels(ctx.app).filter((label) => label.includes(' /dashboard/scrollers'));
expect(liveLabels).toEqual(inventoryLabels);
});
/**
* The inventory is hand-maintained and every other test in this file reads *from* it, so a
* route it does not list is a route this suite silently never probes. Five guarded
* book-request endpoints sat unprobed until this assertion existed.
*
* The inventory does not describe the whole application yet, so the gap is frozen in
* `uncovered-routes.json` rather than pretended away: a route that is in neither the inventory
* nor that baseline fails here, which is what makes omitting a new endpoint impossible. The
* baseline is held to shrinking only, and to naming routes that still exist.
*/
it('keeps every route the application serves either inventoried or in the frozen gap baseline', () => {
const inventoryLabels = new Set(routeInventory.routes.filter((route) => isSupportedMethod(route.httpMethod)).map(routeLabel));
const baseline = new Set(uncoveredRoutes);
const live = liveRouteLabels(ctx.app);
const liveLabels = new Set(live);
// Fastify combines parameter aliases at shared trie nodes, such as :id|:libraryId.
const routeShape = (label: string) => label.replace(/:[\w]+(?:\|:[\w]+)*/g, ':param');
const inventoriedShapes = new Set([...inventoryLabels].map(routeShape));
expect(live.filter((label) => !inventoriedShapes.has(routeShape(label)) && !baseline.has(label))).toEqual([]);
expect([...baseline].filter((label) => inventoryLabels.has(label))).toEqual([]);
expect([...baseline].filter((label) => !liveLabels.has(label))).toEqual([]);
});
it('serves the authenticated random scroller through the batch route', async () => {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/dashboard/scrollers/batch',
headers: authHeader(personas.allPermsUser.accessToken),
payload: { items: [{ id: 'random', type: 'random', limit: 3 }] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
items: [{ id: 'random', books: expect.any(Array), failed: false }],
});
});
it('rejects unauthenticated access to every non-public route', async () => {
const failures: MatrixFailure[] = [];
const protectedRoutes = routeInventory.routes.filter((route) => !route.isPublic && isSupportedMethod(route.httpMethod));
for (const route of protectedRoutes) {
const response = await invokeRoute(route);
if (response.statusCode !== 401) {
failures.push({
route: routeLabel(route),
status: response.statusCode,
reason: 'expected 401 for unauthenticated request',
message: extractMessage(response),
});
}
}
assertNoFailures('protected authentication matrix', failures);
}, 180_000);
it('rejects every permission-decorated route when permission is missing', async () => {
const failures: MatrixFailure[] = [];
const permissionRoutes = routeInventory.routes.filter(
(route) => !route.isPublic && route.permissions.length > 0 && isSupportedMethod(route.httpMethod),
);
for (const route of permissionRoutes) {
const response = await invokeRoute(route, { token: personas.basicUser.accessToken });
const message = extractMessage(response);
if (response.statusCode !== 403 || !message.includes('Missing permission:')) {
failures.push({
route: routeLabel(route),
status: response.statusCode,
reason: 'expected Missing permission denial',
message,
});
}
}
assertNoFailures('permission denial matrix', failures);
}, 180_000);
it('allows a settings operator to read book-request source status without request access', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/book-requests/source-status',
headers: authHeader(personas.appSettingsAdmin.accessToken),
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ configured: expect.any(Number), enabled: expect.any(Number) });
});
it('proves allow-path for each permission without missing-permission errors', async () => {
const permissionRoutes = routeInventory.routes.filter(
(route) => !route.isPublic && route.permissions.length > 0 && isSupportedMethod(route.httpMethod),
);
const inventoryPermissions = Array.from(new Set(permissionRoutes.flatMap((route) => route.permissions)))
.map((raw) => toPermission(raw))
.filter((permission): permission is Permission => permission !== null)
.sort();
// Permissions that grant a capability *within* a route rather than gating the route itself,
// so they legitimately never appear on a guard. Every entry needs a service-level test
// proving the capability is actually enforced; keep this list as short as it can be.
const serviceEnforcedPermissions: Permission[] = [Permission.BookRequestAutoApprove];
const enumPermissions = [...Object.values(Permission)].filter((permission) => !serviceEnforcedPermissions.includes(permission)).sort();
expect(inventoryPermissions).toEqual(enumPermissions);
const probes: Record<
Permission,
{
method: SupportedHttpMethod;
path: string;
query?: string;
payload?: Record<string, unknown>;
params?: Record<string, string>;
token: 'allPerms' | 'uploadUser' | 'readAloudUser';
}
> = {
[Permission.LibraryDownload]: {
method: 'POST',
path: '/books/export',
payload: { bookIds: [999_999] },
token: 'allPerms',
},
[Permission.LibraryUpload]: {
method: 'POST',
path: '/libraries/:id/upload',
token: 'uploadUser',
},
[Permission.LibraryGenerateReadAloud]: {
method: 'GET',
path: '/books/:bookId/read-aloud',
params: { bookId: String(bookA.bookId) },
token: 'readAloudUser',
},
[Permission.LibraryEditMetadata]: {
method: 'GET',
path: '/books/:id/write-log',
token: 'allPerms',
},
[Permission.LibraryDeleteBooks]: {
method: 'DELETE',
path: '/books',
payload: { bookIds: [999_999] },
token: 'allPerms',
},
[Permission.KoboSync]: {
method: 'GET',
path: '/kobo/devices',
token: 'allPerms',
},
[Permission.OpdsAccess]: {
method: 'GET',
path: '/opds-users',
token: 'allPerms',
},
[Permission.BookDockAccess]: {
method: 'GET',
path: '/book-dock/summary',
token: 'allPerms',
},
[Permission.EmailSend]: {
method: 'GET',
path: '/email/providers',
token: 'allPerms',
},
[Permission.ManageEmail]: {
method: 'GET',
path: '/email/admin/log',
token: 'allPerms',
},
[Permission.ManageLibraries]: {
method: 'GET',
path: '/path',
query: 'path=/proc',
token: 'allPerms',
},
[Permission.ManageMetadataConfig]: {
method: 'GET',
path: '/metadata-preferences/global',
token: 'allPerms',
},
[Permission.ManageIcons]: {
method: 'PATCH',
path: '/custom-icons/:slug',
payload: {},
token: 'allPerms',
},
[Permission.ManageAppSettings]: {
method: 'GET',
path: '/app-settings',
token: 'allPerms',
},
[Permission.ManageBookDock]: {
method: 'POST',
path: '/book-dock/rescan',
token: 'allPerms',
},
[Permission.ManageUsers]: {
method: 'GET',
path: '/users',
token: 'allPerms',
},
[Permission.ViewAuditLog]: {
method: 'GET',
path: '/audit-log',
token: 'allPerms',
},
[Permission.ViewUserActivity]: {
method: 'GET',
path: '/account-activity/summary',
token: 'allPerms',
},
[Permission.NotificationAccess]: {
method: 'GET',
path: '/notifications',
token: 'allPerms',
},
[Permission.KoreaderSync]: {
method: 'GET',
path: '/koreader/credentials',
token: 'allPerms',
},
[Permission.HardcoverSync]: {
method: 'GET',
path: '/hardcover/settings',
token: 'allPerms',
},
[Permission.ReadwiseSync]: {
method: 'GET',
path: '/readwise/settings',
token: 'allPerms',
},
[Permission.StorygraphSync]: {
method: 'GET',
path: '/storygraph/settings',
token: 'allPerms',
},
[Permission.DemoRestricted]: {
method: 'GET',
path: '/notifications',
token: 'allPerms',
},
[Permission.BookRequestAccess]: {
method: 'GET',
path: '/book-requests/summary',
token: 'allPerms',
},
[Permission.ManageBookRequests]: {
method: 'GET',
path: '/admin/book-requests',
token: 'allPerms',
},
[Permission.BookRequestSelfFulfill]: {
method: 'GET',
path: '/book-request-fulfilment/download-clients',
token: 'allPerms',
},
};
const failures: MatrixFailure[] = [];
for (const permission of Object.values(Permission)) {
if (serviceEnforcedPermissions.includes(permission)) continue;
const probe = probes[permission];
const inventoryRoute = routeInventory.routes.find((route) => route.httpMethod === probe.method && route.path === probe.path);
expect(inventoryRoute).toBeTruthy();
expect(inventoryRoute?.permissions).toContain(`Permission.${permissionEnumKey(permission)}`);
const response =
permission === Permission.LibraryUpload
? await uploadLibraryFile(ctx, {
token: personas.uploadUser.accessToken,
libraryId: libraryA.libraryId,
folderId: libraryA.libraryFolderId,
fileName: `permission-probe-${randomUUID()}.epub`,
content: 'permission probe content',
})
: await ctx.app.inject({
method: probe.method,
url: buildUrl(probe.path, probe.query, probe.params),
headers: authHeader(probe.token === 'readAloudUser' ? personas.readAloudUser.accessToken : personas.allPermsUser.accessToken),
payload: probe.method === 'GET' ? undefined : (probe.payload ?? {}),
});
const message = extractMessage(response);
if (response.statusCode === 403 && message.includes('Missing permission:')) {
failures.push({
route: `${String(permission)} -> ${probe.method} ${probe.path}`,
status: response.statusCode,
reason: 'permission allow-path still denied by PermissionGuard',
message,
});
}
}
assertNoFailures('permission allow probes', failures);
}, 120_000);
it('covers all library-access guard failure and bypass cases', async () => {
const guardedRoutes = routeInventory.routes.filter(
(route) => !route.isPublic && route.libraryAccess.length > 0 && isSupportedMethod(route.httpMethod),
);
expect(guardedRoutes.map(routeLabel).sort()).toEqual(
[
'GET /libraries/:id',
'POST /libraries/:id/books',
'GET /libraries/:id/stats',
'GET /libraries/:id/recompute-added-at',
'POST /libraries/:id/recompute-added-at',
'POST /libraries/:id/write-metadata-to-files',
'GET /scanner/libraries/:id/scan-history',
'GET /scanner/libraries/:id/reapply-regex-metadata',
'POST /scanner/libraries/:id/reapply-regex-metadata',
'POST /libraries/:id/books/jump-buckets',
'POST /libraries/:id/books/facets',
'GET /libraries/:id/bulk-rename/preview',
'GET /libraries/:id/bulk-rename/status',
'POST /libraries/:id/bulk-rename/execute',
].sort(),
);
for (const route of guardedRoutes) {
const paramName = route.path.includes(':libraryId') ? 'libraryId' : 'id';
const invalidResponse = await invokeRoute(route, {
token: personas.allPermsUser.accessToken,
params: { [paramName]: 'not-a-number' },
query: route.path === '/libraries/:id/write-metadata-to-files' ? 'dryRun=true' : undefined,
});
expectError(invalidResponse, 400, 'Missing or invalid libraryId');
}
for (const route of guardedRoutes) {
const noAccessResponse = await invokeRoute(route, {
token: personas.permsNoLibraryUser.accessToken,
query: route.path === '/libraries/:id/write-metadata-to-files' ? 'dryRun=true' : undefined,
});
expectError(noAccessResponse, 403, 'No library access');
}
const insufficientResponse = await ctx.app.inject({
method: 'POST',
url: `/api/v1/libraries/${libraryA.libraryId}/write-metadata-to-files?dryRun=true`,
headers: authHeader(personas.metadataEditor.accessToken),
});
expectError(insufficientResponse, 403, 'Insufficient library access level');
const bypassResponse = await ctx.app.inject({
method: 'POST',
url: `/api/v1/libraries/${libraryA.libraryId}/write-metadata-to-files?dryRun=true`,
headers: authHeader(ctx.adminToken),
});
expect(bypassResponse.statusCode).toBe(200);
}, 120_000);
it('enforces default-password lock with allow-list exceptions', async () => {
const blockedResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/libraries',
headers: authHeader(personas.defaultPwdUser.accessToken),
});
expectError(blockedResponse, 403, 'Password change required');
const meResponse = await ctx.app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: authHeader(personas.defaultPwdUser.accessToken),
});
expect(meResponse.statusCode).toBe(200);
expect(meResponse.json()).toEqual(expect.objectContaining({ isDefaultPassword: true }));
const changePasswordResponse = await ctx.app.inject({
method: 'POST',
url: '/api/v1/auth/change-password',
headers: authHeader(personas.defaultPwdUser.accessToken),
payload: {
currentPassword: personas.defaultPwdUser.password,
newPassword: 'ChangedDefaultPassword123',
},
});
expect(changePasswordResponse.statusCode).toBe(204);
});
});
describe('custom public guards - opds', () => {
beforeEach(async () => {
await setSetting(ctx, 'opds_enabled', 'true');
});
it('rejects all OPDS routes when OPDS is disabled', async () => {
await setSetting(ctx, 'opds_enabled', 'false');
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/opds/libraries',
headers: {
authorization: basicAuth(opdsValidCreds.username, opdsValidCreds.password),
},
});
expectError(response, 403, 'OPDS server is disabled');
});
it('requires basic credentials when no token query is provided', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/opds/libraries',
});
expectError(response, 401, 'Basic authentication required');
expect(response.headers['www-authenticate']).toContain('Basic realm=');
});
it('rejects invalid OPDS basic credentials', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/opds/libraries',
headers: {
authorization: basicAuth(opdsValidCreds.username, 'WrongPassword123'),
},
});
expectError(response, 401, 'Invalid credentials');
expect(response.headers['www-authenticate']).toContain('Basic realm=');
});
it('rejects disabled OPDS parent users', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/opds/libraries',
headers: {
authorization: basicAuth(opdsDisabledCreds.username, opdsDisabledCreds.password),
},
});
expectError(response, 401, 'disabled');
});
it('rejects OPDS users when parent permission is revoked', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/opds/libraries',
headers: {
authorization: basicAuth(opdsRevokedCreds.username, opdsRevokedCreds.password),
},
});
expectError(response, 403, 'OPDS access revoked');
});
it('allows OPDS access with valid basic credentials', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/opds/libraries',
headers: {
authorization: basicAuth(opdsValidCreds.username, opdsValidCreds.password),
},
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('application/atom+xml');
});
it('rejects invalid OPDS token auth', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/opds/${bookA.bookId}/cover?t=invalid-token`,
});
expectError(response, 401, 'Invalid token');
});
it('rejects OPDS token auth on non-image routes', async () => {
const config = ctx.app.get(ConfigService);
const jwtSecret = config.getOrThrow<string>('auth.jwtSecret');
const token = createCoverToken(personas.opdsOwner.userId, jwtSecret);
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/opds/libraries?t=${token}`,
});
expectError(response, 401, 'Basic authentication required');
expect(response.headers['www-authenticate']).toContain('Basic realm=');
});
it('allows OPDS token auth on image routes when token and permission are valid', async () => {
const config = ctx.app.get(ConfigService);
const jwtSecret = config.getOrThrow<string>('auth.jwtSecret');
const token = createCoverToken(personas.opdsOwner.userId, jwtSecret);
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/opds/${bookA.bookId}/cover?t=${token}`,
});
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('image/jpeg');
});
});
describe('custom public guards - kobo', () => {
it('rejects invalid Kobo device tokens', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/kobo/invalid-token/v1/initialization',
});
expectError(response, 401, 'Invalid device token');
});
it('rejects Kobo routes for disabled accounts', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/kobo/${koboDisabledDeviceToken}/v1/initialization`,
});
expectError(response, 401, 'Account not found or disabled');
});
it('rejects Kobo routes when KoboSync permission is revoked', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/kobo/${koboRevokedDeviceToken}/v1/initialization`,
});
expectError(response, 401, 'Kobo sync permission revoked');
});
it('allows Kobo routes for valid token and permission', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/kobo/${koboActiveDeviceToken}/v1/initialization`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual(expect.objectContaining({ Resources: expect.any(Object) }));
});
it('keeps library sync scoped to currently accessible libraries', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/kobo/${koboActiveDeviceToken}/v1/library/sync`,
});
expect(response.statusCode).toBe(200);
const entitlements = response.json() as unknown[];
const syncedEntitlementIds = extractKoboEntitlementIds(entitlements);
const identities = await ctx.db
.select({ bookId: schema.koboBookEntitlements.bookId, entitlementId: schema.koboBookEntitlements.entitlementId })
.from(schema.koboBookEntitlements)
.where(
and(
eq(schema.koboBookEntitlements.userId, personas.koboActive.userId),
inArray(schema.koboBookEntitlements.bookId, [bookA.bookId, bookB.bookId]),
),
);
const entitlementByBookId = new Map(identities.map((identity) => [identity.bookId, identity.entitlementId]));
expect(syncedEntitlementIds).toContain(entitlementByBookId.get(bookA.bookId));
const inaccessibleEntitlementId = entitlementByBookId.get(bookB.bookId);
if (inaccessibleEntitlementId) expect(syncedEntitlementIds).not.toContain(inaccessibleEntitlementId);
});
it('does not expose metadata for books outside active library access', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/kobo/${koboActiveDeviceToken}/v1/library/${bookB.bookId}/metadata`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([]);
});
it('keeps accessible Kobo media and reading-state flows working', async () => {
const [identity] = await ctx.db
.select({ entitlementId: schema.koboBookEntitlements.entitlementId })
.from(schema.koboBookEntitlements)
.where(and(eq(schema.koboBookEntitlements.userId, personas.koboActive.userId), eq(schema.koboBookEntitlements.bookId, bookA.bookId)))
.limit(1);
expect(identity).toBeDefined();
const thumbnail = await ctx.app.inject({
method: 'GET',
url: `/api/v1/kobo/${koboActiveDeviceToken}/v1/books/${bookA.bookId}/thumbnail/300/300/false/image.jpg`,
});
expect(thumbnail.statusCode).toBe(200);
const writeState = await ctx.app.inject({
method: 'PUT',
url: `/api/v1/kobo/${koboActiveDeviceToken}/v1/library/${bookA.bookId}/state`,
payload: {
ReadingStates: [
{
EntitlementId: identity!.entitlementId,
Created: '2026-01-02T00:00:00.000Z',
LastModified: '2026-01-02T00:00:00.000Z',
PriorityTimestamp: '2026-01-02T00:00:00.000Z',
CurrentBookmark: {
LastModified: '2026-01-02T00:00:00.000Z',
ProgressPercent: 37,
ContentSourceProgressPercent: 37,
},
Statistics: {
LastModified: '2026-01-02T00:00:00.000Z',
},
StatusInfo: {
LastModified: '2026-01-02T00:00:00.000Z',
Status: 'Reading',
TimesStartedReading: 1,
},
},
],
},
});
expect(writeState.statusCode).toBe(200);
const readState = await ctx.app.inject({
method: 'GET',
url: `/api/v1/kobo/${koboActiveDeviceToken}/v1/library/${bookA.bookId}/state`,
});
expect(readState.statusCode).toBe(200);
expect(readState.json()).toEqual(
expect.arrayContaining([
expect.objectContaining({
EntitlementId: identity!.entitlementId,
}),
]),
);
});
});
describe('service authz - ownership and superuser rules', () => {
it('enforces non-superuser restrictions on user admin operations', async () => {
const editSuperuser = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/users/${personas.targetSuperuser.userId}`,
headers: authHeader(personas.manageUsersAdmin.accessToken),
payload: { name: 'Attempted Rename' },
});
expectError(editSuperuser, 403, 'Only administrators can edit administrator accounts');
const deleteSelf = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/users/${personas.manageUsersAdmin.userId}`,
headers: authHeader(personas.manageUsersAdmin.accessToken),
});
expectError(deleteSelf, 409, 'You cannot delete your own account');
const updateOwnPermissions = await ctx.app.inject({
method: 'PUT',
url: `/api/v1/users/${personas.manageUsersAdmin.userId}/permissions`,
headers: authHeader(personas.manageUsersAdmin.accessToken),
payload: { permissionNames: [Permission.ManageLibraries] },
});
expectError(updateOwnPermissions, 409, 'You cannot modify your own permissions');
});
it('enforces author mutation visibility and superuser rules', async () => {
const hiddenAuthor = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/authors/${authorOnlyBId}`,
headers: authHeader(personas.metadataEditor.accessToken),
payload: { name: `rename-hidden-${randomUUID().slice(0, 6)}` },
});
expectError(hiddenAuthor, 404, 'Author not found');
const relatedLibraryAuthor = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/authors/${authorMixedId}`,
headers: authHeader(personas.metadataEditor.accessToken),
payload: { name: `rename-mixed-${randomUUID().slice(0, 6)}` },
});
expectError(relatedLibraryAuthor, 403, 'Insufficient library access');
const mergeAsNonSuper = await ctx.app.inject({
method: 'POST',
url: '/api/v1/authors/merge',
headers: authHeader(personas.metadataEditor.accessToken),
payload: {
targetAuthorId: authorMixedId,
sourceAuthorIds: [authorOnlyBId],
},
});
expectError(mergeAsNonSuper, 403, 'Only superusers can merge authors');
const deleteAsNonSuper = await ctx.app.inject({
method: 'DELETE',
url: '/api/v1/authors',
headers: authHeader(personas.metadataEditor.accessToken),
payload: {
authorIds: [authorOnlyBId],
},
});
expectError(deleteAsNonSuper, 403, 'Only superusers can delete authors');
});
it('enforces collection ownership and library-scoped book access', async () => {
const createdCollection = await ctx.app.inject({
method: 'POST',
url: '/api/v1/collections',
headers: authHeader(personas.ownerUser.accessToken),
payload: {
name: `authz-collection-${randomUUID()}`,
icon: 'FolderOpen',
},
});
expect(createdCollection.statusCode).toBe(201);
const collectionId = (createdCollection.json() as { id: number }).id;
const foreignRead = await ctx.app.inject({
method: 'GET',
url: `/api/v1/collections/${collectionId}`,
headers: authHeader(personas.otherUser.accessToken),
});
expectError(foreignRead, 403, 'No access to this collection');
const addInaccessibleBook = await ctx.app.inject({
method: 'POST',
url: `/api/v1/collections/${collectionId}/books`,
headers: authHeader(personas.ownerUser.accessToken),
payload: {
bookIds: [bookB.bookId],
},
});
expectError(addInaccessibleBook, 403, 'No access to this library');
const publicCollectionResponse = await ctx.app.inject({
method: 'POST',
url: '/api/v1/collections',
headers: authHeader(personas.ownerUser.accessToken),
payload: {
name: `authz-public-collection-${randomUUID()}`,
icon: 'Globe',
isPublic: true,
},
});
expect(publicCollectionResponse.statusCode).toBe(201);
const publicCollectionId = (publicCollectionResponse.json() as { id: number }).id;
const addVisibleOwnerBook = await ctx.app.inject({
method: 'POST',
url: `/api/v1/collections/${publicCollectionId}/books`,
headers: authHeader(personas.ownerUser.accessToken),
payload: { bookIds: [bookA.bookId] },
});
expect(addVisibleOwnerBook.statusCode).toBe(201);
const publicRead = await ctx.app.inject({
method: 'GET',
url: `/api/v1/collections/${publicCollectionId}`,
headers: authHeader(personas.otherUser.accessToken),
});
expect(publicRead.statusCode).toBe(200);
expect(publicRead.json()).toEqual(expect.objectContaining({ id: publicCollectionId, isPublic: true, isOwner: false, bookCount: 0 }));
const publicBooks = await ctx.app.inject({
method: 'GET',
url: `/api/v1/collections/${publicCollectionId}/books?page=0&size=50`,
headers: authHeader(personas.otherUser.accessToken),
});
expect(publicBooks.statusCode).toBe(200);
expect(publicBooks.json()).toEqual(expect.objectContaining({ items: [], total: 0 }));
const publicList = await ctx.app.inject({
method: 'GET',
url: '/api/v1/collections',
headers: authHeader(personas.otherUser.accessToken),
});
expect(publicList.statusCode).toBe(200);
expect(publicList.json()).toEqual(expect.arrayContaining([expect.objectContaining({ id: publicCollectionId, bookCount: 0, isOwner: false })]));
expect(publicList.json()).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: collectionId })]));
const writableTargets = await ctx.app.inject({
method: 'GET',
url: `/api/v1/collections?bookIds=${bookA.bookId}`,
headers: authHeader(personas.otherUser.accessToken),
});
expect(writableTargets.statusCode).toBe(200);
expect(writableTargets.json()).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: publicCollectionId })]));
for (const mutation of [
{ method: 'PATCH' as const, url: `/api/v1/collections/${publicCollectionId}`, payload: { name: 'Hijacked' } },
{ method: 'POST' as const, url: `/api/v1/collections/${publicCollectionId}/books`, payload: { bookIds: [bookA.bookId] } },
{ method: 'DELETE' as const, url: `/api/v1/collections/${publicCollectionId}`, payload: undefined },
]) {
const response = await ctx.app.inject({
method: mutation.method,
url: mutation.url,
headers: authHeader(personas.otherUser.accessToken),
...(mutation.payload ? { payload: mutation.payload } : {}),
});
expectError(response, 403, 'Cannot modify this collection');
}
const otherUserCollectionResponse = await ctx.app.inject({
method: 'POST',
url: '/api/v1/collections',
headers: authHeader(personas.otherUser.accessToken),
payload: {
name: `authz-owned-collection-${randomUUID()}`,
icon: 'Folder',
},
});
expect(otherUserCollectionResponse.statusCode).toBe(201);
const otherUserCollection = otherUserCollectionResponse.json() as { id: number; displayOrder: number };
const foreignReorder = await ctx.app.inject({
method: 'POST',
url: '/api/v1/collections/reorder',
headers: authHeader(personas.otherUser.accessToken),
payload: {
order: [
{ id: otherUserCollection.id, displayOrder: 999 },
{ id: publicCollectionId, displayOrder: 0 },
],
},
});
expectError(foreignReorder, 403, 'Cannot reorder one or more collections');
const listAfterRejectedReorder = await ctx.app.inject({
method: 'GET',
url: '/api/v1/collections',
headers: authHeader(personas.otherUser.accessToken),
});
expect(listAfterRejectedReorder.statusCode).toBe(200);
expect(listAfterRejectedReorder.json()).toEqual(
expect.arrayContaining([expect.objectContaining({ id: otherUserCollection.id, displayOrder: otherUserCollection.displayOrder })]),
);
});
it('enforces smartScope private read and owner-only write rules', async () => {
const privateSmartScopeResponse = await ctx.app.inject({
method: 'POST',
url: '/api/v1/smart-scopes',
headers: authHeader(personas.ownerUser.accessToken),
payload: {
name: `authz-private-smartScope-${randomUUID()}`,
icon: 'Aperture',
defaultSort: [],
isPublic: false,
},
});
expect(privateSmartScopeResponse.statusCode).toBe(201);
const privateSmartScopeId = (privateSmartScopeResponse.json() as { id: number }).id;
const foreignRead = await ctx.app.inject({
method: 'GET',
url: `/api/v1/smart-scopes/${privateSmartScopeId}`,
headers: authHeader(personas.otherUser.accessToken),
});
expectError(foreignRead, 403, 'No access to this smartScope');
const foreignUpdate = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/smart-scopes/${privateSmartScopeId}`,
headers: authHeader(personas.otherUser.accessToken),
payload: { name: 'rename' },
});
expectError(foreignUpdate, 403, 'Cannot modify this smartScope');
const foreignDelete = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/smart-scopes/${privateSmartScopeId}`,
headers: authHeader(personas.otherUser.accessToken),
});
expectError(foreignDelete, 403, 'Cannot delete this smartScope');
const publicSmartScopeResponse = await ctx.app.inject({
method: 'POST',
url: '/api/v1/smart-scopes',
headers: authHeader(personas.ownerUser.accessToken),
payload: {
name: `authz-public-smartScope-${randomUUID()}`,
icon: 'Aperture',
defaultSort: [],
isPublic: true,
},
});
expect(publicSmartScopeResponse.statusCode).toBe(201);
const publicSmartScopeId = (publicSmartScopeResponse.json() as { id: number }).id;
const publicRead = await ctx.app.inject({
method: 'GET',
url: `/api/v1/smart-scopes/${publicSmartScopeId}`,
headers: authHeader(personas.otherUser.accessToken),
});
expect(publicRead.statusCode).toBe(200);
});
it('enforces OPDS user ownership checks', async () => {
const created = await ctx.app.inject({
method: 'POST',
url: '/api/v1/opds-users',
headers: authHeader(personas.opdsOwner.accessToken),
payload: {
username: `authz-opds-owned-${randomUUID().slice(0, 8)}`,
password: 'OwnerScopedPass123',
sortOrder: 'recent',
},
});
expect(created.statusCode).toBe(201);
const opdsUserId = (created.json() as { id: number }).id;
const foreignUpdate = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/opds-users/${opdsUserId}`,
headers: authHeader(personas.opdsIntruder.accessToken),
payload: { sortOrder: 'title_asc' },
});
expectError(foreignUpdate, 403, 'Not the owner of this OPDS user');
const foreignDelete = await ctx.app.inject({
method: 'DELETE',
url: `/api/v1/opds-users/${opdsUserId}`,
headers: authHeader(personas.opdsIntruder.accessToken),
});
expectError(foreignDelete, 403, 'Not the owner of this OPDS user');
});
});
describe('service authz - library scoped data and mixed batch semantics', () => {
it("prevents Book Dock users from reading or mutating another user's entries", async () => {
const foreignRow = await createBookDockRow(ctx, {
fileName: `authz-foreign-book-dock-${randomUUID()}.fb2`,
uploadedBy: personas.otherUser.userId,
});
for (const request of [
{ method: 'GET' as const, payload: undefined },
{ method: 'PATCH' as const, payload: { selectedMetadata: { title: 'Unauthorized edit' } } },
{ method: 'DELETE' as const, payload: undefined },
]) {
const response = await ctx.app.inject({
method: request.method,
url: `/api/v1/book-dock/files/${foreignRow.id}`,
headers: authHeader(personas.bookDockUser.accessToken),
payload: request.payload,
});
expectError(response, 403, 'You do not have access to this Book Dock file');
}
});
it('returns mixed-result finalize envelope for Book Dock authorization failures', async () => {
const accessibleRow = await createBookDockRow(ctx, {
fileName: `authz-finalize-ok-${randomUUID()}.fb2`,
targetLibraryId: libraryA.libraryId,
targetFolderId: libraryA.libraryFolderId,
status: 'ready',
uploadedBy: personas.bookDockUser.userId,
});
const inaccessibleRow = await createBookDockRow(ctx, {
fileName: `authz-finalize-denied-${randomUUID()}.fb2`,
targetLibraryId: libraryB.libraryId,
targetFolderId: libraryB.libraryFolderId,
status: 'ready',
uploadedBy: personas.bookDockUser.userId,
});
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/book-dock/finalize',
headers: authHeader(personas.bookDockUser.accessToken),
payload: {
fileIds: [accessibleRow.id, inaccessibleRow.id],
},
});
expect(response.statusCode).toBe(201);
const body = response.json() as {
total: number;
succeeded: number;
failed: number;
results: Array<{ fileId: number; success: boolean; message?: string }>;
};
expect(body.total).toBe(2);
expect(body.succeeded).toBe(1);
expect(body.failed).toBe(1);
const deniedItem = body.results.find((item) => item.fileId === inaccessibleRow.id);
expect(deniedItem).toBeTruthy();
expect(deniedItem?.success).toBe(false);
expect(deniedItem?.message ?? '').toContain('No access to this library');
});
it('requires both upload permission and library access for uploads', async () => {
const response = await uploadLibraryFile(ctx, {
token: personas.uploadUser.accessToken,
libraryId: libraryA.libraryId,
folderId: libraryA.libraryFolderId,
fileName: `authz-upload-${randomUUID()}.epub`,
content: 'upload authz test content',
});
expectError(response, 403, 'No access to this library');
});
it('intersects unauthorized library filters for statistics and user-statistics', async () => {
const statisticsSummary = await ctx.app.inject({
method: 'GET',
url: `/api/v1/statistics/summary?libraryIds=${libraryB.libraryId}`,
headers: authHeader(personas.ownerUser.accessToken),
});
expect(statisticsSummary.statusCode).toBe(200);
const statisticsBody = statisticsSummary.json() as {
totalBooks: number;
totalAuthors: number;
totalSeries: number;
totalPublishers: number;
totalStorageBytes: string | number;
};
expect(statisticsBody).toEqual(
expect.objectContaining({
totalBooks: 0,
totalAuthors: 0,
totalSeries: 0,
totalPublishers: 0,
}),
);
expect(Number(statisticsBody.totalStorageBytes)).toBe(0);
const userStatisticsSummary = await ctx.app.inject({
method: 'GET',
url: `/api/v1/user-statistics/summary?libraryIds=${libraryB.libraryId}`,
headers: authHeader(personas.ownerUser.accessToken),
});
expect(userStatisticsSummary.statusCode).toBe(200);
expect(userStatisticsSummary.json()).toEqual(
expect.objectContaining({
trackedBooks: 0,
startedBooks: 0,
inProgressBooks: 0,
completedBooks: 0,
}),
);
});
it('returns not found when editing inaccessible session timeline items', async () => {
const response = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/user-statistics/session-timeline/${inaccessibleSessionId}?libraryIds=${libraryB.libraryId}`,
headers: authHeader(personas.ownerUser.accessToken),
payload: {
startedAt: '2026-01-01T10:00:00.000Z',
endedAt: '2026-01-01T10:30:00.000Z',
},
});
expectError(response, 404, 'Reading session not found');
});
it('keeps blocked path listings empty while still permission-gated', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/path?path=/proc',
headers: authHeader(personas.allPermsUser.accessToken),
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([]);
});
});
describe('policy-first regressions', () => {
it('Kobo thumbnails must reject books outside the device user library access', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/kobo/${koboActiveDeviceToken}/v1/books/${bookB.bookId}/thumbnail/300/300/false/image.jpg`,
});
expect([403, 404]).toContain(response.statusCode);
});
it('Kobo reading-state updates must reject inaccessible books', async () => {
const response = await ctx.app.inject({
method: 'PUT',
url: `/api/v1/kobo/${koboActiveDeviceToken}/v1/library/${bookB.bookId}/state`,
payload: {
ReadingStates: [
{
EntitlementId: String(bookB.bookId),
Created: '2026-01-02T00:00:00.000Z',
LastModified: '2026-01-02T00:00:00.000Z',
PriorityTimestamp: '2026-01-02T00:00:00.000Z',
CurrentBookmark: {
LastModified: '2026-01-02T00:00:00.000Z',
ProgressPercent: 42,
ContentSourceProgressPercent: 42,
},
Statistics: {
LastModified: '2026-01-02T00:00:00.000Z',
},
StatusInfo: {
LastModified: '2026-01-02T00:00:00.000Z',
Status: 'Reading',
TimesStartedReading: 1,
},
},
],
},
});
expect([403, 404]).toContain(response.statusCode);
});
});
function routeLabel(route: Pick<RouteInventoryRoute, 'httpMethod' | 'path'>): string {
return `${route.httpMethod} ${route.path}`;
}
function defaultParams(): Record<string, string> {
return {
annotationId: '1',
bookmarkId: '1',
bookFileId: String(bookA.bookFileId),
bookId: String(bookA.bookId),
deviceToken: koboActiveDeviceToken || 'invalid-device-token',
fileId: String(bookA.bookFileId),
formatGroup: 'epub',
height: '300',
id: String(libraryA.libraryId),
isGreyscale: 'false',
key: 'allow_registration',
libraryId: String(libraryA.libraryId),
pageIndex: '1',
productId: '1',
quality: '85',
recipientId: '1',
seriesId: '1',
sessionId: String(inaccessibleSessionId || 1),
type: 'books',
userId: String(personas.ownerUser.userId),
version: '1',
width: '300',
};
}
function buildUrl(pathTemplate: string, query?: string, params?: Record<string, string>): string {
const mergedParams = { ...defaultParams(), ...(params ?? {}) };
const resolvedPath = pathTemplate
.replace(/\*/g, 'wildcard')
.replace(/:([a-zA-Z0-9_]+)/g, (_match, key: string) => encodeURIComponent(mergedParams[key] ?? '1'));
if (!query) return `/api/v1${resolvedPath}`;
return `/api/v1${resolvedPath}${query.startsWith('?') ? query : `?${query}`}`;
}
async function invokeRoute(
route: RouteInventoryRoute,
options: {
token?: string;
query?: string;
payload?: Record<string, unknown>;
params?: Record<string, string>;
} = {},
): Promise<InjectResponse> {
const method = route.httpMethod;
if (!isSupportedMethod(method)) {
throw new Error(`Unsupported route method in matrix: ${routeLabel(route)}`);
}
const request: {
method: SupportedHttpMethod;
url: string;
headers?: Record<string, string>;
payload?: Record<string, unknown>;
} = {
method,
url: buildUrl(route.path, options.query, options.params),
};
if (options.token) {
request.headers = authHeader(options.token);
}
if (method !== 'GET') {
request.payload = options.payload ?? {};
}
return ctx.app.inject(request);
}
function parseBody(response: InjectResponse): Record<string, unknown> | null {
try {
return response.json() as Record<string, unknown>;
} catch {
return null;
}
}
function extractMessage(response: InjectResponse): string {
const body = parseBody(response);
if (!body) return response.body;
const message = body.message;
if (typeof message === 'string') return message;
if (Array.isArray(message)) {
return message.filter((item): item is string => typeof item === 'string').join('; ');
}
return '';
}
function expectError(response: InjectResponse, statusCode: number, messageFragment: string): void {
expect(response.statusCode).toBe(statusCode);
const body = parseBody(response);
if (body && typeof body === 'object') {
expect(body).toEqual(
expect.objectContaining({
statusCode,
}),
);
}
const message = extractMessage(response);
expect(message).toContain(messageFragment);
}
function assertNoFailures(label: string, failures: MatrixFailure[]): void {
if (failures.length === 0) return;
const rendered = failures
.slice(0, 25)
.map((failure) => `- ${failure.route}: status=${failure.status}; ${failure.reason}; message="${failure.message}"`)
.join('\n');
const suffix = failures.length > 25 ? `\n...and ${failures.length - 25} more` : '';
throw new Error(`${label} failed (${failures.length} mismatches)\n${rendered}${suffix}`);
}
function toPermission(inventoryPermission: string): Permission | null {
const [scope, key] = inventoryPermission.split('.');
if (scope !== 'Permission' || !key) return null;
const asRecord = Permission as unknown as Record<string, Permission>;
return asRecord[key] ?? null;
}
function extractKoboEntitlementIds(entitlements: unknown[]): string[] {
const ids = new Set<string>();
for (const item of entitlements) {
if (!item || typeof item !== 'object') continue;
const record = item as Record<string, unknown>;
const payload =
(record.NewEntitlement as Record<string, unknown> | undefined) ??
(record.ChangedProductMetadata as Record<string, unknown> | undefined) ??
(record.ChangedEntitlement as Record<string, unknown> | undefined);
if (!payload || typeof payload !== 'object') continue;
const entitlement = payload.BookEntitlement as Record<string, unknown> | undefined;
const rawId = entitlement?.Id ?? entitlement?.RevisionId ?? entitlement?.CrossRevisionId;
if (typeof rawId === 'string') ids.add(rawId);
}
return [...ids];
}
function permissionEnumKey(permission: Permission): string {
const entry = Object.entries(Permission).find(([, value]) => value === permission);
if (!entry) throw new Error(`Unknown permission value: ${permission}`);
return entry[0];
}
function basicAuth(username: string, password: string): string {
const value = Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
return `Basic ${value}`;
}
});