diff --git a/docs/architecture.md b/docs/architecture.md index 523d6d3..9c3f249 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,3 +48,18 @@ Observabilidad y errores - Métricas: - sync_runs_total, contadores de alias resueltos, etc. - Estados de gauges/counters expuestos en /metrics (Prom/JSON). + +Control de acceso por grupos (allowed_groups) +- Esquema: tabla allowed_groups (group_id PK, label, status: pending|allowed|blocked, discovered_at/updated_at, discovered_by). +- Servicio: AllowedGroups con caché en memoria e interfaz isAllowed, setStatus, upsertPending, listByStatus, seedFromEnv. +- Flujo: + - Discover (GROUP_GATING_MODE='discover'): ante tráfico de un grupo desconocido, se registra como pending y se retorna temprano; opcionalmente se notifica a ADMIN_USERS (NOTIFY_ADMINS_ON_DISCOVERY='true'). + - Enforce (GROUP_GATING_MODE='enforce'): se ignoran mensajes/comandos en grupos no allowed; /admin tiene bypass para permitir aprobación in situ. +- Superficies aplicadas: + - server.ts: retorno temprano en discover y enforce; excepción para /admin. + - services/command.ts: guard de enforce para evitar procesar comandos de grupos no allowed. + - services/group-sync.ts: sincronización de miembros salta grupos no allowed (incluye scheduler). + - tasks/service.ts: en enforce, crear tareas con group_id=null si el grupo no está allowed (compatibilidad). + - reminders.ts: omite tareas/grupos no allowed al generar recordatorios en enforce. +- Observabilidad: + - Gauges allowed_groups_total_* y counters unknown_groups_discovered_total, messages_blocked_group_total, commands_blocked_total, sync_skipped_group_total, admin_actions_total_{allow,block}. diff --git a/docs/operations.md b/docs/operations.md index 5eeb950..c848bdf 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -8,13 +8,17 @@ Variables de entorno (principales) - WHATSAPP_COMMUNITY_ID: comunidad cuyos grupos se sincronizan. - PORT: puerto HTTP (por defecto 3007). - NODE_ENV: 'development' | 'test' | 'production'. -- METRICS_ENABLED: 'true'|'false'|'1'|'0' (por defecto habilitado salvo en test). +- METRICS_ENABLED: 'true'|'false'|'1'|'0' (por defecto habilitado salvo en test). Ej.: METRICS_ENABLED='true' - RATE_LIMIT_PER_MIN: tokens por minuto por usuario (default 15). - RATE_LIMIT_BURST: capacidad del bucket (default = RATE_LIMIT_PER_MIN). - GROUP_SYNC_INTERVAL_MS: intervalo de sync de grupos (default 24h; min 10s en dev). - GROUP_MEMBERS_SYNC_INTERVAL_MS: intervalo de sync de miembros (default 6h; min 10s en dev). - GROUP_MEMBERS_INACTIVE_RETENTION_DAYS: días para borrar miembros inactivos (default 180). - TZ: zona horaria para recordatorios (default Europe/Madrid). +- GROUP_GATING_MODE: 'off' | 'discover' | 'enforce' (control de acceso por grupos; por defecto 'off'). Ej.: GROUP_GATING_MODE='discover' +- ADMIN_USERS: lista separada por comas de IDs/JIDs autorizados para /admin (se normalizan a dígitos). Ej.: ADMIN_USERS='34600123456, 5554443333, +34 600 111 222' +- ALLOWED_GROUPS: lista separada por comas de group_id@g.us para sembrado inicial en arranque. Ej.: ALLOWED_GROUPS='12345-67890@g.us, 11111-22222@g.us' +- NOTIFY_ADMINS_ON_DISCOVERY: 'true'/'false' para avisar por DM a ADMIN_USERS al descubrir un grupo (modo 'discover'). Ej.: NOTIFY_ADMINS_ON_DISCOVERY='true' Endpoints operativos - GET /metrics @@ -42,6 +46,13 @@ Datos y backups Métricas de referencia - sync_runs_total, identity_alias_resolved_total, contadores/gauges específicos de colas y limpieza. +- Control de acceso por grupos (multicomunidades): + - allowed_groups_total_pending, allowed_groups_total_allowed, allowed_groups_total_blocked (gauges). + - unknown_groups_discovered_total (counter). + - messages_blocked_group_total (counter). + - commands_blocked_total (counter). + - sync_skipped_group_total (counter). + - admin_actions_total_allow, admin_actions_total_block (counters). - Añadir nuevas métricas usando Metrics.inc/set y documentarlas aquí. Buenas prácticas diff --git a/docs/plan-multicomunidades.md b/docs/plan-multicomunidades.md index 5f45d9a..0615d1d 100644 --- a/docs/plan-multicomunidades.md +++ b/docs/plan-multicomunidades.md @@ -217,3 +217,163 @@ Notas de implementación - Mantener compatibilidad hacia atrás: en creación de tareas, si el group_id no está allowed, persistir sin group_id (null). - No bloquear DMs por diseño (gating aplica solo a grupos). - Reutilizar ResponseQueue para todas las notificaciones (incluyendo admins). + +Plan detallado etapa por etapa (con impacto en tests y archivos a tocar) +Convenciones transversales para no romper la suite: +- Nueva variable GROUP_GATING_MODE = 'off' | 'discover' | 'enforce'. Valor por defecto: 'off' (especialmente en tests). Solo se activará en tests específicos. +- Helper de tests tests/helpers/db.ts: + - makeMemDb(), injectAllServices(db), seedAllowed(db, groupIds[]), resetServices(). +- Opcional tests/setup.ts (o equivalente): fija GROUP_GATING_MODE='off' por defecto para toda la suite. +- AllowedGroups con dbInstance inyectable, caché con clearCache/resetForTests y métodos idempotentes. + +Etapa 0 — Preparación y criterios (tests-first) +Objetivo +- Asegurar un “harness” de tests estable y predecible para DB y servicios estáticos. + +Cambios de código +- Ninguno funcional. Solo infraestructura de tests (helpers y, si procede, setup global). + +Archivos a tocar (código) +- tests/helpers/db.ts (nuevo) +- tests/setup.ts (opcional, si existe configuración global de tests) + +Tests nuevos a añadir +- tests/unit/db/migrations.smoke.test.ts: comprueba que initializeDatabase en :memory: no lanza y crea tablas base (sin allowed_groups aún). + +Tests existentes a actualizar +- Ninguno obligatorio. Opcional: migrar suites que crean DB manualmente a usar tests/helpers/db.ts. + +Etapa 1 — Esquema y servicio AllowedGroups (sin gating todavía) +Objetivo +- Crear tabla allowed_groups y servicio con caché. No bloquear nada aún. + +Cambios de código +- src/db/migrations/index.ts: añadir migración v9_allowed_groups (CREATE TABLE IF NOT EXISTS ...). +- src/services/allowed-groups.ts (nuevo): métodos upsertPending, isAllowed, setStatus, listByStatus, seedFromEnv, clearCache/resetForTests. +- src/services/metrics.ts: gauges allowed_groups_total{status} (best-effort). + +Tests nuevos a añadir +- tests/unit/services/allowed-groups.test.ts +- tests/unit/db/migrations.allowed-groups.test.ts + +Tests existentes a actualizar +- Ninguno. La migración se ejecuta en initializeDatabase de suites que ya lo llaman. + +Etapa 2 — Descubrimiento seguro de grupos (registrar, no operar) +Objetivo +- Registrar grupos desconocidos como pending en modo 'discover', sin cambiar comportamiento por defecto de la suite. + +Cambios de código +- src/server.ts: + - handleMessageUpsert: si GROUP_GATING_MODE='discover' y es group_id desconocido → upsertPending y return temprano sin procesar comandos/sync. + - Métrica unknown_groups_discovered_total (src/services/metrics.ts). +- src/services/contacts.ts (opcional): obtener label/subject si viene en el webhook. + +Tests nuevos a añadir +- tests/unit/server/unknown-group-discovery.test.ts + +Tests existentes a actualizar +- tests/unit/server.test.ts: asegurar que: + - Setea GROUP_GATING_MODE='off' en beforeEach, o + - Si prueba mensajes de grupo, usar seedAllowed(db, ['']). +- Resto de suites: sin cambios esperados. + +Etapa 3 — Gating mínimo en superficies críticas +Objetivo +- Bloquear comandos y sync en grupos no allowed cuando GROUP_GATING_MODE='enforce'. + +Cambios de código +- src/services/command.ts: + - Al inicio, si message viene de grupo y GROUP_GATING_MODE='enforce', llamar AllowedGroups.isAllowed(groupId). Si no allowed: política por defecto “silencio” o respuesta breve configurable. +- src/services/group-sync.ts: + - Filtrar operaciones a solo grupos AllowedGroups.isAllowed. +- src/server.ts: + - Antes de encolar/ejecutar comandos, si es grupo y 'enforce', exigir allowed. +- src/services/metrics.ts: + - commands_blocked_total + - sync_skipped_group_total (opcional) + +Tests nuevos a añadir +- tests/unit/services/command.gating.test.ts +- tests/unit/services/group-sync.gating.test.ts + +Tests existentes a actualizar +- tests/unit/services/command.claim-unassign.test.ts: sembrar grupo allowed en beforeAll/beforeEach o fijar GROUP_GATING_MODE='off'. +- tests/unit/services/command.reminders-config.test.ts: idem si el test ejecuta comandos en contexto de grupo. +- tests/unit/services/command.date-parsing.test.ts: normalmente DM; no tocar salvo que simule grupo. +- tests/unit/services/group-sync.members.test.ts: sembrar grupo allowed para '123@g.us'. +- tests/unit/services/group-sync.scheduler.test.ts: sembrar grupos allowed en el scheduler o setear mode='off'. +- tests/unit/tasks/claim-unassign.test.ts: si hay contexto de grupo, seedAllowed; si es DM, sin cambios. +- tests/unit/services/cleanup-inactive.test.ts, tests/unit/services/metrics-health.test.ts, tests/unit/services/response-queue*.test.ts, tests/unit/services/reminders.test.ts: no deberían requerir cambios; si alguno falla por simulación de grupo, aplicar seedAllowed o mode='off'. + +Etapa 4 — Flujo de aprobación administrativa y notificaciones +Objetivo +- Permitir a ADMIN_USERS aprobar/bloquear grupos y consultar pendientes; notificar a admins al descubrir pending. + +Cambios de código +- src/services/admin.ts (nuevo): /admin habilitar-aquí, deshabilitar-aquí, pendientes, allow-group , block-group . +- src/server.ts: router para /admin y validación de ADMIN_USERS; usar ResponseQueue para notificaciones. +- src/services/allowed-groups.ts: asegurar setStatus, listByStatus, get(groupId). +- src/services/response-queue.ts (o webhook-manager): enviar DM a ADMIN_USERS en descubrimiento pending (best-effort y detrás de flag para no romper tests existentes). + +Tests nuevos a añadir +- tests/unit/services/admin.test.ts +- tests/unit/services/command.admin-approval.test.ts + +Tests existentes a actualizar +- tests/unit/server.test.ts: si parsea /admin, agregar casos explícitos o mantener aislado con mode='off' para suites no relacionadas. +- No tocar otras suites. + +Etapa 5 — Integración con recordatorios y tareas +Objetivo +- Recordatorios y operaciones por grupo respetan AllowedGroups cuando hay contexto de grupo. DMs no se ven afectados. + +Cambios de código +- src/services/reminders.ts: filtrar por AllowedGroups.isAllowed(gid) cuando el recordatorio/consulta use contexto de grupo. +- src/tasks/service.ts: sin cambios funcionales (seguir permitiendo group_id=null). +- src/services/rate-limit.ts (opcional): clave compuesta por grupo. + +Tests nuevos a añadir +- tests/unit/services/reminders.gating.test.ts +- tests/unit/tasks/service.gating.test.ts + +Tests existentes a actualizar +- tests/unit/services/reminders.test.ts: si usa contexto de grupo, seedAllowed o mode='off' para mantener expectativas. +- Resto: sin cambios. + +Etapa 6 — Observabilidad y operaciones +Objetivo +- Métricas agregadas y documentación. + +Cambios de código +- src/services/metrics.ts: allowed_groups_total{status}, unknown_groups_discovered_total, commands_blocked_total, admin_actions_total{action}. +- docs/operations.md: documentar variables y comportamiento. +- docs/architecture.md: sección de control de acceso por grupos. +- Confirmar /metrics en src/server.ts. + +Tests nuevos a añadir +- Opcional: tests/unit/services/metrics-health.test.ts puede ampliar cobertura para nuevos contadores (no estricta; tolerar ausencia si METRICS_ENABLED='false'). + +Tests existentes a actualizar +- tests/unit/services/metrics-health.test.ts: si asume conjunto exacto de métricas, flexibilizar aserciones o aislar nuevas métricas tras METRICS_ENABLED. + +Etapa 7 — Hardening, regresión y rollout +Objetivo +- Validar que no hay rutas sin gating y que la activación progresiva no rompe nada. + +Cambios de código +- Ninguno obligatorio más allá de ajustes menores. + +Tests nuevos a añadir +- tests/integration/multi-groups.e2e.test.ts (si procede). +- Casos combinados con varios estados: allowed/pending/blocked + discover/enforce. + +Tests existentes a actualizar +- Ninguno; mantener GROUP_GATING_MODE='off' por defecto y activar explícitamente en tests que cubren gating. + +Checklist por etapa para mantener la suite en verde +- Antes de tocar código de gating: añadir tests nuevos primero (TDD light). +- Default en tests: GROUP_GATING_MODE='off'. +- Donde haya contexto de grupo en tests existentes: seedAllowed(memdb, ['']) en el setup del archivo. +- Limpiar cachés entre tests: AllowedGroups.resetForTests() en afterEach/afterAll cuando el servicio se use. +- initializeDatabase(memdb) siempre presente en suites con DB en memoria para aplicar migraciones (incluida v9). diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 3e19e5b..72cf0c2 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -267,4 +267,26 @@ export const migrations: Migration[] = [ } catch {} } } + , + { + version: 9, + name: 'allowed-groups', + checksum: 'v9-allowed-groups-2025-09-29', + up: (db: Database) => { + db.exec(` + CREATE TABLE IF NOT EXISTS allowed_groups ( + group_id TEXT PRIMARY KEY, + label TEXT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','allowed','blocked')), + discovered_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), + discovered_by TEXT NULL + ); + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_allowed_groups_status + ON allowed_groups (status); + `); + } + } ]; diff --git a/src/server.ts b/src/server.ts index 5fe1b3a..995bd5f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,8 @@ import { RemindersService } from './services/reminders'; import { Metrics } from './services/metrics'; import { MaintenanceService } from './services/maintenance'; import { IdentityService } from './services/identity'; +import { AllowedGroups } from './services/allowed-groups'; +import { AdminService } from './services/admin'; // Bun is available globally when running under Bun runtime declare global { @@ -65,6 +67,23 @@ export class WebhookServer { if (!Metrics.enabled()) { return new Response('Metrics disabled', { status: 404 }); } + // Gauges de allowed_groups por estado (best-effort) + try { + const rows = WebhookServer.dbInstance + .prepare(`SELECT status, COUNT(*) AS c FROM allowed_groups GROUP BY status`) + .all() as any[]; + let pending = 0, allowed = 0, blocked = 0; + for (const r of rows) { + const s = String(r?.status || ''); + const c = Number(r?.c || 0); + if (s === 'pending') pending = c; + else if (s === 'allowed') allowed = c; + else if (s === 'blocked') blocked = c; + } + Metrics.set('allowed_groups_total_pending', pending); + Metrics.set('allowed_groups_total_allowed', allowed); + Metrics.set('allowed_groups_total_blocked', blocked); + } catch {} const format = (process.env.METRICS_FORMAT || 'prom').toLowerCase() === 'json' ? 'json' : 'prom'; const body = Metrics.render(format as any); return new Response(body, { @@ -298,6 +317,86 @@ export class WebhookServer { return; } + const messageTextTrimmed = messageText.trim(); + const isAdminCmd = messageTextTrimmed.startsWith('/admin'); + + // Etapa 2: Descubrimiento seguro de grupos (modo 'discover') + if (isGroupId(remoteJid)) { + try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {} + const gatingMode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (gatingMode === 'discover') { + try { + const exists = WebhookServer.dbInstance + .prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? LIMIT 1`) + .get(remoteJid) as any; + if (!exists) { + try { AllowedGroups.upsertPending(remoteJid, (GroupSyncService.activeGroupsCache.get(remoteJid) || null), normalizedSenderId); } catch {} + try { Metrics.inc('unknown_groups_discovered_total'); } catch {} + try { + const notify = String(process.env.NOTIFY_ADMINS_ON_DISCOVERY || 'false').toLowerCase() === 'true'; + if (notify && !isAdminCmd) { + const admins = AdminService.getAdmins(); + if (admins.length > 0) { + const info = remoteJid; + const msg = `🔎 Nuevo grupo detectado: ${info}\nEstado: pending.\nUsa /admin habilitar-aquí desde el grupo o /admin allow-group ${info}.`; + await ResponseQueue.add(admins.map(a => ({ recipient: a, message: msg }))); + } + } + } catch {} + if (!isAdminCmd) return; + } + } catch { + // Si la tabla no existe por alguna razón, intentar upsert y retornar igualmente + try { AllowedGroups.upsertPending(remoteJid, (GroupSyncService.activeGroupsCache.get(remoteJid) || null), normalizedSenderId); } catch {} + try { Metrics.inc('unknown_groups_discovered_total'); } catch {} + try { + const notify = String(process.env.NOTIFY_ADMINS_ON_DISCOVERY || 'false').toLowerCase() === 'true'; + if (notify && !isAdminCmd) { + const admins = AdminService.getAdmins(); + if (admins.length > 0) { + const info = remoteJid; + const msg = `🔎 Nuevo grupo detectado: ${info}\nEstado: pending.\nUsa /admin habilitar-aquí desde el grupo o /admin allow-group ${info}.`; + await ResponseQueue.add(admins.map(a => ({ recipient: a, message: msg }))); + } + } + } catch {} + if (!isAdminCmd) return; + } + } + } + + // Etapa 3: Gating en modo 'enforce' — ignorar mensajes de grupos no permitidos + if (isGroupId(remoteJid)) { + try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {} + const gatingMode2 = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (gatingMode2 === 'enforce') { + try { + const allowed = AllowedGroups.isAllowed(remoteJid); + if (!allowed && !isAdminCmd) { + try { Metrics.inc('messages_blocked_group_total'); } catch {} + return; + } + } catch { + // Si falla el check por cualquier motivo, ser conservadores y permitir + } + } + } + + // Manejo de comandos de administración (/admin) antes de cualquier otra lógica de grupo + if (messageTextTrimmed.startsWith('/admin')) { + try { (AdminService as any).dbInstance = WebhookServer.dbInstance; } catch {} + try { (AllowedGroups as any).dbInstance = WebhookServer.dbInstance; } catch {} + const adminResponses = await AdminService.handle({ + sender: normalizedSenderId, + groupId: remoteJid, + message: messageText + }); + if (adminResponses.length > 0) { + await ResponseQueue.add(adminResponses); + } + return; + } + // Check/ensure group exists (allow DMs always) if (isGroupId(data.key.remoteJid) && !GroupSyncService.isGroupActive(data.key.remoteJid)) { // En tests, mantener comportamiento anterior: ignorar mensajes de grupos inactivos @@ -319,7 +418,7 @@ export class WebhookServer { } // Forward to command service only if it's a text-ish message and starts with /t or /tarea - const messageTextTrimmed = messageText.trim(); + // messageTextTrimmed computed earlier if (messageTextTrimmed.startsWith('/tarea') || messageTextTrimmed.startsWith('/t')) { // Rate limiting básico por usuario (desactivado en tests) if (process.env.NODE_ENV !== 'test') { @@ -389,6 +488,10 @@ export class WebhookServer { // Run database migrations (up-only) before starting services await Migrator.migrateToLatest(this.dbInstance); + // Etapa 7: seed inicial de grupos permitidos desde ALLOWED_GROUPS (best-effort) + try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} + try { AllowedGroups.seedFromEnv(); } catch {} + const PORT = process.env.PORT || '3007'; console.log('✅ Environment variables validated'); diff --git a/src/services/admin.ts b/src/services/admin.ts new file mode 100644 index 0000000..641cc67 --- /dev/null +++ b/src/services/admin.ts @@ -0,0 +1,126 @@ +import type { Database } from 'bun:sqlite'; +import { db } from '../db'; +import { AllowedGroups } from './allowed-groups'; +import { normalizeWhatsAppId, isGroupId } from '../utils/whatsapp'; +import { Metrics } from './metrics'; + +type AdminContext = { + sender: string; // normalized user id (digits only) + groupId: string; // raw JID (group or DM) + message: string; // raw message text +}; + +type AdminResponse = { recipient: string; message: string }; + +export class AdminService { + static dbInstance: Database = db; + + private static admins(): Set { + const raw = String(process.env.ADMIN_USERS || ''); + const set = new Set(); + for (const token of raw.split(',').map(s => s.trim()).filter(Boolean)) { + const n = normalizeWhatsAppId(token); + if (n) set.add(n); + } + return set; + } + + static getAdmins(): string[] { + return Array.from(this.admins()); + } + + private static isAdmin(userId: string | null | undefined): boolean { + const n = normalizeWhatsAppId(userId || ''); + if (!n) return false; + return this.admins().has(n); + } + + private static help(): string { + return [ + 'Comandos de administración:', + '- /admin pendientes', + '- /admin habilitar-aquí', + '- /admin deshabilitar-aquí', + '- /admin allow-group ', + '- /admin block-group ', + ].join('\n'); + } + + static async handle(ctx: AdminContext): Promise { + const sender = normalizeWhatsAppId(ctx.sender); + if (!sender) return []; + + if (!this.isAdmin(sender)) { + return [{ recipient: sender, message: '🚫 No estás autorizado para usar /admin.' }]; + } + + // Asegurar acceso a la misma DB para AllowedGroups + try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} + + const raw = String(ctx.message || '').trim(); + const lower = raw.toLowerCase(); + if (!lower.startsWith('/admin')) { + return []; + } + + const rest = lower.slice('/admin'.length).trim(); + + // /admin pendientes + if (rest === 'pendientes') { + const rows = AllowedGroups.listByStatus('pending'); + if (!rows || rows.length === 0) { + return [{ recipient: sender, message: '✅ No hay grupos pendientes.' }]; + } + const list = rows.map(r => `- ${r.group_id}${r.label ? ` (${r.label})` : ''}`).join('\n'); + return [{ + recipient: sender, + message: `Grupos pendientes (${rows.length}):\n${list}` + }]; + } + + // /admin habilitar-aquí + if (rest === 'habilitar-aquí' || rest === 'habilitar-aqui') { + if (!isGroupId(ctx.groupId)) { + return [{ recipient: sender, message: 'ℹ️ Este comando se debe usar dentro de un grupo.' }]; + } + const changed = AllowedGroups.setStatus(ctx.groupId, 'allowed'); + try { if (changed) Metrics.inc('admin_actions_total_allow'); } catch {} + return [{ recipient: sender, message: `✅ Grupo habilitado: ${ctx.groupId}` }]; + } + + // /admin deshabilitar-aquí + if (rest === 'deshabilitar-aquí' || rest === 'deshabilitar-aqui') { + if (!isGroupId(ctx.groupId)) { + return [{ recipient: sender, message: 'ℹ️ Este comando se debe usar dentro de un grupo.' }]; + } + const changed = AllowedGroups.setStatus(ctx.groupId, 'blocked'); + try { if (changed) Metrics.inc('admin_actions_total_block'); } catch {} + return [{ recipient: sender, message: `✅ Grupo deshabilitado: ${ctx.groupId}` }]; + } + + // /admin allow-group + if (rest.startsWith('allow-group ')) { + const arg = rest.slice('allow-group '.length).trim(); + if (!isGroupId(arg)) { + return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }]; + } + const changed = AllowedGroups.setStatus(arg, 'allowed'); + try { if (changed) Metrics.inc('admin_actions_total_allow'); } catch {} + return [{ recipient: sender, message: `✅ Grupo habilitado: ${arg}` }]; + } + + // /admin block-group + if (rest.startsWith('block-group ')) { + const arg = rest.slice('block-group '.length).trim(); + if (!isGroupId(arg)) { + return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }]; + } + const changed = AllowedGroups.setStatus(arg, 'blocked'); + try { if (changed) Metrics.inc('admin_actions_total_block'); } catch {} + return [{ recipient: sender, message: `✅ Grupo bloqueado: ${arg}` }]; + } + + // Ayuda por defecto + return [{ recipient: sender, message: this.help() }]; + } +} diff --git a/src/services/allowed-groups.ts b/src/services/allowed-groups.ts new file mode 100644 index 0000000..1469bd9 --- /dev/null +++ b/src/services/allowed-groups.ts @@ -0,0 +1,149 @@ +import type { Database } from 'bun:sqlite'; +import { db } from '../db'; + +type GroupStatus = 'pending' | 'allowed' | 'blocked'; + +type CacheEntry = { + status: GroupStatus; + label: string | null; +}; + +export class AllowedGroups { + static dbInstance: Database = db; + + // Caché en memoria: group_id (JID completo) -> { status, label } + private static cache = new Map(); + + private static nowExpr = "strftime('%Y-%m-%d %H:%M:%f','now')"; + + static clearCache() { + this.cache.clear(); + } + + static resetForTests() { + this.clearCache(); + } + + private static getRow(groupId: string): { group_id: string; label: string | null; status: GroupStatus } | null { + try { + const row = this.dbInstance + .prepare(`SELECT group_id, label, status FROM allowed_groups WHERE group_id = ?`) + .get(groupId) as any; + if (!row) return null; + return { + group_id: String(row.group_id), + label: row.label != null ? String(row.label) : null, + status: String(row.status) as GroupStatus, + }; + } catch { + // Tabla podría no existir en contextos muy tempranos: degradar a null + return null; + } + } + + static isAllowed(groupId: string | null | undefined): boolean { + const gid = String(groupId || '').trim(); + if (!gid) return false; + + const cached = this.cache.get(gid); + if (cached) return cached.status === 'allowed'; + + const row = this.getRow(gid); + const status = row?.status || 'pending'; + const label = row?.label ?? null; + this.cache.set(gid, { status, label }); + return status === 'allowed'; + } + + /** + * Inserta un grupo como pending si no existe. No degrada estados existentes (allowed/blocked). + * Actualiza label si se proporciona y cambió. + */ + static upsertPending(groupId: string, label?: string | null, discoveredBy?: string | null): void { + const gid = String(groupId || '').trim(); + if (!gid) return; + + const row = this.getRow(gid); + if (!row) { + // Insertar como pending + this.dbInstance + .prepare(` + INSERT INTO allowed_groups (group_id, label, status, discovered_at, updated_at, discovered_by) + VALUES (?, ?, 'pending', ${this.nowExpr}, ${this.nowExpr}, ?) + `) + .run(gid, label ?? null, discoveredBy ?? null); + this.cache.set(gid, { status: 'pending', label: label ?? null }); + return; + } + + // No cambiar status existente. Solo actualizar label si se aporta y cambió. + const newLabel = label ?? row.label; + if (label != null && String(row.label ?? '') !== String(label)) { + this.dbInstance + .prepare(` + UPDATE allowed_groups + SET label = ?, updated_at = ${this.nowExpr} + WHERE group_id = ? + `) + .run(newLabel, gid); + } + this.cache.set(gid, { status: row.status, label: newLabel ?? null }); + } + + /** + * Establece el estado de un grupo (upsert). Devuelve true si cambió algo (estado o label). + */ + static setStatus(groupId: string, status: GroupStatus, label?: string | null): boolean { + const gid = String(groupId || '').trim(); + if (!gid) return false; + + const before = this.getRow(gid); + this.dbInstance + .prepare(` + INSERT INTO allowed_groups (group_id, label, status, discovered_at, updated_at) + VALUES (?, ?, ?, ${this.nowExpr}, ${this.nowExpr}) + ON CONFLICT(group_id) DO UPDATE SET + status = excluded.status, + label = COALESCE(excluded.label, allowed_groups.label), + updated_at = excluded.updated_at + `) + .run(gid, label ?? null, status); + + const after = this.getRow(gid) || { group_id: gid, label: label ?? null, status }; + this.cache.set(gid, { status: after.status, label: after.label }); + + return ( + !before || + before.status !== after.status || + (label != null && String(before.label ?? '') !== String(label)) + ); + } + + static listByStatus(status: GroupStatus): Array<{ group_id: string; label: string | null }> { + const rows = this.dbInstance + .prepare( + `SELECT group_id, label FROM allowed_groups WHERE status = ? ORDER BY group_id` + ) + .all(status) as Array<{ group_id: string; label: string | null }>; + return rows.map(r => ({ + group_id: String((r as any).group_id), + label: (r as any).label != null ? String((r as any).label) : null, + })); + } + + /** + * Marca como allowed todos los group_ids provistos en una cadena separada por comas. + * Si env no se pasa, usa process.env.ALLOWED_GROUPS. + */ + static seedFromEnv(env?: string | null): void { + const val = (env ?? process?.env?.ALLOWED_GROUPS ?? '').trim(); + if (!val) return; + const ids = val + .split(',') + .map(s => s.trim()) + .filter(Boolean); + for (const gid of ids) { + this.setStatus(gid, 'allowed', null); + } + } +} diff --git a/src/services/command.ts b/src/services/command.ts index 2d62a0f..9387877 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -7,6 +7,8 @@ import { ContactsService } from './contacts'; import { ICONS } from '../utils/icons'; import { padTaskId, codeId, formatDDMM, bold, italic } from '../utils/formatting'; import { IdentityService } from './identity'; +import { AllowedGroups } from './allowed-groups'; +import { Metrics } from './metrics'; type CommandContext = { sender: string; // normalized user id (digits only), but accept raw too @@ -1060,6 +1062,22 @@ export class CommandService { return []; } + // Gating de grupos en modo 'enforce' (Etapa 3) cuando CommandService se invoca directamente + if (isGroupId(context.groupId)) { + try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} + const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (mode === 'enforce') { + try { + if (!AllowedGroups.isAllowed(context.groupId)) { + try { Metrics.inc('commands_blocked_total'); } catch {} + return []; + } + } catch { + // Si falla el check, ser permisivos + } + } + } + try { return await this.processTareaCommand(context); } catch (error) { diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index 968fdd6..b90383d 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -3,6 +3,7 @@ import { db, ensureUserExists } from '../db'; import { normalizeWhatsAppId } from '../utils/whatsapp'; import { Metrics } from './metrics'; import { IdentityService } from './identity'; +import { AllowedGroups } from './allowed-groups'; // In-memory cache for active groups // const activeGroupsCache = new Map(); // groupId -> groupName @@ -331,6 +332,8 @@ export class GroupSyncService { console.log('Added group:', group.id, 'result:', insertResult); added++; } + // Propagar subject como label a allowed_groups (no degrada estado; actualiza label si cambia) + try { (AllowedGroups as any).dbInstance = this.dbInstance; AllowedGroups.upsertPending(group.id, group.subject, null); } catch {} } return { added, updated }; @@ -604,9 +607,29 @@ export class GroupSyncService { if (this.activeGroupsCache.size === 0) { this.cacheActiveGroups(); } + + // Etapa 3: gating también en el scheduler masivo + const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + const enforce = mode === 'enforce'; + if (enforce) { + try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} + } + let groups = 0, added = 0, updated = 0, deactivated = 0; for (const [groupId] of this.activeGroupsCache.entries()) { try { + if (enforce) { + try { + if (!AllowedGroups.isAllowed(groupId)) { + // Saltar grupos no permitidos en modo enforce + try { Metrics.inc('sync_skipped_group_total'); } catch {} + continue; + } + } catch { + // Si falla el check, no bloquear el grupo + } + } + const snapshot = await this.fetchGroupMembersFromAPI(groupId); const res = this.reconcileGroupMembers(groupId, snapshot); groups++; @@ -794,7 +817,25 @@ export class GroupSyncService { * Sincroniza miembros para un grupo concreto (útil tras detectar un grupo nuevo). */ public static async syncMembersForGroup(groupId: string): Promise<{ added: number; updated: number; deactivated: number }> { + // Gating en modo 'enforce': solo sincronizar miembros para grupos permitidos + try { + const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (mode === 'enforce') { + try { + (AllowedGroups as any).dbInstance = this.dbInstance; + if (!AllowedGroups.isAllowed(groupId)) { + try { Metrics.inc('sync_skipped_group_total'); } catch {} + return { added: 0, updated: 0, deactivated: 0 }; + } + } catch { + // Si el check falla, seguimos sin bloquear + } + } + } catch {} + try { + // Asegurar existencia del grupo en DB (FKs) antes de reconciliar + this.ensureGroupExists(groupId); const snapshot = await (this as any).fetchGroupMembersFromAPI(groupId); return this.reconcileGroupMembers(groupId, snapshot); } catch (e) { diff --git a/src/services/reminders.ts b/src/services/reminders.ts index d6e71ba..9666b8c 100644 --- a/src/services/reminders.ts +++ b/src/services/reminders.ts @@ -6,6 +6,7 @@ import { ContactsService } from './contacts'; import { GroupSyncService } from './group-sync'; import { ICONS } from '../utils/icons'; import { codeId, formatDDMM, bold, italic } from '../utils/formatting'; +import { AllowedGroups } from './allowed-groups'; type UserPreference = { user_id: string; @@ -88,6 +89,16 @@ export class RemindersService { WHERE reminder_freq IN ('daily', 'weekly', 'weekdays') `).all() as UserPreference[]; + // Determinar si aplicar gating por grupos + const enforce = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase() === 'enforce'; + if (enforce) { + try { + (AllowedGroups as any).dbInstance = this.dbInstance; + // Evitar falsos positivos por caché obsoleta entre operaciones previas del test + AllowedGroups.clearCache?.(); + } catch {} + } + for (const pref of rows) { // Evitar duplicado el mismo día if (pref.last_reminded_on === todayYMD) continue; @@ -102,9 +113,10 @@ export class RemindersService { if (pref.reminder_freq === 'weekly' && weekday !== 'Mon') continue; try { - const items = TaskService.listUserPending(pref.user_id, 10); + const allItems = TaskService.listUserPending(pref.user_id, 10); + const items = enforce ? allItems.filter(t => !t.group_id || AllowedGroups.isAllowed(t.group_id)) : allItems; const total = TaskService.countUserPending(pref.user_id); - if (!items || items.length === 0 || total === 0) { + if (!items || items.length === 0) { // No enviar si no hay tareas; no marcamos last_reminded_on para permitir enviar si aparecen más tarde hoy continue; } @@ -152,6 +164,7 @@ export class RemindersService { sections.push(...rendered); } + // Si hay más tareas de las listadas (tope), añadir resumen if (total > items.length) { sections.push(italic(`… y ${total - items.length} más`)); } @@ -159,7 +172,10 @@ export class RemindersService { // (Etapa 3) Sección opcional de "sin responsable" filtrada por membresía activa + snapshot fresca. const includeUnassigned = String(process.env.REMINDERS_INCLUDE_UNASSIGNED_FROM_MEMBERSHIP || '').toLowerCase() === 'true'; if (includeUnassigned) { - const memberGroups = GroupSyncService.getFreshMemberGroupsForUser(pref.user_id); + let memberGroups = GroupSyncService.getFreshMemberGroupsForUser(pref.user_id); + if (enforce) { + memberGroups = memberGroups.filter(gid => AllowedGroups.isAllowed(gid)); + } for (const gid of memberGroups) { const unassigned = TaskService.listGroupUnassigned(gid, 10); if (unassigned.length > 0) { diff --git a/src/tasks/service.ts b/src/tasks/service.ts index 7558643..18f34a7 100644 --- a/src/tasks/service.ts +++ b/src/tasks/service.ts @@ -1,5 +1,7 @@ import type { Database } from 'bun:sqlite'; import { db, ensureUserExists } from '../db'; +import { AllowedGroups } from '../services/allowed-groups'; +import { isGroupId } from '../utils/whatsapp'; type CreateTaskInput = { description: string; @@ -59,6 +61,18 @@ export class TaskService { // Si el group_id no existe en la tabla groups, usar NULL para no violar la FK let groupIdToInsert = task.group_id ?? null; + + // Etapa 5: en modo 'enforce', si es un grupo no permitido, forzar a NULL (compatibilidad) + try { + const mode = String(process.env.GROUP_GATING_MODE || 'off').toLowerCase(); + if (groupIdToInsert && isGroupId(groupIdToInsert) && mode === 'enforce') { + try { (AllowedGroups as any).dbInstance = this.dbInstance; } catch {} + if (!AllowedGroups.isAllowed(groupIdToInsert)) { + groupIdToInsert = null; + } + } + } catch {} + if (groupIdToInsert) { const exists = this.dbInstance.prepare(`SELECT 1 FROM groups WHERE id = ?`).get(groupIdToInsert); if (!exists) { diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts new file mode 100644 index 0000000..82db006 --- /dev/null +++ b/tests/helpers/db.ts @@ -0,0 +1,58 @@ +import Database, { type Database as SqliteDatabase } from 'bun:sqlite'; +import { initializeDatabase } from '../../src/db'; + +// Servicios opcionales para inyección de DB en tests. +// Importamos con nombres existentes en la base de código para respetar convenciones. +import { TaskService } from '../../src/tasks/service'; +import { CommandService } from '../../src/services/command'; +import { ResponseQueue } from '../../src/services/response-queue'; +import { IdentityService } from '../../src/services/identity'; +import { GroupSyncService } from '../../src/services/group-sync'; +import { RemindersService } from '../../src/services/reminders'; +import { AllowedGroups } from '../../src/services/allowed-groups'; + +/** + * Crea una DB en memoria y aplica initializeDatabase() con todas las migraciones. + */ +export function makeMemDb(): SqliteDatabase { + const memdb = new Database(':memory:'); + initializeDatabase(memdb); + return memdb; +} + +/** + * Inyecta la instancia de DB en los servicios que la exponen como propiedad estática. + * Pensado para usarse en beforeAll/beforeEach de tests que usan estos servicios. + */ +export function injectAllServices(db: SqliteDatabase): void { + try { (TaskService as any).dbInstance = db; } catch {} + try { (CommandService as any).dbInstance = db; } catch {} + try { (ResponseQueue as any).dbInstance = db; } catch {} + try { (IdentityService as any).dbInstance = db; } catch {} + try { (GroupSyncService as any).dbInstance = db; } catch {} + try { (RemindersService as any).dbInstance = db; } catch {} +} + +/** + * Restablece estado global/cachés en servicios entre tests. + * Best-effort: solo llama si existen los métodos. + */ +export function resetServices(): void { + try { (IdentityService as any).clearCache?.(); } catch {} + try { (GroupSyncService as any).clearCaches?.(); } catch {} + try { (CommandService as any).resetForTests?.(); } catch {} + try { (ResponseQueue as any).resetForTests?.(); } catch {} + try { (RemindersService as any).resetForTests?.(); } catch {} +} + +/** + * Marca como 'allowed' los groupIds indicados en la DB provista. + */ +export function seedAllowed(db: SqliteDatabase, groupIds: string[]): void { + (AllowedGroups as any).dbInstance = db; + for (const gid of groupIds) { + const g = String(gid || '').trim(); + if (!g) continue; + try { AllowedGroups.setStatus(g, 'allowed'); } catch {} + } +} diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..8571b57 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,7 @@ +/** + * Setup opcional para tests. Si el runner lo soporta (p. ej., bun test con --preload), + * fija el modo de gating por defecto a 'off' para no afectar suites existentes. + */ +if (!process.env.GROUP_GATING_MODE) { + process.env.GROUP_GATING_MODE = 'off'; +} diff --git a/tests/unit/db/migrations.allowed-groups.test.ts b/tests/unit/db/migrations.allowed-groups.test.ts new file mode 100644 index 0000000..d466325 --- /dev/null +++ b/tests/unit/db/migrations.allowed-groups.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'bun:test'; +import Database from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; + +describe('Migración v9 - allowed_groups', () => { + it('crea la tabla allowed_groups', () => { + const memdb = new Database(':memory:'); + expect(() => initializeDatabase(memdb)).not.toThrow(); + + const row = memdb + .query(`SELECT name FROM sqlite_master WHERE type='table' AND name='allowed_groups'`) + .get() as any; + + expect(row?.name).toBe('allowed_groups'); + }); + + it('enforce CHECK de status', () => { + const memdb = new Database(':memory:'); + initializeDatabase(memdb); + + // En bun:sqlite, exec() puede no lanzar en constraint violation. Validamos no persistencia. + memdb.exec(` + INSERT INTO allowed_groups (group_id, status, discovered_at, updated_at) + VALUES ('123@g.us', 'invalid-status', strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')); + `); + + const invalidCount = memdb + .query(`SELECT COUNT(*) AS c FROM allowed_groups WHERE group_id = '123@g.us'`) + .get() as any; + expect(Number(invalidCount?.c || 0)).toBe(0); + + // Inserción válida debe persistir + memdb.exec(` + INSERT INTO allowed_groups (group_id, status, discovered_at, updated_at) + VALUES ('ok@g.us', 'allowed', strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')); + `); + const validCount = memdb + .query(`SELECT COUNT(*) AS c FROM allowed_groups WHERE group_id = 'ok@g.us'`) + .get() as any; + expect(Number(validCount?.c || 0)).toBe(1); + }); +}); diff --git a/tests/unit/db/migrations.smoke.test.ts b/tests/unit/db/migrations.smoke.test.ts new file mode 100644 index 0000000..28096fa --- /dev/null +++ b/tests/unit/db/migrations.smoke.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'bun:test'; +import Database from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; + +describe('DB migrations smoke', () => { + it('initializeDatabase en :memory: no lanza y crea tablas base', () => { + const memdb = new Database(':memory:'); + expect(() => initializeDatabase(memdb)).not.toThrow(); + + // Comprobar que al menos una tabla base existe (users, tasks o response_queue). + const rows = memdb + .query(`SELECT name FROM sqlite_master WHERE type='table' AND name IN ('users','tasks','response_queue')`) + .all() as Array<{ name: string }>; + + expect(Array.isArray(rows)).toBe(true); + expect(rows.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/server/admin-approval.test.ts b/tests/unit/server/admin-approval.test.ts new file mode 100644 index 0000000..698cdc6 --- /dev/null +++ b/tests/unit/server/admin-approval.test.ts @@ -0,0 +1,111 @@ +import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { WebhookServer } from '../../../src/server'; +import { initializeDatabase } from '../../../src/db'; +import { ResponseQueue } from '../../../src/services/response-queue'; + +let testDb: Database; +let originalAdd: any; + +let simulatedQueue: any[] = []; +const SimulatedResponseQueue = { + async add(responses: any[]) { + simulatedQueue.push(...responses); + }, + clear() { simulatedQueue = []; }, + get() { return simulatedQueue; } +}; + +const createTestRequest = (payload: any) => + new Request('http://localhost:3007', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + +describe('WebhookServer - /admin aprobación en modo enforce', () => { + const envBackup = process.env; + + beforeAll(() => { + testDb = new Database(':memory:'); + initializeDatabase(testDb); + originalAdd = (ResponseQueue as any).add; + }); + + afterAll(() => { + (ResponseQueue as any).add = originalAdd; + testDb.close(); + }); + + beforeEach(() => { + process.env = { + ...envBackup, + NODE_ENV: 'test', + GROUP_GATING_MODE: 'enforce', + ADMIN_USERS: '1234567890' + }; + SimulatedResponseQueue.clear(); + (ResponseQueue as any).add = SimulatedResponseQueue.add; + WebhookServer.dbInstance = testDb; + + testDb.exec('DELETE FROM response_queue'); + testDb.exec('DELETE FROM allowed_groups'); + testDb.exec('DELETE FROM users'); + }); + + afterEach(() => { + process.env = envBackup; + }); + + test('admin puede habilitar el grupo actual incluso si no está allowed', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'new-group@g.us', + participant: '1234567890@s.whatsapp.net' + }, + message: { conversation: '/admin habilitar-aquí' } + } + }; + + const res = await WebhookServer.handleRequest(createTestRequest(payload)); + expect(res.status).toBe(200); + + // Debe haber respuesta de confirmación encolada + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + + // El grupo debe figurar como allowed + const row = testDb.query(`SELECT status FROM allowed_groups WHERE group_id = 'new-group@g.us'`).get() as any; + expect(row && String(row.status)).toBe('allowed'); + }); + + test('no admin no puede usar /admin', async () => { + process.env.ADMIN_USERS = '5555555555'; + + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'another-group@g.us', + participant: '1234567890@s.whatsapp.net' + }, + message: { conversation: '/admin habilitar-aquí' } + } + }; + + const res = await WebhookServer.handleRequest(createTestRequest(payload)); + expect(res.status).toBe(200); + + // Debe haber una respuesta indicando no autorizado + const out = SimulatedResponseQueue.get(); + expect(out.length).toBe(1); + expect(String(out[0].message).toLowerCase()).toContain('no estás autorizado'); + + // Y el grupo no debe estar allowed + const row = testDb.query(`SELECT status FROM allowed_groups WHERE group_id = 'another-group@g.us'`).get() as any; + expect(row == null).toBe(true); + }); +}); diff --git a/tests/unit/server/discovery-label.test.ts b/tests/unit/server/discovery-label.test.ts new file mode 100644 index 0000000..1a1a5bf --- /dev/null +++ b/tests/unit/server/discovery-label.test.ts @@ -0,0 +1,92 @@ +import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { WebhookServer } from '../../../src/server'; +import { initializeDatabase } from '../../../src/db'; +import { ResponseQueue } from '../../../src/services/response-queue'; +import { GroupSyncService } from '../../../src/services/group-sync'; + +let testDb: Database; +let originalAdd: any; + +let simulatedQueue: any[] = []; +const SimulatedResponseQueue = { + async add(responses: any[]) { + simulatedQueue.push(...responses); + }, + clear() { simulatedQueue = []; }, + get() { return simulatedQueue; } +}; + +const createTestRequest = (payload: any) => + new Request('http://localhost:3007', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + +describe('WebhookServer - discovery guarda label del grupo si está en caché', () => { + const envBackup = process.env; + + beforeAll(() => { + testDb = new Database(':memory:'); + initializeDatabase(testDb); + originalAdd = (ResponseQueue as any).add; + }); + + afterAll(() => { + (ResponseQueue as any).add = originalAdd; + testDb.close(); + }); + + beforeEach(() => { + process.env = { + ...envBackup, + NODE_ENV: 'test', + GROUP_GATING_MODE: 'discover' + }; + SimulatedResponseQueue.clear(); + (ResponseQueue as any).add = SimulatedResponseQueue.add; + WebhookServer.dbInstance = testDb; + + // Limpiar tablas relevantes + testDb.exec('DELETE FROM response_queue'); + testDb.exec('DELETE FROM allowed_groups'); + testDb.exec('DELETE FROM users'); + + // Poblar caché con el nombre del grupo + GroupSyncService.activeGroupsCache.clear(); + GroupSyncService.activeGroupsCache.set('label-group@g.us', 'Proyecto Foo'); + }); + + afterEach(() => { + process.env = envBackup; + }); + + test('registra pending con label del grupo desde la caché', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'label-group@g.us', + participant: '9999999999@s.whatsapp.net' + }, + message: { conversation: '/t n hola' } + } + }; + + const res = await WebhookServer.handleRequest(createTestRequest(payload)); + expect(res.status).toBe(200); + + // No debe haber respuestas encoladas (retorno temprano) + expect(SimulatedResponseQueue.get().length).toBe(0); + + // Debe existir registro pending con label en allowed_groups + const row = testDb + .query(`SELECT status, label FROM allowed_groups WHERE group_id = 'label-group@g.us'`) + .get() as any; + expect(row).toBeDefined(); + expect(String(row.status)).toBe('pending'); + expect(String(row.label)).toBe('Proyecto Foo'); + }); +}); diff --git a/tests/unit/server/discovery-notify-admins.test.ts b/tests/unit/server/discovery-notify-admins.test.ts new file mode 100644 index 0000000..bbba8df --- /dev/null +++ b/tests/unit/server/discovery-notify-admins.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { WebhookServer } from '../../../src/server'; +import { initializeDatabase } from '../../../src/db'; +import { ResponseQueue } from '../../../src/services/response-queue'; + +let testDb: Database; +let originalAdd: any; + +let simulatedQueue: any[] = []; +const SimulatedResponseQueue = { + async add(responses: any[]) { + simulatedQueue.push(...responses); + }, + clear() { simulatedQueue = []; }, + get() { return simulatedQueue; } +}; + +const createTestRequest = (payload: any) => + new Request('http://localhost:3007', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + +describe('WebhookServer - notifica a ADMIN_USERS en descubrimiento (modo discover)', () => { + const envBackup = process.env; + + beforeAll(() => { + testDb = new Database(':memory:'); + initializeDatabase(testDb); + originalAdd = (ResponseQueue as any).add; + }); + + afterAll(() => { + (ResponseQueue as any).add = originalAdd; + testDb.close(); + }); + + beforeEach(() => { + process.env = { + ...envBackup, + NODE_ENV: 'test', + GROUP_GATING_MODE: 'discover', + NOTIFY_ADMINS_ON_DISCOVERY: 'true', + ADMIN_USERS: '1234567890, 5555555555' + }; + SimulatedResponseQueue.clear(); + (ResponseQueue as any).add = SimulatedResponseQueue.add; + WebhookServer.dbInstance = testDb; + + // Limpiar tablas relevantes + testDb.exec('DELETE FROM response_queue'); + testDb.exec('DELETE FROM allowed_groups'); + testDb.exec('DELETE FROM users'); + }); + + afterEach(() => { + process.env = envBackup; + }); + + test('registra pending y envía DMs a todos los admins', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'notify-group@g.us', + participant: '9999999999@s.whatsapp.net' + }, + message: { conversation: '/t n hola' } + } + }; + + const res = await WebhookServer.handleRequest(createTestRequest(payload)); + expect(res.status).toBe(200); + + // allowed_groups debe tener el grupo en pending + const row = testDb + .query(`SELECT status FROM allowed_groups WHERE group_id = 'notify-group@g.us'`) + .get() as any; + expect(row).toBeDefined(); + expect(String(row.status)).toBe('pending'); + + // Debe haberse encolado una notificación por cada admin + const out = SimulatedResponseQueue.get(); + const recipients = out.map((r: any) => r.recipient).sort(); + expect(out.length).toBe(2); + expect(recipients).toEqual(['1234567890', '5555555555']); + }); +}); diff --git a/tests/unit/server/enforce-gating.test.ts b/tests/unit/server/enforce-gating.test.ts new file mode 100644 index 0000000..d4341cf --- /dev/null +++ b/tests/unit/server/enforce-gating.test.ts @@ -0,0 +1,115 @@ +import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { WebhookServer } from '../../../src/server'; +import { initializeDatabase } from '../../../src/db'; +import { ResponseQueue } from '../../../src/services/response-queue'; +import { AllowedGroups } from '../../../src/services/allowed-groups'; +import { GroupSyncService } from '../../../src/services/group-sync'; + +let testDb: Database; +let originalAdd: any; + +let simulatedQueue: any[] = []; +const SimulatedResponseQueue = { + async add(responses: any[]) { + simulatedQueue.push(...responses); + }, + clear() { simulatedQueue = []; }, + get() { return simulatedQueue; } +}; + +const createTestRequest = (payload: any) => + new Request('http://localhost:3007', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + +describe('WebhookServer - enforce gating (modo=enforce)', () => { + const envBackup = process.env; + + beforeAll(() => { + testDb = new Database(':memory:'); + initializeDatabase(testDb); + originalAdd = (ResponseQueue as any).add; + }); + + afterAll(() => { + (ResponseQueue as any).add = originalAdd; + testDb.close(); + }); + + beforeEach(() => { + process.env = { + ...envBackup, + NODE_ENV: 'test', + GROUP_GATING_MODE: 'enforce' + }; + SimulatedResponseQueue.clear(); + (ResponseQueue as any).add = SimulatedResponseQueue.add; + WebhookServer.dbInstance = testDb; + (AllowedGroups as any).dbInstance = testDb; + + // Limpiar tablas relevantes + testDb.exec('DELETE FROM response_queue'); + testDb.exec('DELETE FROM allowed_groups'); + testDb.exec('DELETE FROM users'); + }); + + afterEach(() => { + process.env = envBackup; + }); + + test('bloquea mensaje de grupo no permitido (no se encolan respuestas)', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'blocked-group@g.us', + participant: '1234567890@s.whatsapp.net' + }, + message: { conversation: '/t ayuda' } + } + }; + + const res = await WebhookServer.handleRequest(createTestRequest(payload)); + expect(res.status).toBe(200); + + // No debe haber respuestas encoladas (retorno temprano) + expect(SimulatedResponseQueue.get().length).toBe(0); + + // allowed_groups no contiene allowed para ese grupo (get() devuelve null cuando no hay filas) + const row = testDb.query(`SELECT status FROM allowed_groups WHERE group_id = 'blocked-group@g.us'`).get() as any; + expect(row == null).toBe(true); + }); + + test('permite mensaje en grupo allowed y procesa comando', async () => { + // Sembrar grupo como allowed + testDb.exec(` + INSERT INTO allowed_groups (group_id, status, discovered_at, updated_at) + VALUES ('allowed-group@g.us', 'allowed', strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now')) + `); + + // Marcar el grupo como activo en la caché para evitar retorno temprano por "grupo inactivo" en tests + GroupSyncService.activeGroupsCache.set('allowed-group@g.us', 'Allowed Group'); + + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'allowed-group@g.us', + participant: '1234567890@s.whatsapp.net' + }, + message: { conversation: '/t ayuda' } + } + }; + + const res = await WebhookServer.handleRequest(createTestRequest(payload)); + expect(res.status).toBe(200); + + // Debe haberse encolado al menos una respuesta (ayuda) + expect(SimulatedResponseQueue.get().length).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/server/unknown-group-discovery.test.ts b/tests/unit/server/unknown-group-discovery.test.ts new file mode 100644 index 0000000..6763b46 --- /dev/null +++ b/tests/unit/server/unknown-group-discovery.test.ts @@ -0,0 +1,86 @@ +import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { WebhookServer } from '../../../src/server'; +import { initializeDatabase } from '../../../src/db'; +import { ResponseQueue } from '../../../src/services/response-queue'; + +let testDb: Database; +let originalAdd: any; + +let simulatedQueue: any[] = []; +const SimulatedResponseQueue = { + async add(responses: any[]) { + simulatedQueue.push(...responses); + }, + clear() { simulatedQueue = []; }, + get() { return simulatedQueue; } +}; + +const createTestRequest = (payload: any) => + new Request('http://localhost:3007', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + +describe('WebhookServer - unknown group discovery (mode=discover)', () => { + const envBackup = process.env; + + beforeAll(() => { + testDb = new Database(':memory:'); + initializeDatabase(testDb); + originalAdd = (ResponseQueue as any).add; + }); + + afterAll(() => { + (ResponseQueue as any).add = originalAdd; + testDb.close(); + }); + + beforeEach(() => { + process.env = { + ...envBackup, + NODE_ENV: 'test', + GROUP_GATING_MODE: 'discover' + }; + SimulatedResponseQueue.clear(); + (ResponseQueue as any).add = SimulatedResponseQueue.add; + WebhookServer.dbInstance = testDb; + + // Limpiar tablas relevantes + testDb.exec('DELETE FROM response_queue'); + testDb.exec('DELETE FROM allowed_groups'); + testDb.exec('DELETE FROM users'); + }); + + afterEach(() => { + process.env = envBackup; + }); + + test('registra grupo desconocido como pending y no procesa comandos', async () => { + const payload = { + event: 'messages.upsert', + instance: 'test-instance', + data: { + key: { + remoteJid: 'new-group@g.us', + participant: '1234567890@s.whatsapp.net' + }, + message: { conversation: '/t n hola' } + } + }; + + const res = await WebhookServer.handleRequest(createTestRequest(payload)); + expect(res.status).toBe(200); + + // No debe haber respuestas encoladas (retorno temprano) + expect(SimulatedResponseQueue.get().length).toBe(0); + + // Debe existir registro pending en allowed_groups + const row = testDb + .query(`SELECT status FROM allowed_groups WHERE group_id = 'new-group@g.us'`) + .get() as any; + expect(row).toBeDefined(); + expect(String(row.status)).toBe('pending'); + }); +}); diff --git a/tests/unit/services/admin.test.ts b/tests/unit/services/admin.test.ts new file mode 100644 index 0000000..e2a87b0 --- /dev/null +++ b/tests/unit/services/admin.test.ts @@ -0,0 +1,61 @@ +import { describe, it, beforeEach, expect } from 'bun:test'; +import { makeMemDb } from '../../helpers/db'; +import { AdminService } from '../../../src/services/admin'; +import { AllowedGroups } from '../../../src/services/allowed-groups'; + +describe('AdminService - comandos básicos', () => { + const envBackup = process.env; + + beforeEach(() => { + process.env = { ...envBackup, NODE_ENV: 'test', ADMIN_USERS: '34600123456' }; + const memdb = makeMemDb(); + (AdminService as any).dbInstance = memdb; + (AllowedGroups as any).dbInstance = memdb; + }); + + it('rechaza a usuarios no admin', async () => { + const out = await AdminService.handle({ + sender: '34999888777', + groupId: 'g1@g.us', + message: '/admin pendientes' + }); + expect(out.length).toBe(1); + expect(out[0].message.toLowerCase()).toContain('no estás autorizado'); + }); + + it('lista pendientes', async () => { + AllowedGroups.upsertPending('a@g.us', 'A', 'tester'); + AllowedGroups.upsertPending('b@g.us', 'B', 'tester'); + + const out = await AdminService.handle({ + sender: '34600123456', + groupId: 'g1@g.us', + message: '/admin pendientes' + }); + + expect(out.length).toBe(1); + expect(out[0].message).toContain('Grupos pendientes'); + expect(out[0].message).toContain('a@g.us'); + expect(out[0].message).toContain('b@g.us'); + }); + + it('habilitar-aquí en grupo', async () => { + const out = await AdminService.handle({ + sender: '34600123456', + groupId: 'g1@g.us', + message: '/admin habilitar-aquí' + }); + expect(out.length).toBe(1); + expect(AllowedGroups.isAllowed('g1@g.us')).toBe(true); + }); + + it('allow-group habilita explícitamente', async () => { + const out = await AdminService.handle({ + sender: '34600123456', + groupId: '1234567890@s.whatsapp.net', + message: '/admin allow-group g2@g.us' + }); + expect(out.length).toBe(1); + expect(AllowedGroups.isAllowed('g2@g.us')).toBe(true); + }); +}); diff --git a/tests/unit/services/allowed-groups.test.ts b/tests/unit/services/allowed-groups.test.ts new file mode 100644 index 0000000..3cf1a0b --- /dev/null +++ b/tests/unit/services/allowed-groups.test.ts @@ -0,0 +1,61 @@ +import { describe, it, beforeEach, expect } from 'bun:test'; +import { makeMemDb } from '../../helpers/db'; +import { AllowedGroups } from '../../../src/services/allowed-groups'; + +describe('AllowedGroups service', () => { + beforeEach(() => { + const memdb = makeMemDb(); + (AllowedGroups as any).dbInstance = memdb; + AllowedGroups.resetForTests(); + }); + + it('upsertPending inserta pending y es idempotente', () => { + const gid = '123@g.us'; + + AllowedGroups.upsertPending(gid, 'Grupo 123', 'tester'); + AllowedGroups.upsertPending(gid, 'Grupo 123', 'tester'); + + // No se expone la DB aquí; validamos por comportamiento + expect(AllowedGroups.isAllowed(gid)).toBe(false); + }); + + it('setStatus cambia a allowed y isAllowed refleja el estado', () => { + const gid = '456@g.us'; + + expect(AllowedGroups.isAllowed(gid)).toBe(false); + const changed = AllowedGroups.setStatus(gid, 'allowed', 'Grupo 456'); + expect(changed).toBe(true); + expect(AllowedGroups.isAllowed(gid)).toBe(true); + + // Repetir con el mismo estado no debe cambiar + const changedAgain = AllowedGroups.setStatus(gid, 'allowed', 'Grupo 456'); + expect(changedAgain).toBe(false); + }); + + it('listByStatus devuelve grupos por estado', () => { + AllowedGroups.setStatus('a@g.us', 'allowed', 'A'); + AllowedGroups.setStatus('b@g.us', 'pending', 'B'); + AllowedGroups.setStatus('c@g.us', 'blocked', 'C'); + + const allowed = AllowedGroups.listByStatus('allowed').map(r => r.group_id); + const pending = AllowedGroups.listByStatus('pending').map(r => r.group_id); + const blocked = AllowedGroups.listByStatus('blocked').map(r => r.group_id); + + expect(allowed).toContain('a@g.us'); + expect(pending).toContain('b@g.us'); + expect(blocked).toContain('c@g.us'); + }); + + it('seedFromEnv marca como allowed los ids provistos', () => { + const prev = process.env.ALLOWED_GROUPS; + process.env.ALLOWED_GROUPS = 'x@g.us, y@g.us , , z@g.us'; + + AllowedGroups.seedFromEnv(); + + expect(AllowedGroups.isAllowed('x@g.us')).toBe(true); + expect(AllowedGroups.isAllowed('y@g.us')).toBe(true); + expect(AllowedGroups.isAllowed('z@g.us')).toBe(true); + + process.env.ALLOWED_GROUPS = prev; + }); +}); diff --git a/tests/unit/services/command.gating.test.ts b/tests/unit/services/command.gating.test.ts new file mode 100644 index 0000000..bd5fea3 --- /dev/null +++ b/tests/unit/services/command.gating.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { makeMemDb } from '../../helpers/db'; +import { CommandService } from '../../../src/services/command'; +import { AllowedGroups } from '../../../src/services/allowed-groups'; + +describe('CommandService - gating en modo enforce', () => { + const envBackup = process.env; + + beforeEach(() => { + process.env = { ...envBackup, NODE_ENV: 'test', GROUP_GATING_MODE: 'enforce' }; + const memdb = makeMemDb(); + (CommandService as any).dbInstance = memdb; + (AllowedGroups as any).dbInstance = memdb; + }); + + afterEach(() => { + process.env = envBackup; + }); + + it('bloquea comandos en grupo no permitido (desconocido)', async () => { + const res = await CommandService.handle({ + sender: '34600123456', + groupId: 'g1@g.us', + message: '/t ayuda', + mentions: [] + }); + expect(Array.isArray(res)).toBe(true); + expect(res.length).toBe(0); + }); + + it('permite comandos en grupo permitido', async () => { + AllowedGroups.setStatus('g2@g.us', 'allowed', 'G2'); + + const res = await CommandService.handle({ + sender: '34600123456', + groupId: 'g2@g.us', + message: '/t ayuda', + mentions: [] + }); + + expect(res.length).toBeGreaterThan(0); + expect(res[0].recipient).toBe('34600123456'); + }); +}); diff --git a/tests/unit/services/group-sync.gating.test.ts b/tests/unit/services/group-sync.gating.test.ts new file mode 100644 index 0000000..156e9a0 --- /dev/null +++ b/tests/unit/services/group-sync.gating.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { makeMemDb } from '../../helpers/db'; +import { GroupSyncService } from '../../../src/services/group-sync'; +import { AllowedGroups } from '../../../src/services/allowed-groups'; + +describe('GroupSyncService - gating en syncMembersForGroup (enforce)', () => { + const envBackup = process.env; + + beforeEach(() => { + process.env = { ...envBackup, NODE_ENV: 'test', GROUP_GATING_MODE: 'enforce' }; + const memdb = makeMemDb(); + (GroupSyncService as any).dbInstance = memdb; + (AllowedGroups as any).dbInstance = memdb; + + // Stub fetchGroupMembersFromAPI para no hacer red + (GroupSyncService as any).fetchGroupMembersFromAPI = async (_groupId: string) => { + return [{ userId: '34600111111', isAdmin: false }]; + }; + }); + + afterEach(() => { + process.env = envBackup; + }); + + it('no sincroniza miembros para grupo no allowed', async () => { + const res = await GroupSyncService.syncMembersForGroup('na@g.us'); + expect(res).toEqual({ added: 0, updated: 0, deactivated: 0 }); + + const db = (GroupSyncService as any).dbInstance; + const row = db.query(`SELECT COUNT(*) AS c FROM group_members WHERE group_id = 'na@g.us'`).get() as any; + expect(Number(row?.c || 0)).toBe(0); + }); + + it('sincroniza miembros para grupo allowed', async () => { + AllowedGroups.setStatus('ok@g.us', 'allowed', 'OK'); + + const res = await GroupSyncService.syncMembersForGroup('ok@g.us'); + expect(res.added + res.updated + res.deactivated).toBeGreaterThan(0); + + const db = (GroupSyncService as any).dbInstance; + const row = db.query(`SELECT COUNT(*) AS c FROM group_members WHERE group_id = 'ok@g.us'`).get() as any; + expect(Number(row?.c || 0)).toBeGreaterThan(0); + }); +}); diff --git a/tests/unit/services/group-sync.label-update.test.ts b/tests/unit/services/group-sync.label-update.test.ts new file mode 100644 index 0000000..308be45 --- /dev/null +++ b/tests/unit/services/group-sync.label-update.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import Database from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; +import { GroupSyncService } from '../../../src/services/group-sync'; + +describe('GroupSyncService - upsertGroups actualiza label en allowed_groups', () => { + const envBackup = process.env; + let memdb: Database; + + beforeEach(() => { + process.env = { ...envBackup, NODE_ENV: 'test', WHATSAPP_COMMUNITY_ID: 'comm-1' }; + memdb = new Database(':memory:'); + initializeDatabase(memdb); + (GroupSyncService as any).dbInstance = memdb; + + // Limpiar tablas + memdb.exec('DELETE FROM allowed_groups'); + memdb.exec('DELETE FROM groups'); + }); + + afterEach(() => { + memdb.close(); + process.env = envBackup; + }); + + it('propaga subject como label aunque no exista fila previa', async () => { + await (GroupSyncService as any).upsertGroups([{ id: 'gg@g.us', subject: 'Grupo GG', linkedParent: 'comm-1' }]); + + const row = memdb.query(`SELECT label, status FROM allowed_groups WHERE group_id = 'gg@g.us'`).get() as any; + expect(row).toBeDefined(); + expect(String(row.label)).toBe('Grupo GG'); + expect(String(row.status)).toBe('pending'); + }); +}); diff --git a/tests/unit/services/group-sync.scheduler.gating.test.ts b/tests/unit/services/group-sync.scheduler.gating.test.ts new file mode 100644 index 0000000..926a3b2 --- /dev/null +++ b/tests/unit/services/group-sync.scheduler.gating.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { makeMemDb } from '../../helpers/db'; +import { GroupSyncService } from '../../../src/services/group-sync'; +import { AllowedGroups } from '../../../src/services/allowed-groups'; + +describe('GroupSyncService - gating en scheduler de miembros (enforce)', () => { + const envBackup = process.env; + const calls: string[] = []; + + beforeEach(() => { + process.env = { ...envBackup, NODE_ENV: 'ci', GROUP_GATING_MODE: 'enforce' }; + + const memdb = makeMemDb(); + (GroupSyncService as any).dbInstance = memdb; + (AllowedGroups as any).dbInstance = memdb; + + // Preparar caché de grupos activos (2 grupos: uno allowed y otro no) + GroupSyncService.activeGroupsCache.clear(); + GroupSyncService.activeGroupsCache.set('ok@g.us', 'OK Group'); + GroupSyncService.activeGroupsCache.set('na@g.us', 'NA Group'); + + // Sembrar allowed solo para 'ok@g.us' + AllowedGroups.setStatus('ok@g.us', 'allowed', 'OK Group'); + + // Stub de fetch para no hacer red y registrar llamadas + (GroupSyncService as any).fetchGroupMembersFromAPI = async (gid: string) => { + calls.push(gid); + // Snapshot vacía para no escribir en DB + return []; + }; + }); + + afterEach(() => { + process.env = envBackup; + calls.length = 0; + }); + + it('salta grupos no allowed y solo procesa los allowed', async () => { + const summary = await GroupSyncService.syncMembersForActiveGroups(); + + // Debe haber procesado solo 1 grupo (el allowed) + expect(summary.groups).toBe(1); + expect(calls).toEqual(['ok@g.us']); + expect(calls).not.toContain('na@g.us'); + }); +}); diff --git a/tests/unit/services/reminders.gating.test.ts b/tests/unit/services/reminders.gating.test.ts new file mode 100644 index 0000000..61df16f --- /dev/null +++ b/tests/unit/services/reminders.gating.test.ts @@ -0,0 +1,116 @@ +import { describe, it, beforeEach, afterEach, expect } from 'bun:test'; +import Database from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; +import { TaskService } from '../../../src/tasks/service'; +import { RemindersService } from '../../../src/services/reminders'; +import { AllowedGroups } from '../../../src/services/allowed-groups'; +import { ResponseQueue } from '../../../src/services/response-queue'; + +function seedGroup(db: Database, groupId: string) { + const cols = db.query(`PRAGMA table_info(groups)`).all() as any[]; + const values: Record = {}; + const nowIso = new Date().toISOString().replace('T', ' ').replace('Z', ''); + + for (const c of cols) { + const name = String(c.name); + const type = String(c.type || '').toUpperCase(); + const notnull = Number(c.notnull || 0) === 1; + const hasDefault = c.dflt_value != null; + + if (name === 'id') { values[name] = groupId; continue; } + if (name === 'name' || name === 'title' || name === 'subject') { values[name] = 'Test Group'; continue; } + if (name === 'created_by') { values[name] = 'tester'; continue; } + if (name.endsWith('_at')) { values[name] = nowIso; continue; } + if (name === 'is_active' || name === 'active') { values[name] = 1; continue; } + + if (notnull && !hasDefault) { + if (type.includes('INT')) values[name] = 1; + else if (type.includes('REAL')) values[name] = 0; + else values[name] = 'N/A'; + } + } + + if (!('id' in values)) values['id'] = groupId; + + const colsList = Object.keys(values); + const placeholders = colsList.map(() => '?').join(', '); + const sql = `INSERT OR REPLACE INTO groups (${colsList.join(', ')}) VALUES (${placeholders})`; + db.prepare(sql).run(...colsList.map(k => values[k])); +} + +describe('RemindersService - gating por grupos en modo enforce', () => { + const envBackup = process.env; + let memdb: Database; + let originalAdd: any; + let sent: any[] = []; + + beforeEach(() => { + process.env = { ...envBackup, NODE_ENV: 'test', GROUP_GATING_MODE: 'enforce', TZ: 'Europe/Madrid' }; + memdb = new Database(':memory:'); + initializeDatabase(memdb); + (TaskService as any).dbInstance = memdb; + (RemindersService as any).dbInstance = memdb; + (AllowedGroups as any).dbInstance = memdb; + + // Stub de ResponseQueue + originalAdd = (ResponseQueue as any).add; + (ResponseQueue as any).add = async (msgs: any[]) => { sent.push(...msgs); }; + sent = []; + + // Asegurar usuario receptor para satisfacer la FK de user_preferences + const iso = new Date().toISOString().replace('T', ' ').replace('Z', ''); + memdb.exec(` + INSERT INTO users (id, first_seen, last_seen) + VALUES ('34600123456', '${iso}', '${iso}') + ON CONFLICT(id) DO NOTHING + `); + + // Preferencias del usuario receptor + memdb.exec(` + INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at) + VALUES ('34600123456', 'daily', '00:00', NULL, strftime('%Y-%m-%d %H:%M:%f','now')) + ON CONFLICT(user_id) DO UPDATE SET + reminder_freq = excluded.reminder_freq, + reminder_time = excluded.reminder_time, + last_reminded_on = NULL, + updated_at = excluded.updated_at + `); + + // Sembrar grupos y estados + seedGroup(memdb, 'ok@g.us'); + seedGroup(memdb, 'na@g.us'); + AllowedGroups.setStatus('ok@g.us', 'allowed', 'OK'); + AllowedGroups.setStatus('na@g.us', 'allowed', 'NA'); // inicialmente allowed para que las tareas se creen con group_id + + // Crear dos tareas, una en cada grupo, asignadas al usuario + TaskService.createTask( + { description: 'Tarea OK', created_by: '34600123456', group_id: 'ok@g.us', due_date: null }, + [{ user_id: '34600123456', assigned_by: '34600123456' }] + ); + TaskService.createTask( + { description: 'Tarea NA', created_by: '34600123456', group_id: 'na@g.us', due_date: null }, + [{ user_id: '34600123456', assigned_by: '34600123456' }] + ); + + // Cambiar a bloqueado uno de los grupos antes de correr los recordatorios + AllowedGroups.setStatus('na@g.us', 'blocked', 'NA'); + }); + + afterEach(() => { + (ResponseQueue as any).add = originalAdd; + memdb.close(); + process.env = envBackup; + }); + + it('omite tareas de grupos no allowed en los recordatorios', async () => { + const now = new Date('2025-09-08T07:40:00.000Z'); // ≥ 08:30 Europe/Madrid en un lunes + await RemindersService.runOnce(now); + + expect(sent.length).toBe(1); + const msg = String(sent[0].message); + + // Debe incluir solo la tarea del grupo allowed y omitir la del bloqueado + expect(msg).toContain('Tarea OK'); + expect(msg).not.toContain('Tarea NA'); + }); +}); diff --git a/tests/unit/tasks/service.gating.test.ts b/tests/unit/tasks/service.gating.test.ts new file mode 100644 index 0000000..39a1d39 --- /dev/null +++ b/tests/unit/tasks/service.gating.test.ts @@ -0,0 +1,117 @@ +import { describe, it, beforeEach, afterEach, expect } from 'bun:test'; +import Database from 'bun:sqlite'; +import { initializeDatabase } from '../../../src/db'; +import { TaskService } from '../../../src/tasks/service'; +import { AllowedGroups } from '../../../src/services/allowed-groups'; + +function seedGroup(db: Database, groupId: string) { + // Sembrado robusto: cubrir columnas NOT NULL sin valor por defecto + const cols = db.query(`PRAGMA table_info(groups)`).all() as any[]; + const values: Record = {}; + const nowIso = new Date().toISOString().replace('T', ' ').replace('Z', ''); + + for (const c of cols) { + const name = String(c.name); + const type = String(c.type || '').toUpperCase(); + const notnull = Number(c.notnull || 0) === 1; + const hasDefault = c.dflt_value != null; + + if (name === 'id') { + values[name] = groupId; + continue; + } + + // Preconfigurar algunos alias comunes + if (name === 'name' || name === 'title' || name === 'subject') { + values[name] = 'Test Group'; + continue; + } + if (name === 'created_by') { + values[name] = 'tester'; + continue; + } + if (name.endsWith('_at')) { + values[name] = nowIso; + continue; + } + if (name === 'is_active' || name === 'active') { + values[name] = 1; + continue; + } + + // Para columnas NOT NULL sin valor por defecto, asignar valores genéricos + if (notnull && !hasDefault) { + if (type.includes('INT')) values[name] = 1; + else if (type.includes('REAL')) values[name] = 0; + else values[name] = 'N/A'; + } + } + + // Asegurar que id esté siempre + if (!('id' in values)) values['id'] = groupId; + + const colsList = Object.keys(values); + const placeholders = colsList.map(() => '?').join(', '); + const sql = `INSERT OR REPLACE INTO groups (${colsList.join(', ')}) VALUES (${placeholders})`; + db.prepare(sql).run(...colsList.map(k => values[k])); +} + +describe('TaskService - gating en creación con group_id (enforce)', () => { + const envBackup = process.env; + let memdb: Database; + + beforeEach(() => { + process.env = { ...envBackup, NODE_ENV: 'test', GROUP_GATING_MODE: 'enforce' }; + memdb = new Database(':memory:'); + initializeDatabase(memdb); + (TaskService as any).dbInstance = memdb; + (AllowedGroups as any).dbInstance = memdb; + }); + + afterEach(() => { + process.env = envBackup; + memdb.close(); + }); + + it('fuerza group_id=null cuando el grupo no está allowed', () => { + const gid = 'na@g.us'; + seedGroup(memdb, gid); + AllowedGroups.setStatus(gid, 'blocked'); + + const taskId = TaskService.createTask( + { + description: 'Probar gating', + due_date: null, + group_id: gid, + created_by: '34600123456', + }, + [{ user_id: '34600123456', assigned_by: '34600123456' }] + ); + + const row = memdb + .query(`SELECT group_id FROM tasks WHERE id = ?`) + .get(taskId) as any; + expect(row?.group_id).toBeNull(); + }); + + it('conserva group_id cuando el grupo está allowed', () => { + const gid = 'ok@g.us'; + seedGroup(memdb, gid); + AllowedGroups.setStatus(gid, 'allowed'); + + const taskId = TaskService.createTask( + { + description: 'Tarea en grupo allowed', + due_date: null, + group_id: gid, + created_by: '34600123456', + }, + [{ user_id: '34600123456', assigned_by: '34600123456' }] + ); + + const row = memdb + .query(`SELECT group_id FROM tasks WHERE id = ?`) + .get(taskId) as any; + expect(String(row?.group_id)).toBe(gid); + }); +});