mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(scanner): enhance upload durability and add E2E test suites
This commit is contained in:
+2
-2
@@ -7,8 +7,8 @@
|
||||
"dev:client": "cd client && npm run dev -- --host",
|
||||
"dev:server": "cd server && npm run start:dev",
|
||||
"verify:fast": "pnpm run lint:check && pnpm run typecheck && pnpm run test",
|
||||
"verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test && pnpm run e2e:run -- app-smoke",
|
||||
"verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test && pnpm run e2e:run -- app-smoke",
|
||||
"verify": "pnpm run lint:check && pnpm run typecheck && pnpm run test",
|
||||
"verify:strict": "pnpm run format:check && pnpm run lint:check && pnpm run typecheck:full && pnpm run test",
|
||||
"build:client": "pnpm --filter client build",
|
||||
"build:server": "pnpm --filter server build",
|
||||
"typecheck": "pnpm run typecheck:server && pnpm run typecheck:client",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
const TEST_RESULTS_DIR = "../test-results/server";
|
||||
|
||||
export const E2E_SUITES = Object.freeze({
|
||||
"app-smoke": {
|
||||
id: "app-smoke",
|
||||
name: "App Smoke",
|
||||
"guard-mechanics": {
|
||||
id: "guard-mechanics",
|
||||
name: "Guard Mechanics",
|
||||
timeout: 30,
|
||||
description: "App smoke suite",
|
||||
vitestTarget: "test/app-smoke.e2e-spec.ts",
|
||||
junitOutput: `${TEST_RESULTS_DIR}/app-smoke-e2e-junit.xml`,
|
||||
description: "Guard mechanics suite - verifies @Public/@RequirePermission wiring without a real DB",
|
||||
vitestTarget: "test/guard-mechanics.e2e-spec.ts",
|
||||
junitOutput: `${TEST_RESULTS_DIR}/guard-mechanics-e2e-junit.xml`,
|
||||
prepareDedicatedDatabase: false,
|
||||
useDedicatedDatabase: false,
|
||||
},
|
||||
@@ -101,6 +101,46 @@ export const E2E_SUITES = Object.freeze({
|
||||
prepareDedicatedDatabase: true,
|
||||
useDedicatedDatabase: true,
|
||||
},
|
||||
"book-api-contract": {
|
||||
id: "book-api-contract",
|
||||
name: "Book API Contract",
|
||||
timeout: 60,
|
||||
description: "Book API contract suite",
|
||||
vitestTarget: "test/book-api-contract.e2e-spec.ts",
|
||||
junitOutput: `${TEST_RESULTS_DIR}/book-api-contract-e2e-junit.xml`,
|
||||
prepareDedicatedDatabase: true,
|
||||
useDedicatedDatabase: true,
|
||||
},
|
||||
"app-settings-oidc-contract": {
|
||||
id: "app-settings-oidc-contract",
|
||||
name: "App Settings OIDC Contract",
|
||||
timeout: 60,
|
||||
description: "App settings OIDC contract suite",
|
||||
vitestTarget: "test/app-settings-oidc-contract.e2e-spec.ts",
|
||||
junitOutput: `${TEST_RESULTS_DIR}/app-settings-oidc-contract-e2e-junit.xml`,
|
||||
prepareDedicatedDatabase: true,
|
||||
useDedicatedDatabase: true,
|
||||
},
|
||||
"library-admin-workflows": {
|
||||
id: "library-admin-workflows",
|
||||
name: "Library Admin Workflows",
|
||||
timeout: 60,
|
||||
description: "Library admin workflows suite",
|
||||
vitestTarget: "test/library-admin-workflows.e2e-spec.ts",
|
||||
junitOutput: `${TEST_RESULTS_DIR}/library-admin-workflows-e2e-junit.xml`,
|
||||
prepareDedicatedDatabase: true,
|
||||
useDedicatedDatabase: true,
|
||||
},
|
||||
"reader-format-delivery": {
|
||||
id: "reader-format-delivery",
|
||||
name: "Reader Format Delivery",
|
||||
timeout: 60,
|
||||
description: "Reader format delivery suite",
|
||||
vitestTarget: "test/reader-format-delivery.e2e-spec.ts",
|
||||
junitOutput: `${TEST_RESULTS_DIR}/reader-format-delivery-e2e-junit.xml`,
|
||||
prepareDedicatedDatabase: true,
|
||||
useDedicatedDatabase: true,
|
||||
},
|
||||
"opds-auth-catalog": {
|
||||
id: "opds-auth-catalog",
|
||||
name: "OPDS Auth Catalog",
|
||||
|
||||
@@ -303,6 +303,9 @@ export class EpubService {
|
||||
|
||||
const epubPath = await this.resolveEpubPath(bookId, fileId, user);
|
||||
const cached = await this.getCachedEntry(epubPath);
|
||||
if (!cached.validPaths.has(normalizedPath)) {
|
||||
throw new NotFoundException(`Entry not in archive: ${normalizedPath}`);
|
||||
}
|
||||
const zip = await unzipper.Open.file(epubPath);
|
||||
const entry = findInZip(zip.files, normalizedPath);
|
||||
if (!entry) throw new NotFoundException(`Entry not in archive: ${normalizedPath}`);
|
||||
@@ -343,6 +346,7 @@ export class EpubService {
|
||||
|
||||
const validPaths = new Set<string>(['META-INF/container.xml', normalizeZipPath(info.containerPath)]);
|
||||
for (const item of info.manifest) validPaths.add(normalizeZipPath(item.href));
|
||||
for (const path of info.optionalFiles ?? []) validPaths.add(normalizeZipPath(path));
|
||||
|
||||
const entry: CacheEntry = { info, mtime: mtimeMs, validPaths, lastAccessed: Date.now() };
|
||||
this.evict();
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('UploadService', () => {
|
||||
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book/book.epub');
|
||||
});
|
||||
|
||||
it('throws ConflictException when destination already exists and cleans both temp and destination paths', async () => {
|
||||
it('throws ConflictException when destination already exists and cleans temp path', async () => {
|
||||
db.select
|
||||
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }]))
|
||||
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
|
||||
@@ -142,7 +142,7 @@ describe('UploadService', () => {
|
||||
|
||||
await expect(service.upload(1, 2, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
|
||||
expect(storage.cleanup).toHaveBeenCalledWith('/library/book/book.epub');
|
||||
expect(storage.cleanup).not.toHaveBeenCalledWith('/library/book/book.epub');
|
||||
expect(storage.moveToPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { LibraryService } from '../library/library.service';
|
||||
import { UploadValidatorService } from './upload-validator.service';
|
||||
import { UploadStorageService } from './upload-storage.service';
|
||||
import { UploadProcessorService } from './upload-processor.service';
|
||||
import { resolveUploadPath } from '@projectx/types';
|
||||
import { resolveDownloadFilename, resolveUploadPath } from '@projectx/types';
|
||||
import type { UploadResult } from '@projectx/types';
|
||||
import { extractEpubMetadata } from '../metadata/lib/epub';
|
||||
import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata';
|
||||
@@ -54,18 +54,20 @@ export class UploadService {
|
||||
|
||||
const { tempPath, sizeBytes } = await this.storage.streamToTemp(fileStream);
|
||||
let destinationPath: string | null = null;
|
||||
let shouldCleanupDestination = false;
|
||||
|
||||
try {
|
||||
const { absolutePath, folderPath, relPath } = await this.resolveDestination(library, folder.path, tempPath, filename, format);
|
||||
const { absolutePath, bookFolderPath, relPath } = await this.resolveDestination(library, folder.path, tempPath, filename, format);
|
||||
destinationPath = absolutePath;
|
||||
|
||||
if (await this.destinationExists(absolutePath)) {
|
||||
throw new ConflictException(`A file named "${basename(absolutePath)}" already exists at the target location`);
|
||||
}
|
||||
|
||||
shouldCleanupDestination = true;
|
||||
await this.storage.moveToPath(tempPath, absolutePath);
|
||||
|
||||
const { bookId } = await this.processor.createBookRecord(libraryId, folder.id, folderPath, absolutePath, relPath, format, sizeBytes);
|
||||
const { bookId } = await this.processor.createBookRecord(libraryId, folder.id, bookFolderPath, absolutePath, relPath, format, sizeBytes);
|
||||
|
||||
this.processor.extractMetadataAsync(bookId, absolutePath, format);
|
||||
|
||||
@@ -78,38 +80,62 @@ export class UploadService {
|
||||
this.logger.error(
|
||||
`[${event}] [fail] libraryId=${libraryId} userId=${user.id} folderId=${folder.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - upload failed`,
|
||||
);
|
||||
await Promise.allSettled([this.storage.cleanup(tempPath), destinationPath ? this.storage.cleanup(destinationPath) : Promise.resolve()]);
|
||||
await Promise.allSettled([
|
||||
this.storage.cleanup(tempPath),
|
||||
shouldCleanupDestination && destinationPath ? this.storage.cleanup(destinationPath) : Promise.resolve(),
|
||||
]);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveDestination(
|
||||
library: { fileNamingPattern?: string | null },
|
||||
library: { fileNamingPattern?: string | null; organizationMode?: string | null },
|
||||
libraryFolderPath: string,
|
||||
tempPath: string,
|
||||
filename: string,
|
||||
format: string,
|
||||
): Promise<{ absolutePath: string; folderPath: string; relPath: string }> {
|
||||
): Promise<{ absolutePath: string; bookFolderPath: string; relPath: string }> {
|
||||
const pattern = library.fileNamingPattern ?? (await this.appSettings.getUploadPattern());
|
||||
|
||||
if (pattern) {
|
||||
const stem = basename(filename, extname(filename));
|
||||
const tokens = await this.buildPatternTokens(tempPath, format, stem);
|
||||
const resolved = resolveUploadPath(pattern, tokens, format);
|
||||
if (library.organizationMode === 'book_per_file') {
|
||||
const resolvedFilename = resolveDownloadFilename(pattern, tokens, format);
|
||||
if (resolvedFilename) {
|
||||
const absolutePath = join(libraryFolderPath, resolvedFilename);
|
||||
return {
|
||||
absolutePath,
|
||||
bookFolderPath: absolutePath,
|
||||
relPath: resolvedFilename,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const resolved = resolveUploadPath(pattern, tokens, format);
|
||||
|
||||
if (resolved) {
|
||||
const absolutePath = join(libraryFolderPath, resolved);
|
||||
const folderPath = dirname(absolutePath);
|
||||
const relPath = resolved;
|
||||
return { absolutePath, folderPath, relPath };
|
||||
if (resolved) {
|
||||
const absolutePath = join(libraryFolderPath, resolved);
|
||||
const relPath = resolved;
|
||||
const bookFolderPath = dirname(absolutePath);
|
||||
return { absolutePath, bookFolderPath, relPath };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (library.organizationMode === 'book_per_file') {
|
||||
const absolutePath = join(libraryFolderPath, filename);
|
||||
return {
|
||||
absolutePath,
|
||||
bookFolderPath: absolutePath,
|
||||
relPath: filename,
|
||||
};
|
||||
}
|
||||
|
||||
const stem = basename(filename, extname(filename));
|
||||
const folderPath = join(libraryFolderPath, stem);
|
||||
const absolutePath = join(folderPath, filename);
|
||||
const bookFolderPath = join(libraryFolderPath, stem);
|
||||
const absolutePath = join(bookFolderPath, filename);
|
||||
const relPath = join(stem, filename);
|
||||
return { absolutePath, folderPath, relPath };
|
||||
return { absolutePath, bookFolderPath, relPath };
|
||||
}
|
||||
|
||||
private async buildPatternTokens(tempPath: string, format: string, stem: string): Promise<Record<string, string>> {
|
||||
|
||||
@@ -0,0 +1,833 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { and, eq, gt, isNull } from 'drizzle-orm';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { Permission } from '@projectx/types';
|
||||
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { GlobalExceptionFilter } from '../src/common/filters/http-exception.filter';
|
||||
import { DB } from '../src/db';
|
||||
import * as schema from '../src/db/schema';
|
||||
import { MetadataService } from '../src/modules/metadata/metadata.service';
|
||||
import { OidcDiscoveryService } from '../src/modules/auth/oidc/oidc-discovery.service';
|
||||
import { OidcTokenClientService } from '../src/modules/auth/oidc/oidc-token-client.service';
|
||||
import { OidcTokenValidatorService } from '../src/modules/auth/oidc/oidc-token-validator.service';
|
||||
import { makeMetadataNoopMock, type Db } from './e2e/app-harness';
|
||||
|
||||
type CookieJar = Map<string, string>;
|
||||
type InjectResponse = Awaited<ReturnType<NestFastifyApplication['inject']>>;
|
||||
|
||||
const ADMIN_SETUP_DTO = {
|
||||
username: 'app-settings-oidc-e2e-admin',
|
||||
name: 'App Settings OIDC E2E Admin',
|
||||
email: 'app-settings-oidc-e2e-admin@example.com',
|
||||
password: 'AppSettingsOidcAdmin123',
|
||||
};
|
||||
|
||||
const SCENARIO_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_ISSUER = 'https://issuer.example';
|
||||
|
||||
interface OidcContractContext {
|
||||
app: NestFastifyApplication;
|
||||
db: Db;
|
||||
adminToken: string;
|
||||
fetchMock: ReturnType<typeof vi.spyOn<typeof globalThis, 'fetch'>>;
|
||||
oidcDiscoveryMock: {
|
||||
getDiscoveryDoc: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
oidcTokenClientMock: {
|
||||
exchangeCode: ReturnType<typeof vi.fn>;
|
||||
fetchUserInfo: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
oidcTokenValidatorMock: {
|
||||
validateIdToken: ReturnType<typeof vi.fn>;
|
||||
validateLogoutToken: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
type OidcConfigInput = {
|
||||
enabled: boolean;
|
||||
providerName: string;
|
||||
issuerUri: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scopes: string;
|
||||
claimMapping: {
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
groups: string;
|
||||
};
|
||||
autoProvision: {
|
||||
enabled: boolean;
|
||||
allowLocalLinking: boolean;
|
||||
defaultPermissionNames: string[];
|
||||
};
|
||||
};
|
||||
|
||||
interface LocalUserCredentials {
|
||||
userId: number;
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
userId: number;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
jar: CookieJar;
|
||||
}
|
||||
|
||||
function authHeader(token: string): Record<string, string> {
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function buildDiscoveryDoc(issuerUri = DEFAULT_ISSUER) {
|
||||
const normalized = issuerUri.replace(/\/$/, '');
|
||||
return {
|
||||
issuer: normalized,
|
||||
authorizationEndpoint: `${normalized}/auth`,
|
||||
tokenEndpoint: `${normalized}/token`,
|
||||
jwksUri: `${normalized}/jwks`,
|
||||
userinfoEndpoint: `${normalized}/userinfo`,
|
||||
endSessionEndpoint: `${normalized}/logout`,
|
||||
backchannelLogoutSupported: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDiscoveryResponse(issuerUri = DEFAULT_ISSUER) {
|
||||
const normalized = issuerUri.replace(/\/$/, '');
|
||||
return {
|
||||
issuer: normalized,
|
||||
authorization_endpoint: `${normalized}/auth`,
|
||||
token_endpoint: `${normalized}/token`,
|
||||
jwks_uri: `${normalized}/jwks`,
|
||||
userinfo_endpoint: `${normalized}/userinfo`,
|
||||
end_session_endpoint: `${normalized}/logout`,
|
||||
backchannel_logout_supported: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOidcConfig(overrides: Partial<OidcConfigInput> = {}): OidcConfigInput {
|
||||
return {
|
||||
enabled: true,
|
||||
providerName: 'Example OIDC',
|
||||
issuerUri: DEFAULT_ISSUER,
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
scopes: 'openid profile email',
|
||||
claimMapping: {
|
||||
username: 'preferred_username',
|
||||
name: 'name',
|
||||
email: 'email',
|
||||
groups: 'groups',
|
||||
...(overrides.claimMapping ?? {}),
|
||||
},
|
||||
autoProvision: {
|
||||
enabled: true,
|
||||
allowLocalLinking: true,
|
||||
defaultPermissionNames: [],
|
||||
...(overrides.autoProvision ?? {}),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function responseMessage(response: { message?: string | string[] }): string {
|
||||
if (Array.isArray(response.message)) return response.message.join(' ');
|
||||
return String(response.message ?? '');
|
||||
}
|
||||
|
||||
function expectError(response: InjectResponse, status: number, messageFragment?: string): void {
|
||||
expect(response.statusCode).toBe(status);
|
||||
if (!messageFragment) return;
|
||||
expect(responseMessage(response.json() as { message?: string | string[] })).toContain(messageFragment);
|
||||
}
|
||||
|
||||
function getSetCookieLines(headers: Record<string, string | string[] | undefined>): string[] {
|
||||
const raw = headers['set-cookie'];
|
||||
if (!raw) return [];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
}
|
||||
|
||||
function parseCookiePair(setCookieLine: string): { name: string; value: string } | null {
|
||||
const firstSegment = setCookieLine.split(';', 1)[0];
|
||||
if (!firstSegment) return null;
|
||||
const eqIndex = firstSegment.indexOf('=');
|
||||
if (eqIndex <= 0) return null;
|
||||
return {
|
||||
name: firstSegment.slice(0, eqIndex),
|
||||
value: firstSegment.slice(eqIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
function findCookieLine(setCookieLines: string[], cookieName: string): string | undefined {
|
||||
return setCookieLines.find((line) => line.startsWith(`${cookieName}=`));
|
||||
}
|
||||
|
||||
function cookieValue(setCookieLines: string[], cookieName: string): string | null {
|
||||
const line = findCookieLine(setCookieLines, cookieName);
|
||||
if (!line) return null;
|
||||
return parseCookiePair(line)?.value ?? null;
|
||||
}
|
||||
|
||||
function cookieAttribute(setCookieLine: string, attribute: string): string | null {
|
||||
const segments = setCookieLine.split(';').map((segment) => segment.trim());
|
||||
const target = attribute.toLowerCase();
|
||||
for (const segment of segments.slice(1)) {
|
||||
const [key, ...rest] = segment.split('=');
|
||||
if (key.toLowerCase() !== target) continue;
|
||||
return rest.length > 0 ? rest.join('=') : '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeCookieJar(jar: CookieJar, setCookieLines: string[]): void {
|
||||
for (const line of setCookieLines) {
|
||||
const parsed = parseCookiePair(line);
|
||||
if (!parsed) continue;
|
||||
if (parsed.value === '') {
|
||||
jar.delete(parsed.name);
|
||||
} else {
|
||||
jar.set(parsed.name, parsed.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mockDiscoveryFetch(input: string | URL | Request): Promise<Response> {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.endsWith('/.well-known/openid-configuration')) {
|
||||
const issuerUri = url.slice(0, -'/.well-known/openid-configuration'.length);
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(buildDiscoveryResponse(issuerUri)), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected fetch call: ${url}`));
|
||||
}
|
||||
|
||||
async function createOidcContractContext(): Promise<OidcContractContext> {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(mockDiscoveryFetch);
|
||||
|
||||
const oidcDiscoveryMock = {
|
||||
getDiscoveryDoc: vi.fn().mockResolvedValue(buildDiscoveryDoc()),
|
||||
};
|
||||
const oidcTokenClientMock = {
|
||||
exchangeCode: vi.fn().mockResolvedValue({ accessToken: 'oidc-access-token', idToken: 'oidc-id-token' }),
|
||||
fetchUserInfo: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
const oidcTokenValidatorMock = {
|
||||
validateIdToken: vi.fn().mockResolvedValue({
|
||||
sub: 'oidc-default-subject',
|
||||
sid: 'oidc-default-sid',
|
||||
preferred_username: 'oidc-default-user',
|
||||
name: 'OIDC Default User',
|
||||
email: 'oidc-default-user@example.com',
|
||||
}),
|
||||
validateLogoutToken: vi.fn().mockResolvedValue({
|
||||
sub: 'oidc-default-subject',
|
||||
sid: 'oidc-default-sid',
|
||||
jti: `logout-${randomUUID()}`,
|
||||
}),
|
||||
};
|
||||
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
})
|
||||
.overrideProvider(MetadataService)
|
||||
.useValue(makeMetadataNoopMock())
|
||||
.overrideProvider(OidcDiscoveryService)
|
||||
.useValue(oidcDiscoveryMock)
|
||||
.overrideProvider(OidcTokenClientService)
|
||||
.useValue(oidcTokenClientMock)
|
||||
.overrideProvider(OidcTokenValidatorService)
|
||||
.useValue(oidcTokenValidatorMock)
|
||||
.compile();
|
||||
|
||||
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||
await app.register(fastifyCookie as never);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
|
||||
const setupResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/setup',
|
||||
payload: ADMIN_SETUP_DTO,
|
||||
});
|
||||
expect(setupResponse.statusCode).toBe(201);
|
||||
const setupBody = setupResponse.json() as { accessToken: string };
|
||||
|
||||
const db = app.get<Db>(DB);
|
||||
return {
|
||||
app,
|
||||
db,
|
||||
adminToken: setupBody.accessToken,
|
||||
fetchMock,
|
||||
oidcDiscoveryMock,
|
||||
oidcTokenClientMock,
|
||||
oidcTokenValidatorMock,
|
||||
};
|
||||
}
|
||||
|
||||
async function createLocalUser(
|
||||
db: Db,
|
||||
options: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
active?: boolean;
|
||||
email?: string;
|
||||
} = {},
|
||||
): Promise<LocalUserCredentials> {
|
||||
const suffix = randomUUID().replace(/-/g, '');
|
||||
const username = options.username ?? `oidc-contract-${suffix}`;
|
||||
const password = options.password ?? 'OidcContractUser123';
|
||||
const email = options.email ?? `${username}@example.com`;
|
||||
const passwordHash = await hash(password, 12);
|
||||
|
||||
const [created] = await db
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
username,
|
||||
name: `OIDC Contract ${suffix}`,
|
||||
email,
|
||||
passwordHash,
|
||||
active: options.active ?? true,
|
||||
isDefaultPassword: false,
|
||||
provisioningMethod: 'local',
|
||||
})
|
||||
.returning({ id: schema.users.id });
|
||||
|
||||
return {
|
||||
userId: created.id,
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
async function login(app: NestFastifyApplication, username: string, password: string): Promise<LoginResult> {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/login',
|
||||
payload: { username, password },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const body = response.json() as { accessToken: string; user: { id: number } };
|
||||
const setCookieLines = getSetCookieLines(response.headers);
|
||||
const refreshToken = cookieValue(setCookieLines, 'refresh_token');
|
||||
expect(refreshToken).toBeTruthy();
|
||||
|
||||
const jar: CookieJar = new Map();
|
||||
mergeCookieJar(jar, setCookieLines);
|
||||
|
||||
return {
|
||||
userId: body.user.id,
|
||||
accessToken: body.accessToken,
|
||||
refreshToken: refreshToken!,
|
||||
jar,
|
||||
};
|
||||
}
|
||||
|
||||
async function putOidcConfig(ctx: OidcContractContext, config: OidcConfigInput): Promise<void> {
|
||||
const response = await ctx.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/v1/app-settings/oidc',
|
||||
headers: authHeader(ctx.adminToken),
|
||||
payload: config,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
}
|
||||
|
||||
async function issueState(app: NestFastifyApplication): Promise<string> {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/state',
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as { state: string };
|
||||
expect(body.state).toEqual(expect.any(String));
|
||||
return body.state;
|
||||
}
|
||||
|
||||
async function activeSessionCount(db: Db, userId: number): Promise<number> {
|
||||
const rows = await db
|
||||
.select({ id: schema.refreshTokens.id })
|
||||
.from(schema.refreshTokens)
|
||||
.where(and(eq(schema.refreshTokens.userId, userId), isNull(schema.refreshTokens.revokedAt), gt(schema.refreshTokens.expiresAt, new Date())));
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
let ctx!: OidcContractContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createOidcContractContext();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.fetchMock.mockReset();
|
||||
ctx.fetchMock.mockImplementation(mockDiscoveryFetch);
|
||||
ctx.oidcDiscoveryMock.getDiscoveryDoc.mockReset();
|
||||
ctx.oidcDiscoveryMock.getDiscoveryDoc.mockResolvedValue(buildDiscoveryDoc());
|
||||
ctx.oidcTokenClientMock.exchangeCode.mockReset();
|
||||
ctx.oidcTokenClientMock.exchangeCode.mockResolvedValue({ accessToken: 'oidc-access-token', idToken: 'oidc-id-token' });
|
||||
ctx.oidcTokenClientMock.fetchUserInfo.mockReset();
|
||||
ctx.oidcTokenClientMock.fetchUserInfo.mockResolvedValue({});
|
||||
ctx.oidcTokenValidatorMock.validateIdToken.mockReset();
|
||||
ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValue({
|
||||
sub: 'oidc-default-subject',
|
||||
sid: 'oidc-default-sid',
|
||||
preferred_username: 'oidc-default-user',
|
||||
name: 'OIDC Default User',
|
||||
email: 'oidc-default-user@example.com',
|
||||
});
|
||||
ctx.oidcTokenValidatorMock.validateLogoutToken.mockReset();
|
||||
ctx.oidcTokenValidatorMock.validateLogoutToken.mockResolvedValue({
|
||||
sub: 'oidc-default-subject',
|
||||
sid: 'oidc-default-sid',
|
||||
jti: `logout-${randomUUID()}`,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
ctx.fetchMock.mockRestore();
|
||||
await ctx.app.close();
|
||||
});
|
||||
|
||||
describe('OIDC app-settings contract', () => {
|
||||
it('stores admin config, exposes the public subset, masks the secret, and tests discovery', async () => {
|
||||
const limitedUser = await createLocalUser(ctx.db);
|
||||
const limitedSession = await login(ctx.app, limitedUser.username, limitedUser.password);
|
||||
const config = buildOidcConfig({
|
||||
providerName: 'Contract OIDC',
|
||||
autoProvision: {
|
||||
enabled: true,
|
||||
allowLocalLinking: true,
|
||||
defaultPermissionNames: [Permission.KoboSync],
|
||||
},
|
||||
});
|
||||
|
||||
await putOidcConfig(ctx, config);
|
||||
|
||||
const publicResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/app-settings/oidc/public',
|
||||
});
|
||||
expect(publicResponse.statusCode).toBe(200);
|
||||
expect(publicResponse.json()).toEqual({
|
||||
enabled: true,
|
||||
providerName: 'Contract OIDC',
|
||||
issuerUri: DEFAULT_ISSUER,
|
||||
clientId: 'client-id',
|
||||
scopes: 'openid profile email',
|
||||
});
|
||||
|
||||
const forbiddenResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/app-settings/oidc',
|
||||
headers: authHeader(limitedSession.accessToken),
|
||||
});
|
||||
expect(forbiddenResponse.statusCode).toBe(403);
|
||||
|
||||
const adminResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/app-settings/oidc',
|
||||
headers: authHeader(ctx.adminToken),
|
||||
});
|
||||
expect(adminResponse.statusCode).toBe(200);
|
||||
expect(adminResponse.json()).toEqual({
|
||||
...config,
|
||||
clientSecret: '***',
|
||||
});
|
||||
|
||||
const testResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/app-settings/oidc/test',
|
||||
headers: authHeader(ctx.adminToken),
|
||||
});
|
||||
expect(testResponse.statusCode).toBe(200);
|
||||
expect(testResponse.json()).toEqual({
|
||||
success: true,
|
||||
issuer: DEFAULT_ISSUER,
|
||||
authorizationEndpoint: `${DEFAULT_ISSUER}/auth`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC callback contract', () => {
|
||||
it('issues state, provisions a new OIDC user, sets auth cookies, and consumes state once', async () => {
|
||||
const suffix = randomUUID().replace(/-/g, '');
|
||||
const username = `oidc-provisioned-${suffix}`;
|
||||
const email = `${username}@example.com`;
|
||||
|
||||
await putOidcConfig(
|
||||
ctx,
|
||||
buildOidcConfig({
|
||||
autoProvision: {
|
||||
enabled: true,
|
||||
allowLocalLinking: false,
|
||||
defaultPermissionNames: [Permission.KoboSync],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
ctx.oidcTokenClientMock.exchangeCode.mockResolvedValueOnce({
|
||||
accessToken: 'oidc-access-provisioned',
|
||||
idToken: 'oidc-id-provisioned',
|
||||
});
|
||||
ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({
|
||||
sub: 'oidc-provisioned-subject',
|
||||
sid: 'oidc-provisioned-sid',
|
||||
preferred_username: username,
|
||||
name: 'OIDC Provisioned User',
|
||||
email,
|
||||
});
|
||||
ctx.oidcTokenClientMock.fetchUserInfo.mockResolvedValueOnce({
|
||||
picture: 'https://issuer.example/avatars/provisioned.png',
|
||||
});
|
||||
|
||||
const state = await issueState(ctx.app);
|
||||
const callbackPayload = {
|
||||
code: 'authorization-code-provisioned',
|
||||
codeVerifier: 'verifier-provisioned',
|
||||
redirectUri: 'http://localhost:5173/oauth2-callback',
|
||||
nonce: 'nonce-provisioned',
|
||||
state,
|
||||
};
|
||||
|
||||
const callbackResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/callback',
|
||||
payload: callbackPayload,
|
||||
});
|
||||
|
||||
expect(callbackResponse.statusCode).toBe(200);
|
||||
const callbackBody = callbackResponse.json() as {
|
||||
accessToken: string;
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
provisioningMethod: string;
|
||||
avatarUrl: string | null;
|
||||
permissions: string[];
|
||||
};
|
||||
};
|
||||
expect(callbackBody.user).toMatchObject({
|
||||
username,
|
||||
email,
|
||||
provisioningMethod: 'oidc',
|
||||
avatarUrl: 'https://issuer.example/avatars/provisioned.png',
|
||||
});
|
||||
expect(callbackBody.user.permissions).toContain(Permission.KoboSync);
|
||||
|
||||
const callbackCookies = getSetCookieLines(callbackResponse.headers);
|
||||
const refreshCookieLine = findCookieLine(callbackCookies, 'refresh_token');
|
||||
const accessCookieLine = findCookieLine(callbackCookies, 'access_token');
|
||||
expect(refreshCookieLine).toBeDefined();
|
||||
expect(accessCookieLine).toBeDefined();
|
||||
expect(cookieAttribute(refreshCookieLine!, 'path')).toBe('/api/v1/auth');
|
||||
expect(cookieAttribute(refreshCookieLine!, 'httponly')).toBe('');
|
||||
expect(cookieAttribute(accessCookieLine!, 'path')).toBe('/api');
|
||||
expect(cookieAttribute(accessCookieLine!, 'httponly')).toBe('');
|
||||
|
||||
const createdUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(schema.users.id, callbackBody.user.id),
|
||||
});
|
||||
expect(createdUser).toMatchObject({
|
||||
username,
|
||||
email,
|
||||
oidcSubject: 'oidc-provisioned-subject',
|
||||
oidcIssuer: DEFAULT_ISSUER,
|
||||
provisioningMethod: 'oidc',
|
||||
});
|
||||
|
||||
const createdPermissions = await ctx.db.query.userPermissions.findMany({
|
||||
where: eq(schema.userPermissions.userId, callbackBody.user.id),
|
||||
});
|
||||
expect(createdPermissions.map((row) => row.permissionName)).toContain(Permission.KoboSync);
|
||||
|
||||
const oidcSession = await ctx.db.query.oidcSessions.findFirst({
|
||||
where: eq(schema.oidcSessions.userId, callbackBody.user.id),
|
||||
});
|
||||
expect(oidcSession).toMatchObject({
|
||||
oidcSubject: 'oidc-provisioned-subject',
|
||||
oidcIssuer: DEFAULT_ISSUER,
|
||||
oidcSessionId: 'oidc-provisioned-sid',
|
||||
idTokenHint: 'oidc-id-provisioned',
|
||||
revoked: false,
|
||||
});
|
||||
|
||||
const reusedState = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/callback',
|
||||
payload: callbackPayload,
|
||||
});
|
||||
expectError(reusedState, 401, 'Invalid or expired state');
|
||||
});
|
||||
|
||||
it('links an existing local user and backchannel logout revokes active sessions only for the matched OIDC identity', async () => {
|
||||
const suffix = randomUUID().replace(/-/g, '');
|
||||
const linkedUser = await createLocalUser(ctx.db, {
|
||||
username: `oidc-linked-${suffix}`,
|
||||
});
|
||||
|
||||
await putOidcConfig(
|
||||
ctx,
|
||||
buildOidcConfig({
|
||||
autoProvision: {
|
||||
enabled: false,
|
||||
allowLocalLinking: true,
|
||||
defaultPermissionNames: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
ctx.oidcTokenClientMock.exchangeCode.mockResolvedValueOnce({
|
||||
accessToken: 'oidc-access-linked',
|
||||
idToken: 'oidc-id-linked',
|
||||
});
|
||||
ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({
|
||||
sub: 'oidc-linked-subject',
|
||||
sid: 'oidc-linked-sid',
|
||||
preferred_username: linkedUser.username,
|
||||
name: 'Linked Local User',
|
||||
email: linkedUser.email,
|
||||
});
|
||||
|
||||
const state = await issueState(ctx.app);
|
||||
const callbackResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/callback',
|
||||
payload: {
|
||||
code: 'authorization-code-linked',
|
||||
codeVerifier: 'verifier-linked',
|
||||
redirectUri: 'http://localhost:5173/oauth2-callback',
|
||||
nonce: 'nonce-linked',
|
||||
state,
|
||||
},
|
||||
});
|
||||
|
||||
expect(callbackResponse.statusCode).toBe(200);
|
||||
const callbackBody = callbackResponse.json() as {
|
||||
accessToken: string;
|
||||
user: {
|
||||
id: number;
|
||||
provisioningMethod: string;
|
||||
};
|
||||
};
|
||||
expect(callbackBody.user).toMatchObject({
|
||||
id: linkedUser.userId,
|
||||
provisioningMethod: 'local',
|
||||
});
|
||||
|
||||
const setCookieLines = getSetCookieLines(callbackResponse.headers);
|
||||
const refreshToken = cookieValue(setCookieLines, 'refresh_token');
|
||||
expect(refreshToken).toBeTruthy();
|
||||
|
||||
const linkedUserAfter = await ctx.db.query.users.findFirst({
|
||||
where: eq(schema.users.id, linkedUser.userId),
|
||||
});
|
||||
expect(linkedUserAfter).toMatchObject({
|
||||
oidcSubject: 'oidc-linked-subject',
|
||||
oidcIssuer: DEFAULT_ISSUER,
|
||||
provisioningMethod: 'local',
|
||||
});
|
||||
|
||||
const sessionBeforeUnknownLogout = await activeSessionCount(ctx.db, linkedUser.userId);
|
||||
expect(sessionBeforeUnknownLogout).toBe(1);
|
||||
|
||||
ctx.oidcTokenValidatorMock.validateLogoutToken.mockResolvedValueOnce({
|
||||
sub: `oidc-unknown-${randomUUID()}`,
|
||||
jti: `logout-unknown-${randomUUID()}`,
|
||||
});
|
||||
|
||||
const unknownLogoutResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/backchannel-logout',
|
||||
payload: { logout_token: 'unknown-logout-token' },
|
||||
});
|
||||
expect(unknownLogoutResponse.statusCode).toBe(200);
|
||||
expect(await activeSessionCount(ctx.db, linkedUser.userId)).toBe(1);
|
||||
|
||||
const meBeforeMatchedLogout = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: authHeader(callbackBody.accessToken),
|
||||
});
|
||||
expect(meBeforeMatchedLogout.statusCode).toBe(200);
|
||||
|
||||
ctx.oidcTokenValidatorMock.validateLogoutToken.mockResolvedValueOnce({
|
||||
sub: 'oidc-linked-subject',
|
||||
sid: 'oidc-linked-sid',
|
||||
jti: `logout-linked-${randomUUID()}`,
|
||||
});
|
||||
|
||||
const matchedLogoutResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/backchannel-logout',
|
||||
payload: { logout_token: 'matched-logout-token' },
|
||||
});
|
||||
expect(matchedLogoutResponse.statusCode).toBe(200);
|
||||
|
||||
const oidcSessionAfter = await ctx.db.query.oidcSessions.findFirst({
|
||||
where: eq(schema.oidcSessions.userId, linkedUser.userId),
|
||||
});
|
||||
expect(oidcSessionAfter?.revoked).toBe(true);
|
||||
expect(await activeSessionCount(ctx.db, linkedUser.userId)).toBe(0);
|
||||
|
||||
const refreshAfterLogout = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/refresh',
|
||||
headers: {
|
||||
cookie: `refresh_token=${refreshToken}`,
|
||||
},
|
||||
});
|
||||
expect(refreshAfterLogout.statusCode).toBe(401);
|
||||
|
||||
const meAfterLogout = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/auth/me',
|
||||
headers: authHeader(callbackBody.accessToken),
|
||||
});
|
||||
expect(meAfterLogout.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects invalid state and missing subject claims', async () => {
|
||||
await putOidcConfig(
|
||||
ctx,
|
||||
buildOidcConfig({
|
||||
autoProvision: {
|
||||
enabled: true,
|
||||
allowLocalLinking: false,
|
||||
defaultPermissionNames: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const invalidStateResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/callback',
|
||||
payload: {
|
||||
code: 'authorization-code-invalid-state',
|
||||
codeVerifier: 'verifier-invalid-state',
|
||||
redirectUri: 'http://localhost:5173/oauth2-callback',
|
||||
nonce: 'nonce-invalid-state',
|
||||
state: `invalid-${randomUUID()}`,
|
||||
},
|
||||
});
|
||||
expectError(invalidStateResponse, 401, 'Invalid or expired state');
|
||||
expect(ctx.oidcTokenClientMock.exchangeCode).not.toHaveBeenCalled();
|
||||
|
||||
const state = await issueState(ctx.app);
|
||||
ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({
|
||||
sid: 'oidc-missing-subject-sid',
|
||||
preferred_username: `missing-subject-${randomUUID()}`,
|
||||
name: 'Missing Subject',
|
||||
email: 'missing-subject@example.com',
|
||||
});
|
||||
|
||||
const missingSubjectResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/callback',
|
||||
payload: {
|
||||
code: 'authorization-code-missing-subject',
|
||||
codeVerifier: 'verifier-missing-subject',
|
||||
redirectUri: 'http://localhost:5173/oauth2-callback',
|
||||
nonce: 'nonce-missing-subject',
|
||||
state,
|
||||
},
|
||||
});
|
||||
expectError(missingSubjectResponse, 401, 'Invalid ID token: missing subject claim');
|
||||
});
|
||||
|
||||
it('rejects callback when OIDC is disabled or the linked account is deactivated', async () => {
|
||||
await putOidcConfig(
|
||||
ctx,
|
||||
buildOidcConfig({
|
||||
enabled: false,
|
||||
autoProvision: {
|
||||
enabled: true,
|
||||
allowLocalLinking: true,
|
||||
defaultPermissionNames: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const disabledState = await issueState(ctx.app);
|
||||
const disabledCallbackResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/callback',
|
||||
payload: {
|
||||
code: 'authorization-code-disabled',
|
||||
codeVerifier: 'verifier-disabled',
|
||||
redirectUri: 'http://localhost:5173/oauth2-callback',
|
||||
nonce: 'nonce-disabled',
|
||||
state: disabledState,
|
||||
},
|
||||
});
|
||||
expectError(disabledCallbackResponse, 401, 'OIDC is not enabled');
|
||||
|
||||
const inactiveUser = await createLocalUser(ctx.db, {
|
||||
username: `oidc-inactive-${randomUUID().replace(/-/g, '')}`,
|
||||
active: false,
|
||||
});
|
||||
|
||||
await putOidcConfig(
|
||||
ctx,
|
||||
buildOidcConfig({
|
||||
enabled: true,
|
||||
autoProvision: {
|
||||
enabled: false,
|
||||
allowLocalLinking: true,
|
||||
defaultPermissionNames: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const inactiveState = await issueState(ctx.app);
|
||||
ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({
|
||||
sub: 'oidc-inactive-subject',
|
||||
sid: 'oidc-inactive-sid',
|
||||
preferred_username: inactiveUser.username,
|
||||
name: 'Inactive Linked User',
|
||||
email: inactiveUser.email,
|
||||
});
|
||||
|
||||
const inactiveCallbackResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/auth/oidc/callback',
|
||||
payload: {
|
||||
code: 'authorization-code-inactive',
|
||||
codeVerifier: 'verifier-inactive',
|
||||
redirectUri: 'http://localhost:5173/oauth2-callback',
|
||||
nonce: 'nonce-inactive',
|
||||
state: inactiveState,
|
||||
},
|
||||
});
|
||||
expectError(inactiveCallbackResponse, 401, 'Account is deactivated');
|
||||
|
||||
const inactiveUserSession = await ctx.db.query.oidcSessions.findFirst({
|
||||
where: eq(schema.oidcSessions.userId, inactiveUser.userId),
|
||||
});
|
||||
expect(inactiveUserSession).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,610 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import * as unzipper from 'unzipper';
|
||||
import { Permission } from '@projectx/types';
|
||||
|
||||
import {
|
||||
authHeader,
|
||||
closeMetadataWriteE2EContext,
|
||||
createLibraryWithFolder,
|
||||
createMetadataWriteE2EContext,
|
||||
createUserAndLogin,
|
||||
grantLibraryAccess,
|
||||
locateBookFileByRelPath,
|
||||
triggerAndWaitForLibraryScan,
|
||||
type LocatedBookFile,
|
||||
type MetadataWriteE2EContext,
|
||||
type TestUserSession,
|
||||
} from './e2e/metadata-write/metadata-write-harness';
|
||||
import { createCbzFixture, createEpubFixture, createPdfFixture, writeFixtureFile } from './e2e/metadata-write/metadata-write-fixture-builder';
|
||||
|
||||
type InjectResponse = Awaited<ReturnType<MetadataWriteE2EContext['app']['inject']>>;
|
||||
|
||||
const SCENARIO_TIMEOUT_MS = 120_000;
|
||||
|
||||
function responseMessage(response: { message?: string | string[] }): string {
|
||||
if (Array.isArray(response.message)) return response.message.join(' ');
|
||||
return String(response.message ?? '');
|
||||
}
|
||||
|
||||
function expectError(response: InjectResponse, status: number, messageFragment?: string): void {
|
||||
expect(response.statusCode).toBe(status);
|
||||
if (!messageFragment) return;
|
||||
expect(responseMessage(response.json() as { message?: string | string[] })).toContain(messageFragment);
|
||||
}
|
||||
|
||||
function zipBuffer(response: InjectResponse): Buffer {
|
||||
const rawPayload = (response as unknown as { rawPayload?: unknown }).rawPayload;
|
||||
if (Buffer.isBuffer(rawPayload)) return rawPayload;
|
||||
return Buffer.from(response.body, 'binary');
|
||||
}
|
||||
|
||||
async function listZipEntries(response: InjectResponse): Promise<string[]> {
|
||||
const zip = await unzipper.Open.buffer(zipBuffer(response));
|
||||
return zip.files.map((file) => file.path).sort();
|
||||
}
|
||||
|
||||
async function createBookCoverArtifacts(
|
||||
ctx: MetadataWriteE2EContext,
|
||||
bookId: number,
|
||||
options: {
|
||||
coverExtension?: 'jpg' | 'png';
|
||||
coverContent?: Buffer;
|
||||
thumbnailContent?: Buffer;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const coverExtension = options.coverExtension ?? 'jpg';
|
||||
await writeFixtureFile(
|
||||
ctx.fixture.booksPath,
|
||||
`covers/${bookId}/cover_custom.${coverExtension}`,
|
||||
options.coverContent ?? Buffer.from(`cover-${bookId}`, 'utf8'),
|
||||
);
|
||||
await writeFixtureFile(
|
||||
ctx.fixture.booksPath,
|
||||
`covers/${bookId}/thumbnail.jpg`,
|
||||
options.thumbnailContent ?? Buffer.from(`thumbnail-${bookId}`, 'utf8'),
|
||||
);
|
||||
}
|
||||
|
||||
describe('Book API contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
let ctx!: MetadataWriteE2EContext;
|
||||
|
||||
let visibleLibrary!: Awaited<ReturnType<typeof createLibraryWithFolder>>;
|
||||
let hiddenLibrary!: Awaited<ReturnType<typeof createLibraryWithFolder>>;
|
||||
let visibleLibraryName!: string;
|
||||
let hiddenLibraryName!: string;
|
||||
|
||||
let limitedUser!: TestUserSession;
|
||||
let crossLibraryUser!: TestUserSession;
|
||||
let downloadUser!: TestUserSession;
|
||||
|
||||
let visibleEpub!: LocatedBookFile;
|
||||
let visiblePdf!: LocatedBookFile;
|
||||
let visibleCbz!: LocatedBookFile;
|
||||
let hiddenEpub!: LocatedBookFile;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createMetadataWriteE2EContext();
|
||||
|
||||
visibleLibraryName = `book-api-visible-${randomUUID()}`;
|
||||
hiddenLibraryName = `book-api-hidden-${randomUUID()}`;
|
||||
|
||||
visibleLibrary = await createLibraryWithFolder(ctx, {
|
||||
name: visibleLibraryName,
|
||||
});
|
||||
hiddenLibrary = await createLibraryWithFolder(ctx, {
|
||||
name: hiddenLibraryName,
|
||||
});
|
||||
|
||||
await createEpubFixture(visibleLibrary.folderPath, 'contracts/alpha-contract.epub', {
|
||||
title: 'Alpha Contract EPUB',
|
||||
});
|
||||
await createPdfFixture(visibleLibrary.folderPath, 'contracts/beta-contract.pdf', 'Beta Contract PDF');
|
||||
await createCbzFixture(visibleLibrary.folderPath, 'contracts/gamma-contract.cbz', {
|
||||
title: 'Gamma Contract Comic',
|
||||
author: 'Gamma Cartoonist',
|
||||
});
|
||||
await createEpubFixture(hiddenLibrary.folderPath, 'restricted/omega-hidden.epub', {
|
||||
title: 'Omega Hidden EPUB',
|
||||
});
|
||||
|
||||
await triggerAndWaitForLibraryScan(ctx, visibleLibrary.libraryId);
|
||||
await triggerAndWaitForLibraryScan(ctx, hiddenLibrary.libraryId);
|
||||
|
||||
visibleEpub = await locateBookFileByRelPath(ctx, visibleLibrary.libraryId, 'contracts/alpha-contract.epub');
|
||||
visiblePdf = await locateBookFileByRelPath(ctx, visibleLibrary.libraryId, 'contracts/beta-contract.pdf');
|
||||
visibleCbz = await locateBookFileByRelPath(ctx, visibleLibrary.libraryId, 'contracts/gamma-contract.cbz');
|
||||
hiddenEpub = await locateBookFileByRelPath(ctx, hiddenLibrary.libraryId, 'restricted/omega-hidden.epub');
|
||||
|
||||
limitedUser = await createUserAndLogin(ctx);
|
||||
crossLibraryUser = await createUserAndLogin(ctx);
|
||||
downloadUser = await createUserAndLogin(ctx, {
|
||||
permissions: [Permission.LibraryDownload],
|
||||
});
|
||||
|
||||
await grantLibraryAccess(ctx, limitedUser.userId, visibleLibrary.libraryId, 'viewer');
|
||||
await grantLibraryAccess(ctx, crossLibraryUser.userId, visibleLibrary.libraryId, 'viewer');
|
||||
await grantLibraryAccess(ctx, crossLibraryUser.userId, hiddenLibrary.libraryId, 'viewer');
|
||||
await grantLibraryAccess(ctx, downloadUser.userId, visibleLibrary.libraryId, 'viewer');
|
||||
|
||||
await createBookCoverArtifacts(ctx, visibleEpub.bookId, {
|
||||
coverExtension: 'png',
|
||||
coverContent: Buffer.from('cover-png', 'utf8'),
|
||||
thumbnailContent: Buffer.from('thumbnail-jpg', 'utf8'),
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await closeMetadataWriteE2EContext(ctx);
|
||||
});
|
||||
|
||||
describe('query and search', () => {
|
||||
it('returns scoped global and library query results with filters, sorting, and pagination', async () => {
|
||||
const filteredQuery = {
|
||||
filter: {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'Contract' }],
|
||||
},
|
||||
sort: [{ field: 'title', dir: 'asc' }],
|
||||
pagination: { page: 0, size: 2 },
|
||||
};
|
||||
|
||||
const limitedGlobalQuery = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/books/query',
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
payload: filteredQuery,
|
||||
});
|
||||
|
||||
expect(limitedGlobalQuery.statusCode).toBe(201);
|
||||
expect(limitedGlobalQuery.json()).toMatchObject({
|
||||
total: 3,
|
||||
page: 0,
|
||||
size: 2,
|
||||
items: [
|
||||
{
|
||||
id: visibleEpub.bookId,
|
||||
title: 'Alpha Contract EPUB',
|
||||
files: [{ id: visibleEpub.bookFileId, format: 'epub', role: 'primary' }],
|
||||
},
|
||||
{
|
||||
id: visiblePdf.bookId,
|
||||
title: 'Beta Contract PDF',
|
||||
files: [{ id: visiblePdf.bookFileId, format: 'pdf', role: 'primary' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const libraryQuery = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/libraries/${visibleLibrary.libraryId}/books`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
payload: {
|
||||
filter: {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'format', operator: 'includesAny', value: ['cbz'] }],
|
||||
},
|
||||
sort: [{ field: 'title', dir: 'asc' }],
|
||||
pagination: { page: 0, size: 10 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(libraryQuery.statusCode).toBe(201);
|
||||
expect(libraryQuery.json()).toMatchObject({
|
||||
total: 1,
|
||||
items: [{ id: visibleCbz.bookId, title: 'Gamma Contract Comic' }],
|
||||
});
|
||||
|
||||
const inaccessibleLibraryQuery = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/libraries/${hiddenLibrary.libraryId}/books`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
payload: filteredQuery,
|
||||
});
|
||||
|
||||
expect(inaccessibleLibraryQuery.statusCode).toBe(403);
|
||||
|
||||
const crossLibraryQuery = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/books/query',
|
||||
headers: authHeader(crossLibraryUser.accessToken),
|
||||
payload: {
|
||||
filter: {
|
||||
type: 'group',
|
||||
join: 'AND',
|
||||
rules: [{ type: 'rule', field: 'title', operator: 'contains', value: 'EPUB' }],
|
||||
},
|
||||
sort: [{ field: 'title', dir: 'asc' }],
|
||||
pagination: { page: 0, size: 10 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(crossLibraryQuery.statusCode).toBe(201);
|
||||
expect(crossLibraryQuery.json()).toMatchObject({
|
||||
total: 2,
|
||||
items: [
|
||||
{ id: visibleEpub.bookId, title: 'Alpha Contract EPUB' },
|
||||
{ id: hiddenEpub.bookId, title: 'Omega Hidden EPUB' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns scoped search results and validates search params', async () => {
|
||||
const limitedSearch = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/books/search?q=Contract&limit=20',
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
});
|
||||
|
||||
expect(limitedSearch.statusCode).toBe(200);
|
||||
expect(limitedSearch.json()).toEqual([
|
||||
{
|
||||
id: visibleEpub.bookId,
|
||||
title: 'Alpha Contract EPUB',
|
||||
seriesName: null,
|
||||
authors: [],
|
||||
libraryId: visibleLibrary.libraryId,
|
||||
libraryName: visibleLibraryName,
|
||||
formats: ['epub'],
|
||||
},
|
||||
{
|
||||
id: visiblePdf.bookId,
|
||||
title: 'Beta Contract PDF',
|
||||
seriesName: null,
|
||||
authors: [],
|
||||
libraryId: visibleLibrary.libraryId,
|
||||
libraryName: visibleLibraryName,
|
||||
formats: ['pdf'],
|
||||
},
|
||||
{
|
||||
id: visibleCbz.bookId,
|
||||
title: 'Gamma Contract Comic',
|
||||
seriesName: null,
|
||||
authors: ['Gamma Cartoonist'],
|
||||
libraryId: visibleLibrary.libraryId,
|
||||
libraryName: visibleLibraryName,
|
||||
formats: ['cbz'],
|
||||
},
|
||||
]);
|
||||
|
||||
const authorSearch = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/books/search?q=Cartoonist&limit=20',
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
});
|
||||
|
||||
expect(authorSearch.statusCode).toBe(200);
|
||||
expect(authorSearch.json()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: visibleCbz.bookId,
|
||||
title: 'Gamma Contract Comic',
|
||||
authors: ['Gamma Cartoonist'],
|
||||
}),
|
||||
]);
|
||||
|
||||
const crossLibrarySearch = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/books/search?q=Omega&limit=20',
|
||||
headers: authHeader(crossLibraryUser.accessToken),
|
||||
});
|
||||
|
||||
expect(crossLibrarySearch.statusCode).toBe(200);
|
||||
expect(crossLibrarySearch.json()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: hiddenEpub.bookId,
|
||||
title: 'Omega Hidden EPUB',
|
||||
libraryId: hiddenLibrary.libraryId,
|
||||
libraryName: hiddenLibraryName,
|
||||
}),
|
||||
]);
|
||||
|
||||
const invalidLimit = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/books/search?q=Contract&limit=25',
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
});
|
||||
|
||||
expectError(invalidLimit, 400, 'limit must not be greater than 20');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detail and reader state', () => {
|
||||
it('returns detail payload fields and keeps progress and status isolated per user', async () => {
|
||||
const saveProgress = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/books/files/${visibleEpub.bookFileId}/progress`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
payload: {
|
||||
cfi: 'epubcfi(/6/2!/4/1:0)',
|
||||
percentage: 42.5,
|
||||
positionSeconds: 90,
|
||||
},
|
||||
});
|
||||
|
||||
expect(saveProgress.statusCode).toBe(201);
|
||||
|
||||
const setStatus = await ctx.app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/books/${visibleEpub.bookId}/status`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
payload: { status: 'reading' },
|
||||
});
|
||||
|
||||
expect(setStatus.statusCode).toBe(204);
|
||||
|
||||
const getFileProgress = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/files/${visibleEpub.bookFileId}/progress`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
});
|
||||
|
||||
expect(getFileProgress.statusCode).toBe(200);
|
||||
expect(getFileProgress.json()).toMatchObject({
|
||||
cfi: 'epubcfi(/6/2!/4/1:0)',
|
||||
pageNumber: null,
|
||||
percentage: 42.5,
|
||||
});
|
||||
|
||||
const otherUserFileProgress = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/files/${visibleEpub.bookFileId}/progress`,
|
||||
headers: authHeader(crossLibraryUser.accessToken),
|
||||
});
|
||||
|
||||
expect(otherUserFileProgress.statusCode).toBe(200);
|
||||
expect(otherUserFileProgress.json()).toEqual({
|
||||
cfi: null,
|
||||
pageNumber: null,
|
||||
percentage: 0,
|
||||
});
|
||||
|
||||
const limitedBookProgress = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/${visibleEpub.bookId}/progress`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
});
|
||||
|
||||
expect(limitedBookProgress.statusCode).toBe(200);
|
||||
expect(limitedBookProgress.json()).toEqual([
|
||||
expect.objectContaining({
|
||||
fileId: visibleEpub.bookFileId,
|
||||
cfi: 'epubcfi(/6/2!/4/1:0)',
|
||||
pageNumber: null,
|
||||
percentage: 42.5,
|
||||
}),
|
||||
]);
|
||||
|
||||
const otherUserBookProgress = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/${visibleEpub.bookId}/progress`,
|
||||
headers: authHeader(crossLibraryUser.accessToken),
|
||||
});
|
||||
|
||||
expect(otherUserBookProgress.statusCode).toBe(200);
|
||||
expect(otherUserBookProgress.json()).toEqual([
|
||||
{
|
||||
fileId: visibleEpub.bookFileId,
|
||||
cfi: null,
|
||||
pageNumber: null,
|
||||
percentage: 0,
|
||||
updatedAt: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const limitedDetail = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/${visibleEpub.bookId}`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
});
|
||||
|
||||
expect(limitedDetail.statusCode).toBe(200);
|
||||
expect(limitedDetail.json()).toMatchObject({
|
||||
id: visibleEpub.bookId,
|
||||
libraryId: visibleLibrary.libraryId,
|
||||
libraryName: visibleLibraryName,
|
||||
title: 'Alpha Contract EPUB',
|
||||
coverSource: null,
|
||||
files: [
|
||||
{
|
||||
id: visibleEpub.bookFileId,
|
||||
format: 'epub',
|
||||
role: 'primary',
|
||||
filename: 'alpha-contract.epub',
|
||||
},
|
||||
],
|
||||
collections: [],
|
||||
readStatus: {
|
||||
status: 'reading',
|
||||
source: 'manual',
|
||||
},
|
||||
});
|
||||
|
||||
const crossLibraryDetail = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/${visibleEpub.bookId}`,
|
||||
headers: authHeader(crossLibraryUser.accessToken),
|
||||
});
|
||||
|
||||
expect(crossLibraryDetail.statusCode).toBe(200);
|
||||
expect(crossLibraryDetail.json()).toMatchObject({
|
||||
id: visibleEpub.bookId,
|
||||
title: 'Alpha Contract EPUB',
|
||||
readStatus: null,
|
||||
});
|
||||
|
||||
const inaccessibleDetail = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/${hiddenEpub.bookId}`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
});
|
||||
|
||||
expect(inaccessibleDetail.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file delivery and export', () => {
|
||||
it('serves files inline and as downloads, enforces range semantics, and rejects inaccessible files', async () => {
|
||||
const inlinePdf = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/files/${visiblePdf.bookFileId}/serve`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
});
|
||||
|
||||
expect(inlinePdf.statusCode).toBe(200);
|
||||
expect(inlinePdf.headers['content-type']).toContain('application/pdf');
|
||||
expect(inlinePdf.headers['accept-ranges']).toBe('bytes');
|
||||
expect(Number(inlinePdf.headers['content-length'])).toBeGreaterThan(0);
|
||||
expect(String(inlinePdf.headers['content-disposition'])).toContain('inline;');
|
||||
|
||||
const rangedPdf = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/files/${visiblePdf.bookFileId}/serve`,
|
||||
headers: {
|
||||
...authHeader(limitedUser.accessToken),
|
||||
range: 'bytes=0-9',
|
||||
},
|
||||
});
|
||||
|
||||
expect(rangedPdf.statusCode).toBe(206);
|
||||
expect(rangedPdf.headers['content-range']).toBe(`bytes 0-9/${inlinePdf.headers['content-length']}`);
|
||||
expect(rangedPdf.headers['content-length']).toBe('10');
|
||||
|
||||
const invalidRange = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/files/${visiblePdf.bookFileId}/serve`,
|
||||
headers: {
|
||||
...authHeader(limitedUser.accessToken),
|
||||
range: `bytes=${Number(inlinePdf.headers['content-length']) + 10}-${Number(inlinePdf.headers['content-length']) + 20}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(invalidRange.statusCode).toBe(416);
|
||||
expect(invalidRange.headers['content-range']).toBe(`bytes */${inlinePdf.headers['content-length']}`);
|
||||
|
||||
const downloadEpub = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/files/${visibleEpub.bookFileId}/serve?download=1`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
});
|
||||
|
||||
expect(downloadEpub.statusCode).toBe(200);
|
||||
expect(downloadEpub.headers['content-type']).toContain('application/epub+zip');
|
||||
expect(String(downloadEpub.headers['content-disposition'])).toContain('attachment;');
|
||||
expect(String(downloadEpub.headers['content-disposition'])).toContain('alpha-contract.epub');
|
||||
|
||||
const inaccessibleServe = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/files/${hiddenEpub.bookFileId}/serve`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
});
|
||||
|
||||
expect(inaccessibleServe.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('exports selected books as a zip and rejects unauthorized selections', async () => {
|
||||
const exportResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/books/export',
|
||||
headers: authHeader(downloadUser.accessToken),
|
||||
payload: {
|
||||
bookIds: [visibleEpub.bookId, visiblePdf.bookId],
|
||||
allFormats: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(exportResponse.statusCode).toBe(201);
|
||||
expect(exportResponse.headers['content-type']).toContain('application/zip');
|
||||
expect(exportResponse.headers['content-disposition']).toBe('attachment; filename="books.zip"');
|
||||
|
||||
const zipEntries = await listZipEntries(exportResponse);
|
||||
expect(zipEntries).toEqual(['alpha-contract.epub', 'beta-contract.pdf']);
|
||||
|
||||
const unauthorizedExport = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/books/export',
|
||||
headers: authHeader(downloadUser.accessToken),
|
||||
payload: {
|
||||
bookIds: [visibleEpub.bookId, hiddenEpub.bookId],
|
||||
allFormats: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(unauthorizedExport.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cover and thumbnail caching', () => {
|
||||
it('serves cover and thumbnail with ETags and returns 404 when artifacts are missing', async () => {
|
||||
const cover = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/${visibleEpub.bookId}/cover`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
});
|
||||
|
||||
expect(cover.statusCode).toBe(200);
|
||||
expect(cover.headers['content-type']).toContain('image/png');
|
||||
expect(cover.headers.etag).toBeTruthy();
|
||||
expect(cover.headers['cache-control']).toBe('private, max-age=86400');
|
||||
|
||||
const cachedCover = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/${visibleEpub.bookId}/cover`,
|
||||
headers: {
|
||||
...authHeader(limitedUser.accessToken),
|
||||
'if-none-match': String(cover.headers.etag),
|
||||
},
|
||||
});
|
||||
|
||||
expect(cachedCover.statusCode).toBe(304);
|
||||
expect(cachedCover.headers.etag).toBe(cover.headers.etag);
|
||||
|
||||
const thumbnail = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/${visibleEpub.bookId}/thumbnail`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
});
|
||||
|
||||
expect(thumbnail.statusCode).toBe(200);
|
||||
expect(thumbnail.headers['content-type']).toContain('image/jpeg');
|
||||
expect(thumbnail.headers.etag).toBeTruthy();
|
||||
expect(thumbnail.headers['cache-control']).toBe('private, max-age=86400');
|
||||
|
||||
const cachedThumbnail = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/${visibleEpub.bookId}/thumbnail`,
|
||||
headers: {
|
||||
...authHeader(limitedUser.accessToken),
|
||||
'if-none-match': String(thumbnail.headers.etag),
|
||||
},
|
||||
});
|
||||
|
||||
expect(cachedThumbnail.statusCode).toBe(304);
|
||||
expect(cachedThumbnail.headers.etag).toBe(thumbnail.headers.etag);
|
||||
|
||||
await rm(join(ctx.fixture.booksPath, 'covers', String(visibleEpub.bookId)), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
|
||||
const missingCover = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/${visibleEpub.bookId}/cover`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
});
|
||||
|
||||
expectError(missingCover, 404, `No cover for book ${visibleEpub.bookId}`);
|
||||
|
||||
const missingThumbnail = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/books/${visibleEpub.bookId}/thumbnail`,
|
||||
headers: authHeader(limitedUser.accessToken),
|
||||
});
|
||||
|
||||
expectError(missingThumbnail, 404, `No thumbnail for book ${visibleEpub.bookId}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { expect } from 'vitest';
|
||||
import { expect, vi } from 'vitest';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { count, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
@@ -17,6 +17,13 @@ import { MetadataService } from '../../src/modules/metadata/metadata.service';
|
||||
|
||||
export type Db = NodePgDatabase<typeof schema>;
|
||||
type OrganizationMode = 'book_per_file' | 'book_per_folder';
|
||||
export type MetadataNoopMock = {
|
||||
extractAndSave: ReturnType<typeof vi.fn>;
|
||||
refreshCoverForBook: ReturnType<typeof vi.fn>;
|
||||
extractAudioFileDuration: ReturnType<typeof vi.fn>;
|
||||
aggregateAudioDuration: ReturnType<typeof vi.fn>;
|
||||
extractAudioChaptersAndNarrators: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const ADMIN_SETUP_DTO = {
|
||||
username: 'scanner-e2e-admin',
|
||||
@@ -29,16 +36,13 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function makeMetadataNoopMock(): Pick<
|
||||
MetadataService,
|
||||
'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators'
|
||||
> {
|
||||
export function makeMetadataNoopMock(): MetadataNoopMock {
|
||||
return {
|
||||
extractAndSave: () => Promise.resolve(undefined),
|
||||
refreshCoverForBook: () => Promise.resolve(false),
|
||||
extractAudioFileDuration: () => Promise.resolve(undefined),
|
||||
aggregateAudioDuration: () => Promise.resolve(undefined),
|
||||
extractAudioChaptersAndNarrators: () => Promise.resolve(undefined),
|
||||
extractAndSave: vi.fn().mockResolvedValue(undefined),
|
||||
refreshCoverForBook: vi.fn().mockResolvedValue(false),
|
||||
extractAudioFileDuration: vi.fn().mockResolvedValue(undefined),
|
||||
aggregateAudioDuration: vi.fn().mockResolvedValue(undefined),
|
||||
extractAudioChaptersAndNarrators: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { DB } from '../../../src/db';
|
||||
import * as schema from '../../../src/db/schema';
|
||||
import { MetadataService } from '../../../src/modules/metadata/metadata.service';
|
||||
import { BookBucketWatcherService } from '../../../src/modules/book-bucket/book-bucket-watcher.service';
|
||||
import { seedLibrary, waitForScanCompletion } from '../app-harness';
|
||||
import { makeMetadataNoopMock, seedLibrary, waitForScanCompletion, type MetadataNoopMock } from '../app-harness';
|
||||
import {
|
||||
buildFb2Fixture,
|
||||
createAuthorizationFixtureRoot,
|
||||
@@ -45,6 +45,7 @@ export interface AuthorizationMatrixE2EContext {
|
||||
adminToken: string;
|
||||
fixture: AuthorizationMatrixFixtureRoot;
|
||||
envSnapshot: EnvSnapshot;
|
||||
metadataMock: MetadataNoopMock;
|
||||
}
|
||||
|
||||
export interface CreatedLibrary {
|
||||
@@ -108,11 +109,12 @@ export async function createAuthorizationMatrixE2EContext(): Promise<Authorizati
|
||||
process.env.BOOKS_PATH = fixture.booksPath;
|
||||
process.env.BOOK_BUCKET_PATH = fixture.bookBucketPath;
|
||||
|
||||
const metadataMock = makeMetadataNoopMock();
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
})
|
||||
.overrideProvider(MetadataService)
|
||||
.useValue(makeMetadataNoopMock())
|
||||
.useValue(metadataMock)
|
||||
.compile();
|
||||
|
||||
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
||||
@@ -144,6 +146,7 @@ export async function createAuthorizationMatrixE2EContext(): Promise<Authorizati
|
||||
adminToken,
|
||||
fixture,
|
||||
envSnapshot,
|
||||
metadataMock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -453,19 +456,6 @@ function buildMultipartBody(fileName: string, content: Buffer, contentType: stri
|
||||
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 stopBookBucketWatcher(app: NestFastifyApplication): Promise<void> {
|
||||
const watcher = app.get(BookBucketWatcherService);
|
||||
await watcher.onModuleDestroy();
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface MetadataWriteFixtureRoot {
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface ZipEntryInput {
|
||||
export interface ZipEntryInput {
|
||||
path: string;
|
||||
content: string | Buffer;
|
||||
store?: boolean;
|
||||
@@ -136,6 +136,14 @@ export async function createCb7Fixture(rootPath: string, relativePath: string, i
|
||||
return writeFixtureFile(rootPath, relativePath, archiveBytes);
|
||||
}
|
||||
|
||||
export async function createZipArchiveFixture(rootPath: string, relativePath: string, entries: ZipEntryInput[]): Promise<string> {
|
||||
assertRelativePath(relativePath);
|
||||
const absolutePath = join(rootPath, relativePath);
|
||||
await mkdir(dirname(absolutePath), { recursive: true });
|
||||
await writeZipArchive(absolutePath, entries);
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
async function writeZipArchive(absolutePath: string, entries: ZipEntryInput[]): Promise<void> {
|
||||
const output = createWriteStream(absolutePath);
|
||||
const archive = archiver('zip', { zlib: { level: 6 } });
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { createCbzFixture, createEpubFixture, createZipArchiveFixture } from '../metadata-write/metadata-write-fixture-builder';
|
||||
|
||||
export interface ReaderStateIsolationFixtureRoot {
|
||||
rootPath: string;
|
||||
|
||||
@@ -10,11 +10,12 @@ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { DEFAULT_FORMAT_PRIORITY, type Permission } from '@projectx/types';
|
||||
|
||||
import { AppModule } from '../../../src/app.module';
|
||||
import { GlobalExceptionFilter } from '../../../src/common/filters/http-exception.filter';
|
||||
import { DB } from '../../../src/db';
|
||||
import * as schema from '../../../src/db/schema';
|
||||
import { MetadataService } from '../../../src/modules/metadata/metadata.service';
|
||||
import { BookBucketWatcherService } from '../../../src/modules/book-bucket/book-bucket-watcher.service';
|
||||
import { seedLibrary, waitForScanCompletion } from '../app-harness';
|
||||
import { makeMetadataNoopMock, seedLibrary, waitForScanCompletion } from '../app-harness';
|
||||
import { createReaderStateIsolationFixtureRoot, type ReaderStateIsolationFixtureRoot } from './reader-state-isolation-fixture-builder';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
@@ -91,6 +92,7 @@ export async function createReaderStateIsolationE2EContext(): Promise<ReaderStat
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||
await app.register(fastifyCookie as never);
|
||||
await app.init();
|
||||
await app.getHttpAdapter().getInstance().ready();
|
||||
@@ -321,19 +323,6 @@ async function loginForToken(app: NestFastifyApplication, username: string, pass
|
||||
return body.accessToken ?? null;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
function restoreEnv(snapshot: EnvSnapshot): void {
|
||||
if (snapshot.booksPath === undefined) delete process.env.BOOKS_PATH;
|
||||
else process.env.BOOKS_PATH = snapshot.booksPath;
|
||||
|
||||
@@ -116,7 +116,7 @@ class SmokeProtectedController {
|
||||
})
|
||||
class SmokeAppModule {}
|
||||
|
||||
describe('API smoke (e2e)', () => {
|
||||
describe('Guard mechanics (e2e)', () => {
|
||||
let app: NestFastifyApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -0,0 +1,780 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { access, mkdir, readFile } from 'fs/promises';
|
||||
import { basename, join } from 'path';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { Permission } from '@projectx/types';
|
||||
|
||||
import * as schema from '../src/db/schema';
|
||||
import { waitForCondition, waitForScanCompletion } from './e2e/app-harness';
|
||||
import {
|
||||
authHeader,
|
||||
closeAuthorizationMatrixE2EContext,
|
||||
createAuthorizationMatrixE2EContext,
|
||||
createLibraryWithFolder,
|
||||
createUserAndLogin,
|
||||
grantLibraryAccess,
|
||||
locateBookByAbsolutePath,
|
||||
uploadLibraryFile,
|
||||
type AuthorizationMatrixE2EContext,
|
||||
type TestUserSession,
|
||||
} from './e2e/authorization-matrix/authorization-matrix-harness';
|
||||
import { createEpubFixture, writeFixtureFile } from './e2e/authorization-matrix/authorization-matrix-fixture-builder';
|
||||
|
||||
type InjectResponse = Awaited<ReturnType<AuthorizationMatrixE2EContext['app']['inject']>>;
|
||||
|
||||
const SCENARIO_TIMEOUT_MS = 120_000;
|
||||
|
||||
function responseMessage(response: { message?: string | string[] }): string {
|
||||
if (Array.isArray(response.message)) return response.message.join(' ');
|
||||
return String(response.message ?? '');
|
||||
}
|
||||
|
||||
function expectError(response: InjectResponse, status: number, messageFragment?: string): void {
|
||||
expect(response.statusCode).toBe(status);
|
||||
if (!messageFragment) return;
|
||||
expect(responseMessage(response.json() as { message?: string | string[] })).toContain(messageFragment);
|
||||
}
|
||||
|
||||
function buildMultipartBody(file?: { fileName: string; content: Buffer; contentType?: string }): { body: Buffer; boundary: string } {
|
||||
const boundary = `----projectx-library-admin-${randomUUID()}`;
|
||||
if (!file) {
|
||||
return {
|
||||
boundary,
|
||||
body: Buffer.from(`--${boundary}--\r\n`, 'utf8'),
|
||||
};
|
||||
}
|
||||
|
||||
const preamble = Buffer.from(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.fileName}"\r\nContent-Type: ${
|
||||
file.contentType ?? 'application/octet-stream'
|
||||
}\r\n\r\n`,
|
||||
'utf8',
|
||||
);
|
||||
const closing = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8');
|
||||
return {
|
||||
boundary,
|
||||
body: Buffer.concat([preamble, file.content, closing]),
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadLibraryFileWithRawQuery(
|
||||
ctx: AuthorizationMatrixE2EContext,
|
||||
input: {
|
||||
token: string;
|
||||
libraryId: number;
|
||||
query?: string;
|
||||
file?: {
|
||||
fileName: string;
|
||||
content: Buffer;
|
||||
contentType?: string;
|
||||
};
|
||||
},
|
||||
): Promise<InjectResponse> {
|
||||
const { body, boundary } = buildMultipartBody(input.file);
|
||||
return ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/libraries/${input.libraryId}/upload${input.query ?? ''}`,
|
||||
headers: {
|
||||
...authHeader(input.token),
|
||||
'content-type': `multipart/form-data; boundary=${boundary}`,
|
||||
'content-length': String(body.length),
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
}
|
||||
|
||||
async function createUploadFixtureBytes(ctx: AuthorizationMatrixE2EContext, relativePath: string, title: string): Promise<Buffer> {
|
||||
const absolutePath = await createEpubFixture(ctx.fixture.rootPath, relativePath, { title });
|
||||
return readFile(absolutePath);
|
||||
}
|
||||
|
||||
async function waitForNoRunningScans(ctx: AuthorizationMatrixE2EContext, libraryId: number): Promise<void> {
|
||||
await waitForCondition(async () => {
|
||||
const running = await ctx.db
|
||||
.select({ id: schema.scanJobs.id })
|
||||
.from(schema.scanJobs)
|
||||
.where(and(eq(schema.scanJobs.libraryId, libraryId), eq(schema.scanJobs.status, 'running')));
|
||||
expect(running).toHaveLength(0);
|
||||
}, 15_000);
|
||||
}
|
||||
|
||||
async function startLibraryScanAs(
|
||||
ctx: AuthorizationMatrixE2EContext,
|
||||
token: string,
|
||||
libraryId: number,
|
||||
): Promise<typeof schema.scanJobs.$inferSelect> {
|
||||
const response = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/scanner/libraries/${libraryId}/scan`,
|
||||
headers: authHeader(token),
|
||||
});
|
||||
expect(response.statusCode).toBe(202);
|
||||
|
||||
const body = response.json() as { jobId?: number };
|
||||
expect(body.jobId).toEqual(expect.any(Number));
|
||||
return waitForScanCompletion(ctx.db, body.jobId!, 45_000);
|
||||
}
|
||||
|
||||
async function waitForCoverRefreshCalls(ctx: AuthorizationMatrixE2EContext, expectedCount: number): Promise<Array<[number, string, string]>> {
|
||||
await waitForCondition(() => {
|
||||
expect(ctx.metadataMock.refreshCoverForBook).toHaveBeenCalledTimes(expectedCount);
|
||||
}, 10_000);
|
||||
|
||||
return ctx.metadataMock.refreshCoverForBook.mock.calls as Array<[number, string, string]>;
|
||||
}
|
||||
|
||||
async function createLibraryViaApi(
|
||||
ctx: AuthorizationMatrixE2EContext,
|
||||
token: string,
|
||||
input?: Partial<{
|
||||
name: string;
|
||||
folders: string[];
|
||||
icon: string;
|
||||
watch: boolean;
|
||||
allowedFormats: string[];
|
||||
organizationMode: 'book_per_file' | 'book_per_folder';
|
||||
excludePatterns: string[];
|
||||
readingThreshold: number;
|
||||
markAsFinishedPercentComplete: number;
|
||||
fileWriteEnabled: boolean;
|
||||
}>,
|
||||
): Promise<{
|
||||
response: InjectResponse;
|
||||
body: {
|
||||
id: number;
|
||||
name: string;
|
||||
folders: Array<{ id: number; path: string }>;
|
||||
} & Record<string, unknown>;
|
||||
}> {
|
||||
const libraryFolderPath = input?.folders?.[0] ?? join(ctx.fixture.booksPath, `api-library-${randomUUID()}`);
|
||||
await mkdir(libraryFolderPath, { recursive: true });
|
||||
|
||||
const response = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/libraries',
|
||||
headers: authHeader(token),
|
||||
payload: {
|
||||
name: input?.name ?? `Library Admin ${randomUUID()}`,
|
||||
icon: input?.icon ?? 'library',
|
||||
folders: [libraryFolderPath],
|
||||
watch: input?.watch ?? false,
|
||||
allowedFormats: input?.allowedFormats ?? ['epub'],
|
||||
organizationMode: input?.organizationMode ?? 'book_per_file',
|
||||
excludePatterns: input?.excludePatterns ?? ['*.tmp'],
|
||||
readingThreshold: input?.readingThreshold ?? 0.4,
|
||||
markAsFinishedPercentComplete: input?.markAsFinishedPercentComplete ?? 95,
|
||||
fileWriteEnabled: input?.fileWriteEnabled ?? false,
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(201);
|
||||
|
||||
return {
|
||||
response,
|
||||
body: response.json() as {
|
||||
id: number;
|
||||
name: string;
|
||||
folders: Array<{ id: number; path: string }>;
|
||||
} & Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Library admin workflows (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
let ctx!: AuthorizationMatrixE2EContext;
|
||||
let manager!: TestUserSession;
|
||||
let scopedUser!: TestUserSession;
|
||||
let uploadOnlyUser!: TestUserSession;
|
||||
let noPermissionUser!: TestUserSession;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createAuthorizationMatrixE2EContext();
|
||||
|
||||
manager = await createUserAndLogin(ctx, {
|
||||
permissions: [Permission.ManageLibraries, Permission.LibraryUpload],
|
||||
});
|
||||
scopedUser = await createUserAndLogin(ctx);
|
||||
uploadOnlyUser = await createUserAndLogin(ctx, {
|
||||
permissions: [Permission.LibraryUpload],
|
||||
});
|
||||
noPermissionUser = await createUserAndLogin(ctx);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.metadataMock.extractAndSave.mockClear();
|
||||
ctx.metadataMock.refreshCoverForBook.mockClear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeAuthorizationMatrixE2EContext(ctx);
|
||||
});
|
||||
|
||||
describe('path and prescan', () => {
|
||||
it('lists valid directories, hides blocked or hidden entries, and returns prescan details for valid, overlapping, and missing paths', async () => {
|
||||
const overlapLibraryName = `library-overlap-${randomUUID()}`;
|
||||
const overlapLibrary = await createLibraryWithFolder(ctx, { name: overlapLibraryName });
|
||||
const visibleDir = join(ctx.fixture.booksPath, `path-visible-${randomUUID()}`);
|
||||
const hiddenDir = join(ctx.fixture.booksPath, `.path-hidden-${randomUUID()}`);
|
||||
await mkdir(visibleDir, { recursive: true });
|
||||
await mkdir(hiddenDir, { recursive: true });
|
||||
|
||||
const prescanValidDir = join(ctx.fixture.booksPath, `prescan-valid-${randomUUID()}`);
|
||||
await mkdir(prescanValidDir, { recursive: true });
|
||||
await createEpubFixture(prescanValidDir, 'prescan-book.epub', { title: 'Prescan Valid Title' });
|
||||
|
||||
const pathResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/path?path=${encodeURIComponent(ctx.fixture.booksPath)}`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
});
|
||||
|
||||
expect(pathResponse.statusCode).toBe(200);
|
||||
const pathBody = pathResponse.json() as Array<{ name: string; path: string }>;
|
||||
expect(pathBody).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
name: basename(overlapLibrary.folderPath),
|
||||
path: overlapLibrary.folderPath,
|
||||
},
|
||||
{
|
||||
name: basename(visibleDir),
|
||||
path: visibleDir,
|
||||
},
|
||||
{
|
||||
name: basename(prescanValidDir),
|
||||
path: prescanValidDir,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(pathBody.some((entry) => entry.name === basename(hiddenDir))).toBe(false);
|
||||
|
||||
const blockedPathResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/path?path=/proc',
|
||||
headers: authHeader(manager.accessToken),
|
||||
});
|
||||
expect(blockedPathResponse.statusCode).toBe(200);
|
||||
expect(blockedPathResponse.json()).toEqual([]);
|
||||
|
||||
const forbiddenPathResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/path?path=${encodeURIComponent(ctx.fixture.booksPath)}`,
|
||||
headers: authHeader(noPermissionUser.accessToken),
|
||||
});
|
||||
expect(forbiddenPathResponse.statusCode).toBe(403);
|
||||
|
||||
const prescanResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/libraries/prescan',
|
||||
headers: authHeader(manager.accessToken),
|
||||
payload: {
|
||||
paths: [prescanValidDir, overlapLibrary.folderPath, join(ctx.fixture.booksPath, `missing-${randomUUID()}`)],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prescanResponse.statusCode).toBe(201);
|
||||
const prescanBody = prescanResponse.json() as {
|
||||
totalFiles: number;
|
||||
paths: Array<{
|
||||
path: string;
|
||||
accessible: boolean;
|
||||
fileCount: number;
|
||||
overlapLibrary?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(prescanBody.totalFiles).toBe(1);
|
||||
expect(prescanBody.paths).toEqual([
|
||||
{
|
||||
path: prescanValidDir,
|
||||
accessible: true,
|
||||
fileCount: 1,
|
||||
overlapLibrary: undefined,
|
||||
error: undefined,
|
||||
},
|
||||
{
|
||||
path: overlapLibrary.folderPath,
|
||||
accessible: true,
|
||||
fileCount: 0,
|
||||
overlapLibrary: overlapLibraryName,
|
||||
error: undefined,
|
||||
},
|
||||
{
|
||||
path: expect.stringContaining('/missing-'),
|
||||
accessible: false,
|
||||
fileCount: 0,
|
||||
overlapLibrary: undefined,
|
||||
error: 'Path does not exist',
|
||||
},
|
||||
]);
|
||||
|
||||
const forbiddenPrescanResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/libraries/prescan',
|
||||
headers: authHeader(noPermissionUser.accessToken),
|
||||
payload: { paths: [prescanValidDir] },
|
||||
});
|
||||
expect(forbiddenPrescanResponse.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create library and manage access', () => {
|
||||
it('creates a library with the requested settings, requires explicit access rows, and reflects grant/update/revoke changes', async () => {
|
||||
const { body: createdLibrary } = await createLibraryViaApi(ctx, manager.accessToken, {
|
||||
name: `library-contract-${randomUUID()}`,
|
||||
});
|
||||
|
||||
expect(createdLibrary).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
name: expect.stringContaining('library-contract-'),
|
||||
icon: 'library',
|
||||
watch: false,
|
||||
allowedFormats: ['epub'],
|
||||
organizationMode: 'book_per_file',
|
||||
excludePatterns: ['*.tmp'],
|
||||
readingThreshold: 0.4,
|
||||
markAsFinishedPercentComplete: 95,
|
||||
fileWriteEnabled: false,
|
||||
});
|
||||
expect(createdLibrary.folders).toHaveLength(1);
|
||||
expect(createdLibrary.folders[0]).toMatchObject({
|
||||
id: expect.any(Number),
|
||||
path: expect.any(String),
|
||||
});
|
||||
|
||||
await waitForNoRunningScans(ctx, createdLibrary.id);
|
||||
|
||||
const managerLibrariesBeforeGrant = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/libraries',
|
||||
headers: authHeader(manager.accessToken),
|
||||
});
|
||||
expect(managerLibrariesBeforeGrant.statusCode).toBe(200);
|
||||
const visibleLibraryIdsBeforeGrant = (managerLibrariesBeforeGrant.json() as Array<{ id: number }>).map((library) => library.id);
|
||||
expect(visibleLibraryIdsBeforeGrant).not.toContain(createdLibrary.id);
|
||||
|
||||
const managerStatsBeforeGrant = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}/stats`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
});
|
||||
expectError(managerStatsBeforeGrant, 403, 'No library access');
|
||||
|
||||
const forbiddenAccessLookup = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}/access`,
|
||||
headers: authHeader(noPermissionUser.accessToken),
|
||||
});
|
||||
expect(forbiddenAccessLookup.statusCode).toBe(403);
|
||||
|
||||
const selfGrantResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}/access`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
payload: {
|
||||
userId: manager.userId,
|
||||
accessLevel: 'owner',
|
||||
},
|
||||
});
|
||||
expect(selfGrantResponse.statusCode).toBe(201);
|
||||
|
||||
const scopedGrantResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}/access`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
payload: {
|
||||
userId: scopedUser.userId,
|
||||
accessLevel: 'viewer',
|
||||
},
|
||||
});
|
||||
expect(scopedGrantResponse.statusCode).toBe(201);
|
||||
|
||||
const accessResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}/access`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
});
|
||||
expect(accessResponse.statusCode).toBe(200);
|
||||
expect(accessResponse.json()).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
userId: manager.userId,
|
||||
accessLevel: 'owner',
|
||||
username: manager.username,
|
||||
name: expect.any(String),
|
||||
},
|
||||
{
|
||||
userId: scopedUser.userId,
|
||||
accessLevel: 'viewer',
|
||||
username: scopedUser.username,
|
||||
name: expect.any(String),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const updateAccessResponse = await ctx.app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}/access/${scopedUser.userId}`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
payload: {
|
||||
accessLevel: 'editor',
|
||||
},
|
||||
});
|
||||
expect(updateAccessResponse.statusCode).toBe(200);
|
||||
|
||||
const accessAfterUpdate = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}/access`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
});
|
||||
expect(accessAfterUpdate.statusCode).toBe(200);
|
||||
expect(accessAfterUpdate.json()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
userId: scopedUser.userId,
|
||||
accessLevel: 'editor',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const managerLibrariesAfterGrant = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/libraries',
|
||||
headers: authHeader(manager.accessToken),
|
||||
});
|
||||
expect(managerLibrariesAfterGrant.statusCode).toBe(200);
|
||||
expect((managerLibrariesAfterGrant.json() as Array<{ id: number }>).map((library) => library.id)).toContain(createdLibrary.id);
|
||||
|
||||
const revokeAccessResponse = await ctx.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}/access/${scopedUser.userId}`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
});
|
||||
expect(revokeAccessResponse.statusCode).toBe(204);
|
||||
|
||||
const revokedLibraryResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}`,
|
||||
headers: authHeader(scopedUser.accessToken),
|
||||
});
|
||||
expectError(revokedLibraryResponse, 403, 'No library access');
|
||||
});
|
||||
});
|
||||
|
||||
describe('upload, scan, and stats', () => {
|
||||
it('enforces upload boundaries, persists uploaded files, scans direct file additions, and isolates stats after access revocation', async () => {
|
||||
const { body: createdLibrary } = await createLibraryViaApi(ctx, manager.accessToken, {
|
||||
name: `library-upload-${randomUUID()}`,
|
||||
});
|
||||
await waitForNoRunningScans(ctx, createdLibrary.id);
|
||||
|
||||
const libraryFolder = createdLibrary.folders[0]!;
|
||||
const otherLibrary = await createLibraryWithFolder(ctx, { name: `other-folder-${randomUUID()}` });
|
||||
|
||||
await grantLibraryAccess(ctx, manager.userId, createdLibrary.id, 'owner');
|
||||
await grantLibraryAccess(ctx, scopedUser.userId, createdLibrary.id, 'viewer');
|
||||
await grantLibraryAccess(ctx, noPermissionUser.userId, createdLibrary.id, 'viewer');
|
||||
|
||||
const uploadBytes = await createUploadFixtureBytes(ctx, `upload-fixtures/${randomUUID()}/uploaded-contract.epub`, 'Uploaded Contract Title');
|
||||
|
||||
const uploadForbiddenByAccess = await uploadLibraryFile(ctx, {
|
||||
token: uploadOnlyUser.accessToken,
|
||||
libraryId: createdLibrary.id,
|
||||
folderId: libraryFolder.id,
|
||||
fileName: 'uploaded-contract.epub',
|
||||
content: uploadBytes,
|
||||
contentType: 'application/epub+zip',
|
||||
});
|
||||
expectError(uploadForbiddenByAccess, 403, 'No access to this library');
|
||||
|
||||
const uploadForbiddenByPermission = await uploadLibraryFile(ctx, {
|
||||
token: noPermissionUser.accessToken,
|
||||
libraryId: createdLibrary.id,
|
||||
folderId: libraryFolder.id,
|
||||
fileName: 'uploaded-contract.epub',
|
||||
content: uploadBytes,
|
||||
contentType: 'application/epub+zip',
|
||||
});
|
||||
expect(uploadForbiddenByPermission.statusCode).toBe(403);
|
||||
|
||||
const invalidFolderIdUpload = await uploadLibraryFileWithRawQuery(ctx, {
|
||||
token: manager.accessToken,
|
||||
libraryId: createdLibrary.id,
|
||||
query: '?folderId=not-a-number',
|
||||
file: {
|
||||
fileName: 'invalid-folder.epub',
|
||||
content: uploadBytes,
|
||||
contentType: 'application/epub+zip',
|
||||
},
|
||||
});
|
||||
expectError(invalidFolderIdUpload, 400, 'Invalid folderId');
|
||||
|
||||
const wrongFolderUpload = await uploadLibraryFile(ctx, {
|
||||
token: manager.accessToken,
|
||||
libraryId: createdLibrary.id,
|
||||
folderId: otherLibrary.libraryFolderId,
|
||||
fileName: 'wrong-folder.epub',
|
||||
content: uploadBytes,
|
||||
contentType: 'application/epub+zip',
|
||||
});
|
||||
expectError(wrongFolderUpload, 400, 'Folder does not belong to this library');
|
||||
|
||||
const missingFileUpload = await uploadLibraryFileWithRawQuery(ctx, {
|
||||
token: manager.accessToken,
|
||||
libraryId: createdLibrary.id,
|
||||
});
|
||||
expectError(missingFileUpload, 400, 'No file provided');
|
||||
|
||||
const successfulUpload = await uploadLibraryFile(ctx, {
|
||||
token: manager.accessToken,
|
||||
libraryId: createdLibrary.id,
|
||||
folderId: libraryFolder.id,
|
||||
fileName: 'uploaded-contract.epub',
|
||||
content: uploadBytes,
|
||||
contentType: 'application/epub+zip',
|
||||
});
|
||||
expect(successfulUpload.statusCode).toBe(201);
|
||||
const uploadBody = successfulUpload.json() as {
|
||||
bookId: number;
|
||||
filename: string;
|
||||
format: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
expect(uploadBody).toEqual({
|
||||
bookId: expect.any(Number),
|
||||
filename: 'Uploaded Contract Title.epub',
|
||||
format: 'epub',
|
||||
sizeBytes: uploadBytes.length,
|
||||
});
|
||||
|
||||
await waitForCondition(() => {
|
||||
expect(ctx.metadataMock.extractAndSave).toHaveBeenCalledWith(uploadBody.bookId, expect.any(String), 'epub');
|
||||
}, 10_000);
|
||||
|
||||
const uploadedBook = await ctx.db.query.books.findFirst({
|
||||
where: eq(schema.books.id, uploadBody.bookId),
|
||||
});
|
||||
expect(uploadedBook).toMatchObject({
|
||||
libraryId: createdLibrary.id,
|
||||
libraryFolderId: libraryFolder.id,
|
||||
status: 'present',
|
||||
});
|
||||
|
||||
const uploadedBookFile = await ctx.db.query.bookFiles.findFirst({
|
||||
where: eq(schema.bookFiles.bookId, uploadBody.bookId),
|
||||
});
|
||||
expect(uploadedBookFile).toMatchObject({
|
||||
libraryFolderId: libraryFolder.id,
|
||||
format: 'epub',
|
||||
});
|
||||
expect(uploadedBookFile?.absolutePath).toContain('Uploaded Contract Title.epub');
|
||||
expect(uploadedBookFile?.relPath).toBe('Uploaded Contract Title.epub');
|
||||
|
||||
const uploadedMetadataRow = await ctx.db.query.bookMetadata.findFirst({
|
||||
where: eq(schema.bookMetadata.bookId, uploadBody.bookId),
|
||||
});
|
||||
expect(uploadedMetadataRow).toBeDefined();
|
||||
|
||||
const duplicateUpload = await uploadLibraryFile(ctx, {
|
||||
token: manager.accessToken,
|
||||
libraryId: createdLibrary.id,
|
||||
folderId: libraryFolder.id,
|
||||
fileName: 'uploaded-contract.epub',
|
||||
content: uploadBytes,
|
||||
contentType: 'application/epub+zip',
|
||||
});
|
||||
expectError(duplicateUpload, 409, 'already exists at the target location');
|
||||
|
||||
const scannedFilePath = await createEpubFixture(libraryFolder.path, 'scanned-discovered.epub', {
|
||||
title: 'Scanned Discovered Title',
|
||||
});
|
||||
|
||||
const scanResponseForbidden = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/scanner/libraries/${createdLibrary.id}/scan`,
|
||||
headers: authHeader(noPermissionUser.accessToken),
|
||||
});
|
||||
expect(scanResponseForbidden.statusCode).toBe(403);
|
||||
|
||||
const scanJob = await startLibraryScanAs(ctx, manager.accessToken, createdLibrary.id);
|
||||
expect(scanJob.status).toBe('completed');
|
||||
await waitForNoRunningScans(ctx, createdLibrary.id);
|
||||
|
||||
await waitForCondition(async () => {
|
||||
const presentBooks = await ctx.db.query.books.findMany({
|
||||
where: and(eq(schema.books.libraryId, createdLibrary.id), eq(schema.books.status, 'present')),
|
||||
});
|
||||
expect(presentBooks).toHaveLength(2);
|
||||
}, 10_000);
|
||||
|
||||
const scannedBook = await locateBookByAbsolutePath(ctx, scannedFilePath);
|
||||
expect(scannedBook.libraryId).toBe(createdLibrary.id);
|
||||
expect(scannedBook.format).toBe('epub');
|
||||
|
||||
const statsResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}/stats`,
|
||||
headers: authHeader(scopedUser.accessToken),
|
||||
});
|
||||
expect(statsResponse.statusCode).toBe(200);
|
||||
|
||||
const primaryFiles = await ctx.db
|
||||
.select({
|
||||
sizeBytes: schema.bookFiles.sizeBytes,
|
||||
})
|
||||
.from(schema.books)
|
||||
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId))
|
||||
.where(and(eq(schema.books.libraryId, createdLibrary.id), eq(schema.books.status, 'present')));
|
||||
const expectedTotalSize = primaryFiles.reduce((sum, row) => sum + Number(row.sizeBytes ?? 0), 0);
|
||||
|
||||
expect(statsResponse.json()).toEqual({
|
||||
totalBooks: 2,
|
||||
totalSizeBytes: expectedTotalSize,
|
||||
formatCounts: {
|
||||
epub: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const revokeScopedAccess = await ctx.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}/access/${scopedUser.userId}`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
});
|
||||
expect(revokeScopedAccess.statusCode).toBe(204);
|
||||
|
||||
const revokedStatsResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}/stats`,
|
||||
headers: authHeader(scopedUser.accessToken),
|
||||
});
|
||||
expectError(revokedStatsResponse, 403, 'No library access');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh covers, reorder, and delete', () => {
|
||||
it('queues cover refresh for each primary file, reorders libraries, and deletes a populated library while cleaning cover artifacts', async () => {
|
||||
const { body: createdLibrary } = await createLibraryViaApi(ctx, manager.accessToken, {
|
||||
name: `library-refresh-${randomUUID()}`,
|
||||
});
|
||||
await waitForNoRunningScans(ctx, createdLibrary.id);
|
||||
await grantLibraryAccess(ctx, manager.userId, createdLibrary.id, 'owner');
|
||||
|
||||
const reorderPeerLibrary = await createLibraryWithFolder(ctx, { name: `library-peer-${randomUUID()}` });
|
||||
const peerCoverLibrary = await createLibraryWithFolder(ctx, { name: `library-cover-peer-${randomUUID()}` });
|
||||
await grantLibraryAccess(ctx, manager.userId, reorderPeerLibrary.libraryId, 'owner');
|
||||
await grantLibraryAccess(ctx, manager.userId, peerCoverLibrary.libraryId, 'owner');
|
||||
|
||||
const uploadBytes = await createUploadFixtureBytes(ctx, `refresh-fixtures/${randomUUID()}/refresh-upload.epub`, 'Refresh Upload Title');
|
||||
const libraryFolder = createdLibrary.folders[0]!;
|
||||
|
||||
const uploadResponse = await uploadLibraryFile(ctx, {
|
||||
token: manager.accessToken,
|
||||
libraryId: createdLibrary.id,
|
||||
folderId: libraryFolder.id,
|
||||
fileName: 'refresh-upload.epub',
|
||||
content: uploadBytes,
|
||||
contentType: 'application/epub+zip',
|
||||
});
|
||||
expect(uploadResponse.statusCode).toBe(201);
|
||||
const uploadedBookId = (uploadResponse.json() as { bookId: number }).bookId;
|
||||
|
||||
const scannedFilePath = await createEpubFixture(libraryFolder.path, 'refresh-scanned.epub', {
|
||||
title: 'Refresh Scanned Title',
|
||||
});
|
||||
await startLibraryScanAs(ctx, manager.accessToken, createdLibrary.id);
|
||||
const scannedBook = await locateBookByAbsolutePath(ctx, scannedFilePath);
|
||||
|
||||
ctx.metadataMock.refreshCoverForBook.mockClear();
|
||||
const refreshCoversResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/scanner/libraries/${createdLibrary.id}/refresh-covers`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
});
|
||||
expect(refreshCoversResponse.statusCode).toBe(202);
|
||||
expect(refreshCoversResponse.json()).toEqual({ queued: 2 });
|
||||
|
||||
const refreshCalls = await waitForCoverRefreshCalls(ctx, 2);
|
||||
const normalizedCalls = refreshCalls
|
||||
.map(([bookId, absolutePath, format]) => ({
|
||||
bookId,
|
||||
absolutePath,
|
||||
format,
|
||||
}))
|
||||
.sort((left, right) => left.bookId - right.bookId);
|
||||
expect(normalizedCalls).toEqual([
|
||||
{
|
||||
bookId: uploadedBookId,
|
||||
absolutePath: expect.any(String),
|
||||
format: 'epub',
|
||||
},
|
||||
{
|
||||
bookId: scannedBook.bookId,
|
||||
absolutePath: expect.any(String),
|
||||
format: 'epub',
|
||||
},
|
||||
]);
|
||||
|
||||
await writeFixtureFile(ctx.fixture.booksPath, `covers/${uploadedBookId}/cover_custom.jpg`, Buffer.from('cover', 'utf8'));
|
||||
await writeFixtureFile(ctx.fixture.booksPath, `covers/${uploadedBookId}/thumbnail.jpg`, Buffer.from('thumb', 'utf8'));
|
||||
|
||||
const reorderResponse = await ctx.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/libraries/reorder',
|
||||
headers: authHeader(manager.accessToken),
|
||||
payload: {
|
||||
order: [
|
||||
{ id: createdLibrary.id, displayOrder: 9 },
|
||||
{ id: reorderPeerLibrary.libraryId, displayOrder: 1 },
|
||||
{ id: peerCoverLibrary.libraryId, displayOrder: 2 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(reorderResponse.statusCode).toBe(204);
|
||||
|
||||
const adminLibrariesResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/libraries',
|
||||
headers: authHeader(ctx.adminToken),
|
||||
});
|
||||
expect(adminLibrariesResponse.statusCode).toBe(200);
|
||||
const orderedLibraries = (adminLibrariesResponse.json() as Array<{ id: number; displayOrder: number }>).filter((library) =>
|
||||
[createdLibrary.id, reorderPeerLibrary.libraryId, peerCoverLibrary.libraryId].includes(library.id),
|
||||
);
|
||||
expect(orderedLibraries.map((library) => ({ id: library.id, displayOrder: library.displayOrder }))).toEqual([
|
||||
{ id: reorderPeerLibrary.libraryId, displayOrder: 1 },
|
||||
{ id: peerCoverLibrary.libraryId, displayOrder: 2 },
|
||||
{ id: createdLibrary.id, displayOrder: 9 },
|
||||
]);
|
||||
|
||||
const deleteResponse = await ctx.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}`,
|
||||
headers: authHeader(manager.accessToken),
|
||||
});
|
||||
expect(deleteResponse.statusCode).toBe(204);
|
||||
|
||||
const deletedLibraryResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/libraries/${createdLibrary.id}`,
|
||||
headers: authHeader(ctx.adminToken),
|
||||
});
|
||||
expectError(deletedLibraryResponse, 404, 'Library not found');
|
||||
|
||||
const deletedLibraryBooks = await ctx.db
|
||||
.select({ id: schema.books.id })
|
||||
.from(schema.books)
|
||||
.where(eq(schema.books.libraryId, createdLibrary.id));
|
||||
expect(deletedLibraryBooks).toHaveLength(0);
|
||||
|
||||
const deletedAccessRows = await ctx.db
|
||||
.select({ userId: schema.userLibraryAccess.userId })
|
||||
.from(schema.userLibraryAccess)
|
||||
.where(eq(schema.userLibraryAccess.libraryId, createdLibrary.id));
|
||||
expect(deletedAccessRows).toHaveLength(0);
|
||||
|
||||
await expect(access(join(ctx.fixture.booksPath, 'covers', String(uploadedBookId)))).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,412 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import * as schema from '../src/db/schema';
|
||||
import {
|
||||
authHeader,
|
||||
closeReaderStateIsolationE2EContext,
|
||||
createLibraryWithFolder,
|
||||
createReaderStateIsolationE2EContext,
|
||||
createUserAndLogin,
|
||||
grantLibraryAccess,
|
||||
locateBookByAbsolutePath,
|
||||
triggerAndWaitForLibraryScan,
|
||||
type LocatedBookFile,
|
||||
type ReaderStateIsolationE2EContext,
|
||||
type TestUserSession,
|
||||
} from './e2e/reader-state-isolation/reader-state-isolation-harness';
|
||||
import { createEpubFixture, createZipArchiveFixture } from './e2e/reader-state-isolation/reader-state-isolation-fixture-builder';
|
||||
|
||||
type InjectResponse = Awaited<ReturnType<ReaderStateIsolationE2EContext['app']['inject']>>;
|
||||
|
||||
const SCENARIO_TIMEOUT_MS = 120_000;
|
||||
const EPUB_STYLESHEET = 'body { background: #f5f1e8; color: #1f2937; }\nimg { max-width: 100%; }';
|
||||
const EPUB_BOOKMARKS = 'last-read=OPS/chapter.xhtml#intro';
|
||||
const PAGE_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+cQpUAAAAASUVORK5CYII=', 'base64');
|
||||
const PAGE_JPEG = Buffer.from(
|
||||
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBxAQEBAQEA8QEA8QDw8QEA8PDw8QFREWFhURFRUYHSggGBolGxUVITEhJSkrLi4uFx8zODMsNygtLisBCgoKDg0OGhAQGy0lICUtLS8tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLf/AABEIAAEAAgMBEQACEQEDEQH/xAAXAAEBAQEAAAAAAAAAAAAAAAABAgME/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEAMQAAAB6A//xAAYEAEAAwEAAAAAAAAAAAAAAAABABEhMf/aAAgBAQABBQJXJZ//xAAVEQEBAAAAAAAAAAAAAAAAAAABAP/aAAgBAwEBPwGn/8QAFREBAQAAAAAAAAAAAAAAAAAAARD/2gAIAQIBAT8Bp//EABgQAQADAQAAAAAAAAAAAAAAAAEAESEx/9oACAEBAAY/AhGQx//EABsQAQABBQEAAAAAAAAAAAAAAAERACExQVFh/9oACAEBAAE/IV2K4zGq4Jm1q//aAAwDAQACAAMAAAAQ8//EABcRAQEBAQAAAAAAAAAAAAAAAAEREDH/2gAIAQMBAT8Qw0f/xAAWEQEBAQAAAAAAAAAAAAAAAAABEBH/2gAIAQIBAT8QkL//xAAbEAEBAQADAQEAAAAAAAAAAAABEQAhMUFhcf/aAAgBAQABPxC4oLQ1M8JrIoYNewc19hXtOD87mpy4V/mQJu1WDVYj1WFJsbgx5caX//Z',
|
||||
'base64',
|
||||
);
|
||||
|
||||
function responseMessage(response: { message?: string | string[] }): string {
|
||||
if (Array.isArray(response.message)) return response.message.join(' ');
|
||||
return String(response.message ?? '');
|
||||
}
|
||||
|
||||
function expectError(response: InjectResponse, status: number, messageFragment?: string): void {
|
||||
expect(response.statusCode).toBe(status);
|
||||
if (!messageFragment) return;
|
||||
expect(responseMessage(response.json() as { message?: string | string[] })).toContain(messageFragment);
|
||||
}
|
||||
|
||||
function responseBuffer(response: InjectResponse): Buffer {
|
||||
const rawPayload = (response as unknown as { rawPayload?: unknown }).rawPayload;
|
||||
if (Buffer.isBuffer(rawPayload)) return rawPayload;
|
||||
return Buffer.from(response.body, 'binary');
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||
}
|
||||
|
||||
async function createReaderDeliveryEpub(rootPath: string, relativePath: string, title: string): Promise<string> {
|
||||
const identifier = `urn:uuid:${randomUUID()}`;
|
||||
const author = 'Reader Fixture Author';
|
||||
const publisher = 'Reader Fixture Publisher';
|
||||
const description = 'Reader delivery fixture description';
|
||||
const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml" />
|
||||
</rootfiles>
|
||||
</container>`;
|
||||
const opfXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package version="3.0" unique-identifier="uid" xmlns="http://www.idpf.org/2007/opf">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="uid">${escapeXml(identifier)}</dc:identifier>
|
||||
<dc:title>${escapeXml(title)}</dc:title>
|
||||
<dc:creator>${escapeXml(author)}</dc:creator>
|
||||
<dc:language>en</dc:language>
|
||||
<dc:publisher>${escapeXml(publisher)}</dc:publisher>
|
||||
<dc:description>${escapeXml(description)}</dc:description>
|
||||
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml" />
|
||||
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav" />
|
||||
<item id="style" href="styles/reader style.css" media-type="text/css" />
|
||||
<item id="cover" href="images/cover image.png" media-type="image/png" properties="cover-image" />
|
||||
</manifest>
|
||||
<spine>
|
||||
<itemref idref="chapter" />
|
||||
</spine>
|
||||
</package>`;
|
||||
const chapterXml = `<html xmlns="http://www.w3.org/1999/xhtml"><head><title>${escapeXml(title)}</title><link rel="stylesheet" type="text/css" href="styles/reader style.css" /></head><body><h1 id="intro">${escapeXml(title)}</h1><p>fixture chapter</p><img src="images/cover image.png" alt="cover" /></body></html>`;
|
||||
const navXml = `<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><head><title>${escapeXml(
|
||||
title,
|
||||
)}</title></head><body><nav epub:type="toc"><ol><li><a href="chapter.xhtml#intro">Start Reading</a></li></ol></nav></body></html>`;
|
||||
|
||||
return createZipArchiveFixture(rootPath, relativePath, [
|
||||
{ path: 'mimetype', content: 'application/epub+zip', store: true },
|
||||
{ path: 'META-INF/container.xml', content: containerXml },
|
||||
{ path: 'META-INF/calibre_bookmarks.txt', content: EPUB_BOOKMARKS },
|
||||
{ path: 'OPS/content.opf', content: opfXml },
|
||||
{ path: 'OPS/chapter.xhtml', content: chapterXml },
|
||||
{ path: 'OPS/nav.xhtml', content: navXml },
|
||||
{ path: 'OPS/styles/reader style.css', content: EPUB_STYLESHEET },
|
||||
{ path: 'OPS/images/cover image.png', content: PAGE_PNG },
|
||||
{ path: 'OPS/private.txt', content: 'hidden-unmanifested-entry' },
|
||||
]);
|
||||
}
|
||||
|
||||
async function createReaderDeliveryCbz(rootPath: string, relativePath: string, title: string): Promise<string> {
|
||||
const comicInfoXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
|
||||
<Title>${escapeXml(title)}</Title>
|
||||
<Writer>Reader Fixture Cartoonist</Writer>
|
||||
<Publisher>Reader Fixture Press</Publisher>
|
||||
<Year>2026</Year>
|
||||
</ComicInfo>`;
|
||||
|
||||
return createZipArchiveFixture(rootPath, relativePath, [
|
||||
{ path: 'ComicInfo.xml', content: comicInfoXml },
|
||||
{ path: 'pages/001-cover.png', content: PAGE_PNG },
|
||||
{ path: 'pages/002-spread.jpg', content: PAGE_JPEG },
|
||||
{ path: '.hidden/003-secret.png', content: PAGE_PNG },
|
||||
{ path: 'notes/readme.txt', content: 'not a page' },
|
||||
]);
|
||||
}
|
||||
|
||||
describe('Reader format delivery (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, () => {
|
||||
let ctx!: ReaderStateIsolationE2EContext;
|
||||
|
||||
let sharedLibrary!: Awaited<ReturnType<typeof createLibraryWithFolder>>;
|
||||
let hiddenLibrary!: Awaited<ReturnType<typeof createLibraryWithFolder>>;
|
||||
|
||||
let sharedEpub!: LocatedBookFile;
|
||||
let sharedCbz!: LocatedBookFile;
|
||||
let hiddenEpub!: LocatedBookFile;
|
||||
|
||||
let viewer!: TestUserSession;
|
||||
let crossLibraryUser!: TestUserSession;
|
||||
let outsider!: TestUserSession;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createReaderStateIsolationE2EContext();
|
||||
|
||||
sharedLibrary = await createLibraryWithFolder(ctx, { name: `reader-delivery-shared-${randomUUID()}` });
|
||||
hiddenLibrary = await createLibraryWithFolder(ctx, { name: `reader-delivery-hidden-${randomUUID()}` });
|
||||
|
||||
const sharedEpubPath = await createReaderDeliveryEpub(sharedLibrary.folderPath, 'delivery/reader-delivery.epub', 'Reader Delivery EPUB');
|
||||
const sharedCbzPath = await createReaderDeliveryCbz(sharedLibrary.folderPath, 'delivery/reader-delivery.cbz', 'Reader Delivery Comic');
|
||||
const hiddenEpubPath = await createEpubFixture(hiddenLibrary.folderPath, 'restricted/hidden-reader.epub', {
|
||||
title: 'Hidden Reader EPUB',
|
||||
});
|
||||
|
||||
await triggerAndWaitForLibraryScan(ctx, sharedLibrary.libraryId);
|
||||
await triggerAndWaitForLibraryScan(ctx, hiddenLibrary.libraryId);
|
||||
|
||||
sharedEpub = await locateBookByAbsolutePath(ctx, sharedEpubPath);
|
||||
sharedCbz = await locateBookByAbsolutePath(ctx, sharedCbzPath);
|
||||
hiddenEpub = await locateBookByAbsolutePath(ctx, hiddenEpubPath);
|
||||
|
||||
viewer = await createUserAndLogin(ctx);
|
||||
crossLibraryUser = await createUserAndLogin(ctx);
|
||||
outsider = await createUserAndLogin(ctx);
|
||||
|
||||
await grantLibraryAccess(ctx, viewer.userId, sharedLibrary.libraryId, 'viewer');
|
||||
await grantLibraryAccess(ctx, crossLibraryUser.userId, sharedLibrary.libraryId, 'viewer');
|
||||
await grantLibraryAccess(ctx, crossLibraryUser.userId, hiddenLibrary.libraryId, 'viewer');
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (ctx) {
|
||||
await closeReaderStateIsolationE2EContext(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
describe('EPUB info and asset delivery', () => {
|
||||
it('returns the EPUB info shape the reader expects and serves manifest and optional files', async () => {
|
||||
const infoResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${sharedEpub.bookFileId}`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expect(infoResponse.statusCode).toBe(200);
|
||||
|
||||
const info = infoResponse.json() as {
|
||||
containerPath: string;
|
||||
rootPath: string;
|
||||
coverPath: string | null;
|
||||
optionalFiles?: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
spine: Array<{ idref: string; href: string; mediaType: string; linear: boolean }>;
|
||||
manifest: Array<{ id: string; href: string; mediaType: string; size: number; properties?: string[] }>;
|
||||
toc: { label: string; children?: Array<{ label: string; href?: string }> } | null;
|
||||
};
|
||||
expect(info).toMatchObject({
|
||||
containerPath: 'OPS/content.opf',
|
||||
rootPath: 'OPS/',
|
||||
coverPath: 'OPS/images/cover image.png',
|
||||
optionalFiles: ['META-INF/calibre_bookmarks.txt'],
|
||||
metadata: {
|
||||
title: 'Reader Delivery EPUB',
|
||||
creator: 'Reader Fixture Author',
|
||||
language: 'en',
|
||||
publisher: 'Reader Fixture Publisher',
|
||||
description: 'Reader delivery fixture description',
|
||||
identifier: expect.stringContaining('urn:uuid:'),
|
||||
},
|
||||
spine: [
|
||||
{
|
||||
idref: 'chapter',
|
||||
href: 'OPS/chapter.xhtml',
|
||||
mediaType: 'application/xhtml+xml',
|
||||
linear: true,
|
||||
},
|
||||
],
|
||||
toc: {
|
||||
label: 'Table of Contents',
|
||||
children: [{ label: 'Start Reading', href: 'OPS/chapter.xhtml#intro' }],
|
||||
},
|
||||
});
|
||||
expect(info.manifest).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'chapter', href: 'OPS/chapter.xhtml', mediaType: 'application/xhtml+xml', size: expect.any(Number) }),
|
||||
expect.objectContaining({
|
||||
id: 'nav',
|
||||
href: 'OPS/nav.xhtml',
|
||||
mediaType: 'application/xhtml+xml',
|
||||
size: expect.any(Number),
|
||||
properties: ['nav'],
|
||||
}),
|
||||
expect.objectContaining({ id: 'style', href: 'OPS/styles/reader style.css', mediaType: 'text/css', size: expect.any(Number) }),
|
||||
expect.objectContaining({
|
||||
id: 'cover',
|
||||
href: 'OPS/images/cover image.png',
|
||||
mediaType: 'image/png',
|
||||
size: expect.any(Number),
|
||||
properties: ['cover-image'],
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const defaultFileInfoResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${sharedEpub.bookId}/info`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expect(defaultFileInfoResponse.statusCode).toBe(200);
|
||||
const defaultInfo = defaultFileInfoResponse.json() as { containerPath: string; coverPath: string | null };
|
||||
expect(defaultInfo.containerPath).toBe('OPS/content.opf');
|
||||
expect(defaultInfo.coverPath).toBe('OPS/images/cover image.png');
|
||||
|
||||
const chapterResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${sharedEpub.bookId}/file/OPS/chapter.xhtml`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expect(chapterResponse.statusCode).toBe(200);
|
||||
expect(chapterResponse.headers['content-type']).toContain('application/xhtml+xml');
|
||||
expect(chapterResponse.headers['cache-control']).toBe('public, max-age=3600');
|
||||
expect(chapterResponse.body).toContain('id="intro"');
|
||||
|
||||
const stylesheetResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${sharedEpub.bookId}/file/OPS/styles/reader%20style.css?fileId=${sharedEpub.bookFileId}`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expect(stylesheetResponse.statusCode).toBe(200);
|
||||
expect(stylesheetResponse.headers['content-type']).toContain('text/css');
|
||||
expect(stylesheetResponse.headers['content-length']).toBe(String(Buffer.byteLength(EPUB_STYLESHEET)));
|
||||
expect(stylesheetResponse.headers['cache-control']).toBe('public, max-age=3600');
|
||||
expect(stylesheetResponse.body).toBe(EPUB_STYLESHEET);
|
||||
|
||||
const optionalFileResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${sharedEpub.bookId}/file/META-INF/calibre_bookmarks.txt?fileId=${sharedEpub.bookFileId}`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expect(optionalFileResponse.statusCode).toBe(200);
|
||||
expect(optionalFileResponse.headers['content-type']).toContain('application/octet-stream');
|
||||
expect(optionalFileResponse.body).toBe(EPUB_BOOKMARKS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EPUB path guards and user scoping', () => {
|
||||
it('blocks traversal and unmanifested assets while enforcing library access boundaries', async () => {
|
||||
const hiddenInfoForbidden = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${hiddenEpub.bookId}/info?fileId=${hiddenEpub.bookFileId}`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expectError(hiddenInfoForbidden, 403, 'No access to this library');
|
||||
|
||||
const hiddenInfoAllowed = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${hiddenEpub.bookId}/info?fileId=${hiddenEpub.bookFileId}`,
|
||||
headers: authHeader(crossLibraryUser.accessToken),
|
||||
});
|
||||
expect(hiddenInfoAllowed.statusCode).toBe(200);
|
||||
expect((hiddenInfoAllowed.json() as { metadata: { title?: string } }).metadata.title).toBe('Hidden Reader EPUB');
|
||||
|
||||
const unmanifestedAssetResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${sharedEpub.bookId}/file/OPS/private.txt?fileId=${sharedEpub.bookFileId}`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expectError(unmanifestedAssetResponse, 404, 'Entry not in archive: OPS/private.txt');
|
||||
|
||||
const traversalResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${sharedEpub.bookId}/file/%252E%252E/OPS/chapter.xhtml?fileId=${sharedEpub.bookFileId}`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expectError(traversalResponse, 403, 'Invalid path');
|
||||
|
||||
const mismatchedFileResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${hiddenEpub.bookFileId}`,
|
||||
headers: authHeader(crossLibraryUser.accessToken),
|
||||
});
|
||||
expectError(mismatchedFileResponse, 404, `File ${hiddenEpub.bookFileId} not found for book ${sharedEpub.bookId}`);
|
||||
|
||||
const outsiderResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${sharedEpub.bookFileId}`,
|
||||
headers: authHeader(outsider.accessToken),
|
||||
});
|
||||
expectError(outsiderResponse, 403, 'No access to this library');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CBZ page delivery contract', () => {
|
||||
it('returns page counts, streams ordered pages, and rejects invalid page requests and unsupported formats', async () => {
|
||||
const pagesResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expect(pagesResponse.statusCode).toBe(200);
|
||||
expect(pagesResponse.json()).toEqual({ pageCount: 2 });
|
||||
|
||||
const firstPageResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages/0`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expect(firstPageResponse.statusCode).toBe(200);
|
||||
expect(firstPageResponse.headers['content-type']).toContain('image/png');
|
||||
expect(firstPageResponse.headers['cache-control']).toBe('public, max-age=31536000, immutable');
|
||||
expect(responseBuffer(firstPageResponse)).toEqual(PAGE_PNG);
|
||||
|
||||
const secondPageResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages/1`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expect(secondPageResponse.statusCode).toBe(200);
|
||||
expect(secondPageResponse.headers['content-type']).toContain('image/jpeg');
|
||||
expect(responseBuffer(secondPageResponse)).toEqual(PAGE_JPEG);
|
||||
|
||||
const negativePageResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages/-1`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expectError(negativePageResponse, 404, 'Page -1 out of range');
|
||||
|
||||
const overflowPageResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages/2`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expectError(overflowPageResponse, 404, 'Page 2 out of range');
|
||||
|
||||
const unsupportedFormatResponse = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/cbz/files/${sharedEpub.bookFileId}/pages`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expectError(unsupportedFormatResponse, 404, 'Unsupported comic format: epub');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reader access revocation', () => {
|
||||
it('applies library access changes immediately while leaving other users isolated', async () => {
|
||||
await ctx.db
|
||||
.delete(schema.userLibraryAccess)
|
||||
.where(and(eq(schema.userLibraryAccess.userId, viewer.userId), eq(schema.userLibraryAccess.libraryId, sharedLibrary.libraryId)));
|
||||
|
||||
const revokedEpubInfo = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${sharedEpub.bookFileId}`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expectError(revokedEpubInfo, 403, 'No access to this library');
|
||||
|
||||
const revokedCbzPages = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages`,
|
||||
headers: authHeader(viewer.accessToken),
|
||||
});
|
||||
expectError(revokedCbzPages, 403, 'No access to this library');
|
||||
|
||||
const unaffectedUserEpubInfo = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/epub/${sharedEpub.bookId}/info?fileId=${sharedEpub.bookFileId}`,
|
||||
headers: authHeader(crossLibraryUser.accessToken),
|
||||
});
|
||||
expect(unaffectedUserEpubInfo.statusCode).toBe(200);
|
||||
|
||||
const unaffectedUserCbzPages = await ctx.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/cbz/files/${sharedCbz.bookFileId}/pages`,
|
||||
headers: authHeader(crossLibraryUser.accessToken),
|
||||
});
|
||||
expect(unaffectedUserCbzPages.statusCode).toBe(200);
|
||||
expect(unaffectedUserCbzPages.json()).toEqual({ pageCount: 2 });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user