From 4ae68d01de53115602a2602b9e0572e8db04f4bd Mon Sep 17 00:00:00 2001 From: Rob Walsh Date: Tue, 3 Mar 2026 11:51:38 -0700 Subject: [PATCH] Encrypt Hardcover Api Token and fix failing tests --- src/app/api/user/hardcover-shelves/route.ts | 6 ++- src/lib/services/hardcover-sync.service.ts | 16 ++++++- src/lib/services/scheduler.service.ts | 52 ++++++++++++++------- test_hardcover.js | 12 ----- tests/services/job-queue.service.test.ts | 8 ++-- tests/services/scheduler.service.test.ts | 17 ++++--- 6 files changed, 71 insertions(+), 40 deletions(-) delete mode 100644 test_hardcover.js diff --git a/src/app/api/user/hardcover-shelves/route.ts b/src/app/api/user/hardcover-shelves/route.ts index b6ec6a8..4390220 100644 --- a/src/app/api/user/hardcover-shelves/route.ts +++ b/src/app/api/user/hardcover-shelves/route.ts @@ -8,6 +8,7 @@ import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { fetchHardcoverList } from '@/lib/services/hardcover-sync.service'; import { getJobQueueService } from '@/lib/services/job-queue.service'; +import { getEncryptionService } from '@/lib/services/encryption.service'; import { z } from 'zod'; import { RMABLogger } from '@/lib/utils/logger'; @@ -150,12 +151,15 @@ export async function POST(request: NextRequest) { ); } + const encryptionService = getEncryptionService(); + const encryptedToken = encryptionService.encrypt(apiToken); + const shelf = await prisma.hardcoverShelf.create({ data: { userId: req.user.id, name: listName, listId, - apiToken, + apiToken: encryptedToken, bookCount, coverUrls: initialBooks.length > 0 ? JSON.stringify(initialBooks) : null, diff --git a/src/lib/services/hardcover-sync.service.ts b/src/lib/services/hardcover-sync.service.ts index 53f08ea..e644d14 100644 --- a/src/lib/services/hardcover-sync.service.ts +++ b/src/lib/services/hardcover-sync.service.ts @@ -10,6 +10,7 @@ import axios from 'axios'; import { prisma } from '@/lib/db'; import { getAudibleService } from '@/lib/integrations/audible.service'; import { createRequestForUser } from '@/lib/services/request-creator.service'; +import { getEncryptionService } from '@/lib/services/encryption.service'; import { RMABLogger } from '@/lib/utils/logger'; const logger = RMABLogger.create('HardcoverSync'); @@ -330,9 +331,22 @@ async function processShelf( `Fetching Hardcover List "${shelf.name}" (user: ${shelf.user.plexUsername})`, ); + const encryptionService = getEncryptionService(); + let decryptedToken = shelf.apiToken; + try { + // Check if the token is encrypted (our new storage method format) + if (encryptionService.isEncryptedFormat(shelf.apiToken)) { + decryptedToken = encryptionService.decrypt(shelf.apiToken); + } + } catch (err) { + log.error( + `Failed to decrypt API token for user ${shelf.user.plexUsername}`, + ); + } + let fetchedData: { listName: string; books: HardcoverApiBook[] }; try { - fetchedData = await fetchHardcoverList(shelf.apiToken, shelf.listId); + fetchedData = await fetchHardcoverList(decryptedToken, shelf.listId); } catch (error) { log.error( `Failed to fetch Hardcover list "${shelf.name}": ${error instanceof Error ? error.message : 'Unknown error'}`, diff --git a/src/lib/services/scheduler.service.ts b/src/lib/services/scheduler.service.ts index 7b1b2eb..7281376 100644 --- a/src/lib/services/scheduler.service.ts +++ b/src/lib/services/scheduler.service.ts @@ -189,7 +189,9 @@ export class SchedulerService { await this.unscheduleJob(job); } await prisma.scheduledJob.delete({ where: { id: job.id } }); - logger.info(`Removed deprecated scheduled job: ${job.name} (${job.type})`); + logger.info( + `Removed deprecated scheduled job: ${job.name} (${job.type})`, + ); } } catch (error) { logger.error('Failed to cleanup deprecated scheduled jobs', { @@ -222,11 +224,13 @@ export class SchedulerService { job.type, { scheduledJobId: job.id }, job.schedule, - `scheduled-${job.id}` + `scheduled-${job.id}`, ); logger.info(`Job scheduled: ${job.name} (${job.schedule})`); } catch (error) { - logger.error(`Failed to schedule job ${job.name}`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to schedule job ${job.name}`, { + error: error instanceof Error ? error.message : String(error), + }); throw error; } } @@ -239,11 +243,13 @@ export class SchedulerService { await this.jobQueue.removeRepeatableJob( job.type, job.schedule, - `scheduled-${job.id}` + `scheduled-${job.id}`, ); logger.info(`Job unscheduled: ${job.name}`); } catch (error) { - logger.error(`Failed to unschedule job ${job.name}`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to unschedule job ${job.name}`, { + error: error instanceof Error ? error.message : String(error), + }); // Don't throw - job might not exist in Bull yet } } @@ -295,7 +301,7 @@ export class SchedulerService { */ async updateScheduledJob( id: string, - dto: UpdateScheduledJobDto + dto: UpdateScheduledJobDto, ): Promise { if (dto.schedule) { this.validateCronExpression(dto.schedule); @@ -439,7 +445,8 @@ export class SchedulerService { throw new Error(errorMsg); } - libraryId = job.payload?.libraryId || absConfig['audiobookshelf.library_id']; + libraryId = + job.payload?.libraryId || absConfig['audiobookshelf.library_id']; } else { const plexConfig = await configService.getMany([ 'plex_url', @@ -463,15 +470,18 @@ export class SchedulerService { throw new Error(errorMsg); } - libraryId = job.payload?.libraryId || plexConfig.plex_audiobook_library_id; + libraryId = + job.payload?.libraryId || plexConfig.plex_audiobook_library_id; } - logger.info(`Triggering ${backendMode} library scan for library: ${libraryId}`); + logger.info( + `Triggering ${backendMode} library scan for library: ${libraryId}`, + ); return await this.jobQueue.addPlexScanJob( libraryId || '', job.payload?.partial, - job.payload?.path + job.payload?.path, ); } @@ -492,7 +502,6 @@ export class SchedulerService { return await this.jobQueue.addAudibleRefreshJob(job.id); } - /** * Enable a scheduled job */ @@ -524,10 +533,12 @@ export class SchedulerService { await this.triggerJobNow(job.id); // Stagger triggers to avoid connection pool burst on startup - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 500)); } } catch (error) { - logger.error(`Failed to trigger overdue job "${job.name}"`, { error: error instanceof Error ? error.message : String(error) }); + logger.error(`Failed to trigger overdue job "${job.name}"`, { + error: error instanceof Error ? error.message : String(error), + }); } } } @@ -600,13 +611,22 @@ export class SchedulerService { if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { const hourNum = parseInt(hour, 10); const minuteNum = parseInt(minute, 10); - if (!isNaN(hourNum) && !isNaN(minuteNum) && hourNum >= 0 && hourNum <= 23 && minuteNum >= 0 && minuteNum <= 59) { + if ( + !isNaN(hourNum) && + !isNaN(minuteNum) && + hourNum >= 0 && + hourNum <= 23 && + minuteNum >= 0 && + minuteNum <= 59 + ) { return 24 * 60 * 60 * 1000; // 24 hours } } // For other patterns, return a conservative default (24 hours) - logger.warn(`Unknown cron pattern "${cronExpression}", defaulting to 24 hours`); + logger.warn( + `Unknown cron pattern "${cronExpression}", defaulting to 24 hours`, + ); return 24 * 60 * 60 * 1000; } @@ -656,7 +676,7 @@ export class SchedulerService { * Trigger Reading shelves sync */ private async triggerSyncShelves(job: any): Promise { - return await this.jobQueue.addSyncShelvesJob(job.id, undefined, 'goodreads'); + return await this.jobQueue.addSyncShelvesJob(job.id); } } diff --git a/test_hardcover.js b/test_hardcover.js deleted file mode 100644 index c6ba0d3..0000000 --- a/test_hardcover.js +++ /dev/null @@ -1,12 +0,0 @@ -const axios = require('axios'); -async function run() { - try { - const res = await axios.post('https://api.hardcover.app/v1/graphql', { - query: "{ __schema { types { name } } }" - }); - console.log(res.data); - } catch(e) { - console.log(e.response ? e.response.data : e.message); - } -} -run(); diff --git a/tests/services/job-queue.service.test.ts b/tests/services/job-queue.service.test.ts index 78e3998..922584c 100644 --- a/tests/services/job-queue.service.test.ts +++ b/tests/services/job-queue.service.test.ts @@ -21,7 +21,7 @@ const processorsMock = vi.hoisted(() => ({ processRetryMissingTorrents: vi.fn().mockResolvedValue('ok'), processRetryFailedImports: vi.fn().mockResolvedValue('ok'), processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'), - processSyncGoodreadsShelves: vi.fn().mockResolvedValue('ok'), + processSyncShelves: vi.fn().mockResolvedValue('ok'), // Ebook processors processSearchEbook: vi.fn().mockResolvedValue('ok'), processStartDirectDownload: vi.fn().mockResolvedValue('ok'), @@ -116,8 +116,8 @@ vi.mock('@/lib/processors/cleanup-seeded-torrents.processor', () => ({ processCleanupSeededTorrents: processorsMock.processCleanupSeededTorrents, })); -vi.mock('@/lib/processors/sync-goodreads-shelves.processor', () => ({ - processSyncGoodreadsShelves: processorsMock.processSyncGoodreadsShelves, +vi.mock('@/lib/processors/sync-shelves.processor', () => ({ + processSyncShelves: processorsMock.processSyncShelves, })); // Ebook processors @@ -564,7 +564,7 @@ describe('JobQueueService', () => { expect(processorsMock.processRetryMissingTorrents).toHaveBeenCalled(); expect(processorsMock.processRetryFailedImports).toHaveBeenCalled(); expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled(); - expect(processorsMock.processSyncGoodreadsShelves).toHaveBeenCalled(); + expect(processorsMock.processSyncShelves).toHaveBeenCalled(); }); it('returns repeatable jobs from the queue', async () => { diff --git a/tests/services/scheduler.service.test.ts b/tests/services/scheduler.service.test.ts index a64b021..bd3b0f2 100644 --- a/tests/services/scheduler.service.test.ts +++ b/tests/services/scheduler.service.test.ts @@ -18,7 +18,8 @@ const jobQueueMock = vi.hoisted(() => ({ addRetryFailedImportsJob: vi.fn(), addCleanupSeededTorrentsJob: vi.fn(), addMonitorRssFeedsJob: vi.fn(), - addSyncGoodreadsShelvesJob: vi.fn(), + addMonitorRssFeedsJob: vi.fn(), + addSyncShelvesJob: vi.fn(), })); const configServiceMock = vi.hoisted(() => ({ @@ -63,7 +64,9 @@ describe('SchedulerService', () => { prismaMock.scheduledJob.findFirst.mockResolvedValue(null); prismaMock.scheduledJob.create.mockResolvedValue({}); prismaMock.scheduledJob.findMany + .mockResolvedValueOnce([]) // cleanupDeprecatedJobs .mockResolvedValueOnce([ + // scheduleAllJobs { id: 'job-1', name: 'Audible Data Refresh', @@ -72,9 +75,10 @@ describe('SchedulerService', () => { enabled: true, }, ]) - .mockResolvedValueOnce([]); + .mockResolvedValue([]); // triggerOverdueJobs - const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const { SchedulerService } = + await import('@/lib/services/scheduler.service'); const service = new SchedulerService(); await service.start(); @@ -83,7 +87,7 @@ describe('SchedulerService', () => { 'audible_refresh', { scheduledJobId: 'job-1' }, '0 0 * * *', - 'scheduled-job-1' + 'scheduled-job-1', ); }); @@ -289,7 +293,7 @@ describe('SchedulerService', () => { ['retry_failed_imports', 'addRetryFailedImportsJob'], ['cleanup_seeded_torrents', 'addCleanupSeededTorrentsJob'], ['monitor_rss_feeds', 'addMonitorRssFeedsJob'], - ['sync_goodreads_shelves', 'addSyncGoodreadsShelvesJob'], + ['sync_reading_shelves', 'addSyncShelvesJob'], ])('triggers %s jobs with job queue', async (type, queueMethod) => { prismaMock.scheduledJob.findUnique.mockResolvedValue({ id: 'job-type', @@ -302,7 +306,8 @@ describe('SchedulerService', () => { (jobQueueMock as any)[queueMethod].mockResolvedValue('bull-type'); prismaMock.scheduledJob.update.mockResolvedValue({}); - const { SchedulerService } = await import('@/lib/services/scheduler.service'); + const { SchedulerService } = + await import('@/lib/services/scheduler.service'); const service = new SchedulerService(); const jobId = await service.triggerJobNow('job-type');