feat(scanner): enhance upload durability and add E2E test suites

This commit is contained in:
neon
2026-04-07 12:44:12 -06:00
parent ef82cb5d00
commit 54f2894762
15 changed files with 2763 additions and 67 deletions
+2 -2
View File
@@ -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",
+46 -6
View File
@@ -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();
});
+37 -11
View File
@@ -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);
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 };
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();
});
});
});
+610
View File
@@ -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}`);
});
});
});
+14 -10
View File
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
}
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 });
});
});
});