Agregar notificaciones y limite de acciones criticas

main
EMOTIONS-HUNTER 4 weeks ago
parent 8797cb4990
commit 7d8959f164

@ -46,6 +46,8 @@ ventana de procesamiento en vez de bloquear un segundo por sensor.
- `API_AUTH_TOKEN` protege endpoints criticos cuando esta configurado. - `API_AUTH_TOKEN` protege endpoints criticos cuando esta configurado.
- El dashboard envia el token como `X-API-Token` desde almacenamiento local del - El dashboard envia el token como `X-API-Token` desde almacenamiento local del
navegador. navegador.
- La API compara tokens en tiempo constante y emite headers defensivos basicos.
- Los POST criticos usan rate limit configurable en memoria.
- Sin `API_AUTH_TOKEN`, el modo desarrollo permanece sin autenticacion. - Sin `API_AUTH_TOKEN`, el modo desarrollo permanece sin autenticacion.
## Bitacora de alarmas ## Bitacora de alarmas
@ -70,6 +72,13 @@ ventana de procesamiento en vez de bloquear un segundo por sensor.
- La poda conserva los nombres actuales de archivos para no romper graficas ni - La poda conserva los nombres actuales de archivos para no romper graficas ni
exportaciones. exportaciones.
## Notificaciones
- `config/notifications.json` permite activar webhook y Telegram.
- `GET /api/config/notifications` devuelve configuracion redactada.
- `POST /api/config/notifications` valida y persiste canales con token.
- El recolector despacha notificaciones desde eventos de `logs/alarms.csv`.
## Modos ## Modos
- `EZO_MODE=demo`: adquisición y comandos simulados. - `EZO_MODE=demo`: adquisición y comandos simulados.

@ -102,6 +102,10 @@ En produccion configure `API_AUTH_TOKEN` en `/etc/default/photobioreactor` para
proteger comandos EZO, calibracion, cambios de configuracion y borrado de proteger comandos EZO, calibracion, cambios de configuracion y borrado de
historicos. El dashboard incluye un campo "Token API" que guarda el valor solo historicos. El dashboard incluye un campo "Token API" que guarda el valor solo
en el navegador local y lo envia como header `X-API-Token`. en el navegador local y lo envia como header `X-API-Token`.
La API aplica comparacion de token en tiempo constante y headers defensivos
basicos (`nosniff`, `DENY`, `same-origin`, `no-store`).
Los POST criticos aplican rate limit en memoria mediante
`API_RATE_LIMIT_WINDOW_MS` y `API_RATE_LIMIT_MAX`.
## Bitacora de alarmas ## Bitacora de alarmas
@ -121,6 +125,13 @@ Los limites se guardan en `config/alarms.json`, se consultan con
`GET /api/config/alarms` y pueden editarse desde el dashboard. Guardar cambios `GET /api/config/alarms` y pueden editarse desde el dashboard. Guardar cambios
requiere `API_AUTH_TOKEN` cuando esta configurado. requiere `API_AUTH_TOKEN` cuando esta configurado.
## Notificaciones
`config/notifications.json` define canales de webhook y Telegram. El recolector
envia notificaciones cuando una alarma persistente alcanza `minSeverity`. La API
expone configuracion redactada en `GET /api/config/notifications`; guardar
requiere token y nunca devuelve secretos sin redaccion.
## Archivos de datos ## Archivos de datos
- Lecturas actuales: `data/EZORTD.json`, `EZOPH.json`, `EZODO.json`, - Lecturas actuales: `data/EZORTD.json`, `EZOPH.json`, `EZODO.json`,

