mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
17aa7425f8
* feat(scanner): set book added_at from earliest on-disk file mtime A book's "date added" now reflects when its content files first landed on disk (the earliest mtime across its content-role files) instead of the moment BookOrbit imported it. Cover/metadata/supplement sidecars are excluded. When no content file has a usable mtime, addedAt is left unset so the DB defaultNow() fallback (import time) still applies. Only set at initial book creation; re-scan/restore paths do not touch it. * feat(library): add per-library added_at source with recompute Add an addedAtSource setting to libraries so "date added" can follow the book's files on disk rather than the moment the scanner happened to see them. The setting takes imported (unchanged default), file_modified, or file_created, guarded by a check constraint on the column. Expose a recompute action so the chosen source can be applied to books that are already imported. file_modified runs as a single set-based update over the stored mtimes; file_created has to stat the files, since birthtime is not persisted, so it batches those reads. Leave every existing library on imported so behavior is unchanged unless a user opts in. * feat(library): add added_at source control to the library editor Surface the new addedAtSource setting in the library editor as a radio group, alongside a button that triggers the recompute for the library being edited. Keep the recompute out of the save path so changing the setting stays cheap: the setting alone only affects books imported from then on, and applying it to existing books is an explicit choice. * i18n(library): translate added_at source strings (de/nl/pt/sl) * fix(library): correct create payload, recompute floor and failure logging The library creator posts its whole form, so omitting addedAtSource from CreateLibraryDto made the global whitelist reject every create request with a 400. Declare the field, validate it against the same source list as the update DTO, and persist it so the initial scan uses the requested source rather than silently falling back to imported. Apply the scanner's usable-time floor to the mtime recompute query too. It previously excluded only NULL, so a filesystem reporting epoch 0 would rewrite added_at to 1970 instead of leaving the import time in place. The floor now lives in library.constants rather than being spelled out twice. Wrap the recompute in try/catch so a database or stat failure emits the [fail] log the logging conventions require instead of propagating with only a [start] line recorded. * fix(library): gate recompute on the saved source and label the radios The recompute button stayed enabled while the selected source differed from the stored one. Because the request carries no source, clicking it recomputed against the old stored value while the UI showed the new selection. A hint already said to save first; disable the control as well so the mismatch is not actionable. Give the source list radiogroup semantics with aria-checked so screen readers can tell which option is active, since selection was conveyed by styling alone. * test(library): assert the create DTO under the real validation options The previous comment claimed the case exercised whitelist and forbidNonWhitelisted, but the shared helper calls validate without them, so it only proved @IsIn rejects an unknown value. Drop the misleading comment and add a case that validates with the same options main.ts gives ValidationPipe, so an undeclared addedAtSource fails the way it would in a real request. * docs(library): remove redundant date-added documentation --------- Co-authored-by: Bob Bobber <user@example.com> Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
1777 lines
70 KiB
TypeScript
1777 lines
70 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 path = prefixedPath.slice('/api/v1'.length) || '/';
|
|
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] }),
|
|
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.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>;
|
|
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.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),
|
|
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',
|
|
].sort(),
|
|
);
|
|
|
|
for (const route of guardedRoutes) {
|
|
const invalidResponse = await invokeRoute(route, {
|
|
token: personas.allPermsUser.accessToken,
|
|
params: { id: '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 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: String(bookA.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: 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);
|
|
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(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}`;
|
|
}
|
|
});
|