diff --git a/api/.dependency-cruiser.js b/api/.dependency-cruiser.js index 0103d07c15..e683dba37d 100644 --- a/api/.dependency-cruiser.js +++ b/api/.dependency-cruiser.js @@ -78,6 +78,10 @@ module.exports = { from: {}, to: { path: "express" }, }, + { + from: {}, + to: { path: "http-status-codes" }, + }, { from: {}, to: { path: "crypto" }, diff --git a/api/package.json b/api/package.json index 69affb3aef..bd922ecf33 100644 --- a/api/package.json +++ b/api/package.json @@ -51,6 +51,7 @@ "dedent": "1.5.1", "express": "4.18.2", "helmet": "7.1.0", + "http-status-codes": "^2.3.0", "jsonwebtoken": "9.0.2", "lodash": "4.17.21", "serverless-http": "3.2.0", diff --git a/api/serverless.ts b/api/serverless.ts index 733c13e67c..5069068ff0 100644 --- a/api/serverless.ts +++ b/api/serverless.ts @@ -111,7 +111,7 @@ const serverlessConfiguration: AWS = { ], provider: { name: "aws", - runtime: "nodejs16.x", + runtime: "nodejs18.x", stage: stage, region: region, iam: { diff --git a/api/src/api/healthCheckRouter.test.ts b/api/src/api/healthCheckRouter.test.ts new file mode 100644 index 0000000000..1ebfb38e67 --- /dev/null +++ b/api/src/api/healthCheckRouter.test.ts @@ -0,0 +1,100 @@ +import { healthCheckRouterFactory } from "@api/healthCheckRouter"; +import type { HealthCheckMethod } from "@domain/types"; +import { setupExpress } from "@libs/express"; +import type { Express } from "express"; +import { ReasonPhrases, StatusCodes } from "http-status-codes"; +import request from "supertest"; + +const mockOKHealthCheckMethod: HealthCheckMethod = async () => { + return new Promise((resolve) => { + resolve({ + success: true, + data: { + message: ReasonPhrases.OK, + }, + }); + }); +}; + +const mockErrorHealthCheckMethod: HealthCheckMethod = async () => { + return new Promise((resolve) => { + resolve({ + success: false, + error: { + message: ReasonPhrases.BAD_GATEWAY, + serverResponseBody: "", + serverResponseCode: 404, + timeout: false, + }, + }); + }); +}; + +const mockTimeoutHealthCheckMethod: HealthCheckMethod = async () => { + return new Promise((resolve) => { + resolve({ + success: false, + error: { + message: ReasonPhrases.GATEWAY_TIMEOUT, + timeout: true, + }, + }); + }); +}; + +describe("healthCheckRouter", () => { + let app: Express; + + beforeEach(async () => { + app = setupExpress(false); + app.use( + healthCheckRouterFactory(new Map([["mockOK", mockOKHealthCheckMethod]])), + healthCheckRouterFactory(new Map([["mockErr", mockErrorHealthCheckMethod]])), + healthCheckRouterFactory( + new Map([["mockTimeout", mockTimeoutHealthCheckMethod]]) + ) + ); + }); + + afterAll(async () => { + await new Promise((resolve) => { + return setTimeout(resolve, 500); + }); + }); + + describe("GET /", () => { + it("should return a successful response for the server's self check", async () => { + const response = await request(app).get("/"); + expect(response.status).toBe(StatusCodes.OK); + expect(response.body.success).toBe(true); + expect(response.body.data.message).toEqual("OK"); + }); + }); + + describe("GET /mockOK", () => { + it("should return a successful response for a mocked health check", async () => { + const response = await request(app).get("/mockOK"); + expect(response.status).toBe(StatusCodes.OK); + expect(response.body.success).toBe(true); + expect(response.body.data.message).toEqual("OK"); + }); + }); + + describe("GET /mockErr", () => { + it("should return an unsuccessful response for a errored health check", async () => { + const response = await request(app).get("/mockErr"); + expect(response.status).toBe(StatusCodes.BAD_GATEWAY); + expect(response.body.success).toBe(false); + expect(response.body.error.message).toEqual(ReasonPhrases.BAD_GATEWAY); + }); + }); + + describe("GET /mockTimeout", () => { + it("should return a successful response for a mocked health check", async () => { + const response = await request(app).get("/mockTimeout"); + expect(response.status).toBe(StatusCodes.GATEWAY_TIMEOUT); + expect(response.body.success).toBe(false); + expect(response.body.error.message).toEqual(ReasonPhrases.GATEWAY_TIMEOUT); + }); + }); +}); diff --git a/api/src/api/healthCheckRouter.ts b/api/src/api/healthCheckRouter.ts new file mode 100644 index 0000000000..02211f1d68 --- /dev/null +++ b/api/src/api/healthCheckRouter.ts @@ -0,0 +1,47 @@ +import type { HealthCheckMetadata, HealthCheckMethod, SuccessfulHealthCheckResponse } from "@domain/types"; +import { Router } from "express"; +import { ReasonPhrases, StatusCodes } from "http-status-codes"; + +const requestTimestamp = (): number => Math.round(Date.now() / 1000); + +const healthCheckResponseStatus = (response: HealthCheckMetadata): StatusCodes => { + let statusCode = StatusCodes.OK; + + if (!response.success && response.error) { + statusCode = response.error.timeout ? StatusCodes.GATEWAY_TIMEOUT : StatusCodes.BAD_GATEWAY; + } + + return statusCode; +}; + +type HealthCheckEndpoint = string; +type HealthChecks = Map; + +export const healthCheckRouterFactory = (clients: HealthChecks): Router => { + const router = Router(); + + router.get("/", (_req, res) => { + res.json({ + timestamp: requestTimestamp(), + success: true, + data: { + message: ReasonPhrases.OK, + }, + } as SuccessfulHealthCheckResponse); + }); + + for (const [endpoint, client] of clients) { + router.get(`/${endpoint}`, async (_req, res) => { + const timestamp = requestTimestamp(); + const healthCheckResult = await client(); + const statusCode = healthCheckResponseStatus(healthCheckResult); + + res.status(statusCode).json({ + timestamp, + ...healthCheckResult, + }); + }); + } + + return router; +}; diff --git a/api/src/client/WebserviceLicenseStatusClient.test.ts b/api/src/client/WebserviceLicenseStatusClient.test.ts index 33b6f66915..7809996685 100644 --- a/api/src/client/WebserviceLicenseStatusClient.test.ts +++ b/api/src/client/WebserviceLicenseStatusClient.test.ts @@ -33,4 +33,33 @@ describe("WebserviceLicenseStatusClient", () => { mockAxios.post.mockResolvedValue({ data: "" }); expect(await client.search("name", "11111", "some-type")).toEqual([]); }); + + it("returns a passing health check if data can be retrieved sucessfully", async () => { + mockAxios.post.mockResolvedValue({}); + expect(await client.health()).toEqual({ success: true, data: { message: "OK" } }); + }); + + it("returns a failing health check if unexpected data is retrieved", async () => { + mockAxios.post.mockRejectedValue({ response: { status: 404 }, message: "" }); + expect(await client.health()).toEqual({ + success: false, + error: { + message: "Bad Gateway", + serverResponseBody: "", + serverResponseCode: 404, + timeout: false, + }, + }); + }); + + it("returns a failing health check if axios request times out", async () => { + mockAxios.post.mockRejectedValue({}); + expect(await client.health()).toEqual({ + success: false, + error: { + message: "Gateway Timeout", + timeout: true, + }, + }); + }); }); diff --git a/api/src/client/WebserviceLicenseStatusClient.ts b/api/src/client/WebserviceLicenseStatusClient.ts index 086a314fdf..16ba88d59d 100644 --- a/api/src/client/WebserviceLicenseStatusClient.ts +++ b/api/src/client/WebserviceLicenseStatusClient.ts @@ -1,7 +1,8 @@ -import { LicenseStatusClient } from "@domain/types"; +import { HealthCheckMetadata, HealthCheckMethod, LicenseStatusClient } from "@domain/types"; import { LogWriterType } from "@libs/logWriter"; import { LicenseEntity } from "@shared/license"; import axios, { AxiosError } from "axios"; +import { ReasonPhrases } from "http-status-codes"; export const WebserviceLicenseStatusClient = ( baseUrl: string, @@ -33,7 +34,53 @@ export const WebserviceLicenseStatusClient = ( }); }; + const health: HealthCheckMethod = () => { + const url = `${baseUrl}/ws/simple/queryLicenseStatus`; + const logId = logWriter.GetId(); + const licenseType = "HVACR"; + const name = "Innovation Test Business"; + const zipCode = 12345; + + return axios + .post(url, { + zipCode: zipCode, + businessName: name, + licenseType: licenseType, + }) + .then(() => { + return { + success: true, + data: { + message: ReasonPhrases.OK, + }, + } as HealthCheckMetadata; + }) + .catch((error: AxiosError) => { + console.dir({ error }); + logWriter.LogError(`License Status Search - Id:${logId} - Error:`, error); + if (error.response) { + return { + success: false, + error: { + serverResponseBody: error.message, + serverResponseCode: error.response.status, + message: ReasonPhrases.BAD_GATEWAY, + timeout: false, + }, + } as HealthCheckMetadata; + } + return { + success: false, + error: { + message: ReasonPhrases.GATEWAY_TIMEOUT, + timeout: true, + }, + } as HealthCheckMetadata; + }); + }; + return { search, + health, }; }; diff --git a/api/src/client/WebserviceLicenseStatusProcessorClient.test.ts b/api/src/client/WebserviceLicenseStatusProcessorClient.test.ts index 66f91fafe0..2398ff132a 100644 --- a/api/src/client/WebserviceLicenseStatusProcessorClient.test.ts +++ b/api/src/client/WebserviceLicenseStatusProcessorClient.test.ts @@ -23,6 +23,7 @@ describe("WebserviceLicenseStatusProcessorClient", () => { beforeEach(() => { stubLicenseStatusClient = { search: jest.fn(), + health: jest.fn(), }; searchLicenseStatus = WebserviceLicenseStatusProcessorClient(stubLicenseStatusClient); diff --git a/api/src/client/dynamics/elevator-safety/DynamicsElevatorSafetyHealthCheckClient.test.ts b/api/src/client/dynamics/elevator-safety/DynamicsElevatorSafetyHealthCheckClient.test.ts new file mode 100644 index 0000000000..8ed8a18ac7 --- /dev/null +++ b/api/src/client/dynamics/elevator-safety/DynamicsElevatorSafetyHealthCheckClient.test.ts @@ -0,0 +1,57 @@ +import { DynamicsElevatorSafetyHealthCheckClient } from "@client/dynamics/elevator-safety/DynamicsElevatorSafetyHealthCheckClient"; +import { AccessTokenClient } from "@client/dynamics/types"; +import { HealthCheckMethod } from "@domain/types"; +import { LogWriter, LogWriterType } from "@libs/logWriter"; +import axios from "axios"; + +jest.mock("axios"); +jest.mock("winston"); +const mockAxios = axios as jest.Mocked; + +describe("DynamicsElevatorSafetyHealthCheckClient", () => { + let client: HealthCheckMethod; + let logger: LogWriterType; + let stubAccessTokenClient: jest.Mocked; + + const ORG_URL = "www.test-org-url.com"; + + beforeEach(() => { + logger = LogWriter("NavigatorWebService", "ApiLogs", "us-test-1"); + stubAccessTokenClient = { + getAccessToken: jest.fn(), + }; + client = DynamicsElevatorSafetyHealthCheckClient(logger, { + accessTokenClient: stubAccessTokenClient, + orgUrl: ORG_URL, + }); + }); + + it("returns a passing health check if data can be retrieved sucessfully", async () => { + mockAxios.get.mockResolvedValue({}); + expect(await client()).toEqual({ success: true, data: { message: "OK" } }); + }); + + it("returns a failing health check if unexpected data is retrieved", async () => { + mockAxios.get.mockRejectedValue({ response: { status: 404 }, message: "" }); + expect(await client()).toEqual({ + success: false, + error: { + message: "Bad Gateway", + serverResponseBody: "", + serverResponseCode: 404, + timeout: false, + }, + }); + }); + + it("returns a failing health check if axios request times out", async () => { + mockAxios.get.mockRejectedValue({}); + expect(await client()).toEqual({ + success: false, + error: { + message: "Gateway Timeout", + timeout: true, + }, + }); + }); +}); diff --git a/api/src/client/dynamics/elevator-safety/DynamicsElevatorSafetyHealthCheckClient.ts b/api/src/client/dynamics/elevator-safety/DynamicsElevatorSafetyHealthCheckClient.ts new file mode 100644 index 0000000000..6880adbe12 --- /dev/null +++ b/api/src/client/dynamics/elevator-safety/DynamicsElevatorSafetyHealthCheckClient.ts @@ -0,0 +1,55 @@ +import type { AccessTokenClient } from "@client/dynamics/types"; +import type { HealthCheckMetadata, HealthCheckMethod } from "@domain/types"; +import type { LogWriterType } from "@libs/logWriter"; +import axios, { type AxiosError } from "axios"; +import { ReasonPhrases } from "http-status-codes"; + +type Config = { + accessTokenClient: AccessTokenClient; + orgUrl: string; +}; + +export const DynamicsElevatorSafetyHealthCheckClient = ( + logWriter: LogWriterType, + config: Config +): HealthCheckMethod => { + return async (): Promise => { + const logId = logWriter.GetId(); + const accessToken = await config.accessTokenClient.getAccessToken(); + return axios + .get(`${config.orgUrl}/api/data/v9.2/ultra_elsainspections?$top=1`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + .then(() => { + return { + success: true, + data: { + message: ReasonPhrases.OK, + }, + } as HealthCheckMetadata; + }) + .catch((error: AxiosError) => { + logWriter.LogError(`Dynamics Elevator Safety Health Check Failed - Id:${logId} - Error:`, error); + if (error.response) { + return { + success: false, + error: { + serverResponseBody: error.message, + serverResponseCode: error.response.status, + message: ReasonPhrases.BAD_GATEWAY, + timeout: false, + }, + } as HealthCheckMetadata; + } + return { + success: false, + error: { + message: ReasonPhrases.GATEWAY_TIMEOUT, + timeout: true, + }, + } as HealthCheckMetadata; + }); + }; +}; diff --git a/api/src/client/dynamics/fire-safety/DynamicsFireSafetyHealthCheckClient.test.ts b/api/src/client/dynamics/fire-safety/DynamicsFireSafetyHealthCheckClient.test.ts new file mode 100644 index 0000000000..e0d9c590c7 --- /dev/null +++ b/api/src/client/dynamics/fire-safety/DynamicsFireSafetyHealthCheckClient.test.ts @@ -0,0 +1,57 @@ +import { DynamicsFireSafetyHealthCheckClient } from "@client/dynamics/fire-safety/DynamicsFireSafetyHealthCheckClient"; +import { AccessTokenClient } from "@client/dynamics/types"; +import { HealthCheckMethod } from "@domain/types"; +import { LogWriter, LogWriterType } from "@libs/logWriter"; +import axios from "axios"; + +jest.mock("axios"); +jest.mock("winston"); +const mockAxios = axios as jest.Mocked; + +describe("DynamicsFireSafetyHealthCheckClient", () => { + let client: HealthCheckMethod; + let logger: LogWriterType; + let stubAccessTokenClient: jest.Mocked; + + const ORG_URL = "www.test-org-url.com"; + + beforeEach(() => { + logger = LogWriter("NavigatorWebService", "ApiLogs", "us-test-1"); + stubAccessTokenClient = { + getAccessToken: jest.fn(), + }; + client = DynamicsFireSafetyHealthCheckClient(logger, { + accessTokenClient: stubAccessTokenClient, + orgUrl: ORG_URL, + }); + }); + + it("returns a passing health check if data can be retrieved sucessfully", async () => { + mockAxios.get.mockResolvedValue({}); + expect(await client()).toEqual({ success: true, data: { message: "OK" } }); + }); + + it("returns a failing health check if unexpected data is retrieved", async () => { + mockAxios.get.mockRejectedValue({ response: { status: 404 }, message: "" }); + expect(await client()).toEqual({ + success: false, + error: { + message: "Bad Gateway", + serverResponseBody: "", + serverResponseCode: 404, + timeout: false, + }, + }); + }); + + it("returns a failing health check if axios request times out", async () => { + mockAxios.get.mockRejectedValue({}); + expect(await client()).toEqual({ + success: false, + error: { + message: "Gateway Timeout", + timeout: true, + }, + }); + }); +}); diff --git a/api/src/client/dynamics/fire-safety/DynamicsFireSafetyHealthCheckClient.ts b/api/src/client/dynamics/fire-safety/DynamicsFireSafetyHealthCheckClient.ts new file mode 100644 index 0000000000..55a19ffebc --- /dev/null +++ b/api/src/client/dynamics/fire-safety/DynamicsFireSafetyHealthCheckClient.ts @@ -0,0 +1,55 @@ +import type { AccessTokenClient } from "@client/dynamics/types"; +import type { HealthCheckMetadata, HealthCheckMethod } from "@domain/types"; +import type { LogWriterType } from "@libs/logWriter"; +import axios, { type AxiosError } from "axios"; +import { ReasonPhrases } from "http-status-codes"; + +type Config = { + accessTokenClient: AccessTokenClient; + orgUrl: string; +}; + +export const DynamicsFireSafetyHealthCheckClient = ( + logWriter: LogWriterType, + config: Config +): HealthCheckMethod => { + return async (): Promise => { + const logId = logWriter.GetId(); + const accessToken = await config.accessTokenClient.getAccessToken(); + return axios + .get(`${config.orgUrl}/api/data/v9.2/ultra_fireinspections?$top=1`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + .then(() => { + return { + success: true, + data: { + message: ReasonPhrases.OK, + }, + } as HealthCheckMetadata; + }) + .catch((error: AxiosError) => { + logWriter.LogError(`Dynamics Fire Safety Health Check Failed - Id:${logId} - Error:`, error); + if (error.response) { + return { + success: false, + error: { + serverResponseBody: error.message, + serverResponseCode: error.response.status, + message: ReasonPhrases.BAD_GATEWAY, + timeout: false, + }, + } as HealthCheckMetadata; + } + return { + success: false, + error: { + message: ReasonPhrases.GATEWAY_TIMEOUT, + timeout: true, + }, + } as HealthCheckMetadata; + }); + }; +}; diff --git a/api/src/client/dynamics/fire-safety/DynamicsFireSafetyInspectionClient.ts b/api/src/client/dynamics/fire-safety/DynamicsFireSafetyInspectionClient.ts index 3883123148..381c969f41 100644 --- a/api/src/client/dynamics/fire-safety/DynamicsFireSafetyInspectionClient.ts +++ b/api/src/client/dynamics/fire-safety/DynamicsFireSafetyInspectionClient.ts @@ -1,10 +1,7 @@ -import { FireSafetyInspection } from "@client/dynamics/fire-safety/types"; +import { FireSafetyInspection, FireSafetyInspectionClient } from "@client/dynamics/fire-safety/types"; import { LogWriterType } from "@libs/logWriter"; import axios, { AxiosError } from "axios"; -export interface FireSafetyInspectionClient { - getFireSafetyInspections: (accessToken: string, address: string) => Promise; -} export const DynamicsFireSafetyInspectionClient = ( logWriter: LogWriterType, orgUrl: string diff --git a/api/src/client/dynamics/housing/DynamicsHousingHealthCheckClient.test.ts b/api/src/client/dynamics/housing/DynamicsHousingHealthCheckClient.test.ts new file mode 100644 index 0000000000..a15fc39c8a --- /dev/null +++ b/api/src/client/dynamics/housing/DynamicsHousingHealthCheckClient.test.ts @@ -0,0 +1,57 @@ +import { DynamicsHousingHealthCheckClient } from "@client/dynamics/housing/DynamicsHousingHealthCheckClient"; +import { AccessTokenClient } from "@client/dynamics/types"; +import { HealthCheckMethod } from "@domain/types"; +import { LogWriter, LogWriterType } from "@libs/logWriter"; +import axios from "axios"; + +jest.mock("axios"); +jest.mock("winston"); +const mockAxios = axios as jest.Mocked; + +describe("DynamicsHousingHealthCheckClient", () => { + let client: HealthCheckMethod; + let logger: LogWriterType; + let stubAccessTokenClient: jest.Mocked; + + const ORG_URL = "www.test-org-url.com"; + + beforeEach(() => { + logger = LogWriter("NavigatorWebService", "ApiLogs", "us-test-1"); + stubAccessTokenClient = { + getAccessToken: jest.fn(), + }; + client = DynamicsHousingHealthCheckClient(logger, { + accessTokenClient: stubAccessTokenClient, + orgUrl: ORG_URL, + }); + }); + + it("returns a passing health check if data can be retrieved sucessfully", async () => { + mockAxios.get.mockResolvedValue({}); + expect(await client()).toEqual({ success: true, data: { message: "OK" } }); + }); + + it("returns a failing health check if unexpected data is retrieved", async () => { + mockAxios.get.mockRejectedValue({ response: { status: 404 }, message: "" }); + expect(await client()).toEqual({ + success: false, + error: { + message: "Bad Gateway", + serverResponseBody: "", + serverResponseCode: 404, + timeout: false, + }, + }); + }); + + it("returns a failing health check if axios request times out", async () => { + mockAxios.get.mockRejectedValue({}); + expect(await client()).toEqual({ + success: false, + error: { + message: "Gateway Timeout", + timeout: true, + }, + }); + }); +}); diff --git a/api/src/client/dynamics/housing/DynamicsHousingHealthCheckClient.ts b/api/src/client/dynamics/housing/DynamicsHousingHealthCheckClient.ts new file mode 100644 index 0000000000..ec0ae7e1f9 --- /dev/null +++ b/api/src/client/dynamics/housing/DynamicsHousingHealthCheckClient.ts @@ -0,0 +1,55 @@ +import type { AccessTokenClient } from "@client/dynamics/types"; +import type { HealthCheckMetadata, HealthCheckMethod } from "@domain/types"; +import type { LogWriterType } from "@libs/logWriter"; +import axios, { type AxiosError } from "axios"; +import { ReasonPhrases } from "http-status-codes"; + +type Config = { + accessTokenClient: AccessTokenClient; + orgUrl: string; +}; + +export const DynamicsHousingHealthCheckClient = ( + logWriter: LogWriterType, + config: Config +): HealthCheckMethod => { + return async (): Promise => { + const logId = logWriter.GetId(); + const accessToken = await config.accessTokenClient.getAccessToken(); + return axios + .get(`${config.orgUrl}/api/data/v9.2/ultra_propertyinterests?$top=1`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + .then(() => { + return { + success: true, + data: { + message: ReasonPhrases.OK, + }, + } as HealthCheckMetadata; + }) + .catch((error: AxiosError) => { + logWriter.LogError(`Dynamics Housing Health Check Failed - Id:${logId} - Error:`, error); + if (error.response) { + return { + success: false, + error: { + serverResponseBody: error.message, + serverResponseCode: error.response.status, + message: ReasonPhrases.BAD_GATEWAY, + timeout: false, + }, + } as HealthCheckMetadata; + } + return { + success: false, + error: { + message: ReasonPhrases.GATEWAY_TIMEOUT, + timeout: true, + }, + } as HealthCheckMetadata; + }); + }; +}; diff --git a/api/src/client/dynamics/license-status/DynamicsLicenseHealthCheckClient.test.ts b/api/src/client/dynamics/license-status/DynamicsLicenseHealthCheckClient.test.ts new file mode 100644 index 0000000000..11127357ce --- /dev/null +++ b/api/src/client/dynamics/license-status/DynamicsLicenseHealthCheckClient.test.ts @@ -0,0 +1,57 @@ +import { DynamicsLicenseHealthCheckClient } from "@client/dynamics/license-status/DynamicsLicenseHealthCheckClient"; +import { AccessTokenClient } from "@client/dynamics/types"; +import { HealthCheckMethod } from "@domain/types"; +import { LogWriter, LogWriterType } from "@libs/logWriter"; +import axios from "axios"; + +jest.mock("axios"); +jest.mock("winston"); +const mockAxios = axios as jest.Mocked; + +describe("DynamicsLicenseHealthCheckClient", () => { + let client: HealthCheckMethod; + let logger: LogWriterType; + let stubAccessTokenClient: jest.Mocked; + + const ORG_URL = "www.test-org-url.com"; + + beforeEach(() => { + logger = LogWriter("NavigatorWebService", "ApiLogs", "us-test-1"); + stubAccessTokenClient = { + getAccessToken: jest.fn(), + }; + client = DynamicsLicenseHealthCheckClient(logger, { + accessTokenClient: stubAccessTokenClient, + orgUrl: ORG_URL, + }); + }); + + it("returns a passing health check if data can be retrieved sucessfully", async () => { + mockAxios.get.mockResolvedValue({}); + expect(await client()).toEqual({ success: true, data: { message: "OK" } }); + }); + + it("returns a failing health check if unexpected data is retrieved", async () => { + mockAxios.get.mockRejectedValue({ response: { status: 404 }, message: "" }); + expect(await client()).toEqual({ + success: false, + error: { + message: "Bad Gateway", + serverResponseBody: "", + serverResponseCode: 404, + timeout: false, + }, + }); + }); + + it("returns a failing health check if axios request times out", async () => { + mockAxios.get.mockRejectedValue({}); + expect(await client()).toEqual({ + success: false, + error: { + message: "Gateway Timeout", + timeout: true, + }, + }); + }); +}); diff --git a/api/src/client/dynamics/license-status/DynamicsLicenseHealthCheckClient.ts b/api/src/client/dynamics/license-status/DynamicsLicenseHealthCheckClient.ts new file mode 100644 index 0000000000..0620e72eb1 --- /dev/null +++ b/api/src/client/dynamics/license-status/DynamicsLicenseHealthCheckClient.ts @@ -0,0 +1,55 @@ +import type { AccessTokenClient } from "@client/dynamics/types"; +import type { HealthCheckMetadata, HealthCheckMethod } from "@domain/types"; +import type { LogWriterType } from "@libs/logWriter"; +import axios, { type AxiosError } from "axios"; +import { ReasonPhrases } from "http-status-codes"; + +type Config = { + accessTokenClient: AccessTokenClient; + orgUrl: string; +}; + +export const DynamicsLicenseHealthCheckClient = ( + logWriter: LogWriterType, + config: Config +): HealthCheckMethod => { + return async (): Promise => { + const logId = logWriter.GetId(); + const accessToken = await config.accessTokenClient.getAccessToken(); + return axios + .get(`${config.orgUrl}/api/data/v9.2/accounts?$top=1`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + .then(() => { + return { + success: true, + data: { + message: ReasonPhrases.OK, + }, + } as HealthCheckMetadata; + }) + .catch((error: AxiosError) => { + logWriter.LogError(`Dynamics License Status Health Check Failed - Id:${logId} - Error:`, error); + if (error.response) { + return { + success: false, + error: { + serverResponseBody: error.message, + serverResponseCode: error.response.status, + message: ReasonPhrases.BAD_GATEWAY, + timeout: false, + }, + } as HealthCheckMetadata; + } + return { + success: false, + error: { + message: ReasonPhrases.GATEWAY_TIMEOUT, + timeout: true, + }, + } as HealthCheckMetadata; + }); + }; +}; diff --git a/api/src/domain/types.ts b/api/src/domain/types.ts index c5e62c388c..59cbfa5b99 100644 --- a/api/src/domain/types.ts +++ b/api/src/domain/types.ts @@ -8,6 +8,7 @@ import { LicenseEntity, LicenseSearchNameAndAddress, LicenseStatusResult } from import { ProfileData } from "@shared/profileData"; import { TaxFilingCalendarEvent, TaxFilingLookupState, TaxFilingOnboardingState } from "@shared/taxFiling"; import { UserData } from "@shared/userData"; +import { ReasonPhrases } from "http-status-codes"; import * as https from "node:https"; export interface UserDataClient { @@ -66,6 +67,7 @@ export type AddToUserTesting = (userData: UserData) => Promise; export interface LicenseStatusClient { search: (name: string, zipCode: string, licenseType: string) => Promise; + health: HealthCheckMethod; } export interface UserTestingClient { @@ -118,6 +120,38 @@ export type ElevatorSafetyInspectionStatus = ( address: string ) => Promise; +export interface SuccessfulHealthCheckMetadata { + success: true; + data?: { + message: HealthCheckMessage; + }; +} + +export interface UnsuccessfulHealthCheckMetadata { + success: false; + error?: { + message: HealthCheckMessage; + timeout?: boolean; + serverResponseCode?: number; + serverResponseBody?: string; + }; +} + +export type HealthCheckMetadata = SuccessfulHealthCheckMetadata | UnsuccessfulHealthCheckMetadata; + +export type SuccessfulHealthCheckResponse = { + timestamp: number; +} & SuccessfulHealthCheckMetadata; + +export type UnsuccessfulHealthCheckResponse = { + timestamp: number; +} & UnsuccessfulHealthCheckMetadata; + +export type HealthCheckResponse = SuccessfulHealthCheckResponse | UnsuccessfulHealthCheckResponse; + +export type HealthCheckMethod = () => Promise; +export type HealthCheckMessage = ReasonPhrases; + export type UpdateOperatingPhase = (userData: UserData) => UserData; export type UpdateSidebarCards = (userData: UserData) => UserData; export type GetCertHttpsAgent = () => Promise; diff --git a/api/src/functions/express/app.ts b/api/src/functions/express/app.ts index fad562d0c5..21c26e830a 100644 --- a/api/src/functions/express/app.ts +++ b/api/src/functions/express/app.ts @@ -1,6 +1,7 @@ import { elevatorSafetyRouterFactory } from "@api/elevatorSafetyRouter"; import { fireSafetyRouterFactory } from "@api/fireSafetyRouter"; import { formationRouterFactory } from "@api/formationRouter"; +import { healthCheckRouterFactory } from "@api/healthCheckRouter"; import { housingRouterFactory } from "@api/housingRouter"; import { licenseStatusRouterFactory } from "@api/licenseStatusRouter"; import { selfRegRouterFactory } from "@api/selfRegRouter"; @@ -13,15 +14,19 @@ import { ApiBusinessNameClient } from "@client/ApiBusinessNameClient"; import { ApiFormationClient } from "@client/ApiFormationClient"; import { DynamicsAccessTokenClient } from "@client/dynamics/DynamicsAccessTokenClient"; import { DynamicsElevatorSafetyClient } from "@client/dynamics/elevator-safety/DynamicsElevatorSafetyClient"; +import { DynamicsElevatorSafetyHealthCheckClient } from "@client/dynamics/elevator-safety/DynamicsElevatorSafetyHealthCheckClient"; import { DynamicsElevatorSafetyInspectionClient } from "@client/dynamics/elevator-safety/DynamicsElevatorSafetyInspectionClient"; import { DynamicsFireSafetyClient } from "@client/dynamics/fire-safety/DynamicsFireSafetyClient"; +import { DynamicsFireSafetyHealthCheckClient } from "@client/dynamics/fire-safety/DynamicsFireSafetyHealthCheckClient"; import { DynamicsFireSafetyInspectionClient } from "@client/dynamics/fire-safety/DynamicsFireSafetyInspectionClient"; import { DynamicsHousingClient } from "@client/dynamics/housing/DynamicsHousingClient"; +import { DynamicsHousingHealthCheckClient } from "@client/dynamics/housing/DynamicsHousingHealthCheckClient"; import { DynamicsHousingPropertyInterestClient } from "@client/dynamics/housing/DynamicsHousingPropertyInterestClient"; import { DynamicsBusinessAddressClient } from "@client/dynamics/license-status/DynamicsBusinessAddressClient"; import { DynamicsBusinessIdsClient } from "@client/dynamics/license-status/DynamicsBusinessIdsClient"; import { DynamicsChecklistItemsClient } from "@client/dynamics/license-status/DynamicsChecklistItemsClient"; import { DynamicsLicenseApplicationIdClient } from "@client/dynamics/license-status/DynamicsLicenseApplicationIdClient"; +import { DynamicsLicenseHealthCheckClient } from "@client/dynamics/license-status/DynamicsLicenseHealthCheckClient"; import { DynamicsLicenseStatusClient } from "@client/dynamics/license-status/DynamicsLicenseStatusClient"; import { FakeSelfRegClientFactory } from "@client/fakeSelfRegClient"; import { GovDeliveryNewsletterClient } from "@client/GovDeliveryNewsletterClient"; @@ -30,6 +35,7 @@ import { WebserviceLicenseStatusClient } from "@client/WebserviceLicenseStatusCl import { WebserviceLicenseStatusProcessorClient } from "@client/WebserviceLicenseStatusProcessorClient"; import { dynamoDbTranslateConfig, DynamoUserDataClient } from "@db/DynamoUserDataClient"; import { searchLicenseStatusFactory } from "@domain/license-status/searchLicenseStatusFactory"; +import { HealthCheckMethod } from "@domain/types"; import { updateSidebarCards } from "@domain/updateSidebarCards"; import { addToUserTestingFactory } from "@domain/user-testing/addToUserTestingFactory"; import { timeStampBusinessSearch } from "@domain/user/timeStampBusinessSearch"; @@ -81,6 +87,7 @@ const logger = LogWriter(`NavigatorWebService/${STAGE}`, "ApiLogs"); const LICENSE_STATUS_BASE_URL = process.env.LICENSE_STATUS_BASE_URL || `http://${IS_DOCKER ? "wiremock" : "localhost"}:9000`; const webServiceLicenseStatusClient = WebserviceLicenseStatusClient(LICENSE_STATUS_BASE_URL, logger); +const webServiceLicenseStatusHealthCheckClient = webServiceLicenseStatusClient.health; const webserviceLicenseStatusProcessorClient = WebserviceLicenseStatusProcessorClient( webServiceLicenseStatusClient ); @@ -107,6 +114,11 @@ const dynamicsLicenseStatusClient = DynamicsLicenseStatusClient(logger, { checklistItemsClient: dynamicsCheckListItemsClient, }); +const dynamicsLicenseHealthCheckClient = DynamicsLicenseHealthCheckClient(logger, { + accessTokenClient: dynamicsLicenseStatusAccessTokenClient, + orgUrl: DYNAMICS_LICENSE_STATUS_URL, +}); + const DYNAMICS_FIRE_SAFETY_URL = process.env.DYNAMICS_FIRE_SAFETY_URL || ""; const dynamicsFireSafetyAccessTokenClient = DynamicsAccessTokenClient(logger, { @@ -161,6 +173,18 @@ const dynamicsElevatorSafetyClient = DynamicsElevatorSafetyClient(logger, { accessTokenClient: dynamicsElevatorSafetyAccessTokenClient, elevatorSafetyInspectionClient: dynamicsElevatorSafetyInspectionClient, }); +const dynamicsElevatorSafetyHealthCheckClient = DynamicsElevatorSafetyHealthCheckClient(logger, { + accessTokenClient: dynamicsElevatorSafetyAccessTokenClient, + orgUrl: DYNAMICS_ELEVATOR_SAFETY_URL, +}); +const dynamicsFireSafetyHealthCheckClient = DynamicsFireSafetyHealthCheckClient(logger, { + accessTokenClient: dynamicsFireSafetyAccessTokenClient, + orgUrl: DYNAMICS_FIRE_SAFETY_URL, +}); +const dynamicsHousingHealthCheckClient = DynamicsHousingHealthCheckClient(logger, { + accessTokenClient: dynamicsHousingAccessTokenClient, + orgUrl: DYNAMICS_HOUSING_URL, +}); const BUSINESS_NAME_BASE_URL = process.env.BUSINESS_NAME_BASE_URL || `http://${IS_DOCKER ? "wiremock" : "localhost"}:9000`; @@ -288,6 +312,19 @@ app.use( ); app.use("/api", taxDecryptionRouterFactory(AWSEncryptionDecryptionClient)); +app.use( + "/health", + healthCheckRouterFactory( + new Map([ + ["dynamics/elevator", dynamicsElevatorSafetyHealthCheckClient], + ["dynamics/fire-safety", dynamicsFireSafetyHealthCheckClient], + ["dynamics/housing", dynamicsHousingHealthCheckClient], + ["dynamics/license-status", dynamicsLicenseHealthCheckClient], + ["webservice/license-status", webServiceLicenseStatusHealthCheckClient], + ]) + ) +); + app.post("/api/mgmt/auth", (req, res) => { if (req.body.password === process.env.ADMIN_PASSWORD) { logger.LogInfo(`MgmtAuth - Id:${logger.GetId()} - MATCH`); @@ -302,8 +339,4 @@ app.post("/api/mgmt/auth", (req, res) => { } }); -app.get("/health", (_req, res) => { - res.status(500).send("Alive"); -}); - export const handler = serverless(app); diff --git a/api/src/functions/express/index.ts b/api/src/functions/express/index.ts index cf0ba24570..34a5596c2d 100644 --- a/api/src/functions/express/index.ts +++ b/api/src/functions/express/index.ts @@ -34,6 +34,13 @@ export default (cognitoArn: string, vpcConfig: FnType["vpc"]): FnType => { cors: true, }, }, + { + http: { + method: "ANY", + path: "/health/{proxy+}", + cors: false, + }, + }, { http: { method: "ANY", diff --git a/package.json b/package.json index 6815d56433..4d00c21b17 100644 --- a/package.json +++ b/package.json @@ -190,6 +190,7 @@ "eslint-plugin-storybook": "^0.6.15", "eslint-plugin-testing-library": "6.0.1", "eslint-plugin-unicorn": "50.0.1", + "http-status-codes": "^2.3.0", "husky": "8.0.3", "identity-obj-proxy": "3.0.0", "jest": "29.7.0", diff --git a/yarn.lock b/yarn.lock index 365eebd691..2fab1a8dd2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4057,6 +4057,7 @@ __metadata: eslint-plugin-unicorn: 50.0.1 express: 4.18.2 helmet: 7.1.0 + http-status-codes: ^2.3.0 jest: 29.7.0 jest-dynalite: 3.6.1 jest-junit: 16.0.0 @@ -4231,6 +4232,7 @@ __metadata: gray-matter: 4.0.3 helmet: 7.1.0 html2canvas: 1.4.1 + http-status-codes: ^2.3.0 husky: 8.0.3 identity-obj-proxy: 3.0.0 jest: 29.7.0 @@ -20441,6 +20443,13 @@ __metadata: languageName: node linkType: hard +"http-status-codes@npm:^2.3.0": + version: 2.3.0 + resolution: "http-status-codes@npm:2.3.0" + checksum: dae3b99e0155441b6df28e8265ff27c56a45f82c6092f736414233e9ccf063d5ea93c1e1279e8b499c4642e2538b37995c76b1640ed3f615d0e2883d3a1dcfd5 + languageName: node + linkType: hard + "http2-wrapper@npm:^1.0.0-beta.5.2": version: 1.0.3 resolution: "http2-wrapper@npm:1.0.3"