diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9ec7f85..8d53f99 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -35,7 +35,7 @@ jobs: environment: ${{ needs.determine-environment.outputs.environment_name }} steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@v6 with: aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }} @@ -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" @@ -85,7 +86,7 @@ jobs: terraform_version: '1.11.4' - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@v6 with: aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }} @@ -156,7 +157,7 @@ jobs: run: npm ci - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@v6 with: aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/manual-deploy.yml b/.github/workflows/manual-deploy.yml index 551f130..aa59536 100644 --- a/.github/workflows/manual-deploy.yml +++ b/.github/workflows/manual-deploy.yml @@ -43,7 +43,7 @@ jobs: environment: ${{ github.event.inputs.environment }} steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@v6 with: aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }} @@ -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" ) @@ -94,7 +95,7 @@ jobs: terraform_version: '1.11.4' - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@v6 with: aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }} @@ -171,7 +172,7 @@ jobs: run: npm ci - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@v6 with: aws-access-key-id: ${{ env.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ env.AWS_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index d638908..278385f 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -85,7 +85,7 @@ jobs: terraform_version: '1.11.4' - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@v6 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} @@ -158,7 +158,7 @@ jobs: terraform_version: '1.11.4' - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@v6 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index db78ec1..c3cbb5a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -43,7 +43,7 @@ "tailwindcss": "^4.0.9", "typescript": "~5.7.2", "typescript-eslint": "^8.22.0", - "vite": "^6.1.1", + "vite": "^6.4.2", "vitest": "^3.1.3" } }, @@ -1348,13 +1348,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.9.tgz", - "integrity": "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog==", + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz", + "integrity": "sha512-iu2pyvaqmeatIJLURLqx9D+4jKAdTH20ntzB6BFwjyN7V960r4jK32mx0Zf7YbtOYAbmbtQfDNuL60ONinyw7A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "fast-xml-parser": "5.4.1", + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" }, "engines": { @@ -1362,9 +1362,9 @@ } }, "node_modules/@aws-sdk/xml-builder/node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "version": "5.5.8", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz", + "integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==", "funding": [ { "type": "github", @@ -1373,8 +1373,9 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -4434,9 +4435,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", - "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -6882,21 +6883,24 @@ "license": "MIT" }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } }, "node_modules/fast-xml-parser": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz", - "integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==", + "version": "5.5.9", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", + "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", "funding": [ { "type": "github", @@ -6905,8 +6909,9 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", - "strnum": "^2.1.2" + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.0", + "strnum": "^2.2.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -7974,9 +7979,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -8331,6 +8336,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", + "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -9075,9 +9095,9 @@ "license": "MIT" }, "node_modules/strnum": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", - "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", "funding": [ { "type": "github", @@ -9551,9 +9571,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 8aa457c..8c4fa38 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "overrides": { "flatted": "^3.4.2", "glob": "^10.5.0", + "lodash": "^4.18.1", "minimatch": "^9.0.9" }, "dependencies": { @@ -57,7 +58,7 @@ "tailwindcss": "^4.0.9", "typescript": "~5.7.2", "typescript-eslint": "^8.22.0", - "vite": "^6.1.1", + "vite": "^6.4.2", "vitest": "^3.1.3" } } diff --git a/package-lock.json b/package-lock.json index 50fb44d..78895ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -125,10 +125,11 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" }, "node_modules/min-indent": { "version": "1.0.1", diff --git a/package.json b/package.json index 92f28de..58457ba 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "test": "echo \"Error: no test specified\" && exit 1", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\"" }, + "overrides": { + "lodash": "^4.18.1" + }, "keywords": [], "author": "Code with Asheville", "license": "MIT", 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/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/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: diff --git a/serverless/lib/AwsWafChallengeSolver.ts b/serverless/lib/AwsWafChallengeSolver.ts index 6f31aa6..1d15c3d 100644 --- a/serverless/lib/AwsWafChallengeSolver.ts +++ b/serverless/lib/AwsWafChallengeSolver.ts @@ -34,11 +34,7 @@ export interface WafChallengeSolverOptions { */ export interface IAwsWafChallengeSolver { detectChallenge(response: AxiosResponse): boolean; - solveChallenge( - websiteURL: string, - htmlContent: string, - options?: WafChallengeSolverOptions - ): Promise; + solveChallenge(websiteURL: string, htmlContent: string, options?: WafChallengeSolverOptions): Promise; } /** @@ -83,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') || @@ -150,11 +149,7 @@ class CapSolverProvider implements IAwsWafChallengeSolver { } } - private async waitForTaskResult( - taskId: string, - apiKey: string, - options: WafChallengeSolverOptions - ): Promise { + private async waitForTaskResult(taskId: string, apiKey: string, options: WafChallengeSolverOptions): Promise { const maxAttempts = options.maxRetries || 30; // Default: 30 attempts const delay = options.retryDelay || 5000; // Default: 5 seconds @@ -180,13 +175,10 @@ class CapSolverProvider implements IAwsWafChallengeSolver { } else if (result.status === 'failed' || result.errorId !== 0) { throw new Error(`WAF solver task failed: ${result.errorDescription || 'Unknown error'}`); } - - console.log(`WAF solver task still processing... (attempt ${attempt}/${maxAttempts})`); } catch (error) { if (attempt === maxAttempts) { throw error; } - console.log(`Error polling WAF solver result (attempt ${attempt}), retrying...`); } } @@ -220,10 +212,8 @@ class CapSolverProvider implements IAwsWafChallengeSolver { challengeData.awsProblemUrl = problemUrlMatch[0]; } } - - console.log('Parsed AWS WAF challenge data:', challengeData); - } catch (error) { - console.log('Error parsing AWS WAF challenge data:', error); + } catch { + console.warn('Error parsing AWS WAF challenge data'); } return challengeData; 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/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/PortalAuthenticator.ts b/serverless/lib/PortalAuthenticator.ts index 33721d9..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,25 +163,19 @@ const PortalAuthenticator = { if (debug) console.log('AWS WAF challenge detected, attempting to solve...'); try { - const wafResult = await AwsWafChallengeSolver.solveChallenge( - portalBaseUrl + '/Portal/Account/Login', - loginPageResponse.data - ); + 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); @@ -261,7 +278,7 @@ const PortalAuthenticator = { 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 @@ -342,9 +359,7 @@ const PortalAuthenticator = { }); } - const hasSessionCookie = - cookies.some(cookie => cookie.key === 'FedAuth') && - cookies.some(cookie => cookie.key === 'FedAuth1'); + const hasSessionCookie = cookies.some(cookie => cookie.key === 'FedAuth') && cookies.some(cookie => cookie.key === 'FedAuth1'); // Check for both "Sign In" button (failure) and "Welcome, " text (success) const hasWelcomeUser = completeWsFedResponse.data.includes('Welcome, '); @@ -374,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, @@ -396,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'; @@ -410,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); @@ -429,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 @@ -452,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) { @@ -477,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); @@ -494,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); @@ -521,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/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, + }); + } +} diff --git a/serverless/lib/__tests__/AwsWafChallengeSolver.test.ts b/serverless/lib/__tests__/AwsWafChallengeSolver.test.ts index 77d2ea8..bfba42d 100644 --- a/serverless/lib/__tests__/AwsWafChallengeSolver.test.ts +++ b/serverless/lib/__tests__/AwsWafChallengeSolver.test.ts @@ -2,11 +2,10 @@ * Tests for the AwsWafChallengeSolver module */ import { AwsWafChallengeSolver } from '../AwsWafChallengeSolver'; -import axios from 'axios'; +import { SSMClient } from '@aws-sdk/client-ssm'; // Mock axios jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; // Mock AWS SDK jest.mock('@aws-sdk/client-ssm', () => ({ @@ -90,12 +89,34 @@ describe('AwsWafChallengeSolver', () => { const result = AwsWafChallengeSolver.detectChallenge(mockResponse); expect(result).toBe(false); }); + + it('should not throw on JSON response bodies', () => { + const mockResponse = { + data: { CaseSummaryHeader: { CaseId: 'case-123' } }, + status: 200, + headers: {}, + } as any; + + const result = AwsWafChallengeSolver.detectChallenge(mockResponse); + expect(result).toBe(false); + }); + + it('should detect challenge from x-amzn-waf-action header', () => { + const mockResponse = { + data: '', + status: 202, + headers: { 'x-amzn-waf-action': 'challenge' }, + } as any; + + const result = AwsWafChallengeSolver.detectChallenge(mockResponse); + expect(result).toBe(true); + }); }); describe('solveChallenge', () => { it('should handle solving errors gracefully', async () => { // Mock SSM to throw an error - const mockSSMClient = require('@aws-sdk/client-ssm').SSMClient; + const mockSSMClient = SSMClient as unknown as jest.Mock; mockSSMClient.mockImplementation(() => ({ send: jest.fn().mockRejectedValue(new Error('SSM error')), })); 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', 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: { diff --git a/serverless/lib/__tests__/portalAuthenticator.test.ts b/serverless/lib/__tests__/portalAuthenticator.test.ts index e241f0c..8b47777 100644 --- a/serverless/lib/__tests__/portalAuthenticator.test.ts +++ b/serverless/lib/__tests__/portalAuthenticator.test.ts @@ -2,6 +2,8 @@ * Tests for the PortalAuthenticator module */ 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'; @@ -40,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', () => { @@ -74,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({ @@ -126,6 +174,75 @@ describe('PortalAuthenticator', () => { expect(result.success).toBe(true); expect(result.cookieJar).toBeDefined(); }); + + it('should solve the dashboard WAF challenge after WS-Fed completes', async () => { + const mockGet = jest + .fn() + .mockResolvedValueOnce({ + data: '', + request: { res: { responseUrl: 'https://test-login.example.com' } }, + }) + .mockResolvedValueOnce({ + data: '', + status: 405, + request: { res: { responseUrl: 'https://test-portal.example.com/Portal/Home/Dashboard/29' } }, + }); + + const mockPost = jest + .fn() + .mockResolvedValueOnce({ + data: '', + request: { res: { responseUrl: 'https://test-federation.example.com' } }, + }) + .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 + axios.create.mockReturnValue({ + get: mockGet, + post: mockPost, + }); + + const mockCookies = [ + { 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 + CookieJar().getCookiesSync.mockReturnValue(mockCookies); + + const detectChallengeSpy = jest + .spyOn(AwsWafChallengeSolver, 'detectChallenge') + .mockReturnValueOnce(false) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + const solveChallengeSpy = jest.spyOn(AwsWafChallengeSolver, 'solveChallenge').mockResolvedValue({ + success: true, + cookie: 'dashboard-waf-cookie', + }); + + const result = await PortalAuthenticator.authenticateWithPortal('testuser', 'password'); + + 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(); + solveChallengeSpy.mockRestore(); + }); }); describe('getOrCreateUserSession', () => { @@ -134,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 () => { @@ -205,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(); @@ -221,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); }); @@ -243,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); + }); }); }); diff --git a/serverless/package-lock.json b/serverless/package-lock.json index 67b8345..22c7341 100644 --- a/serverless/package-lock.json +++ b/serverless/package-lock.json @@ -17,7 +17,7 @@ "@aws-sdk/client-textract": "^3.994.0", "@aws-sdk/lib-dynamodb": "^3.994.0", "@aws-sdk/s3-request-presigner": "^3.994.0", - "axios": "^1.12.1", + "axios": "^1.15.0", "axios-cookiejar-support": "^5.0.5", "cheerio": "^1.0.0", "exceljs": "^4.4.0", @@ -5675,9 +5675,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -6020,14 +6020,14 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/axios-cookiejar-support": { @@ -6060,6 +6060,15 @@ "tunnel": "^0.0.6" } }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -6246,9 +6255,9 @@ "license": "MIT" }, "node_modules/basic-ftp": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", - "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.1.tgz", + "integrity": "sha512-0yaL8JdxTknKDILitVpfYfV2Ob6yb3udX/hK97M7I3jOeznBNxQPtVvTUtnhUkyHlxFWyr5Lvknmgzoc7jf+1Q==", "dev": true, "license": "MIT", "engines": { @@ -8213,9 +8222,9 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11331,6 +11340,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, "license": "MIT" }, "node_modules/punycode": { @@ -12899,9 +12909,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/serverless/package.json b/serverless/package.json index 293e0f0..e2a0a67 100644 --- a/serverless/package.json +++ b/serverless/package.json @@ -1,9 +1,12 @@ { "overrides": { + "@xmldom/xmldom": "^0.8.13", "flatted": "^3.4.2", + "handlebars": "^4.7.9", "rimraf": { "glob": "^10.5.0" - } + }, + "undici": "^6.24.0" }, "devDependencies": { "@eslint/js": "^9.24.0", @@ -36,7 +39,7 @@ "@aws-sdk/client-textract": "^3.994.0", "@aws-sdk/lib-dynamodb": "^3.994.0", "@aws-sdk/s3-request-presigner": "^3.994.0", - "axios": "^1.12.1", + "axios": "^1.15.0", "axios-cookiejar-support": "^5.0.5", "cheerio": "^1.0.0", "exceljs": "^4.4.0",