Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
114 changes: 77 additions & 37 deletions backend/src/services/statusCheckService.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof exec>;

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();
});
});
68 changes: 36 additions & 32 deletions backend/src/services/statusCheckService.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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);
}
});
});
}
22 changes: 14 additions & 8 deletions backend/src/utils/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }) => {
Expand All @@ -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;
10 changes: 5 additions & 5 deletions backend/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"
]
}