From 8272c0bb7b8a45b8b1046878874d5ca7ca46cc9e Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 10:15:44 +0200 Subject: [PATCH 01/25] =?UTF-8?q?docs:=20a=C3=B1ade=20plan=20detallado=20p?= =?UTF-8?q?or=20etapas=20para=20multicomunidades?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- docs/plan-multicomunidades.md | 160 ++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) 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). -- 2.38.5 From a553d5261ce8910c84e07607c53908396e46608c Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 10:18:45 +0200 Subject: [PATCH 02/25] =?UTF-8?q?test:=20a=C3=B1adir=20helpers=20de=20DB?= =?UTF-8?q?=20en=20memoria=20y=20setup=20de=20pruebas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- tests/helpers/db.ts | 49 ++++++++++++++++++++++++++ tests/setup.ts | 7 ++++ tests/unit/db/migrations.smoke.test.ts | 18 ++++++++++ 3 files changed, 74 insertions(+) create mode 100644 tests/helpers/db.ts create mode 100644 tests/setup.ts create mode 100644 tests/unit/db/migrations.smoke.test.ts diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts new file mode 100644 index 0000000..a10b2ee --- /dev/null +++ b/tests/helpers/db.ts @@ -0,0 +1,49 @@ +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'; + +/** + * 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 {} +} + +/** + * Nota: en Etapa 1 añadiremos seedAllowed() aquí cuando exista AllowedGroups. + */ 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.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); + }); +}); -- 2.38.5 From 0fa985c145451e0bf162059ff12e6e787164b9d8 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 10:37:21 +0200 Subject: [PATCH 03/25] =?UTF-8?q?feat:=20a=C3=B1adir=20migraci=C3=B3n=20v9?= =?UTF-8?q?=5Fallowed=5Fgroups=20y=20servicio=20AllowedGroups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/db/migrations/index.ts | 22 +++ src/services/allowed-groups.ts | 149 ++++++++++++++++++ .../unit/db/migrations.allowed-groups.test.ts | 28 ++++ tests/unit/services/allowed-groups.test.ts | 61 +++++++ 4 files changed, 260 insertions(+) create mode 100644 src/services/allowed-groups.ts create mode 100644 tests/unit/db/migrations.allowed-groups.test.ts create mode 100644 tests/unit/services/allowed-groups.test.ts 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/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/tests/unit/db/migrations.allowed-groups.test.ts b/tests/unit/db/migrations.allowed-groups.test.ts new file mode 100644 index 0000000..e020f4c --- /dev/null +++ b/tests/unit/db/migrations.allowed-groups.test.ts @@ -0,0 +1,28 @@ +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); + + expect(() => + 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')); + `) + ).toThrow(); + }); +}); 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; + }); +}); -- 2.38.5 From 98d3ca3553671898f157713ad4affc2debccd7d4 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 10:38:46 +0200 Subject: [PATCH 04/25] =?UTF-8?q?test:=20adaptar=20migraci=C3=B3n=20v9=20p?= =?UTF-8?q?ara=20validar=20persistencia=20en=20bun:sqlite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- .../unit/db/migrations.allowed-groups.test.ts | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/unit/db/migrations.allowed-groups.test.ts b/tests/unit/db/migrations.allowed-groups.test.ts index e020f4c..d466325 100644 --- a/tests/unit/db/migrations.allowed-groups.test.ts +++ b/tests/unit/db/migrations.allowed-groups.test.ts @@ -18,11 +18,25 @@ describe('Migración v9 - allowed_groups', () => { const memdb = new Database(':memory:'); initializeDatabase(memdb); - expect(() => - 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')); - `) - ).toThrow(); + // 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); }); }); -- 2.38.5 From c7c049120047bf73a656660699092347c782d14c Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 10:48:21 +0200 Subject: [PATCH 05/25] feat: implementar descubrimiento de grupos desconocidos (discover) Co-authored-by: aider (openrouter/openai/gpt-5) --- src/server.ts | 24 ++++++ .../server/unknown-group-discovery.test.ts | 86 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tests/unit/server/unknown-group-discovery.test.ts diff --git a/src/server.ts b/src/server.ts index 5fe1b3a..d0061aa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,7 @@ 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'; // Bun is available globally when running under Bun runtime declare global { @@ -298,6 +299,29 @@ export class WebhookServer { return; } + // 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, null, normalizedSenderId); } catch {} + try { Metrics.inc('unknown_groups_discovered_total'); } catch {} + return; + } + } catch { + // Si la tabla no existe por alguna razón, intentar upsert y retornar igualmente + try { AllowedGroups.upsertPending(remoteJid, null, normalizedSenderId); } catch {} + try { Metrics.inc('unknown_groups_discovered_total'); } catch {} + 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 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'); + }); +}); -- 2.38.5 From a03604d293e1de18ff0df6f43f9b91936fa03faa Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 10:57:04 +0200 Subject: [PATCH 06/25] feat: implementar gating de grupos en server en modo enforce Co-authored-by: aider (openrouter/openai/gpt-5) --- src/server.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/server.ts b/src/server.ts index d0061aa..373f15f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -322,6 +322,23 @@ export class WebhookServer { } } + // 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) { + try { Metrics.inc('messages_blocked_group_total'); } catch {} + return; + } + } catch { + // Si falla el check por cualquier motivo, ser conservadores y permitir + } + } + } + // 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 -- 2.38.5 From d747e7aa4b5066bfeae5212584b6d77fb4d0e042 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 11:08:31 +0200 Subject: [PATCH 07/25] feat: activar gating de grupos en CommandService y GroupSyncService Co-authored-by: aider (openrouter/openai/gpt-5) --- src/services/command.ts | 16 +++ src/services/group-sync.ts | 16 +++ tests/helpers/db.ts | 11 +- tests/unit/server/enforce-gating.test.ts | 115 ++++++++++++++++++ tests/unit/services/command.gating.test.ts | 44 +++++++ tests/unit/services/group-sync.gating.test.ts | 44 +++++++ 6 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 tests/unit/server/enforce-gating.test.ts create mode 100644 tests/unit/services/command.gating.test.ts create mode 100644 tests/unit/services/group-sync.gating.test.ts diff --git a/src/services/command.ts b/src/services/command.ts index 2d62a0f..eb3f17f 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -7,6 +7,7 @@ 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'; type CommandContext = { sender: string; // normalized user id (digits only), but accept raw too @@ -1060,6 +1061,21 @@ 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)) { + 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..dd1a5a1 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 @@ -794,6 +795,21 @@ 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)) { + return { added: 0, updated: 0, deactivated: 0 }; + } + } catch { + // Si el check falla, seguimos sin bloquear + } + } + } catch {} + try { const snapshot = await (this as any).fetchGroupMembersFromAPI(groupId); return this.reconcileGroupMembers(groupId, snapshot); diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index a10b2ee..82db006 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -9,6 +9,7 @@ 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. @@ -45,5 +46,13 @@ export function resetServices(): void { } /** - * Nota: en Etapa 1 añadiremos seedAllowed() aquí cuando exista AllowedGroups. + * 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/unit/server/enforce-gating.test.ts b/tests/unit/server/enforce-gating.test.ts new file mode 100644 index 0000000..6143b85 --- /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 + const row = testDb.query(`SELECT status FROM allowed_groups WHERE group_id = 'blocked-group@g.us'`).get() as any; + expect(row).toBeUndefined(); + }); + + 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/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); + }); +}); -- 2.38.5 From 37db7b283daef2c946c0d4513b376a30ef6b80e9 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 11:10:17 +0200 Subject: [PATCH 08/25] fix: asegurar grupo existente en DB; actualizar prueba de gating Co-authored-by: aider (openrouter/openai/gpt-5) --- src/services/group-sync.ts | 2 ++ tests/unit/server/enforce-gating.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index dd1a5a1..82c6b89 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -811,6 +811,8 @@ export class GroupSyncService { } 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/tests/unit/server/enforce-gating.test.ts b/tests/unit/server/enforce-gating.test.ts index 6143b85..d4341cf 100644 --- a/tests/unit/server/enforce-gating.test.ts +++ b/tests/unit/server/enforce-gating.test.ts @@ -79,9 +79,9 @@ describe('WebhookServer - enforce gating (modo=enforce)', () => { // No debe haber respuestas encoladas (retorno temprano) expect(SimulatedResponseQueue.get().length).toBe(0); - // allowed_groups no contiene allowed para ese grupo + // 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).toBeUndefined(); + expect(row == null).toBe(true); }); test('permite mensaje en grupo allowed y procesa comando', async () => { -- 2.38.5 From 302ba6daa8dfe441ff07fd0e1ef431f6f7bab6f8 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 11:18:13 +0200 Subject: [PATCH 09/25] =?UTF-8?q?feat:=20aplicar=20gating=20en=20syncMembe?= =?UTF-8?q?rsForActiveGroups=20y=20a=C3=B1adir=20pruebas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/services/group-sync.ts | 19 ++++++++ .../group-sync.scheduler.gating.test.ts | 46 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 tests/unit/services/group-sync.scheduler.gating.test.ts diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index 82c6b89..bdc3748 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -605,9 +605,28 @@ 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 + continue; + } + } catch { + // Si falla el check, no bloquear el grupo + } + } + const snapshot = await this.fetchGroupMembersFromAPI(groupId); const res = this.reconcileGroupMembers(groupId, snapshot); groups++; 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'); + }); +}); -- 2.38.5 From ae0a853b63f9dfe90355f9fdd5bdc3161db82d4e Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 11:29:58 +0200 Subject: [PATCH 10/25] feat: agrega AdminService y comandos /admin con bypass del gating Co-authored-by: aider (openrouter/openai/gpt-5) --- src/server.ts | 27 +++++- src/services/admin.ts | 117 +++++++++++++++++++++++ tests/unit/server/admin-approval.test.ts | 111 +++++++++++++++++++++ tests/unit/services/admin.test.ts | 61 ++++++++++++ 4 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 src/services/admin.ts create mode 100644 tests/unit/server/admin-approval.test.ts create mode 100644 tests/unit/services/admin.test.ts diff --git a/src/server.ts b/src/server.ts index 373f15f..e1357d5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,6 +15,7 @@ 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 { @@ -299,6 +300,9 @@ 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 {} @@ -311,13 +315,13 @@ export class WebhookServer { if (!exists) { try { AllowedGroups.upsertPending(remoteJid, null, normalizedSenderId); } catch {} try { Metrics.inc('unknown_groups_discovered_total'); } catch {} - return; + if (!isAdminCmd) return; } } catch { // Si la tabla no existe por alguna razón, intentar upsert y retornar igualmente try { AllowedGroups.upsertPending(remoteJid, null, normalizedSenderId); } catch {} try { Metrics.inc('unknown_groups_discovered_total'); } catch {} - return; + if (!isAdminCmd) return; } } } @@ -329,7 +333,7 @@ export class WebhookServer { if (gatingMode2 === 'enforce') { try { const allowed = AllowedGroups.isAllowed(remoteJid); - if (!allowed) { + if (!allowed && !isAdminCmd) { try { Metrics.inc('messages_blocked_group_total'); } catch {} return; } @@ -339,6 +343,21 @@ export class WebhookServer { } } + // 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 @@ -360,7 +379,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') { diff --git a/src/services/admin.ts b/src/services/admin.ts new file mode 100644 index 0000000..3a26e57 --- /dev/null +++ b/src/services/admin.ts @@ -0,0 +1,117 @@ +import type { Database } from 'bun:sqlite'; +import { db } from '../db'; +import { AllowedGroups } from './allowed-groups'; +import { normalizeWhatsAppId, isGroupId } from '../utils/whatsapp'; + +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; + } + + 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.' }]; + } + AllowedGroups.setStatus(ctx.groupId, 'allowed'); + 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.' }]; + } + AllowedGroups.setStatus(ctx.groupId, 'blocked'); + 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' }]; + } + AllowedGroups.setStatus(arg, 'allowed'); + 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' }]; + } + AllowedGroups.setStatus(arg, 'blocked'); + return [{ recipient: sender, message: `✅ Grupo bloqueado: ${arg}` }]; + } + + // Ayuda por defecto + return [{ recipient: sender, message: this.help() }]; + } +} 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/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); + }); +}); -- 2.38.5 From 7308c73a79d94971babefb3504323c623876e074 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 11:31:57 +0200 Subject: [PATCH 11/25] fix: normalizar ID de WhatsApp para usuarios y grupos Co-authored-by: aider (openrouter/openai/gpt-5) --- src/utils/whatsapp.ts | 61 ++++++++++++++++++++----------------------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/src/utils/whatsapp.ts b/src/utils/whatsapp.ts index 3422955..8cbcb28 100644 --- a/src/utils/whatsapp.ts +++ b/src/utils/whatsapp.ts @@ -1,43 +1,38 @@ /** - * Normalizes a WhatsApp ID by removing the domain part (@s.whatsapp.net, @g.us) - * and any participant identifier (:12). - * Handles potential variations like participant IDs in group messages. - * - * Examples: + * Normaliza un ID de WhatsApp. + * - Para usuarios (DMs o @s.whatsapp.net): devuelve solo dígitos. + * - Para grupos (@g.us): devuelve el identificador numérico con guion (ej. 12345-67890). + * + * Ejemplos: * - '1234567890@s.whatsapp.net' -> '1234567890' + * - '+34 600 123 456' -> '34600123456' + * - '1234567890:12@s.whatsapp.net' -> '1234567890' * - '1234567890-1234567890@g.us' -> '1234567890-1234567890' - * - '1234567890:12@s.whatsapp.net' -> '1234567890' (handles participant format) - * - 'status_me@broadcast' -> 'status_me' (handles status broadcast) - * - * @param id The raw WhatsApp ID string. Can be null or undefined. - * @returns The normalized ID string, or null if the input is null/undefined or invalid after normalization. + * - 'status_me@broadcast' -> null */ export function normalizeWhatsAppId(id: string | null | undefined): string | null { - if (!id) { - return null; - } - - // Remove domain part (@s.whatsapp.net, @g.us, @broadcast etc.) - let normalized = id.split('@')[0]; - - // Handle potential participant format like '1234567890:12' by taking the part before ':' - normalized = normalized.split(':')[0]; - - // Basic validation: should contain alphanumeric characters, possibly hyphens for group IDs - // Allows simple numbers, group IDs with hyphens, and potentially status_me - if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) { - console.warn(`[normalizeWhatsAppId] Invalid characters found in WhatsApp ID after normalization: ${id} -> ${normalized}`); - // Return null for clearly invalid IDs after normalization - return null; - } + if (!id) return null; + const raw = String(id).trim(); + if (!raw) return null; + + // Partes antes de @ y antes de ':' (participante) + const atIdx = raw.indexOf('@'); + const beforeAt = atIdx >= 0 ? raw.slice(0, atIdx) : raw; + const core = beforeAt.split(':')[0]; - // Prevent empty strings after normalization - if (normalized.length === 0) { - console.warn(`[normalizeWhatsAppId] Empty string resulted from normalization: ${id}`); - return null; + // Si es un JID de grupo (en el raw termina en @g.us), conservar dígitos y guion + if (raw.endsWith('@g.us')) { + const groupCore = core.replace(/[^0-9-]/g, ''); + // Aceptar formatos tipo "123-456" o "123456" (algunas libs incluyen un único bloque) + if (!groupCore) return null; + if (!/^\d+(?:-\d+)?$/.test(groupCore)) return null; + return groupCore; } - - return normalized; + + // Usuarios: conservar solo dígitos (eliminar +, espacios, etc.) + const digits = core.replace(/\D+/g, ''); + if (!digits) return null; + return digits; } /** -- 2.38.5 From adad0a06098a186ae05692d319ee1eed63f97110 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 11:35:11 +0200 Subject: [PATCH 12/25] refactor: normalizar IDs de WhatsApp eliminando dominio y separador Co-authored-by: aider (openrouter/openai/gpt-5) --- src/utils/whatsapp.ts | 61 +++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/src/utils/whatsapp.ts b/src/utils/whatsapp.ts index 8cbcb28..3422955 100644 --- a/src/utils/whatsapp.ts +++ b/src/utils/whatsapp.ts @@ -1,38 +1,43 @@ /** - * Normaliza un ID de WhatsApp. - * - Para usuarios (DMs o @s.whatsapp.net): devuelve solo dígitos. - * - Para grupos (@g.us): devuelve el identificador numérico con guion (ej. 12345-67890). - * - * Ejemplos: + * Normalizes a WhatsApp ID by removing the domain part (@s.whatsapp.net, @g.us) + * and any participant identifier (:12). + * Handles potential variations like participant IDs in group messages. + * + * Examples: * - '1234567890@s.whatsapp.net' -> '1234567890' - * - '+34 600 123 456' -> '34600123456' - * - '1234567890:12@s.whatsapp.net' -> '1234567890' * - '1234567890-1234567890@g.us' -> '1234567890-1234567890' - * - 'status_me@broadcast' -> null + * - '1234567890:12@s.whatsapp.net' -> '1234567890' (handles participant format) + * - 'status_me@broadcast' -> 'status_me' (handles status broadcast) + * + * @param id The raw WhatsApp ID string. Can be null or undefined. + * @returns The normalized ID string, or null if the input is null/undefined or invalid after normalization. */ export function normalizeWhatsAppId(id: string | null | undefined): string | null { - if (!id) return null; - const raw = String(id).trim(); - if (!raw) return null; - - // Partes antes de @ y antes de ':' (participante) - const atIdx = raw.indexOf('@'); - const beforeAt = atIdx >= 0 ? raw.slice(0, atIdx) : raw; - const core = beforeAt.split(':')[0]; - - // Si es un JID de grupo (en el raw termina en @g.us), conservar dígitos y guion - if (raw.endsWith('@g.us')) { - const groupCore = core.replace(/[^0-9-]/g, ''); - // Aceptar formatos tipo "123-456" o "123456" (algunas libs incluyen un único bloque) - if (!groupCore) return null; - if (!/^\d+(?:-\d+)?$/.test(groupCore)) return null; - return groupCore; + if (!id) { + return null; + } + + // Remove domain part (@s.whatsapp.net, @g.us, @broadcast etc.) + let normalized = id.split('@')[0]; + + // Handle potential participant format like '1234567890:12' by taking the part before ':' + normalized = normalized.split(':')[0]; + + // Basic validation: should contain alphanumeric characters, possibly hyphens for group IDs + // Allows simple numbers, group IDs with hyphens, and potentially status_me + if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) { + console.warn(`[normalizeWhatsAppId] Invalid characters found in WhatsApp ID after normalization: ${id} -> ${normalized}`); + // Return null for clearly invalid IDs after normalization + return null; } - // Usuarios: conservar solo dígitos (eliminar +, espacios, etc.) - const digits = core.replace(/\D+/g, ''); - if (!digits) return null; - return digits; + // Prevent empty strings after normalization + if (normalized.length === 0) { + console.warn(`[normalizeWhatsAppId] Empty string resulted from normalization: ${id}`); + return null; + } + + return normalized; } /** -- 2.38.5 From c51cb3f124adb2a49c3f68b382197e9a82ba9cb5 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 11:48:44 +0200 Subject: [PATCH 13/25] feat: notifica a ADMIN_USERS al descubrir grupos (modo discover) Co-authored-by: aider (openrouter/openai/gpt-5) --- src/server.ts | 22 +++++ src/services/admin.ts | 4 + .../server/discovery-notify-admins.test.ts | 91 +++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 tests/unit/server/discovery-notify-admins.test.ts diff --git a/src/server.ts b/src/server.ts index e1357d5..e0f4c40 100644 --- a/src/server.ts +++ b/src/server.ts @@ -315,12 +315,34 @@ export class WebhookServer { if (!exists) { try { AllowedGroups.upsertPending(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, 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; } } diff --git a/src/services/admin.ts b/src/services/admin.ts index 3a26e57..69584b4 100644 --- a/src/services/admin.ts +++ b/src/services/admin.ts @@ -24,6 +24,10 @@ export class AdminService { 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; 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']); + }); +}); -- 2.38.5 From db9f71abaaaf3e3079fd2a82bd1cd99b5a322e39 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 11:57:48 +0200 Subject: [PATCH 14/25] feat: aplicar gating por AllowedGroups en tareas y recordatorios Co-authored-by: aider (openrouter/openai/gpt-5) --- src/services/reminders.ts | 22 +++-- src/tasks/service.ts | 14 ++++ tests/unit/services/reminders.gating.test.ts | 75 +++++++++++++++++ tests/unit/tasks/service.gating.test.ts | 84 ++++++++++++++++++++ 4 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 tests/unit/services/reminders.gating.test.ts create mode 100644 tests/unit/tasks/service.gating.test.ts diff --git a/src/services/reminders.ts b/src/services/reminders.ts index d6e71ba..175f213 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,12 @@ 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; } catch {} + } + for (const pref of rows) { // Evitar duplicado el mismo día if (pref.last_reminded_on === todayYMD) continue; @@ -102,9 +109,9 @@ export class RemindersService { if (pref.reminder_freq === 'weekly' && weekday !== 'Mon') continue; try { - const items = TaskService.listUserPending(pref.user_id, 10); - const total = TaskService.countUserPending(pref.user_id); - if (!items || items.length === 0 || total === 0) { + const allItems = TaskService.listUserPending(pref.user_id, 10); + const items = enforce ? allItems.filter(t => !t.group_id || AllowedGroups.isAllowed(t.group_id)) : allItems; + 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,14 +159,15 @@ export class RemindersService { sections.push(...rendered); } - if (total > items.length) { - sections.push(italic(`… y ${total - items.length} más`)); - } + // No contamos "total" global para evitar inconsistencias de grupos bloqueados; dejamos el resumen por ítems visibles. // (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/unit/services/reminders.gating.test.ts b/tests/unit/services/reminders.gating.test.ts new file mode 100644 index 0000000..5bf18fd --- /dev/null +++ b/tests/unit/services/reminders.gating.test.ts @@ -0,0 +1,75 @@ +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'; + +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 = []; + + // 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 + memdb.exec(`INSERT OR IGNORE INTO groups (id) VALUES ('ok@g.us')`); + memdb.exec(`INSERT OR IGNORE INTO groups (id) VALUES ('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 () => { + await RemindersService.runOnce(new Date()); + + expect(sent.length).toBe(1); + const msg = String(sent[0].message); + + // Debe mencionar el grupo allowed y omitir el bloqueado + expect(msg).toContain('ok@g.us'); + expect(msg).not.toContain('na@g.us'); + }); +}); diff --git a/tests/unit/tasks/service.gating.test.ts b/tests/unit/tasks/service.gating.test.ts new file mode 100644 index 0000000..6b36194 --- /dev/null +++ b/tests/unit/tasks/service.gating.test.ts @@ -0,0 +1,84 @@ +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) { + // Intento genérico de seed para la tabla groups con columnas comunes + const cols = db.query(`PRAGMA table_info(groups)`).all() as any[]; + const colNames = cols.map(c => String(c.name)); + const values: Record = {}; + for (const c of colNames) { + if (c === 'id') values[c] = groupId; + else if (c === 'name' || c === 'title' || c === 'subject') values[c] = 'Test Group'; + else if (c === 'is_active' || c === 'active') values[c] = 1; + else if (c.endsWith('_at')) values[c] = new Date().toISOString().replace('T', ' ').replace('Z', ''); + else if (c === 'created_by') values[c] = 'tester'; + // Para otras columnas dejaremos NULL si lo permite + } + const colsList = Object.keys(values); + const placeholders = colsList.map(() => '?').join(', '); + const sql = `INSERT OR IGNORE 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); + }); +}); -- 2.38.5 From 78efc857bcfac4eb5a48b985767c87ed6526a794 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 12:02:04 +0200 Subject: [PATCH 15/25] =?UTF-8?q?feat:=20mostrar=20resumen=20'=E2=80=A6=20?= =?UTF-8?q?y=20N=20m=C3=A1s'=20en=20recordatorios=20cuando=20hay=20tope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/services/reminders.ts | 6 ++- tests/unit/tasks/service.gating.test.ts | 53 ++++++++++++++++++++----- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/services/reminders.ts b/src/services/reminders.ts index 175f213..b191f03 100644 --- a/src/services/reminders.ts +++ b/src/services/reminders.ts @@ -111,6 +111,7 @@ export class RemindersService { try { 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) { // No enviar si no hay tareas; no marcamos last_reminded_on para permitir enviar si aparecen más tarde hoy continue; @@ -159,7 +160,10 @@ export class RemindersService { sections.push(...rendered); } - // No contamos "total" global para evitar inconsistencias de grupos bloqueados; dejamos el resumen por ítems visibles. + // 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`)); + } // (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'; diff --git a/tests/unit/tasks/service.gating.test.ts b/tests/unit/tasks/service.gating.test.ts index 6b36194..39a1d39 100644 --- a/tests/unit/tasks/service.gating.test.ts +++ b/tests/unit/tasks/service.gating.test.ts @@ -5,21 +5,54 @@ import { TaskService } from '../../../src/tasks/service'; import { AllowedGroups } from '../../../src/services/allowed-groups'; function seedGroup(db: Database, groupId: string) { - // Intento genérico de seed para la tabla groups con columnas comunes + // Sembrado robusto: cubrir columnas NOT NULL sin valor por defecto const cols = db.query(`PRAGMA table_info(groups)`).all() as any[]; - const colNames = cols.map(c => String(c.name)); const values: Record = {}; - for (const c of colNames) { - if (c === 'id') values[c] = groupId; - else if (c === 'name' || c === 'title' || c === 'subject') values[c] = 'Test Group'; - else if (c === 'is_active' || c === 'active') values[c] = 1; - else if (c.endsWith('_at')) values[c] = new Date().toISOString().replace('T', ' ').replace('Z', ''); - else if (c === 'created_by') values[c] = 'tester'; - // Para otras columnas dejaremos NULL si lo permite + 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 IGNORE INTO groups (${colsList.join(', ')}) VALUES (${placeholders})`; + const sql = `INSERT OR REPLACE INTO groups (${colsList.join(', ')}) VALUES (${placeholders})`; db.prepare(sql).run(...colsList.map(k => values[k])); } -- 2.38.5 From 6a58f9d136b5cb8fb852dbeaa4857c6ffb02513e Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 12:05:07 +0200 Subject: [PATCH 16/25] test: ajusta aserciones de gating para usar 'Tarea OK' y 'Tarea NA' Co-authored-by: aider (openrouter/openai/gpt-5) --- tests/unit/services/reminders.gating.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/services/reminders.gating.test.ts b/tests/unit/services/reminders.gating.test.ts index 5bf18fd..716dbf0 100644 --- a/tests/unit/services/reminders.gating.test.ts +++ b/tests/unit/services/reminders.gating.test.ts @@ -68,8 +68,8 @@ describe('RemindersService - gating por grupos en modo enforce', () => { expect(sent.length).toBe(1); const msg = String(sent[0].message); - // Debe mencionar el grupo allowed y omitir el bloqueado - expect(msg).toContain('ok@g.us'); - expect(msg).not.toContain('na@g.us'); + // Debe incluir solo la tarea del grupo allowed y omitir la del bloqueado + expect(msg).toContain('Tarea OK'); + expect(msg).not.toContain('Tarea NA'); }); }); -- 2.38.5 From bf46976b1cc867ef16aaf90e6ba1eb26632fbebc Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 12:35:56 +0200 Subject: [PATCH 17/25] =?UTF-8?q?fix:=20limpiar=20cach=C3=A9=20de=20Allowe?= =?UTF-8?q?dGroups=20al=20activar=20gating=20por=20grupos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/services/reminders.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/services/reminders.ts b/src/services/reminders.ts index b191f03..9666b8c 100644 --- a/src/services/reminders.ts +++ b/src/services/reminders.ts @@ -92,7 +92,11 @@ export class RemindersService { // 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; } catch {} + 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) { -- 2.38.5 From 9ca9757f355a53de1e55e41d67e5f52e163143a3 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 12:38:39 +0200 Subject: [PATCH 18/25] test: fijar fecha en gating de recordatorios para pruebas Co-authored-by: aider (openrouter/openai/gpt-5) --- tests/unit/services/reminders.gating.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/services/reminders.gating.test.ts b/tests/unit/services/reminders.gating.test.ts index 716dbf0..d5844fc 100644 --- a/tests/unit/services/reminders.gating.test.ts +++ b/tests/unit/services/reminders.gating.test.ts @@ -63,7 +63,8 @@ describe('RemindersService - gating por grupos en modo enforce', () => { }); it('omite tareas de grupos no allowed en los recordatorios', async () => { - await RemindersService.runOnce(new Date()); + 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); -- 2.38.5 From 43ee9301e442495f75d8979e8b04c1e0070271f1 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 14:32:04 +0200 Subject: [PATCH 19/25] test: crear registro de usuario para cumplir FK de user_preferences Co-authored-by: aider (openrouter/openai/gpt-5) --- tests/unit/services/reminders.gating.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/unit/services/reminders.gating.test.ts b/tests/unit/services/reminders.gating.test.ts index d5844fc..7e3314c 100644 --- a/tests/unit/services/reminders.gating.test.ts +++ b/tests/unit/services/reminders.gating.test.ts @@ -25,6 +25,14 @@ describe('RemindersService - gating por grupos en modo enforce', () => { (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) -- 2.38.5 From a7004d5ef1f0faaa7b855386dc7ea9c6e4ab9338 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 14:34:24 +0200 Subject: [PATCH 20/25] test: usar seedGroup para poblar grupos en gating Co-authored-by: aider (openrouter/openai/gpt-5) --- tests/unit/services/reminders.gating.test.ts | 36 ++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/tests/unit/services/reminders.gating.test.ts b/tests/unit/services/reminders.gating.test.ts index 7e3314c..61df16f 100644 --- a/tests/unit/services/reminders.gating.test.ts +++ b/tests/unit/services/reminders.gating.test.ts @@ -6,6 +6,38 @@ 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; @@ -45,8 +77,8 @@ describe('RemindersService - gating por grupos en modo enforce', () => { `); // Sembrar grupos y estados - memdb.exec(`INSERT OR IGNORE INTO groups (id) VALUES ('ok@g.us')`); - memdb.exec(`INSERT OR IGNORE INTO groups (id) VALUES ('na@g.us')`); + 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 -- 2.38.5 From df8f8a70965b53d0dcf8a9d8fd7f777ff1ad1f09 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 15:48:47 +0200 Subject: [PATCH 21/25] =?UTF-8?q?feat:=20sembrar=20ALLOWED=5FGROUPS=20desd?= =?UTF-8?q?e=20env=20y=20exponer=20m=C3=A9tricas=20en=20/metrics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- docs/architecture.md | 15 +++++++++++++++ docs/operations.md | 11 +++++++++++ src/server.ts | 3 +++ 3 files changed, 29 insertions(+) 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..5d514ba 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -15,6 +15,10 @@ Variables de entorno (principales) - 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'). +- ADMIN_USERS: lista separada por comas de IDs/JIDs autorizados para /admin (se normalizan a dígitos). +- ALLOWED_GROUPS: lista separada por comas de group_id@g.us para sembrado inicial en arranque. +- NOTIFY_ADMINS_ON_DISCOVERY: 'true'/'false' para avisar por DM a ADMIN_USERS al descubrir un grupo (modo 'discover'). 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/src/server.ts b/src/server.ts index e0f4c40..ecd04a5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -471,6 +471,9 @@ 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.seedFromEnv(); } catch {} + const PORT = process.env.PORT || '3007'; console.log('✅ Environment variables validated'); -- 2.38.5 From cdab9da161b537e3c102f57416f89014aeb6ff37 Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 15:50:38 +0200 Subject: [PATCH 22/25] =?UTF-8?q?docs:=20a=C3=B1adir=20ejemplos=20de=20for?= =?UTF-8?q?mato=20a=20variables=20de=20entorno?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- docs/operations.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index 5d514ba..c848bdf 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -8,17 +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'). -- ADMIN_USERS: lista separada por comas de IDs/JIDs autorizados para /admin (se normalizan a dígitos). -- ALLOWED_GROUPS: lista separada por comas de group_id@g.us para sembrado inicial en arranque. -- NOTIFY_ADMINS_ON_DISCOVERY: 'true'/'false' para avisar por DM a ADMIN_USERS al descubrir un grupo (modo 'discover'). +- 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 -- 2.38.5 From 662c3358242e2671cfd012613251e256ed2fb9be Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 16:03:35 +0200 Subject: [PATCH 23/25] =?UTF-8?q?feat:=20instrumentar=20m=C3=A9tricas=20de?= =?UTF-8?q?=20allowed=5Fgroups,=20admin,=20commands=20y=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aider (openrouter/openai/gpt-5) --- src/server.ts | 17 +++++++++++++++++ src/services/admin.ts | 13 +++++++++---- src/services/command.ts | 2 ++ src/services/group-sync.ts | 2 ++ 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/server.ts b/src/server.ts index ecd04a5..a48ef9b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -67,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, { diff --git a/src/services/admin.ts b/src/services/admin.ts index 69584b4..641cc67 100644 --- a/src/services/admin.ts +++ b/src/services/admin.ts @@ -2,6 +2,7 @@ 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) @@ -82,7 +83,8 @@ export class AdminService { if (!isGroupId(ctx.groupId)) { return [{ recipient: sender, message: 'ℹ️ Este comando se debe usar dentro de un grupo.' }]; } - AllowedGroups.setStatus(ctx.groupId, 'allowed'); + 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}` }]; } @@ -91,7 +93,8 @@ export class AdminService { if (!isGroupId(ctx.groupId)) { return [{ recipient: sender, message: 'ℹ️ Este comando se debe usar dentro de un grupo.' }]; } - AllowedGroups.setStatus(ctx.groupId, 'blocked'); + 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}` }]; } @@ -101,7 +104,8 @@ export class AdminService { if (!isGroupId(arg)) { return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }]; } - AllowedGroups.setStatus(arg, 'allowed'); + const changed = AllowedGroups.setStatus(arg, 'allowed'); + try { if (changed) Metrics.inc('admin_actions_total_allow'); } catch {} return [{ recipient: sender, message: `✅ Grupo habilitado: ${arg}` }]; } @@ -111,7 +115,8 @@ export class AdminService { if (!isGroupId(arg)) { return [{ recipient: sender, message: '⚠️ Debes indicar un group_id válido terminado en @g.us' }]; } - AllowedGroups.setStatus(arg, 'blocked'); + const changed = AllowedGroups.setStatus(arg, 'blocked'); + try { if (changed) Metrics.inc('admin_actions_total_block'); } catch {} return [{ recipient: sender, message: `✅ Grupo bloqueado: ${arg}` }]; } diff --git a/src/services/command.ts b/src/services/command.ts index eb3f17f..9387877 100644 --- a/src/services/command.ts +++ b/src/services/command.ts @@ -8,6 +8,7 @@ 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 @@ -1068,6 +1069,7 @@ export class CommandService { if (mode === 'enforce') { try { if (!AllowedGroups.isAllowed(context.groupId)) { + try { Metrics.inc('commands_blocked_total'); } catch {} return []; } } catch { diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index bdc3748..55ae482 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -620,6 +620,7 @@ export class GroupSyncService { try { if (!AllowedGroups.isAllowed(groupId)) { // Saltar grupos no permitidos en modo enforce + try { Metrics.inc('sync_skipped_group_total'); } catch {} continue; } } catch { @@ -821,6 +822,7 @@ export class GroupSyncService { 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 { -- 2.38.5 From ad26dd517514132169271be1c245fa784d0d9a1c Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 16:14:59 +0200 Subject: [PATCH 24/25] feat: guarda label en allowed_groups y actualiza en upsertGroups Co-authored-by: aider (openrouter/openai/gpt-5) --- src/server.ts | 4 +- src/services/group-sync.ts | 2 + tests/unit/server/discovery-label.test.ts | 92 +++++++++++++++++++ .../services/group-sync.label-update.test.ts | 34 +++++++ 4 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 tests/unit/server/discovery-label.test.ts create mode 100644 tests/unit/services/group-sync.label-update.test.ts diff --git a/src/server.ts b/src/server.ts index a48ef9b..751d709 100644 --- a/src/server.ts +++ b/src/server.ts @@ -330,7 +330,7 @@ export class WebhookServer { .prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? LIMIT 1`) .get(remoteJid) as any; if (!exists) { - try { AllowedGroups.upsertPending(remoteJid, null, normalizedSenderId); } catch {} + 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'; @@ -347,7 +347,7 @@ export class WebhookServer { } } catch { // Si la tabla no existe por alguna razón, intentar upsert y retornar igualmente - try { AllowedGroups.upsertPending(remoteJid, null, normalizedSenderId); } catch {} + 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'; diff --git a/src/services/group-sync.ts b/src/services/group-sync.ts index 55ae482..b90383d 100644 --- a/src/services/group-sync.ts +++ b/src/services/group-sync.ts @@ -332,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 }; 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/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'); + }); +}); -- 2.38.5 From 95abc8d0254618488ba41db887a8f235fb817f4b Mon Sep 17 00:00:00 2001 From: borja Date: Mon, 29 Sep 2025 16:23:12 +0200 Subject: [PATCH 25/25] fix: alinear dbInstance de AllowedGroups con la del servidor antes del seed Co-authored-by: aider (openrouter/openai/gpt-5) --- src/server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server.ts b/src/server.ts index 751d709..995bd5f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -489,6 +489,7 @@ export class WebhookServer { 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'; -- 2.38.5