diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85957c379..5f22d73ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -343,7 +343,7 @@ jobs: name: Detect E2E Suites runs-on: ubuntu-latest needs: [quality-gate] - if: needs.quality-gate.result == 'success' + if: always() && needs.quality-gate.result == 'success' && github.event_name == 'schedule' timeout-minutes: 10 outputs: matrix: ${{ steps.build-matrix.outputs.matrix }} @@ -372,7 +372,7 @@ jobs: e2e: needs: [e2e-setup] - if: needs.e2e-setup.outputs.matrix != '[]' + if: github.event_name == 'schedule' && needs.e2e-setup.outputs.matrix != '[]' strategy: fail-fast: false matrix: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2a455a97b..3da93a89d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -383,7 +383,7 @@ For the full release runbook, see [`docs/RELEASE_PROCESS.md`](docs/RELEASE_PROCE ### E2E in CI -E2E suites are dispatched automatically based on which files changed. On nightly builds and manual dispatches, all suites run. Each suite runs in its own job with a dedicated PostgreSQL service container. +E2E suites run only on nightly scheduled CI builds. Each suite runs in its own job with a dedicated PostgreSQL service container. For on-demand E2E runs, use the manual `.github/workflows/e2e.yml` workflow. ### Reading CI Failures diff --git a/docs/TESTING.md b/docs/TESTING.md index e85ed8b32..349ee89c1 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -313,9 +313,8 @@ report artifacts are retained for 7 days. E2E suites are selected by `scripts/e2e/select-matrix.mjs`: -- **Pull request:** The smoke suite (`guard-mechanics`) always runs. Full-lane suites run only - when their `changedPaths` match the PR diff. -- **Push to main / nightly / manual dispatch:** All 18 suites run. +- **Nightly schedule (`CI`):** All 18 suites run. +- **Pull request / push to main / manual `CI` dispatch:** E2E is skipped in `.github/workflows/ci.yml`. Each selected suite runs as its own GitHub Actions job via the reusable `.github/workflows/e2e-runner.yml` workflow, which spins up a PostgreSQL 16 + pgvector service diff --git a/scripts/e2e/run-suite.mjs b/scripts/e2e/run-suite.mjs index a5bf1901b..3fc7a6ee0 100644 --- a/scripts/e2e/run-suite.mjs +++ b/scripts/e2e/run-suite.mjs @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; import { getE2ESuite, listE2ESuites } from "./suite-registry.mjs"; -const DEFAULT_E2E_DATABASE_URL = "postgres://projectx:projectx@localhost:5432/projectx_e2e"; +const DEFAULT_E2E_DATABASE_URL = "postgres://bookorbit:bookorbit@localhost:5432/bookorbit_e2e"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const rootDir = join(scriptDir, "..", ".."); diff --git a/scripts/e2e/select-matrix.mjs b/scripts/e2e/select-matrix.mjs index 77a5f77a2..45fec1279 100644 --- a/scripts/e2e/select-matrix.mjs +++ b/scripts/e2e/select-matrix.mjs @@ -56,12 +56,12 @@ function toMatrixItems(suites) { function selectSuitesForEvent(eventName, changedFiles) { const suites = listE2ESuites(); - if (eventName === "workflow_dispatch" || eventName === "push" || eventName === "schedule") { + if (eventName === "schedule") { return toMatrixItems(suites); } if (eventName !== "pull_request") { - return toMatrixItems(suites); + return []; } const smokeSuites = suites.filter((suite) => suite.lane === "smoke"); diff --git a/server/src/modules/auth/auth.service.test.ts b/server/src/modules/auth/auth.service.test.ts index 0942f7c68..42ef19b46 100644 --- a/server/src/modules/auth/auth.service.test.ts +++ b/server/src/modules/auth/auth.service.test.ts @@ -114,6 +114,7 @@ function makeService(dbOverrides?: Record) { oidcDiscovery as never, { emit: vi.fn() } as never, magicLinkRepo as never, + appSettings as never, db, ); @@ -391,7 +392,7 @@ describe('AuthService', () => { expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalledWith( 'access_token', 'signed-jwt', - expect.objectContaining({ path: '/api', secure: 'auto', sameSite: 'strict' }), + expect.objectContaining({ path: '/api', secure: 'auto', sameSite: 'lax' }), ); expect((reply as unknown as { setCookie: vi.Mock }).setCookie).toHaveBeenCalledWith( 'refresh_token', diff --git a/server/src/modules/auth/auth.service.ts b/server/src/modules/auth/auth.service.ts index a2281a0be..de3337af2 100644 --- a/server/src/modules/auth/auth.service.ts +++ b/server/src/modules/auth/auth.service.ts @@ -38,6 +38,7 @@ import { SetupDto } from './dto/setup.dto'; import { OidcDiscoveryService } from './oidc/oidc-discovery.service'; import { OidcSessionRepository } from './oidc/oidc-session.repository'; import { MagicLinkRepository } from './magic-link.repository'; +import { AppSettingsService } from '../app-settings/app-settings.service'; function parseDurationMs(duration: string): number { const match = duration.match(/^(\d+)([smhd])$/); @@ -76,6 +77,7 @@ export class AuthService { private readonly oidcDiscovery: OidcDiscoveryService, private readonly auditEvents: AuditEventsService, private readonly magicLinkRepo: MagicLinkRepository, + private readonly appSettings: AppSettingsService, @Inject(DB) private readonly db: NodePgDatabase, ) {} @@ -388,11 +390,18 @@ export class AuthService { const oidcLogoutStart = Date.now(); try { - if (!oidcSession.providerId) return {}; - const provider = await this.db.query.oidcProviders.findFirst({ where: eq(schema.oidcProviders.id, oidcSession.providerId) }); - if (!provider?.enabled) return {}; + let issuerUri: string; + if (oidcSession.providerId) { + const provider = await this.db.query.oidcProviders.findFirst({ where: eq(schema.oidcProviders.id, oidcSession.providerId) }); + if (!provider?.enabled) return {}; + issuerUri = provider.issuerUri; + } else { + const oidcConfig = await this.appSettings.getOidcConfig(); + if (!oidcConfig.enabled) return {}; + issuerUri = oidcSession.oidcIssuer; + } - const disc = await this.oidcDiscovery.getDiscoveryDoc(provider.issuerUri); + const disc = await this.oidcDiscovery.getDiscoveryDoc(issuerUri); if (!disc.endSessionEndpoint) return {}; const origin = req.headers['origin'] ?? req.headers['referer']; @@ -649,7 +658,7 @@ export class AuthService { const ttlSeconds = parseDurationMs(this.config.get('auth.jwtExpiresIn') ?? '15m') / 1000; reply.setCookie('access_token', accessToken, { httpOnly: true, - sameSite: 'strict', + sameSite: 'lax', path: '/api', maxAge: ttlSeconds, secure: 'auto', @@ -659,7 +668,7 @@ export class AuthService { private clearAccessCookie(reply: FastifyReply) { reply.setCookie('access_token', '', { httpOnly: true, - sameSite: 'strict', + sameSite: 'lax', path: '/api', maxAge: 0, secure: 'auto', diff --git a/server/src/modules/book/book.controller.test.ts b/server/src/modules/book/book.controller.test.ts index 739bdf994..c30da4690 100644 --- a/server/src/modules/book/book.controller.test.ts +++ b/server/src/modules/book/book.controller.test.ts @@ -178,7 +178,7 @@ describe('BookController', () => { expect(bookService.embedAll).toHaveBeenCalledTimes(1); }); - it('returns 304 when cover etag matches (unversioned → 1h cache)', async () => { + it('returns 304 when cover etag matches (unversioned → 24h private cache)', async () => { const { controller, bookService } = makeController(); const { reply, headers } = makeReply(); bookService.getCoverPath.mockResolvedValue('/tmp/cover.jpg'); @@ -187,7 +187,7 @@ describe('BookController', () => { await controller.getCover(7, makeUser(), reply, undefined, '"1234"'); expect(reply.status).toHaveBeenCalledWith(304); - expect(headers['Cache-Control']).toBe('public, max-age=3600'); + expect(headers['Cache-Control']).toBe('private, max-age=86400'); expect(headers['ETag']).toBe('"1234"'); expect(reply.send).toHaveBeenCalled(); expect(mockCreateReadStream).not.toHaveBeenCalled(); @@ -205,7 +205,7 @@ describe('BookController', () => { expect(headers['Cache-Control']).toBe('public, max-age=31536000, immutable'); }); - it('streams cover with 1h cache when no ?t= param', async () => { + it('streams cover with 24h private cache when no ?t= param', async () => { const { controller, bookService } = makeController(); const { reply, headers } = makeReply(); bookService.getCoverPath.mockResolvedValue('/tmp/cover.png'); @@ -213,7 +213,7 @@ describe('BookController', () => { await controller.getCover(7, makeUser(), reply, undefined, undefined); - expect(headers['Cache-Control']).toBe('public, max-age=3600'); + expect(headers['Cache-Control']).toBe('private, max-age=86400'); expect(headers['ETag']).toBe('"4321"'); expect(reply.type).toHaveBeenCalledWith('image/png'); expect(mockCreateReadStream).toHaveBeenCalledWith('/tmp/cover.png'); @@ -592,11 +592,11 @@ describe('BookController', () => { await controller.getThumbnail(7, makeUser(), first.reply, undefined, '"1000"'); expect(first.reply.status).toHaveBeenCalledWith(304); expect(first.headers['ETag']).toBe('"1000"'); - expect(first.headers['Cache-Control']).toBe('public, max-age=3600'); + expect(first.headers['Cache-Control']).toBe('private, max-age=86400'); await controller.getThumbnail(7, makeUser(), second.reply, undefined, undefined); expect(second.reply.type).toHaveBeenCalledWith('image/jpeg'); - expect(second.headers['Cache-Control']).toBe('public, max-age=3600'); + expect(second.headers['Cache-Control']).toBe('private, max-age=86400'); expect(mockCreateReadStream).toHaveBeenCalledWith('/tmp/thumb.jpg'); }); diff --git a/server/src/modules/book/book.controller.ts b/server/src/modules/book/book.controller.ts index a86fc2b12..a3de38580 100644 --- a/server/src/modules/book/book.controller.ts +++ b/server/src/modules/book/book.controller.ts @@ -350,7 +350,7 @@ export class BookController { const { mtimeMs } = await stat(coverPath); const etag = `"${Math.floor(mtimeMs)}"`; - const cacheControl = t ? 'public, max-age=31536000, immutable' : 'public, max-age=3600'; + const cacheControl = t ? 'public, max-age=31536000, immutable' : 'private, max-age=86400'; if (ifNoneMatch === etag) { reply.status(304).header('Cache-Control', cacheControl).header('ETag', etag).send(); @@ -378,7 +378,7 @@ export class BookController { const { mtimeMs } = await stat(thumbnailPath); const etag = `"${Math.floor(mtimeMs)}"`; - const cacheControl = t ? 'public, max-age=31536000, immutable' : 'public, max-age=3600'; + const cacheControl = t ? 'public, max-age=31536000, immutable' : 'private, max-age=86400'; if (ifNoneMatch === etag) { reply.status(304).header('Cache-Control', cacheControl).header('ETag', etag).send(); diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index 7658012ac..53a14c052 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -1069,6 +1069,7 @@ export class BookService { if (dto.itunesId !== undefined) scalarFields.itunesId = dto.itunesId ?? null; if (dto.audibleId !== undefined) scalarFields.audibleId = dto.audibleId ?? null; if (dto.comicvineId !== undefined) scalarFields.comicvineId = dto.comicvineId ?? null; + if (dto.rating !== undefined) scalarFields.rating = dto.rating ?? null; if (dto.audioMetadata) { if (dto.audioMetadata.durationSeconds !== undefined) scalarFields.durationSeconds = dto.audioMetadata.durationSeconds ?? null; if (dto.audioMetadata.abridged !== undefined) scalarFields.abridged = dto.audioMetadata.abridged ?? false; diff --git a/server/src/modules/file-write/file-write.constants.ts b/server/src/modules/file-write/file-write.constants.ts index 3fc2d79d2..0dae15296 100644 --- a/server/src/modules/file-write/file-write.constants.ts +++ b/server/src/modules/file-write/file-write.constants.ts @@ -19,6 +19,7 @@ export const BOOK_WRITE_FIELD_KEYS: readonly BookWritePayloadKey[] = [ 'seriesIndex', 'isbn10', 'isbn13', + 'rating', 'authors', 'genres', 'tags', diff --git a/server/src/modules/file-write/file-write.repository.ts b/server/src/modules/file-write/file-write.repository.ts index 1f6c5cf4b..33701d242 100644 --- a/server/src/modules/file-write/file-write.repository.ts +++ b/server/src/modules/file-write/file-write.repository.ts @@ -114,6 +114,7 @@ export class FileWriteRepository { hardcoverId: meta.hardcoverId, openLibraryId: meta.openLibraryId, itunesId: meta.itunesId, + rating: meta.rating, authors: authorRows, genres: genreRows.map((g) => g.name), tags: tagRows.map((t) => t.name), diff --git a/server/src/modules/kobo/services/kobo-sync.service.ts b/server/src/modules/kobo/services/kobo-sync.service.ts index ef6818661..583738d42 100644 --- a/server/src/modules/kobo/services/kobo-sync.service.ts +++ b/server/src/modules/kobo/services/kobo-sync.service.ts @@ -484,7 +484,7 @@ export class KoboSyncService { seriesName: schema.bookMetadata.seriesName, seriesIndex: schema.bookMetadata.seriesIndex, metadataUpdatedAt: schema.bookMetadata.updatedAt, - fileHash: schema.bookFiles.hash, + fileHash: schema.bookFiles.fileHash, authorNamesCsv: sql`coalesce(string_agg(${schema.authors.name}, ',' ORDER BY ${schema.bookAuthors.displayOrder}, ${schema.bookAuthors.authorId}), '')`, }) .from(schema.books) @@ -499,7 +499,7 @@ export class KoboSyncService { schema.bookMetadata.seriesName, schema.bookMetadata.seriesIndex, schema.bookMetadata.updatedAt, - schema.bookFiles.hash, + schema.bookFiles.fileHash, ); return rows.map((row) => ({ @@ -534,7 +534,7 @@ export class KoboSyncService { seriesIndex: schema.bookMetadata.seriesIndex, fileFormat: schema.bookFiles.format, fileSizeBytes: schema.bookFiles.sizeBytes, - fileHash: schema.bookFiles.hash, + fileHash: schema.bookFiles.fileHash, metadataUpdatedAt: schema.bookMetadata.updatedAt, addedAt: schema.books.addedAt, updatedAt: schema.books.updatedAt, diff --git a/server/src/modules/migration/executor/migration-import.repository.ts b/server/src/modules/migration/executor/migration-import.repository.ts index 9fcb6fcbc..ba4fd52f1 100644 --- a/server/src/modules/migration/executor/migration-import.repository.ts +++ b/server/src/modules/migration/executor/migration-import.repository.ts @@ -534,7 +534,7 @@ export class MigrationImportRepository { .select({ id: schema.bookFiles.id, bookId: schema.bookFiles.bookId, - hash: schema.bookFiles.hash, + hash: schema.bookFiles.fileHash, absolutePath: schema.bookFiles.absolutePath, }) .from(schema.bookFiles) diff --git a/server/src/modules/migration/planner/matching.service.ts b/server/src/modules/migration/planner/matching.service.ts index ab2a61e99..7e915078e 100644 --- a/server/src/modules/migration/planner/matching.service.ts +++ b/server/src/modules/migration/planner/matching.service.ts @@ -207,9 +207,9 @@ export class MatchingService { for (let i = 0; i < allHashes.length; i += chunkSize) { const chunk = allHashes.slice(i, i + chunkSize); const rows = await this.db - .select({ bookId: schema.bookFiles.bookId, hash: schema.bookFiles.hash }) + .select({ bookId: schema.bookFiles.bookId, hash: schema.bookFiles.fileHash }) .from(schema.bookFiles) - .where(inArray(schema.bookFiles.hash, chunk)); + .where(inArray(schema.bookFiles.fileHash, chunk)); for (const row of rows) { if (!row.hash) continue; const existing = bookIdsByHash.get(row.hash) ?? []; diff --git a/server/test/app-settings-oidc-contract.e2e-spec.ts b/server/test/app-settings-oidc-contract.e2e-spec.ts index 87472e44d..c6d87d607 100644 --- a/server/test/app-settings-oidc-contract.e2e-spec.ts +++ b/server/test/app-settings-oidc-contract.e2e-spec.ts @@ -35,6 +35,7 @@ const ADMIN_SETUP_DTO = { const SCENARIO_TIMEOUT_MS = 60_000; const DEFAULT_ISSUER = 'https://issuer.example'; +const DEFAULT_SLUG = 'example-oidc'; interface OidcContractContext { app: NestFastifyApplication; @@ -55,8 +56,9 @@ interface OidcContractContext { } type OidcConfigInput = { + slug: string; + displayName: string; enabled: boolean; - providerName: string; issuerUri: string; clientId: string; clientSecret: string; @@ -120,8 +122,9 @@ function buildDiscoveryResponse(issuerUri = DEFAULT_ISSUER) { function buildOidcConfig(overrides: Partial = {}): OidcConfigInput { return { + slug: DEFAULT_SLUG, + displayName: 'Example OIDC', enabled: true, - providerName: 'Example OIDC', issuerUri: DEFAULT_ISSUER, clientId: 'client-id', clientSecret: 'client-secret', @@ -143,6 +146,12 @@ function buildOidcConfig(overrides: Partial = {}): OidcConfigIn }; } +function makeFakeLogoutToken(claims: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); + return `${header}.${payload}.fakesig`; +} + function responseMessage(response: { message?: string | string[] }): string { if (Array.isArray(response.message)) return response.message.join(' '); return String(response.message ?? ''); @@ -391,19 +400,28 @@ async function login(app: NestFastifyApplication, username: string, password: st } async function putOidcConfig(ctx: OidcContractContext, config: OidcConfigInput): Promise { - const response = await ctx.app.inject({ - method: 'PUT', - url: '/api/v1/app-settings/oidc', + const createResponse = await ctx.app.inject({ + method: 'POST', + url: '/api/v1/app-settings/oidc/providers', headers: authHeader(ctx.adminToken), payload: config, }); - expect(response.statusCode).toBe(200); + if (createResponse.statusCode === 201) return; + + const { slug, ...updatePayload } = config; + const updateResponse = await ctx.app.inject({ + method: 'PUT', + url: `/api/v1/app-settings/oidc/providers/${slug}`, + headers: authHeader(ctx.adminToken), + payload: updatePayload, + }); + expect(updateResponse.statusCode).toBe(200); } -async function issueState(app: NestFastifyApplication): Promise { +async function issueState(app: NestFastifyApplication, slug = DEFAULT_SLUG): Promise { const response = await app.inject({ method: 'POST', - url: '/api/v1/auth/oidc/state', + url: `/api/v1/auth/oidc/${slug}/state`, }); expect(response.statusCode).toBe(200); const body = response.json() as { state: string }; @@ -461,7 +479,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ( const limitedUser = await createLocalUser(ctx.db); const limitedSession = await login(ctx.app, limitedUser.username, limitedUser.password); const config = buildOidcConfig({ - providerName: 'Contract OIDC', + displayName: 'Contract OIDC', autoProvision: { enabled: true, allowLocalLinking: true, @@ -473,42 +491,51 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ( const publicResponse = await ctx.app.inject({ method: 'GET', - url: '/api/v1/app-settings/oidc/public', + url: '/api/v1/app-settings/oidc/providers/public', }); expect(publicResponse.statusCode).toBe(200); - expect(publicResponse.json()).toEqual({ - enabled: true, - providerName: 'Contract OIDC', - issuerUri: DEFAULT_ISSUER, - clientId: 'client-id', - scopes: 'openid profile email', - }); + expect(publicResponse.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + slug: DEFAULT_SLUG, + displayName: 'Contract OIDC', + enabled: true, + clientId: 'client-id', + scopes: 'openid profile email', + }), + ]), + ); const forbiddenResponse = await ctx.app.inject({ method: 'GET', - url: '/api/v1/app-settings/oidc', + url: `/api/v1/app-settings/oidc/providers/${DEFAULT_SLUG}`, headers: authHeader(limitedSession.accessToken), }); expect(forbiddenResponse.statusCode).toBe(403); const adminResponse = await ctx.app.inject({ method: 'GET', - url: '/api/v1/app-settings/oidc', + url: `/api/v1/app-settings/oidc/providers/${DEFAULT_SLUG}`, headers: authHeader(ctx.adminToken), }); expect(adminResponse.statusCode).toBe(200); - expect(adminResponse.json()).toEqual({ - ...config, + expect(adminResponse.json()).toMatchObject({ + slug: DEFAULT_SLUG, + displayName: 'Contract OIDC', + enabled: true, + issuerUri: DEFAULT_ISSUER, + clientId: 'client-id', clientSecret: '***', + scopes: 'openid profile email', }); const testResponse = await ctx.app.inject({ method: 'POST', - url: '/api/v1/app-settings/oidc/test', + url: `/api/v1/app-settings/oidc/providers/${DEFAULT_SLUG}/test`, headers: authHeader(ctx.adminToken), }); expect(testResponse.statusCode).toBe(200); - expect(testResponse.json()).toEqual({ + expect(testResponse.json()).toMatchObject({ success: true, issuer: DEFAULT_ISSUER, authorizationEndpoint: `${DEFAULT_ISSUER}/auth`, @@ -548,7 +575,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ( picture: 'https://issuer.example/avatars/provisioned.png', }); - const state = await issueState(ctx.app); + const state = await issueState(ctx.app, DEFAULT_SLUG); const callbackPayload = { code: 'authorization-code-provisioned', codeVerifier: 'verifier-provisioned', @@ -625,7 +652,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ( url: '/api/v1/auth/oidc/callback', payload: callbackPayload, }); - expectError(reusedState, 401, 'Invalid or expired state'); + expectError(reusedState, 401, 'Your login session expired'); }); it('links an existing local user and backchannel logout revokes active sessions only for the matched OIDC identity', async () => { @@ -657,7 +684,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ( email: linkedUser.email, }); - const state = await issueState(ctx.app); + const state = await issueState(ctx.app, DEFAULT_SLUG); const callbackResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/oidc/callback', @@ -707,7 +734,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ( const unknownLogoutResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/oidc/backchannel-logout', - payload: { logout_token: 'unknown-logout-token' }, + payload: { logout_token: makeFakeLogoutToken({ iss: DEFAULT_ISSUER }) }, }); expect(unknownLogoutResponse.statusCode).toBe(200); expect(await activeSessionCount(ctx.db, linkedUser.userId)).toBe(1); @@ -728,7 +755,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ( const matchedLogoutResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/oidc/backchannel-logout', - payload: { logout_token: 'matched-logout-token' }, + payload: { logout_token: makeFakeLogoutToken({ iss: DEFAULT_ISSUER }) }, }); expect(matchedLogoutResponse.statusCode).toBe(200); @@ -778,10 +805,10 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ( state: `invalid-${randomUUID()}`, }, }); - expectError(invalidStateResponse, 401, 'Invalid or expired state'); + expectError(invalidStateResponse, 401, 'Your login session expired'); expect(ctx.oidcTokenClientMock.exchangeCode).not.toHaveBeenCalled(); - const state = await issueState(ctx.app); + const state = await issueState(ctx.app, DEFAULT_SLUG); ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({ sid: 'oidc-missing-subject-sid', preferred_username: `missing-subject-${randomUUID()}`, @@ -800,10 +827,25 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ( state, }, }); - expectError(missingSubjectResponse, 401, 'Invalid ID token: missing subject claim'); + expectError(missingSubjectResponse, 401, 'Could not complete sign-in with your provider'); }); it('rejects callback when OIDC is disabled or the linked account is deactivated', async () => { + // Generate state while provider is enabled, then disable it so the callback is rejected. + await putOidcConfig( + ctx, + buildOidcConfig({ + enabled: true, + autoProvision: { + enabled: true, + allowLocalLinking: true, + defaultPermissionNames: [], + }, + }), + ); + + const disabledState = await issueState(ctx.app, DEFAULT_SLUG); + await putOidcConfig( ctx, buildOidcConfig({ @@ -816,7 +858,6 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ( }), ); - const disabledState = await issueState(ctx.app); const disabledCallbackResponse = await ctx.app.inject({ method: 'POST', url: '/api/v1/auth/oidc/callback', @@ -828,7 +869,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ( state: disabledState, }, }); - expectError(disabledCallbackResponse, 401, 'OIDC is not enabled'); + expectError(disabledCallbackResponse, 401, 'OIDC provider is not enabled'); const inactiveUser = await createLocalUser(ctx.db, { username: `oidc-inactive-${randomUUID().replace(/-/g, '')}`, @@ -847,7 +888,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ( }), ); - const inactiveState = await issueState(ctx.app); + const inactiveState = await issueState(ctx.app, DEFAULT_SLUG); ctx.oidcTokenValidatorMock.validateIdToken.mockResolvedValueOnce({ sub: 'oidc-inactive-subject', sid: 'oidc-inactive-sid', @@ -867,7 +908,7 @@ describe('App settings OIDC contract (e2e)', { timeout: SCENARIO_TIMEOUT_MS }, ( state: inactiveState, }, }); - expectError(inactiveCallbackResponse, 401, 'Account is deactivated'); + expectError(inactiveCallbackResponse, 401, 'Your account has been deactivated'); const inactiveUserSession = await ctx.db.query.oidcSessions.findFirst({ where: eq(schema.oidcSessions.userId, inactiveUser.userId), diff --git a/server/test/authorization-matrix.e2e-spec.ts b/server/test/authorization-matrix.e2e-spec.ts index d9a2b6e76..682511e45 100644 --- a/server/test/authorization-matrix.e2e-spec.ts +++ b/server/test/authorization-matrix.e2e-spec.ts @@ -416,6 +416,16 @@ describe('Authorization matrix (e2e)', () => { path: '/notifications', token: 'allPerms', }, + [Permission.KoreaderSync]: { + method: 'GET', + path: '/koreader/credentials', + token: 'allPerms', + }, + [Permission.DemoRestricted]: { + method: 'GET', + path: '/notifications', + token: 'allPerms', + }, }; const failures: MatrixFailure[] = []; diff --git a/server/test/e2e/authorization-matrix/route-inventory/manifest.json b/server/test/e2e/authorization-matrix/route-inventory/manifest.json index 64fac3b01..d8853084c 100644 --- a/server/test/e2e/authorization-matrix/route-inventory/manifest.json +++ b/server/test/e2e/authorization-matrix/route-inventory/manifest.json @@ -1,16 +1,19 @@ { - "totalRoutes": 323, + "totalRoutes": 353, "byPermission": { "(none)": 161, "Permission.ManageMetadataConfig": 32, "Permission.EmailSend": 30, - "Permission.LibraryEditMetadata": 23, + "Permission.LibraryEditMetadata": 31, "Permission.BookDockAccess": 17, - "Permission.ManageAppSettings": 14, + "Permission.ManageAppSettings": 23, "Permission.ManageLibraries": 13, "Permission.ManageUsers": 10, "Permission.ManageEmail": 8, "Permission.KoboSync": 7, + "Permission.NotificationAccess": 6, + "Permission.DemoRestricted": 6, + "Permission.KoreaderSync": 8, "Permission.OpdsAccess": 4, "Permission.ViewAuditLog": 1, "Permission.LibraryDeleteBooks": 1, diff --git a/server/test/e2e/authorization-matrix/route-inventory/routes-01.json b/server/test/e2e/authorization-matrix/route-inventory/routes-01.json index 55cb32165..91683faae 100644 --- a/server/test/e2e/authorization-matrix/route-inventory/routes-01.json +++ b/server/test/e2e/authorization-matrix/route-inventory/routes-01.json @@ -122,9 +122,9 @@ { "file": "server/src/modules/app-settings/app-settings.controller.ts", "className": "AppSettingsController", - "methodName": "getOidcPublicConfig", + "methodName": "getOidcProvidersPublic", "httpMethod": "GET", - "path": "/app-settings/oidc/public", + "path": "/app-settings/oidc/providers/public", "permissions": ["Permission.ManageAppSettings"], "libraryAccess": [], "isPublic": true, @@ -134,9 +134,9 @@ { "file": "server/src/modules/app-settings/app-settings.controller.ts", "className": "AppSettingsController", - "methodName": "getOidcConfig", + "methodName": "listOidcProviders", "httpMethod": "GET", - "path": "/app-settings/oidc", + "path": "/app-settings/oidc/providers", "permissions": ["Permission.ManageAppSettings"], "libraryAccess": [], "isPublic": false, @@ -146,9 +146,9 @@ { "file": "server/src/modules/app-settings/app-settings.controller.ts", "className": "AppSettingsController", - "methodName": "updateOidcConfig", - "httpMethod": "PUT", - "path": "/app-settings/oidc", + "methodName": "getOidcProvider", + "httpMethod": "GET", + "path": "/app-settings/oidc/providers/:slug", "permissions": ["Permission.ManageAppSettings"], "libraryAccess": [], "isPublic": false, @@ -158,9 +158,105 @@ { "file": "server/src/modules/app-settings/app-settings.controller.ts", "className": "AppSettingsController", - "methodName": "testOidcConnection", + "methodName": "createOidcProvider", "httpMethod": "POST", - "path": "/app-settings/oidc/test", + "path": "/app-settings/oidc/providers", + "permissions": ["Permission.ManageAppSettings"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "updateOidcProvider", + "httpMethod": "PUT", + "path": "/app-settings/oidc/providers/:slug", + "permissions": ["Permission.ManageAppSettings"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "deleteOidcProvider", + "httpMethod": "DELETE", + "path": "/app-settings/oidc/providers/:slug", + "permissions": ["Permission.ManageAppSettings"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "testOidcProviderConnection", + "httpMethod": "POST", + "path": "/app-settings/oidc/providers/:slug/test", + "permissions": ["Permission.ManageAppSettings"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "reorderOidcProviders", + "httpMethod": "PUT", + "path": "/app-settings/oidc/providers/reorder", + "permissions": ["Permission.ManageAppSettings"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "listProviderGroupMappings", + "httpMethod": "GET", + "path": "/app-settings/oidc/providers/:slug/group-mappings", + "permissions": ["Permission.ManageAppSettings"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "createProviderGroupMapping", + "httpMethod": "POST", + "path": "/app-settings/oidc/providers/:slug/group-mappings", + "permissions": ["Permission.ManageAppSettings"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "updateProviderGroupMapping", + "httpMethod": "PUT", + "path": "/app-settings/oidc/providers/:slug/group-mappings/:id", + "permissions": ["Permission.ManageAppSettings"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/app-settings/app-settings.controller.ts", + "className": "AppSettingsController", + "methodName": "deleteProviderGroupMapping", + "httpMethod": "DELETE", + "path": "/app-settings/oidc/providers/:slug/group-mappings/:id", "permissions": ["Permission.ManageAppSettings"], "libraryAccess": [], "isPublic": false, @@ -384,12 +480,156 @@ "useGuards": [] }, { - "file": "server/src/modules/authors/authors.controller.ts", - "className": "AuthorsController", - "methodName": "findDuplicateSuggestions", + "file": "server/src/modules/entity-manager/entity-manager.controller.ts", + "className": "EntityManagerController", + "methodName": "browse", "httpMethod": "GET", - "path": "/authors/suggestions/duplicates", - "permissions": [], + "path": "/entity-manager/:entityType/browse", + "permissions": ["Permission.LibraryEditMetadata"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/entity-manager/entity-manager.controller.ts", + "className": "EntityManagerController", + "methodName": "scanDuplicates", + "httpMethod": "GET", + "path": "/entity-manager/:entityType/duplicates/scan", + "permissions": ["Permission.LibraryEditMetadata"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/entity-manager/entity-manager.controller.ts", + "className": "EntityManagerController", + "methodName": "getDuplicateScanStatus", + "httpMethod": "GET", + "path": "/entity-manager/:entityType/duplicates/status", + "permissions": ["Permission.LibraryEditMetadata"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/entity-manager/entity-manager.controller.ts", + "className": "EntityManagerController", + "methodName": "refreshDuplicates", + "httpMethod": "POST", + "path": "/entity-manager/:entityType/duplicates/refresh", + "permissions": ["Permission.LibraryEditMetadata"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/entity-manager/entity-manager.controller.ts", + "className": "EntityManagerController", + "methodName": "merge", + "httpMethod": "POST", + "path": "/entity-manager/:entityType/merge", + "permissions": ["Permission.LibraryEditMetadata"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/entity-manager/entity-manager.controller.ts", + "className": "EntityManagerController", + "methodName": "rename", + "httpMethod": "POST", + "path": "/entity-manager/:entityType/rename", + "permissions": ["Permission.LibraryEditMetadata"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/entity-manager/entity-manager.controller.ts", + "className": "EntityManagerController", + "methodName": "deleteEntity", + "httpMethod": "POST", + "path": "/entity-manager/:entityType/delete", + "permissions": ["Permission.LibraryEditMetadata"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/entity-manager/entity-manager.controller.ts", + "className": "EntityManagerController", + "methodName": "bulkDelete", + "httpMethod": "POST", + "path": "/entity-manager/:entityType/bulk-delete", + "permissions": ["Permission.LibraryEditMetadata"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/entity-manager/entity-manager.controller.ts", + "className": "EntityManagerController", + "methodName": "split", + "httpMethod": "POST", + "path": "/entity-manager/:entityType/split", + "permissions": ["Permission.LibraryEditMetadata"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/entity-manager/entity-manager.controller.ts", + "className": "EntityManagerController", + "methodName": "dismissPair", + "httpMethod": "POST", + "path": "/entity-manager/:entityType/duplicates/dismiss", + "permissions": ["Permission.LibraryEditMetadata"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/entity-manager/entity-manager.controller.ts", + "className": "EntityManagerController", + "methodName": "undismissPair", + "httpMethod": "POST", + "path": "/entity-manager/:entityType/duplicates/undismiss", + "permissions": ["Permission.LibraryEditMetadata"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/entity-manager/entity-manager.controller.ts", + "className": "EntityManagerController", + "methodName": "getDismissedPairs", + "httpMethod": "GET", + "path": "/entity-manager/:entityType/duplicates/dismissed", + "permissions": ["Permission.LibraryEditMetadata"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/entity-manager/entity-manager.controller.ts", + "className": "EntityManagerController", + "methodName": "getEntityInfo", + "httpMethod": "GET", + "path": "/entity-manager/:entityType/info/:entityId", + "permissions": ["Permission.LibraryEditMetadata"], "libraryAccess": [], "isPublic": false, "allowDefault": false, diff --git a/server/test/e2e/authorization-matrix/route-inventory/routes-07.json b/server/test/e2e/authorization-matrix/route-inventory/routes-07.json index c127f3ef3..b55979338 100644 --- a/server/test/e2e/authorization-matrix/route-inventory/routes-07.json +++ b/server/test/e2e/authorization-matrix/route-inventory/routes-07.json @@ -228,60 +228,168 @@ "useGuards": [] }, { - "file": "server/src/modules/tools/tools.controller.ts", - "className": "ToolsController", - "methodName": "scanAuthorDuplicates", - "httpMethod": "GET", - "path": "/tools/authors/duplicates/scan", - "permissions": ["Permission.LibraryEditMetadata"], - "libraryAccess": [], - "isPublic": false, - "allowDefault": false, - "useGuards": [] - }, - { - "file": "server/src/modules/tools/tools.controller.ts", - "className": "ToolsController", - "methodName": "merge", + "file": "server/src/modules/koreader/koreader.controller.ts", + "className": "KoreaderController", + "methodName": "createCredentials", "httpMethod": "POST", - "path": "/tools/authors/duplicates/merge", - "permissions": ["Permission.LibraryEditMetadata"], + "path": "/koreader/credentials", + "permissions": ["Permission.KoreaderSync"], "libraryAccess": [], "isPublic": false, "allowDefault": false, "useGuards": [] }, { - "file": "server/src/modules/tools/tools.controller.ts", - "className": "ToolsController", - "methodName": "batchMerge", - "httpMethod": "POST", - "path": "/tools/authors/duplicates/batch-merge", - "permissions": ["Permission.LibraryEditMetadata"], + "file": "server/src/modules/koreader/koreader.controller.ts", + "className": "KoreaderController", + "methodName": "updateCredentials", + "httpMethod": "PATCH", + "path": "/koreader/credentials", + "permissions": ["Permission.KoreaderSync"], "libraryAccess": [], "isPublic": false, "allowDefault": false, "useGuards": [] }, { - "file": "server/src/modules/tools/tools.controller.ts", - "className": "ToolsController", - "methodName": "dismiss", - "httpMethod": "POST", - "path": "/tools/authors/duplicates/dismiss", - "permissions": ["Permission.LibraryEditMetadata"], - "libraryAccess": [], - "isPublic": false, - "allowDefault": false, - "useGuards": [] - }, - { - "file": "server/src/modules/tools/tools.controller.ts", - "className": "ToolsController", - "methodName": "undismiss", + "file": "server/src/modules/koreader/koreader.controller.ts", + "className": "KoreaderController", + "methodName": "deleteCredentials", "httpMethod": "DELETE", - "path": "/tools/authors/duplicates/dismiss", - "permissions": ["Permission.LibraryEditMetadata"], + "path": "/koreader/credentials", + "permissions": ["Permission.KoreaderSync"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/koreader/koreader.controller.ts", + "className": "KoreaderController", + "methodName": "getCredentials", + "httpMethod": "GET", + "path": "/koreader/credentials", + "permissions": ["Permission.KoreaderSync"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/koreader/koreader.controller.ts", + "className": "KoreaderController", + "methodName": "getSyncStatus", + "httpMethod": "GET", + "path": "/koreader/sync-status", + "permissions": ["Permission.KoreaderSync"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/koreader/koreader.controller.ts", + "className": "KoreaderController", + "methodName": "getDevices", + "httpMethod": "GET", + "path": "/koreader/devices", + "permissions": ["Permission.KoreaderSync"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/koreader/koreader.controller.ts", + "className": "KoreaderController", + "methodName": "getBookProgress", + "httpMethod": "GET", + "path": "/koreader/books/:bookId/progress", + "permissions": ["Permission.KoreaderSync"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/koreader/koreader.controller.ts", + "className": "KoreaderController", + "methodName": "testConnection", + "httpMethod": "POST", + "path": "/koreader/test-connection", + "permissions": ["Permission.KoreaderSync"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/notification/notification.controller.ts", + "className": "NotificationController", + "methodName": "list", + "httpMethod": "GET", + "path": "/notifications", + "permissions": ["Permission.NotificationAccess", "Permission.DemoRestricted"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/notification/notification.controller.ts", + "className": "NotificationController", + "methodName": "getUnreadCount", + "httpMethod": "GET", + "path": "/notifications/unread-count", + "permissions": ["Permission.NotificationAccess", "Permission.DemoRestricted"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/notification/notification.controller.ts", + "className": "NotificationController", + "methodName": "markAllAsRead", + "httpMethod": "PATCH", + "path": "/notifications/read-all", + "permissions": ["Permission.NotificationAccess", "Permission.DemoRestricted"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/notification/notification.controller.ts", + "className": "NotificationController", + "methodName": "markAsRead", + "httpMethod": "PATCH", + "path": "/notifications/:id/read", + "permissions": ["Permission.NotificationAccess", "Permission.DemoRestricted"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/notification/notification.controller.ts", + "className": "NotificationController", + "methodName": "dismiss", + "httpMethod": "DELETE", + "path": "/notifications/:id", + "permissions": ["Permission.NotificationAccess", "Permission.DemoRestricted"], + "libraryAccess": [], + "isPublic": false, + "allowDefault": false, + "useGuards": [] + }, + { + "file": "server/src/modules/notification/notification.controller.ts", + "className": "NotificationController", + "methodName": "clearAll", + "httpMethod": "DELETE", + "path": "/notifications", + "permissions": ["Permission.NotificationAccess", "Permission.DemoRestricted"], "libraryAccess": [], "isPublic": false, "allowDefault": false, diff --git a/server/test/e2e/migration-booklore/migration-booklore-fixture-builder.ts b/server/test/e2e/migration-booklore/migration-booklore-fixture-builder.ts index 2055e8a73..feebd19fe 100644 --- a/server/test/e2e/migration-booklore/migration-booklore-fixture-builder.ts +++ b/server/test/e2e/migration-booklore/migration-booklore-fixture-builder.ts @@ -838,7 +838,7 @@ async function insertTargetBook(ctx: MigrationBookloreE2EContext, input: InsertT ino: nextInode++, sizeBytes: 128, mtime: new Date('2024-01-01T12:00:00Z'), - hash: file.hash, + fileHash: file.hash, format: file.format, role: 'content', sortOrder: index, diff --git a/server/test/e2e/opds/opds-harness.ts b/server/test/e2e/opds/opds-harness.ts index 6c8cfb2dc..b763c3436 100644 --- a/server/test/e2e/opds/opds-harness.ts +++ b/server/test/e2e/opds/opds-harness.ts @@ -208,6 +208,11 @@ export async function createBookCoverArtifacts( await writeFixtureFile(ctx.fixture.booksPath, `covers/${bookId}/cover.${coverExtension}`, coverContent); await writeFixtureFile(ctx.fixture.booksPath, `covers/${bookId}/thumbnail.jpg`, thumbnailContent); + + await ctx.db + .insert(schema.bookMetadata) + .values({ bookId, coverSource: 'custom' }) + .onConflictDoUpdate({ target: schema.bookMetadata.bookId, set: { coverSource: 'custom' } }); } export async function createUserAndLogin( diff --git a/server/test/users-admin-lifecycle.e2e-spec.ts b/server/test/users-admin-lifecycle.e2e-spec.ts index 764c23d82..aa8863848 100644 --- a/server/test/users-admin-lifecycle.e2e-spec.ts +++ b/server/test/users-admin-lifecycle.e2e-spec.ts @@ -521,13 +521,21 @@ describe('Users admin lifecycle (e2e)', { timeout: 180_000 }, () => { headers: authHeader(regularA.accessToken), payload: { name: 'Regular A Updated', - settings: { syncReaderPreferences: true }, }, }); expect(updateProfile.statusCode).toBe(200); - const updatedProfile = updateProfile.json() as { name: string; settings: { syncReaderPreferences?: boolean } }; + const updatedProfile = updateProfile.json() as { name: string }; expect(updatedProfile.name).toBe('Regular A Updated'); - expect(updatedProfile.settings.syncReaderPreferences).toBe(true); + + const updateSettings = await ctx.app.inject({ + method: 'PATCH', + url: '/api/v1/users/me/settings', + headers: authHeader(regularA.accessToken), + payload: { settings: { syncReaderPreferences: true } }, + }); + expect(updateSettings.statusCode).toBe(200); + const updatedSettings = updateSettings.json() as { settings: { syncReaderPreferences?: boolean } }; + expect(updatedSettings.settings.syncReaderPreferences).toBe(true); const noFileUpload = await ctx.app.inject({ method: 'POST',