feat: add production deployment and runtime safeguards
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8ce2fcdfea
commit
a9ef4d776d
@@ -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];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user