Files
bookorbit/server/test/authorization-matrix.e2e-spec.ts
T
neon e0d3d42150 feat: add read-aloud and media playback support
Add EPUB narration, shared media controls, and native device session integration.

Apply the generated migrations and update all first-party clients alongside the server.

BREAKING CHANGE: introduce incompatible database and API contracts for media playback
2026-09-19 02:27:05 +00:00

1957 lines
78 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;
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 podcastLibrary: 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()}` });
podcastLibrary = await createLibraryWithFolder(ctx, { name: `authz-podcast-lib-${randomUUID()}`, type: 'podcasts' });
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] }),
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.allPermsUser.userId, podcastLibrary.libraryId, 'owner'),
grantLibraryAccess(ctx, personas.metadataEditor.userId, libraryA.libraryId, 'viewer'),
grantLibraryAccess(ctx, personas.bookDockUser.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';
}
> = {
[Permission.LibraryDownload]: {
method: 'POST',
path: '/books/export',
payload: { bookIds: [999_999] },
token: 'allPerms',
},
[Permission.LibraryUpload]: {
method: 'POST',
path: '/libraries/:id/upload',
token: 'uploadUser',
},
[Permission.LibraryEditMetadata]: {
method: 'GET',
path: '/books/:id/write-log',
token: 'allPerms',
},
[Permission.LibraryDeleteBooks]: {
method: 'DELETE',
path: '/books',
payload: { bookIds: [999_999] },
token: 'allPerms',
},
[Permission.PodcastManageFeeds]: {
method: 'GET',
path: '/podcast-libraries/:libraryId/jobs',
params: { libraryId: String(podcastLibrary.libraryId) },
token: 'allPerms',
},
[Permission.PodcastDownload]: {
method: 'POST',
path: '/podcast-episodes/:episodeId/download',
params: { episodeId: '999999' },
token: 'allPerms',
},
[Permission.PodcastEditMetadata]: {
method: 'PATCH',
path: '/podcasts/:podcastId/metadata',
params: { podcastId: '999999' },
payload: { title: 'Authorization probe' },
token: 'allPerms',
},
[Permission.PodcastManageRetention]: {
method: 'PATCH',
path: '/podcast-libraries/:libraryId/settings',
params: { libraryId: String(podcastLibrary.libraryId) },
payload: { completionRemainingSeconds: 60 },
token: 'allPerms',
},
[Permission.PodcastPurge]: {
method: 'GET',
path: '/podcasts/:podcastId/purge-preview',
params: { podcastId: '999999' },
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(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',
'POST /libraries/:id/books/jump-buckets',
'GET /libraries/:id/bulk-rename/preview',
'GET /libraries/:id/bulk-rename/status',
'POST /libraries/:id/bulk-rename/execute',
'GET /podcast-libraries/:libraryId/podcasts',
'GET /podcast-libraries/:libraryId/episodes',
'POST /podcast-libraries/:libraryId/queue-episodes',
'POST /podcast-libraries/:libraryId/feed-preview',
'POST /podcast-libraries/:libraryId/podcasts',
'POST /podcast-libraries/:libraryId/opml/import',
'POST /podcast-libraries/:libraryId/import-scan',
'GET /podcast-libraries/:libraryId/import-scan/latest',
'POST /podcast-libraries/:libraryId/shows/bulk-purge-preview',
'POST /podcast-libraries/:libraryId/shows/bulk-delete',
'GET /podcast-libraries/:libraryId/opml/export',
'GET /podcast-libraries/:libraryId/settings',
'PATCH /podcast-libraries/:libraryId/settings',
'GET /podcast-libraries/:libraryId/health',
'GET /podcast-libraries/:libraryId/activity',
'GET /podcast-libraries/:libraryId/jobs',
'GET /podcast-libraries/:libraryId/jobs/:jobId',
].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 params = route.path.startsWith('/podcast-libraries/') ? { libraryId: String(podcastLibrary.libraryId) } : undefined;
const noAccessResponse = await invokeRoute(route, {
token: personas.permsNoLibraryUser.accessToken,
params,
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);
const podcastBypassResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/podcast-libraries/${podcastLibrary.libraryId}/settings`,
headers: authHeader(ctx.adminToken),
});
expect(podcastBypassResponse.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 - podcast stream tickets', () => {
it('rejects an unauthenticated stream request that carries no ticket', async () => {
const response = await ctx.app.inject({ method: 'GET', url: '/api/v1/podcast-episodes/1/stream' });
expect(response.statusCode).toBe(401);
});
it('rejects a stream request whose ticket does not verify', async () => {
const response = await ctx.app.inject({ method: 'GET', url: '/api/v1/podcast-episodes/1/stream?ticket=not-a-real-ticket' });
expectError(response, 401, 'Invalid or expired podcast stream ticket');
});
it('rejects a stream request whose ticket is an ordinary access token', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/podcast-episodes/1/stream?ticket=${personas.allPermsUser.accessToken}`,
});
expect(response.statusCode).toBe(401);
});
it('still accepts header authentication on the stream route', async () => {
const response = await ctx.app.inject({
method: 'GET',
url: '/api/v1/podcast-episodes/1/stream',
headers: authHeader(personas.allPermsUser.accessToken),
});
expect(response.statusCode).toBe(404);
});
it('checks episode access before issuing a ticket', async () => {
const unauthenticated = await ctx.app.inject({ method: 'POST', url: '/api/v1/podcast-episodes/1/stream-ticket' });
expect(unauthenticated.statusCode).toBe(401);
const inaccessible = await ctx.app.inject({
method: 'POST',
url: '/api/v1/podcast-episodes/1/stream-ticket',
headers: authHeader(personas.basicUser.accessToken),
});
expect(inaccessible.statusCode).toBe(404);
});
it('omits inaccessible episodes from a batch state read instead of failing', async () => {
const unauthenticated = await ctx.app.inject({
method: 'POST',
url: '/api/v1/podcast-episodes/state-batch',
payload: { episodeIds: [1] },
});
expect(unauthenticated.statusCode).toBe(401);
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/podcast-episodes/state-batch',
headers: authHeader(personas.basicUser.accessToken),
payload: { episodeIds: [1, 2, 3] },
});
expect(response.statusCode).toBe(201);
expect(response.json()).toEqual([]);
});
it('rejects a batch state read above the id cap', async () => {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/podcast-episodes/state-batch',
headers: authHeader(personas.basicUser.accessToken),
payload: { episodeIds: Array.from({ length: 201 }, (_, index) => index + 1) },
});
expect(response.statusCode).toBe(400);
});
/**
* Directory search has no `:libraryId` for the library guard to read, so its editor gate
* lives in the service. These probes cover that gate without reaching the directory host.
*/
it('refuses directory search for a permitted user with no manageable podcast library', async () => {
const unauthenticated = await ctx.app.inject({ method: 'GET', url: '/api/v1/podcast-search?q=orbit' });
expect(unauthenticated.statusCode).toBe(401);
const noLibrary = await ctx.app.inject({
method: 'GET',
url: '/api/v1/podcast-search?q=orbit',
headers: authHeader(personas.permsNoLibraryUser.accessToken),
});
expectError(noLibrary, 403, 'No podcast library access');
const noPermission = await ctx.app.inject({
method: 'GET',
url: '/api/v1/podcast-search?q=orbit',
headers: authHeader(personas.basicUser.accessToken),
});
expect(noPermission.statusCode).toBe(403);
});
it('validates directory search bounds before reaching the directory', async () => {
const missingTerm = await ctx.app.inject({
method: 'GET',
url: '/api/v1/podcast-search',
headers: authHeader(personas.allPermsUser.accessToken),
});
const oversizedLimit = await ctx.app.inject({
method: 'GET',
url: '/api/v1/podcast-search?q=orbit&limit=51',
headers: authHeader(personas.allPermsUser.accessToken),
});
expect(missingTerm.statusCode).toBe(400);
expect(oversizedLimit.statusCode).toBe(400);
});
});
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),
podcastId: '1',
episodeId: '1',
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}`;
}
});