diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index dd008ea..7ea1161 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -46,6 +46,8 @@ ventana de procesamiento en vez de bloquear un segundo por sensor. - `API_AUTH_TOKEN` protege endpoints criticos cuando esta configurado. - El dashboard envia el token como `X-API-Token` desde almacenamiento local del 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. ## 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 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 - `EZO_MODE=demo`: adquisición y comandos simulados. diff --git a/README.md b/README.md index 7af3ba6..0979e89 100644 --- a/README.md +++ b/README.md @@ -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 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`. +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 @@ -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 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 - Lecturas actuales: `data/EZORTD.json`, `EZOPH.json`, `EZODO.json`, diff --git a/api/acquisition-service.js b/api/acquisition-service.js index a894240..8ef57b2 100644 --- a/api/acquisition-service.js +++ b/api/acquisition-service.js @@ -3,6 +3,7 @@ const fsPromises = require('node:fs/promises'); const path = require('node:path'); const { execFile } = require('node:child_process'); const { promisify } = require('node:util'); +const { dispatchAlarmNotifications } = require('./notification-service'); const execFileAsync = promisify(execFile); @@ -495,6 +496,7 @@ async function collectReadings() { }) ); const alarmEvents = await persistAlarmEvents(results, timestamp); + const notificationResults = await dispatchAlarmNotifications(alarmEvents); const prunedFiles = await enforceHistoryRetention( runtimeConfig.historyRetentionDays, new Date(timestamp) @@ -505,6 +507,7 @@ async function collectReadings() { timestamp, results, alarmEvents, + notificationResults, prunedFiles, enabledSensors: runtimeConfig.enabledSensors }; diff --git a/api/notification-service.js b/api/notification-service.js new file mode 100644 index 0000000..c922c70 --- /dev/null +++ b/api/notification-service.js @@ -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 +}; diff --git a/api/server.js b/api/server.js index a9f98f8..6bb1980 100644 --- a/api/server.js +++ b/api/server.js @@ -1,5 +1,6 @@ const express = require('express'); const cors = require('cors'); +const crypto = require('node:crypto'); const fs = require('node:fs/promises'); const path = require('node:path'); const { @@ -11,11 +12,18 @@ const { readRuntimeConfig, writeRuntimeConfig } = require('./acquisition-service'); +const { + readNotificationConfig, + redactNotificationConfig, + writeNotificationConfig +} = require('./notification-service'); const app = express(); const PORT = Number(process.env.PORT || 3000); const HOST = process.env.HOST || '127.0.0.1'; 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 ? path.resolve(process.env.LOGS_DIRECTORY) : path.join(__dirname, '..', 'logs'); @@ -42,6 +50,13 @@ const HISTORY_SENSOR_IDS = { ec: '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) { return csvText @@ -133,6 +148,12 @@ async function writeAlarmConfigFile(rawConfig) { app.use(cors()); 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('/data', express.static(path.join(__dirname, '..', 'data'), { etag: false, @@ -178,7 +199,7 @@ function requireApiToken(req, res, next) { const providedToken = req.get('X-API-Token') || ''; - if (providedToken === API_AUTH_TOKEN) { + if (tokensMatch(providedToken, API_AUTH_TOKEN)) { 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) => { const commandMode = detectMode(); 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 { const result = await executeEzoCommand( 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 { const thresholds = await writeAlarmConfigFile(req.body.thresholds || req.body); 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 { const config = await writeRuntimeConfig({ rate: req.body.rate }); 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 { const config = await writeRuntimeConfig({ 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 { await fs.mkdir(LOGS_DIRECTORY, { recursive: true }); await Promise.all( @@ -426,5 +517,6 @@ if (require.main === module) { module.exports = { app, HISTORY_FILES, - parseHistoryCsv + parseHistoryCsv, + resetRateLimits: () => rateLimitBuckets.clear() }; diff --git a/config/notifications.json b/config/notifications.json new file mode 100644 index 0000000..690b45c --- /dev/null +++ b/config/notifications.json @@ -0,0 +1,16 @@ +{ + "enabled": false, + "minSeverity": "CRITICAL", + "channels": { + "webhook": { + "enabled": false, + "url": "", + "headers": {} + }, + "telegram": { + "enabled": false, + "botToken": "", + "chatId": "" + } + } +} diff --git a/deployment/photobioreactor.env b/deployment/photobioreactor.env index 7915540..a760ecf 100644 --- a/deployment/photobioreactor.env +++ b/deployment/photobioreactor.env @@ -6,3 +6,8 @@ EZO_MODE=hardware EZO_ENABLED_SENSORS= # Configure un valor secreto para proteger comandos, calibracion y cambios criticos. 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 diff --git a/scripts/install-raspberry-pi.sh b/scripts/install-raspberry-pi.sh index a167ea2..3434a3c 100644 --- a/scripts/install-raspberry-pi.sh +++ b/scripts/install-raspberry-pi.sh @@ -8,6 +8,7 @@ fi PROJECT_SOURCE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" PROJECT_TARGET="/opt/photobioreactor" +CONFIG_TARGET="/etc/photobioreactor" SERVICE_USER="photobioreactor" apt-get update @@ -18,6 +19,7 @@ if ! id "${SERVICE_USER}" >/dev/null 2>&1; then fi 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}/" rm -rf "${PROJECT_TARGET}/node_modules" @@ -27,6 +29,8 @@ make -C sensors/EZOCommand chown -R "${SERVICE_USER}:${SERVICE_USER}" "${PROJECT_TARGET}" 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-acquisition.service /etc/systemd/system/ install -m 0644 deployment/nginx-photobioreactor.conf \ diff --git a/test/api.test.js b/test/api.test.js index 2c4d8d6..cc6b407 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -9,6 +9,7 @@ const temporaryLogs = path.join(temporaryRoot, 'logs'); const temporaryData = path.join(temporaryRoot, 'data'); const temporaryRuntime = path.join(temporaryRoot, 'runtime.json'); const temporaryAlarms = path.join(temporaryRoot, 'alarms.json'); +const temporaryNotifications = path.join(temporaryRoot, 'notifications.json'); fs.mkdirSync(temporaryLogs, { recursive: true }); fs.mkdirSync(temporaryData, { recursive: true }); fs.writeFileSync(temporaryAlarms, JSON.stringify({ @@ -17,19 +18,34 @@ fs.writeFileSync(temporaryAlarms, JSON.stringify({ do: { min: 4, max: 12 }, 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.DATA_DIRECTORY = temporaryData; process.env.RUNTIME_CONFIG_FILE = temporaryRuntime; process.env.ALARM_CONFIG_FILE = temporaryAlarms; +process.env.NOTIFICATION_CONFIG_FILE = temporaryNotifications; process.env.EZO_MODE = 'demo'; 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 { collectReadings, pruneCsvByRetention, publishReading } = require('../api/acquisition-service'); +const { + dispatchAlarmNotifications, + writeNotificationConfig +} = require('../api/notification-service'); let server; let baseUrl; @@ -109,6 +125,8 @@ test('serves the dashboard in local development mode', async () => { const html = await response.text(); 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/); }); @@ -125,6 +143,20 @@ test('protects critical API endpoints when token is configured', async () => { assert.equal(response.status, 401); 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 () => { @@ -335,6 +367,95 @@ test('rejects invalid alarm thresholds', async () => { 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 () => { await fetch(`${baseUrl}/api/config/runtime`, { method: 'POST', diff --git a/test/static.test.js b/test/static.test.js index 3be2252..8b87179 100644 --- a/test/static.test.js +++ b/test/static.test.js @@ -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', () => { const html = read('frontend/index.html'); const openDivs = (html.match(/