diff --git a/server/src/services/infrastructure/NetworkService.ts b/server/src/services/infrastructure/NetworkService.ts index 05dfcdbce..3b9fd4a32 100644 --- a/server/src/services/infrastructure/NetworkService.ts +++ b/server/src/services/infrastructure/NetworkService.ts @@ -71,9 +71,23 @@ class NetworkService implements INetworkService { private buildStatusResponse = ( monitor: IMonitor, response: Response | null, - certificateExpiry: Date | null, - error: any | null + certificateExpiryOrError?: Date | null | any, + maybeError?: any | null ): StatusResponse => { + // Support both call styles: + // - buildStatusResponse(monitor, response, error) + // - buildStatusResponse(monitor, response, certificateExpiry, error) + let certificateExpiry: Date | null = null; + let error: any | null = null; + if ( + certificateExpiryOrError instanceof Date || + certificateExpiryOrError === null + ) { + certificateExpiry = certificateExpiryOrError ?? null; + error = maybeError ?? null; + } else if (typeof certificateExpiryOrError !== "undefined") { + error = certificateExpiryOrError; + } if (error) { const statusResponse: StatusResponse = { monitorId: monitor._id.toString(), @@ -81,14 +95,14 @@ class NetworkService implements INetworkService { type: monitor.type, status: "down" as MonitorStatus, code: this.NETWORK_ERROR, - message: error.message || "Network error", + message: (error && error.message) || "Network error", responseTime: 0, timings: { phases: {} } as GotTimings, certificateExpiry, }; if (error instanceof HTTPError) { statusResponse.code = error?.response?.statusCode || this.NETWORK_ERROR; - statusResponse.message = error.message || "HTTP error"; + statusResponse.message = (error && error.message) || "HTTP error"; statusResponse.responseTime = error.timings?.phases?.total || 0; statusResponse.timings = error.timings; } @@ -119,9 +133,14 @@ class NetworkService implements INetworkService { let certificateExpiry: Date | null = null; try { - const req: any = this.client(url, { - https: { rejectUnauthorized: monitor.rejectUnauthorized }, - }); + const isHttps = url.startsWith("https://"); + const haveRejectFlag = + isHttps && typeof (monitor as any).rejectUnauthorized !== "undefined"; + const req: any = haveRejectFlag + ? this.client(url, { + https: { rejectUnauthorized: (monitor as any).rejectUnauthorized }, + }) + : this.client(url); req.on("request", (nodeReq: ClientRequest) => { nodeReq.on("socket", (socket: TLSSocket) => { const capture = () => { diff --git a/server/src/services/infrastructure/StatusService.ts b/server/src/services/infrastructure/StatusService.ts index f42e73c7e..b9759cf87 100644 --- a/server/src/services/infrastructure/StatusService.ts +++ b/server/src/services/infrastructure/StatusService.ts @@ -136,7 +136,9 @@ class StatusService implements IStatusService { stats.maxResponseTime, statusResponse.responseTime ); - stats.certificateExpiry = statusResponse.certificateExpiry || null; + if (statusResponse.certificateExpiry !== null) { + stats.certificateExpiry = statusResponse.certificateExpiry; + } return await stats.save(); }; diff --git a/server/tests/services/infrastructure/NetworkService.test.js b/server/tests/services/infrastructure/NetworkService.test.js index 51c52819b..505481815 100644 --- a/server/tests/services/infrastructure/NetworkService.test.js +++ b/server/tests/services/infrastructure/NetworkService.test.js @@ -77,7 +77,19 @@ const createService = (clientMock) => { const gotModule = getGotModule(); const extendMock = gotModule.default.extend; const requestMock = clientMock ?? jest.fn(); - extendMock.mockReturnValue(requestMock); + // Return a function that produces a promise-like with an `.on` method + extendMock.mockImplementation(() => { + return (...args) => { + const p = requestMock(...args); + // ensure we always have a thenable + const promise = p && typeof p.then === "function" ? p : Promise.resolve(p); + // preserve custom `.on` from the mock if provided; otherwise add a no-op + if (typeof (promise).on !== "function") { + (promise).on = jest.fn(() => promise); + } + return promise; + }; + }); const service = new NetworkService(gotModule.default); return { service, requestMock, extendMock, gotModule }; }; @@ -210,7 +222,7 @@ describe("NetworkService.requestHttp", () => { expect(extendMock).toHaveBeenCalledTimes(1); expect(requestMock).not.toHaveBeenCalled(); }); - it("returns fallback response when request throws", async () => { + it("returns fallback response when request throws", async () => { const error = new Error("connection reset"); const requestMock = jest.fn().mockRejectedValue(error); const { service, extendMock } = createService(requestMock); @@ -223,7 +235,157 @@ describe("NetworkService.requestHttp", () => { expect(result.message).toBe("connection reset"); expect(result.responseTime).toBe(0); expect(result.timings).toEqual({ phases: {} }); + }); + + it("captures certificate expiry from reused TLS socket (immediate capture)", async () => { + const timings = { phases: { total: 50 } }; + const response = { statusCode: 200, statusMessage: "OK", ok: true, timings }; + const validTo = "2030-01-01T00:00:00Z"; + + const fakeSocket = { + getPeerCertificate: jest.fn(() => ({ valid_to: validTo })), + once: jest.fn((event, cb) => { + if (event === "secureConnect") { + // No-op to simulate reused socket (secureConnect not fired) + } + }), + }; + + const fakeNodeReq = { + on: jest.fn((event, cb) => { + if (event === "socket") cb(fakeSocket); + return fakeNodeReq; + }), + }; + + const requestMock = jest.fn((..._args) => { + const p = Promise.resolve(response); + p.on = jest.fn((event, cb) => { + if (event === "request") cb(fakeNodeReq); + return p; + }); + return p; }); + + const { service, extendMock } = createService(requestMock); + const monitor = buildMonitor({ url: "https://api.example.com" }); + const result = await service.requestHttp(monitor); + expect(extendMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith("https://api.example.com"); + expect(result.status).toBe("up"); + expect(result.certificateExpiry?.toISOString()).toBe(new Date(validTo).toISOString()); + }); + + it("captures certificate expiry after secureConnect (fresh TLS handshake)", async () => { + const timings = { phases: { total: 50 } }; + const response = { statusCode: 200, statusMessage: "OK", ok: true, timings }; + const validTo = "2031-02-03T04:05:06Z"; + + let secureCb; + // First call returns empty object (before handshake); after secureConnect returns populated cert + const getPeerCertificate = jest + .fn() + .mockImplementationOnce(() => ({})) + .mockImplementation(() => ({ valid_to: validTo })); + + const fakeSocket = { + getPeerCertificate, + once: jest.fn((event, cb) => { + if (event === "secureConnect") { + secureCb = cb; + // simulate handshake completion + cb(); + } + }), + }; + + const fakeNodeReq = { + on: jest.fn((event, cb) => { + if (event === "socket") cb(fakeSocket); + return fakeNodeReq; + }), + }; + + const requestMock = jest.fn((..._args) => { + const p = Promise.resolve(response); + p.on = jest.fn((event, cb) => { + if (event === "request") cb(fakeNodeReq); + return p; + }); + return p; + }); + + const { service } = createService(requestMock); + const monitor = buildMonitor({ url: "https://secure.example.com" }); + const result = await service.requestHttp(monitor); + expect(result.status).toBe("up"); + expect(result.certificateExpiry?.toISOString()).toBe(new Date(validTo).toISOString()); + expect(getPeerCertificate).toHaveBeenCalled(); + }); + + it("skips TLS options for http and leaves certificateExpiry null", async () => { + const timings = { phases: { total: 33 } }; + const response = { statusCode: 200, statusMessage: "OK", ok: true, timings }; + + const fakeSocket = { + getPeerCertificate: jest.fn(() => undefined), + once: jest.fn(() => {}), + }; + const fakeNodeReq = { + on: jest.fn((event, cb) => { + if (event === "socket") cb(fakeSocket); + return fakeNodeReq; + }), + }; + const requestMock = jest.fn((..._args) => { + const p = Promise.resolve(response); + p.on = jest.fn((event, cb) => { + if (event === "request") cb(fakeNodeReq); + return p; + }); + return p; + }); + const { service } = createService(requestMock); + const monitor = buildMonitor({ url: "http://api.example.com" }); + const result = await service.requestHttp(monitor); + expect(requestMock).toHaveBeenCalledWith("http://api.example.com"); + expect(result.status).toBe("up"); + expect(result.certificateExpiry).toBeNull(); + }); + + it("passes https rejectUnauthorized=false per call when set on monitor", async () => { + const timings = { phases: { total: 40 } }; + const response = { statusCode: 200, statusMessage: "OK", ok: true, timings }; + const validTo = "2032-01-01T00:00:00Z"; + + const fakeSocket = { + getPeerCertificate: jest.fn(() => ({ valid_to: validTo })), + once: jest.fn(() => {}), + }; + const fakeNodeReq = { + on: jest.fn((event, cb) => { + if (event === "socket") cb(fakeSocket); + return fakeNodeReq; + }), + }; + const requestMock = jest.fn((..._args) => { + const p = Promise.resolve(response); + p.on = jest.fn((event, cb) => { + if (event === "request") cb(fakeNodeReq); + return p; + }); + return p; + }); + + const { service } = createService(requestMock); + const monitor = buildMonitor({ url: "https://secure.example.com", rejectUnauthorized: false }); + const result = await service.requestHttp(monitor); + expect(requestMock).toHaveBeenCalledWith("https://secure.example.com", { + https: { rejectUnauthorized: false }, + }); + expect(result.status).toBe("up"); + expect(result.certificateExpiry?.toISOString()).toBe(new Date(validTo).toISOString()); + }); }); // requestInfrastructure block covers success path and every guard/exception branch so all lines execute. describe("NetworkService.requestInfrastructure", () => { @@ -597,9 +759,26 @@ describe("NetworkService.requestStatus", () => { await service.requestStatus(monitor); expect(pingSpy).toHaveBeenCalledTimes(1); }); - it("throws for unsupported monitor types", async () => { - const { service } = createService(); - const monitor = buildMonitor({ type: "websocket" }); - await expect(service.requestStatus(monitor)).rejects.toThrow("Not implemented"); + it("throws for unsupported monitor types", async () => { + const { service } = createService(); + const monitor = buildMonitor({ type: "websocket" }); + await expect(service.requestStatus(monitor)).rejects.toThrow("Not implemented"); + }); + + it("routes port monitors through requestPort", async () => { + const { service } = createService(); + const portSpy = jest.spyOn(service, "requestPort").mockResolvedValueOnce({ + monitorId: "6", + teamId: "t", + type: "port", + status: "up", + message: "Port is open", + responseTime: 10, + timings: { phases: {} }, }); + const monitor = buildMonitor({ type: "port", url: "localhost", port: 80 }); + const result = await service.requestStatus(monitor); + expect(portSpy).toHaveBeenCalledTimes(1); + expect(result.status).toBe("up"); + }); });