547 lines
18 KiB
JavaScript
547 lines
18 KiB
JavaScript
const { describe, it, beforeEach, afterEach, before, after } = require('mocha');
|
|
const { expect } = require('chai');
|
|
const sinon = require('sinon');
|
|
const AlertService = require('../../services/alertService');
|
|
const { setupTestEnvironment, teardownTestEnvironment, cleanDatabase, createTestUser, createTestTenant, createTestDevice, createTestDetection } = require('../setup');
|
|
|
|
describe('AlertService', () => {
|
|
let models, sequelize, alertService;
|
|
|
|
before(async () => {
|
|
({ models, sequelize } = await setupTestEnvironment());
|
|
alertService = new AlertService();
|
|
});
|
|
|
|
after(async () => {
|
|
await teardownTestEnvironment();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await cleanDatabase();
|
|
// Clear active alerts between tests
|
|
alertService.activeAlerts.clear();
|
|
});
|
|
|
|
describe('assessThreatLevel', () => {
|
|
it('should assess critical threat for very close drones', () => {
|
|
const result = alertService.assessThreatLevel(-35, 2);
|
|
|
|
expect(result.level).to.equal('critical');
|
|
expect(result.requiresImmediateAction).to.be.true;
|
|
expect(result.priority).to.equal(1);
|
|
expect(result.description).to.include('IMMEDIATE THREAT');
|
|
});
|
|
|
|
it('should assess high threat for close drones', () => {
|
|
const result = alertService.assessThreatLevel(-50, 2);
|
|
|
|
expect(result.level).to.equal('high');
|
|
expect(result.requiresImmediateAction).to.be.true;
|
|
expect(result.priority).to.equal(2);
|
|
expect(result.description).to.include('HIGH THREAT');
|
|
});
|
|
|
|
it('should assess medium threat for medium distance', () => {
|
|
const result = alertService.assessThreatLevel(-65, 2);
|
|
|
|
expect(result.level).to.equal('medium');
|
|
expect(result.requiresImmediateAction).to.be.false;
|
|
expect(result.priority).to.equal(3);
|
|
expect(result.description).to.include('MEDIUM THREAT');
|
|
});
|
|
|
|
it('should assess low threat for distant drones', () => {
|
|
const result = alertService.assessThreatLevel(-80, 2);
|
|
|
|
expect(result.level).to.equal('low');
|
|
expect(result.requiresImmediateAction).to.be.false;
|
|
expect(result.priority).to.equal(4);
|
|
expect(result.description).to.include('LOW THREAT');
|
|
});
|
|
|
|
it('should assess monitoring for very distant drones', () => {
|
|
const result = alertService.assessThreatLevel(-90, 2);
|
|
|
|
expect(result.level).to.equal('monitoring');
|
|
expect(result.requiresImmediateAction).to.be.false;
|
|
expect(result.description).to.include('MONITORING');
|
|
});
|
|
|
|
it('should escalate military drones to critical regardless of distance', () => {
|
|
// Test with a distant military drone (Orlan type 2)
|
|
const result = alertService.assessThreatLevel(-85, 2); // Very far but military
|
|
|
|
expect(result.level).to.equal('critical');
|
|
expect(result.requiresImmediateAction).to.be.true;
|
|
expect(result.description).to.include('CRITICAL THREAT');
|
|
expect(result.description).to.include('IMMEDIATE RESPONSE REQUIRED');
|
|
});
|
|
|
|
it('should calculate estimated distance from RSSI', () => {
|
|
const result = alertService.assessThreatLevel(-65, 2);
|
|
|
|
expect(result.estimatedDistance).to.be.a('number');
|
|
expect(result.estimatedDistance).to.be.greaterThan(0);
|
|
expect(result.rssi).to.equal(-65);
|
|
});
|
|
|
|
it('should include drone type information', () => {
|
|
const result = alertService.assessThreatLevel(-65, 2);
|
|
|
|
expect(result.droneType).to.be.a('string');
|
|
expect(result.droneCategory).to.be.a('string');
|
|
expect(result.threatLevel).to.be.a('string');
|
|
});
|
|
|
|
it('should escalate professional drones by one threat level', () => {
|
|
// Assuming drone type 13 is DJI (professional)
|
|
const result = alertService.assessThreatLevel(-80, 13); // Would normally be low
|
|
|
|
expect(result.level).to.equal('medium'); // Escalated from low
|
|
expect(result.requiresImmediateAction).to.be.true;
|
|
});
|
|
|
|
it('should handle racing drones appropriately', () => {
|
|
// Test racing drone close proximity
|
|
const result = alertService.assessThreatLevel(-50, 7); // FPV racing drone, close
|
|
|
|
expect(result.level).to.equal('high');
|
|
expect(result.description).to.include('HIGH-SPEED');
|
|
});
|
|
});
|
|
|
|
describe('checkAlertRules', () => {
|
|
it('should trigger alert when detection meets rule criteria', async () => {
|
|
const tenant = await createTestTenant();
|
|
const user = await createTestUser({ tenant_id: tenant.id });
|
|
const device = await createTestDevice({ tenant_id: tenant.id });
|
|
|
|
// Create alert rule
|
|
await models.AlertRule.create({
|
|
user_id: user.id,
|
|
tenant_id: tenant.id,
|
|
name: 'Test Rule',
|
|
drone_type: 2,
|
|
min_rssi: -70,
|
|
max_distance: 1000,
|
|
is_active: true
|
|
});
|
|
|
|
const detection = await createTestDetection({
|
|
device_id: device.id,
|
|
drone_type: 2,
|
|
rssi: -60 // Stronger than min_rssi
|
|
});
|
|
|
|
const alerts = await alertService.checkAlertRules(detection);
|
|
|
|
expect(alerts).to.be.an('array');
|
|
expect(alerts).to.have.length(1);
|
|
expect(alerts[0].rule_name).to.equal('Test Rule');
|
|
});
|
|
|
|
it('should not trigger alert when detection does not meet criteria', async () => {
|
|
const tenant = await createTestTenant();
|
|
const user = await createTestUser({ tenant_id: tenant.id });
|
|
const device = await createTestDevice({ tenant_id: tenant.id });
|
|
|
|
// Create alert rule with strict criteria
|
|
await models.AlertRule.create({
|
|
user_id: user.id,
|
|
tenant_id: tenant.id,
|
|
name: 'Strict Rule',
|
|
drone_type: 2,
|
|
min_rssi: -40, // Very strong signal required
|
|
is_active: true
|
|
});
|
|
|
|
const detection = await createTestDetection({
|
|
device_id: device.id,
|
|
drone_type: 2,
|
|
rssi: -80 // Weaker than min_rssi
|
|
});
|
|
|
|
const alerts = await alertService.checkAlertRules(detection);
|
|
|
|
expect(alerts).to.be.an('array');
|
|
expect(alerts).to.have.length(0);
|
|
});
|
|
|
|
it('should not trigger alert for inactive rules', async () => {
|
|
const tenant = await createTestTenant();
|
|
const user = await createTestUser({ tenant_id: tenant.id });
|
|
const device = await createTestDevice({ tenant_id: tenant.id });
|
|
|
|
// Create inactive alert rule
|
|
await models.AlertRule.create({
|
|
user_id: user.id,
|
|
tenant_id: tenant.id,
|
|
name: 'Inactive Rule',
|
|
drone_type: 2,
|
|
min_rssi: -70,
|
|
is_active: false // Inactive
|
|
});
|
|
|
|
const detection = await createTestDetection({
|
|
device_id: device.id,
|
|
drone_type: 2,
|
|
rssi: -60
|
|
});
|
|
|
|
const alerts = await alertService.checkAlertRules(detection);
|
|
|
|
expect(alerts).to.have.length(0);
|
|
});
|
|
|
|
it('should handle multiple matching rules', async () => {
|
|
const tenant = await createTestTenant();
|
|
const user = await createTestUser({ tenant_id: tenant.id });
|
|
const device = await createTestDevice({ tenant_id: tenant.id });
|
|
|
|
// Create multiple alert rules
|
|
await models.AlertRule.create({
|
|
user_id: user.id,
|
|
tenant_id: tenant.id,
|
|
name: 'Rule 1',
|
|
drone_type: 2,
|
|
min_rssi: -70,
|
|
is_active: true
|
|
});
|
|
|
|
await models.AlertRule.create({
|
|
user_id: user.id,
|
|
tenant_id: tenant.id,
|
|
name: 'Rule 2',
|
|
min_rssi: -70, // No specific drone type
|
|
is_active: true
|
|
});
|
|
|
|
const detection = await createTestDetection({
|
|
device_id: device.id,
|
|
drone_type: 2,
|
|
rssi: -60
|
|
});
|
|
|
|
const alerts = await alertService.checkAlertRules(detection);
|
|
|
|
expect(alerts).to.have.length(2);
|
|
});
|
|
|
|
it('should filter rules by tenant', async () => {
|
|
const tenant1 = await createTestTenant({ slug: 'tenant1' });
|
|
const tenant2 = await createTestTenant({ slug: 'tenant2' });
|
|
const device1 = await createTestDevice({ tenant_id: tenant1.id });
|
|
|
|
// Create rules for different tenants
|
|
await models.AlertRule.create({
|
|
tenant_id: tenant1.id,
|
|
name: 'Tenant 1 Rule',
|
|
drone_type: 2,
|
|
min_rssi: -70,
|
|
is_active: true
|
|
});
|
|
|
|
await models.AlertRule.create({
|
|
tenant_id: tenant2.id,
|
|
name: 'Tenant 2 Rule',
|
|
drone_type: 2,
|
|
min_rssi: -70,
|
|
is_active: true
|
|
});
|
|
|
|
const detection = await createTestDetection({
|
|
device_id: device1.id,
|
|
drone_type: 2,
|
|
rssi: -60
|
|
});
|
|
|
|
const alerts = await alertService.checkAlertRules(detection);
|
|
|
|
expect(alerts).to.have.length(1);
|
|
expect(alerts[0].rule_name).to.equal('Tenant 1 Rule');
|
|
});
|
|
});
|
|
|
|
describe('logAlert', () => {
|
|
it('should create alert log entry', async () => {
|
|
const tenant = await createTestTenant();
|
|
const user = await createTestUser({ tenant_id: tenant.id });
|
|
const device = await createTestDevice({ tenant_id: tenant.id });
|
|
const detection = await createTestDetection({ device_id: device.id });
|
|
|
|
// Create a test alert rule
|
|
const rule = await models.AlertRule.create({
|
|
user_id: user.id,
|
|
tenant_id: tenant.id,
|
|
rule_name: 'Test Alert',
|
|
min_rssi: -80,
|
|
drone_type: 2,
|
|
alert_channels: ['sms'],
|
|
sms_phone_number: '+1234567890',
|
|
is_active: true
|
|
});
|
|
|
|
const logEntry = await alertService.logAlert(rule, detection, 'sms', '+1234567890');
|
|
|
|
expect(logEntry).to.exist;
|
|
expect(logEntry.alert_rule_id).to.equal(rule.id);
|
|
expect(logEntry.detection_id).to.equal(detection.id);
|
|
expect(logEntry.alert_type).to.equal('sms');
|
|
expect(logEntry.status).to.equal('sent');
|
|
});
|
|
|
|
it('should include detection and threat data in log', async () => {
|
|
const tenant = await createTestTenant();
|
|
const user = await createTestUser({ tenant_id: tenant.id });
|
|
const device = await createTestDevice({ tenant_id: tenant.id });
|
|
const detection = await createTestDetection({
|
|
device_id: device.id,
|
|
drone_type: 2,
|
|
rssi: -50
|
|
});
|
|
|
|
// Create a test alert rule
|
|
const rule = await models.AlertRule.create({
|
|
user_id: user.id,
|
|
tenant_id: tenant.id,
|
|
rule_name: 'Critical Alert',
|
|
min_rssi: -80,
|
|
drone_type: 2,
|
|
alert_channels: ['sms'],
|
|
sms_phone_number: '+1234567890',
|
|
is_active: true
|
|
});
|
|
|
|
const logEntry = await alertService.logAlert(rule, detection, 'sms', '+1234567890');
|
|
|
|
expect(logEntry.alert_rule_id).to.equal(rule.id);
|
|
expect(logEntry.detection_id).to.equal(detection.id);
|
|
expect(logEntry.alert_type).to.equal('sms');
|
|
expect(logEntry.status).to.equal('sent');
|
|
});
|
|
});
|
|
|
|
describe('sendSMSAlert', () => {
|
|
beforeEach(() => {
|
|
// Mock Twilio for SMS tests
|
|
alertService.twilioEnabled = true;
|
|
alertService.twilioClient = {
|
|
messages: {
|
|
create: sinon.stub().resolves({ sid: 'test-message-id' })
|
|
}
|
|
};
|
|
alertService.twilioPhone = '+1234567890';
|
|
});
|
|
|
|
it('should send SMS alert for critical threats', async () => {
|
|
const tenant = await createTestTenant();
|
|
const device = await createTestDevice({ tenant_id: tenant.id });
|
|
const detection = await createTestDetection({ device_id: device.id });
|
|
|
|
const rule = {
|
|
id: 1,
|
|
name: 'Test Rule',
|
|
priority: 'high'
|
|
};
|
|
|
|
const phoneNumber = '+1987654321';
|
|
const message = 'Critical threat detected';
|
|
|
|
const result = await alertService.sendSMSAlert(phoneNumber, message, rule, detection);
|
|
|
|
expect(result.status).to.equal('sent');
|
|
expect(alertService.twilioClient.messages.create.calledOnce).to.be.true;
|
|
});
|
|
|
|
it('should not send SMS for low priority alerts', async () => {
|
|
const tenant = await createTestTenant();
|
|
const device = await createTestDevice({ tenant_id: tenant.id });
|
|
const detection = await createTestDetection({ device_id: device.id });
|
|
|
|
// For this test, we'll test the priority logic in the calling function
|
|
// sendSMSAlert itself always tries to send if called
|
|
const rule = {
|
|
id: 1,
|
|
name: 'Test Rule',
|
|
priority: 'low'
|
|
};
|
|
|
|
const phoneNumber = '+1987654321';
|
|
const message = 'Low threat detected';
|
|
|
|
const result = await alertService.sendSMSAlert(phoneNumber, message, rule, detection);
|
|
|
|
// SMS should still be sent since this method is called explicitly
|
|
expect(result.status).to.equal('sent');
|
|
expect(alertService.twilioClient.messages.create.calledOnce).to.be.true;
|
|
});
|
|
|
|
it('should handle Twilio errors gracefully', async () => {
|
|
alertService.twilioClient.messages.create = sinon.stub().rejects(new Error('Twilio error'));
|
|
|
|
const tenant = await createTestTenant();
|
|
const device = await createTestDevice({ tenant_id: tenant.id });
|
|
const detection = await createTestDetection({ device_id: device.id });
|
|
|
|
const rule = {
|
|
id: 1,
|
|
name: 'Test Rule',
|
|
priority: 'high'
|
|
};
|
|
|
|
const phoneNumber = '+1987654321';
|
|
const message = 'Critical threat detected';
|
|
|
|
const result = await alertService.sendSMSAlert(phoneNumber, message, rule, detection);
|
|
|
|
expect(result.status).to.equal('failed');
|
|
expect(result.error_message).to.include('Twilio error');
|
|
});
|
|
|
|
it('should handle disabled Twilio', async () => {
|
|
alertService.twilioEnabled = false;
|
|
|
|
const tenant = await createTestTenant();
|
|
const device = await createTestDevice({ tenant_id: tenant.id });
|
|
const detection = await createTestDetection({ device_id: device.id });
|
|
|
|
const rule = {
|
|
id: 1,
|
|
name: 'Test Rule',
|
|
priority: 'high'
|
|
};
|
|
|
|
const phoneNumber = '+1987654321';
|
|
const message = 'Critical threat detected';
|
|
|
|
const result = await alertService.sendSMSAlert(phoneNumber, message, rule, detection);
|
|
|
|
expect(result.status).to.equal('failed');
|
|
expect(result.error_message).to.include('not configured');
|
|
});
|
|
});
|
|
|
|
describe('processDetectionAlert', () => {
|
|
it('should process complete alert workflow', async () => {
|
|
const tenant = await createTestTenant();
|
|
const device = await createTestDevice({
|
|
tenant_id: tenant.id,
|
|
name: 'Security Device'
|
|
});
|
|
|
|
// Create alert rule
|
|
await models.AlertRule.create({
|
|
tenant_id: tenant.id,
|
|
name: 'Security Rule',
|
|
drone_type: 2,
|
|
min_rssi: -70,
|
|
is_active: true
|
|
});
|
|
|
|
const detection = await createTestDetection({
|
|
device_id: device.id,
|
|
drone_type: 2,
|
|
rssi: -50 // High threat
|
|
});
|
|
|
|
// Mock socket.io
|
|
const mockIo = {
|
|
emit: sinon.stub(),
|
|
emitToDashboard: sinon.stub(),
|
|
emitToDevice: sinon.stub(),
|
|
to: (room) => ({
|
|
emit: sinon.stub()
|
|
})
|
|
};
|
|
|
|
const result = await alertService.processDetectionAlert(detection, mockIo);
|
|
|
|
expect(result).to.exist;
|
|
expect(result.alertsTriggered).to.be.greaterThan(0);
|
|
expect(mockIo.emitToDashboard.called).to.be.true;
|
|
});
|
|
|
|
it('should emit socket events for real-time updates', async () => {
|
|
const device = await createTestDevice();
|
|
const detection = await createTestDetection({
|
|
device_id: device.id,
|
|
drone_type: 2,
|
|
rssi: -40 // Critical threat
|
|
});
|
|
|
|
const mockIo = {
|
|
emit: sinon.stub(),
|
|
emitToDashboard: sinon.stub(),
|
|
emitToDevice: sinon.stub(),
|
|
to: (room) => ({
|
|
emit: sinon.stub()
|
|
})
|
|
};
|
|
|
|
await alertService.processDetectionAlert(detection, mockIo);
|
|
|
|
expect(mockIo.emitToDashboard.calledWith('new_alert')).to.be.true;
|
|
expect(mockIo.emitToDevice.calledWith(device.id, 'device_alert')).to.be.true;
|
|
});
|
|
});
|
|
|
|
describe('Alert Deduplication', () => {
|
|
it('should prevent duplicate alerts for same drone', async () => {
|
|
const device = await createTestDevice();
|
|
const droneId = 12345;
|
|
|
|
// First detection
|
|
const detection1 = await createTestDetection({
|
|
device_id: device.id,
|
|
drone_id: droneId,
|
|
drone_type: 2,
|
|
rssi: -50
|
|
});
|
|
|
|
// Second detection from same drone shortly after
|
|
const detection2 = await createTestDetection({
|
|
device_id: device.id,
|
|
drone_id: droneId,
|
|
drone_type: 2,
|
|
rssi: -45
|
|
});
|
|
|
|
const mockIo = {
|
|
emitToDashboard: sinon.stub(),
|
|
emitToDevice: sinon.stub(),
|
|
to: (room) => ({ emit: sinon.stub() })
|
|
};
|
|
|
|
await alertService.processDetectionAlert(detection1, mockIo);
|
|
await alertService.processDetectionAlert(detection2, mockIo);
|
|
|
|
// Should have deduplication logic to prevent spam
|
|
expect(alertService.activeAlerts.has(droneId)).to.be.true;
|
|
});
|
|
|
|
it('should send clear notification when drone disappears', async () => {
|
|
const device = await createTestDevice();
|
|
const droneId = 12345;
|
|
|
|
// Add active alert
|
|
alertService.activeAlerts.set(droneId, {
|
|
deviceId: device.id,
|
|
lastSeen: new Date(),
|
|
threatLevel: 'high'
|
|
});
|
|
|
|
const mockIo = {
|
|
emitToDashboard: sinon.stub(),
|
|
emitToDevice: sinon.stub(),
|
|
to: (room) => ({ emit: sinon.stub() })
|
|
};
|
|
|
|
// Simulate clearing old alerts
|
|
await alertService.clearExpiredAlerts(mockIo);
|
|
|
|
// Should emit clear notification for expired alerts
|
|
expect(mockIo.emitToDashboard.called).to.be.true;
|
|
});
|
|
});
|
|
});
|