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); + }); +});