diff --git a/backend/package.json b/backend/package.json index fc7b1e2..0cb0b6b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -38,8 +38,8 @@ "@types/jest": "^29.5.14", "eslint": "^9.19.0", "globals": "^15.14.0", - "jiti": "^2.4.2", "jest": "^29.7.0", + "jiti": "^2.4.2", "prettier": "3.4.2", "ts-jest": "^29.2.5", "typescript-eslint": "^8.22.0" diff --git a/backend/src/services/statusCheckService.test.ts b/backend/src/services/statusCheckService.test.ts index b0541b7..32c4cf9 100644 --- a/backend/src/services/statusCheckService.test.ts +++ b/backend/src/services/statusCheckService.test.ts @@ -1,57 +1,97 @@ import { isPCOnline } from "./statusCheckService"; -import net from "net"; +import { exec, ExecException } from "child_process"; -jest.mock("net"); +jest.mock("child_process", () => ({ + exec: jest.fn(), +})); -describe("isPCOnline", () => { - const mockSocket = { - setTimeout: jest.fn(), - once: jest.fn(), - destroy: jest.fn(), - connect: jest.fn(), +jest.mock("./statusCheckService", () => { + const original = jest.requireActual("./statusCheckService"); + return { + ...original, + getLocalAddress: () => undefined, }; +}); + +const mockExec = exec as jest.MockedFunction; + +describe("isPCOnline (ARP)", () => { + const ip = "192.168.1.666"; + const iface = "wlan0"; beforeEach(() => { jest.clearAllMocks(); - (net.Socket as unknown as jest.Mock).mockImplementation(() => mockSocket); }); - it("should resolve true when connection is successful", async () => { - const ip = "192.168.1.1"; - const netInterface = "eth0"; - const port = 3389; - - const promise = isPCOnline(ip, netInterface, port); + /** + * Helper to simulate exec callback for both two-arg and three-arg invocations. + */ + function simulateExec( + err: ExecException | null, + stdout: string, + stderr: string + ) { + mockExec.mockImplementation( + ( + cmd: string, + optionsOrCb?: unknown, + cb?: unknown + ) => { + // Determine which argument is the callback + const callback = typeof optionsOrCb === "function" + ? optionsOrCb as (( + error: ExecException | null, + stdout: string, + stderr: string + ) => void) + : cb as (( + error: ExecException | null, + stdout: string, + stderr: string + ) => void); - mockSocket.once.mock.calls[0][1](); // Simulate 'connect' event + // Invoke callback with simulated results + callback(err, stdout, stderr); - const result = await promise; - expect(result).toBe(true); - }); - - it("should resolve false when connection times out", async () => { - const ip = "192.168.1.1"; - const netInterface = "eth0"; - const port = 3389; + // Return dummy ChildProcess + return { + pid: 1, + stdin: null, + stdout: null, + stderr: null, + stdio: [], + kill() {}, + send() { return false; }, + disconnect() {}, + unref() {}, + ref() {} + } as unknown as import("child_process").ChildProcess; + } + ); + } - const promise = isPCOnline(ip, netInterface, port); + it("should resolve true when arping replies", async () => { + simulateExec( + null, + "1 packets transmitted, 1 received, 0% packet loss\nbytes from 74:56:3c:e6:f9:c2 (\"192.168.1.666\"): index=0 time=4.593 ms", + "" + ); - mockSocket.once.mock.calls[1][1](); // Simulate 'timeout' event - - const result = await promise; - expect(result).toBe(false); + await expect(isPCOnline(ip, iface, 3000)).resolves.toBe(true); + expect(mockExec).toHaveBeenCalled(); }); - it("should resolve false when connection fails", async () => { - const ip = "192.168.1.1"; - const netInterface = "eth0"; - const port = 3389; + it("should resolve false when arping returns an error", async () => { + simulateExec(new Error("exec error"), "", "error output"); - const promise = isPCOnline(ip, netInterface, port); + await expect(isPCOnline(ip, iface, 3000)).resolves.toBe(false); + expect(mockExec).toHaveBeenCalled(); + }); - mockSocket.once.mock.calls[2][1](new Error("Connection error")); // Simulate 'error' event + it("should resolve false when no ARP reply in stdout", async () => { + simulateExec(null, "0 packets transmitted, 0 received, 100% packet loss", ""); - const result = await promise; - expect(result).toBe(false); + await expect(isPCOnline(ip, iface, 3000)).resolves.toBe(false); + expect(mockExec).toHaveBeenCalled(); }); }); diff --git a/backend/src/services/statusCheckService.ts b/backend/src/services/statusCheckService.ts index e69c557..f2d32e1 100644 --- a/backend/src/services/statusCheckService.ts +++ b/backend/src/services/statusCheckService.ts @@ -1,60 +1,64 @@ -import net from "net"; +import { exec } from "child_process"; import os from "os"; -import logger from "../utils/logger"; // Import the logger +import logger from "../utils/logger"; +/** + * Retrieves the IPv4 address of the specified network interface. + * @param netInterface - Name of the network interface (e.g., "wlan0"). + * @returns The IPv4 address, or undefined if not found. + */ function getLocalAddress(netInterface: string): string | undefined { const interfaces = os.networkInterfaces(); const iface = interfaces[netInterface]; - if (!iface) { logger.warn(`Interface ${netInterface} not found.`); return undefined; } - const addressInfo = iface.find( (detail) => detail.family === "IPv4" && !detail.internal ); return addressInfo?.address; } +/** + * Checks if a host is reachable via an ARP ping using the system 'arping' command. + * @param ip - The target IP address to check. + * @param netInterface - The network interface to use (e.g., "wlan0"). + * @param timeout - Timeout for the ARP request in milliseconds (default: 5000). + * @returns Promise that resolves to true if the host replies to ARP, false otherwise. + */ export function isPCOnline( ip: string, netInterface: string, - port = 3389, timeout = 5000 ): Promise { return new Promise((resolve) => { - const socket = new net.Socket(); - socket.setTimeout(timeout); - - socket.once("connect", () => { - logger.info(`Connection successful: ${ip}:${port}`); - socket.destroy(); - resolve(true); - }); - - socket.once("timeout", () => { - logger.warn(`Timeout reached for ${ip}:${port}`); - socket.destroy(); - resolve(false); - }); - - socket.once("error", (err) => { - logger.error(`Failed to connect to ${ip}:${port} - ${err.message}`); - socket.destroy(); - resolve(false); - }); - - const options: net.NetConnectOpts = { port, host: ip }; - - logger.info(`Checking status for ${ip}:${port} on ${netInterface}`); + const seconds = Math.ceil(timeout / 1000); + let cmd = `arping -c3 -w${seconds} -I ${netInterface}`; const localAddress = getLocalAddress(netInterface); if (localAddress) { - options.localAddress = localAddress; - logger.info(`Using local address: ${localAddress}`); + cmd += ` -s ${localAddress}`; + logger.info(`Using source address ${localAddress} for ARP`); } + cmd += ` ${ip}`; - socket.connect(options); + logger.info(`Executing ARP ping: ${cmd}`); + exec(cmd, (err, stdout, stderr) => { + const output = stdout + stderr; + // Match common reply patterns: + const replyRegex = /bytes from|Unicast reply|Received \d+ response/; + if (!err && replyRegex.test(output)) { + logger.info(`Host ${ip} is online (ARP reply detected)`); + resolve(true); + } else { + logger.warn( + `Host ${ip} is offline (no ARP reply or error): ${ + err?.message || output.trim() + }` + ); + resolve(false); + } + }); }); } diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts index b58eb1e..2de08e0 100644 --- a/backend/src/utils/logger.ts +++ b/backend/src/utils/logger.ts @@ -2,8 +2,13 @@ import winston from "winston"; import DailyRotateFile from "winston-daily-rotate-file"; import path from "path"; -// 🔍 Determine log level from environment -const logLevel = process.env.LOG_LEVEL || "info"; +// 🔍 Determine log level from environment, defaulting to 'info' +const env = process.env.NODE_ENV || 'development'; +const defaultLevel = process.env.LOG_LEVEL || 'info'; + +// 🔍 In test environment, only log errors to reduce noise +const consoleLevel = env === 'test' ? 'error' : defaultLevel; + const logDirectory = path.join(__dirname, "../../logs"); // 🔥 Log file rotation setup @@ -13,12 +18,12 @@ const dailyRotateTransport = new DailyRotateFile({ zippedArchive: true, maxSize: "10m", maxFiles: "14d", - level: logLevel, // ✅ File logs respect `LOG_LEVEL` + level: defaultLevel, // File logs respect LOG_LEVEL }); // 🔥 Create Winston Logger const logger = winston.createLogger({ - level: logLevel, // ✅ This controls what gets logged globally + level: defaultLevel, // Controls global logging threshold format: winston.format.combine( winston.format.timestamp(), winston.format.printf(({ timestamp, level, message }) => { @@ -27,13 +32,14 @@ const logger = winston.createLogger({ ), transports: [ new winston.transports.Console({ - level: logLevel, // ✅ Console logs also respect `LOG_LEVEL` + level: consoleLevel, // Console respects test override + silent: env === 'test', // Silence console entirely in tests }), - dailyRotateTransport, // ✅ File logs respect `LOG_LEVEL` + dailyRotateTransport, ], }); -// 🔍 Announce current log level -logger.info(`Logger initialized with level: ${logLevel.toUpperCase()}`); +// 🔍 Announce current log level (will only show outside test env) +logger.info(`Logger initialized with level: ${defaultLevel.toUpperCase()} (env=${env})`); export default logger; diff --git a/backend/tsconfig.json b/backend/tsconfig.json index d4ea9c0..e4ee49c 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -1,16 +1,16 @@ { "compilerOptions": { + "esModuleInterop": true, + "module": "commonjs", "outDir": "./build", // Compiled files go here "rootDir": "./src", // Source files are located here "strict": true, - "module": "commonjs", "target": "es6", - "esModuleInterop": true + "typeRoots": ["node_modules/@types"], + "types": ["node", "jest"] }, "include": ["src/**/*"], "exclude": [ - "node_modules", - "**/*.test.ts", - "**/*.spec.ts" + "node_modules" ] }