Add Jest unit test infrastructure and initial test suite
Sets up Jest + ts-jest for the backend and adds 47 tests across 5 suites covering crypto utilities, OUI vendor lookup, auth middleware, AlertService dispatch/cooldown logic, and DeviceCollector uptime parsing.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/db/migrations/**',
|
||||
'!src/index.ts',
|
||||
],
|
||||
coverageReporters: ['text', 'lcov'],
|
||||
};
|
||||
Generated
+3619
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,9 @@
|
||||
"start": "node dist/index.js",
|
||||
"dev": "nodemon --watch src --ext ts --exec ts-node src/index.ts",
|
||||
"lint": "eslint src",
|
||||
"migrate": "ts-node src/db/migrate.ts"
|
||||
"migrate": "ts-node src/db/migrate.ts",
|
||||
"test": "jest",
|
||||
"test:coverage": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@influxdata/influxdb-client": "^1.33.2",
|
||||
@@ -35,6 +37,7 @@
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.10.5",
|
||||
@@ -43,7 +46,9 @@
|
||||
"@types/ssh2": "^1.11.17",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-security": "^4.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"ts-jest": "^29.4.9",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript-eslint": "^8.58.2"
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { signToken, verifyToken, requireAuth, requireAdmin, requireWrite, AuthPayload } from '../auth';
|
||||
|
||||
const TEST_PAYLOAD: AuthPayload = { userId: 1, username: 'alice', role: 'admin' };
|
||||
|
||||
// Minimal Express mock helpers
|
||||
function mockReq(authHeader?: string): Request {
|
||||
return { headers: { authorization: authHeader } } as unknown as Request;
|
||||
}
|
||||
|
||||
function mockRes() {
|
||||
const res = {} as Response;
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── signToken / verifyToken ─────────────────────────────────────────────────
|
||||
|
||||
describe('signToken / verifyToken', () => {
|
||||
it('round-trips a payload', () => {
|
||||
const token = signToken(TEST_PAYLOAD);
|
||||
const decoded = verifyToken(token);
|
||||
expect(decoded.userId).toBe(TEST_PAYLOAD.userId);
|
||||
expect(decoded.username).toBe(TEST_PAYLOAD.username);
|
||||
expect(decoded.role).toBe(TEST_PAYLOAD.role);
|
||||
});
|
||||
|
||||
it('throws on an expired token', () => {
|
||||
const expired = jwt.sign(TEST_PAYLOAD, 'changeme', { expiresIn: '-1s' });
|
||||
expect(() => verifyToken(expired)).toThrow();
|
||||
});
|
||||
|
||||
it('throws on a tampered token', () => {
|
||||
const token = signToken(TEST_PAYLOAD);
|
||||
const tampered = token.slice(0, -4) + 'xxxx';
|
||||
expect(() => verifyToken(tampered)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireAuth ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('requireAuth', () => {
|
||||
it('returns 401 when Authorization header is missing', () => {
|
||||
const req = mockReq();
|
||||
const res = mockRes();
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
requireAuth(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 for non-Bearer scheme', () => {
|
||||
const req = mockReq('Basic dXNlcjpwYXNz');
|
||||
const res = mockRes();
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
requireAuth(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 for an invalid token', () => {
|
||||
const req = mockReq('Bearer not.a.valid.jwt');
|
||||
const res = mockRes();
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
requireAuth(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls next and sets req.user for a valid token', () => {
|
||||
const token = signToken(TEST_PAYLOAD);
|
||||
const req = mockReq(`Bearer ${token}`);
|
||||
const res = mockRes();
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
requireAuth(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.user?.userId).toBe(TEST_PAYLOAD.userId);
|
||||
expect(req.user?.role).toBe('admin');
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireAdmin ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('requireAdmin', () => {
|
||||
it('returns 403 when req.user is not set', () => {
|
||||
const req = mockReq();
|
||||
const res = mockRes();
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
requireAdmin(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 403 for a non-admin role', () => {
|
||||
const req = mockReq();
|
||||
(req as Request & { user: AuthPayload }).user = { userId: 2, username: 'bob', role: 'operator' };
|
||||
const res = mockRes();
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
requireAdmin(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('calls next for admin role', () => {
|
||||
const req = mockReq();
|
||||
(req as Request & { user: AuthPayload }).user = TEST_PAYLOAD;
|
||||
const res = mockRes();
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
requireAdmin(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireWrite ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('requireWrite', () => {
|
||||
it('returns 403 for viewer role', () => {
|
||||
const req = mockReq();
|
||||
(req as Request & { user: AuthPayload }).user = { userId: 3, username: 'carol', role: 'viewer' };
|
||||
const res = mockRes();
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
requireWrite(req, res, next);
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls next for admin role', () => {
|
||||
const req = mockReq();
|
||||
(req as Request & { user: AuthPayload }).user = TEST_PAYLOAD;
|
||||
const res = mockRes();
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
requireWrite(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls next for operator role', () => {
|
||||
const req = mockReq();
|
||||
(req as Request & { user: AuthPayload }).user = { userId: 4, username: 'dave', role: 'operator' };
|
||||
const res = mockRes();
|
||||
const next = jest.fn() as unknown as NextFunction;
|
||||
requireWrite(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
jest.mock('../../config/database');
|
||||
jest.mock('nodemailer');
|
||||
|
||||
import { AlertService } from '../AlertService';
|
||||
import { query } from '../../config/database';
|
||||
|
||||
const mockedQuery = jest.mocked(query);
|
||||
|
||||
// Minimal rule and channel fixtures
|
||||
const enabledRule = { event_type: 'device_offline', enabled: true, threshold: null, cooldown_min: 15 };
|
||||
const disabledRule = { event_type: 'device_online', enabled: false, threshold: null, cooldown_min: 15 };
|
||||
const slackChannel = { id: 1, name: 'slack-test', type: 'slack' as const, enabled: true, config: { webhook_url: 'https://hooks.example.com/test' } };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── dispatch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('dispatch', () => {
|
||||
it('does nothing when the rule is disabled', async () => {
|
||||
mockedQuery.mockResolvedValueOnce([disabledRule]);
|
||||
const service = new AlertService();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendSpy = jest.spyOn(service as any, 'sendToChannel').mockResolvedValue(undefined);
|
||||
await service.dispatch('device_online', 'came back', { cooldownKey: 'test-disabled' });
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls sendToChannel when the rule is enabled', async () => {
|
||||
mockedQuery
|
||||
.mockResolvedValueOnce([enabledRule])
|
||||
.mockResolvedValueOnce([slackChannel])
|
||||
.mockResolvedValueOnce([]);
|
||||
const service = new AlertService();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendSpy = jest.spyOn(service as any, 'sendToChannel').mockResolvedValue(undefined);
|
||||
await service.dispatch('device_offline', 'went down', { cooldownKey: 'test-enabled' });
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
expect(sendSpy).toHaveBeenCalledWith(slackChannel, 'device_offline', 'went down', expect.any(Object));
|
||||
});
|
||||
|
||||
it('does nothing when no channels are configured', async () => {
|
||||
mockedQuery
|
||||
.mockResolvedValueOnce([enabledRule])
|
||||
.mockResolvedValueOnce([]);
|
||||
const service = new AlertService();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendSpy = jest.spyOn(service as any, 'sendToChannel').mockResolvedValue(undefined);
|
||||
await service.dispatch('device_offline', 'msg', { cooldownKey: 'test-no-channels' });
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks a second dispatch during the cooldown window', async () => {
|
||||
jest.useFakeTimers();
|
||||
mockedQuery
|
||||
.mockResolvedValueOnce([enabledRule]) // rule for first dispatch
|
||||
.mockResolvedValueOnce([slackChannel]) // channels for first dispatch
|
||||
.mockResolvedValueOnce([]); // history insert
|
||||
const service = new AlertService();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendSpy = jest.spyOn(service as any, 'sendToChannel').mockResolvedValue(undefined);
|
||||
|
||||
// First dispatch sets the cooldown
|
||||
await service.dispatch('high_cpu', 'cpu spike', { cooldownKey: 'cooldown-test-key' });
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
sendSpy.mockClear();
|
||||
// Second dispatch immediately — cooldown not yet expired
|
||||
mockedQuery.mockResolvedValueOnce([enabledRule]);
|
||||
await service.dispatch('high_cpu', 'cpu spike again', { cooldownKey: 'cooldown-test-key' });
|
||||
expect(sendSpy).not.toHaveBeenCalled();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('allows dispatch again after the cooldown expires', async () => {
|
||||
jest.useFakeTimers();
|
||||
const shortCooldownRule = { ...enabledRule, cooldown_min: 1 };
|
||||
mockedQuery
|
||||
.mockResolvedValueOnce([shortCooldownRule])
|
||||
.mockResolvedValueOnce([slackChannel])
|
||||
.mockResolvedValueOnce([]);
|
||||
const service = new AlertService();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendSpy = jest.spyOn(service as any, 'sendToChannel').mockResolvedValue(undefined);
|
||||
|
||||
await service.dispatch('log_error', 'first', { cooldownKey: 'cooldown-expire-test' });
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance past 1-minute cooldown
|
||||
jest.advanceTimersByTime(61 * 1000);
|
||||
sendSpy.mockClear();
|
||||
|
||||
mockedQuery
|
||||
.mockResolvedValueOnce([shortCooldownRule])
|
||||
.mockResolvedValueOnce([slackChannel])
|
||||
.mockResolvedValueOnce([]);
|
||||
await service.dispatch('log_error', 'second', { cooldownKey: 'cooldown-expire-test' });
|
||||
expect(sendSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
// ── slackColor ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('slackColor', () => {
|
||||
const service = new AlertService();
|
||||
const slackColor = (et: string) => (service as never as Record<string, (s: string) => string>).slackColor(et);
|
||||
|
||||
it('returns "good" for device_online', () => {
|
||||
expect(slackColor('device_online')).toBe('good');
|
||||
});
|
||||
|
||||
it('returns "danger" for device_offline', () => {
|
||||
expect(slackColor('device_offline')).toBe('danger');
|
||||
});
|
||||
|
||||
it('returns "danger" for high_cpu', () => {
|
||||
expect(slackColor('high_cpu')).toBe('danger');
|
||||
});
|
||||
|
||||
it('returns "warning" for cert_expiry', () => {
|
||||
expect(slackColor('cert_expiry')).toBe('warning');
|
||||
});
|
||||
});
|
||||
|
||||
// ── discordColor ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('discordColor', () => {
|
||||
const service = new AlertService();
|
||||
const discordColor = (et: string) => (service as never as Record<string, (s: string) => number>).discordColor(et);
|
||||
|
||||
it('returns green (0x22c55e) for device_online', () => {
|
||||
expect(discordColor('device_online')).toBe(0x22c55e);
|
||||
});
|
||||
|
||||
it('returns amber (0xf59e0b) for log_warning', () => {
|
||||
expect(discordColor('log_warning')).toBe(0xf59e0b);
|
||||
});
|
||||
|
||||
it('returns amber (0xf59e0b) for cert_expiry', () => {
|
||||
expect(discordColor('cert_expiry')).toBe(0xf59e0b);
|
||||
});
|
||||
|
||||
it('returns red (0xef4444) for device_offline', () => {
|
||||
expect(discordColor('device_offline')).toBe(0xef4444);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Mock external deps before importing DeviceCollector
|
||||
jest.mock('../../../utils/crypto', () => ({ decrypt: (s: string) => s }));
|
||||
jest.mock('../RouterOSClient');
|
||||
jest.mock('../../../config/database');
|
||||
jest.mock('../../../config/influxdb');
|
||||
jest.mock('../../../utils/oui');
|
||||
jest.mock('../../../utils/serverArp');
|
||||
|
||||
import { DeviceCollector, DeviceRow } from '../DeviceCollector';
|
||||
|
||||
const testDevice: DeviceRow = {
|
||||
id: 1,
|
||||
name: 'test-router',
|
||||
ip_address: '192.168.1.1',
|
||||
api_port: 8728,
|
||||
api_username: 'admin',
|
||||
api_password_encrypted: 'plaintext-password',
|
||||
device_type: 'router',
|
||||
status: 'online',
|
||||
};
|
||||
|
||||
// ── parseUptime ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseUptime', () => {
|
||||
let collector: DeviceCollector;
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new DeviceCollector(testDevice);
|
||||
});
|
||||
|
||||
function parse(uptime: string): number {
|
||||
// Access private method for unit testing
|
||||
return (collector as unknown as Record<string, (s: string) => number>).parseUptime(uptime);
|
||||
}
|
||||
|
||||
it('parses a full uptime string (1w2d3h4m5s)', () => {
|
||||
// 1w=604800, 2d=172800, 3h=10800, 4m=240, 5s=5 → 788645
|
||||
expect(parse('1w2d3h4m5s')).toBe(788645);
|
||||
});
|
||||
|
||||
it('parses weeks only', () => {
|
||||
expect(parse('2w')).toBe(2 * 604800);
|
||||
});
|
||||
|
||||
it('parses days only', () => {
|
||||
expect(parse('3d')).toBe(3 * 86400);
|
||||
});
|
||||
|
||||
it('parses hours and minutes', () => {
|
||||
expect(parse('5h30m')).toBe(5 * 3600 + 30 * 60);
|
||||
});
|
||||
|
||||
it('parses seconds only', () => {
|
||||
expect(parse('45s')).toBe(45);
|
||||
});
|
||||
|
||||
it('returns 0 for an empty string', () => {
|
||||
expect(parse('')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 for zero-second uptime (0s)', () => {
|
||||
expect(parse('0s')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { encrypt, decrypt } from '../crypto';
|
||||
|
||||
describe('encrypt / decrypt', () => {
|
||||
it('round-trips plaintext', () => {
|
||||
const plain = 'super-secret-password';
|
||||
expect(decrypt(encrypt(plain))).toBe(plain);
|
||||
});
|
||||
|
||||
it('round-trips unicode', () => {
|
||||
const plain = 'пароль-123-密码';
|
||||
expect(decrypt(encrypt(plain))).toBe(plain);
|
||||
});
|
||||
|
||||
it('round-trips empty string', () => {
|
||||
expect(decrypt(encrypt(''))).toBe('');
|
||||
});
|
||||
|
||||
it('produces different ciphertext on each call due to random IV', () => {
|
||||
const plain = 'same-text';
|
||||
expect(encrypt(plain)).not.toBe(encrypt(plain));
|
||||
});
|
||||
|
||||
it('throws on invalid format (fewer than 3 colon-separated parts)', () => {
|
||||
expect(() => decrypt('notvalidformat')).toThrow('Invalid encrypted text format');
|
||||
});
|
||||
|
||||
it('throws when ciphertext is tampered', () => {
|
||||
const enc = encrypt('hello');
|
||||
const parts = enc.split(':');
|
||||
parts[2] = 'deadbeef';
|
||||
expect(() => decrypt(parts.join(':'))).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// Prevent any real HTTPS calls — all tests either use mocked fs cache or don't reach the download path
|
||||
jest.mock('https', () => ({
|
||||
get: jest.fn((_url: string, _opts: unknown, _cb: unknown) => {
|
||||
const req = { on: jest.fn().mockReturnThis(), destroy: jest.fn() };
|
||||
return req;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { lookupVendor, initOuiDatabase } from '../oui';
|
||||
|
||||
describe('lookupVendor - before initialization', () => {
|
||||
it('returns empty string when database is not loaded', () => {
|
||||
// The module starts with db = null; lookupVendor returns '' immediately
|
||||
expect(lookupVendor('AA:BB:CC:DD:EE:FF')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookupVendor - after initialization from cache', () => {
|
||||
// Build fake cache once (must be > 10,000 entries to pass the size check in _load)
|
||||
const fakeCache: Record<string, string> = { AABBCC: 'Test Vendor Inc' };
|
||||
for (let i = 0; i < 10001; i++) {
|
||||
const key = i.toString(16).padStart(6, '0').toUpperCase();
|
||||
if (!fakeCache[key]) fakeCache[key] = `Vendor ${i}`;
|
||||
}
|
||||
|
||||
let lookup: (mac: string) => string;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules();
|
||||
|
||||
jest.doMock('fs', () => ({
|
||||
statSync: () => ({ mtimeMs: Date.now() - 1000 }),
|
||||
readFileSync: () => JSON.stringify(fakeCache),
|
||||
writeFileSync: jest.fn(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require('../oui') as { initOuiDatabase: () => Promise<void>; lookupVendor: (mac: string) => string };
|
||||
await mod.initOuiDatabase();
|
||||
lookup = mod.lookupVendor;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('finds a known vendor', () => {
|
||||
expect(lookup('AABBCC')).toBe('Test Vendor Inc');
|
||||
});
|
||||
|
||||
it('normalizes colon separators (AA:BB:CC:DD:EE:FF)', () => {
|
||||
expect(lookup('AA:BB:CC:DD:EE:FF')).toBe('Test Vendor Inc');
|
||||
});
|
||||
|
||||
it('normalizes dash separators (AA-BB-CC-DD-EE-FF)', () => {
|
||||
expect(lookup('AA-BB-CC-DD-EE-FF')).toBe('Test Vendor Inc');
|
||||
});
|
||||
|
||||
it('normalizes dot separators (AABB.CCDD.EEFF)', () => {
|
||||
expect(lookup('AABB.CCDD.EEFF')).toBe('Test Vendor Inc');
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(lookup('aa:bb:cc:dd:ee:ff')).toBe('Test Vendor Inc');
|
||||
});
|
||||
|
||||
it('returns empty string for an unknown MAC', () => {
|
||||
expect(lookup('FF:FF:FF:FF:FF:FF')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// Verify that initOuiDatabase only runs _load once (singleton promise)
|
||||
describe('initOuiDatabase - singleton', () => {
|
||||
it('returns the same promise on repeated calls', () => {
|
||||
const p1 = initOuiDatabase();
|
||||
const p2 = initOuiDatabase();
|
||||
expect(p1).toBe(p2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user