test(server): harden E2E suites across scanner, auth, and email modules

This commit is contained in:
neon
2026-04-04 23:32:25 -06:00
parent 585da64312
commit 578cfd584a
50 changed files with 825 additions and 301 deletions
+7 -5
View File
@@ -6,6 +6,7 @@ import {
InternalServerErrorException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
@@ -265,9 +266,6 @@ export class AuthService {
throw new UnauthorizedException();
}
// Rotate: revoke old, issue new
await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, row.id));
const userForToken = await this.db.query.users.findFirst({ where: eq(schema.users.id, row.userId) });
if (!userForToken) throw new UnauthorizedException();
if (!userForToken.active) {
@@ -277,6 +275,9 @@ export class AuthService {
throw new UnauthorizedException('Account disabled');
}
// Rotate: revoke old, issue new
await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, row.id));
const { accessToken, rawRefreshToken } = await this.issueTokenPair(row.userId, userForToken.tokenVersion);
this.setRefreshCookie(reply, rawRefreshToken);
this.setAccessCookie(reply, accessToken);
@@ -325,6 +326,7 @@ export class AuthService {
await this.oidcSessionRepo.revokeByUserId(userId);
if (!oidcSession.idTokenHint) return {};
const oidcLogoutStart = Date.now();
try {
const config = await this.appSettings.getOidcConfig();
if (!config.enabled) return {};
@@ -340,7 +342,7 @@ export class AuthService {
return { logoutUrl: `${disc.endSessionEndpoint}?${params.toString()}` };
} catch (error) {
const durationMs = 0;
const durationMs = Date.now() - oidcLogoutStart;
const errorClass = error instanceof Error ? error.name : 'UnknownError';
const errorMessage = error instanceof Error ? error.message : 'unknown error';
this.logger.warn(
@@ -368,7 +370,7 @@ export class AuthService {
const row = await this.db.query.refreshTokens.findFirst({
where: eq(schema.refreshTokens.id, sessionId),
});
if (!row) throw new UnauthorizedException();
if (!row) throw new NotFoundException('Session not found');
if (row.userId !== userId) throw new ForbiddenException('You do not have access to this session');
await this.db.update(schema.refreshTokens).set({ revokedAt: new Date() }).where(eq(schema.refreshTokens.id, sessionId));
}
@@ -1,7 +1,7 @@
import { BadRequestException, Inject, Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common';
import { basename, dirname, extname, join } from 'path';
import { access as fsAccess, readFile, stat, unlink } from 'fs/promises';
import { and, eq, ilike, inArray, or } from 'drizzle-orm';
import { and, eq, inArray, or, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { BookBucketAutoFinalizeMetadataMode, BookBucketFinalizeFileResult, BookBucketFinalizeResult, BookBucketMetadata } from '@projectx/types';
@@ -169,7 +169,7 @@ export class BookBucketFinalizeService implements OnModuleInit {
let bookId: number;
try {
const { size } = await stat(destPath);
const bookFolderPath = dirname(destPath) === folder.path ? destPath : dirname(destPath);
const bookFolderPath = dirname(destPath);
({ bookId } = await this.processor.createBookRecord(
libraryId,
folder.id,
@@ -278,7 +278,7 @@ export class BookBucketFinalizeService implements OnModuleInit {
.select({ bookId: bookMetadata.bookId })
.from(bookMetadata)
.innerJoin(books, eq(books.id, bookMetadata.bookId))
.where(and(eq(books.libraryId, libraryId), ilike(bookMetadata.title, meta.title)))
.where(and(eq(books.libraryId, libraryId), sql`lower(${bookMetadata.title}) = lower(${meta.title})`))
.limit(1);
if (existing) return existing.bookId;
+43 -1
View File
@@ -1,7 +1,48 @@
import { IsArray, IsBoolean, IsInt, IsNumber, IsObject, IsOptional, IsString, ValidateIf, ValidateNested } from 'class-validator';
import {
IsArray,
IsBoolean,
IsInt,
IsNumber,
IsObject,
IsOptional,
IsString,
ValidateIf,
ValidateNested,
type ValidationArguments,
registerDecorator,
type ValidationOptions,
type ValidatorConstraintInterface,
ValidatorConstraint,
} from 'class-validator';
import { Type } from 'class-transformer';
import type { BookBucketMetadata } from '@projectx/types';
@ValidatorConstraint({ name: 'hasCompleteDefaultDestination', async: false })
class HasCompleteDefaultDestinationConstraint implements ValidatorConstraintInterface {
validate(_: unknown, args?: ValidationArguments): boolean {
const dto = args?.object as FinalizeBookBucketDto | undefined;
if (!dto) return false;
return (dto.defaultLibraryId === undefined) === (dto.defaultFolderId === undefined);
}
defaultMessage(): string {
return 'defaultLibraryId and defaultFolderId must either both be provided or both be omitted';
}
}
function HasCompleteDefaultDestination(options?: ValidationOptions) {
return function (constructor: new (...args: unknown[]) => unknown) {
registerDecorator({
name: 'hasCompleteDefaultDestination',
target: constructor,
propertyName: '',
options,
constraints: [],
validator: HasCompleteDefaultDestinationConstraint,
});
};
}
export class UpdateBookBucketFileDto {
@IsOptional()
@IsObject()
@@ -31,6 +72,7 @@ class FinalizeOverrideDto {
folderId?: number;
}
@HasCompleteDefaultDestination()
export class FinalizeBookBucketDto {
@IsOptional()
@IsArray()
@@ -122,17 +122,20 @@ describe('EmailProviderRepository', () => {
});
});
it('setDefault marks only owner record and returns updated provider', () => {
it('setDefault atomically flips defaults within the owner scope and returns updated rows', () => {
const { db, updateBuilder } = makeDb();
const repo = new EmailProviderRepository(db as never);
void repo.setDefault(9, 4);
expect(db.update).toHaveBeenCalledWith(emailProviders);
expect(updateBuilder.set).toHaveBeenCalledWith({
isDefault: true,
updatedAt: expect.objectContaining({ op: 'sql', text: 'now()' }),
});
expect(updateBuilder.set).toHaveBeenCalledWith(
expect.objectContaining({
isDefault: expect.objectContaining({ op: 'sql', text: 'case when = then true else false end' }),
updatedAt: expect.objectContaining({ op: 'sql', text: 'now()' }),
}),
);
expect(updateBuilder.where).toHaveBeenCalledWith({ op: 'eq', left: emailProviders.userId, right: 4 });
expect(updateBuilder.returning).toHaveBeenCalled();
});
@@ -46,8 +46,11 @@ export class EmailProviderRepository {
setDefault(id: number, userId: number) {
return this.db
.update(emailProviders)
.set({ isDefault: true, updatedAt: sql`now()` })
.where(and(eq(emailProviders.id, id), eq(emailProviders.userId, userId)))
.set({
isDefault: sql`case when ${emailProviders.id} = ${id} then true else false end`,
updatedAt: sql`now()`,
})
.where(eq(emailProviders.userId, userId))
.returning();
}
@@ -142,7 +142,6 @@ describe('EmailProviderService', () => {
describe('setDefault', () => {
it('should set default provider', async () => {
const result = await service.setDefault(10, mockUser);
expect(repo.clearDefault).toHaveBeenCalledWith(1);
expect(repo.setDefault).toHaveBeenCalledWith(10, 1);
expect(result.id).toBe(10);
});
@@ -92,8 +92,7 @@ export class EmailProviderService {
async setDefault(id: number, user: RequestUser) {
await this.getOwned(id, user);
await this.repo.clearDefault(user.id);
const [updated] = await this.repo.setDefault(id, user.id);
const updated = (await this.repo.setDefault(id, user.id)).find((provider) => provider.id === id);
if (!updated) throw new NotFoundException('Provider not found');
return this.sanitize(updated);
}
@@ -3,12 +3,14 @@ import { Test, TestingModule } from '@nestjs/testing';
import { EmailRecipientGroupService } from './email-recipient-group.service';
import { EmailRecipientGroupRepository } from './email-recipient-group.repository';
import { EmailRecipientRepository } from './email-recipient.repository';
import { EmailTemplateService } from './email-template.service';
import type { RequestUser } from '../../common/types/request-user';
describe('EmailRecipientGroupService', () => {
let service: EmailRecipientGroupService;
let repo: EmailRecipientGroupRepository;
let recipientRepo: EmailRecipientRepository;
let templateService: EmailTemplateService;
const mockUser: RequestUser = {
id: 1,
@@ -52,12 +54,19 @@ describe('EmailRecipientGroupService', () => {
findById: vi.fn().mockResolvedValue([{ id: 100, userId: 1 }]),
},
},
{
provide: EmailTemplateService,
useValue: {
findOne: vi.fn().mockResolvedValue({ id: 8 }),
},
},
],
}).compile();
service = module.get<EmailRecipientGroupService>(EmailRecipientGroupService);
repo = module.get<EmailRecipientGroupRepository>(EmailRecipientGroupRepository);
recipientRepo = module.get<EmailRecipientRepository>(EmailRecipientRepository);
templateService = module.get<EmailTemplateService>(EmailTemplateService);
});
describe('findAll', () => {
@@ -86,6 +95,11 @@ describe('EmailRecipientGroupService', () => {
expect(result.id).toBe(10);
});
it('should validate template access when creating a group with a default template', async () => {
await service.create({ name: 'New Group', defaultTemplateId: 8 }, mockUser);
expect(templateService.findOne).toHaveBeenCalledWith(8, mockUser);
});
it('should map duplicate group names to ConflictException', async () => {
(repo.insert as vi.Mock).mockRejectedValue({ code: '23505' });
await expect(service.create({ name: 'New Group' }, mockUser)).rejects.toThrow(ConflictException);
@@ -100,6 +114,11 @@ describe('EmailRecipientGroupService', () => {
expect(result.id).toBe(10);
});
it('should validate template access when updating a group default template', async () => {
await service.update(10, { defaultTemplateId: 8 }, mockUser);
expect(templateService.findOne).toHaveBeenCalledWith(8, mockUser);
});
it('should map duplicate group names to ConflictException', async () => {
(repo.update as vi.Mock).mockRejectedValue({ code: '23505' });
await expect(service.update(10, { name: 'Updated' }, mockUser)).rejects.toThrow(ConflictException);
@@ -3,6 +3,7 @@ import { ConflictException, ForbiddenException, Injectable, NotFoundException }
import type { RequestUser } from '../../common/types/request-user';
import { EmailRecipientGroupRepository } from './email-recipient-group.repository';
import { EmailRecipientRepository } from './email-recipient.repository';
import { EmailTemplateService } from './email-template.service';
import { CreateEmailRecipientGroupDto } from './dto/create-email-recipient-group.dto';
import { UpdateEmailRecipientGroupDto } from './dto/update-email-recipient-group.dto';
import type { EmailRecipientGroup } from '../../db/schema';
@@ -13,6 +14,7 @@ export class EmailRecipientGroupService {
constructor(
private readonly repo: EmailRecipientGroupRepository,
private readonly recipientRepo: EmailRecipientRepository,
private readonly templateService: EmailTemplateService,
) {}
async findAll(user: RequestUser) {
@@ -43,6 +45,7 @@ export class EmailRecipientGroupService {
}
async create(dto: CreateEmailRecipientGroupDto, user: RequestUser) {
await this.validateDefaultTemplate(dto.defaultTemplateId, user);
try {
const [created] = await this.repo.insert({
userId: user.id,
@@ -60,6 +63,7 @@ export class EmailRecipientGroupService {
async update(id: number, dto: UpdateEmailRecipientGroupDto, user: RequestUser) {
await this.getOwned(id, user);
await this.validateDefaultTemplate(dto.defaultTemplateId, user);
try {
const [updated] = await this.repo.update(id, user.id, dto);
if (!updated) throw new NotFoundException('Group not found');
@@ -103,4 +107,9 @@ export class EmailRecipientGroupService {
if (group.userId !== user.id) throw new ForbiddenException('Cannot modify this group');
return group;
}
private async validateDefaultTemplate(templateId: number | null | undefined, user: RequestUser): Promise<void> {
if (templateId === null || templateId === undefined) return;
await this.templateService.findOne(templateId, user);
}
}
@@ -142,7 +142,13 @@ describe('EmailRecipientRepository', () => {
void repo.setDefault(11, 3);
expect(db.update).toHaveBeenCalledWith(emailRecipients);
expect(updateBuilder.set).toHaveBeenCalledWith({ isDefault: true });
expect(updateBuilder.set).toHaveBeenCalledWith(
expect.objectContaining({
isDefault: expect.objectContaining({ op: 'sql', parts: expect.any(Array), values: [emailRecipients.id, 11] }),
updatedAt: expect.objectContaining({ op: 'sql', parts: expect.any(Array), values: [] }),
}),
);
expect(updateBuilder.where).toHaveBeenCalledWith({ op: 'eq', left: emailRecipients.userId, right: 3 });
expect(updateBuilder.returning).toHaveBeenCalled();
});
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq, inArray } from 'drizzle-orm';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -48,8 +48,11 @@ export class EmailRecipientRepository {
setDefault(id: number, userId: number): Promise<EmailRecipientRow[]> {
return this.db
.update(emailRecipients)
.set({ isDefault: true })
.where(and(eq(emailRecipients.id, id), eq(emailRecipients.userId, userId)))
.set({
isDefault: sql`case when ${emailRecipients.id} = ${id} then true else false end`,
updatedAt: sql`now()`,
})
.where(eq(emailRecipients.userId, userId))
.returning();
}
@@ -1,12 +1,14 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { EmailRecipientService } from './email-recipient.service';
import { EmailRecipientRepository } from './email-recipient.repository';
import { EmailTemplateService } from './email-template.service';
import type { RequestUser } from '../../common/types/request-user';
describe('EmailRecipientService', () => {
let service: EmailRecipientService;
let repo: EmailRecipientRepository;
let templateService: EmailTemplateService;
const mockUser: RequestUser = {
id: 1,
@@ -42,11 +44,18 @@ describe('EmailRecipientService', () => {
setDefault: vi.fn().mockResolvedValue([mockRecipient]),
},
},
{
provide: EmailTemplateService,
useValue: {
findOne: vi.fn().mockResolvedValue({ id: 99 }),
},
},
],
}).compile();
service = module.get<EmailRecipientService>(EmailRecipientService);
repo = module.get<EmailRecipientRepository>(EmailRecipientRepository);
templateService = module.get<EmailTemplateService>(EmailTemplateService);
});
it('should find all for user', async () => {
@@ -67,6 +76,16 @@ describe('EmailRecipientService', () => {
expect(result.id).toBe(10);
});
it('should validate template access when creating a recipient with a default template', async () => {
await service.create({ name: 'New', email: 'new@test.com', defaultTemplateId: 99 }, mockUser);
expect(templateService.findOne).toHaveBeenCalledWith(99, mockUser);
});
it('should propagate template access errors when creating a recipient with a default template', async () => {
(templateService.findOne as vi.Mock).mockRejectedValueOnce(new ForbiddenException('No access to this template'));
await expect(service.create({ name: 'New', email: 'new@test.com', defaultTemplateId: 99 }, mockUser)).rejects.toThrow(ForbiddenException);
});
it('should map duplicate recipient emails to ConflictException on create', async () => {
(repo.insert as vi.Mock).mockRejectedValue({ code: '23505' });
await expect(service.create({ name: 'New', email: 'new@test.com' }, mockUser)).rejects.toThrow(ConflictException);
@@ -79,6 +98,11 @@ describe('EmailRecipientService', () => {
expect(result.id).toBe(10);
});
it('should validate template access when updating a recipient default template', async () => {
await service.update(10, { defaultTemplateId: 99 }, mockUser);
expect(templateService.findOne).toHaveBeenCalledWith(99, mockUser);
});
it('should remove a recipient', async () => {
await service.remove(10, mockUser);
expect(repo.delete).toHaveBeenCalledWith(10, 1);
@@ -86,7 +110,6 @@ describe('EmailRecipientService', () => {
it('should set default recipient', async () => {
const result = await service.setDefault(10, mockUser);
expect(repo.clearDefault).toHaveBeenCalledWith(1);
expect(repo.setDefault).toHaveBeenCalledWith(10, 1);
expect(result.id).toBe(10);
});
@@ -2,6 +2,7 @@ import { ConflictException, ForbiddenException, Injectable, NotFoundException }
import type { RequestUser } from '../../common/types/request-user';
import { EmailRecipientRepository } from './email-recipient.repository';
import { EmailTemplateService } from './email-template.service';
import { CreateEmailRecipientDto } from './dto/create-email-recipient.dto';
import { UpdateEmailRecipientDto } from './dto/update-email-recipient.dto';
import type { EmailRecipient } from '../../db/schema';
@@ -9,7 +10,10 @@ import { isUniqueViolation } from './email-db-error.util';
@Injectable()
export class EmailRecipientService {
constructor(private readonly repo: EmailRecipientRepository) {}
constructor(
private readonly repo: EmailRecipientRepository,
private readonly templateService: EmailTemplateService,
) {}
findAll(user: RequestUser) {
return this.repo.findAllForUser(user.id);
@@ -20,6 +24,7 @@ export class EmailRecipientService {
}
async create(dto: CreateEmailRecipientDto, user: RequestUser) {
await this.validateDefaultTemplate(dto.defaultTemplateId, user);
try {
const [created] = await this.repo.insert({
userId: user.id,
@@ -40,6 +45,7 @@ export class EmailRecipientService {
async update(id: number, dto: UpdateEmailRecipientDto, user: RequestUser) {
await this.getOwnedById(id, user);
await this.validateDefaultTemplate(dto.defaultTemplateId, user);
try {
const [updated] = await this.repo.update(id, user.id, dto);
if (!updated) throw new NotFoundException('Recipient not found');
@@ -59,8 +65,7 @@ export class EmailRecipientService {
async setDefault(id: number, user: RequestUser) {
await this.getOwnedById(id, user);
await this.repo.clearDefault(user.id);
const [updated] = await this.repo.setDefault(id, user.id);
const updated = (await this.repo.setDefault(id, user.id)).find((recipient) => recipient.id === id);
if (!updated) throw new NotFoundException('Recipient not found');
return updated;
}
@@ -85,4 +90,9 @@ export class EmailRecipientService {
return uniqueIds.map((id) => recipientById.get(id)!);
}
private async validateDefaultTemplate(templateId: number | null | undefined, user: RequestUser): Promise<void> {
if (templateId === null || templateId === undefined) return;
await this.templateService.findOne(templateId, user);
}
}
@@ -127,16 +127,16 @@ describe('EmailTemplateRepository', () => {
});
void repo.setDefault(22, 4);
expect(updateBuilder.set).toHaveBeenLastCalledWith({
isDefault: true,
updatedAt: expect.objectContaining({ op: 'sql', text: 'now()' }),
});
expect(updateBuilder.set).toHaveBeenLastCalledWith(
expect.objectContaining({
isDefault: expect.objectContaining({ op: 'sql', text: 'case when = then true else false end' }),
updatedAt: expect.objectContaining({ op: 'sql', text: 'now()' }),
}),
);
expect(updateBuilder.where).toHaveBeenLastCalledWith({
op: 'and',
clauses: [
{ op: 'eq', left: emailTemplates.id, right: 22 },
{ op: 'eq', left: emailTemplates.userId, right: 4 },
],
op: 'eq',
left: emailTemplates.userId,
right: 4,
});
void repo.delete(22, 4);
@@ -70,8 +70,11 @@ export class EmailTemplateRepository {
setDefault(id: number, userId: number) {
return this.db
.update(emailTemplates)
.set({ isDefault: true, updatedAt: sql`now()` })
.where(and(eq(emailTemplates.id, id), eq(emailTemplates.userId, userId)))
.set({
isDefault: sql`case when ${emailTemplates.id} = ${id} then true else false end`,
updatedAt: sql`now()`,
})
.where(eq(emailTemplates.userId, userId))
.returning();
}
@@ -1,4 +1,4 @@
import { ConflictException, ForbiddenException } from '@nestjs/common';
import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { EmailTemplateService } from './email-template.service';
import { EmailTemplateRepository } from './email-template.repository';
@@ -144,7 +144,6 @@ describe('EmailTemplateService', () => {
describe('setDefault', () => {
it('should set owned template as default', async () => {
const result = await service.setDefault(10, mockUser);
expect(repo.clearDefault).toHaveBeenCalledWith(1);
expect(repo.setDefault).toHaveBeenCalledWith(10, 1);
expect(result.id).toBe(10);
});
@@ -186,6 +185,11 @@ describe('EmailTemplateService', () => {
await expect(service.resolveTemplate(10, mockUser)).rejects.toThrow(ForbiddenException);
});
it('should throw NotFoundException if a specific template is requested but missing', async () => {
(repo.findById as vi.Mock).mockResolvedValue([]);
await expect(service.resolveTemplate(999, mockUser)).rejects.toThrow(NotFoundException);
});
it('should return user default if no template requested', async () => {
const result = await service.resolveTemplate(null, mockUser);
expect(result.id).toBe(10);
@@ -79,12 +79,12 @@ export class EmailTemplateService {
if (template.userId !== user.id && !template.isSystem) {
throw new ForbiddenException('Cannot set this template as default');
}
await this.repo.clearDefault(user.id);
if (!template.isSystem) {
const [updated] = await this.repo.setDefault(id, user.id);
const updated = (await this.repo.setDefault(id, user.id)).find((row) => row.id === id);
if (!updated) throw new NotFoundException('Template not found');
return updated;
}
await this.repo.clearDefault(user.id);
return template;
}
@@ -96,14 +96,15 @@ export class EmailTemplateService {
}
async resolveTemplate(templateId: number | null | undefined, user: RequestUser): Promise<EmailTemplate> {
if (templateId) {
if (templateId !== null && templateId !== undefined) {
const [tpl] = await this.repo.findById(templateId);
if (tpl) {
if (tpl.userId !== null && tpl.userId !== user.id) {
throw new ForbiddenException('No access to the specified email template');
}
return tpl;
if (!tpl) {
throw new NotFoundException('Template not found');
}
if (tpl.userId !== null && tpl.userId !== user.id) {
throw new ForbiddenException('No access to the specified email template');
}
return tpl;
}
const [userDefault] = await this.repo.findUserDefault(user.id);
if (userDefault) return userDefault;
@@ -40,7 +40,8 @@ export class FileWriteRepository {
})
.from(books)
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.where(and(eq(books.libraryId, libraryId), ne(books.status, 'missing')));
.where(and(eq(books.libraryId, libraryId), ne(books.status, 'missing')))
.orderBy(asc(books.id));
}
async loadPayload(bookId: number) {
@@ -25,6 +25,7 @@ export class FileWriteService implements OnModuleDestroy {
private readonly debounceMs: number;
private readonly maxConcurrentWrites: number;
private readonly debounceMap = new Map<number, NodeJS.Timeout>();
private readonly scheduledWriteRuns = new Set<Promise<unknown>>();
private readonly writeQueue: Array<() => void> = [];
private activeWrites = 0;
@@ -53,24 +54,40 @@ export class FileWriteService implements OnModuleDestroy {
this.logger.debug(
`[${FILE_WRITE_SCHEDULE_EVENT}] [end] bookId=${bookId} triggeredBy=${triggeredBy} userId=${formatUserId(userId)} - scheduled file write fired`,
);
this.writeToFile(bookId, triggeredBy, userId).catch((err: Error) =>
this.logger.warn(
`[${FILE_WRITE_SCHEDULE_EVENT}] [fail] bookId=${bookId} triggeredBy=${triggeredBy} userId=${formatUserId(userId)} errorClass=${err.name} error="${sanitizeErrorMessage(err.message)}" - scheduled file write failed`,
),
);
const run = this.writeToFile(bookId, triggeredBy, userId)
.catch((err: Error) =>
this.logger.warn(
`[${FILE_WRITE_SCHEDULE_EVENT}] [fail] bookId=${bookId} triggeredBy=${triggeredBy} userId=${formatUserId(userId)} errorClass=${err.name} error="${sanitizeErrorMessage(err.message)}" - scheduled file write failed`,
),
)
.finally(() => {
this.scheduledWriteRuns.delete(run);
});
this.scheduledWriteRuns.add(run);
}, this.debounceMs);
this.debounceMap.set(bookId, timer);
}
onModuleDestroy(): void {
for (const timer of this.debounceMap.values()) clearTimeout(timer);
this.debounceMap.clear();
this.clearScheduledWrites();
for (const release of this.writeQueue) {
release();
}
this.writeQueue.length = 0;
}
async drainScheduledWritesForTests(): Promise<void> {
this.clearScheduledWrites();
while (this.scheduledWriteRuns.size > 0) {
await Promise.allSettled([...this.scheduledWriteRuns]);
}
}
private clearScheduledWrites(): void {
for (const timer of this.debounceMap.values()) clearTimeout(timer);
this.debounceMap.clear();
}
async writeToFile(bookId: number, triggeredBy: 'auto' | 'sync', userId?: number, dryRun = false): Promise<WriteResult> {
await this.acquireWriteSlot();
@@ -0,0 +1,61 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { KoboBookAccessService } from './kobo-book-access.service';
describe('KoboBookAccessService', () => {
function createService(options: {
user?: { isSuperuser: boolean } | undefined;
accessRows?: Array<{ libraryId: number }>;
book?: { id: number; libraryId: number } | undefined;
}) {
const db = {
query: {
users: {
findFirst: vi.fn().mockResolvedValue(options.user),
},
books: {
findFirst: vi.fn().mockResolvedValue(options.book),
},
},
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(options.accessRows ?? []),
}),
}),
};
return {
service: new KoboBookAccessService(db as never),
db,
};
}
it('throws not found when the book does not exist', async () => {
const { service } = createService({
user: { isSuperuser: false },
book: undefined,
});
await expect(service.assertBookAccessible(7, 99)).rejects.toThrow(NotFoundException);
});
it('throws forbidden when the user lacks access to the book library', async () => {
const { service } = createService({
user: { isSuperuser: false },
accessRows: [{ libraryId: 1 }],
book: { id: 42, libraryId: 2 },
});
await expect(service.assertBookAccessible(7, 42)).rejects.toThrow(ForbiddenException);
});
it('allows access when the user can reach the book library', async () => {
const { service } = createService({
user: { isSuperuser: false },
accessRows: [{ libraryId: 2 }],
book: { id: 42, libraryId: 2 },
});
await expect(service.assertBookAccessible(7, 42)).resolves.toBeUndefined();
});
});
@@ -1,4 +1,4 @@
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
import { ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
@@ -31,7 +31,9 @@ export class KoboBookAccessService {
columns: { id: true, libraryId: true },
});
if (!book) return;
if (!book) {
throw new NotFoundException('Book not found');
}
const accessibleLibraryIds = await this.getAccessibleLibraryIds(userId);
if (accessibleLibraryIds !== null && !accessibleLibraryIds.includes(book.libraryId)) {
@@ -276,12 +276,21 @@ describe('LibraryService', () => {
});
it('writeMetadataToFiles blocks non-dry-run when file write is disabled', async () => {
libraryRepo.findById.mockResolvedValue([{ id: 1, name: 'L' }]);
fileWriteService.resolveSettings.mockResolvedValue({ enabled: false });
await expect(service.writeMetadataToFiles(1, 7, false)).rejects.toBeInstanceOf(BadRequestException);
});
it('writeMetadataToFiles throws when the library does not exist', async () => {
libraryRepo.findById.mockResolvedValue([]);
await expect(service.writeMetadataToFiles(404, 7, true)).rejects.toBeInstanceOf(NotFoundException);
expect(fileWriteService.findNonMissingPrimaryFilesByLibrary).not.toHaveBeenCalled();
});
it('writeMetadataToFiles emits progress and returns summary counters', async () => {
libraryRepo.findById.mockResolvedValue([{ id: 1, name: 'L' }]);
fileWriteService.resolveSettings.mockResolvedValue({ enabled: true });
fileWriteService.findNonMissingPrimaryFilesByLibrary.mockResolvedValue([{ bookId: 1 }, { bookId: 2 }, { bookId: 3 }]);
fileWriteService.writeToFile
@@ -299,6 +308,7 @@ describe('LibraryService', () => {
});
it('writeMetadataToFiles stops when cancellation is requested', async () => {
libraryRepo.findById.mockResolvedValue([{ id: 1, name: 'L' }]);
fileWriteService.resolveSettings.mockResolvedValue({ enabled: true });
fileWriteService.findNonMissingPrimaryFilesByLibrary.mockResolvedValue([{ bookId: 1 }, { bookId: 2 }]);
fileWriteService.writeToFile.mockResolvedValue({ status: 'success', fieldsWritten: [], durationMs: 1 });
@@ -280,6 +280,9 @@ export class LibraryService {
dryRun: boolean,
options: LibraryMetadataWriteStreamOptions = {},
): Promise<LibraryMetadataWriteSummary> {
const [library] = await this.libraryRepo.findById(libraryId);
if (!library) throw new NotFoundException('Library not found');
if (!dryRun) {
const settings = await this.fileWriteService.resolveSettings(libraryId);
if (!settings.enabled) {
@@ -118,6 +118,16 @@ describe('parsePdfFile', () => {
);
});
it('can skip cover extraction when only metadata is needed', async () => {
mockExtractXmpXml.mockReturnValue(null);
const parsed = await parsePdfFile('/books/book.pdf', { extractCover: false });
expect(parsed).toEqual(expect.objectContaining({ title: 'Info Title', coverBuffer: null }));
expect(mockExecFile).not.toHaveBeenCalled();
expect(mockReadFile).toHaveBeenCalledTimes(1);
});
it('returns null coverBuffer when pdftoppm command fails, but still returns metadata', async () => {
mockExtractXmpXml.mockReturnValue(null);
mockExecFile.mockImplementation((_file: string, _args: string[], cb: (err: Error | null) => void) => cb(new Error('pdftoppm missing')));
@@ -34,6 +34,10 @@ export interface PdfParsed {
coverBuffer: Buffer | null;
}
interface PdfParseOptions {
extractCover?: boolean;
}
function clean(value: string | undefined): string | null {
if (!value) return null;
const s = value.trim();
@@ -64,7 +68,7 @@ async function extractPdfCover(absolutePath: string): Promise<Buffer | null> {
}
}
export async function parsePdfFile(absolutePath: string): Promise<PdfParsed | null> {
export async function parsePdfFile(absolutePath: string, options: PdfParseOptions = {}): Promise<PdfParsed | null> {
try {
const buf = await readFile(absolutePath);
const doc = await PDFDocument.load(buf, { ignoreEncryption: true });
@@ -88,7 +92,7 @@ export async function parsePdfFile(absolutePath: string): Promise<PdfParsed | nu
.map((name) => ({ name, sortName: null }))
: [];
const coverBuffer = await extractPdfCover(absolutePath);
const coverBuffer = options.extractCover === false ? null : await extractPdfCover(absolutePath);
return {
title: hasXmp ? xmp.title : infoTitle,
@@ -142,6 +142,14 @@ describe('OpdsAuthGuard', () => {
expect((request.opdsUser as OpdsRequestUser).userId).toBe(1);
});
it('rejects a same-length tampered cover token', async () => {
const guard = makeGuard();
const token = createCoverToken(1, TEST_SECRET);
const tamperedToken = `${token.slice(0, -1)}${token.endsWith('0') ? '1' : '0'}`;
const { context } = mockContext(undefined, tamperedToken, '/api/v1/opds/42/cover');
await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
});
it('attaches opdsUser to request on success', async () => {
const guard = makeGuard();
const { context, request } = mockContext(basicHeader('reader', 'pass'));
+6 -2
View File
@@ -1,4 +1,4 @@
import { createHmac } from 'crypto';
import { createHmac, timingSafeEqual } from 'crypto';
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@@ -39,7 +39,11 @@ function parseCoverToken(token: string, secret: string): number | null {
const userId = parseInt(token.slice(0, dot), 10);
if (isNaN(userId) || userId <= 0) return null;
const expected = createCoverToken(userId, secret);
if (token !== expected) return null;
const tokenBuffer = Buffer.from(token);
const expectedBuffer = Buffer.from(expected);
if (tokenBuffer.length !== expectedBuffer.length || !timingSafeEqual(tokenBuffer, expectedBuffer)) {
return null;
}
return userId;
}
@@ -2,5 +2,5 @@ import { IsObject } from 'class-validator';
export class UpsertPreferenceDto {
@IsObject()
settings: Record<string, unknown>;
settings!: Record<string, unknown>;
}
@@ -225,7 +225,7 @@ export class FileEventProcessorService {
if (!match || match.file.absolutePath === newAbsolutePath) return { type: 'noop' };
const { file, libraryId: rowLibraryId, folderPath: oldFolderPath, libraryFolderPath } = match;
const newFolderPath = oldFolderPath === file.absolutePath ? newAbsolutePath : dirname(newAbsolutePath);
const newFolderPath = dirname(newAbsolutePath) === libraryFolderPath ? newAbsolutePath : dirname(newAbsolutePath);
if (newFolderPath !== oldFolderPath) {
const targetBooks = await this.scannerRepo.findBooksByFolderPath(newFolderPath, rowLibraryId);
const hasFolderCollision = targetBooks.some((book) => book.id !== file.bookId && book.folderPath === newFolderPath);
+35 -17
View File
@@ -51,16 +51,19 @@ function naturalCompare(a: string, b: string): number {
return 0;
}
function matchesExcludePattern(name: string, patterns: string[]): boolean {
for (const pattern of patterns) {
if (!pattern.includes('*')) {
if (name === pattern) return true;
} else {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
if (new RegExp(`^${escaped}$`).test(name)) return true;
function buildExcludeMatcher(patterns: string[]): (name: string) => boolean {
if (patterns.length === 0) return () => false;
const compiled = patterns.map((p) => {
if (!p.includes('*')) return { literal: p, regex: null as RegExp | null };
const escaped = p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
return { literal: null as string | null, regex: new RegExp(`^${escaped}$`) };
});
return (name: string) => {
for (const { literal, regex } of compiled) {
if (literal !== null ? name === literal : regex!.test(name)) return true;
}
}
return false;
return false;
};
}
// Recursively collect files, grouped by their parent directory.
@@ -68,7 +71,7 @@ async function collectByDir(
dir: string,
libraryRoot: string,
acc: Map<string, FileStat[]>,
excludePatterns: string[],
shouldExclude: (name: string) => boolean,
logger?: (msg: string) => void,
): Promise<void> {
let entries;
@@ -90,7 +93,7 @@ async function collectByDir(
const full = join(dir, entry.name);
if (entry.name.startsWith('.')) continue;
if (excludePatterns.length > 0 && matchesExcludePattern(entry.name, excludePatterns)) continue;
if (shouldExclude(entry.name)) continue;
if (entry.isDirectory()) {
subdirs.push(full);
@@ -104,8 +107,18 @@ async function collectByDir(
}
if (filePaths.length > 0) {
const stats = await Promise.all(filePaths.map(async (full) => ({ full, s: await stat(full) })));
for (const { full, s } of stats) {
const statResults = await Promise.all(
filePaths.map(async (full) => {
const s = await stat(full).catch((err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') return null;
throw err;
});
return s ? { full, s } : null;
}),
);
for (const entry of statResults) {
if (!entry) continue;
const { full, s } = entry;
if (!acc.has(dir)) acc.set(dir, []);
acc.get(dir)!.push({
absolutePath: full,
@@ -117,7 +130,7 @@ async function collectByDir(
}
}
await Promise.all(subdirs.map((full) => collectByDir(full, libraryRoot, acc, excludePatterns, logger)));
await Promise.all(subdirs.map((full) => collectByDir(full, libraryRoot, acc, shouldExclude, logger)));
}
/**
@@ -143,7 +156,8 @@ export async function findBookCandidates(
logger?: (msg: string) => void,
): Promise<BookCandidate[]> {
const byDir = new Map<string, FileStat[]>();
await collectByDir(libraryFolderPath, libraryFolderPath, byDir, excludePatterns, logger);
const shouldExclude = buildExcludeMatcher(excludePatterns);
await collectByDir(libraryFolderPath, libraryFolderPath, byDir, shouldExclude, logger);
// Flatten disc subdirectories (e.g. "CD 1", "Disc 2") into their parent.
// Collect disc dirs first to avoid mutating the map while iterating.
@@ -234,7 +248,8 @@ export async function findLooseFileCandidates(
logger?: (msg: string) => void,
): Promise<BookCandidate[]> {
const byDir = new Map<string, FileStat[]>();
await collectByDir(libraryFolderPath, libraryFolderPath, byDir, excludePatterns, logger);
const shouldExclude = buildExcludeMatcher(excludePatterns);
await collectByDir(libraryFolderPath, libraryFolderPath, byDir, shouldExclude, logger);
const candidates: BookCandidate[] = [];
@@ -272,13 +287,14 @@ export async function buildSingleBookCandidate(
return null;
}
const shouldExclude = buildExcludeMatcher(excludePatterns);
const filePaths: string[] = [];
const discDirs: string[] = [];
const nonDiscDirs: { name: string; path: string }[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
if (excludePatterns.length > 0 && matchesExcludePattern(entry.name, excludePatterns)) continue;
if (shouldExclude(entry.name)) continue;
const full = join(folderPath, entry.name);
if (entry.isDirectory()) {
if (isDiscDirectory(entry.name)) {
@@ -295,6 +311,7 @@ export async function buildSingleBookCandidate(
const discEntries = await readdir(discDir, { withFileTypes: true }).catch(() => []);
for (const entry of discEntries) {
if (!entry.isFile() || entry.name.startsWith('.')) continue;
if (shouldExclude(entry.name)) continue;
const full = join(discDir, entry.name);
if (full.length <= MAX_PATH_LENGTH) filePaths.push(full);
}
@@ -310,6 +327,7 @@ export async function buildSingleBookCandidate(
const stemEntries = await readdir(stemDir, { withFileTypes: true }).catch(() => []);
for (const entry of stemEntries) {
if (!entry.isFile() || entry.name.startsWith('.')) continue;
if (shouldExclude(entry.name)) continue;
const full = join(stemDir, entry.name);
if (full.length <= MAX_PATH_LENGTH) filePaths.push(full);
}
@@ -12,7 +12,7 @@ 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 { SystemMailService } from '../src/modules/email/system-mail.service';
import type { Db } from './e2e/app-harness';
import { makeMetadataNoopMock, type Db } from './e2e/app-harness';
type CookieJar = Map<string, string>;
@@ -94,19 +94,6 @@ function cookieHeader(jar: CookieJar): string {
.join('; ');
}
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 defaultDiscoveryDoc() {
return {
issuer: 'https://issuer.example',
@@ -586,6 +573,30 @@ describe('Auth recovery and OIDC logout hardening (e2e)', () => {
});
describe('OIDC logout', () => {
it('clears cookies and revokes session for local user without OIDC session', async () => {
const user = await createUser(context.db);
const session = await login(context.app, user.username, user.password);
const logoutResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
headers: { cookie: cookieHeader(session.jar) },
});
expect(logoutResponse.statusCode).toBe(200);
expect(logoutResponse.json()).toEqual({});
const logoutCookies = getSetCookieLines(logoutResponse.headers);
expect(cookieValue(logoutCookies, 'refresh_token')).toBe('');
expect(cookieValue(logoutCookies, 'access_token')).toBe('');
const refreshAfterLogout = await context.app.inject({
method: 'POST',
url: '/api/v1/auth/refresh',
headers: { cookie: `refresh_token=${session.refreshToken}` },
});
expect(refreshAfterLogout.statusCode).toBe(401);
});
it('returns provider logout URL and revokes local OIDC session', async () => {
const user = await createUser(context.db);
const session = await login(context.app, user.username, user.password);
+9 -14
View File
@@ -10,6 +10,7 @@ import { AppModule } from '../src/app.module';
import { DB } from '../src/db';
import * as schema from '../src/db/schema';
import { MetadataService } from '../src/modules/metadata/metadata.service';
import { makeMetadataNoopMock } from './e2e/app-harness';
import type { Db } from './e2e/app-harness';
type CookieJar = Map<string, string>;
@@ -101,19 +102,6 @@ function cookieHeader(jar: CookieJar): string {
.join('; ');
}
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 createAuthE2EContext(): Promise<AuthE2EContext> {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
@@ -241,7 +229,11 @@ describe('Auth session security (e2e)', () => {
expect(refreshCookieLine).toBeDefined();
expect(accessCookieLine).toBeDefined();
expect(cookieAttribute(refreshCookieLine!, 'path')).toBe('/api/v1/auth');
expect(cookieAttribute(refreshCookieLine!, 'httponly')).toBe('');
expect(cookieAttribute(refreshCookieLine!, 'samesite')).toBe('Strict');
expect(cookieAttribute(accessCookieLine!, 'path')).toBe('/api');
expect(cookieAttribute(accessCookieLine!, 'httponly')).toBe('');
expect(cookieAttribute(accessCookieLine!, 'samesite')).toBe('Lax');
const statusAfter = await context.app.inject({
method: 'GET',
@@ -369,6 +361,9 @@ describe('Auth session security (e2e)', () => {
expect(cookieValue(responseCookies, 'refresh_token')).toBe('');
expect(cookieValue(responseCookies, 'access_token')).toBe('');
const tokenRow = await findRefreshTokenByRawToken(context.db, loginResult.refreshToken);
expect(tokenRow?.revokedAt).toBeNull();
const meResponse = await context.app.inject({
method: 'GET',
url: '/api/v1/auth/me',
@@ -704,7 +699,7 @@ describe('Auth session security (e2e)', () => {
authorization: `Bearer ${sessionB.accessToken}`,
},
});
expect(missingSessionResponse.statusCode).toBe(401);
expect(missingSessionResponse.statusCode).toBe(404);
const activeSessionRefresh = await context.app.inject({
method: 'POST',
+4 -1
View File
@@ -804,7 +804,10 @@ describe('Authorization matrix (e2e)', () => {
method: 'POST',
url: '/api/v1/collections',
headers: authHeader(personas.ownerUser.accessToken),
payload: { name: `authz-collection-${randomUUID()}` },
payload: {
name: `authz-collection-${randomUUID()}`,
icon: 'FolderOpen',
},
});
expect(createdCollection.statusCode).toBe(201);
const collectionId = (createdCollection.json() as { id: number }).id;
@@ -308,6 +308,7 @@ describe('Book Bucket ingest + finalize (e2e)', () => {
.select({
id: schema.books.id,
libraryId: schema.books.libraryId,
folderPath: schema.books.folderPath,
})
.from(schema.books)
.where(eq(schema.books.id, finalizedBookId))
@@ -324,6 +325,7 @@ describe('Book Bucket ingest + finalize (e2e)', () => {
.limit(1);
expect(bookFile).toBeDefined();
expect(bookFile!.absolutePath.startsWith(destination.folderPath)).toBe(true);
expect(book?.folderPath).toBe(dirname(bookFile!.absolutePath));
expect(await fileExists(bookFile!.absolutePath)).toBe(true);
expect(await fileExists(bookBucketRow.absolutePath)).toBe(false);
@@ -423,6 +425,93 @@ describe('Book Bucket ingest + finalize (e2e)', () => {
expect(await getBookBucketRow(context, successRow.id)).toBeUndefined();
});
it('finalize stores a folder path even when the naming pattern is flat', async () => {
const destination = await createLibraryWithFolder(context);
await context.db.update(schema.libraries).set({ fileNamingPattern: '{title}' }).where(eq(schema.libraries.id, destination.libraryId));
const bookBucketRow = await createBookBucketRow(context, {
fileName: 'flat-pattern.fb2',
selectedMetadata: {
title: 'Flat Pattern Title',
authors: ['Flat Pattern Author'],
},
targetLibraryId: destination.libraryId,
targetFolderId: destination.libraryFolderId,
});
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/book-bucket/finalize',
headers: authHeader(context.adminToken),
payload: {
fileIds: [bookBucketRow.id],
},
});
expect(response.statusCode).toBe(201);
const body = response.json() as BookBucketFinalizeResult;
expect(body).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
const finalizedBookId = body.results[0]!.bookId!;
const [book] = await context.db
.select({
folderPath: schema.books.folderPath,
})
.from(schema.books)
.where(eq(schema.books.id, finalizedBookId))
.limit(1);
const [bookFile] = await context.db
.select({
absolutePath: schema.bookFiles.absolutePath,
})
.from(schema.bookFiles)
.where(eq(schema.bookFiles.bookId, finalizedBookId))
.limit(1);
expect(book?.folderPath).toBe(destination.folderPath);
expect(book?.folderPath).toBe(dirname(bookFile!.absolutePath));
});
it('finalize treats title duplicates as exact matches instead of wildcard patterns', async () => {
const destination = await createLibraryWithFolder(context);
const seedRow = await createBookBucketRow(context, {
fileName: 'seed-wildcard-duplicate.fb2',
selectedMetadata: { title: 'The Real Title', authors: ['Seed Author'] },
targetLibraryId: destination.libraryId,
targetFolderId: destination.libraryFolderId,
});
const seedResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/book-bucket/finalize',
headers: authHeader(context.adminToken),
payload: { fileIds: [seedRow.id] },
});
expect(seedResponse.statusCode).toBe(201);
expect((seedResponse.json() as BookBucketFinalizeResult).results[0]?.success).toBe(true);
const wildcardTitleRow = await createBookBucketRow(context, {
fileName: 'wildcard-title.fb2',
selectedMetadata: { title: 'The %', authors: ['Wildcard Author'] },
targetLibraryId: destination.libraryId,
targetFolderId: destination.libraryFolderId,
});
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/book-bucket/finalize',
headers: authHeader(context.adminToken),
payload: { fileIds: [wildcardTitleRow.id] },
});
expect(response.statusCode).toBe(201);
const body = response.json() as BookBucketFinalizeResult;
expect(body).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
expect(body.results[0]).toMatchObject({ fileId: wildcardTitleRow.id, success: true });
expect(body.results[0]?.isDuplicate).toBeUndefined();
});
it('finalize selectAll honors status/search filters and excluded ids', async () => {
const destination = await createLibraryWithFolder(context);
@@ -586,6 +675,21 @@ describe('Book Bucket ingest + finalize (e2e)', () => {
message: 'targetLibraryId and targetFolderId must both be set or both be null',
});
const finalizeValidationResponse = await context.app.inject({
method: 'POST',
url: '/api/v1/book-bucket/finalize',
headers: authHeader(context.adminToken),
payload: {
fileIds: [123],
defaultLibraryId: 1,
},
});
expect(finalizeValidationResponse.statusCode).toBe(400);
expect(finalizeValidationResponse.json()).toMatchObject({
message: expect.arrayContaining(['defaultLibraryId and defaultFolderId must either both be provided or both be omitted']),
});
const retriableErrorRow = await createBookBucketRow(context, {
fileName: 'retry-fetch.fb2',
status: 'error',
+22 -18
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'crypto';
import { expect } from 'vitest';
import fastifyCookie from '@fastify/cookie';
import { count, eq, isNull, sql } from 'drizzle-orm';
import { count, eq, inArray, isNull, sql } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
@@ -27,7 +27,7 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function makeMetadataNoopMock(): Pick<
export function makeMetadataNoopMock(): Pick<
MetadataService,
'extractAndSave' | 'refreshCoverForBook' | 'extractAudioFileDuration' | 'aggregateAudioDuration' | 'extractAudioChaptersAndNarrators'
> {
@@ -181,26 +181,30 @@ export async function loadLibraryBookState(db: Db, libraryId: number): Promise<L
.from(books)
.where(eq(books.libraryId, libraryId));
const states: LibraryBookState[] = [];
if (bookRows.length === 0) return [];
for (const row of bookRows) {
const fileRows = await db.select({ absolutePath: bookFiles.absolutePath }).from(bookFiles).where(eq(bookFiles.bookId, row.id));
const bookIds = bookRows.map((r) => r.id);
const allFileRows = await db
.select({ bookId: bookFiles.bookId, id: bookFiles.id, absolutePath: bookFiles.absolutePath })
.from(bookFiles)
.where(inArray(bookFiles.bookId, bookIds));
let primaryPath: string | null = null;
if (row.primaryFileId !== null) {
const [primary] = await db.select({ absolutePath: bookFiles.absolutePath }).from(bookFiles).where(eq(bookFiles.id, row.primaryFileId)).limit(1);
primaryPath = primary?.absolutePath ?? null;
}
states.push({
folderPath: row.folderPath,
status: row.status,
primaryPath,
filePaths: fileRows.map((fileRow) => fileRow.absolutePath).sort(),
});
const filesByBookId = new Map<number, { id: number; absolutePath: string }[]>();
const fileById = new Map<number, string>();
for (const f of allFileRows) {
if (!filesByBookId.has(f.bookId)) filesByBookId.set(f.bookId, []);
filesByBookId.get(f.bookId)!.push({ id: f.id, absolutePath: f.absolutePath });
fileById.set(f.id, f.absolutePath);
}
return states.sort((a, b) => a.folderPath.localeCompare(b.folderPath));
return bookRows
.map((row) => ({
folderPath: row.folderPath,
status: row.status,
primaryPath: row.primaryFileId !== null ? (fileById.get(row.primaryFileId) ?? null) : null,
filePaths: (filesByBookId.get(row.id) ?? []).map((f) => f.absolutePath).sort(),
}))
.sort((a, b) => a.folderPath.localeCompare(b.folderPath));
}
export interface IntegritySnapshot {
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
import { hash } from 'bcryptjs';
import fastifyCookie from '@fastify/cookie';
import fastifyMultipart from '@fastify/multipart';
import { ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { mkdir, stat } from 'fs/promises';
@@ -10,6 +11,7 @@ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { Permission, BookBucketMetadata } 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';
@@ -23,6 +25,7 @@ import {
} from './authorization-matrix-fixture-builder';
type Db = NodePgDatabase<typeof schema>;
const TEST_PASSWORD_HASH_ROUNDS = 4;
const ADMIN_SETUP_DTO = {
username: 'authorization-matrix-e2e-admin',
@@ -114,6 +117,14 @@ export async function createAuthorizationMatrixE2EContext(): Promise<Authorizati
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.register(fastifyMultipart as never);
await app.init();
@@ -207,7 +218,7 @@ export async function createUserAndLogin(
const username = options.username ?? `authorization-matrix-user-${suffix}`;
const password = options.password ?? 'AuthorizationMatrixUser123';
const email = options.email ?? `${username}@example.com`;
const passwordHash = await hash(password, 12);
const passwordHash = await hash(password, TEST_PASSWORD_HASH_ROUNDS);
const [created] = await ctx.db
.insert(schema.users)
@@ -370,7 +381,7 @@ export async function createOpdsUser(
): Promise<{ row: typeof schema.opdsUsers.$inferSelect; password: string }> {
const username = input.username ?? `opds-${randomUUID().replaceAll('-', '').slice(0, 12)}`;
const password = input.password ?? 'OpdsPassword123';
const passwordHash = await hash(password, 12);
const passwordHash = await hash(password, TEST_PASSWORD_HASH_ROUNDS);
const [row] = await ctx.db
.insert(schema.opdsUsers)
@@ -474,7 +485,7 @@ async function getAdminToken(app: NestFastifyApplication, db: Db): Promise<strin
const suffix = randomUUID().replaceAll('-', '');
const fallbackUsername = `authorization-matrix-admin-${suffix}`;
const fallbackPassword = ADMIN_SETUP_DTO.password;
const passwordHash = await hash(fallbackPassword, 12);
const passwordHash = await hash(fallbackPassword, TEST_PASSWORD_HASH_ROUNDS);
await db.insert(schema.users).values({
username: fallbackUsername,
@@ -1,10 +1,10 @@
{
"totalRoutes": 319,
"totalRoutes": 320,
"byPermission": {
"(none)": 161,
"Permission.ManageMetadataConfig": 32,
"Permission.EmailSend": 30,
"Permission.LibraryEditMetadata": 17,
"Permission.LibraryEditMetadata": 18,
"Permission.BookBucketAccess": 17,
"Permission.ManageAppSettings": 16,
"Permission.ManageLibraries": 13,
@@ -41,9 +41,7 @@
"methodName": "refreshEnrichment",
"httpMethod": "POST",
"path": "/authors/:id/enrichment/refresh",
"permissions": [
"Permission.LibraryEditMetadata"
],
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -55,9 +53,7 @@
"methodName": "update",
"httpMethod": "PATCH",
"path": "/authors/:id",
"permissions": [
"Permission.LibraryEditMetadata"
],
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -69,9 +65,7 @@
"methodName": "merge",
"httpMethod": "POST",
"path": "/authors/merge",
"permissions": [
"Permission.LibraryEditMetadata"
],
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -83,9 +77,7 @@
"methodName": "delete",
"httpMethod": "DELETE",
"path": "/authors",
"permissions": [
"Permission.LibraryEditMetadata"
],
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -97,9 +89,7 @@
"methodName": "embedAll",
"httpMethod": "POST",
"path": "/books/embed-all",
"permissions": [
"Permission.ManageAppSettings"
],
"permissions": ["Permission.ManageAppSettings"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -111,9 +101,7 @@
"methodName": "deleteBooks",
"httpMethod": "DELETE",
"path": "/books",
"permissions": [
"Permission.LibraryDeleteBooks"
],
"permissions": ["Permission.LibraryDeleteBooks"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -149,9 +137,7 @@
"methodName": "bulkRefreshMetadata",
"httpMethod": "POST",
"path": "/books/bulk-refresh-metadata",
"permissions": [
"Permission.LibraryEditMetadata"
],
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -163,9 +149,7 @@
"methodName": "bulkReExtractCover",
"httpMethod": "POST",
"path": "/books/bulk-re-extract-cover",
"permissions": [
"Permission.LibraryEditMetadata"
],
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -177,9 +161,7 @@
"methodName": "reExtractCover",
"httpMethod": "POST",
"path": "/books/:id/re-extract-cover",
"permissions": [
"Permission.LibraryEditMetadata"
],
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -191,9 +173,7 @@
"methodName": "exportBooks",
"httpMethod": "POST",
"path": "/books/export",
"permissions": [
"Permission.LibraryDownload"
],
"permissions": ["Permission.LibraryDownload"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -289,9 +269,19 @@
"methodName": "updateMetadata",
"httpMethod": "PATCH",
"path": "/books/:id/metadata",
"permissions": [
"Permission.LibraryEditMetadata"
],
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book/book.controller.ts",
"className": "BookController",
"methodName": "updateMetadataLocks",
"httpMethod": "PATCH",
"path": "/books/:id/metadata-locks",
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -303,9 +293,7 @@
"methodName": "refreshMetadata",
"httpMethod": "POST",
"path": "/books/:id/refresh-metadata",
"permissions": [
"Permission.LibraryEditMetadata"
],
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -317,9 +305,7 @@
"methodName": "getWriteLog",
"httpMethod": "GET",
"path": "/books/:id/write-log",
"permissions": [
"Permission.LibraryEditMetadata"
],
"permissions": ["Permission.LibraryEditMetadata"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -331,9 +317,7 @@
"methodName": "getKoboState",
"httpMethod": "GET",
"path": "/books/:id/kobo-state",
"permissions": [
"Permission.KoboSync"
],
"permissions": ["Permission.KoboSync"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -381,9 +365,7 @@
"methodName": "getConfig",
"httpMethod": "GET",
"path": "/book-metadata-fetch/config",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -395,9 +377,7 @@
"methodName": "updateConfig",
"httpMethod": "PUT",
"path": "/book-metadata-fetch/config",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -409,9 +389,7 @@
"methodName": "getLibraryConfig",
"httpMethod": "GET",
"path": "/book-metadata-fetch/config/libraries/:id",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -423,9 +401,7 @@
"methodName": "updateLibraryConfig",
"httpMethod": "PUT",
"path": "/book-metadata-fetch/config/libraries/:id",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -437,9 +413,7 @@
"methodName": "getStatus",
"httpMethod": "GET",
"path": "/book-metadata-fetch/status",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -451,9 +425,7 @@
"methodName": "triggerGlobal",
"httpMethod": "POST",
"path": "/book-metadata-fetch/run",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -465,9 +437,7 @@
"methodName": "triggerForLibrary",
"httpMethod": "POST",
"path": "/book-metadata-fetch/run/:libraryId",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -479,9 +449,7 @@
"methodName": "pause",
"httpMethod": "POST",
"path": "/book-metadata-fetch/pause",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -493,9 +461,7 @@
"methodName": "resume",
"httpMethod": "POST",
"path": "/book-metadata-fetch/resume",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -507,9 +473,7 @@
"methodName": "cancel",
"httpMethod": "POST",
"path": "/book-metadata-fetch/cancel",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -521,9 +485,7 @@
"methodName": "retryFailed",
"httpMethod": "POST",
"path": "/book-metadata-fetch/retry-failed",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -535,9 +497,7 @@
"methodName": "previewCount",
"httpMethod": "POST",
"path": "/book-metadata-fetch/preview-count",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -549,9 +509,7 @@
"methodName": "getFailedItems",
"httpMethod": "GET",
"path": "/book-metadata-fetch/failed",
"permissions": [
"Permission.ManageMetadataConfig"
],
"permissions": ["Permission.ManageMetadataConfig"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
@@ -173,7 +173,7 @@ export async function createUserAndLogin(
const username = options.username ?? `book-bucket-user-${suffix}`;
const password = options.password ?? 'BookBucketUser123';
const email = options.email ?? `${username}@example.com`;
const passwordHash = await hash(password, 12);
const passwordHash = await hash(password, 4);
const [created] = await ctx.db
.insert(schema.users)
@@ -342,7 +342,7 @@ async function getAdminToken(app: NestFastifyApplication, db: Db): Promise<strin
const suffix = randomUUID().replaceAll('-', '');
const fallbackUsername = `book-bucket-e2e-admin-${suffix}`;
const fallbackPassword = ADMIN_SETUP_DTO.password;
const passwordHash = await hash(fallbackPassword, 12);
const passwordHash = await hash(fallbackPassword, 4);
await db.insert(schema.users).values({
username: fallbackUsername,
@@ -298,33 +298,35 @@ export async function readBookMutationState(ctx: MetadataLockE2EContext, bookId:
ctx.db.select().from(comicMetadata).where(eq(comicMetadata.bookId, bookId)).limit(1),
]);
const meta = metadataRows[0];
const comic = comicRows[0] ?? null;
return {
metadata: metadataRows[0]
metadata: meta
? {
...metadataRows[0],
title: metadataRows[0].title ?? null,
subtitle: metadataRows[0].subtitle ?? null,
description: metadataRows[0].description ?? null,
publisher: metadataRows[0].publisher ?? null,
publishedYear: metadataRows[0].publishedYear ?? null,
language: metadataRows[0].language ?? null,
pageCount: metadataRows[0].pageCount ?? null,
coverSource: metadataRows[0].coverSource ?? null,
goodreadsId: metadataRows[0].goodreadsId ?? null,
openLibraryId: metadataRows[0].openLibraryId ?? null,
durationSeconds: metadataRows[0].durationSeconds ?? null,
lockedFields: [...(metadataRows[0].lockedFields ?? [])],
lastMetadataFetchAt: metadataRows[0].lastMetadataFetchAt ?? null,
title: meta.title,
subtitle: meta.subtitle,
description: meta.description,
publisher: meta.publisher,
publishedYear: meta.publishedYear,
language: meta.language,
pageCount: meta.pageCount,
coverSource: meta.coverSource,
goodreadsId: meta.goodreadsId,
openLibraryId: meta.openLibraryId,
durationSeconds: meta.durationSeconds,
abridged: meta.abridged,
chapters: meta.chapters,
lockedFields: [...meta.lockedFields],
lastMetadataFetchAt: meta.lastMetadataFetchAt,
}
: null,
genres: genreRows.map((row) => row.name).sort(),
narrators: narratorRows.map((row) => row.name),
comicMetadata: comic
? {
issueNumber: comic.issueNumber ?? null,
volumeName: comic.volumeName ?? null,
issueNumber: comic.issueNumber,
volumeName: comic.volumeName,
storyArcs: [...(comic.storyArcs ?? [])].sort(),
}
: null,
@@ -3,7 +3,7 @@ import { randomUUID } from 'crypto';
import { createWriteStream } from 'fs';
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import { dirname, join } from 'path';
import { dirname, isAbsolute, join, normalize } from 'path';
import { PDFDocument } from 'pdf-lib';
import { getSevenZip } from '../../../src/common/sevenzip';
@@ -33,9 +33,10 @@ interface ComicFixtureInput {
year?: number;
}
function assertRelativePath(path: string): void {
if (path.startsWith('/')) {
throw new Error(`Fixture paths must be relative. Received "${path}"`);
function assertRelativePath(relativePath: string): void {
const normalized = normalize(relativePath);
if (isAbsolute(relativePath) || normalized === '..' || normalized.startsWith('../') || normalized.startsWith('..\\')) {
throw new Error(`Fixture paths must be relative. Received "${relativePath}"`);
}
}
@@ -71,7 +71,7 @@ export async function createMetadataWriteE2EContext(): Promise<MetadataWriteE2EC
};
process.env.BOOKS_PATH = fixture.booksPath;
process.env.FILE_WRITE_DEBOUNCE_MS = '100';
process.env.FILE_WRITE_DEBOUNCE_MS = '25';
process.env.FILE_WRITE_MAX_CONCURRENT_WRITES = '1';
const moduleFixture = await Test.createTestingModule({
@@ -214,7 +214,7 @@ export async function createUserAndLogin(
const username = options.username ?? `reader-state-user-${suffix}`;
const password = options.password ?? 'ReaderStateUser123';
const email = options.email ?? `${username}@example.com`;
const passwordHash = await hash(password, 12);
const passwordHash = await hash(password, 4);
const [created] = await ctx.db
.insert(schema.users)
@@ -281,7 +281,7 @@ async function getAdminToken(app: NestFastifyApplication, db: Db): Promise<strin
const suffix = randomUUID().replaceAll('-', '');
const fallbackUsername = `reader-state-e2e-admin-${suffix}`;
const passwordHash = await hash(ADMIN_SETUP_DTO.password, 12);
const passwordHash = await hash(ADMIN_SETUP_DTO.password, 4);
await db.insert(schema.users).values({
username: fallbackUsername,
@@ -15,7 +15,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 } from '../app-harness';
import { makeMetadataNoopMock, seedLibrary } from '../app-harness';
import { createUsersAdminLifecycleFixtureRoot, type UsersAdminLifecycleFixtureRoot } from './users-admin-lifecycle-fixture-builder';
type Db = NodePgDatabase<typeof schema>;
@@ -27,6 +27,7 @@ const ADMIN_SETUP_DTO = {
password: 'UsersAdminLifecycleE2E123',
};
const MAX_MULTIPART_BYTES = 20 * 1024 * 1024;
const BCRYPT_TEST_ROUNDS = 4;
interface EnvSnapshot {
booksPath: string | undefined;
@@ -157,7 +158,7 @@ export async function createUserAndLogin(
const username = options.username ?? `users-admin-lifecycle-${suffix}`;
const password = options.password ?? 'UsersAdminLifecycleUser123';
const email = options.email ?? `${username}@example.com`;
const passwordHash = await hash(password, 12);
const passwordHash = await hash(password, BCRYPT_TEST_ROUNDS);
const [created] = await ctx.db
.insert(schema.users)
@@ -201,7 +202,7 @@ export async function createOidcUser(
const suffix = randomUUID().replaceAll('-', '');
const username = options.username ?? `users-admin-lifecycle-oidc-${suffix}`;
const email = options.email ?? `${username}@example.com`;
const passwordHash = await hash(`OIDC_${suffix}`, 12);
const passwordHash = await hash(`OIDC_${suffix}`, BCRYPT_TEST_ROUNDS);
const [created] = await ctx.db
.insert(schema.users)
@@ -263,7 +264,7 @@ async function getAdminToken(app: NestFastifyApplication, db: Db): Promise<strin
const suffix = randomUUID().replaceAll('-', '');
const fallbackUsername = `users-admin-lifecycle-admin-${suffix}`;
const passwordHash = await hash(ADMIN_SETUP_DTO.password, 12);
const passwordHash = await hash(ADMIN_SETUP_DTO.password, BCRYPT_TEST_ROUNDS);
await db.insert(schema.users).values({
username: fallbackUsername,
@@ -303,19 +304,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;
+21
View File
@@ -504,6 +504,19 @@ describe('Email lifecycle (e2e)', { timeout: 180_000 }, () => {
defaultTemplateId: foreignTemplate.id,
});
expectError(foreignTemplatePreferenceResponse, 403, 'No access to this template');
const recipientWithForeignTemplate = await apiAs(senderOnly, 'POST', '/api/v1/email/recipients', {
name: 'Recipient With Foreign Template',
email: 'recipient-with-foreign-template@example.com',
defaultTemplateId: foreignTemplate.id,
});
expectError(recipientWithForeignTemplate, 403, 'No access to this template');
const groupWithForeignTemplate = await apiAs(senderOnly, 'POST', '/api/v1/email/recipient-groups', {
name: `foreign-template-group-${randomUUID().slice(0, 8)}`,
defaultTemplateId: foreignTemplate.id,
});
expectError(groupWithForeignTemplate, 403, 'No access to this template');
});
});
@@ -565,6 +578,14 @@ describe('Email lifecycle (e2e)', { timeout: 180_000 }, () => {
expect(fromHeader).toContain('ProjectX Bot <bot@example.com>');
expect(subjectHeader).toBe('Override Email Lifecycle Book');
expect(subjectHeader).not.toContain('Recipient');
const missingTemplateSendResponse = await apiAs(senderOnly, 'POST', '/api/v1/email/send', {
bookIds: [book.bookId],
recipientIds: [recipient.id],
providerId: provider.id,
templateId: 999_999_999,
});
expectError(missingTemplateSendResponse, 404, 'Template not found');
});
it('keeps group defaultTemplateId non-operational in send resolution', async () => {
+1
View File
@@ -343,6 +343,7 @@ describe('metadata-lock e2e', { timeout: 30_000 }, () => {
const lock = await patchLocks(initial.bookId, ['title']);
expect(lock.statusCode).toBe(200);
// Filesystem timestamp resolution is 1s on many OSes; wait so the new file gets a newer mtime.
await pause(1_100);
await createEpubWithCoverFixture(library.folderPath, epubRelPath, {
title: 'Scanner EPUB Winner',
+89 -32
View File
@@ -6,6 +6,7 @@ import { Permission, type LibraryFileSyncProgressEvent } from '@projectx/types';
import * as schema from '../src/db/schema';
import { BookService, type MetadataUpdateFailpoint } from '../src/modules/book/book.service';
import { FileWriteService } from '../src/modules/file-write/file-write.service';
import { extractCb7Metadata, extractCbzMetadata } from '../src/modules/metadata/lib/cbz-metadata';
import { extractEpubMetadata } from '../src/modules/metadata/lib/epub';
import { parsePdfFile } from '../src/modules/metadata/lib/pdf-parser';
@@ -203,9 +204,10 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
context = await createMetadataWriteE2EContext();
});
afterEach((taskContext) => {
afterEach(async (taskContext) => {
if (context) {
context.app.get(BookService).clearMetadataUpdateFailpointForTests();
await drainScheduledFileWrites(context);
}
const result = taskContext.task.result;
@@ -231,7 +233,7 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
beforeEach(async () => {
scenarioStartedAt = Date.now();
await setFileWriteSettings(context.db, {
enabled: true,
enabled: false,
writeCover: false,
epub: { enabled: true },
pdf: { enabled: true },
@@ -441,6 +443,8 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
describe('auto-write on metadata patch', () => {
it('writes PDF metadata to disk and records auto write-log entry', async () => {
await setFileWriteSettings(context.db, { enabled: true });
const library = await createLibraryWithFolder(context, { mode: 'book_per_file' });
await createPdfFixture(library.folderPath, 'auto-write/book.pdf', 'Auto Seed PDF');
await triggerAndWaitForLibraryScan(context, library.libraryId);
@@ -466,7 +470,7 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
});
expect(logEntry.format).toBe('pdf');
const parsedPdf = await parsePdfFile(book.absolutePath);
const parsedPdf = await parsePdfFile(book.absolutePath, { extractCover: false });
expect(parsedPdf?.title).toBe('Auto PDF Title');
expect(parsedPdf?.authors.map((author) => author.name)).toContain('Auto PDF Author');
expect(parsedPdf?.description).toBe('Auto PDF Description');
@@ -480,6 +484,8 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
});
it('writes EPUB metadata to disk and records auto write-log entry', async () => {
await setFileWriteSettings(context.db, { enabled: true });
const library = await createLibraryWithFolder(context, { mode: 'book_per_file' });
await createEpubFixture(library.folderPath, 'auto-write/book.epub', { title: 'Auto Seed EPUB' });
await triggerAndWaitForLibraryScan(context, library.libraryId);
@@ -541,7 +547,7 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
expect(patchResponse.statusCode).toBe(200);
}
await sleep(250);
await drainScheduledFileWrites(context);
await setFileWriteSettings(context.db, {
enabled: true,
@@ -574,7 +580,7 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
}
}
const parsedPdf = await parsePdfFile(prepared.books.pdf.absolutePath);
const parsedPdf = await parsePdfFile(prepared.books.pdf.absolutePath, { extractCover: false });
expect(parsedPdf).toMatchObject({
title: MATRIX_SET_PAYLOAD.title,
subtitle: MATRIX_SET_PAYLOAD.subtitle,
@@ -763,7 +769,7 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
expect(tagRows).toHaveLength(0);
}
await sleep(250);
await drainScheduledFileWrites(context);
await setFileWriteSettings(context.db, {
enabled: true,
@@ -784,7 +790,7 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
);
expect(doneEvent).toMatchObject({ processed: 4, succeeded: 4, failed: 0, skipped: 0 });
const parsedPdf = await parsePdfFile(prepared.books.pdf.absolutePath);
const parsedPdf = await parsePdfFile(prepared.books.pdf.absolutePath, { extractCover: false });
expect(parsedPdf).toMatchObject({
title: null,
subtitle: null,
@@ -903,7 +909,7 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
});
expect(patchResponse.statusCode).toBe(200);
await sleep(250);
await drainScheduledFileWrites(context);
await setFileWriteSettings(context.db, { enabled: true });
@@ -979,7 +985,7 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
expect(patchResponse.statusCode).toBe(200);
}
await sleep(250);
await drainScheduledFileWrites(context);
await setFileWriteSettings(context.db, { enabled: true, cbx: { enabled: true, formats: ['cbz', 'cb7'] } });
@@ -1001,13 +1007,15 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
expect(successLog.errorMessage ?? null).toBeNull();
expect(failureLog.errorMessage).toBeTruthy();
const parsedPdf = await parsePdfFile(okBook.absolutePath);
const parsedPdf = await parsePdfFile(okBook.absolutePath, { extractCover: false });
expect(parsedPdf?.title).toBe('Failure Isolation Success');
});
});
describe('concurrency hardening', () => {
it('resolves parallel PATCH conflicts to one complete payload shape', async () => {
await setFileWriteSettings(context.db, { enabled: true });
const library = await createLibraryWithFolder(context, { mode: 'book_per_file' });
await createPdfFixture(library.folderPath, 'concurrency/parallel-patch.pdf', 'Parallel Patch Seed');
await triggerAndWaitForLibraryScan(context, library.libraryId);
@@ -1089,7 +1097,7 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
const winningPayload = matchesBookWriteState(dbState, payloadA) ? payloadA : matchesBookWriteState(dbState, payloadB) ? payloadB : null;
expect(winningPayload).not.toBeNull();
const parsedPdf = await parsePdfFile(book.absolutePath);
const parsedPdf = await parsePdfFile(book.absolutePath, { extractCover: false });
expect(parsedPdf).toMatchObject({
title: winningPayload!.title,
subtitle: winningPayload!.subtitle,
@@ -1139,7 +1147,7 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
expect(seedResponse.statusCode).toBe(200);
}
await sleep(250);
await drainScheduledFileWrites(context);
await setFileWriteSettings(context.db, {
enabled: true,
@@ -1173,23 +1181,58 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
itunesId: 'overlap-itunes',
} as const;
const syncPromise = context.app.inject({
method: 'POST',
url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`,
headers: authHeader(context.adminToken),
const fileWriteService = context.app.get(FileWriteService);
const originalWriteToFile = fileWriteService.writeToFile.bind(fileWriteService);
let releaseFirstSyncWrite!: () => void;
let resolveFirstSyncWriteStarted!: () => void;
const firstSyncWriteStarted = new Promise<void>((resolve) => {
resolveFirstSyncWriteStarted = resolve;
});
await sleep(75);
const patchResponse = await context.app.inject({
method: 'PATCH',
url: `/api/v1/books/${targetBook.bookId}/metadata`,
headers: authHeader(context.adminToken),
payload: overlapPayload,
const releaseFirstSyncWritePromise = new Promise<void>((resolve) => {
releaseFirstSyncWrite = resolve;
});
expect(patchResponse.statusCode).toBe(200);
let blockedFirstSyncWrite = false;
let releasedFirstSyncWrite = false;
const writeSpy = vi
.spyOn(fileWriteService, 'writeToFile')
.mockImplementation(async (bookId: number, triggeredBy: 'auto' | 'sync', userId?: number, dryRun?: boolean) => {
if (triggeredBy === 'sync' && !blockedFirstSyncWrite) {
blockedFirstSyncWrite = true;
resolveFirstSyncWriteStarted();
await releaseFirstSyncWritePromise;
}
return originalWriteToFile(bookId, triggeredBy, userId, dryRun);
});
let syncResponse!: Awaited<ReturnType<typeof context.app.inject>>;
try {
const syncPromise = context.app.inject({
method: 'POST',
url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`,
headers: authHeader(context.adminToken),
});
await firstSyncWriteStarted;
const patchResponse = await context.app.inject({
method: 'PATCH',
url: `/api/v1/books/${targetBook.bookId}/metadata`,
headers: authHeader(context.adminToken),
payload: overlapPayload,
});
expect(patchResponse.statusCode).toBe(200);
releasedFirstSyncWrite = true;
releaseFirstSyncWrite();
syncResponse = await syncPromise;
} finally {
if (blockedFirstSyncWrite && !releasedFirstSyncWrite) {
releaseFirstSyncWrite();
}
writeSpy.mockRestore();
}
const syncResponse = await syncPromise;
expect(syncResponse.statusCode).toBe(200);
const syncEvents = parseSseEvents(syncResponse.body);
const doneEvent = syncEvents.find(
@@ -1206,7 +1249,7 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
const targetState = await readBookWriteState(context, targetBook.bookId);
expect(matchesBookWriteState(targetState, overlapPayload)).toBe(true);
const parsedPdf = await parsePdfFile(targetBook.absolutePath);
const parsedPdf = await parsePdfFile(targetBook.absolutePath, { extractCover: false });
expect(parsedPdf).toMatchObject({
title: overlapPayload.title,
subtitle: overlapPayload.subtitle,
@@ -1252,7 +1295,7 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
expect(patchResponse.statusCode).toBe(200);
}
await sleep(250);
await drainScheduledFileWrites(context);
await setFileWriteSettings(context.db, {
enabled: true,
@@ -1334,6 +1377,19 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
expect(success.statusCode).toBe(200);
});
it('returns 404 for sync requests targeting a missing library', async () => {
await setFileWriteSettings(context.db, { enabled: true });
const response = await context.app.inject({
method: 'POST',
url: '/api/v1/libraries/999999/write-metadata-to-files',
headers: authHeader(context.adminToken),
});
expect(response.statusCode).toBe(404);
expect(response.json()).toMatchObject({ message: 'Library not found' });
});
it('enforces permission and editor-level library access for sync endpoint', async () => {
const library = await createLibraryWithFolder(context, { mode: 'book_per_file' });
await createPdfFixture(library.folderPath, 'access/sync.pdf');
@@ -1369,6 +1425,7 @@ describe('Metadata write operations (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ()
expect(viewerAccess.statusCode).toBe(403);
await grantLibraryAccess(context, withPermissionUser.userId, library.libraryId, 'editor');
await setFileWriteSettings(context.db, { enabled: true });
const editorAccess = await context.app.inject({
method: 'POST',
url: `/api/v1/libraries/${library.libraryId}/write-metadata-to-files`,
@@ -1427,6 +1484,10 @@ function parseSseEvents(rawBody: string): LibraryFileSyncProgressEvent[] {
return events;
}
async function drainScheduledFileWrites(context: MetadataWriteE2EContext): Promise<void> {
await context.app.get(FileWriteService).drainScheduledWritesForTests();
}
function buildAtomicityFailurePayload(failpoint: MetadataUpdateFailpoint) {
const token = failpoint
.replace(/[^a-z]/gi, '')
@@ -1646,7 +1707,3 @@ async function countSyncSuccessLogs(context: MetadataWriteE2EContext, bookId: nu
return rows.filter((row) => row.triggeredBy === 'sync' && row.status === 'success').length;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+29 -9
View File
@@ -231,6 +231,17 @@ describe('Reader state isolation (e2e)', { timeout: 120_000 }, () => {
describe('reader preferences ownership', () => {
it('isolates per-book and default reader preferences per user', async () => {
const preCustomizationResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/reader/preferences/${sharedEpub.bookFileId}`,
headers: authHeader(userA.accessToken),
});
expect(preCustomizationResponse.statusCode).toBe(200);
expect(preCustomizationResponse.json()).toEqual({
settings: null,
isCustomized: false,
});
const userASettings = {
themeName: 'dark',
fontSize: 20,
@@ -539,6 +550,13 @@ describe('Reader state isolation (e2e)', { timeout: 120_000 }, () => {
});
expect(bookmarkResponse.statusCode).toBe(403);
const annotationResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/books/${sharedEpub.bookId}/annotations`,
headers: authHeader(outsider.accessToken),
});
expect(annotationResponse.statusCode).toBe(403);
const preferenceResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/reader/preferences/${sharedEpub.bookFileId}`,
@@ -664,13 +682,15 @@ describe('Reader state isolation (e2e)', { timeout: 120_000 }, () => {
});
async function resetReaderState(ctx: ReaderStateIsolationE2EContext): Promise<void> {
await ctx.db.delete(schema.readingSessions);
await ctx.db.delete(schema.userReadingDailyStats);
await ctx.db.delete(schema.readingProgress);
await ctx.db.delete(schema.audiobookProgress);
await ctx.db.delete(schema.bookmarks);
await ctx.db.delete(schema.annotations);
await ctx.db.delete(schema.readerPreferences);
await ctx.db.delete(schema.readerDefaultPreferences);
await ctx.db.delete(schema.userBookStatus);
await Promise.all([
ctx.db.delete(schema.readingSessions),
ctx.db.delete(schema.userReadingDailyStats),
ctx.db.delete(schema.readingProgress),
ctx.db.delete(schema.audiobookProgress),
ctx.db.delete(schema.bookmarks),
ctx.db.delete(schema.annotations),
ctx.db.delete(schema.readerPreferences),
ctx.db.delete(schema.readerDefaultPreferences),
ctx.db.delete(schema.userBookStatus),
]);
}
@@ -468,6 +468,7 @@ const structuralScenarios: StructuralScenario[] = [
statusByFolder: { Shelf: 'present' },
fileOwners: { 'Shelf/book.epub': 'Shelf' },
presentCount: 1,
missingCount: 0,
},
},
},
@@ -482,6 +483,7 @@ const structuralScenarios: StructuralScenario[] = [
statusByFolder: { 'book.epub': 'present' },
fileOwners: { 'book.epub': 'book.epub' },
presentCount: 1,
missingCount: 0,
},
},
},
+84 -2
View File
@@ -264,7 +264,7 @@ describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => {
});
expect(readAfterPermissionUpdate.statusCode).toBe(200);
const afterPermissionUpdateBody = readAfterPermissionUpdate.json() as { permissions: Permission[] };
expect(afterPermissionUpdateBody.permissions).toEqual([PermissionEnum.LibraryDownload, PermissionEnum.KoboSync]);
expect(afterPermissionUpdateBody.permissions.toSorted()).toEqual([PermissionEnum.LibraryDownload, PermissionEnum.KoboSync].toSorted());
const invalidPermissionUpdate = await ctx.app.inject({
method: 'PUT',
@@ -310,6 +310,66 @@ describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => {
});
expectError(readAfterDelete, 404, 'User not found');
});
it('rejects duplicate username on create', async () => {
const response = await ctx.app.inject({
method: 'POST',
url: '/api/v1/users',
headers: authHeader(manageUsersAdmin.accessToken),
payload: {
username: regularA.username,
name: 'Duplicate Username User',
email: `dup-username-${randomUUID().slice(0, 8)}@example.com`,
},
});
expectError(response, 409, 'Username already taken');
});
it('returns 401 for unauthenticated requests to admin routes', async () => {
const cases: Array<{
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
url: string;
payload?: Record<string, unknown>;
}> = [
{ method: 'GET', url: '/api/v1/users' },
{ method: 'POST', url: '/api/v1/users', payload: { username: 'noauth', name: 'No Auth', email: 'noauth@test.com' } },
{ method: 'GET', url: `/api/v1/users/${regularA.userId}` },
{ method: 'PATCH', url: `/api/v1/users/${regularA.userId}`, payload: { name: 'hacked' } },
{ method: 'DELETE', url: `/api/v1/users/${regularA.userId}` },
];
for (const entry of cases) {
const response = await ctx.app.inject({
method: entry.method,
url: entry.url,
...(entry.payload ? { payload: entry.payload } : {}),
});
expect(response.statusCode).toBe(401);
}
});
it('returns 403 for authenticated users lacking manage_users permission', async () => {
const cases: Array<{
method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
url: string;
payload?: Record<string, unknown>;
}> = [
{ method: 'GET', url: '/api/v1/users' },
{ method: 'POST', url: '/api/v1/users', payload: { username: 'noperm', name: 'No Perm', email: 'noperm@test.com' } },
{ method: 'GET', url: `/api/v1/users/${regularA.userId}` },
{ method: 'PATCH', url: `/api/v1/users/${regularA.userId}`, payload: { name: 'hacked' } },
{ method: 'DELETE', url: `/api/v1/users/${regularA.userId}` },
];
for (const entry of cases) {
const response = await ctx.app.inject({
method: entry.method,
url: entry.url,
headers: authHeader(regularB.accessToken),
...(entry.payload ? { payload: entry.payload } : {}),
});
expectError(response, 403, 'Missing permission: manage_users');
}
});
});
describe('protection rules', () => {
@@ -421,6 +481,16 @@ describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => {
expect(readAfterDemote.statusCode).toBe(200);
expect((readAfterDemote.json() as { isSuperuser: boolean }).isSuperuser).toBe(false);
});
it('blocks self-superuser toggle by a superuser', async () => {
const selfToggle = await ctx.app.inject({
method: 'PUT',
url: `/api/v1/users/${superuserTarget.userId}/superuser`,
headers: authHeader(superuserTarget.accessToken),
payload: { isSuperuser: false },
});
expectError(selfToggle, 409, 'You cannot change your own superuser status');
});
});
describe('profile and avatar behavior', () => {
@@ -569,7 +639,7 @@ describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => {
url: `/api/v1/users/${created.id}/reset-password`,
headers: authHeader(manageUsersAdmin.accessToken),
});
expect([200, 201]).toContain(secondResetResponse.statusCode);
expect(secondResetResponse.statusCode).toBe(201);
const secondToken = parseTokenFromResetUrl((secondResetResponse.json() as { resetUrl: string }).resetUrl);
const reuseFirstToken = await ctx.app.inject({
@@ -654,6 +724,18 @@ describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => {
});
expect(sessionsAfterChange.statusCode).toBe(200);
});
it('rejects login for inactive users', async () => {
const inactiveLogin = await ctx.app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: {
username: inactiveAssignable.username,
password: inactiveAssignable.password,
},
});
expect(inactiveLogin.statusCode).toBe(401);
});
});
describe('assignable users contract', () => {