feat: add production deployment and runtime safeguards

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Gustavo
2026-09-28 15:48:44 -03:00
co-authored by Claude Sonnet 5
parent 8ce2fcdfea
commit a9ef4d776d
23 changed files with 1696 additions and 8 deletions
+89
View File
@@ -0,0 +1,89 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createTelegramAlerter } from '../src/alerts/telegram.mjs';
import { loadDotEnv, telegramConfig } from '../src/env.mjs';
test('telegram alert skips when disabled or missing config', async () => {
const calls = [];
const alerter = createTelegramAlerter({ enabled: false, token: '', chatId: '', fetchImpl: async (...args) => calls.push(args) });
assert.deepEqual(await alerter.alert('qr', 'QR necessario'), { sent: false, reason: 'disabled' });
assert.equal(calls.length, 0);
const missingConfigAlerter = createTelegramAlerter({ enabled: true, token: '', chatId: '', fetchImpl: async (...args) => calls.push(args) });
assert.deepEqual(await missingConfigAlerter.alert('qr', 'QR necessario'), { sent: false, reason: 'not_configured' });
assert.equal(calls.length, 0);
});
test('telegram alert sends message without leaking token to text', async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url, options });
return { ok: true, json: async () => ({ ok: true }) };
};
const alerter = createTelegramAlerter({ enabled: true, token: '123:SECRET', chatId: '456', fetchImpl });
assert.deepEqual(await alerter.alert('qr', 'WhatsApp precisa de QR'), { sent: true });
assert.equal(calls.length, 1);
assert.match(calls[0].url, /sendMessage$/);
const payload = JSON.parse(calls[0].options.body);
assert.equal(payload.chat_id, '456');
assert.equal(payload.text, 'WhatsApp precisa de QR');
assert.doesNotMatch(payload.text, /SECRET/);
});
test('telegram alert deduplicates per key within cooldown', async () => {
let current = 1000;
const calls = [];
const fetchImpl = async (...args) => {
calls.push(args);
return { ok: true, json: async () => ({ ok: true }) };
};
const alerter = createTelegramAlerter({ enabled: true, token: 't', chatId: 'c', fetchImpl, now: () => current, cooldownMs: 1000 });
assert.equal((await alerter.alert('same', 'A')).sent, true);
assert.deepEqual(await alerter.alert('same', 'A again'), { sent: false, reason: 'cooldown' });
current = 2501;
assert.equal((await alerter.alert('same', 'A later')).sent, true);
assert.equal(calls.length, 2);
});
test('telegram alert sends first message when clock starts at zero', async () => {
const calls = [];
const fetchImpl = async (...args) => {
calls.push(args);
return { ok: true, json: async () => ({ ok: true }) };
};
const alerter = createTelegramAlerter({ enabled: true, token: 't', chatId: 'c', fetchImpl, now: () => 0, cooldownMs: 1000 });
assert.equal((await alerter.alert('zero', 'First')).sent, true);
assert.deepEqual(await alerter.alert('zero', 'Second'), { sent: false, reason: 'cooldown' });
assert.equal(calls.length, 1);
});
test('telegram config reflects values loaded from dotenv', async () => {
const keys = ['TELEGRAM_ALERTS_ENABLED', 'TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID'];
const previous = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'telegram-env-'));
try {
for (const key of keys) delete process.env[key];
await fs.writeFile(
path.join(rootDir, '.env'),
'TELEGRAM_ALERTS_ENABLED=true\nTELEGRAM_BOT_TOKEN=123:SECRET\nTELEGRAM_CHAT_ID=456\n'
);
await loadDotEnv(rootDir);
assert.deepEqual(telegramConfig(), {
enabled: true,
botToken: '123:SECRET',
chatId: '456'
});
} finally {
for (const key of keys) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
}
});
+70
View File
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { createBackup, isCliEntryPoint } from '../scripts/backup-data.mjs';
test('backup creates data copy and keeps configured retention', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-backup-'));
const dataDir = path.join(tmp, 'data');
const backupsDir = path.join(dataDir, 'backups');
const authDir = path.join(dataDir, 'runtime', 'whatsapp-auth');
await fs.mkdir(authDir, { recursive: true });
await fs.mkdir(path.join(backupsDir, 'maquina-pessoal-2026-08-01'), { recursive: true });
await fs.mkdir(path.join(backupsDir, 'maquina-pessoal-2026-08-02'), { recursive: true });
await fs.writeFile(path.join(dataDir, 'maquina-pessoal.db'), 'db');
await fs.writeFile(path.join(authDir, 'session.json'), 'auth');
try {
const result = await createBackup({ dataDir, now: () => new Date('2026-08-03T10:00:00Z'), retention: 1 });
assert.match(result.file, /maquina-pessoal-2026-08-03$/);
assert.equal(await fs.readFile(path.join(result.file, 'maquina-pessoal.db'), 'utf8'), 'db');
assert.equal(await fs.readFile(path.join(result.file, 'whatsapp-auth', 'session.json'), 'utf8'), 'auth');
assert.deepEqual(await fs.readdir(backupsDir), ['maquina-pessoal-2026-08-03']);
} finally {
await fs.rm(tmp, { recursive: true, force: true });
}
});
test('CLI guard matches Windows file URLs from argv paths', () => {
assert.equal(
isCliEntryPoint('file:///D:/PROJETOS/COMERCIAL%20GTI/MAQUINA%20COMERCIAL%20PESSOAL/scripts/backup-data.mjs', 'D:\\PROJETOS\\COMERCIAL GTI\\MAQUINA COMERCIAL PESSOAL\\scripts\\backup-data.mjs'),
true
);
});
test('backup captures committed WAL data while source database remains open', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-backup-wal-'));
const dataDir = path.join(tmp, 'data');
const authDir = path.join(dataDir, 'runtime', 'whatsapp-auth');
const dbPath = path.join(dataDir, 'maquina-pessoal.db');
let db;
await fs.mkdir(authDir, { recursive: true });
await fs.writeFile(path.join(authDir, 'session.json'), 'auth');
try {
db = new DatabaseSync(dbPath);
db.exec('PRAGMA journal_mode = WAL;');
db.exec('CREATE TABLE records (id INTEGER PRIMARY KEY, name TEXT NOT NULL);');
db.exec("INSERT INTO records (name) VALUES ('committed in wal');");
await fs.stat(`${dbPath}-wal`);
const result = await createBackup({ dataDir, now: () => new Date('2026-08-03T10:00:00Z'), retention: 1 });
const backupDb = new DatabaseSync(path.join(result.file, 'maquina-pessoal.db'), { readOnly: true });
try {
assert.deepEqual(backupDb.prepare('SELECT name FROM records').all().map((row) => row.name), ['committed in wal']);
} finally {
backupDb.close();
}
} finally {
db?.close();
await fs.rm(tmp, { recursive: true, force: true });
}
});
+39
View File
@@ -177,6 +177,33 @@ async function renderTreatment(app, rows) {
return app.element('tabela-leads-tbody');
}
test('docker deployment files preserve persistent data and do not include secrets', async () => {
const dockerfile = await read('Dockerfile');
const compose = await read('docker-compose.yml');
const envExample = await read('.env.production.example');
const caddy = await read('Caddyfile.example');
assert.match(dockerfile, /FROM node:24/);
assert.match(compose, /restart: unless-stopped/);
assert.match(compose, /APP_HOST=0\.0\.0\.0/);
assert.match(compose, /APP_DB_PATH=\/app\/data\/maquina-pessoal\.db/);
assert.match(compose, /APP_WHATSAPP_AUTH_DIR=\/app\/data\/runtime\/whatsapp-auth/);
assert.match(caddy, /reverse_proxy app:3910/);
assert.doesNotMatch(envExample, /[0-9]{5,}:[A-Za-z0-9_-]{20,}/);
});
test('oracle deployment runbook changes default admin password before public ingress', async () => {
const runbook = await read('docs/deployment/oracle-free-docker.md');
const firstPrivateStep = runbook.indexOf('Do not open `80/tcp`, `443/tcp`, or public DNS yet.');
const passwordChangeStep = runbook.indexOf('Open **Configuracoes > Alterar minha senha**');
const publicIngressStep = runbook.indexOf('open OCI ingress for `80/tcp` and `443/tcp` from the internet');
assert.ok(firstPrivateStep > -1);
assert.ok(passwordChangeStep > firstPrivateStep);
assert.ok(publicIngressStep > passwordChangeStep);
assert.match(runbook, /127\.0\.0\.1:3910:3910/);
});
test('interface e arquivos operacionais usam somente a identidade pessoal', async () => {
const files = await Promise.all([
'public/index.html',
@@ -231,6 +258,18 @@ test('fontes usam os nomes de ambiente documentados', async () => {
assert.doesNotMatch(placesText, /GOOGLE_MAPS_API_KEY/);
});
test('env exposes APP_HOST with local-safe fallback', async () => {
const previous = process.env.APP_HOST;
process.env.APP_HOST = '0.0.0.0';
try {
const env = await import(`../src/env.mjs?host-test=${Date.now()}`);
assert.equal(env.APP_HOST, '0.0.0.0');
} finally {
if (previous === undefined) delete process.env.APP_HOST;
else process.env.APP_HOST = previous;
}
});
test('catalogo preserva segmentos com um servico neutro por segmento', async () => {
const config = JSON.parse(await read('prospeccao/config.json'));
assert.ok(config.segments.length > 0);
+116
View File
@@ -0,0 +1,116 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
hasCampaignsPausedByRestart,
notifyInitialReconnectFailure,
notifyStartupState,
notifyWhatsAppTransition,
startInitialWhatsAppReconnect
} from '../src/alerts/runtime-alerts.mjs';
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
test('startup alerts notify restart pause and QR requirement once', async () => {
const alerts = [];
const alerter = { alert: async (key, text) => alerts.push({ key, text }) };
await notifyStartupState({
alerter,
whatsappStatus: 'QR_REQUIRED',
campaignsPausedByRestart: true
});
assert.deepEqual(alerts.map((alert) => alert.key), ['campaigns-paused-restart', 'whatsapp-qr']);
});
test('startup alert delivery failures are logged and do not reject startup', async () => {
const logs = [];
const alerter = { alert: async () => { throw new Error('telegram token SECRET failed'); } };
await assert.doesNotReject(() => notifyStartupState({
alerter,
whatsappStatus: 'QR_REQUIRED',
campaignsPausedByRestart: true,
log: (message) => logs.push(message)
}));
assert.equal(logs.length, 2);
assert.match(logs[0], /telegram: alerta campaigns-paused-restart falhou/);
assert.match(logs[1], /telegram: alerta whatsapp-qr falhou/);
assert.doesNotMatch(logs.join('\n'), /SECRET|token/i);
});
test('initial WhatsApp reconnect failure alerts operator without real send', async () => {
const alerts = [];
const logs = [];
let resolveAlert;
const alerted = new Promise((resolve) => { resolveAlert = resolve; });
const alerter = {
alert: async (key, text) => {
alerts.push({ key, text });
resolveAlert();
}
};
const started = await startInitialWhatsAppReconnect({
hasSession: async () => true,
whatsapp: { connect: async () => { throw new Error('falha fake'); } },
alerter,
log: (message) => logs.push(message)
});
await alerted;
assert.equal(started, true);
assert.deepEqual(alerts.map((alert) => alert.key), ['whatsapp-initial-reconnect-failed']);
assert.match(logs[0], /reconexao inicial falhou: falha fake/);
});
test('initial reconnect alert delivery failures are logged and do not reject', async () => {
const logs = [];
const alerter = { alert: async () => { throw new Error('telegram token SECRET failed'); } };
await assert.doesNotReject(() => notifyInitialReconnectFailure({
alerter,
error: new Error('falha fake'),
log: (message) => logs.push(message)
}));
assert.match(logs[0], /reconexao inicial falhou: falha fake/);
assert.match(logs[1], /telegram: alerta whatsapp-initial-reconnect-failed falhou/);
assert.doesNotMatch(logs.join('\n'), /SECRET|token/i);
});
test('WhatsApp transition alerts when leaving READY and requiring QR', async () => {
const alerts = [];
const alerter = { alert: async (key, text) => alerts.push({ key, text }) };
await notifyWhatsAppTransition({ alerter, previousState: 'READY', nextState: 'QR_REQUIRED' });
assert.deepEqual(alerts.map((alert) => alert.key), ['whatsapp-left-ready', 'whatsapp-qr']);
});
test('restart pause detector only matches paused campaigns with server_restart reason', () => {
let sql;
const db = {
prepare: (statement) => {
sql = statement;
return { get: () => ({ found: 1 }) };
}
};
assert.equal(hasCampaignsPausedByRestart(db), true);
assert.match(sql, /status = 'paused'/);
assert.match(sql, /pause_reason = 'server_restart'/);
});
test('server startup does not await Telegram startup alert delivery before listen', async () => {
const source = await fs.readFile(path.join(root, 'scripts', 'servidor.mjs'), 'utf8');
assert.doesNotMatch(source, /await notifyStartupState/);
assert.match(source, /void notifyStartupState\(\{[^}]*log/s);
assert.ok(source.indexOf('notifyStartupState') < source.indexOf('server.listen'));
});
+37
View File
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../src/db.mjs';
import { createAppServer } from '../src/server/index.mjs';
async function startTestServer() {
const db = openDb(':memory:');
const jobs = { subscribe: () => null, activeJobs: () => [] };
const whatsapp = {
getStatus: () => ({ state: 'READY', connected: true }),
sendText: async () => { throw new Error('sendText nao deveria ser chamado'); }
};
const server = createAppServer({ db, jobs, whatsapp });
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const close = async () => {
await new Promise((resolve) => server.close(resolve));
db.close();
};
return { server, baseUrl, close };
}
test('health endpoint is public and does not expose secrets', async () => {
const { server, baseUrl, close } = await startTestServer();
try {
const res = await fetch(`${baseUrl}/api/health`);
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.ok, true);
assert.ok(body.app);
assert.ok(body.database);
assert.ok(body.whatsapp);
assert.doesNotMatch(JSON.stringify(body), /token|password|secret|cookie/i);
} finally {
await close(server);
}
});