@ -3,6 +3,7 @@ const fsPromises = require('node:fs/promises');
const path = require('node:path'); const path = require('node:path');
const { execFile } = require('node:child_process'); const { execFile } = require('node:child_process');
const { promisify } = require('node:util'); const { promisify } = require('node:util');
const { dispatchAlarmNotifications } = require('./notification-service');
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@ -495,6 +496,7 @@ async function collectReadings() {
}) })
); );
const alarmEvents = await persistAlarmEvents(results, timestamp); const alarmEvents = await persistAlarmEvents(results, timestamp);
const notificationResults = await dispatchAlarmNotifications(alarmEvents);
const prunedFiles = await enforceHistoryRetention( const prunedFiles = await enforceHistoryRetention(
runtimeConfig.historyRetentionDays, runtimeConfig.historyRetentionDays,
new Date(timestamp) new Date(timestamp)
@ -505,6 +507,7 @@ async function collectReadings() {
timestamp, timestamp,
results, results,
alarmEvents, alarmEvents,
notificationResults,
prunedFiles, prunedFiles,
enabledSensors: runtimeConfig.enabledSensors enabledSensors: runtimeConfig.enabledSensors
}; };

@ -0,0 +1,245 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const ROOT_DIRECTORY = path.join(__dirname, '..');
const NOTIFICATION_CONFIG_FILE = process.env.NOTIFICATION_CONFIG_FILE
? path.resolve(process.env.NOTIFICATION_CONFIG_FILE)
: path.join(ROOT_DIRECTORY, 'config', 'notifications.json');
const SEVERITY_RANK = {
RECOVERY: 0,
NORMAL: 0,
WARNING: 1,
OFFLINE: 2,
CRITICAL: 3
};
const DEFAULT_NOTIFICATION_CONFIG = {
enabled: false,
minSeverity: 'CRITICAL',
channels: {
webhook: {
enabled: false,
url: '',
headers: {}
},
telegram: {
enabled: false,
botToken: '',
chatId: ''
}
}
};
function mergeNotificationConfig(config = {}) {
return {
enabled: config.enabled === true,
minSeverity: SEVERITY_RANK[config.minSeverity] === undefined
? DEFAULT_NOTIFICATION_CONFIG.minSeverity
: config.minSeverity,
channels: {
webhook: {
...DEFAULT_NOTIFICATION_CONFIG.channels.webhook,
...(config.channels && config.channels.webhook)
},
telegram: {
...DEFAULT_NOTIFICATION_CONFIG.channels.telegram,
...(config.channels && config.channels.telegram)
}
}
};
}
async function readNotificationConfig() {
try {
const rawConfig = await fs.readFile(NOTIFICATION_CONFIG_FILE, 'utf8');
return mergeNotificationConfig(JSON.parse(rawConfig));
} catch {
return mergeNotificationConfig();
}
}
function validateNotificationConfig(rawConfig) {
const config = mergeNotificationConfig(rawConfig);
if (SEVERITY_RANK[config.minSeverity] === undefined) {
throw Object.assign(new Error('Severidad minima no valida.'), { status: 400 });
}
if (config.channels.webhook.enabled && !isValidHttpUrl(config.channels.webhook.url)) {
throw Object.assign(new Error('URL de webhook no valida.'), { status: 400 });
}
if (config.channels.telegram.enabled) {
if (!config.channels.telegram.botToken || !config.channels.telegram.chatId) {
throw Object.assign(
new Error('Telegram requiere botToken y chatId.'),
{ status: 400 }
);
}
}
if (
config.channels.webhook.headers &&
(typeof config.channels.webhook.headers !== 'object' ||
Array.isArray(config.channels.webhook.headers))
) {
throw Object.assign(new Error('Headers de webhook invalidos.'), { status: 400 });
}
return config;
}
async function writeNotificationConfig(rawConfig) {
const config = validateNotificationConfig(rawConfig);
await fs.mkdir(path.dirname(NOTIFICATION_CONFIG_FILE), { recursive: true });
await fs.writeFile(
NOTIFICATION_CONFIG_FILE,
`${JSON.stringify(config, null, 4)}\n`,
'utf8'
);
return config;
}
function redactNotificationConfig(config) {
return {
...config,
channels: {
webhook: {
...config.channels.webhook,
headers: Object.keys(config.channels.webhook.headers || {}).length > 0
? '[configured]'
: {}
},
telegram: {
...config.channels.telegram,
botToken: config.channels.telegram.botToken ? '[configured]' : ''
}
}
};
}
function isValidHttpUrl(value) {
try {
const url = new URL(value);
return ['http:', 'https:'].includes(url.protocol);
} catch {
return false;
}
}
function shouldNotify(event, minSeverity) {
const eventRank = SEVERITY_RANK[event.state] ?? 0;
const minimumRank = SEVERITY_RANK[minSeverity] ?? SEVERITY_RANK.CRITICAL;
return eventRank >= minimumRank;
}
function buildNotificationMessage(event) {
const value = event.value === '' || event.value === null || event.value === undefined
? 'N/A'
: event.value;
return [
`Photobioreactor alarm: ${event.state}`,
`Sensor: ${event.sensorId || event.sensor}`,
`Value: ${value}`,
`Message: ${event.message}`,
`Time: ${event.timestamp}`
].join('\n');
}
async function dispatchAlarmNotifications(events) {
const config = await readNotificationConfig();
if (!config.enabled || !Array.isArray(events) || events.length === 0) {
return [];
}
const eligibleEvents = events.filter((event) =>
shouldNotify(event, config.minSeverity)
);
const results = [];
for (const event of eligibleEvents) {
if (config.channels.webhook.enabled) {
results.push(await sendSafely('webhook', event, () =>
sendWebhookNotification(event, config.channels.webhook)
));
}
if (config.channels.telegram.enabled) {
results.push(await sendSafely('telegram', event, () =>
sendTelegramNotification(event, config.channels.telegram)
));
}
}
return results;
}
async function sendSafely(channel, event, send) {
try {
await send();
return {
channel,
sensorId: event.sensorId || event.sensor,
state: event.state,
success: true
};
} catch (error) {
return {
channel,
sensorId: event.sensorId || event.sensor,
state: event.state,
success: false,
error: error.message
};
}
}
async function sendWebhookNotification(event, channelConfig) {
await postJson(channelConfig.url, {
event,
text: buildNotificationMessage(event)
}, channelConfig.headers || {});
}
async function sendTelegramNotification(event, channelConfig) {
const url = `https://api.telegram.org/bot${channelConfig.botToken}/sendMessage`;
await postJson(url, {
chat_id: channelConfig.chatId,
text: buildNotificationMessage(event)
});
}
async function postJson(url, payload, headers = {}) {
if (typeof fetch !== 'function') {
throw new Error('fetch no esta disponible en este runtime.');
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...headers
},
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
}
module.exports = {
DEFAULT_NOTIFICATION_CONFIG,
NOTIFICATION_CONFIG_FILE,
dispatchAlarmNotifications,
readNotificationConfig,
redactNotificationConfig,
shouldNotify,
validateNotificationConfig,
writeNotificationConfig
};

@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const crypto = require('node:crypto');
const fs = require('node:fs/promises'); const fs = require('node:fs/promises');
const path = require('node:path'); const path = require('node:path');
const { const {
@ -11,11 +12,18 @@ const {
readRuntimeConfig, readRuntimeConfig,
writeRuntimeConfig writeRuntimeConfig
} = require('./acquisition-service'); } = require('./acquisition-service');
const {
readNotificationConfig,
redactNotificationConfig,
writeNotificationConfig
} = require('./notification-service');
const app = express(); const app = express();
const PORT = Number(process.env.PORT || 3000); const PORT = Number(process.env.PORT || 3000);
const HOST = process.env.HOST || '127.0.0.1'; const HOST = process.env.HOST || '127.0.0.1';
const API_AUTH_TOKEN = process.env.API_AUTH_TOKEN || ''; const API_AUTH_TOKEN = process.env.API_AUTH_TOKEN || '';
const API_RATE_LIMIT_WINDOW_MS = Number(process.env.API_RATE_LIMIT_WINDOW_MS || 60000);
const API_RATE_LIMIT_MAX = Number(process.env.API_RATE_LIMIT_MAX || 60);
const LOGS_DIRECTORY = process.env.LOGS_DIRECTORY const LOGS_DIRECTORY = process.env.LOGS_DIRECTORY
? path.resolve(process.env.LOGS_DIRECTORY) ? path.resolve(process.env.LOGS_DIRECTORY)
: path.join(__dirname, '..', 'logs'); : path.join(__dirname, '..', 'logs');
@ -42,6 +50,13 @@ const HISTORY_SENSOR_IDS = {
ec: 'ec' ec: 'ec'
}; };
const ALARM_SENSOR_IDS = ['temperature', 'ph', 'do', 'ec']; const ALARM_SENSOR_IDS = ['temperature', 'ph', 'do', 'ec'];
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'same-origin',
'Cache-Control': 'no-store'
};
const rateLimitBuckets = new Map();
function parseAlarmCsv(csvText) { function parseAlarmCsv(csvText) {
return csvText return csvText
@ -133,6 +148,12 @@ async function writeAlarmConfigFile(rawConfig) {
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use((req, res, next) => {
Object.entries(SECURITY_HEADERS).forEach(([name, value]) => {
res.setHeader(name, value);
});
next();
});
app.use('/frontend', express.static(path.join(__dirname, '..', 'frontend'))); app.use('/frontend', express.static(path.join(__dirname, '..', 'frontend')));
app.use('/data', express.static(path.join(__dirname, '..', 'data'), { app.use('/data', express.static(path.join(__dirname, '..', 'data'), {
etag: false, etag: false,
@ -178,7 +199,7 @@ function requireApiToken(req, res, next) {
const providedToken = req.get('X-API-Token') || ''; const providedToken = req.get('X-API-Token') || '';
if (providedToken === API_AUTH_TOKEN) { if (tokensMatch(providedToken, API_AUTH_TOKEN)) {
return next(); return next();
} }
@ -188,6 +209,51 @@ function requireApiToken(req, res, next) {
}); });
} }
function tokensMatch(providedToken, expectedToken) {
const provided = Buffer.from(String(providedToken));
const expected = Buffer.from(String(expectedToken));
if (provided.length !== expected.length) {
return false;
}
return crypto.timingSafeEqual(provided, expected);
}
function getRateLimitKey(req) {
const token = req.get('X-API-Token') || '';
const identity = token || req.ip || req.socket.remoteAddress || 'unknown';
return `${identity}:${req.method}:${req.path}`;
}
function rateLimitCriticalApi(req, res, next) {
if (API_RATE_LIMIT_MAX <= 0 || API_RATE_LIMIT_WINDOW_MS <= 0) {
return next();
}
const now = Date.now();
const key = getRateLimitKey(req);
const bucket = rateLimitBuckets.get(key) || [];
const recentRequests = bucket.filter((timestamp) =>
now - timestamp < API_RATE_LIMIT_WINDOW_MS
);
if (recentRequests.length >= API_RATE_LIMIT_MAX) {
const retryAfterMs = API_RATE_LIMIT_WINDOW_MS - (now - recentRequests[0]);
res.setHeader('Retry-After', String(Math.max(1, Math.ceil(retryAfterMs / 1000))));
rateLimitBuckets.set(key, recentRequests);
return res.status(429).json({
success: false,
error: 'Demasiadas solicitudes. Intente nuevamente en unos segundos.'
});
}
recentRequests.push(now);
rateLimitBuckets.set(key, recentRequests);
return next();
}
app.get('/api/system/ezo', (req, res) => { app.get('/api/system/ezo', (req, res) => {
const commandMode = detectMode(); const commandMode = detectMode();
const acquisitionMode = detectAcquisitionMode(); const acquisitionMode = detectAcquisitionMode();
@ -277,7 +343,7 @@ app.get('/api/alarms', async (req, res) => {
} }
}); });
app.post('/api/sensors/:type/command', requireApiToken, async (req, res) => { app.post('/api/sensors/:type/command', requireApiToken, rateLimitCriticalApi, async (req, res) => {
try { try {
const result = await executeEzoCommand( const result = await executeEzoCommand(
req.params.type.toLowerCase(), req.params.type.toLowerCase(),
@ -318,7 +384,7 @@ app.get('/api/config/alarms', async (req, res) => {
} }
}); });
app.post('/api/config/alarms', requireApiToken, async (req, res) => { app.post('/api/config/alarms', requireApiToken, rateLimitCriticalApi, async (req, res) => {
try { try {
const thresholds = await writeAlarmConfigFile(req.body.thresholds || req.body); const thresholds = await writeAlarmConfigFile(req.body.thresholds || req.body);
console.log('[CONFIG] Umbrales de alarma actualizados.'); console.log('[CONFIG] Umbrales de alarma actualizados.');
@ -335,7 +401,32 @@ app.post('/api/config/alarms', requireApiToken, async (req, res) => {
} }
}); });
app.post('/api/config/logging', requireApiToken, async (req, res) => { app.get('/api/config/notifications', async (req, res) => {
const config = await readNotificationConfig();
res.json({
success: true,
config: redactNotificationConfig(config)
});
});
app.post('/api/config/notifications', requireApiToken, rateLimitCriticalApi, async (req, res) => {
try {
const config = await writeNotificationConfig(req.body.config || req.body);
console.log('[CONFIG] Notificaciones actualizadas.');
res.json({
success: true,
config: redactNotificationConfig(config),
message: 'Configuracion de notificaciones actualizada.'
});
} catch (error) {
res.status(error.status || 500).json({
success: false,
error: error.message
});
}
});
app.post('/api/config/logging', requireApiToken, rateLimitCriticalApi, async (req, res) => {
try { try {
const config = await writeRuntimeConfig({ rate: req.body.rate }); const config = await writeRuntimeConfig({ rate: req.body.rate });
console.log( console.log(
@ -365,7 +456,7 @@ app.get('/api/config/runtime', async (req, res) => {
}); });
}); });
app.post('/api/config/runtime', requireApiToken, async (req, res) => { app.post('/api/config/runtime', requireApiToken, rateLimitCriticalApi, async (req, res) => {
try { try {
const config = await writeRuntimeConfig({ const config = await writeRuntimeConfig({
rate: req.body.rate, rate: req.body.rate,
@ -390,7 +481,7 @@ app.post('/api/config/runtime', requireApiToken, async (req, res) => {
} }
}); });
app.post('/api/history/clear', requireApiToken, async (req, res) => { app.post('/api/history/clear', requireApiToken, rateLimitCriticalApi, async (req, res) => {
try { try {
await fs.mkdir(LOGS_DIRECTORY, { recursive: true }); await fs.mkdir(LOGS_DIRECTORY, { recursive: true });
await Promise.all( await Promise.all(
@ -426,5 +517,6 @@ if (require.main === module) {
module.exports = { module.exports = {
app, app,
HISTORY_FILES, HISTORY_FILES,
parseHistoryCsv parseHistoryCsv,
resetRateLimits: () => rateLimitBuckets.clear()
}; };

@ -0,0 +1,16 @@
{
"enabled": false,
"minSeverity": "CRITICAL",
"channels": {
"webhook": {
"enabled": false,
"url": "",
"headers": {}
},
"telegram": {
"enabled": false,
"botToken": "",
"chatId": ""
}
}
}

@ -6,3 +6,8 @@ EZO_MODE=hardware
EZO_ENABLED_SENSORS= EZO_ENABLED_SENSORS=
# Configure un valor secreto para proteger comandos, calibracion y cambios criticos. # Configure un valor secreto para proteger comandos, calibracion y cambios criticos.
API_AUTH_TOKEN= API_AUTH_TOKEN=
# Limite de acciones criticas por ventana; use 0 para desactivar.
API_RATE_LIMIT_WINDOW_MS=60000
API_RATE_LIMIT_MAX=60
# Archivo de canales webhook/Telegram; no exponga secretos en frontend.
NOTIFICATION_CONFIG_FILE=/etc/photobioreactor/notifications.json

@ -8,6 +8,7 @@ fi
PROJECT_SOURCE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PROJECT_SOURCE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PROJECT_TARGET="/opt/photobioreactor" PROJECT_TARGET="/opt/photobioreactor"
CONFIG_TARGET="/etc/photobioreactor"
SERVICE_USER="photobioreactor" SERVICE_USER="photobioreactor"
apt-get update apt-get update
@ -18,6 +19,7 @@ if ! id "${SERVICE_USER}" >/dev/null 2>&1; then
fi fi
install -d -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${PROJECT_TARGET}" install -d -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${PROJECT_TARGET}"
install -d -o "${SERVICE_USER}" -g "${SERVICE_USER}" "${CONFIG_TARGET}"
cp -a "${PROJECT_SOURCE}/." "${PROJECT_TARGET}/" cp -a "${PROJECT_SOURCE}/." "${PROJECT_TARGET}/"
rm -rf "${PROJECT_TARGET}/node_modules" rm -rf "${PROJECT_TARGET}/node_modules"
@ -27,6 +29,8 @@ make -C sensors/EZOCommand
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${PROJECT_TARGET}" chown -R "${SERVICE_USER}:${SERVICE_USER}" "${PROJECT_TARGET}"
install -m 0644 deployment/photobioreactor.env /etc/default/photobioreactor install -m 0644 deployment/photobioreactor.env /etc/default/photobioreactor
install -o "${SERVICE_USER}" -g "${SERVICE_USER}" -m 0600 \
config/notifications.json "${CONFIG_TARGET}/notifications.json"
install -m 0644 deployment/photobioreactor-api.service /etc/systemd/system/ install -m 0644 deployment/photobioreactor-api.service /etc/systemd/system/
install -m 0644 deployment/photobioreactor-acquisition.service /etc/systemd/system/ install -m 0644 deployment/photobioreactor-acquisition.service /etc/systemd/system/
install -m 0644 deployment/nginx-photobioreactor.conf \ install -m 0644 deployment/nginx-photobioreactor.conf \

@ -9,6 +9,7 @@ const temporaryLogs = path.join(temporaryRoot, 'logs');
const temporaryData = path.join(temporaryRoot, 'data'); const temporaryData = path.join(temporaryRoot, 'data');
const temporaryRuntime = path.join(temporaryRoot, 'runtime.json'); const temporaryRuntime = path.join(temporaryRoot, 'runtime.json');
const temporaryAlarms = path.join(temporaryRoot, 'alarms.json'); const temporaryAlarms = path.join(temporaryRoot, 'alarms.json');
const temporaryNotifications = path.join(temporaryRoot, 'notifications.json');
fs.mkdirSync(temporaryLogs, { recursive: true }); fs.mkdirSync(temporaryLogs, { recursive: true });
fs.mkdirSync(temporaryData, { recursive: true }); fs.mkdirSync(temporaryData, { recursive: true });
fs.writeFileSync(temporaryAlarms, JSON.stringify({ fs.writeFileSync(temporaryAlarms, JSON.stringify({
@ -17,19 +18,34 @@ fs.writeFileSync(temporaryAlarms, JSON.stringify({
do: { min: 4, max: 12 }, do: { min: 4, max: 12 },
ec: { min: 500, max: 2500 } ec: { min: 500, max: 2500 }
})); }));
fs.writeFileSync(temporaryNotifications, JSON.stringify({
enabled: false,
minSeverity: 'CRITICAL',
channels: {
webhook: { enabled: false, url: '', headers: {} },
telegram: { enabled: false, botToken: '', chatId: '' }
}
}));
process.env.LOGS_DIRECTORY = temporaryLogs; process.env.LOGS_DIRECTORY = temporaryLogs;
process.env.DATA_DIRECTORY = temporaryData; process.env.DATA_DIRECTORY = temporaryData;
process.env.RUNTIME_CONFIG_FILE = temporaryRuntime; process.env.RUNTIME_CONFIG_FILE = temporaryRuntime;
process.env.ALARM_CONFIG_FILE = temporaryAlarms; process.env.ALARM_CONFIG_FILE = temporaryAlarms;
process.env.NOTIFICATION_CONFIG_FILE = temporaryNotifications;
process.env.EZO_MODE = 'demo'; process.env.EZO_MODE = 'demo';
process.env.API_AUTH_TOKEN = 'test-token'; process.env.API_AUTH_TOKEN = 'test-token';
process.env.API_RATE_LIMIT_WINDOW_MS = '60000';
process.env.API_RATE_LIMIT_MAX = '1000';
const { app, HISTORY_FILES } = require('../api/server'); const { app, HISTORY_FILES, resetRateLimits } = require('../api/server');
const { const {
collectReadings, collectReadings,
pruneCsvByRetention, pruneCsvByRetention,
publishReading publishReading
} = require('../api/acquisition-service'); } = require('../api/acquisition-service');
const {
dispatchAlarmNotifications,
writeNotificationConfig
} = require('../api/notification-service');
let server; let server;
let baseUrl; let baseUrl;
@ -109,6 +125,8 @@ test('serves the dashboard in local development mode', async () => {
const html = await response.text(); const html = await response.text();
assert.equal(response.status, 200); assert.equal(response.status, 200);
assert.equal(response.headers.get('x-content-type-options'), 'nosniff');
assert.equal(response.headers.get('x-frame-options'), 'DENY');
assert.match(html, /Panel del Fotobiorreactor/); assert.match(html, /Panel del Fotobiorreactor/);
}); });
@ -125,6 +143,20 @@ test('protects critical API endpoints when token is configured', async () => {
assert.equal(response.status, 401); assert.equal(response.status, 401);
assert.equal(data.success, false); assert.equal(data.success, false);
const wrongTokenResponse = await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'wrong-token'
},
body: JSON.stringify({
rate: 1,
enabledSensors: ['temperature']
})
});
assert.equal(wrongTokenResponse.status, 401);
}); });
test('validates sensor commands', async () => { test('validates sensor commands', async () => {
@ -335,6 +367,95 @@ test('rejects invalid alarm thresholds', async () => {
assert.equal(response.status, 400); assert.equal(response.status, 400);
}); });
test('persists notification configuration with redacted secrets', async () => {
const response = await fetch(`${baseUrl}/api/config/notifications`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Token': 'test-token'
},
body: JSON.stringify({
config: {
enabled: true,
minSeverity: 'WARNING',
channels: {
webhook: {
enabled: true,
url: 'https://example.test/alarm',
headers: { Authorization: 'Bearer secret' }
},
telegram: {
enabled: true,
botToken: '123456:secret',
chatId: '42'
}
}
}
})
});
const data = await response.json();
assert.equal(response.status, 200);
assert.equal(data.config.enabled, true);
assert.equal(data.config.channels.webhook.headers, '[configured]');
assert.equal(data.config.channels.telegram.botToken, '[configured]');
const readResponse = await fetch(`${baseUrl}/api/config/notifications`);
const readData = await readResponse.json();
assert.equal(readData.config.channels.telegram.botToken, '[configured]');
});
test('dispatches webhook notifications for eligible alarm events', async () => {
await writeNotificationConfig({
enabled: true,
minSeverity: 'WARNING',
channels: {
webhook: {
enabled: true,
url: 'https://example.test/alarm',
headers: { Authorization: 'Bearer secret' }
},
telegram: {
enabled: false,
botToken: '',
chatId: ''
}
}
});
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options) => {
calls.push({ url, options });
return { ok: true, status: 200 };
};
try {
const result = await dispatchAlarmNotifications([{
timestamp: '2026-06-27T12:00:00.000Z',
sensorId: 'temperature',
state: 'CRITICAL',
value: 31.5,
message: 'Valor fuera de rango'
}]);
assert.equal(result.length, 1);
assert.equal(result[0].success, true);
assert.equal(calls.length, 1);
assert.equal(calls[0].url, 'https://example.test/alarm');
assert.match(calls[0].options.body, /temperature/);
} finally {
global.fetch = originalFetch;
await writeNotificationConfig({
enabled: false,
minSeverity: 'CRITICAL',
channels: {
webhook: { enabled: false, url: '', headers: {} },
telegram: { enabled: false, botToken: '', chatId: '' }
}
});
}
});
test('demo acquisition writes live JSON and CSV files', async () => { test('demo acquisition writes live JSON and CSV files', async () => {
await fetch(`${baseUrl}/api/config/runtime`, { await fetch(`${baseUrl}/api/config/runtime`, {
method: 'POST', method: 'POST',

@ -33,6 +33,21 @@ test('dashboard starts live polling and exposes required controls', () => {
); );
}); });
test('api protects critical endpoints with token and rate limiting hooks', () => {
const server = read('api/server.js');
const notifications = read('api/notification-service.js');
assert.match(server, /API_RATE_LIMIT_WINDOW_MS/);
assert.match(server, /API_RATE_LIMIT_MAX/);
assert.match(server, /rateLimitCriticalApi/);
assert.match(server, /Retry-After/);
assert.match(server, /timingSafeEqual/);
assert.match(server, /\/api\/config\/notifications/);
assert.match(notifications, /redactNotificationConfig/);
assert.match(notifications, /sendTelegramNotification/);
assert.match(notifications, /sendWebhookNotification/);
});
test('dashboard HTML has balanced structural containers', () => { test('dashboard HTML has balanced structural containers', () => {
const html = read('frontend/index.html'); const html = read('frontend/index.html');
const openDivs = (html.match(/<div\b/g) || []).length; const openDivs = (html.match(/<div\b/g) || []).length;

Loading…
Cancel
Save