From 671d1bb50dc011887d6d18b0b0ce9c3a5d535715 Mon Sep 17 00:00:00 2001 From: Jay Hill <116148+jayhill@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:40:28 -0400 Subject: [PATCH 1/5] #208 use SSM parameter for dashboard path --- .github/workflows/deploy.yml | 1 + .github/workflows/manual-deploy.yml | 1 + serverless/api/serverless.yml | 1 + serverless/app/serverless.yml | 1 + 4 files changed, 4 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a661dc1..8d53f99 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,6 +46,7 @@ jobs: REQUIRED_PARAMS=( "/zipcase/portal_url" "/zipcase/portal_case_url" + "/zipcase/portal_dashboard_path" "/zipcase/cognito/user_pool_id" "/zipcase/cognito/app_client_id" "/zipcase/alert-email" diff --git a/.github/workflows/manual-deploy.yml b/.github/workflows/manual-deploy.yml index f7cf1b1..aa59536 100644 --- a/.github/workflows/manual-deploy.yml +++ b/.github/workflows/manual-deploy.yml @@ -54,6 +54,7 @@ jobs: REQUIRED_PARAMS=( "/zipcase/portal_url" "/zipcase/portal_case_url" + "/zipcase/portal_dashboard_path" "/zipcase/cognito/user_pool_id" "/zipcase/cognito/app_client_id" ) diff --git a/serverless/api/serverless.yml b/serverless/api/serverless.yml index 0c5b029..effe7cf 100644 --- a/serverless/api/serverless.yml +++ b/serverless/api/serverless.yml @@ -14,6 +14,7 @@ provider: CASE_DATA_QUEUE_URL: ${cf:infra-${self:provider.stage}.CaseDataQueueUrl} PORTAL_URL: ${ssm:/zipcase/portal_url} PORTAL_CASE_URL: ${ssm:/zipcase/portal_case_url} + PORTAL_DASHBOARD_PATH: ${ssm:/zipcase/portal_dashboard_path} iam: role: statements: diff --git a/serverless/app/serverless.yml b/serverless/app/serverless.yml index 92dd388..3d72c42 100644 --- a/serverless/app/serverless.yml +++ b/serverless/app/serverless.yml @@ -18,6 +18,7 @@ provider: DEFAULT_USAGE_PLAN_ID: ${cf:api-${self:provider.stage}.TestUsagePlanId} PORTAL_URL: ${ssm:/zipcase/portal_url} PORTAL_CASE_URL: ${ssm:/zipcase/portal_case_url} + PORTAL_DASHBOARD_PATH: ${ssm:/zipcase/portal_dashboard_path} UPLOADS_BUCKET: ${ssm:/zipcase/uploads_bucket} iam: role: From 194ec725b2636a5f3620bd0603889f67a6ffc960 Mon Sep 17 00:00:00 2001 From: Jay Hill <116148+jayhill@users.noreply.github.com> Date: Sun, 26 Apr 2026 08:55:09 -0400 Subject: [PATCH 2/5] #208 slim down CaseProcessor to remove duplicate functionality with other processors --- .../app/handlers/__tests__/case.test.ts | 23 +- serverless/lib/CaseProcessor.ts | 635 +++++------------- .../__tests__/CaseStatusPublishing.test.ts | 61 +- .../lib/__tests__/caseProcessor.test.ts | 141 +--- 4 files changed, 255 insertions(+), 605 deletions(-) diff --git a/serverless/app/handlers/__tests__/case.test.ts b/serverless/app/handlers/__tests__/case.test.ts index 043b8ef..5332b1e 100644 --- a/serverless/app/handlers/__tests__/case.test.ts +++ b/serverless/app/handlers/__tests__/case.test.ts @@ -8,7 +8,18 @@ import CaseProcessor from '../../../lib/CaseProcessor'; jest.mock('../../../lib/StorageClient'); jest.mock('../../../lib/PortalAuthenticator'); jest.mock('../../../lib/QueueClient'); -jest.mock('../../../lib/CaseProcessor'); +jest.mock('../../../lib/CaseProcessor', () => { + const actual = jest.requireActual('../../../lib/CaseProcessor'); + + return { + __esModule: true, + ...actual, + default: { + ...actual.default, + processCaseData: jest.fn(), + }, + }; +}); // Mock event with auth context const createEvent = (pathParams?: any, userId = 'test-user-id') => ({ @@ -25,8 +36,18 @@ const createEvent = (pathParams?: any, userId = 'test-user-id') => ({ }); describe('case handler', () => { + let logSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; + beforeEach(() => { jest.clearAllMocks(); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); }); describe('get function', () => { diff --git a/serverless/lib/CaseProcessor.ts b/serverless/lib/CaseProcessor.ts index c5b765b..be22622 100644 --- a/serverless/lib/CaseProcessor.ts +++ b/serverless/lib/CaseProcessor.ts @@ -1,16 +1,14 @@ import { SQSHandler, SQSEvent } from 'aws-lambda'; -import PortalAuthenticator from './PortalAuthenticator'; import QueueClient from './QueueClient'; import StorageClient from './StorageClient'; import UserAgentClient from './UserAgentClient'; import AlertService, { Severity, AlertCategory } from './AlertService'; +import PortalAuthenticator from './PortalAuthenticator'; +import PortalRequestClient from './PortalRequestClient'; import { CaseSummary, Charge, Disposition, FetchStatus } from '../../shared/types'; import WebSocketPublisher from './WebSocketPublisher'; -import { CookieJar } from 'tough-cookie'; -import axios from 'axios'; -import { wrapper } from 'axios-cookiejar-support'; +import { AxiosResponse } from 'axios'; import { parseUsDate, formatIsoDate } from '../../shared/DateTimeUtils'; -import * as cheerio from 'cheerio'; // Version date used to determine whether a cached 'complete' CaseSummary is // up-to-date or should be re-fetched to align with current schema/logic. @@ -21,37 +19,6 @@ export const CASE_SUMMARY_VERSION_DATE = new Date('2025-10-08T14:00:00Z'); // eslint-disable-next-line @typescript-eslint/no-explicit-any type PortalApiResponse = any; -// Process the case search queue - responsible for finding caseId (status: 'found') -const processCaseSearch: SQSHandler = async (event: SQSEvent) => { - console.log(`Received ${event.Records.length} case search messages`); - - // Create specialized logger for case search - const caseSearchLogger = AlertService.forCategory(AlertCategory.SYSTEM); - - for (const record of event.Records) { - try { - const messageBody = JSON.parse(record.body); - const { caseNumber, userId, userAgent } = messageBody; - - if (!caseNumber || !userId) { - await caseSearchLogger.error('Invalid message format, missing required fields', undefined, { - caseNumber, - userId, - messageId: record.messageId, - }); - continue; - } - - console.log(`Searching for case ${caseNumber} for user ${userId}`); - await processCaseSearchRecord(caseNumber, userId, record.receiptHandle, userAgent); - } catch (error) { - await caseSearchLogger.error('Failed to process case search record', error as Error, { - messageId: record.messageId, - }); - } - } -}; - // Process the case data queue - responsible for fetching case data (status: 'complete') const processCaseData: SQSHandler = async (event: SQSEvent) => { console.log(`Received ${event.Records.length} case data messages`); @@ -84,172 +51,6 @@ const processCaseData: SQSHandler = async (event: SQSEvent) => { } }; -function queueCasesForSearch(cases: Array, userId: string): Promise { - return QueueClient.queueCasesForSearch(cases, userId); -} - -// Process a case search message - responsible for finding the caseId -async function processCaseSearchRecord( - caseNumber: string, - userId: string, - receiptHandle: string, - userAgent?: string -): Promise { - try { - const now = new Date(); - const nowTime = now.getTime(); - const isoNow = now.toISOString(); - - const zipCase = await StorageClient.getCase(caseNumber); - - if (zipCase) { - const fetchStatus = zipCase.fetchStatus.status; - - // If already in a found or complete state, no need to search for the case again - if (['found', 'complete'].includes(fetchStatus) && zipCase.caseId) { - // Case ID is already known, delete the search queue item - await QueueClient.deleteMessage(receiptHandle, 'search'); - console.log(`Case ${caseNumber} already has a caseId; deleted search queue item`); - return zipCase.fetchStatus; - } - - if (['queued', 'failed', 'notFound'].includes(fetchStatus)) { - await StorageClient.saveCase({ - caseNumber, - fetchStatus: { status: 'processing' }, - lastUpdated: isoNow, - }); - } else if (fetchStatus === 'processing') { - // Handle processing timeout (5 minutes) - const lastUpdated = zipCase.lastUpdated ? new Date(zipCase.lastUpdated) : new Date(0); - const minutesDiff = (nowTime - lastUpdated.getTime()) / (1000 * 60); - - if (minutesDiff < 5) { - console.log(`Case ${caseNumber} is already being processed (${minutesDiff.toFixed(1)} mins), skipping`); - return zipCase.fetchStatus; - } - - console.log(`Reprocessing case ${caseNumber} after timeout in 'processing' state (${minutesDiff.toFixed(1)} mins)`); - } - } - - // Authenticate with the portal, passing along the user agent if available - const authResult = await PortalAuthenticator.getOrCreateUserSession(userId, userAgent); - - if (!authResult?.success || !authResult.cookieJar) { - const message = !authResult?.success - ? authResult?.message || 'Unknown authentication error' - : `No session CookieJar found for user ${userId}`; - - await AlertService.logError( - // Use ERROR level if it's a credentials issue, CRITICAL for system issues - message.includes('Invalid Email or password') ? Severity.ERROR : Severity.CRITICAL, - AlertCategory.AUTHENTICATION, - 'Portal authentication failed during case search', - undefined, - { - userId, - caseNumber, - message, - } - ); - - const failedStatus: FetchStatus = { status: 'failed', message }; - - await StorageClient.saveCase({ - caseNumber, - fetchStatus: failedStatus, - lastUpdated: isoNow, - caseId: zipCase?.caseId, - }); - - // Delete the queue item since we've saved the failed status - await QueueClient.deleteMessage(receiptHandle, 'search'); - console.log(`Authentication failed for user ${userId}; deleted search queue item for case ${caseNumber}`); - - return failedStatus; - } - - // Search for the case ID - const searchResult = await fetchCaseIdFromPortal(caseNumber, authResult.cookieJar); - - if (!searchResult.caseId) { - // Check if this is a system error or a "not found" case - if (searchResult.error && searchResult.error.isSystemError) { - // System error - mark as failed - await AlertService.logError( - Severity.ERROR, - AlertCategory.PORTAL, - 'Case search failed with system error', - new Error(searchResult.error.message), - { - userId, - caseNumber, - resource: 'case-search', - } - ); - - const failedStatus: FetchStatus = { - status: 'failed', - message: searchResult.error.message, - }; - - await StorageClient.saveCase({ - caseNumber, - fetchStatus: failedStatus, - lastUpdated: isoNow, - }); - - await QueueClient.deleteMessage(receiptHandle, 'search'); - return failedStatus; - } else { - // Not found - legitimate case not found scenario - console.warn(`Case not found: ${caseNumber} for user ${userId}`); - - const notFoundStatus: FetchStatus = { status: 'notFound' }; - - await StorageClient.saveCase({ - caseNumber, - fetchStatus: notFoundStatus, - lastUpdated: isoNow, - }); - - await QueueClient.deleteMessage(receiptHandle, 'search'); - return notFoundStatus; - } - } - - const caseId = searchResult.caseId; - - // Found the case - update status to 'found' and queue for data retrieval - const foundStatus: FetchStatus = { status: 'found' }; - await StorageClient.saveCase({ - caseNumber, - caseId, - fetchStatus: foundStatus, - lastUpdated: isoNow, - }); - - // Delete the search queue item - await QueueClient.deleteMessage(receiptHandle, 'search'); - - // Queue the case for data retrieval - await QueueClient.queueCaseForDataRetrieval(caseNumber, caseId, userId); - console.log(`Case ${caseNumber} found with ID ${caseId}, queued for data retrieval`); - - return foundStatus; - } catch (error) { - const message = `Unhandled error while searching case ${caseNumber}: ${(error as Error).message}`; - - await AlertService.logError(Severity.ERROR, AlertCategory.SYSTEM, 'Unhandled error during case search', error as Error, { - caseNumber, - userId, - }); - - return { status: 'failed', message }; - } -} - // Process a case data message - responsible for fetching case details async function processCaseDataRecord(caseNumber: string, caseId: string, userId: string, receiptHandle: string): Promise { try { @@ -271,7 +72,7 @@ async function processCaseDataRecord(caseNumber: string, caseId: string, userId: } // Fetch case summary - const caseSummary = await fetchCaseSummary(caseId); + const caseSummary = await fetchCaseSummary(caseId, userId); if (!caseSummary) { const message = `Failed to fetch required case summary data for case ${caseNumber}`; @@ -361,216 +162,161 @@ async function processCaseDataRecord(caseNumber: string, caseId: string, userId: } } -// For type hinting and clearer error handling -interface CaseSearchResult { - caseId: string | null; - error?: { - message: string; - isSystemError: boolean; // true for system errors, false for "not found" - }; +interface EndpointConfig { + path: string; } -async function fetchCaseIdFromPortal(caseNumber: string, cookieJar: CookieJar): Promise { - try { - // Get the portal URL from environment variable - const portalUrl = process.env.PORTAL_URL; - - if (!portalUrl) { - const errorMsg = 'PORTAL_URL environment variable is not set'; - - await AlertService.logError( - Severity.CRITICAL, - AlertCategory.SYSTEM, - 'Missing required environment variable: PORTAL_URL', - new Error(errorMsg), - { resource: 'case-search' } - ); - - return { - caseId: null, - error: { - message: 'Portal URL environment variable is not set', - isSystemError: true, - }, - }; - } - - const userAgent = await UserAgentClient.getUserAgent('system'); - - const client = wrapper(axios).create({ - timeout: 20000, - maxRedirects: 10, - validateStatus: status => status < 500, // Only reject on 5xx errors - jar: cookieJar, - withCredentials: true, - headers: { - ...PortalAuthenticator.getDefaultRequestHeaders(userAgent), - Origin: portalUrl, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }); - - console.log(`Searching for case number ${caseNumber}`); - - // Step 1: Submit the search form (following the Insomnia export) - const searchFormData = new URLSearchParams(); - searchFormData.append('caseCriteria.SearchCriteria', caseNumber); - searchFormData.append('caseCriteria.SearchCases', 'true'); - - const searchResponse = await client.post(`${portalUrl}/Portal/SmartSearch/SmartSearch/SmartSearch`, searchFormData); +const caseEndpoints: Record = { + summary: { + path: 'Service/CaseSummariesSlim?key={caseId}', + }, + charges: { + path: "Service/Charges('{caseId}')", + }, + dispositionEvents: { + path: "Service/DispositionEvents('{caseId}')", + }, + financialSummary: { + path: "Service/FinancialSummary('{caseId}')", + }, + caseEvents: { + path: "Service/CaseEvents('{caseId}')?top=200", + }, +}; - if (searchResponse.status !== 200) { - const errorMessage = `Search request failed with status ${searchResponse.status}`; +const CASE_DATA_ACCEPT_HEADER = 'application/json, text/plain, */*'; + +function getCaseDataRequestHeaders(userAgent: string, portalCaseUrl: string): Record { + return { + 'User-Agent': userAgent, + Accept: CASE_DATA_ACCEPT_HEADER, + 'Accept-Language': 'en-US,en;q=0.9', + 'Cache-Control': 'no-cache', + Pragma: 'no-cache', + Referer: portalCaseUrl, + Origin: new URL(portalCaseUrl).origin, + 'Sec-Fetch-Site': 'same-origin', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Dest': 'empty', + }; +} - await AlertService.logError(Severity.ERROR, AlertCategory.PORTAL, 'Case search request failed', new Error(errorMessage), { - caseNumber, - statusCode: searchResponse.status, - resource: 'portal-search', - }); +function getErrorDetails(error: unknown): { + message?: string; + code?: string; + response?: { + status?: number; + headers?: unknown; + }; +} { + if (!error || typeof error !== 'object') { + return {}; + } - return { - caseId: null, - error: { - message: errorMessage, - isSystemError: true, - }, - }; - } + const candidate = error as { + message?: unknown; + code?: unknown; + response?: { + status?: unknown; + headers?: unknown; + }; + }; - // Step 2: Get the search results page - const resultsResponse = await client.get(`${portalUrl}/Portal/SmartSearch/SmartSearchResults`); + return { + message: typeof candidate.message === 'string' ? candidate.message : undefined, + code: typeof candidate.code === 'string' ? candidate.code : undefined, + response: candidate.response + ? { + status: typeof candidate.response.status === 'number' ? candidate.response.status : undefined, + headers: candidate.response.headers, + } + : undefined, + }; +} - if (resultsResponse.status !== 200) { - const errorMessage = `Results request failed with status ${resultsResponse.status}`; +function asObjectRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } - await AlertService.logError( - Severity.ERROR, - AlertCategory.PORTAL, - 'Case search results request failed', - new Error(errorMessage), - { - caseNumber, - statusCode: resultsResponse.status, - resource: 'portal-search-results', - } - ); + return value as Record; +} - return { - caseId: null, - error: { - message: errorMessage, - isSystemError: true, - }, - }; - } +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} - // Check for the specific error message - if (resultsResponse.data.includes('Smart Search is having trouble processing your search')) { - const errorMessage = 'Smart Search is having trouble processing your search. Please try again later.'; +function asString(value: unknown): string { + if (typeof value === 'string') { + return value; + } - await AlertService.logError(Severity.ERROR, AlertCategory.PORTAL, 'Smart Search processing error', new Error(errorMessage), { - caseNumber, - resource: 'smart-search', - }); + if (value === null || typeof value === 'undefined') { + return ''; + } - return { - caseId: null, - error: { - message: errorMessage, - isSystemError: true, - }, - }; - } + return String(value); +} - // Step 3: Extract the case ID from the response using cheerio - const $ = cheerio.load(resultsResponse.data); +function asNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } - // Look for anchor tags with class "caseLink" and get the data-caseid attribute - // From the Insomnia export's after-response script - const caseLinks = $('a.caseLink'); + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } - if (caseLinks.length === 0) { - console.log(`No cases found for case number ${caseNumber}`); - return { - caseId: null, - error: { - message: `No cases found for case number ${caseNumber}`, - isSystemError: false, // This is a "not found" scenario, not a system error - }, - }; - } + return null; +} - // Extract the first case ID (per requirement to just use one) - const caseId = caseLinks.first().attr('data-caseid'); +async function createCaseDataClient(options: { userId: string; userAgent?: string }): Promise<{ + client: PortalRequestClient; + portalCaseUrl: string; + userAgent: string; +}> { + const portalBaseUrl = process.env.PORTAL_URL; + const portalCaseUrl = process.env.PORTAL_CASE_URL; - if (!caseId) { - const errorMessage = `No case ID found in search results for ${caseNumber}`; + if (!portalBaseUrl) { + throw new Error('PORTAL_URL environment variable is not set'); + } - await AlertService.logError( - Severity.ERROR, - AlertCategory.PORTAL, - 'No case ID found in search results', - new Error(errorMessage), - { - caseNumber, - resource: 'case-search-results', - } - ); - return { - caseId: null, - error: { - message: errorMessage, - isSystemError: true, // This is more of a system issue - }, - }; - } + if (!portalCaseUrl) { + throw new Error('PORTAL_CASE_URL environment variable is not set'); + } - console.log(`Found case ID ${caseId} for case number ${caseNumber}`); - return { caseId }; - } catch (error) { - const errorMessage = `Error fetching case ID from portal: ${(error as Error).message}`; + const authResult = await PortalAuthenticator.getOrCreateUserSession(options.userId, options.userAgent); + if (!authResult.success || !authResult.cookieJar) { + throw new Error(authResult.message || 'Failed to acquire portal session for case data fetch'); + } - await AlertService.logError(Severity.ERROR, AlertCategory.PORTAL, 'Failed to fetch case ID from portal', error as Error, { - caseNumber, - resource: 'case-id-fetch', - }); + const userAgent = await UserAgentClient.getUserAgent(options.userId, options.userAgent); - return { - caseId: null, - error: { - message: errorMessage, - isSystemError: true, - }, - }; - } -} + const client = new PortalRequestClient({ + jar: authResult.cookieJar, + portalUrl: portalBaseUrl, + userAgent, + timeout: 10000, + defaultHeaders: getCaseDataRequestHeaders(userAgent, portalCaseUrl), + }); -interface EndpointConfig { - path: string; + return { + client, + portalCaseUrl, + userAgent, + }; } -const caseEndpoints: Record = { - summary: { - path: 'Service/CaseSummariesSlim?key={caseId}', - }, - charges: { - path: "Service/Charges('{caseId}')", - }, - dispositionEvents: { - path: "Service/DispositionEvents('{caseId}')", - }, - financialSummary: { - path: "Service/FinancialSummary('{caseId}')", - }, - caseEvents: { - path: "Service/CaseEvents('{caseId}')?top=200", - }, -}; - const ENDPOINT_FETCH_MAX_RETRIES = parseInt(process.env.ENDPOINT_FETCH_MAX_RETRIES || '3', 10); const ENDPOINT_FETCH_RETRY_BASE_MS = parseInt(process.env.ENDPOINT_FETCH_RETRY_BASE_MS || '200', 10); -export async function fetchWithRetry(client: any, url: string, key: string) { +type CaseDataHttpClient = { + get(url: string): Promise>; +}; + +export async function fetchWithRetry(client: CaseDataHttpClient, url: string, key: string) { let attempt = 0; while (attempt < ENDPOINT_FETCH_MAX_RETRIES) { @@ -593,7 +339,7 @@ export async function fetchWithRetry(client: any, url: string, key: string) { return { key, success: false, error: `${key} request failed with status ${response.status}` }; } catch (error) { - const err: any = error; + const err = getErrorDetails(error); // If axios returned a response, check its status const status = err?.response?.status; @@ -627,9 +373,9 @@ export async function fetchWithRetry(client: any, url: string, key: string) { return { key, success: false, error: `Failed to fetch ${key} after ${ENDPOINT_FETCH_MAX_RETRIES} attempts` }; } -async function fetchCaseSummary(caseId: string): Promise { +async function fetchCaseSummary(caseId: string, userId: string): Promise { try { - const portalCaseUrl = process.env.PORTAL_CASE_URL; + const { client, portalCaseUrl } = await createCaseDataClient({ userId }); if (!portalCaseUrl) { const errorMsg = 'PORTAL_CASE_URL environment variable is not set'; @@ -645,20 +391,6 @@ async function fetchCaseSummary(caseId: string): Promise { return null; } - const userAgent = await UserAgentClient.getUserAgent('system'); - - const client = axios.create({ - timeout: 10000, - maxRedirects: 5, - validateStatus: status => status < 400, - headers: { - 'User-Agent': userAgent, - Accept: 'application/json, text/plain, */*', - 'Accept-Language': 'en-US,en;q=0.9', - Referer: portalCaseUrl, - }, - }); - // First, collect all raw data from endpoints const rawData: Record = {}; @@ -666,7 +398,6 @@ async function fetchCaseSummary(caseId: string): Promise { const endpointPromises = Object.entries(caseEndpoints).map(async ([key, endpoint]) => { try { const url = `${portalCaseUrl}${endpoint.path.replace('{caseId}', caseId)}`; - console.log(`Fetching ${key} data from ${url}`); const fetchResult = await fetchWithRetry(client, url, key); @@ -729,7 +460,7 @@ async function fetchCaseSummary(caseId: string): Promise { } } -function buildCaseSummary(rawData: Record): CaseSummary | null { +export function buildCaseSummary(rawData: Record): CaseSummary | null { try { if (!rawData['summary']) { console.error('Missing required summary data for building case summary'); @@ -754,24 +485,24 @@ function buildCaseSummary(rawData: Record): CaseSumma const chargeMap = new Map(); // Process charges - const charges = rawData['charges'] && rawData['charges']['Charges'] ? rawData['charges']['Charges'] : []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - charges.forEach((chargeData: any) => { + const charges: unknown[] = Array.isArray(rawData['charges']?.['Charges']) ? rawData['charges']['Charges'] : []; + charges.forEach((chargeValue: unknown) => { + const chargeData = asObjectRecord(chargeValue); if (!chargeData) return; // The charge offense data is nested within the ChargeOffense property - const chargeOffense = chargeData['ChargeOffense'] || {}; + const chargeOffense = asObjectRecord(chargeData['ChargeOffense']) || {}; const charge: Charge = { - offenseDate: chargeData['OffenseDate'] || '', - filedDate: chargeData['FiledDate'] || '', - description: chargeOffense['ChargeOffenseDescription'] || '', - statute: chargeOffense['Statute'] || '', + offenseDate: asString(chargeData['OffenseDate']), + filedDate: asString(chargeData['FiledDate']), + description: asString(chargeOffense['ChargeOffenseDescription']), + statute: asString(chargeOffense['Statute']), degree: { - code: chargeOffense['Degree'] || '', - description: chargeOffense['DegreeDescription'] || '', + code: asString(chargeOffense['Degree']), + description: asString(chargeOffense['DegreeDescription']), }, - fine: typeof chargeOffense['FineAmount'] === 'number' ? chargeOffense['FineAmount'] : 0, + fine: asNumber(chargeOffense['FineAmount']) ?? 0, dispositions: [], filingAgency: null, filingAgencyAddress: [], @@ -784,16 +515,17 @@ function buildCaseSummary(rawData: Record): CaseSumma // Extract filing agency address if present. It will be an array of strings. const filingAgencyAddressRaw = chargeData['FilingAgencyAddress']; - if (filingAgencyAddressRaw) { - charge.filingAgencyAddress.push(...(filingAgencyAddressRaw as any)); + if (Array.isArray(filingAgencyAddressRaw)) { + charge.filingAgencyAddress.push(...filingAgencyAddressRaw.map(item => String(item))); } // Add to charges array caseSummary.charges.push(charge); // Add to map for easy lookup when processing dispositions - if (chargeData['ChargeId'] != null) { - chargeMap.set(chargeData['ChargeId'], charge); + const chargeId = asNumber(chargeData['ChargeId']); + if (chargeId !== null) { + chargeMap.set(chargeId, charge); } }); @@ -814,21 +546,22 @@ function buildCaseSummary(rawData: Record): CaseSumma } // Process dispositions and link them to charges - const dispositionEvents = - rawData['dispositionEvents'] && rawData['dispositionEvents']['Events'] ? rawData['dispositionEvents']['Events'] : []; + const dispositionEvents: unknown[] = Array.isArray(rawData['dispositionEvents']?.['Events']) + ? rawData['dispositionEvents']['Events'] + : []; console.log(`📋 Found ${dispositionEvents.length} disposition events`); dispositionEvents - .filter( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (eventData: any) => eventData && eventData['Type'] === 'CriminalDispositionEvent' - ) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .forEach((eventData: any) => { - if (!eventData || !eventData['Event']) return; + .map(asObjectRecord) + .filter((eventData: Record | null): eventData is Record => { + return !!eventData && eventData['Type'] === 'CriminalDispositionEvent'; + }) + .forEach((eventData: Record) => { + const event = asObjectRecord(eventData['Event']); + if (!event) return; // CriminalDispositions are inside the Event property - const dispositions = eventData['Event']['CriminalDispositions'] || []; + const dispositions = asArray(event['CriminalDispositions']); console.log(`🔍 Processing disposition event with ${dispositions.length} dispositions`); // Alert if more than one disposition @@ -845,29 +578,29 @@ function buildCaseSummary(rawData: Record): CaseSumma ).catch(err => console.error('Failed to log alert:', err)); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - dispositions.forEach((disp: any) => { + dispositions.forEach((dispositionValue: unknown) => { + const disp = asObjectRecord(dispositionValue); if (!disp) return; // Extract the event date from either the Event.Date or SortEventDate - const eventDate = eventData['Event']['Date'] || eventData['SortEventDate'] || ''; + const eventDate = String(event['Date'] || eventData['SortEventDate'] || ''); // The criminal disposition type information contains the code and description - const dispTypeId = disp['CriminalDispositionTypeId'] || {}; + const dispTypeId = asObjectRecord(disp['CriminalDispositionTypeId']) || {}; // Create the disposition object const disposition: Disposition = { date: eventDate, - code: dispTypeId['Word'] || '', - description: dispTypeId['Description'] || '', + code: asString(dispTypeId['Word']), + description: asString(dispTypeId['Description']), }; console.log(`📝 Created disposition:`, disposition); // The charge ID is in ChargeID (note the capitalization) - const chargeId = disp['ChargeID']; + const chargeId = asNumber(disp['ChargeID']); // Find the matching charge and add the disposition - if (chargeId != null) { + if (chargeId !== null) { const charge = chargeMap.get(chargeId); if (charge) { charge.dispositions.push(disposition); @@ -887,23 +620,29 @@ function buildCaseSummary(rawData: Record): CaseSumma // Process case-level events to determine arrest or citation date (LPSD -> Arrest, CIT -> Citation) try { - const caseEvents = rawData['caseEvents']?.['Events'] || []; + const caseEvents: unknown[] = Array.isArray(rawData['caseEvents']?.['Events']) ? rawData['caseEvents']['Events'] : []; console.log(`📋 Found ${caseEvents.length} case events`); // Filter only events that have the LPSD (arrest) or CIT (citation) TypeId and a valid EventDate - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const candidateEvents = caseEvents.filter( - (ev: any) => ev && ev['Event'] && ev['Event']['TypeId'] && ev['Event']['TypeId']['Word'] && ev['Event']['EventDate'] - ); + const candidateEvents = caseEvents.filter((eventValue: unknown) => { + const eventWrapper = asObjectRecord(eventValue); + const event = asObjectRecord(eventWrapper?.['Event']); + const typeId = asObjectRecord(event?.['TypeId']); + + return !!event && !!typeId?.['Word'] && !!event['EventDate']; + }); console.log(`🔎 Found ${candidateEvents.length} candidate events for arrest/citation`); if (candidateEvents.length > 0) { const parsedCandidates: { date: Date; type: 'Arrest' | 'Citation'; raw: string }[] = []; - candidateEvents.forEach((ev: any, idx: number) => { - const typeWord = ev['Event']['TypeId']['Word']; - const eventDateStr = ev['Event']['EventDate']; + candidateEvents.forEach((eventValue: unknown, idx: number) => { + const eventWrapper = asObjectRecord(eventValue); + const event = asObjectRecord(eventWrapper?.['Event']); + const typeId = asObjectRecord(event?.['TypeId']); + const typeWord = typeof typeId?.['Word'] === 'string' ? typeId['Word'] : ''; + const eventDateStr = typeof event?.['EventDate'] === 'string' ? event['EventDate'] : ''; if (typeWord !== 'LPSD' && typeWord !== 'CIT') { return; @@ -951,11 +690,7 @@ function buildCaseSummary(rawData: Record): CaseSumma } const CaseProcessor = { - processCaseSearch, processCaseData, - queueCasesForSearch, - fetchCaseIdFromPortal, - buildCaseSummary, }; export default CaseProcessor; diff --git a/serverless/lib/__tests__/CaseStatusPublishing.test.ts b/serverless/lib/__tests__/CaseStatusPublishing.test.ts index 2532979..a76e039 100644 --- a/serverless/lib/__tests__/CaseStatusPublishing.test.ts +++ b/serverless/lib/__tests__/CaseStatusPublishing.test.ts @@ -1,48 +1,57 @@ import { processCaseSearchRecord } from '../CaseSearchProcessor'; import PortalAuthenticator from '../PortalAuthenticator'; -import QueueClient from '../QueueClient'; import StorageClient from '../StorageClient'; import UserAgentClient from '../UserAgentClient'; import WebSocketPublisher from '../WebSocketPublisher'; +import AlertService from '../AlertService'; +import PortalRequestClient from '../PortalRequestClient'; jest.mock('../PortalAuthenticator'); jest.mock('../QueueClient'); jest.mock('../StorageClient'); jest.mock('../UserAgentClient'); jest.mock('../WebSocketPublisher'); - -jest.mock('axios', () => { - const get = jest.fn(); - return { - __esModule: true, - default: { - request: jest.fn(), - create: jest.fn(() => ({ get })), - }, - request: jest.fn(), - create: jest.fn(() => ({ get })), - }; -}); +jest.mock('../AlertService'); jest.mock('axios-cookiejar-support', () => ({ wrapper: jest.fn((client: unknown) => client), })); +jest.mock('../PortalRequestClient', () => { + return jest.fn().mockImplementation(() => ({ + get: jest.fn(), + })); +}); + const mockPortal = PortalAuthenticator as jest.Mocked; -const mockQueue = QueueClient as jest.Mocked; const mockStorage = StorageClient as jest.Mocked; const mockUserAgent = UserAgentClient as jest.Mocked; const mockPublisher = WebSocketPublisher as jest.Mocked; +const mockAlertService = AlertService as jest.Mocked; +const mockPortalRequestClient = PortalRequestClient as jest.MockedClass; describe('case status websocket publishing', () => { let CaseProcessor: any; + let logSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; beforeEach(() => { jest.clearAllMocks(); process.env.PORTAL_URL = 'https://portal.example.com'; process.env.PORTAL_CASE_URL = 'https://portal.example.com/'; mockPublisher.publishCaseStatusUpdated.mockResolvedValue(undefined); - CaseProcessor = require('../CaseProcessor').default; + mockAlertService.logError.mockResolvedValue(undefined); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + ({ default: CaseProcessor } = jest.requireActual('../CaseProcessor')); + }); + + afterEach(() => { + logSpy.mockRestore(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); }); it('publishes failed status when case search auth fails', async () => { @@ -88,11 +97,15 @@ describe('case status websocket publishing', () => { mockStorage.getCase.mockResolvedValue(null as never); mockStorage.saveCaseSummary.mockResolvedValue(undefined as never); mockUserAgent.getUserAgent.mockResolvedValue('test-agent'); + mockPortal.getOrCreateUserSession.mockResolvedValue({ + success: true, + cookieJar: {} as never, + } as never); - const axios = await import('axios'); - const client = (axios.default.create as jest.Mock).mock.results[0]?.value || (axios.default.create as jest.Mock)(); + const client = { get: jest.fn() }; + mockPortalRequestClient.mockImplementation(() => client as never); - (client.get as jest.Mock).mockImplementation((url: string) => { + client.get.mockImplementation((url: string) => { if (url.includes('CaseSummariesSlim')) { return Promise.resolve({ status: 200, @@ -151,11 +164,15 @@ describe('case status websocket publishing', () => { it('publishes failed status when case data processing throws', async () => { mockStorage.getCase.mockResolvedValue(null as never); mockUserAgent.getUserAgent.mockResolvedValue('test-agent'); + mockPortal.getOrCreateUserSession.mockResolvedValue({ + success: true, + cookieJar: {} as never, + } as never); - const axios = await import('axios'); - const client = (axios.default.create as jest.Mock).mock.results[0]?.value || (axios.default.create as jest.Mock)(); + const client = { get: jest.fn() }; + mockPortalRequestClient.mockImplementation(() => client as never); - (client.get as jest.Mock).mockRejectedValue(new Error('portal timeout')); + client.get.mockRejectedValue(new Error('portal timeout')); await (CaseProcessor as any).processCaseData({ Records: [ diff --git a/serverless/lib/__tests__/caseProcessor.test.ts b/serverless/lib/__tests__/caseProcessor.test.ts index 92b9dec..3f55f28 100644 --- a/serverless/lib/__tests__/caseProcessor.test.ts +++ b/serverless/lib/__tests__/caseProcessor.test.ts @@ -1,150 +1,29 @@ /** * Tests for the CaseProcessor module */ -import CaseProcessor from '../CaseProcessor'; -import QueueClient from '../QueueClient'; -import { CookieJar } from 'tough-cookie'; -import axios from 'axios'; +import { buildCaseSummary } from '../CaseProcessor'; // Mock dependencies -jest.mock('../PortalAuthenticator'); -jest.mock('../QueueClient'); jest.mock('../StorageClient'); -jest.mock('axios'); -jest.mock('axios-cookiejar-support', () => ({ - wrapper: jest.fn(axios => axios), -})); -jest.mock('tough-cookie'); - -// Mock environment variable -process.env.PORTAL_URL = 'https://test-portal.example.com'; describe('CaseProcessor', () => { + let logSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + beforeEach(() => { // Reset all mocks before each test jest.clearAllMocks(); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); }); - describe('fetchCaseIdFromPortal', () => { - it('should return error if PORTAL_URL is not set', async () => { - // Temporarily remove the environment variable - const originalUrl = process.env.PORTAL_URL; - delete process.env.PORTAL_URL; - - const mockJar = new CookieJar(); - const result = await CaseProcessor.fetchCaseIdFromPortal('22CR123456-789', mockJar); - - // Restore environment variable - process.env.PORTAL_URL = originalUrl; - - expect(result.caseId).toBeNull(); - expect(result.error).toBeDefined(); - expect(result.error?.isSystemError).toBe(true); - }); - - it('should make requests to search for a case and extract the case ID', async () => { - const mockJar = new CookieJar(); - const mockCaseId = '123ABC456DEF'; - - // Mock axios post/get methods - const mockPost = jest.fn().mockResolvedValue({ - status: 200, - data: 'search form submitted', - }); - - const mockGet = jest.fn().mockResolvedValue({ - status: 200, - data: `Case Link`, - }); - - // @ts-ignore - mock the axios create method - axios.create.mockReturnValue({ - post: mockPost, - get: mockGet, - }); - - const result = await CaseProcessor.fetchCaseIdFromPortal('22CR123456-789', mockJar); - - expect(mockPost).toHaveBeenCalledWith( - expect.stringContaining('/Portal/SmartSearch/SmartSearch/SmartSearch'), - expect.any(URLSearchParams) - ); - expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('/Portal/SmartSearch/SmartSearchResults')); - expect(result.caseId).toBe(mockCaseId); - expect(result.error).toBeUndefined(); - }); - - it('should return error with isSystemError=false if no case links are found', async () => { - const mockJar = new CookieJar(); - - // Mock axios post/get methods - const mockPost = jest.fn().mockResolvedValue({ - status: 200, - data: 'search form submitted', - }); - - const mockGet = jest.fn().mockResolvedValue({ - status: 200, - data: 'No cases found', // No caseLink elements - }); - - // @ts-ignore - mock the axios create method - axios.create.mockReturnValue({ - post: mockPost, - get: mockGet, - }); - - const result = await CaseProcessor.fetchCaseIdFromPortal('22CR123456-789', mockJar); - - expect(result.caseId).toBeNull(); - expect(result.error).toBeDefined(); - expect(result.error?.isSystemError).toBe(false); // Not a system error, a legitimate "not found" - }); - - it('should return error with isSystemError=true if the search request fails', async () => { - const mockJar = new CookieJar(); - - // Mock axios post method to fail - const mockPost = jest.fn().mockResolvedValue({ - status: 500, - data: 'server error', - }); - - // @ts-ignore - mock the axios create method - axios.create.mockReturnValue({ - post: mockPost, - get: jest.fn(), - }); - - const result = await CaseProcessor.fetchCaseIdFromPortal('22CR123456-789', mockJar); - - expect(mockPost).toHaveBeenCalled(); - expect(result.caseId).toBeNull(); - expect(result.error).toBeDefined(); - expect(result.error?.isSystemError).toBe(true); - }); - }); - - describe('queueCasesForSearch', () => { - // We'll test the queueCasesForSearch function which is the correct one according to our implementation - - it('should queue cases for search', async () => { - // @ts-ignore - mock implementation - QueueClient.queueCasesForSearch.mockResolvedValue(undefined); - - const cases = ['22CR123456-789', '23CV654321-456']; - const userId = 'test-user'; - - await CaseProcessor.queueCasesForSearch(cases, userId); - - expect(QueueClient.queueCasesForSearch).toHaveBeenCalledWith(cases, userId); - }); + afterEach(() => { + logSpy.mockRestore(); + warnSpy.mockRestore(); }); // Tests for buildCaseSummary (moved from separate test file) describe('buildCaseSummary', () => { - const { buildCaseSummary } = CaseProcessor as any; - it('extracts the earliest LPSD Event.EventDate and sets arrestOrCitationDate and type as Arrest', () => { const rawData = { summary: { @@ -250,8 +129,6 @@ describe('CaseProcessor', () => { }); it('ignores malformed LPSD Event.EventDate values', () => { - const { buildCaseSummary } = CaseProcessor as any; - const rawData = { summary: { CaseSummaryHeader: { From e3e94107c635d01d703b21ca9d8f2b2bed1bf470 Mon Sep 17 00:00:00 2001 From: Jay Hill <116148+jayhill@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:36:37 -0400 Subject: [PATCH 3/5] #208 add shared portal request client --- serverless/docs/CAPSOLVER_INTEGRATION.md | 272 +++++++++++++++++++++++ serverless/lib/PortalRequestClient.ts | 109 +++++++++ 2 files changed, 381 insertions(+) create mode 100644 serverless/docs/CAPSOLVER_INTEGRATION.md create mode 100644 serverless/lib/PortalRequestClient.ts diff --git a/serverless/docs/CAPSOLVER_INTEGRATION.md b/serverless/docs/CAPSOLVER_INTEGRATION.md new file mode 100644 index 0000000..df1f455 --- /dev/null +++ b/serverless/docs/CAPSOLVER_INTEGRATION.md @@ -0,0 +1,272 @@ +# AWS WAF Challenge Solver Integration + +## Overview + +This document describes the integration of a generic AWS WAF challenge solver into the ZipCase court portal authentication flow. The system currently uses CapSolver as the backend provider but is designed to be easily switchable to other providers. + +## Architecture + +The solution is built with a modular architecture: + +- **AwsWafChallengeSolver**: Generic service that provides a common interface for WAF challenge solving +- **CapSolverProvider**: Current implementation using CapSolver's API +- **PortalAuthenticator**: Uses the generic solver service during authentication + +This design allows for easy provider switching without changing the core authentication logic. + +## Integration Point + +The AWS WAF challenge solver is integrated into `/serverless/lib/PortalAuthenticator.ts` after the login page fetch: + + - Detects AWS WAF challenges in the initial login page response + - Solves the challenge and adds the resulting cookie to the session + - Re-fetches the login page with the WAF cookie + +## Implementation Details + +### Generic AWS WAF Challenge Solver (`AwsWafChallengeSolver`) + +A new generic service provides the main interface: + +- **Provider Pattern**: Uses the strategy pattern to support different backend providers +- **Challenge Detection**: Identifies AWS WAF challenges in HTTP responses +- **Challenge Solving**: Delegates to the configured provider for actual solving +- **Error Handling**: Comprehensive error handling with logging + +### CapSolver Provider Implementation + +The current implementation uses CapSolver with the following features: + +- **API Key Management**: Retrieves API keys from AWS SSM Parameter Store +- **Challenge Parsing**: Extracts challenge data from various AWS WAF challenge formats +- **Solution Polling**: Polls CapSolver API for task completion and retrieves solutions + +### AWS WAF Challenge Detection + +The system detects AWS WAF challenges by looking for: + +- HTTP 405 status codes +- `window.gokuProps` JavaScript objects +- `challenge.js` script URLs +- `captcha.js` script URLs +- `visualSolutionsRequired` indicators +- `awswaf.com` domain references +- `aws-waf-token` strings + +### Challenge Types Supported + +The integration supports multiple AWS WAF challenge formats: + +1. **Situation 1**: JavaScript challenges with `gokuProps` (key, iv, context) +2. **Situation 3**: Challenge.js URLs +3. **Situation 4**: Visual solutions with problem URLs + +## Configuration + +### Environment Variables + +- `WAF_SOLVER_API_KEY_PARAMETER`: SSM parameter path (default: `/zipcase/waf-solver/api-key`) +- `AWS_REGION`: AWS region for SSM client (default: `us-east-2`) + +### SSM Parameter Setup + +Store your CapSolver API key in AWS SSM Parameter Store: + +```bash +aws ssm put-parameter \ + --name "/zipcase/waf-solver/api-key" \ + --value "your-capsolver-api-key" \ + --type "SecureString" \ + --description "WAF challenge solver API key" +``` + +## Switching Providers + +To use a different WAF challenge solver provider: + +1. **Implement the Interface**: Create a new class implementing `IAwsWafChallengeSolver` +2. **Set the Provider**: Use `AwsWafChallengeSolver.setProvider(new YourProvider())` +3. **Update Configuration**: Modify SSM parameter names as needed + +Example: + +```typescript +import { AwsWafChallengeSolver, IAwsWafChallengeSolver } from './AwsWafChallengeSolver'; + +class CustomSolverProvider implements IAwsWafChallengeSolver { + detectChallenge(response: AxiosResponse): boolean { + // Your detection logic + } + + async solveChallenge( + websiteURL: string, + htmlContent: string + ): Promise { + // Your solving logic + } +} + +// Switch to your provider +AwsWafChallengeSolver.setProvider(new CustomSolverProvider()); +``` + +## API Usage + +### CapSolver API Endpoints + +1. **Create Task**: `POST https://api.capsolver.com/createTask` +2. **Get Result**: `POST https://api.capsolver.com/getTaskResult` + +### Task Configuration + +```typescript +{ + clientKey: "your-api-key", + task: { + type: "AntiAwsWafTaskProxyLess", + websiteURL: "https://portal.example.com/login", + awsKey: "extracted-from-challenge", + awsIv: "extracted-from-challenge", + awsContext: "extracted-from-challenge", + awsChallengeJS: "https://challenge-url.js", + awsProblemUrl: "https://problem-url" + } +} +``` + +## Error Handling + +The integration includes comprehensive error handling: + +- **API Key Retrieval Failures**: Logged as CRITICAL errors +- **Challenge Solving Failures**: Logged as ERROR level +- **Graceful Degradation**: Authentication continues even if WAF solving fails +- **Timeout Protection**: Maximum 30 polling attempts (2.5 minutes) + +## Logging and Monitoring + +All CapSolver operations are logged with appropriate severity levels: + +- CRITICAL: API key retrieval failures +- ERROR: Challenge solving failures +- INFO: Successful challenge resolution + +## Testing + +### Unit Tests + +Run the specific tests for the AWS WAF Challenge Solver: + +```bash +cd /home/jay/dev/zipcase/serverless +npm test -- lib/__tests__/AwsWafChallengeSolver.test.ts +``` + +### Integration Tests + +The existing portal authentication tests should continue to pass: + +```bash +npm test -- lib/__tests__/portalAuthenticator.test.ts +``` + +### Full Test Suite + +Run all tests to ensure the integration doesn't break existing functionality: + +```bash +npm test +``` + +### Manual Testing + +To test the integration manually: + +1. Ensure your CapSolver API key is configured in SSM +2. Run a portal authentication with debug enabled +3. Monitor logs for WAF challenge detection and resolution + +## Dependencies + +The integration uses existing dependencies: + +- `@aws-sdk/client-ssm`: For API key retrieval +- `axios`: For CapSolver API calls +- `cheerio`: For HTML parsing (challenge detection) + +## Security Considerations + +- API keys are stored securely in AWS SSM Parameter Store with encryption +- No sensitive data is logged in plain text +- Challenge data is parsed safely to prevent XSS +- Timeout limits prevent indefinite polling + +## Performance Impact + +- Challenge detection adds minimal overhead (simple string checks) +- Challenge solving only occurs when WAF challenges are detected +- Polling is limited to 2.5 minutes maximum +- Failed solving attempts don't block authentication + +## Monitoring and Alerts + +The integration leverages the existing AlertService for: + +- API key retrieval failures +- Challenge solving failures +- Authentication flow errors + +Monitor these alerts to ensure the CapSolver integration is functioning properly. + +## Troubleshooting + +### Common Issues + +1. **API Key Not Found** + + - Verify SSM parameter exists and is accessible + - Check IAM permissions for SSM access + +2. **Challenge Detection False Positives** + + - Review detection criteria in `detectAwsWafChallenge` + - Adjust detection logic if needed + +3. **Solving Timeout** + + - Check CapSolver service status + - Verify API key balance and limits + +4. **Authentication Still Failing** + - Enable debug logging to see detailed flow + - Check if WAF challenges are being detected correctly + +### Debug Logging + +Enable debug logging in portal authentication: + +```typescript +const result = await PortalAuthenticator.authenticateWithPortal(username, password, { + debug: true, +}); +``` + +## Next Steps + +1. **Production Deployment**: Deploy the updated PortalAuthenticator to production +2. **Monitoring Setup**: Configure alerts for CapSolver integration metrics +3. **Performance Tuning**: Monitor and optimize challenge detection and solving +4. **Documentation Updates**: Update API documentation with WAF handling details + +## Support + +For CapSolver-specific issues: + +- CapSolver Documentation: https://docs.capsolver.com/ +- Support: Contact CapSolver support team + +For integration issues: + +- Review logs in CloudWatch +- Check AlertService notifications +- Validate SSM parameter configuration diff --git a/serverless/lib/PortalRequestClient.ts b/serverless/lib/PortalRequestClient.ts new file mode 100644 index 0000000..fabeb6a --- /dev/null +++ b/serverless/lib/PortalRequestClient.ts @@ -0,0 +1,109 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +import { wrapper } from 'axios-cookiejar-support'; +import { CookieJar } from 'tough-cookie'; +import AwsWafChallengeSolver from './AwsWafChallengeSolver'; +import PortalAuthenticator from './PortalAuthenticator'; + +const DEFAULT_TIMEOUT = 20000; + +export interface PortalRequestClientOptions { + jar: CookieJar; + portalUrl: string; + userAgent: string; + timeout?: number; + maxRetries?: number; + defaultHeaders?: Record; +} + +export interface PortalRequestConfig extends AxiosRequestConfig { + wafContextUrl?: string; + skipWafHandling?: boolean; +} + +export default class PortalRequestClient { + private readonly client: AxiosInstance; + private readonly jar: CookieJar; + private readonly portalUrl: string; + private readonly maxRetries: number; + + constructor(options: PortalRequestClientOptions) { + this.jar = options.jar; + this.portalUrl = options.portalUrl; + this.maxRetries = options.maxRetries ?? 2; + + this.client = wrapper(axios).create({ + timeout: options.timeout ?? DEFAULT_TIMEOUT, + maxRedirects: 10, + validateStatus: status => status < 500, + jar: options.jar, + withCredentials: true, + headers: { + ...PortalAuthenticator.getDefaultRequestHeaders(options.userAgent), + ...(options.defaultHeaders || {}), + }, + }); + } + + async request(config: PortalRequestConfig): Promise> { + const attemptRequest = async (attempt: number): Promise> => { + const method = String(config.method || 'GET').toLowerCase(); + const response = await this.executeRequest(method, config); + + if (config.skipWafHandling || !AwsWafChallengeSolver.detectChallenge(response as AxiosResponse)) { + return response; + } + + if (attempt >= this.maxRetries) { + return response; + } + + const wafContextUrl = + config.wafContextUrl || + response.request?.res?.responseUrl || + (typeof config.url === 'string' ? config.url : this.portalUrl); + + const wafResult = await AwsWafChallengeSolver.solveChallenge(wafContextUrl, String(response.data || '')); + if (!wafResult.success || !wafResult.cookie) { + return response; + } + + PortalAuthenticator.addWafCookieToJar(this.jar, wafResult.cookie, [this.portalUrl, wafContextUrl]); + return attemptRequest(attempt + 1); + }; + + return attemptRequest(0); + } + + private async executeRequest(method: string, config: PortalRequestConfig): Promise> { + if (typeof this.client.request === 'function') { + return this.client.request(config); + } + + if (method === 'post' && typeof this.client.post === 'function') { + return this.client.post(String(config.url), config.data, config); + } + + if (method === 'get' && typeof this.client.get === 'function') { + return this.client.get(String(config.url), config); + } + + throw new Error(`Unsupported axios client method: ${method}`); + } + + async get(url: string, config: PortalRequestConfig = {}): Promise> { + return this.request({ + ...config, + method: 'GET', + url, + }); + } + + async post(url: string, data?: unknown, config: PortalRequestConfig = {}): Promise> { + return this.request({ + ...config, + method: 'POST', + url, + data, + }); + } +} From a98c864f6db7c76a230df8daa0dcb0416079ee95 Mon Sep 17 00:00:00 2001 From: Jay Hill <116148+jayhill@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:36:42 -0400 Subject: [PATCH 4/5] #208 update portal auth to solve and reuse WAF challenges --- serverless/lib/AwsWafChallengeSolver.ts | 98 +++++---- serverless/lib/PortalAuthenticator.ts | 132 ++++++++++-- .../__tests__/AwsWafChallengeSolver.test.ts | 201 +++--------------- .../lib/__tests__/portalAuthenticator.test.ts | 171 ++++++++++++--- 4 files changed, 344 insertions(+), 258 deletions(-) diff --git a/serverless/lib/AwsWafChallengeSolver.ts b/serverless/lib/AwsWafChallengeSolver.ts index 64c68ba..1d15c3d 100644 --- a/serverless/lib/AwsWafChallengeSolver.ts +++ b/serverless/lib/AwsWafChallengeSolver.ts @@ -9,25 +9,13 @@ import axios, { AxiosResponse } from 'axios'; import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm'; import AlertService, { Severity, AlertCategory } from './AlertService'; -const formatPollingError = (error: unknown): string => { - if (axios.isAxiosError(error)) { - const status = error.response?.status; - const responseData = error.response?.data; - const message = error.message; - - return JSON.stringify({ - message, - status, - responseData, - }); - } - - if (error instanceof Error) { - return error.message; - } - - return String(error); -}; +export interface AwsWafChallengeData { + awsKey?: string; + awsIv?: string; + awsContext?: string; + awsChallengeJS?: string; + awsProblemUrl?: string; +} export interface WafChallengeSolverResult { success: boolean; @@ -46,7 +34,7 @@ export interface WafChallengeSolverOptions { */ export interface IAwsWafChallengeSolver { detectChallenge(response: AxiosResponse): boolean; - solveChallenge(websiteURL: string, options?: WafChallengeSolverOptions): Promise; + solveChallenge(websiteURL: string, htmlContent: string, options?: WafChallengeSolverOptions): Promise; } /** @@ -91,11 +79,14 @@ class CapSolverProvider implements IAwsWafChallengeSolver { } detectChallenge(response: AxiosResponse): boolean { - const html = response.data; + const html = typeof response.data === 'string' ? response.data : ''; const status = response.status; + const wafActionHeader = response.headers?.['x-amzn-waf-action']; + const wafAction = Array.isArray(wafActionHeader) ? wafActionHeader[0] : wafActionHeader; // Check for common AWS WAF challenge indicators return ( + wafAction === 'challenge' || status === 405 || html.includes('window.gokuProps') || html.includes('challenge.js') || @@ -106,9 +97,14 @@ class CapSolverProvider implements IAwsWafChallengeSolver { ); } - async solveChallenge(websiteURL: string, options: WafChallengeSolverOptions = {}): Promise { + async solveChallenge( + websiteURL: string, + htmlContent: string, + options: WafChallengeSolverOptions = {} + ): Promise { try { const apiKey = await CapSolverProvider.getApiKey(); + const challengeData = this.parseAwsWafChallenge(htmlContent); // Create task const createTaskPayload = { @@ -116,6 +112,7 @@ class CapSolverProvider implements IAwsWafChallengeSolver { task: { type: 'AntiAwsWafTaskProxyLess', websiteURL, + ...challengeData, }, }; @@ -175,32 +172,51 @@ class CapSolverProvider implements IAwsWafChallengeSolver { if (result.status === 'ready' && result.solution?.cookie) { console.log('WAF challenge solved successfully'); return result.solution.cookie; - } - - if (result.status === 'failed' || result.errorId !== 0) { + } else if (result.status === 'failed' || result.errorId !== 0) { throw new Error(`WAF solver task failed: ${result.errorDescription || 'Unknown error'}`); } - - if (result.status !== 'processing' && result.status !== 'idle') { - throw new Error(`WAF solver returned unexpected status: ${result.status || 'missing status'}`); - } - - console.log(`WAF solver task still processing... (attempt ${attempt}/${maxAttempts})`); } catch (error) { if (attempt === maxAttempts) { throw error; } + } + } - const shouldRetry = axios.isAxiosError(error); - if (!shouldRetry) { - throw error; - } + throw new Error('WAF solver task timed out after maximum attempts'); + } + + private parseAwsWafChallenge(htmlContent: string): AwsWafChallengeData { + const challengeData: AwsWafChallengeData = {}; + + try { + // Look for gokuProps (Situation 1) + const gokuPropsMatch = htmlContent.match(/window\.gokuProps\s*=\s*({[^}]+})/); + if (gokuPropsMatch) { + const gokuProps = JSON.parse(gokuPropsMatch[1]); + if (gokuProps.key) challengeData.awsKey = gokuProps.key; + if (gokuProps.iv) challengeData.awsIv = gokuProps.iv; + if (gokuProps.context) challengeData.awsContext = gokuProps.context; + } + + // Look for challenge.js URL (Situation 3) + const challengeJsMatch = htmlContent.match(/https?:\/\/[^"'\s]*challenge\.js[^"'\s]*/); + if (challengeJsMatch) { + challengeData.awsChallengeJS = challengeJsMatch[0]; + } - console.log(`Error polling WAF solver result (attempt ${attempt}), retrying: ${formatPollingError(error)}`); + // Look for problem URL with visualSolutionsRequired (Situation 4) + const visualSolutionsMatch = htmlContent.match(/visualSolutionsRequired/); + if (visualSolutionsMatch) { + const problemUrlMatch = htmlContent.match(/https?:\/\/[^"'\s]*problem[^"'\s]*num_solutions_required[^"'\s]*/); + if (problemUrlMatch) { + challengeData.awsProblemUrl = problemUrlMatch[0]; + } } + } catch { + console.warn('Error parsing AWS WAF challenge data'); } - throw new Error('WAF solver task timed out after maximum attempts'); + return challengeData; } } @@ -228,8 +244,12 @@ export class AwsWafChallengeSolver { /** * Solve an AWS WAF challenge */ - static async solveChallenge(websiteURL: string, options?: WafChallengeSolverOptions): Promise { - return this.provider.solveChallenge(websiteURL, options); + static async solveChallenge( + websiteURL: string, + htmlContent: string, + options?: WafChallengeSolverOptions + ): Promise { + return this.provider.solveChallenge(websiteURL, htmlContent, options); } } diff --git a/serverless/lib/PortalAuthenticator.ts b/serverless/lib/PortalAuthenticator.ts index feac3aa..75f7f7c 100644 --- a/serverless/lib/PortalAuthenticator.ts +++ b/serverless/lib/PortalAuthenticator.ts @@ -16,6 +16,7 @@ import StorageClient from './StorageClient'; import UserAgentClient from './UserAgentClient'; import AlertService, { Severity, AlertCategory } from './AlertService'; import AwsWafChallengeSolver from './AwsWafChallengeSolver'; +import PortalRequestClient from './PortalRequestClient'; const DEFAULT_TIMEOUT = 20000; @@ -24,6 +25,7 @@ const axiosWithCookies = wrapper(axios); const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'; +const isDevStage = (): boolean => (process.env.STAGE || '').toLowerCase() === 'dev'; export function getDefaultRequestHeaders(userAgent?: string): Record { return { @@ -83,11 +85,21 @@ function extractWsFedToken(html: string): string | null { } } +function addWafCookieToJar(cookieJar: CookieJar, cookie: string, urls: string[]): void { + const normalizedCookie = cookie.startsWith('aws-waf-token=') ? cookie : `aws-waf-token=${cookie}`; + + for (const url of urls) { + cookieJar.setCookieSync(normalizedCookie, url); + } +} + const PortalAuthenticator = { + addWafCookieToJar, getDefaultRequestHeaders, async authenticateWithPortal(username: string, password: string, options: PortalAuthOptions = {}): Promise { const portalBaseUrl = process.env.PORTAL_URL; + const portalDashboardPath = process.env.PORTAL_DASHBOARD_PATH; if (!portalBaseUrl) { const errorMsg = 'PORTAL_URL environment variable is not set'; @@ -100,6 +112,17 @@ const PortalAuthenticator = { }; } + if (!portalDashboardPath) { + const errorMsg = 'PORTAL_DASHBOARD_PATH environment variable is not set'; + + await AlertService.logError(Severity.CRITICAL, AlertCategory.SYSTEM, '', new Error(errorMsg), { resource: 'portal-auth' }); + + return { + success: false, + message: errorMsg, + }; + } + const timeout = options.timeout || DEFAULT_TIMEOUT; const debug = options.debug || false; @@ -140,22 +163,19 @@ const PortalAuthenticator = { if (debug) console.log('AWS WAF challenge detected, attempting to solve...'); try { - const wafResult = await AwsWafChallengeSolver.solveChallenge(loginUrl); + const wafResult = await AwsWafChallengeSolver.solveChallenge(loginUrl, loginPageResponse.data); if (wafResult.success && wafResult.cookie) { // Add the solved WAF cookie to our cookie jar for both the login page domain and the portal domain const loginUrlBase = new URL(loginUrl).origin; const portalBase = new URL(portalBaseUrl).origin; - jar.setCookieSync(`aws-waf-token=${wafResult.cookie}`, loginUrlBase); - if (loginUrlBase !== portalBase) { - jar.setCookieSync(`aws-waf-token=${wafResult.cookie}`, portalBase); - } + addWafCookieToJar(jar, wafResult.cookie, [loginUrlBase, portalBase]); if (debug) { console.log('AWS WAF challenge solved, cookie added to jar for domains:', loginUrlBase, portalBase); } - // Re-fetch the login page with the WAF cookie - const retryLoginPageResponse = await client.get(portalBaseUrl + '/Portal/Account/Login'); + // Re-fetch the redirected login page with the WAF cookie + const retryLoginPageResponse = await client.get(loginUrl); // Use the retry response for subsequent processing Object.assign(loginPageResponse, retryLoginPageResponse); @@ -254,11 +274,11 @@ const PortalAuthenticator = { if (debug) console.log('AWS WAF challenge detected after login submission, attempting to solve...'); try { - const wafResult = await AwsWafChallengeSolver.solveChallenge(loginUrl); + const wafResult = await AwsWafChallengeSolver.solveChallenge(loginUrl, loginSubmitResponse.data); if (wafResult.success && wafResult.cookie) { // Add the solved WAF cookie to our cookie jar - jar.setCookieSync(wafResult.cookie, portalBaseUrl); + addWafCookieToJar(jar, wafResult.cookie, [portalBaseUrl, loginUrl]); if (debug) console.log('AWS WAF challenge solved after login, cookie added to jar'); // Re-submit the login form with the WAF cookie @@ -369,6 +389,34 @@ const PortalAuthenticator = { }; } + const dashboardUrl = new URL(portalDashboardPath, `${portalBaseUrl}/`).toString(); + const dashboardResponse = await client.get(dashboardUrl, { + headers: { + Referer: portalBaseUrl, + 'User-Agent': options.userAgent || DEFAULT_USER_AGENT, + }, + }); + + if (AwsWafChallengeSolver.detectChallenge(dashboardResponse)) { + if (debug) console.log('AWS WAF challenge detected on dashboard, attempting to solve...'); + + const dashboardChallengeUrl = dashboardResponse.request?.res?.responseUrl || dashboardUrl; + const dashboardWafResult = await AwsWafChallengeSolver.solveChallenge(dashboardChallengeUrl, dashboardResponse.data); + + if (!dashboardWafResult.success || !dashboardWafResult.cookie) { + return { + success: false, + message: dashboardWafResult.error || 'Failed to solve dashboard AWS WAF challenge', + }; + } + + addWafCookieToJar(jar, dashboardWafResult.cookie, [portalBaseUrl, dashboardChallengeUrl, dashboardUrl]); + + if (debug) { + console.log('AWS WAF challenge solved on dashboard, cookie added to jar'); + } + } + // Success! Return the cookie jar for session management return { success: true, @@ -391,6 +439,7 @@ const PortalAuthenticator = { async verifySession(cookieJar: CookieJar, options: PortalAuthOptions = {}): Promise { const portalBaseUrl = process.env.PORTAL_URL; + const portalDashboardPath = process.env.PORTAL_DASHBOARD_PATH; if (!portalBaseUrl) { const errorMsg = 'PORTAL_URL environment variable is not set'; @@ -405,12 +454,27 @@ const PortalAuthenticator = { return false; } + if (!portalDashboardPath) { + const errorMsg = 'PORTAL_DASHBOARD_PATH environment variable is not set'; + + await AlertService.logError( + Severity.CRITICAL, + AlertCategory.SYSTEM, + 'Missing required environment variable: PORTAL_DASHBOARD_PATH', + new Error(errorMsg) + ); + + return false; + } + const timeout = options.timeout || DEFAULT_TIMEOUT; const debug = options.debug || false; + const dashboardUrl = new URL(portalDashboardPath, `${portalBaseUrl}/`).toString(); try { // Check for FedAuth cookies which are critical for authentication const cookies = cookieJar.getCookiesSync(portalBaseUrl, { allPaths: true }); + const wafCookies = cookies.filter(cookie => cookie.key === 'aws-waf-token'); if (debug) { console.log('Number of cookies before verification:', cookies.length); @@ -424,16 +488,18 @@ const PortalAuthenticator = { const fedAuth1Cookie = cookies.find(cookie => cookie.key === 'FedAuth1'); console.log('FedAuth cookie exists:', !!fedAuthCookie); console.log('FedAuth1 cookie exists:', !!fedAuth1Cookie); + console.log('aws-waf-token cookie count:', wafCookies.length); + wafCookies.forEach((cookie, index) => { + const expires = cookie.expires instanceof Date ? cookie.expires.toISOString() : String(cookie.expires || 'session'); + console.log(`aws-waf-token[${index}] domain=${cookie.domain} path=${cookie.path} expires=${expires}`); + }); } - // Create axios instance with cookie jar support - const client = axiosWithCookies.create({ - timeout, - maxRedirects: 10, - validateStatus: status => status < 500, + const client = new PortalRequestClient({ jar: cookieJar, - withCredentials: true, - headers: getDefaultRequestHeaders(options.userAgent), + portalUrl: portalBaseUrl, + userAgent: options.userAgent || DEFAULT_USER_AGENT, + timeout, }); // Build a manual cookie string to ensure all cookies are properly sent @@ -447,23 +513,29 @@ const PortalAuthenticator = { console.log('Manual cookie header:', cookieHeader); } - const response = await client.get(portalBaseUrl + '/Portal', { + const response = await client.get(dashboardUrl, { headers: { Cookie: cookieHeader, + Referer: portalBaseUrl, 'User-Agent': options.userAgent || DEFAULT_USER_AGENT, }, + wafContextUrl: dashboardUrl, }); if (debug) { + console.log('Verification URL:', dashboardUrl); console.log('Response status:', response.status); console.log('Response URL (after redirects):', response.request?.res?.responseUrl || 'No redirect URL'); + console.log('x-amzn-waf-action:', response.headers?.['x-amzn-waf-action'] || 'none'); // Check for login indicators const hasSignIn = response.data.includes('Sign In'); const hasWelcomeUser = response.data.includes('Welcome, '); + const hasWafChallenge = AwsWafChallengeSolver.detectChallenge(response); console.log('Page contains "Sign In":', hasSignIn); console.log('Page contains "Welcome, ":', hasWelcomeUser); + console.log('WAF challenge detected during verifySession:', hasWafChallenge); // If the response is too large, just log a snippet if (response.data.length > 500) { @@ -472,7 +544,10 @@ const PortalAuthenticator = { } // Session is valid if the welcome message is present or no sign in button - return response.data.includes('Welcome, ') || !response.data.includes('Sign In'); + return ( + !AwsWafChallengeSolver.detectChallenge(response) && + (response.data.includes('Welcome, ') || !response.data.includes('Sign In')) + ); } catch (error) { if (debug) { console.error('Error verifying session:', error); @@ -489,10 +564,22 @@ const PortalAuthenticator = { if (sessionCookieJar) { console.log('Session cookie jar found in storage.'); - return { - success: true, - cookieJar: CookieJar.fromJSON(sessionCookieJar), - }; + + const restoredCookieJar = CookieJar.fromJSON(sessionCookieJar); + const shouldDebugSession = isDevStage(); + const isValidSession = await this.verifySession(restoredCookieJar, { + userAgent, + debug: shouldDebugSession, + }); + + if (isValidSession) { + return { + success: true, + cookieJar: restoredCookieJar, + }; + } + + console.log('Stored portal session is invalid for dashboard access, re-authenticating.'); } const portalCredentials = await StorageClient.sensitiveGetPortalCredentials(userId); @@ -516,6 +603,7 @@ const PortalAuthenticator = { const authResult = await this.authenticateWithPortal(portalCredentials.username, portalCredentials.password, { userAgent: resolvedUserAgent, + debug: isDevStage(), }); if (authResult.success && authResult.cookieJar) { diff --git a/serverless/lib/__tests__/AwsWafChallengeSolver.test.ts b/serverless/lib/__tests__/AwsWafChallengeSolver.test.ts index 4e9e8df..bfba42d 100644 --- a/serverless/lib/__tests__/AwsWafChallengeSolver.test.ts +++ b/serverless/lib/__tests__/AwsWafChallengeSolver.test.ts @@ -1,16 +1,16 @@ /** * Tests for the AwsWafChallengeSolver module */ -import axios from 'axios'; +import { AwsWafChallengeSolver } from '../AwsWafChallengeSolver'; +import { SSMClient } from '@aws-sdk/client-ssm'; // Mock axios jest.mock('axios'); -const mockSsmSend = jest.fn(); // Mock AWS SDK jest.mock('@aws-sdk/client-ssm', () => ({ SSMClient: jest.fn().mockImplementation(() => ({ - send: mockSsmSend, + send: jest.fn(), })), GetParameterCommand: jest.fn(), })); @@ -35,21 +35,12 @@ jest.mock('../AlertService', () => ({ })); describe('AwsWafChallengeSolver', () => { - const loadSolverContext = async () => ({ - AwsWafChallengeSolver: (await import('../AwsWafChallengeSolver')).AwsWafChallengeSolver, - mockedAxios: (await import('axios')).default as jest.Mocked, - mockedAlertService: (await import('../AlertService')).default, - }); - beforeEach(() => { - jest.resetModules(); jest.clearAllMocks(); - mockSsmSend.mockReset(); }); describe('detectChallenge', () => { it('should detect challenge with 405 status code', () => { - const { AwsWafChallengeSolver } = require('../AwsWafChallengeSolver'); const mockResponse = { data: 'Some content', status: 405, @@ -60,7 +51,6 @@ describe('AwsWafChallengeSolver', () => { }); it('should detect challenge with gokuProps', () => { - const { AwsWafChallengeSolver } = require('../AwsWafChallengeSolver'); const mockResponse = { data: '', status: 200, @@ -71,7 +61,6 @@ describe('AwsWafChallengeSolver', () => { }); it('should detect challenge with challenge.js', () => { - const { AwsWafChallengeSolver } = require('../AwsWafChallengeSolver'); const mockResponse = { data: '', status: 200, @@ -82,7 +71,6 @@ describe('AwsWafChallengeSolver', () => { }); it('should detect challenge with aws-waf-token', () => { - const { AwsWafChallengeSolver } = require('../AwsWafChallengeSolver'); const mockResponse = { data: '', status: 200, @@ -93,7 +81,6 @@ describe('AwsWafChallengeSolver', () => { }); it('should not detect challenge in normal response', () => { - const { AwsWafChallengeSolver } = require('../AwsWafChallengeSolver'); const mockResponse = { data: 'Normal page content', status: 200, @@ -102,174 +89,42 @@ describe('AwsWafChallengeSolver', () => { const result = AwsWafChallengeSolver.detectChallenge(mockResponse); expect(result).toBe(false); }); - }); - - describe('solveChallenge', () => { - it('should create a CapSolver task with only websiteURL', async () => { - const { AwsWafChallengeSolver, mockedAxios } = await loadSolverContext(); - mockSsmSend.mockResolvedValue({ - Parameter: { - Value: 'test-api-key', - }, - }); - mockedAxios.post - .mockResolvedValueOnce({ - data: { - errorId: 0, - taskId: 'task-123', - }, - } as any) - .mockResolvedValueOnce({ - data: { - errorId: 0, - status: 'ready', - solution: { - cookie: 'solved-cookie', - }, - }, - } as any); - - const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback: any) => { - callback(); - return 0 as any; - }); - - const result = await AwsWafChallengeSolver.solveChallenge('https://example.com/login', { maxRetries: 1, retryDelay: 1 }); + it('should not throw on JSON response bodies', () => { + const mockResponse = { + data: { CaseSummaryHeader: { CaseId: 'case-123' } }, + status: 200, + headers: {}, + } as any; - expect(result).toEqual({ - success: true, - cookie: 'solved-cookie', - }); + const result = AwsWafChallengeSolver.detectChallenge(mockResponse); + expect(result).toBe(false); + }); - expect(mockedAxios.post).toHaveBeenNthCalledWith( - 1, - 'https://api.capsolver.com/createTask', - { - clientKey: 'test-api-key', - task: { - type: 'AntiAwsWafTaskProxyLess', - websiteURL: 'https://example.com/login', - }, - }, - { - headers: { 'Content-Type': 'application/json' }, - timeout: 10000, - } - ); + it('should detect challenge from x-amzn-waf-action header', () => { + const mockResponse = { + data: '', + status: 202, + headers: { 'x-amzn-waf-action': 'challenge' }, + } as any; - setTimeoutSpy.mockRestore(); + const result = AwsWafChallengeSolver.detectChallenge(mockResponse); + expect(result).toBe(true); }); + }); + describe('solveChallenge', () => { it('should handle solving errors gracefully', async () => { - const { AwsWafChallengeSolver, mockedAlertService } = await loadSolverContext(); - mockSsmSend.mockRejectedValue(new Error('SSM error')); + // Mock SSM to throw an error + const mockSSMClient = SSMClient as unknown as jest.Mock; + mockSSMClient.mockImplementation(() => ({ + send: jest.fn().mockRejectedValue(new Error('SSM error')), + })); - const result = await AwsWafChallengeSolver.solveChallenge('https://example.com'); + const result = await AwsWafChallengeSolver.solveChallenge('https://example.com', 'challenge content'); expect(result.success).toBe(false); expect(result.error).toBeDefined(); - expect(mockedAlertService.logError).toHaveBeenCalled(); - }); - - it('should fail immediately on terminal CapSolver polling errors', async () => { - const { AwsWafChallengeSolver, mockedAxios, mockedAlertService } = await loadSolverContext(); - mockSsmSend.mockResolvedValue({ - Parameter: { - Value: 'test-api-key', - }, - }); - - mockedAxios.post - .mockResolvedValueOnce({ - data: { - errorId: 0, - taskId: 'task-123', - }, - } as any) - .mockResolvedValueOnce({ - data: { - errorId: 1, - status: 'failed', - errorDescription: 'ERROR_TASK_NOT_SUPPORTED', - }, - } as any); - - const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback: any) => { - callback(); - return 0 as any; - }); - - const result = await AwsWafChallengeSolver.solveChallenge('https://example.com/login', { - maxRetries: 5, - retryDelay: 1, - }); - - expect(result).toEqual({ - success: false, - error: 'WAF solver task failed: ERROR_TASK_NOT_SUPPORTED', - }); - expect(mockedAxios.post).toHaveBeenCalledTimes(2); - expect(mockedAlertService.logError).toHaveBeenCalled(); - - setTimeoutSpy.mockRestore(); - }); - - it('should retry transient polling transport errors', async () => { - const { AwsWafChallengeSolver, mockedAxios } = await loadSolverContext(); - mockSsmSend.mockResolvedValue({ - Parameter: { - Value: 'test-api-key', - }, - }); - - mockedAxios.post - .mockResolvedValueOnce({ - data: { - errorId: 0, - taskId: 'task-123', - }, - } as any) - .mockRejectedValueOnce({ - isAxiosError: true, - message: 'socket hang up', - response: { - status: 502, - data: { error: 'bad gateway' }, - }, - } as any) - .mockResolvedValueOnce({ - data: { - errorId: 0, - status: 'ready', - solution: { - cookie: 'solved-cookie', - }, - }, - } as any); - - const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback: any) => { - callback(); - return 0 as any; - }); - - mockedAxios.isAxiosError.mockImplementation(error => Boolean((error as any)?.isAxiosError)); - const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); - - const result = await AwsWafChallengeSolver.solveChallenge('https://example.com/login', { - maxRetries: 3, - retryDelay: 1, - }); - - expect(result).toEqual({ - success: true, - cookie: 'solved-cookie', - }); - expect(mockedAxios.post).toHaveBeenCalledTimes(3); - expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Error polling WAF solver result (attempt 1), retrying:')); - - consoleLogSpy.mockRestore(); - setTimeoutSpy.mockRestore(); }); // Note: We can't easily test the full solving flow without mocking the entire diff --git a/serverless/lib/__tests__/portalAuthenticator.test.ts b/serverless/lib/__tests__/portalAuthenticator.test.ts index 4b33ec6..8b47777 100644 --- a/serverless/lib/__tests__/portalAuthenticator.test.ts +++ b/serverless/lib/__tests__/portalAuthenticator.test.ts @@ -3,6 +3,7 @@ */ import PortalAuthenticator from '../PortalAuthenticator'; import AwsWafChallengeSolver from '../AwsWafChallengeSolver'; +import AlertService from '../AlertService'; import StorageClient from '../StorageClient'; import { CookieJar } from 'tough-cookie'; import axios from 'axios'; @@ -41,14 +42,47 @@ jest.mock('../StorageClient', () => ({ getCaseMetadata: jest.fn(), saveUserSession: jest.fn(), })); +jest.mock('../AlertService', () => ({ + __esModule: true, + default: { + logError: jest.fn(), + forCategory: jest.fn(), + }, + Severity: { + CRITICAL: 'CRITICAL', + ERROR: 'ERROR', + WARNING: 'WARNING', + INFO: 'INFO', + }, + AlertCategory: { + SYSTEM: 'SYS', + PORTAL: 'PORTAL', + AUTHENTICATION: 'AUTH', + }, +})); // Set environment variable before importing the module process.env.PORTAL_URL = 'https://test-portal.example.com'; +process.env.PORTAL_DASHBOARD_PATH = '/Portal/Home/Dashboard/29'; describe('PortalAuthenticator', () => { + let logSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; + beforeEach(() => { // Reset all mocks before each test jest.clearAllMocks(); + (AlertService.logError as jest.Mock).mockResolvedValue(undefined); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + logSpy.mockRestore(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); }); describe('Public API', () => { @@ -75,6 +109,19 @@ describe('PortalAuthenticator', () => { expect(result.cookieJar).toBeUndefined(); }); + it('should return error if PORTAL_DASHBOARD_PATH is not set', async () => { + const originalDashboardPath = process.env.PORTAL_DASHBOARD_PATH; + delete process.env.PORTAL_DASHBOARD_PATH; + + const result = await PortalAuthenticator.authenticateWithPortal('username', 'password'); + + process.env.PORTAL_DASHBOARD_PATH = originalDashboardPath; + + expect(result.success).toBe(false); + expect(result.message).toBe('PORTAL_DASHBOARD_PATH environment variable is not set'); + expect(result.cookieJar).toBeUndefined(); + }); + it('should make the proper requests for authentication', async () => { // Mock axios methods const mockGet = jest.fn().mockResolvedValue({ @@ -128,18 +175,17 @@ describe('PortalAuthenticator', () => { expect(result.cookieJar).toBeDefined(); }); - it('should pass the final redirected login URL to the WAF solver', async () => { + it('should solve the dashboard WAF challenge after WS-Fed completes', async () => { const mockGet = jest .fn() .mockResolvedValueOnce({ - data: '', - status: 202, - request: { res: { responseUrl: 'https://test-idp.example.com/idp/account/signin' } }, + data: '', + request: { res: { responseUrl: 'https://test-login.example.com' } }, }) .mockResolvedValueOnce({ - data: '', - status: 200, - request: { res: { responseUrl: 'https://test-idp.example.com/idp/account/signin' } }, + data: '', + status: 405, + request: { res: { responseUrl: 'https://test-portal.example.com/Portal/Home/Dashboard/29' } }, }); const mockPost = jest @@ -151,6 +197,7 @@ describe('PortalAuthenticator', () => { .mockResolvedValueOnce({ data: 'Welcome, TestUser', headers: {}, + request: { res: { responseUrl: 'https://test-portal.example.com/Portal/Home/Dashboard/29' } }, }); // @ts-ignore - need to mock the axios create method @@ -160,18 +207,8 @@ describe('PortalAuthenticator', () => { }); const mockCookies = [ - { - key: 'FedAuth', - value: 'test-token', - domain: 'portal.example.com', - path: '/', - }, - { - key: 'FedAuth1', - value: 'test-token', - domain: 'portal.example.com', - path: '/', - }, + { key: 'FedAuth', value: 'test-token', domain: 'portal.example.com', path: '/' }, + { key: 'FedAuth1', value: 'test-token', domain: 'portal.example.com', path: '/' }, ]; // @ts-ignore - update the getCookiesSync mock for this test @@ -179,16 +216,28 @@ describe('PortalAuthenticator', () => { const detectChallengeSpy = jest .spyOn(AwsWafChallengeSolver, 'detectChallenge') - .mockReturnValueOnce(true) - .mockReturnValueOnce(false); + .mockReturnValueOnce(false) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); const solveChallengeSpy = jest.spyOn(AwsWafChallengeSolver, 'solveChallenge').mockResolvedValue({ success: true, - cookie: 'solved-cookie', + cookie: 'dashboard-waf-cookie', }); const result = await PortalAuthenticator.authenticateWithPortal('testuser', 'password'); - expect(solveChallengeSpy).toHaveBeenCalledWith('https://test-idp.example.com/idp/account/signin'); + expect(mockGet).toHaveBeenCalledWith( + 'https://test-portal.example.com/Portal/Home/Dashboard/29', + expect.objectContaining({ + headers: expect.objectContaining({ + Referer: 'https://test-portal.example.com', + }), + }) + ); + expect(solveChallengeSpy).toHaveBeenCalledWith( + 'https://test-portal.example.com/Portal/Home/Dashboard/29', + expect.stringContaining('challenge.js') + ); expect(result.success).toBe(true); detectChallengeSpy.mockRestore(); @@ -202,12 +251,46 @@ describe('PortalAuthenticator', () => { // @ts-ignore - mock implementation StorageClient.getUserSession.mockResolvedValue(JSON.stringify(mockSessionJson)); + const verifySessionSpy = jest.spyOn(PortalAuthenticator, 'verifySession').mockResolvedValue(true); const result = await PortalAuthenticator.getOrCreateUserSession('test-user'); expect(StorageClient.getUserSession).toHaveBeenCalledWith('test-user'); expect(result.success).toBe(true); expect(result.cookieJar).toBeDefined(); + + verifySessionSpy.mockRestore(); + }); + + it('should reauthenticate when stored session fails dashboard verification', async () => { + const mockSessionJson = { cookies: [{ key: 'FedAuth', value: 'test' }] }; + + // @ts-ignore - mock implementation + StorageClient.getUserSession.mockResolvedValue(JSON.stringify(mockSessionJson)); + // @ts-ignore - mock implementation + StorageClient.sensitiveGetPortalCredentials.mockResolvedValue({ + username: 'testuser', + password: 'password', + isBad: false, + }); + + const verifySessionSpy = jest.spyOn(PortalAuthenticator, 'verifySession').mockResolvedValue(false); + const mockCookieJar = new CookieJar(); + const authenticateSpy = jest.spyOn(PortalAuthenticator, 'authenticateWithPortal').mockResolvedValue({ + success: true, + cookieJar: mockCookieJar, + }); + + const result = await PortalAuthenticator.getOrCreateUserSession('test-user'); + + expect(verifySessionSpy).toHaveBeenCalled(); + expect(StorageClient.sensitiveGetPortalCredentials).toHaveBeenCalledWith('test-user'); + expect(authenticateSpy).toHaveBeenCalledWith('testuser', 'password', expect.any(Object)); + expect(result.success).toBe(true); + expect(result.cookieJar).toBe(mockCookieJar); + + verifySessionSpy.mockRestore(); + authenticateSpy.mockRestore(); }); it('should try to create a new session if none exists', async () => { @@ -273,6 +356,18 @@ describe('PortalAuthenticator', () => { expect(result).toBe(false); }); + it('should return false if PORTAL_DASHBOARD_PATH is not set', async () => { + const originalDashboardPath = process.env.PORTAL_DASHBOARD_PATH; + delete process.env.PORTAL_DASHBOARD_PATH; + + const mockJar = new CookieJar(); + const result = await PortalAuthenticator.verifySession(mockJar); + + process.env.PORTAL_DASHBOARD_PATH = originalDashboardPath; + + expect(result).toBe(false); + }); + it('should check for welcome message in response', async () => { const mockJar = new CookieJar(); @@ -289,7 +384,14 @@ describe('PortalAuthenticator', () => { const result = await PortalAuthenticator.verifySession(mockJar); - expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('/Portal'), expect.any(Object)); + expect(mockGet).toHaveBeenCalledWith( + 'https://test-portal.example.com/Portal/Home/Dashboard/29', + expect.objectContaining({ + headers: expect.objectContaining({ + Referer: 'https://test-portal.example.com', + }), + }) + ); expect(result).toBe(true); }); @@ -311,5 +413,26 @@ describe('PortalAuthenticator', () => { expect(result).toBe(false); }); + + it('should return false if the dashboard response is a WAF challenge', async () => { + const mockJar = new CookieJar(); + + const mockGet = jest.fn().mockResolvedValue({ + data: '', + status: 405, + headers: { + 'x-amzn-waf-action': 'captcha', + }, + }); + + // @ts-ignore - need to mock the axios create method + axios.create.mockReturnValue({ + get: mockGet, + }); + + const result = await PortalAuthenticator.verifySession(mockJar, { debug: true }); + + expect(result).toBe(false); + }); }); }); From 3726edeb0221db9b73bdc014d5770ea9cacf4990 Mon Sep 17 00:00:00 2001 From: Jay Hill <116148+jayhill@users.noreply.github.com> Date: Sun, 26 Apr 2026 09:36:47 -0400 Subject: [PATCH 5/5] #208 route portal search requests through shared WAF client --- serverless/lib/CaseSearchProcessor.ts | 47 +++++++++++++------ serverless/lib/NameSearchPortalClient.ts | 29 ++++++------ .../lib/__tests__/CaseSearchProcessor.test.ts | 13 +++++ 3 files changed, 60 insertions(+), 29 deletions(-) diff --git a/serverless/lib/CaseSearchProcessor.ts b/serverless/lib/CaseSearchProcessor.ts index dd2efc2..5350921 100644 --- a/serverless/lib/CaseSearchProcessor.ts +++ b/serverless/lib/CaseSearchProcessor.ts @@ -5,12 +5,11 @@ import StorageClient from './StorageClient'; import PortalAuthenticator from './PortalAuthenticator'; import AlertService, { Severity, AlertCategory } from './AlertService'; import { CookieJar } from 'tough-cookie'; -import axios from 'axios'; -import { wrapper } from 'axios-cookiejar-support'; import * as cheerio from 'cheerio'; import UserAgentClient from './UserAgentClient'; import { CASE_SUMMARY_VERSION_DATE } from './CaseProcessor'; import WebSocketPublisher from './WebSocketPublisher'; +import PortalRequestClient from './PortalRequestClient'; async function publishCaseUpdate(userId: string, zipCase: ZipCase): Promise { await WebSocketPublisher.publishCaseStatusUpdated(userId, zipCase.caseNumber, { @@ -61,7 +60,7 @@ export async function processCaseSearchRequest(req: CaseSearchRequest): Promise< const caseSummary = results[caseNumber].caseSummary; switch (status) { - case 'complete': + case 'complete': { const lastUpdated = results[caseNumber].zipCase.lastUpdated; if (caseSummary && lastUpdated && new Date(lastUpdated) >= CASE_SUMMARY_VERSION_DATE) { // Truly complete - has both ID and an up-to-date summary @@ -99,6 +98,7 @@ export async function processCaseSearchRequest(req: CaseSearchRequest): Promise< casesToQueue.push(caseNumber); } break; + } case 'found': case 'reprocessing': console.log(`Case ${caseNumber} already has status ${status}, preserving`); @@ -121,7 +121,7 @@ export async function processCaseSearchRequest(req: CaseSearchRequest): Promise< case 'notFound': case 'failed': case 'queued': - case 'processing': + case 'processing': { // We requeue 'queued' and 'processing' because they might be stuck. // When they get picked up from the queue, we'll see whether they became 'complete' in the mean time and exit early. const zipCase = results[caseNumber].zipCase; @@ -131,6 +131,8 @@ export async function processCaseSearchRequest(req: CaseSearchRequest): Promise< await StorageClient.saveCase(zipCase); casesToQueue.push(caseNumber); + break; + } } } else { // Case doesn't exist yet - create it with queued status and add to queue @@ -381,6 +383,7 @@ export async function fetchCaseIdFromPortal(caseNumber: string, cookieJar: Cooki try { // Get the portal URL from environment variable const portalUrl = process.env.PORTAL_URL; + const portalDashboardPath = process.env.PORTAL_DASHBOARD_PATH; if (!portalUrl) { const errorMsg = 'PORTAL_URL environment variable is not set'; @@ -396,19 +399,31 @@ export async function fetchCaseIdFromPortal(caseNumber: string, cookieJar: Cooki }; } + if (!portalDashboardPath) { + const errorMsg = 'PORTAL_DASHBOARD_PATH environment variable is not set'; + + await AlertService.logError(Severity.CRITICAL, AlertCategory.SYSTEM, '', new Error(errorMsg), { resource: 'case-search' }); + + return { + caseId: null, + error: { + message: 'Portal dashboard path environment variable is not set', + isSystemError: true, + }, + }; + } + const userAgent = await UserAgentClient.getUserAgent('system'); + const dashboardUrl = new URL(portalDashboardPath, `${portalUrl}/`).toString(); - const client = wrapper(axios).create({ - timeout: 20000, - maxRedirects: 10, - validateStatus: status => status < 500, // Only reject on 5xx errors + const client = new PortalRequestClient({ jar: cookieJar, - withCredentials: true, - headers: { - ...PortalAuthenticator.getDefaultRequestHeaders(userAgent), + portalUrl, + userAgent, + defaultHeaders: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: portalUrl, - Referer: 'https://portal-nc.tylertech.cloud/Portal/Home/Dashboard/29', + Referer: dashboardUrl, }, }); @@ -419,7 +434,9 @@ export async function fetchCaseIdFromPortal(caseNumber: string, cookieJar: Cooki searchFormData.append('caseCriteria.SearchCriteria', caseNumber); searchFormData.append('caseCriteria.SearchCases', 'true'); - const searchResponse = await client.post(`${portalUrl}/Portal/SmartSearch/SmartSearch/SmartSearch`, searchFormData); + const searchResponse = await client.post(`${portalUrl}/Portal/SmartSearch/SmartSearch/SmartSearch`, searchFormData, { + wafContextUrl: `${portalUrl}/Portal/SmartSearch/SmartSearch/SmartSearch`, + }); if (searchResponse.status !== 200) { const errorMessage = `Search request failed with status ${searchResponse.status}`; @@ -440,7 +457,9 @@ export async function fetchCaseIdFromPortal(caseNumber: string, cookieJar: Cooki } // Step 2: Get the search results page - const resultsResponse = await client.get(`${portalUrl}/Portal/SmartSearch/SmartSearchResults`); + const resultsResponse = await client.get(`${portalUrl}/Portal/SmartSearch/SmartSearchResults`, { + wafContextUrl: `${portalUrl}/Portal/SmartSearch/SmartSearchResults`, + }); if (resultsResponse.status !== 200) { const errorMessage = `Results request failed with status ${resultsResponse.status}`; diff --git a/serverless/lib/NameSearchPortalClient.ts b/serverless/lib/NameSearchPortalClient.ts index 379859a..d26479d 100644 --- a/serverless/lib/NameSearchPortalClient.ts +++ b/serverless/lib/NameSearchPortalClient.ts @@ -1,9 +1,7 @@ import { CookieJar } from 'tough-cookie'; -import axios from 'axios'; -import { wrapper } from 'axios-cookiejar-support'; import AlertService, { Severity, AlertCategory } from './AlertService'; -import PortalAuthenticator from './PortalAuthenticator'; import UserAgentClient from './UserAgentClient'; +import PortalRequestClient from './PortalRequestClient'; // Interface for the result of a name search export interface NameSearchResult { @@ -36,14 +34,12 @@ export async function fetchCasesByName( const userAgent = await UserAgentClient.getUserAgent('system'); - const client = wrapper(axios).create({ - timeout: 60000, - maxRedirects: 10, - validateStatus: status => status < 500, // Only reject on 5xx errors + const client = new PortalRequestClient({ jar: cookieJar, - withCredentials: true, - headers: { - ...PortalAuthenticator.getDefaultRequestHeaders(userAgent), + portalUrl, + userAgent, + timeout: 60000, + defaultHeaders: { Origin: portalUrl, 'Content-Type': 'application/x-www-form-urlencoded', }, @@ -72,7 +68,9 @@ export async function fetchCasesByName( searchFormData.append('caseCriteria.UseSoundex', 'true'); } - const searchResponse = await client.post(`${portalUrl}/Portal/SmartSearch/SmartSearch/SmartSearch`, searchFormData); + const searchResponse = await client.post(`${portalUrl}/Portal/SmartSearch/SmartSearch/SmartSearch`, searchFormData, { + wafContextUrl: `${portalUrl}/Portal/SmartSearch/SmartSearch/SmartSearch`, + }); console.log(`Search response status: ${searchResponse.status}`); @@ -101,7 +99,10 @@ export async function fetchCasesByName( const resultsRequestHeaders: Record = { Referer: `${portalUrl}/Portal/Home/WorkspaceMode?p=0`, }; - const resultsResponse = await client.get(`${portalUrl}/Portal/SmartSearch/SmartSearchResults`, { headers: resultsRequestHeaders }); + const resultsResponse = await client.get(`${portalUrl}/Portal/SmartSearch/SmartSearchResults`, { + headers: resultsRequestHeaders, + wafContextUrl: `${portalUrl}/Portal/SmartSearch/SmartSearchResults`, + }); console.log(`SmartSearchResults response status: ${resultsResponse.status}`); @@ -173,9 +174,7 @@ export async function fetchCasesByName( Severity.ERROR, AlertCategory.PORTAL, '', - error instanceof Error - ? error - : new Error(`Error parsing search results: ${String(error)}`), + error instanceof Error ? error : new Error(`Error parsing search results: ${String(error)}`), { name, resource: 'portal-search-results-json', diff --git a/serverless/lib/__tests__/CaseSearchProcessor.test.ts b/serverless/lib/__tests__/CaseSearchProcessor.test.ts index 585a057..1ede2bb 100644 --- a/serverless/lib/__tests__/CaseSearchProcessor.test.ts +++ b/serverless/lib/__tests__/CaseSearchProcessor.test.ts @@ -20,8 +20,15 @@ const mockQueueClient = QueueClient as jest.Mocked; const mockPortalAuthenticator = PortalAuthenticator as jest.Mocked; describe('CaseSearchProcessor', () => { + let logSpy: jest.SpyInstance; + let warnSpy: jest.SpyInstance; + let errorSpy: jest.SpyInstance; + beforeEach(() => { jest.clearAllMocks(); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); // Default mock for portal authenticator mockPortalAuthenticator.getOrCreateUserSession.mockResolvedValue({ @@ -31,6 +38,12 @@ describe('CaseSearchProcessor', () => { }); }); + afterEach(() => { + logSpy.mockRestore(); + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + describe('processCaseSearchRequest', () => { const baseRequest: CaseSearchRequest = { input: '22CR123456-789',