mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
test(server): harden E2E suites across scanner, auth, and email modules
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user