diff --git a/scripts/test/e2e-suites.mjs b/scripts/test/e2e-suites.mjs index 08df224bd..30d183e29 100644 --- a/scripts/test/e2e-suites.mjs +++ b/scripts/test/e2e-suites.mjs @@ -57,6 +57,14 @@ export const E2E_SUITES = Object.freeze({ prepareDedicatedDatabase: true, useDedicatedDatabase: true, }, + "authorization-matrix": { + id: "authorization-matrix", + description: "Authorization matrix suite", + vitestTarget: "test/authorization-matrix.e2e-spec.ts", + junitOutput: `${TEST_RESULTS_DIR}/authorization-matrix-e2e-junit.xml`, + prepareDedicatedDatabase: true, + useDedicatedDatabase: true, + }, }); export function listE2ESuites() { diff --git a/server/src/modules/architecture/architecture-boundaries.test.ts b/server/src/modules/architecture/architecture-boundaries.test.ts index 234b930a0..bccbf3625 100644 --- a/server/src/modules/architecture/architecture-boundaries.test.ts +++ b/server/src/modules/architecture/architecture-boundaries.test.ts @@ -48,6 +48,7 @@ describe('Architecture boundaries', () => { 'src/modules/catalog/catalog.service.ts', 'src/modules/cover/cover.service.ts', 'src/modules/file-write/file-write-settings.service.ts', + 'src/modules/kobo/services/kobo-book-access.service.ts', 'src/modules/kobo/services/kobo-device.service.ts', 'src/modules/kobo/services/kobo-download.service.ts', 'src/modules/kobo/services/kobo-reading-state.service.ts', diff --git a/server/src/modules/kobo/kobo-device.controller.ts b/server/src/modules/kobo/kobo-device.controller.ts index c18122850..c92109737 100644 --- a/server/src/modules/kobo/kobo-device.controller.ts +++ b/server/src/modules/kobo/kobo-device.controller.ts @@ -28,33 +28,36 @@ export class KoboDeviceController { async thumbnailFull( @Param('bookId') bookId: string, @Headers('if-none-match') ifNoneMatch: string | undefined, + @CurrentUser() user: RequestUser, @KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest, @Res() reply: FastifyReply, ) { - await this.serveThumbnailOrProxy(bookId, ifNoneMatch, device, req, reply); + await this.serveThumbnailOrProxy(bookId, ifNoneMatch, user, device, req, reply); } @Get('v1/books/:bookId/thumbnail/:width/:height/false/image.jpg') async thumbnailSimple( @Param('bookId') bookId: string, @Headers('if-none-match') ifNoneMatch: string | undefined, + @CurrentUser() user: RequestUser, @KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest, @Res() reply: FastifyReply, ) { - await this.serveThumbnailOrProxy(bookId, ifNoneMatch, device, req, reply); + await this.serveThumbnailOrProxy(bookId, ifNoneMatch, user, device, req, reply); } @Get('v1/books/:bookId/:version/thumbnail/:width/:height/false/image.jpg') async thumbnailVersioned( @Param('bookId') bookId: string, @Headers('if-none-match') ifNoneMatch: string | undefined, + @CurrentUser() user: RequestUser, @KoboDevice() device: KoboDeviceContext, @Req() req: FastifyRequest, @Res() reply: FastifyReply, ) { - await this.serveThumbnailOrProxy(bookId, ifNoneMatch, device, req, reply); + await this.serveThumbnailOrProxy(bookId, ifNoneMatch, user, device, req, reply); } @Get('v1/books/:bookId/download') @@ -109,12 +112,13 @@ export class KoboDeviceController { private async serveThumbnailOrProxy( bookId: string, ifNoneMatch: string | undefined, + user: RequestUser, device: KoboDeviceContext, req: FastifyRequest, reply: FastifyReply, ) { const id = parseInt(bookId, 10); if (isNaN(id)) return this.proxyService.forward(req, reply, device.deviceToken); - await this.thumbnailService.serveThumbnail(id, ifNoneMatch, reply); + await this.thumbnailService.serveThumbnail(user.id, id, ifNoneMatch, reply); } } diff --git a/server/src/modules/kobo/kobo.module.ts b/server/src/modules/kobo/kobo.module.ts index 234fad67b..663f45d0d 100644 --- a/server/src/modules/kobo/kobo.module.ts +++ b/server/src/modules/kobo/kobo.module.ts @@ -7,6 +7,7 @@ import { KoboDeviceController } from './kobo-device.controller'; import { KoboSyncController } from './kobo-sync.controller'; import { KoboUserController } from './kobo-user.controller'; import { KoboTokenGuard } from './guards/kobo-token.guard'; +import { KoboBookAccessService } from './services/kobo-book-access.service'; import { KepubifyBinaryService } from './services/kepubify-binary.service'; import { KoboDeviceService } from './services/kobo-device.service'; import { KoboDownloadService } from './services/kobo-download.service'; @@ -24,6 +25,7 @@ import { KoboThumbnailService } from './services/kobo-thumbnail.service'; KepubifyBinaryService, KoboDeviceService, KoboSettingsService, + KoboBookAccessService, KoboSyncService, KoboReadingStateService, KoboThumbnailService, diff --git a/server/src/modules/kobo/services/kobo-book-access.service.ts b/server/src/modules/kobo/services/kobo-book-access.service.ts new file mode 100644 index 000000000..ba4490b41 --- /dev/null +++ b/server/src/modules/kobo/services/kobo-book-access.service.ts @@ -0,0 +1,41 @@ +import { ForbiddenException, Inject, Injectable } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { DB } from '../../../db/db.module'; +import * as schema from '../../../db/schema'; + +type Db = NodePgDatabase; + +@Injectable() +export class KoboBookAccessService { + constructor(@Inject(DB) private readonly db: Db) {} + + async getAccessibleLibraryIds(userId: number): Promise { + const user = await this.db.query.users.findFirst({ + where: eq(schema.users.id, userId), + columns: { isSuperuser: true }, + }); + if (user?.isSuperuser) return null; + + const rows = await this.db + .select({ libraryId: schema.userLibraryAccess.libraryId }) + .from(schema.userLibraryAccess) + .where(eq(schema.userLibraryAccess.userId, userId)); + return rows.map((row) => row.libraryId); + } + + async assertBookAccessible(userId: number, bookId: number): Promise { + const book = await this.db.query.books.findFirst({ + where: eq(schema.books.id, bookId), + columns: { id: true, libraryId: true }, + }); + + if (!book) return; + + const accessibleLibraryIds = await this.getAccessibleLibraryIds(userId); + if (accessibleLibraryIds !== null && !accessibleLibraryIds.includes(book.libraryId)) { + throw new ForbiddenException('No access to this book'); + } + } +} diff --git a/server/src/modules/kobo/services/kobo-download.service.ts b/server/src/modules/kobo/services/kobo-download.service.ts index 7878cc705..e9702ad65 100644 --- a/server/src/modules/kobo/services/kobo-download.service.ts +++ b/server/src/modules/kobo/services/kobo-download.service.ts @@ -4,7 +4,7 @@ import { mkdir, stat } from 'fs/promises'; import { join } from 'path'; import { promisify } from 'util'; -import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { and, eq } from 'drizzle-orm'; import type { FastifyReply } from 'fastify'; @@ -12,6 +12,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { DB } from '../../../db/db.module'; import * as schema from '../../../db/schema'; +import { KoboBookAccessService } from './kobo-book-access.service'; import { KepubifyBinaryService } from './kepubify-binary.service'; import { KoboSettingsService } from './kobo-settings.service'; @@ -35,6 +36,7 @@ export class KoboDownloadService { private readonly config: ConfigService, private readonly kepubifyBinaryService: KepubifyBinaryService, private readonly settingsService: KoboSettingsService, + private readonly bookAccessService: KoboBookAccessService, ) { this.booksPath = this.config.get('storage.booksPath')!; this.kepubCachePath = join(this.booksPath, '.kepub-cache'); @@ -44,8 +46,7 @@ export class KoboDownloadService { const book = await this.db.query.books.findFirst({ where: eq(schema.books.id, bookId) }); if (!book) throw new NotFoundException('Book not found'); - const accessible = await this.isBookAccessibleToUser(userId, bookId); - if (!accessible) throw new ForbiddenException('No access to this book'); + await this.bookAccessService.assertBookAccessible(userId, bookId); const file = await this.db.query.bookFiles.findFirst({ where: and(eq(schema.bookFiles.bookId, bookId), eq(schema.bookFiles.id, book.primaryFileId ?? -1)), @@ -106,22 +107,4 @@ export class KoboDownloadService { return this.streamFile(sourcePath, fileId, 'epub', reply); } } - - private async isBookAccessibleToUser(userId: number, bookId: number): Promise { - const book = await this.db.query.books.findFirst({ where: eq(schema.books.id, bookId) }); - if (!book) return false; - - const isSuperuser = await this.checkSuperuser(userId); - if (isSuperuser) return true; - - const access = await this.db.query.userLibraryAccess.findFirst({ - where: and(eq(schema.userLibraryAccess.userId, userId), eq(schema.userLibraryAccess.libraryId, book.libraryId)), - }); - return !!access; - } - - private async checkSuperuser(userId: number): Promise { - const user = await this.db.query.users.findFirst({ where: eq(schema.users.id, userId) }); - return user?.isSuperuser ?? false; - } } diff --git a/server/src/modules/kobo/services/kobo-reading-state.service.ts b/server/src/modules/kobo/services/kobo-reading-state.service.ts index 247f06069..6ebe5c2cf 100644 --- a/server/src/modules/kobo/services/kobo-reading-state.service.ts +++ b/server/src/modules/kobo/services/kobo-reading-state.service.ts @@ -1,9 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; -import { and, eq, isNull, or, sql } from 'drizzle-orm'; +import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm'; import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { DB } from '../../../db'; import * as schema from '../../../db/schema'; +import { KoboBookAccessService } from './kobo-book-access.service'; type Db = NodePgDatabase; type JsonObj = Record; @@ -19,9 +20,14 @@ function mergeSubObject(incoming: JsonObj | null | undefined, existing: JsonObj @Injectable() export class KoboReadingStateService { - constructor(@Inject(DB) private readonly db: Db) {} + constructor( + @Inject(DB) private readonly db: Db, + private readonly bookAccessService: KoboBookAccessService, + ) {} async upsertState(userId: number, bookId: number, payload: Record, readingThreshold: number, finishedThreshold: number) { + await this.bookAccessService.assertBookAccessible(userId, bookId); + const entitlementId = String(bookId); const now = new Date().toISOString(); @@ -96,6 +102,8 @@ export class KoboReadingStateService { } async getRawState(userId: number, bookId: number): Promise { + await this.bookAccessService.assertBookAccessible(userId, bookId); + const row = await this.db.query.koboReadingStates.findFirst({ where: and(eq(schema.koboReadingStates.userId, userId), eq(schema.koboReadingStates.bookId, bookId)), }); @@ -114,6 +122,14 @@ export class KoboReadingStateService { } async getAndMarkStatesNeedingPush(userId: number, readingThreshold: number, finishedThreshold: number): Promise { + const accessibleLibraryIds = await this.bookAccessService.getAccessibleLibraryIds(userId); + const libraryAccessFilter = + accessibleLibraryIds === null + ? undefined + : accessibleLibraryIds.length === 0 + ? sql`false` + : inArray(schema.books.libraryId, accessibleLibraryIds); + const rows = await this.db .select({ bookId: schema.books.id, @@ -126,6 +142,7 @@ export class KoboReadingStateService { .where( and( eq(schema.readingProgress.userId, userId), + libraryAccessFilter, or( isNull(schema.koboReadingStates.progressSyncedAt), sql`${schema.readingProgress.updatedAt} > ${schema.koboReadingStates.progressSyncedAt}`, diff --git a/server/src/modules/kobo/services/kobo-sync.service.ts b/server/src/modules/kobo/services/kobo-sync.service.ts index 23224e9a1..f6aa84f74 100644 --- a/server/src/modules/kobo/services/kobo-sync.service.ts +++ b/server/src/modules/kobo/services/kobo-sync.service.ts @@ -5,6 +5,7 @@ import { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { DB } from '../../../db/db.module'; import * as schema from '../../../db/schema'; +import { KoboBookAccessService } from './kobo-book-access.service'; import { KoboReadingStateService } from './kobo-reading-state.service'; type Db = NodePgDatabase; @@ -46,6 +47,7 @@ function encodeSyncToken(snapshotId: number): string { export class KoboSyncService { constructor( @Inject(DB) private readonly db: Db, + private readonly bookAccessService: KoboBookAccessService, private readonly readingStateService: KoboReadingStateService, ) {} @@ -401,6 +403,14 @@ export class KoboSyncService { } private async fetchEligibleBooks(userId: number): Promise { + const accessibleLibraryIds = await this.bookAccessService.getAccessibleLibraryIds(userId); + const libraryAccessFilter = + accessibleLibraryIds === null + ? undefined + : accessibleLibraryIds.length === 0 + ? sql`false` + : inArray(schema.books.libraryId, accessibleLibraryIds); + const rows = await this.db .select({ bookId: schema.books.id, @@ -430,7 +440,7 @@ export class KoboSyncService { eq(schema.collections.syncToKobo, true), ), ) - .where(and(eq(schema.books.status, 'present'), sql`lower(${schema.bookFiles.format}) in ('epub')`)) + .where(and(eq(schema.books.status, 'present'), sql`lower(${schema.bookFiles.format}) in ('epub')`, libraryAccessFilter)) .groupBy( schema.books.id, schema.bookMetadata.title, diff --git a/server/src/modules/kobo/services/kobo-thumbnail.service.ts b/server/src/modules/kobo/services/kobo-thumbnail.service.ts index 52c07dc84..018c2586d 100644 --- a/server/src/modules/kobo/services/kobo-thumbnail.service.ts +++ b/server/src/modules/kobo/services/kobo-thumbnail.service.ts @@ -4,16 +4,22 @@ import { readdir, stat } from 'fs/promises'; import { createReadStream } from 'fs'; import { join } from 'path'; import type { FastifyReply } from 'fastify'; +import { KoboBookAccessService } from './kobo-book-access.service'; @Injectable() export class KoboThumbnailService { private readonly booksPath: string; - constructor(private readonly config: ConfigService) { + constructor( + private readonly config: ConfigService, + private readonly bookAccessService: KoboBookAccessService, + ) { this.booksPath = this.config.get('storage.booksPath')!; } - async serveThumbnail(bookId: number, ifNoneMatch: string | undefined, reply: FastifyReply) { + async serveThumbnail(userId: number, bookId: number, ifNoneMatch: string | undefined, reply: FastifyReply) { + await this.bookAccessService.assertBookAccessible(userId, bookId); + const thumbnailPath = join(this.booksPath, 'covers', String(bookId), 'thumbnail.jpg'); try { const { mtimeMs } = await stat(thumbnailPath); diff --git a/server/src/modules/statistics/statistics.repository.ts b/server/src/modules/statistics/statistics.repository.ts index 56a00c3a6..516b18a86 100644 --- a/server/src/modules/statistics/statistics.repository.ts +++ b/server/src/modules/statistics/statistics.repository.ts @@ -21,11 +21,12 @@ export class StatisticsRepository { return rows.map((r) => r.libraryId); } - private intersectLibraryIds(accessible: number[] | null, requested: number[] | undefined): number[] | null { - if (!requested || requested.length === 0) return accessible; - if (accessible === null) return requested; + private intersectLibraryIds(accessible: number[] | null, requested: number[] | number | undefined): number[] | null { + const requestedIds = Array.isArray(requested) ? requested : requested == null ? [] : [requested]; + if (requestedIds.length === 0) return accessible; + if (accessible === null) return requestedIds; const set = new Set(accessible); - return requested.filter((id) => set.has(id)); + return requestedIds.filter((id) => set.has(id)); } private libraryFilter(libraryIds: number[] | null) { diff --git a/server/src/modules/user-statistics/user-statistics.repository.ts b/server/src/modules/user-statistics/user-statistics.repository.ts index 251706667..44a939740 100644 --- a/server/src/modules/user-statistics/user-statistics.repository.ts +++ b/server/src/modules/user-statistics/user-statistics.repository.ts @@ -59,11 +59,12 @@ export class UserStatisticsRepository { return rows.map((r) => r.libraryId); } - private intersectLibraryIds(accessible: number[] | null, requested: number[] | undefined): number[] | null { - if (!requested || requested.length === 0) return accessible; - if (accessible === null) return requested; + private intersectLibraryIds(accessible: number[] | null, requested: number[] | number | undefined): number[] | null { + const requestedIds = Array.isArray(requested) ? requested : requested == null ? [] : [requested]; + if (requestedIds.length === 0) return accessible; + if (accessible === null) return requestedIds; const set = new Set(accessible); - return requested.filter((id) => set.has(id)); + return requestedIds.filter((id) => set.has(id)); } private libraryFilter(libraryIds: number[] | null) { diff --git a/server/test/authorization-matrix.e2e-spec.ts b/server/test/authorization-matrix.e2e-spec.ts new file mode 100644 index 000000000..dd94251c2 --- /dev/null +++ b/server/test/authorization-matrix.e2e-spec.ts @@ -0,0 +1,1222 @@ +import { randomUUID } from 'crypto'; +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +import { ConfigService } from '@nestjs/config'; +import { Permission } from '@projectx/types'; +import type { NestFastifyApplication } from '@nestjs/platform-fastify'; + +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, + createStagingRow, + 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>; + +interface RouteInventoryRoute { + file: string; + className: string; + methodName: string; + httpMethod: string; + path: string; + permissions: string[]; + libraryAccess: string[]; + isPublic: boolean; + allowDefault: boolean; + useGuards: string[]; +} + +interface RouteInventory { + totalRoutes: number; + byPermission: Record; + byLibraryAccess: Record; + routes: RouteInventoryRoute[]; +} + +interface RouteInventoryManifest { + totalRoutes: number; + byPermission: Record; + byLibraryAccess: Record; + routeChunks: string[]; +} + +interface MatrixFailure { + route: string; + status: number; + reason: string; + message: string; +} + +interface Personas { + basicUser: TestUserSession; + defaultPwdUser: TestUserSession; + allPermsUser: TestUserSession; + permsNoLibraryUser: TestUserSession; + manageUsersAdmin: TestUserSession; + metadataEditor: TestUserSession; + opdsOwner: TestUserSession; + opdsIntruder: TestUserSession; + opdsDisabled: TestUserSession; + opdsRevoked: TestUserSession; + koboActive: TestUserSession; + koboDisabled: TestUserSession; + koboRevoked: TestUserSession; + stagingUser: TestUserSession; + uploadUser: TestUserSession; + ownerUser: TestUserSession; + otherUser: TestUserSession; + targetSuperuser: TestUserSession; +} + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const routeInventory = loadRouteInventory(); + +const supportedMethods = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); + +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, + }; +} + +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] }), + 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] }), + stagingUser: await createUserAndLogin(ctx, { permissions: [Permission.StagingAccess] }), + uploadUser: await createUserAndLogin(ctx, { permissions: [Permission.LibraryUpload] }), + 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.stagingUser.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, + 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('guard matrix - jwt/permission/library/default-password', () => { + 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('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(); + const enumPermissions = [...Object.values(Permission)].sort(); + expect(inventoryPermissions).toEqual(enumPermissions); + + const probes: Record< + Permission, + { + method: SupportedHttpMethod; + path: string; + query?: string; + payload?: Record; + 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.StagingAccess]: { + method: 'GET', + path: '/staging/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.ManageAppSettings]: { + method: 'GET', + path: '/app-settings', + token: 'allPerms', + }, + [Permission.ManageUsers]: { + method: 'GET', + path: '/users', + token: 'allPerms', + }, + [Permission.ViewAuditLog]: { + method: 'GET', + path: '/audit-log', + token: 'allPerms', + }, + }; + + const failures: MatrixFailure[] = []; + + for (const permission of Object.values(Permission)) { + 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: `${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', 'POST /libraries/:id/write-metadata-to-files'].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/libraries?t=invalid-token', + }); + expectError(response, 401, 'Invalid token'); + }); + + it('allows OPDS token auth when token and permission are valid', async () => { + const config = ctx.app.get(ConfigService); + const jwtSecret = config.getOrThrow('auth.jwtSecret'); + const token = createCoverToken(personas.opdsOwner.userId, jwtSecret); + + const response = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/opds/libraries?t=${token}`, + }); + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('application/atom+xml'); + }); + }); + + 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 syncedBookIds = extractKoboEntitlementBookIds(entitlements); + expect(syncedBookIds).toContain(bookA.bookId); + expect(syncedBookIds).not.toContain(bookB.bookId); + }); + + 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); + expect(readState.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + EntitlementId: String(bookA.bookId), + }), + ]), + ); + }); + }); + + 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()}` }, + }); + 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'); + }); + + it('enforces lens private read and owner-only write rules', async () => { + const privateLensResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/lenses', + headers: authHeader(personas.ownerUser.accessToken), + payload: { + name: `authz-private-lens-${randomUUID()}`, + defaultSort: [], + isPublic: false, + }, + }); + expect(privateLensResponse.statusCode).toBe(201); + const privateLensId = (privateLensResponse.json() as { id: number }).id; + + const foreignRead = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/lenses/${privateLensId}`, + headers: authHeader(personas.otherUser.accessToken), + }); + expectError(foreignRead, 403, 'No access to this lens'); + + const foreignUpdate = await ctx.app.inject({ + method: 'PATCH', + url: `/api/v1/lenses/${privateLensId}`, + headers: authHeader(personas.otherUser.accessToken), + payload: { name: 'rename' }, + }); + expectError(foreignUpdate, 403, 'Cannot modify this lens'); + + const foreignDelete = await ctx.app.inject({ + method: 'DELETE', + url: `/api/v1/lenses/${privateLensId}`, + headers: authHeader(personas.otherUser.accessToken), + }); + expectError(foreignDelete, 403, 'Cannot delete this lens'); + + const publicLensResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/lenses', + headers: authHeader(personas.ownerUser.accessToken), + payload: { + name: `authz-public-lens-${randomUUID()}`, + defaultSort: [], + isPublic: true, + }, + }); + expect(publicLensResponse.statusCode).toBe(201); + const publicLensId = (publicLensResponse.json() as { id: number }).id; + + const publicRead = await ctx.app.inject({ + method: 'GET', + url: `/api/v1/lenses/${publicLensId}`, + 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('returns mixed-result finalize envelope for staging authorization failures', async () => { + const accessibleRow = await createStagingRow(ctx, { + fileName: `authz-finalize-ok-${randomUUID()}.fb2`, + targetLibraryId: libraryA.libraryId, + targetFolderId: libraryA.libraryFolderId, + status: 'ready', + }); + const inaccessibleRow = await createStagingRow(ctx, { + fileName: `authz-finalize-denied-${randomUUID()}.fb2`, + targetLibraryId: libraryB.libraryId, + targetFolderId: libraryB.libraryFolderId, + status: 'ready', + }); + + const response = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/staging/finalize', + headers: authHeader(personas.stagingUser.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 isSupportedMethod(method: string): method is SupportedHttpMethod { + return supportedMethods.has(method as SupportedHttpMethod); + } + + function routeLabel(route: Pick): string { + return `${route.httpMethod} ${route.path}`; + } + + function defaultParams(): Record { + 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 { + 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; + params?: Record; + } = {}, + ): Promise { + 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; + payload?: Record; + } = { + 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 | null { + try { + return response.json() as Record; + } 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; + return asRecord[key] ?? null; + } + + function extractKoboEntitlementBookIds(entitlements: unknown[]): number[] { + const ids = new Set(); + for (const item of entitlements) { + if (!item || typeof item !== 'object') continue; + const record = item as Record; + const payload = + (record.NewEntitlement as Record | undefined) ?? + (record.ChangedProductMetadata as Record | undefined) ?? + (record.ChangedEntitlement as Record | undefined); + if (!payload || typeof payload !== 'object') continue; + const entitlement = payload.BookEntitlement as Record | undefined; + const rawId = entitlement?.Id ?? entitlement?.RevisionId ?? entitlement?.CrossRevisionId; + const parsedId = typeof rawId === 'string' ? Number.parseInt(rawId, 10) : Number.NaN; + if (Number.isFinite(parsedId)) { + ids.add(parsedId); + } + } + 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}`; + } +}); diff --git a/server/test/e2e/authorization-matrix/authorization-matrix-fixture-builder.ts b/server/test/e2e/authorization-matrix/authorization-matrix-fixture-builder.ts new file mode 100644 index 000000000..49fe8f20c --- /dev/null +++ b/server/test/e2e/authorization-matrix/authorization-matrix-fixture-builder.ts @@ -0,0 +1,94 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { dirname, join } from 'path'; + +export { createEpubFixture } from '../metadata-write/metadata-write-fixture-builder'; + +export interface AuthorizationMatrixFixtureRoot { + rootPath: string; + booksPath: string; + stagingPath: string; + cleanup: () => Promise; +} + +export interface Fb2FixtureInput { + title?: string; + authors?: string[]; + language?: string; + genre?: string; + description?: string; + year?: number; +} + +function assertRelativePath(path: string): void { + if (path.startsWith('/')) { + throw new Error(`Fixture paths must be relative. Received "${path}"`); + } +} + +export async function createAuthorizationFixtureRoot(prefix = 'authorization-matrix-e2e-'): Promise { + const rootPath = await mkdtemp(join(tmpdir(), prefix)); + const booksPath = join(rootPath, 'books'); + const stagingPath = join(booksPath, 'staging'); + await mkdir(stagingPath, { recursive: true }); + + return { + rootPath, + booksPath, + stagingPath, + cleanup: async () => { + await rm(rootPath, { recursive: true, force: true }); + }, + }; +} + +export async function writeFixtureFile(rootPath: string, relativePath: string, content: string | Buffer): Promise { + assertRelativePath(relativePath); + + const absolutePath = join(rootPath, relativePath); + await mkdir(dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, content); + return absolutePath; +} + +export function buildFb2Fixture(input: Fb2FixtureInput = {}): string { + const title = input.title ?? 'Authorization Matrix Fixture Title'; + const authors = input.authors ?? ['Authorization Matrix Fixture Author']; + const language = input.language ?? 'en'; + const genre = input.genre ?? 'fiction'; + const description = input.description ?? 'Fixture description'; + const year = input.year ?? 2024; + + const authorXml = authors + .map((name) => { + const [firstName = 'Unknown', ...rest] = name.trim().split(/\s+/); + const lastName = rest.length > 0 ? rest.join(' ') : 'Author'; + return `${escapeXml(firstName)}${escapeXml(lastName)}`; + }) + .join(''); + + return ` + + + + ${escapeXml(genre)} + ${authorXml} + ${escapeXml(title)} +

${escapeXml(description)}

+ ${year} + ${escapeXml(language)} +
+ + ${year} + +
+ +

fixture content

+ +
+`; +} + +function escapeXml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} diff --git a/server/test/e2e/authorization-matrix/authorization-matrix-harness.ts b/server/test/e2e/authorization-matrix/authorization-matrix-harness.ts new file mode 100644 index 000000000..493fed7a9 --- /dev/null +++ b/server/test/e2e/authorization-matrix/authorization-matrix-harness.ts @@ -0,0 +1,530 @@ +import { randomUUID } from 'crypto'; +import { hash } from 'bcryptjs'; +import fastifyCookie from '@fastify/cookie'; +import fastifyMultipart from '@fastify/multipart'; +import { Test } from '@nestjs/testing'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { mkdir, stat } from 'fs/promises'; +import { and, eq } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import type { Permission, StagingMetadata } from '@projectx/types'; + +import { AppModule } from '../../../src/app.module'; +import { DB } from '../../../src/db'; +import * as schema from '../../../src/db/schema'; +import { MetadataService } from '../../../src/modules/metadata/metadata.service'; +import { StagingWatcherService } from '../../../src/modules/staging/staging-watcher.service'; +import { seedLibrary, waitForScanCompletion } from '../app-harness'; +import { + buildFb2Fixture, + createAuthorizationFixtureRoot, + type AuthorizationMatrixFixtureRoot, + writeFixtureFile, +} from './authorization-matrix-fixture-builder'; + +type Db = NodePgDatabase; + +const ADMIN_SETUP_DTO = { + username: 'authorization-matrix-e2e-admin', + name: 'Authorization Matrix E2E Admin', + email: 'authorization-matrix-e2e-admin@example.com', + password: 'AuthorizationMatrixAdmin123', +}; + +interface EnvSnapshot { + booksPath: string | undefined; + stagingPath: string | undefined; +} + +export interface AuthorizationMatrixE2EContext { + app: NestFastifyApplication; + db: Db; + adminToken: string; + fixture: AuthorizationMatrixFixtureRoot; + envSnapshot: EnvSnapshot; +} + +export interface CreatedLibrary { + libraryId: number; + libraryFolderId: number; + folderPath: string; +} + +export interface TestUserSession { + userId: number; + username: string; + password: string; + accessToken: string; +} + +export interface LocatedBookFile { + libraryId: number; + bookId: number; + bookFileId: number; + absolutePath: string; + relPath: string | null; + format: string | null; +} + +export interface CreateStagingRowInput { + fileName?: string; + content?: string | Buffer; + status?: typeof schema.stagingFiles.$inferInsert.status; + format?: string; + embeddedMetadata?: StagingMetadata | null; + selectedMetadata?: StagingMetadata | null; + fetchedMetadata?: StagingMetadata | null; + targetLibraryId?: number | null; + targetFolderId?: number | null; + confidence?: number | null; + errorMessage?: string | null; + metadataEditedAt?: Date | null; +} + +export interface CreateReadingSessionInput { + userId: number; + bookFileId: number; + startedAt: Date; + endedAt: Date; + sessionId?: string; + progressDelta?: number | null; + endProgress?: number | null; +} + +export function authHeader(token: string): Record { + return { authorization: `Bearer ${token}` }; +} + +export async function createAuthorizationMatrixE2EContext(): Promise { + const fixture = await createAuthorizationFixtureRoot(); + const envSnapshot: EnvSnapshot = { + booksPath: process.env.BOOKS_PATH, + stagingPath: process.env.STAGING_PATH, + }; + + process.env.BOOKS_PATH = fixture.booksPath; + process.env.STAGING_PATH = fixture.stagingPath; + + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(MetadataService) + .useValue(makeMetadataNoopMock()) + .compile(); + + const app = moduleFixture.createNestApplication(new FastifyAdapter()); + app.setGlobalPrefix('api/v1'); + await app.register(fastifyCookie as never); + await app.register(fastifyMultipart as never); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + + await stopStagingWatcher(app); + + const db = app.get(DB); + const adminToken = await getAdminToken(app, db); + await setSettingValue(db, 'staging_auto_fetch_metadata', 'false'); + await setSettingValue(db, 'staging_auto_finalize_enabled', 'false'); + await setSettingValue(db, 'opds_enabled', 'true'); + + return { + app, + db, + adminToken, + fixture, + envSnapshot, + }; +} + +export async function closeAuthorizationMatrixE2EContext(ctx: AuthorizationMatrixE2EContext): Promise { + await ctx.app.close(); + await ctx.fixture.cleanup(); + restoreEnv(ctx.envSnapshot); +} + +export async function createLibraryWithFolder( + ctx: AuthorizationMatrixE2EContext, + options: { + mode?: 'book_per_file' | 'book_per_folder'; + allowedFormats?: string[]; + name?: string; + } = {}, +): Promise { + const folderPath = `${ctx.fixture.booksPath}/library-${randomUUID()}`; + await mkdir(folderPath, { recursive: true }); + + const { libraryId, libraryFolderId } = await seedLibrary(ctx.db, { + rootPath: folderPath, + mode: options.mode ?? 'book_per_file', + allowedFormats: options.allowedFormats ?? [], + watch: false, + name: options.name, + }); + + return { + libraryId, + libraryFolderId, + folderPath, + }; +} + +export async function triggerAndWaitForLibraryScan( + ctx: AuthorizationMatrixE2EContext, + libraryId: number, + timeoutMs = 45_000, +): Promise { + const response = await ctx.app.inject({ + method: 'POST', + url: `/api/v1/scanner/libraries/${libraryId}/scan`, + headers: authHeader(ctx.adminToken), + }); + + if (response.statusCode !== 202) { + throw new Error(`Scan endpoint failed: ${response.statusCode} ${response.body}`); + } + + const body = response.json() as { jobId?: number }; + if (!body.jobId) { + throw new Error(`Scan endpoint returned no jobId: ${response.body}`); + } + + return waitForScanCompletion(ctx.db, body.jobId, timeoutMs); +} + +export async function createUserAndLogin( + ctx: AuthorizationMatrixE2EContext, + options: { + permissions?: Permission[]; + isSuperuser?: boolean; + isDefaultPassword?: boolean; + active?: boolean; + username?: string; + password?: string; + email?: string; + } = {}, +): Promise { + const suffix = randomUUID().replaceAll('-', ''); + const username = options.username ?? `authorization-matrix-user-${suffix}`; + const password = options.password ?? 'AuthorizationMatrixUser123'; + const email = options.email ?? `${username}@example.com`; + const passwordHash = await hash(password, 12); + + const [created] = await ctx.db + .insert(schema.users) + .values({ + username, + name: `Authorization Matrix User ${suffix}`, + email, + passwordHash, + active: options.active ?? true, + isSuperuser: options.isSuperuser ?? false, + isDefaultPassword: options.isDefaultPassword ?? false, + provisioningMethod: 'local', + }) + .returning({ id: schema.users.id }); + + const permissions = options.permissions ?? []; + if (permissions.length > 0) { + await ctx.db.insert(schema.userPermissions).values(permissions.map((permissionName) => ({ userId: created.id, permissionName }))); + } + + const accessToken = await loginForToken(ctx.app, username, password); + if (!accessToken) { + throw new Error(`Login failed for ${username}`); + } + + return { + userId: created.id, + username, + password, + accessToken, + }; +} + +export async function grantLibraryAccess( + ctx: AuthorizationMatrixE2EContext, + userId: number, + libraryId: number, + accessLevel: 'viewer' | 'editor' | 'owner' = 'viewer', +): Promise { + await ctx.db + .insert(schema.userLibraryAccess) + .values({ userId, libraryId, accessLevel }) + .onConflictDoUpdate({ + target: [schema.userLibraryAccess.userId, schema.userLibraryAccess.libraryId], + set: { accessLevel }, + }); +} + +export async function replaceUserPermissions(ctx: AuthorizationMatrixE2EContext, userId: number, permissions: Permission[]): Promise { + await ctx.db.delete(schema.userPermissions).where(eq(schema.userPermissions.userId, userId)); + if (permissions.length === 0) return; + await ctx.db.insert(schema.userPermissions).values(permissions.map((permissionName) => ({ userId, permissionName }))); +} + +export async function setUserActive(ctx: AuthorizationMatrixE2EContext, userId: number, active: boolean): Promise { + await ctx.db.update(schema.users).set({ active }).where(eq(schema.users.id, userId)); +} + +export async function setSetting(ctx: AuthorizationMatrixE2EContext, key: string, value: string): Promise { + await setSettingValue(ctx.db, key, value); +} + +export async function locateBookByAbsolutePath(ctx: AuthorizationMatrixE2EContext, absolutePath: string): Promise { + const [row] = await ctx.db + .select({ + libraryId: schema.books.libraryId, + bookId: schema.books.id, + bookFileId: schema.bookFiles.id, + absolutePath: schema.bookFiles.absolutePath, + relPath: schema.bookFiles.relPath, + format: schema.bookFiles.format, + }) + .from(schema.bookFiles) + .innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId)) + .where(and(eq(schema.books.status, 'present'), eq(schema.bookFiles.absolutePath, absolutePath))) + .limit(1); + + if (!row) { + throw new Error(`No present book file found for path "${absolutePath}"`); + } + + return row; +} + +export async function createBookCoverArtifacts( + ctx: AuthorizationMatrixE2EContext, + bookId: number, + options: { + coverExtension?: 'jpg' | 'png'; + coverContent?: Buffer; + thumbnailContent?: Buffer; + } = {}, +): Promise { + const coverExtension = options.coverExtension ?? 'jpg'; + const coverContent = options.coverContent ?? Buffer.from(`cover-${bookId}`, 'utf8'); + const thumbnailContent = options.thumbnailContent ?? Buffer.from(`thumbnail-${bookId}`, 'utf8'); + + await writeFixtureFile(ctx.fixture.booksPath, `covers/${bookId}/cover.${coverExtension}`, coverContent); + await writeFixtureFile(ctx.fixture.booksPath, `covers/${bookId}/thumbnail.jpg`, thumbnailContent); +} + +export async function createStagingRow( + ctx: AuthorizationMatrixE2EContext, + input: CreateStagingRowInput = {}, +): Promise { + const fileName = input.fileName ?? `authz-staging-${randomUUID()}.fb2`; + const content = input.content ?? buildFb2Fixture({ title: `Authorization Matrix ${randomUUID()}`, authors: ['Fixture Author'] }); + const format = input.format ?? fileName.split('.').pop()?.toLowerCase() ?? 'fb2'; + const absolutePath = await writeFixtureFile(ctx.fixture.booksPath, `staging/${fileName}`, content); + const fileStat = await stat(absolutePath); + + const [row] = await ctx.db + .insert(schema.stagingFiles) + .values({ + fileName, + absolutePath, + fileSize: fileStat.size, + format, + status: input.status ?? 'ready', + embeddedMetadata: input.embeddedMetadata ?? null, + selectedMetadata: input.selectedMetadata ?? null, + fetchedMetadata: input.fetchedMetadata ?? null, + targetLibraryId: input.targetLibraryId ?? null, + targetFolderId: input.targetFolderId ?? null, + confidence: input.confidence ?? null, + errorMessage: input.errorMessage ?? null, + metadataEditedAt: input.metadataEditedAt ?? null, + }) + .returning(); + + return row; +} + +export async function createKoboDevice( + ctx: AuthorizationMatrixE2EContext, + userId: number, + name = `kobo-device-${randomUUID()}`, +): Promise { + const token = randomUUID().replaceAll('-', ''); + const [device] = await ctx.db + .insert(schema.koboDevices) + .values({ + userId, + name, + token, + }) + .returning(); + + return device; +} + +export async function createOpdsUser( + ctx: AuthorizationMatrixE2EContext, + input: { + userId: number; + username?: string; + password?: string; + sortOrder?: typeof schema.opdsUsers.$inferInsert.sortOrder; + }, +): Promise<{ row: typeof schema.opdsUsers.$inferSelect; password: string }> { + const username = input.username ?? `opds-${randomUUID().replaceAll('-', '').slice(0, 12)}`; + const password = input.password ?? 'OpdsPassword123'; + const passwordHash = await hash(password, 12); + + const [row] = await ctx.db + .insert(schema.opdsUsers) + .values({ + userId: input.userId, + username, + passwordHash, + sortOrder: input.sortOrder ?? 'recent', + }) + .returning(); + + return { row, password }; +} + +export async function createReadingSession( + ctx: AuthorizationMatrixE2EContext, + input: CreateReadingSessionInput, +): Promise { + const durationSeconds = Math.round((input.endedAt.getTime() - input.startedAt.getTime()) / 1000); + const [row] = await ctx.db + .insert(schema.readingSessions) + .values({ + userId: input.userId, + bookFileId: input.bookFileId, + sessionId: input.sessionId ?? `session-${randomUUID()}`, + startedAt: input.startedAt, + endedAt: input.endedAt, + durationSeconds, + progressDelta: input.progressDelta ?? null, + endProgress: input.endProgress ?? null, + }) + .returning(); + return row; +} + +export async function uploadLibraryFile( + ctx: AuthorizationMatrixE2EContext, + input: { + token: string; + libraryId: number; + fileName: string; + content: string | Buffer; + folderId?: number; + contentType?: string; + }, +) { + const contentBuffer = typeof input.content === 'string' ? Buffer.from(input.content, 'utf8') : input.content; + const { body, boundary } = buildMultipartBody(input.fileName, contentBuffer, input.contentType ?? 'application/octet-stream'); + const folderQuery = input.folderId ? `?folderId=${input.folderId}` : ''; + return ctx.app.inject({ + method: 'POST', + url: `/api/v1/libraries/${input.libraryId}/upload${folderQuery}`, + headers: { + ...authHeader(input.token), + 'content-type': `multipart/form-data; boundary=${boundary}`, + 'content-length': String(body.length), + }, + payload: body, + }); +} + +function buildMultipartBody(fileName: string, content: Buffer, contentType: string): { body: Buffer; boundary: string } { + const boundary = `----projectx-authz-${randomUUID()}`; + const preamble = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: ${contentType}\r\n\r\n`, + 'utf8', + ); + const closing = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8'); + return { body: Buffer.concat([preamble, content, closing]), boundary }; +} + +function makeMetadataNoopMock(): Pick< + MetadataService, + 'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators' +> { + return { + extractAndSave: () => Promise.resolve(undefined), + refreshCoverForBook: () => Promise.resolve(false), + extractAudioFileDuration: () => Promise.resolve(undefined), + aggregateAudioDuration: () => Promise.resolve(undefined), + extractAudioChaptersAndNarrators: () => Promise.resolve(undefined), + }; +} + +async function stopStagingWatcher(app: NestFastifyApplication): Promise { + const watcher = app.get(StagingWatcherService); + await watcher.onModuleDestroy(); +} + +async function getAdminToken(app: NestFastifyApplication, db: Db): Promise { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/setup', + payload: ADMIN_SETUP_DTO, + }); + + if (response.statusCode === 409) { + const existingToken = await loginForToken(app, ADMIN_SETUP_DTO.username, ADMIN_SETUP_DTO.password); + if (existingToken) return existingToken; + + const suffix = randomUUID().replaceAll('-', ''); + const fallbackUsername = `authorization-matrix-admin-${suffix}`; + const fallbackPassword = ADMIN_SETUP_DTO.password; + const passwordHash = await hash(fallbackPassword, 12); + + await db.insert(schema.users).values({ + username: fallbackUsername, + name: 'Authorization Matrix E2E Admin', + email: `${fallbackUsername}@example.com`, + passwordHash, + isSuperuser: true, + isDefaultPassword: false, + provisioningMethod: 'local', + }); + + const fallbackToken = await loginForToken(app, fallbackUsername, fallbackPassword); + if (fallbackToken) return fallbackToken; + throw new Error('Setup is already complete and fallback admin login failed'); + } + + if (response.statusCode !== 201) { + throw new Error(`Unable to complete setup: ${response.statusCode} ${response.body}`); + } + + const body = response.json() as { accessToken?: string }; + if (!body.accessToken) { + throw new Error('Setup succeeded but accessToken is missing'); + } + + return body.accessToken; +} + +async function loginForToken(app: NestFastifyApplication, username: string, password: string): Promise { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { username, password }, + }); + if (response.statusCode !== 200) return null; + const body = response.json() as { accessToken?: string }; + return body.accessToken ?? null; +} + +async function setSettingValue(db: Db, key: string, value: string): Promise { + await db.insert(schema.appSettings).values({ key, value }).onConflictDoUpdate({ + target: schema.appSettings.key, + set: { value }, + }); +} + +function restoreEnv(snapshot: EnvSnapshot): void { + if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH; + else process.env.BOOKS_PATH = snapshot.booksPath; + + if (snapshot.stagingPath === undefined) delete process.env.STAGING_PATH; + else process.env.STAGING_PATH = snapshot.stagingPath; +} diff --git a/server/test/e2e/authorization-matrix/route-inventory/manifest.json b/server/test/e2e/authorization-matrix/route-inventory/manifest.json new file mode 100644 index 000000000..6c99e2b4f --- /dev/null +++ b/server/test/e2e/authorization-matrix/route-inventory/manifest.json @@ -0,0 +1,33 @@ +{ + "totalRoutes": 319, + "byPermission": { + "(none)": 161, + "Permission.ManageMetadataConfig": 32, + "Permission.EmailSend": 30, + "Permission.LibraryEditMetadata": 17, + "Permission.StagingAccess": 17, + "Permission.ManageAppSettings": 16, + "Permission.ManageLibraries": 13, + "Permission.ManageUsers": 10, + "Permission.ManageEmail": 8, + "Permission.KoboSync": 7, + "Permission.OpdsAccess": 4, + "Permission.ViewAuditLog": 1, + "Permission.LibraryDeleteBooks": 1, + "Permission.LibraryDownload": 1, + "Permission.LibraryUpload": 1 + }, + "byLibraryAccess": { + "'viewer'": 3, + "'editor'": 1 + }, + "routeChunks": [ + "routes-01.json", + "routes-02.json", + "routes-03.json", + "routes-04.json", + "routes-05.json", + "routes-06.json", + "routes-07.json" + ] +} diff --git a/server/test/e2e/authorization-matrix/route-inventory/routes-01.json b/server/test/e2e/authorization-matrix/route-inventory/routes-01.json new file mode 100644 index 000000000..87af544e1 --- /dev/null +++ b/server/test/e2e/authorization-matrix/route-inventory/routes-01.json @@ -0,0 +1,650 @@ +[ + { + "file": "server/src/modules/annotation/annotation.controller.ts", + "className": "AnnotationController", + "methodName": "getAnnotations", + "httpMethod": "GET", + "path": "/books/:bookId/annotations", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/annotation/annotation.controller.ts", + "className": "AnnotationController", + "methodName": "createAnnotation", + "httpMethod": "POST", + "path": "/books/:bookId/annotations", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/annotation/annotation.controller.ts", + "className": "AnnotationController", + "methodName": "updateAnnotation", + "httpMethod": "PATCH", + "path": "/books/:bookId/annotations/:annotationId", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/annotation/annotation.controller.ts", + "className": "AnnotationController", + "methodName": "deleteAnnotation", + "httpMethod": "DELETE", + "path": "/books/:bookId/annotations/:annotationId", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "listSettings", + "httpMethod": "GET", + "path": "/app-settings", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "update", + "httpMethod": "PATCH", + "path": "/app-settings/:key", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "getUploadPattern", + "httpMethod": "GET", + "path": "/app-settings/upload-pattern", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "setUploadPattern", + "httpMethod": "PUT", + "path": "/app-settings/upload-pattern", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "getDownloadPattern", + "httpMethod": "GET", + "path": "/app-settings/download-pattern", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "setDownloadPattern", + "httpMethod": "PUT", + "path": "/app-settings/download-pattern", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "getOidcPublicConfig", + "httpMethod": "GET", + "path": "/app-settings/oidc/public", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "getOidcConfig", + "httpMethod": "GET", + "path": "/app-settings/oidc", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "updateOidcConfig", + "httpMethod": "PUT", + "path": "/app-settings/oidc", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "testOidcConnection", + "httpMethod": "POST", + "path": "/app-settings/oidc/test", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "getFileWriteSettings", + "httpMethod": "GET", + "path": "/app-settings/file-write-settings", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "updateFileWriteSettings", + "httpMethod": "PUT", + "path": "/app-settings/file-write-settings", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/audit/audit.controller.ts", + "className": "AuditController", + "methodName": "getAuditLogs", + "httpMethod": "GET", + "path": "/audit-log", + "permissions": [ + "Permission.ViewAuditLog" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "register", + "httpMethod": "POST", + "path": "/auth/register", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "setupStatus", + "httpMethod": "GET", + "path": "/auth/setup-status", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "setup", + "httpMethod": "POST", + "path": "/auth/setup", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "login", + "httpMethod": "POST", + "path": "/auth/login", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "refresh", + "httpMethod": "POST", + "path": "/auth/refresh", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "logout", + "httpMethod": "POST", + "path": "/auth/logout", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "me", + "httpMethod": "GET", + "path": "/auth/me", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": true, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "getSessions", + "httpMethod": "GET", + "path": "/auth/sessions", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "revokeSession", + "httpMethod": "DELETE", + "path": "/auth/sessions/:id", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "forgotPassword", + "httpMethod": "POST", + "path": "/auth/forgot-password", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "resetPassword", + "httpMethod": "POST", + "path": "/auth/reset-password", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "changePassword", + "httpMethod": "POST", + "path": "/auth/change-password", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": true, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "oidcGenerateState", + "httpMethod": "POST", + "path": "/auth/oidc/state", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "oidcCallback", + "httpMethod": "POST", + "path": "/auth/oidc/callback", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/auth/auth.controller.ts", + "className": "AuthController", + "methodName": "oidcBackchannelLogout", + "httpMethod": "POST", + "path": "/auth/oidc/backchannel-logout", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "findAll", + "httpMethod": "GET", + "path": "/authors", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "findInsights", + "httpMethod": "GET", + "path": "/authors/insights", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "findDuplicateSuggestions", + "httpMethod": "GET", + "path": "/authors/suggestions/duplicates", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "listMetadataProviders", + "httpMethod": "GET", + "path": "/authors/metadata/providers", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "searchMetadata", + "httpMethod": "GET", + "path": "/authors/metadata/search", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "lookupMetadata", + "httpMethod": "GET", + "path": "/authors/metadata/lookup", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "bulkRefreshMetadata", + "httpMethod": "POST", + "path": "/authors/bulk-refresh-metadata", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "getEnrichmentConfig", + "httpMethod": "GET", + "path": "/authors/enrichment/config", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "setEnrichmentConfig", + "httpMethod": "PUT", + "path": "/authors/enrichment/config", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "previewCount", + "httpMethod": "POST", + "path": "/authors/enrichment/preview-count", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "enqueueBackfill", + "httpMethod": "POST", + "path": "/authors/enrichment/backfill", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "enqueueBackfillAll", + "httpMethod": "POST", + "path": "/authors/enrichment/backfill-all", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "pauseEnrichment", + "httpMethod": "POST", + "path": "/authors/enrichment/pause", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "resumeEnrichment", + "httpMethod": "POST", + "path": "/authors/enrichment/resume", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "cancelEnrichment", + "httpMethod": "POST", + "path": "/authors/enrichment/cancel", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "retryFailedEnrichment", + "httpMethod": "POST", + "path": "/authors/enrichment/retry-failed", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "getFailedEnrichment", + "httpMethod": "GET", + "path": "/authors/enrichment/failed", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "findOne", + "httpMethod": "GET", + "path": "/authors/:id", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + } +] diff --git a/server/test/e2e/authorization-matrix/route-inventory/routes-02.json b/server/test/e2e/authorization-matrix/route-inventory/routes-02.json new file mode 100644 index 000000000..d0683b1b1 --- /dev/null +++ b/server/test/e2e/authorization-matrix/route-inventory/routes-02.json @@ -0,0 +1,656 @@ +[ + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "findBooks", + "httpMethod": "GET", + "path": "/authors/:id/books", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "getThumbnail", + "httpMethod": "GET", + "path": "/authors/:id/thumbnail", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "getImage", + "httpMethod": "GET", + "path": "/authors/:id/image", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "refreshEnrichment", + "httpMethod": "POST", + "path": "/authors/:id/enrichment/refresh", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "update", + "httpMethod": "PATCH", + "path": "/authors/:id", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "merge", + "httpMethod": "POST", + "path": "/authors/merge", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/authors/authors.controller.ts", + "className": "AuthorsController", + "methodName": "delete", + "httpMethod": "DELETE", + "path": "/authors", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "embedAll", + "httpMethod": "POST", + "path": "/books/embed-all", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "deleteBooks", + "httpMethod": "DELETE", + "path": "/books", + "permissions": [ + "Permission.LibraryDeleteBooks" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "searchBooks", + "httpMethod": "GET", + "path": "/books/search", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "globalQuery", + "httpMethod": "POST", + "path": "/books/query", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "bulkRefreshMetadata", + "httpMethod": "POST", + "path": "/books/bulk-refresh-metadata", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "bulkReExtractCover", + "httpMethod": "POST", + "path": "/books/bulk-re-extract-cover", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "reExtractCover", + "httpMethod": "POST", + "path": "/books/:id/re-extract-cover", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "exportBooks", + "httpMethod": "POST", + "path": "/books/export", + "permissions": [ + "Permission.LibraryDownload" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "getCover", + "httpMethod": "GET", + "path": "/books/:id/cover", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "getThumbnail", + "httpMethod": "GET", + "path": "/books/:id/thumbnail", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "serveFile", + "httpMethod": "GET", + "path": "/books/files/:fileId/serve", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "getFileProgress", + "httpMethod": "GET", + "path": "/books/files/:fileId/progress", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "saveFileProgress", + "httpMethod": "POST", + "path": "/books/files/:fileId/progress", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "getAudioProgress", + "httpMethod": "GET", + "path": "/books/:id/audio-progress", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "saveAudioProgress", + "httpMethod": "PATCH", + "path": "/books/:id/audio-progress", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "updateMetadata", + "httpMethod": "PATCH", + "path": "/books/:id/metadata", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "refreshMetadata", + "httpMethod": "POST", + "path": "/books/:id/refresh-metadata", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "getWriteLog", + "httpMethod": "GET", + "path": "/books/:id/write-log", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "getKoboState", + "httpMethod": "GET", + "path": "/books/:id/kobo-state", + "permissions": [ + "Permission.KoboSync" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "getBookProgress", + "httpMethod": "GET", + "path": "/books/:id/progress", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "setReadStatus", + "httpMethod": "PATCH", + "path": "/books/:id/status", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book/book.controller.ts", + "className": "BookController", + "methodName": "getDetail", + "httpMethod": "GET", + "path": "/books/:id", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts", + "className": "BookMetadataFetchController", + "methodName": "getConfig", + "httpMethod": "GET", + "path": "/book-metadata-fetch/config", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts", + "className": "BookMetadataFetchController", + "methodName": "updateConfig", + "httpMethod": "PUT", + "path": "/book-metadata-fetch/config", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts", + "className": "BookMetadataFetchController", + "methodName": "getLibraryConfig", + "httpMethod": "GET", + "path": "/book-metadata-fetch/config/libraries/:id", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts", + "className": "BookMetadataFetchController", + "methodName": "updateLibraryConfig", + "httpMethod": "PUT", + "path": "/book-metadata-fetch/config/libraries/:id", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts", + "className": "BookMetadataFetchController", + "methodName": "getStatus", + "httpMethod": "GET", + "path": "/book-metadata-fetch/status", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts", + "className": "BookMetadataFetchController", + "methodName": "triggerGlobal", + "httpMethod": "POST", + "path": "/book-metadata-fetch/run", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts", + "className": "BookMetadataFetchController", + "methodName": "triggerForLibrary", + "httpMethod": "POST", + "path": "/book-metadata-fetch/run/:libraryId", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts", + "className": "BookMetadataFetchController", + "methodName": "pause", + "httpMethod": "POST", + "path": "/book-metadata-fetch/pause", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts", + "className": "BookMetadataFetchController", + "methodName": "resume", + "httpMethod": "POST", + "path": "/book-metadata-fetch/resume", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts", + "className": "BookMetadataFetchController", + "methodName": "cancel", + "httpMethod": "POST", + "path": "/book-metadata-fetch/cancel", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts", + "className": "BookMetadataFetchController", + "methodName": "retryFailed", + "httpMethod": "POST", + "path": "/book-metadata-fetch/retry-failed", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts", + "className": "BookMetadataFetchController", + "methodName": "previewCount", + "httpMethod": "POST", + "path": "/book-metadata-fetch/preview-count", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/book-metadata-fetch/book-metadata-fetch.controller.ts", + "className": "BookMetadataFetchController", + "methodName": "getFailedItems", + "httpMethod": "GET", + "path": "/book-metadata-fetch/failed", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/bookmark/bookmark.controller.ts", + "className": "BookmarkController", + "methodName": "getBookmarks", + "httpMethod": "GET", + "path": "/books/:bookId/bookmarks", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/bookmark/bookmark.controller.ts", + "className": "BookmarkController", + "methodName": "createBookmark", + "httpMethod": "POST", + "path": "/books/:bookId/bookmarks", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/bookmark/bookmark.controller.ts", + "className": "BookmarkController", + "methodName": "deleteBookmark", + "httpMethod": "DELETE", + "path": "/books/:bookId/bookmarks/:bookmarkId", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/catalog/catalog.controller.ts", + "className": "CatalogController", + "methodName": "searchAuthors", + "httpMethod": "GET", + "path": "/metadata/authors", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/catalog/catalog.controller.ts", + "className": "CatalogController", + "methodName": "searchGenres", + "httpMethod": "GET", + "path": "/metadata/genres", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/catalog/catalog.controller.ts", + "className": "CatalogController", + "methodName": "searchTags", + "httpMethod": "GET", + "path": "/metadata/tags", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/catalog/catalog.controller.ts", + "className": "CatalogController", + "methodName": "searchNarrators", + "httpMethod": "GET", + "path": "/metadata/narrators", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/catalog/catalog.controller.ts", + "className": "CatalogController", + "methodName": "searchPublishers", + "httpMethod": "GET", + "path": "/metadata/publishers", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + } +] diff --git a/server/test/e2e/authorization-matrix/route-inventory/routes-03.json b/server/test/e2e/authorization-matrix/route-inventory/routes-03.json new file mode 100644 index 000000000..cc7845c46 --- /dev/null +++ b/server/test/e2e/authorization-matrix/route-inventory/routes-03.json @@ -0,0 +1,676 @@ +[ + { + "file": "server/src/modules/catalog/catalog.controller.ts", + "className": "CatalogController", + "methodName": "searchSeries", + "httpMethod": "GET", + "path": "/metadata/series", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/catalog/catalog.controller.ts", + "className": "CatalogController", + "methodName": "searchLanguages", + "httpMethod": "GET", + "path": "/metadata/languages", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/catalog/catalog.controller.ts", + "className": "CatalogController", + "methodName": "searchCollections", + "httpMethod": "GET", + "path": "/metadata/collections", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/collection/collection.controller.ts", + "className": "CollectionController", + "methodName": "findAll", + "httpMethod": "GET", + "path": "/collections", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/collection/collection.controller.ts", + "className": "CollectionController", + "methodName": "findOne", + "httpMethod": "GET", + "path": "/collections/:id", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/collection/collection.controller.ts", + "className": "CollectionController", + "methodName": "create", + "httpMethod": "POST", + "path": "/collections", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/collection/collection.controller.ts", + "className": "CollectionController", + "methodName": "reorder", + "httpMethod": "POST", + "path": "/collections/reorder", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/collection/collection.controller.ts", + "className": "CollectionController", + "methodName": "update", + "httpMethod": "PATCH", + "path": "/collections/:id", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/collection/collection.controller.ts", + "className": "CollectionController", + "methodName": "remove", + "httpMethod": "DELETE", + "path": "/collections/:id", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/collection/collection.controller.ts", + "className": "CollectionController", + "methodName": "addBooks", + "httpMethod": "POST", + "path": "/collections/:id/books", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/collection/collection.controller.ts", + "className": "CollectionController", + "methodName": "removeBooks", + "httpMethod": "DELETE", + "path": "/collections/:id/books", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/collection/collection.controller.ts", + "className": "CollectionController", + "methodName": "getBooks", + "httpMethod": "GET", + "path": "/collections/:id/books", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/cover/cover.controller.ts", + "className": "CoverController", + "methodName": "searchCovers", + "httpMethod": "GET", + "path": "/books/cover/search", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/cover/cover.controller.ts", + "className": "CoverController", + "methodName": "proxyImage", + "httpMethod": "GET", + "path": "/books/cover/proxy", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/cover/cover.controller.ts", + "className": "CoverController", + "methodName": "uploadCover", + "httpMethod": "POST", + "path": "/books/:id/cover", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/cover/cover.controller.ts", + "className": "CoverController", + "methodName": "uploadCoverFromUrl", + "httpMethod": "POST", + "path": "/books/:id/cover/from-url", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/cover/cover.controller.ts", + "className": "CoverController", + "methodName": "deleteCover", + "httpMethod": "DELETE", + "path": "/books/:id/cover", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/dashboard/dashboard.controller.ts", + "className": "DashboardController", + "methodName": "getScroller", + "httpMethod": "GET", + "path": "/dashboard/scrollers/:type", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-admin-log.controller.ts", + "className": "EmailAdminLogController", + "methodName": "findAll", + "httpMethod": "GET", + "path": "/email/admin/log", + "permissions": [ + "Permission.ManageEmail" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-preferences.controller.ts", + "className": "EmailPreferencesController", + "methodName": "findForUser", + "httpMethod": "GET", + "path": "/email/preferences", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-preferences.controller.ts", + "className": "EmailPreferencesController", + "methodName": "upsert", + "httpMethod": "PUT", + "path": "/email/preferences", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-provider.controller.ts", + "className": "EmailProviderController", + "methodName": "findAll", + "httpMethod": "GET", + "path": "/email/providers", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-provider.controller.ts", + "className": "EmailProviderController", + "methodName": "findOne", + "httpMethod": "GET", + "path": "/email/providers/:id", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-provider.controller.ts", + "className": "EmailProviderController", + "methodName": "create", + "httpMethod": "POST", + "path": "/email/providers", + "permissions": [ + "Permission.ManageEmail" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-provider.controller.ts", + "className": "EmailProviderController", + "methodName": "update", + "httpMethod": "PUT", + "path": "/email/providers/:id", + "permissions": [ + "Permission.ManageEmail" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-provider.controller.ts", + "className": "EmailProviderController", + "methodName": "remove", + "httpMethod": "DELETE", + "path": "/email/providers/:id", + "permissions": [ + "Permission.ManageEmail" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-provider.controller.ts", + "className": "EmailProviderController", + "methodName": "setDefault", + "httpMethod": "PATCH", + "path": "/email/providers/:id/default", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-provider.controller.ts", + "className": "EmailProviderController", + "methodName": "toggleShared", + "httpMethod": "PATCH", + "path": "/email/providers/:id/share", + "permissions": [ + "Permission.ManageEmail" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-provider.controller.ts", + "className": "EmailProviderController", + "methodName": "testConnection", + "httpMethod": "POST", + "path": "/email/providers/:id/test", + "permissions": [ + "Permission.ManageEmail" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-provider.controller.ts", + "className": "EmailProviderController", + "methodName": "setSystemProvider", + "httpMethod": "PATCH", + "path": "/email/providers/:id/system", + "permissions": [ + "Permission.ManageEmail" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-provider.controller.ts", + "className": "EmailProviderController", + "methodName": "clearSystemProvider", + "httpMethod": "DELETE", + "path": "/email/providers/system", + "permissions": [ + "Permission.ManageEmail" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-recipient-group.controller.ts", + "className": "EmailRecipientGroupController", + "methodName": "findAll", + "httpMethod": "GET", + "path": "/email/recipient-groups", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-recipient-group.controller.ts", + "className": "EmailRecipientGroupController", + "methodName": "findOne", + "httpMethod": "GET", + "path": "/email/recipient-groups/:id", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-recipient-group.controller.ts", + "className": "EmailRecipientGroupController", + "methodName": "create", + "httpMethod": "POST", + "path": "/email/recipient-groups", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-recipient-group.controller.ts", + "className": "EmailRecipientGroupController", + "methodName": "update", + "httpMethod": "PUT", + "path": "/email/recipient-groups/:id", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-recipient-group.controller.ts", + "className": "EmailRecipientGroupController", + "methodName": "remove", + "httpMethod": "DELETE", + "path": "/email/recipient-groups/:id", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-recipient-group.controller.ts", + "className": "EmailRecipientGroupController", + "methodName": "addMember", + "httpMethod": "POST", + "path": "/email/recipient-groups/:id/members", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-recipient-group.controller.ts", + "className": "EmailRecipientGroupController", + "methodName": "removeMember", + "httpMethod": "DELETE", + "path": "/email/recipient-groups/:id/members/:recipientId", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-recipient.controller.ts", + "className": "EmailRecipientController", + "methodName": "findAll", + "httpMethod": "GET", + "path": "/email/recipients", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-recipient.controller.ts", + "className": "EmailRecipientController", + "methodName": "findOne", + "httpMethod": "GET", + "path": "/email/recipients/:id", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-recipient.controller.ts", + "className": "EmailRecipientController", + "methodName": "create", + "httpMethod": "POST", + "path": "/email/recipients", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-recipient.controller.ts", + "className": "EmailRecipientController", + "methodName": "update", + "httpMethod": "PUT", + "path": "/email/recipients/:id", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-recipient.controller.ts", + "className": "EmailRecipientController", + "methodName": "remove", + "httpMethod": "DELETE", + "path": "/email/recipients/:id", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-recipient.controller.ts", + "className": "EmailRecipientController", + "methodName": "setDefault", + "httpMethod": "PATCH", + "path": "/email/recipients/:id/default", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-send-log.controller.ts", + "className": "EmailSendLogController", + "methodName": "findForUser", + "httpMethod": "GET", + "path": "/email/log", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-send-log.controller.ts", + "className": "EmailSendLogController", + "methodName": "remove", + "httpMethod": "DELETE", + "path": "/email/log/:id", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-send-log.controller.ts", + "className": "EmailSendLogController", + "methodName": "resend", + "httpMethod": "POST", + "path": "/email/log/:id/resend", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-send.controller.ts", + "className": "EmailSendController", + "methodName": "send", + "httpMethod": "POST", + "path": "/email/send", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-send.controller.ts", + "className": "EmailSendController", + "methodName": "quickSend", + "httpMethod": "POST", + "path": "/email/send/quick/:bookId", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-template.controller.ts", + "className": "EmailTemplateController", + "methodName": "findAll", + "httpMethod": "GET", + "path": "/email/templates", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + } +] diff --git a/server/test/e2e/authorization-matrix/route-inventory/routes-04.json b/server/test/e2e/authorization-matrix/route-inventory/routes-04.json new file mode 100644 index 000000000..db3ffd39f --- /dev/null +++ b/server/test/e2e/authorization-matrix/route-inventory/routes-04.json @@ -0,0 +1,686 @@ +[ + { + "file": "server/src/modules/email/email-template.controller.ts", + "className": "EmailTemplateController", + "methodName": "findOne", + "httpMethod": "GET", + "path": "/email/templates/:id", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-template.controller.ts", + "className": "EmailTemplateController", + "methodName": "create", + "httpMethod": "POST", + "path": "/email/templates", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-template.controller.ts", + "className": "EmailTemplateController", + "methodName": "update", + "httpMethod": "PUT", + "path": "/email/templates/:id", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-template.controller.ts", + "className": "EmailTemplateController", + "methodName": "remove", + "httpMethod": "DELETE", + "path": "/email/templates/:id", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-template.controller.ts", + "className": "EmailTemplateController", + "methodName": "setDefault", + "httpMethod": "PATCH", + "path": "/email/templates/:id/default", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/email/email-template.controller.ts", + "className": "EmailTemplateController", + "methodName": "preview", + "httpMethod": "POST", + "path": "/email/templates/:id/preview", + "permissions": [ + "Permission.EmailSend" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/health/health.controller.ts", + "className": "HealthController", + "methodName": "check", + "httpMethod": "GET", + "path": "/", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/kobo/kobo-auth.controller.ts", + "className": "KoboAuthController", + "methodName": "authDevice", + "httpMethod": "POST", + "path": "/kobo/:deviceToken/v1/auth/device", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-auth.controller.ts", + "className": "KoboAuthController", + "methodName": "authRefresh", + "httpMethod": "POST", + "path": "/kobo/:deviceToken/v1/auth/refresh", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-device.controller.ts", + "className": "KoboDeviceController", + "methodName": "thumbnailFull", + "httpMethod": "GET", + "path": "/kobo/:deviceToken/v1/books/:bookId/thumbnail/:width/:height/:quality/:isGreyscale/image.jpg", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-device.controller.ts", + "className": "KoboDeviceController", + "methodName": "thumbnailSimple", + "httpMethod": "GET", + "path": "/kobo/:deviceToken/v1/books/:bookId/thumbnail/:width/:height/false/image.jpg", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-device.controller.ts", + "className": "KoboDeviceController", + "methodName": "thumbnailVersioned", + "httpMethod": "GET", + "path": "/kobo/:deviceToken/v1/books/:bookId/:version/thumbnail/:width/:height/false/image.jpg", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-device.controller.ts", + "className": "KoboDeviceController", + "methodName": "download", + "httpMethod": "GET", + "path": "/kobo/:deviceToken/v1/books/:bookId/download", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-device.controller.ts", + "className": "KoboDeviceController", + "methodName": "affiliate", + "httpMethod": "GET", + "path": "/kobo/:deviceToken/v1/affiliate", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-device.controller.ts", + "className": "KoboDeviceController", + "methodName": "remainingBookSeries", + "httpMethod": "GET", + "path": "/kobo/:deviceToken/v1/products/books/series/:seriesId", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-device.controller.ts", + "className": "KoboDeviceController", + "methodName": "productNextRead", + "httpMethod": "GET", + "path": "/kobo/:deviceToken/v1/products/:productId/nextread", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-device.controller.ts", + "className": "KoboDeviceController", + "methodName": "getTests", + "httpMethod": "POST", + "path": "/kobo/:deviceToken/v1/analytics/gettests", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-device.controller.ts", + "className": "KoboDeviceController", + "methodName": "analyticsEvent", + "httpMethod": "POST", + "path": "/kobo/:deviceToken/v1/analytics/event", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-device.controller.ts", + "className": "KoboDeviceController", + "methodName": "proxy", + "httpMethod": "ALL", + "path": "/kobo/:deviceToken/*", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-sync.controller.ts", + "className": "KoboSyncController", + "methodName": "initialization", + "httpMethod": "GET", + "path": "/kobo/:deviceToken/v1/initialization", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-sync.controller.ts", + "className": "KoboSyncController", + "methodName": "librarySync", + "httpMethod": "GET", + "path": "/kobo/:deviceToken/v1/library/sync", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-sync.controller.ts", + "className": "KoboSyncController", + "methodName": "getBookMetadata", + "httpMethod": "GET", + "path": "/kobo/:deviceToken/v1/library/:bookId/metadata", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-sync.controller.ts", + "className": "KoboSyncController", + "methodName": "deleteFromLibrary", + "httpMethod": "DELETE", + "path": "/kobo/:deviceToken/v1/library/:bookId", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-sync.controller.ts", + "className": "KoboSyncController", + "methodName": "getReadingState", + "httpMethod": "GET", + "path": "/kobo/:deviceToken/v1/library/:bookId/state", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-sync.controller.ts", + "className": "KoboSyncController", + "methodName": "updateReadingState", + "httpMethod": "PUT", + "path": "/kobo/:deviceToken/v1/library/:bookId/state", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "KoboTokenGuard" + ] + }, + { + "file": "server/src/modules/kobo/kobo-user.controller.ts", + "className": "KoboUserController", + "methodName": "listDevices", + "httpMethod": "GET", + "path": "/kobo/devices", + "permissions": [ + "Permission.KoboSync" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/kobo/kobo-user.controller.ts", + "className": "KoboUserController", + "methodName": "createDevice", + "httpMethod": "POST", + "path": "/kobo/devices", + "permissions": [ + "Permission.KoboSync" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/kobo/kobo-user.controller.ts", + "className": "KoboUserController", + "methodName": "renameDevice", + "httpMethod": "PATCH", + "path": "/kobo/devices/:id", + "permissions": [ + "Permission.KoboSync" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/kobo/kobo-user.controller.ts", + "className": "KoboUserController", + "methodName": "revokeDevice", + "httpMethod": "DELETE", + "path": "/kobo/devices/:id", + "permissions": [ + "Permission.KoboSync" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/kobo/kobo-user.controller.ts", + "className": "KoboUserController", + "methodName": "getSettings", + "httpMethod": "GET", + "path": "/kobo/settings", + "permissions": [ + "Permission.KoboSync" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/kobo/kobo-user.controller.ts", + "className": "KoboUserController", + "methodName": "updateSettings", + "httpMethod": "PATCH", + "path": "/kobo/settings", + "permissions": [ + "Permission.KoboSync" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/lens/lens.controller.ts", + "className": "LensController", + "methodName": "findAll", + "httpMethod": "GET", + "path": "/lenses", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/lens/lens.controller.ts", + "className": "LensController", + "methodName": "findOne", + "httpMethod": "GET", + "path": "/lenses/:id", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/lens/lens.controller.ts", + "className": "LensController", + "methodName": "create", + "httpMethod": "POST", + "path": "/lenses", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/lens/lens.controller.ts", + "className": "LensController", + "methodName": "reorder", + "httpMethod": "POST", + "path": "/lenses/reorder", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/lens/lens.controller.ts", + "className": "LensController", + "methodName": "update", + "httpMethod": "PATCH", + "path": "/lenses/:id", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/lens/lens.controller.ts", + "className": "LensController", + "methodName": "remove", + "httpMethod": "DELETE", + "path": "/lenses/:id", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/lens/lens.controller.ts", + "className": "LensController", + "methodName": "executeLens", + "httpMethod": "GET", + "path": "/lenses/:id/books", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/library/library.controller.ts", + "className": "LibraryController", + "methodName": "findAll", + "httpMethod": "GET", + "path": "/libraries", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/library/library.controller.ts", + "className": "LibraryController", + "methodName": "findOne", + "httpMethod": "GET", + "path": "/libraries/:id", + "permissions": [], + "libraryAccess": [ + "'viewer'" + ], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/library/library.controller.ts", + "className": "LibraryController", + "methodName": "queryBooks", + "httpMethod": "POST", + "path": "/libraries/:id/books", + "permissions": [], + "libraryAccess": [ + "'viewer'" + ], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/library/library.controller.ts", + "className": "LibraryController", + "methodName": "create", + "httpMethod": "POST", + "path": "/libraries", + "permissions": [ + "Permission.ManageLibraries" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/library/library.controller.ts", + "className": "LibraryController", + "methodName": "update", + "httpMethod": "PATCH", + "path": "/libraries/:id", + "permissions": [ + "Permission.ManageLibraries" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/library/library.controller.ts", + "className": "LibraryController", + "methodName": "remove", + "httpMethod": "DELETE", + "path": "/libraries/:id", + "permissions": [ + "Permission.ManageLibraries" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/library/library.controller.ts", + "className": "LibraryController", + "methodName": "prescan", + "httpMethod": "POST", + "path": "/libraries/prescan", + "permissions": [ + "Permission.ManageLibraries" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/library/library.controller.ts", + "className": "LibraryController", + "methodName": "reorder", + "httpMethod": "POST", + "path": "/libraries/reorder", + "permissions": [ + "Permission.ManageLibraries" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/library/library.controller.ts", + "className": "LibraryController", + "methodName": "getStats", + "httpMethod": "GET", + "path": "/libraries/:id/stats", + "permissions": [], + "libraryAccess": [ + "'viewer'" + ], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/library/library.controller.ts", + "className": "LibraryController", + "methodName": "writeMetadataToFiles", + "httpMethod": "POST", + "path": "/libraries/:id/write-metadata-to-files", + "permissions": [ + "Permission.LibraryEditMetadata" + ], + "libraryAccess": [ + "'editor'" + ], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/library/library.controller.ts", + "className": "LibraryController", + "methodName": "getAccess", + "httpMethod": "GET", + "path": "/libraries/:libraryId/access", + "permissions": [ + "Permission.ManageLibraries" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/library/library.controller.ts", + "className": "LibraryController", + "methodName": "grantAccess", + "httpMethod": "POST", + "path": "/libraries/:libraryId/access", + "permissions": [ + "Permission.ManageLibraries" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + } +] diff --git a/server/test/e2e/authorization-matrix/route-inventory/routes-05.json b/server/test/e2e/authorization-matrix/route-inventory/routes-05.json new file mode 100644 index 000000000..95e0f5528 --- /dev/null +++ b/server/test/e2e/authorization-matrix/route-inventory/routes-05.json @@ -0,0 +1,672 @@ +[ + { + "file": "server/src/modules/library/library.controller.ts", + "className": "LibraryController", + "methodName": "updateAccess", + "httpMethod": "PATCH", + "path": "/libraries/:libraryId/access/:userId", + "permissions": [ + "Permission.ManageLibraries" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/library/library.controller.ts", + "className": "LibraryController", + "methodName": "revokeAccess", + "httpMethod": "DELETE", + "path": "/libraries/:libraryId/access/:userId", + "permissions": [ + "Permission.ManageLibraries" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-fetch/metadata-fetch.controller.ts", + "className": "MetadataFetchController", + "methodName": "listProviders", + "httpMethod": "GET", + "path": "/metadata-fetch/providers", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-fetch/metadata-fetch.controller.ts", + "className": "MetadataFetchController", + "methodName": "listProviderRuntime", + "httpMethod": "GET", + "path": "/metadata-fetch/providers/runtime", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-fetch/metadata-fetch.controller.ts", + "className": "MetadataFetchController", + "methodName": "lookup", + "httpMethod": "GET", + "path": "/metadata-fetch/lookup", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-preferences/metadata-preferences.controller.ts", + "className": "MetadataPreferencesController", + "methodName": "getGlobal", + "httpMethod": "GET", + "path": "/metadata-preferences/global", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-preferences/metadata-preferences.controller.ts", + "className": "MetadataPreferencesController", + "methodName": "setGlobal", + "httpMethod": "PUT", + "path": "/metadata-preferences/global", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-preferences/metadata-preferences.controller.ts", + "className": "MetadataPreferencesController", + "methodName": "getForLibrary", + "httpMethod": "GET", + "path": "/metadata-preferences/libraries/:id", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-preferences/metadata-preferences.controller.ts", + "className": "MetadataPreferencesController", + "methodName": "setLibraryOverrides", + "httpMethod": "PUT", + "path": "/metadata-preferences/libraries/:id", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-preferences/metadata-preferences.controller.ts", + "className": "MetadataPreferencesController", + "methodName": "resetGlobal", + "httpMethod": "DELETE", + "path": "/metadata-preferences/global", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-preferences/metadata-preferences.controller.ts", + "className": "MetadataPreferencesController", + "methodName": "resetLibraryToGlobal", + "httpMethod": "DELETE", + "path": "/metadata-preferences/libraries/:id", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-preferences/provider-config.controller.ts", + "className": "ProviderConfigController", + "methodName": "getConfig", + "httpMethod": "GET", + "path": "/metadata-preferences/providers", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-preferences/provider-config.controller.ts", + "className": "ProviderConfigController", + "methodName": "updateConfig", + "httpMethod": "PUT", + "path": "/metadata-preferences/providers", + "permissions": [ + "Permission.ManageMetadataConfig" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-score/metadata-score.controller.ts", + "className": "MetadataScoreController", + "methodName": "getWeights", + "httpMethod": "GET", + "path": "/metadata-score/weights", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-score/metadata-score.controller.ts", + "className": "MetadataScoreController", + "methodName": "updateWeights", + "httpMethod": "PATCH", + "path": "/metadata-score/weights", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-score/metadata-score.controller.ts", + "className": "MetadataScoreController", + "methodName": "recalculate", + "httpMethod": "POST", + "path": "/metadata-score/recalculate", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/metadata-score/metadata-score.controller.ts", + "className": "MetadataScoreController", + "methodName": "getRecalculationStatus", + "httpMethod": "GET", + "path": "/metadata-score/recalculate/status", + "permissions": [ + "Permission.ManageAppSettings" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/opds/opds-user.controller.ts", + "className": "OpdsUserController", + "methodName": "findAll", + "httpMethod": "GET", + "path": "/opds-users", + "permissions": [ + "Permission.OpdsAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/opds/opds-user.controller.ts", + "className": "OpdsUserController", + "methodName": "create", + "httpMethod": "POST", + "path": "/opds-users", + "permissions": [ + "Permission.OpdsAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/opds/opds-user.controller.ts", + "className": "OpdsUserController", + "methodName": "update", + "httpMethod": "PATCH", + "path": "/opds-users/:id", + "permissions": [ + "Permission.OpdsAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/opds/opds-user.controller.ts", + "className": "OpdsUserController", + "methodName": "delete", + "httpMethod": "DELETE", + "path": "/opds-users/:id", + "permissions": [ + "Permission.OpdsAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/opds/opds.controller.ts", + "className": "OpdsController", + "methodName": "root", + "httpMethod": "GET", + "path": "/opds", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "OpdsEnabledGuard, OpdsAuthGuard" + ] + }, + { + "file": "server/src/modules/opds/opds.controller.ts", + "className": "OpdsController", + "methodName": "libraries", + "httpMethod": "GET", + "path": "/opds/libraries", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "OpdsEnabledGuard, OpdsAuthGuard" + ] + }, + { + "file": "server/src/modules/opds/opds.controller.ts", + "className": "OpdsController", + "methodName": "collections", + "httpMethod": "GET", + "path": "/opds/collections", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "OpdsEnabledGuard, OpdsAuthGuard" + ] + }, + { + "file": "server/src/modules/opds/opds.controller.ts", + "className": "OpdsController", + "methodName": "lenses", + "httpMethod": "GET", + "path": "/opds/lenses", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "OpdsEnabledGuard, OpdsAuthGuard" + ] + }, + { + "file": "server/src/modules/opds/opds.controller.ts", + "className": "OpdsController", + "methodName": "authors", + "httpMethod": "GET", + "path": "/opds/authors", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "OpdsEnabledGuard, OpdsAuthGuard" + ] + }, + { + "file": "server/src/modules/opds/opds.controller.ts", + "className": "OpdsController", + "methodName": "series", + "httpMethod": "GET", + "path": "/opds/series", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "OpdsEnabledGuard, OpdsAuthGuard" + ] + }, + { + "file": "server/src/modules/opds/opds.controller.ts", + "className": "OpdsController", + "methodName": "catalog", + "httpMethod": "GET", + "path": "/opds/catalog", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "OpdsEnabledGuard, OpdsAuthGuard" + ] + }, + { + "file": "server/src/modules/opds/opds.controller.ts", + "className": "OpdsController", + "methodName": "recent", + "httpMethod": "GET", + "path": "/opds/recent", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "OpdsEnabledGuard, OpdsAuthGuard" + ] + }, + { + "file": "server/src/modules/opds/opds.controller.ts", + "className": "OpdsController", + "methodName": "surprise", + "httpMethod": "GET", + "path": "/opds/surprise", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "OpdsEnabledGuard, OpdsAuthGuard" + ] + }, + { + "file": "server/src/modules/opds/opds.controller.ts", + "className": "OpdsController", + "methodName": "searchDescription", + "httpMethod": "GET", + "path": "/opds/search.opds", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "OpdsEnabledGuard, OpdsAuthGuard" + ] + }, + { + "file": "server/src/modules/opds/opds.controller.ts", + "className": "OpdsController", + "methodName": "cover", + "httpMethod": "GET", + "path": "/opds/:bookId/cover", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "OpdsEnabledGuard, OpdsAuthGuard" + ] + }, + { + "file": "server/src/modules/opds/opds.controller.ts", + "className": "OpdsController", + "methodName": "thumbnail", + "httpMethod": "GET", + "path": "/opds/:bookId/thumbnail", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "OpdsEnabledGuard, OpdsAuthGuard" + ] + }, + { + "file": "server/src/modules/opds/opds.controller.ts", + "className": "OpdsController", + "methodName": "download", + "httpMethod": "GET", + "path": "/opds/:bookId/download", + "permissions": [], + "libraryAccess": [], + "isPublic": true, + "allowDefault": false, + "useGuards": [ + "OpdsEnabledGuard, OpdsAuthGuard" + ] + }, + { + "file": "server/src/modules/path/path.controller.ts", + "className": "PathController", + "methodName": "listDirectories", + "httpMethod": "GET", + "path": "/path", + "permissions": [ + "Permission.ManageLibraries" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/reader/cbz/cbz.controller.ts", + "className": "CbzController", + "methodName": "getPageCount", + "httpMethod": "GET", + "path": "/cbz/files/:fileId/pages", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/reader/cbz/cbz.controller.ts", + "className": "CbzController", + "methodName": "getPage", + "httpMethod": "GET", + "path": "/cbz/files/:fileId/pages/:pageIndex", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/reader/epub/epub.controller.ts", + "className": "EpubController", + "methodName": "getBookInfo", + "httpMethod": "GET", + "path": "/epub/:bookId/info", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/reader/epub/epub.controller.ts", + "className": "EpubController", + "methodName": "getFile", + "httpMethod": "GET", + "path": "/epub/:bookId/file/*", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/reader-preferences/reader-preferences.controller.ts", + "className": "ReaderPreferencesController", + "methodName": "getPreference", + "httpMethod": "GET", + "path": "/reader/preferences/:bookFileId", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/reader-preferences/reader-preferences.controller.ts", + "className": "ReaderPreferencesController", + "methodName": "upsertPreference", + "httpMethod": "PUT", + "path": "/reader/preferences/:bookFileId", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/reader-preferences/reader-preferences.controller.ts", + "className": "ReaderPreferencesController", + "methodName": "deletePreference", + "httpMethod": "DELETE", + "path": "/reader/preferences/:bookFileId", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/reader-preferences/reader-preferences.controller.ts", + "className": "ReaderPreferencesController", + "methodName": "getAllDefaults", + "httpMethod": "GET", + "path": "/reader/defaults", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/reader-preferences/reader-preferences.controller.ts", + "className": "ReaderPreferencesController", + "methodName": "upsertDefault", + "httpMethod": "PUT", + "path": "/reader/defaults/:formatGroup", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/reader-preferences/reader-preferences.controller.ts", + "className": "ReaderPreferencesController", + "methodName": "deleteDefault", + "httpMethod": "DELETE", + "path": "/reader/defaults/:formatGroup", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/reading-session/reading-session.controller.ts", + "className": "ReadingSessionController", + "methodName": "saveSession", + "httpMethod": "POST", + "path": "/books/files/:fileId/sessions", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/recommendation/recommendation.controller.ts", + "className": "RecommendationController", + "methodName": "getRecommendations", + "httpMethod": "GET", + "path": "/books/:id/recommendations", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/scanner/scanner.controller.ts", + "className": "ScannerController", + "methodName": "scan", + "httpMethod": "POST", + "path": "/scanner/libraries/:id/scan", + "permissions": [ + "Permission.ManageLibraries" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/scanner/scanner.controller.ts", + "className": "ScannerController", + "methodName": "refreshCovers", + "httpMethod": "POST", + "path": "/scanner/libraries/:id/refresh-covers", + "permissions": [ + "Permission.ManageLibraries" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "listFiles", + "httpMethod": "GET", + "path": "/staging/files", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + } +] diff --git a/server/test/e2e/authorization-matrix/route-inventory/routes-06.json b/server/test/e2e/authorization-matrix/route-inventory/routes-06.json new file mode 100644 index 000000000..336f47e00 --- /dev/null +++ b/server/test/e2e/authorization-matrix/route-inventory/routes-06.json @@ -0,0 +1,654 @@ +[ + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "getSummary", + "httpMethod": "GET", + "path": "/staging/summary", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "getStatistics", + "httpMethod": "GET", + "path": "/staging/statistics", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "getFile", + "httpMethod": "GET", + "path": "/staging/files/:id", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "getCover", + "httpMethod": "GET", + "path": "/staging/files/:id/cover", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "upload", + "httpMethod": "POST", + "path": "/staging/upload", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "updateFile", + "httpMethod": "PATCH", + "path": "/staging/files/:id", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "discardFile", + "httpMethod": "DELETE", + "path": "/staging/files/:id", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "bulkDiscard", + "httpMethod": "POST", + "path": "/staging/files/discard", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "applyFetched", + "httpMethod": "POST", + "path": "/staging/files/apply-fetched", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "retryFetch", + "httpMethod": "POST", + "path": "/staging/files/retry-fetch", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "setTarget", + "httpMethod": "POST", + "path": "/staging/files/set-target", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "selectionSummary", + "httpMethod": "POST", + "path": "/staging/files/selection-summary", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "bulkEdit", + "httpMethod": "POST", + "path": "/staging/files/bulk-edit", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "previewNames", + "httpMethod": "POST", + "path": "/staging/files/preview-names", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "finalize", + "httpMethod": "POST", + "path": "/staging/finalize", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/staging/staging.controller.ts", + "className": "StagingController", + "methodName": "rescan", + "httpMethod": "POST", + "path": "/staging/rescan", + "permissions": [ + "Permission.StagingAccess" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getFormatDistribution", + "httpMethod": "GET", + "path": "/statistics/format-distribution", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getLanguageDistribution", + "httpMethod": "GET", + "path": "/statistics/language-distribution", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getBooksAddedOverTime", + "httpMethod": "GET", + "path": "/statistics/books-added-over-time", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getStorageByFormat", + "httpMethod": "GET", + "path": "/statistics/storage-by-format", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getMetadataScoreDistribution", + "httpMethod": "GET", + "path": "/statistics/metadata-score-distribution", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getLibraryMetadataCompleteness", + "httpMethod": "GET", + "path": "/statistics/library-metadata-completeness", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getFormatShareOverTime", + "httpMethod": "GET", + "path": "/statistics/format-share-over-time", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getPageCountDistribution", + "httpMethod": "GET", + "path": "/statistics/page-count-distribution", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getPublicationDecade", + "httpMethod": "GET", + "path": "/statistics/publication-decade", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getPublicationYearTimeline", + "httpMethod": "GET", + "path": "/statistics/publication-year-timeline", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getTopAuthors", + "httpMethod": "GET", + "path": "/statistics/top-authors", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getMetadataCompleteness", + "httpMethod": "GET", + "path": "/statistics/metadata-completeness", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getGenreDistribution", + "httpMethod": "GET", + "path": "/statistics/genre-distribution", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getSummary", + "httpMethod": "GET", + "path": "/statistics/summary", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getGenreCooccurrence", + "httpMethod": "GET", + "path": "/statistics/genre-cooccurrence", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getMetadataFreshnessGauge", + "httpMethod": "GET", + "path": "/statistics/metadata-freshness-gauge", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getLibraryIntegrityGauge", + "httpMethod": "GET", + "path": "/statistics/library-integrity-gauge", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getAcquisitionLagScatter", + "httpMethod": "GET", + "path": "/statistics/acquisition-lag-scatter", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getLargestBooks", + "httpMethod": "GET", + "path": "/statistics/largest-books", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/statistics/statistics.controller.ts", + "className": "StatisticsController", + "methodName": "getTopSeries", + "httpMethod": "GET", + "path": "/statistics/top-series", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/upload/upload.controller.ts", + "className": "UploadController", + "methodName": "uploadBook", + "httpMethod": "POST", + "path": "/libraries/:id/upload", + "permissions": [ + "Permission.LibraryUpload" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "findAll", + "httpMethod": "GET", + "path": "/users", + "permissions": [ + "Permission.ManageUsers" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "findAssignable", + "httpMethod": "GET", + "path": "/users/assignable", + "permissions": [ + "Permission.ManageLibraries" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "updateMe", + "httpMethod": "PATCH", + "path": "/users/me", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "uploadMyAvatar", + "httpMethod": "POST", + "path": "/users/me/avatar", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "deleteMyAvatar", + "httpMethod": "DELETE", + "path": "/users/me/avatar", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "findById", + "httpMethod": "GET", + "path": "/users/:id", + "permissions": [ + "Permission.ManageUsers" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "getAvatar", + "httpMethod": "GET", + "path": "/users/:id/avatar", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "createUser", + "httpMethod": "POST", + "path": "/users", + "permissions": [ + "Permission.ManageUsers" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "updateUser", + "httpMethod": "PATCH", + "path": "/users/:id", + "permissions": [ + "Permission.ManageUsers" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "deleteUser", + "httpMethod": "DELETE", + "path": "/users/:id", + "permissions": [ + "Permission.ManageUsers" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "setPermissions", + "httpMethod": "PUT", + "path": "/users/:id/permissions", + "permissions": [ + "Permission.ManageUsers" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "setSuperuser", + "httpMethod": "PUT", + "path": "/users/:id/superuser", + "permissions": [ + "Permission.ManageUsers" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "getLibraries", + "httpMethod": "GET", + "path": "/users/:id/libraries", + "permissions": [ + "Permission.ManageUsers" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + } +] diff --git a/server/test/e2e/authorization-matrix/route-inventory/routes-07.json b/server/test/e2e/authorization-matrix/route-inventory/routes-07.json new file mode 100644 index 000000000..df31c8d14 --- /dev/null +++ b/server/test/e2e/authorization-matrix/route-inventory/routes-07.json @@ -0,0 +1,234 @@ +[ + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "setLibraries", + "httpMethod": "PUT", + "path": "/users/:id/libraries", + "permissions": [ + "Permission.ManageUsers" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user/user.controller.ts", + "className": "UserController", + "methodName": "adminResetPassword", + "httpMethod": "POST", + "path": "/users/:id/reset-password", + "permissions": [ + "Permission.ManageUsers" + ], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getSummary", + "httpMethod": "GET", + "path": "/user-statistics/summary", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getDailyReading", + "httpMethod": "GET", + "path": "/user-statistics/daily-reading", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getReadingHeatmap", + "httpMethod": "GET", + "path": "/user-statistics/reading-heatmap", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getPeakHours", + "httpMethod": "GET", + "path": "/user-statistics/peak-hours", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getFavoriteDays", + "httpMethod": "GET", + "path": "/user-statistics/favorite-days", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getSessionTimeline", + "httpMethod": "GET", + "path": "/user-statistics/session-timeline", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "updateSessionTimelineSession", + "httpMethod": "PATCH", + "path": "/user-statistics/session-timeline/:sessionId", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getCompletionTimeline", + "httpMethod": "GET", + "path": "/user-statistics/completion-timeline", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getGoalTrajectory", + "httpMethod": "GET", + "path": "/user-statistics/goal-trajectory", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getProgressFunnel", + "httpMethod": "GET", + "path": "/user-statistics/progress-funnel", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getCompletionLatency", + "httpMethod": "GET", + "path": "/user-statistics/completion-latency", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getGenreReadingTime", + "httpMethod": "GET", + "path": "/user-statistics/genre-reading-time", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getReadingPace", + "httpMethod": "GET", + "path": "/user-statistics/reading-pace", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getReadingSurvival", + "httpMethod": "GET", + "path": "/user-statistics/reading-survival", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getCompletionRace", + "httpMethod": "GET", + "path": "/user-statistics/completion-race", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getSessionArchetypes", + "httpMethod": "GET", + "path": "/user-statistics/session-archetypes", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/user-statistics/user-statistics.controller.ts", + "className": "UserStatisticsController", + "methodName": "getAuthorGenreChord", + "httpMethod": "GET", + "path": "/user-statistics/author-genre-chord", + "permissions": [], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + } +]