db = Database::getInstance(); $this->backupGlobals(); } public function runAllWebhookTests() { echo "

🔗 Tests Exhaustivos de Webhook

"; $this->testWebhookVerification(); $this->testWebhookMessageProcessing(); $this->testWebhookSecurity(); $this->testWebhookLogging(); $this->testWebhookErrorHandling(); $this->restoreGlobals(); } private function backupGlobals() { $this->originalServer = $_SERVER ?? []; $this->originalGet = $_GET ?? []; $this->originalPost = $_POST ?? []; } private function restoreGlobals() { $_SERVER = $this->originalServer; $_GET = $this->originalGet; $_POST = $this->originalPost; } private function testWebhookVerification() { echo "

✅ Tests de Verificación de Webhook

"; $this->webhookTest("Verificación exitosa con token correcto", function() { // Configurar entorno para verificación $_SERVER['REQUEST_METHOD'] = 'GET'; $_GET = [ 'hub_mode' => 'subscribe', 'hub_verify_token' => WEBHOOK_VERIFY_TOKEN, 'hub_challenge' => 'test_challenge_123' ]; ob_start(); try { $webhook = new WhatsAppWebhook(); $reflection = new ReflectionClass($webhook); $method = $reflection->getMethod('verifyWebhook'); $method->setAccessible(true); $method->invoke($webhook); $output = ob_get_clean(); if ($output !== 'test_challenge_123') { throw new Exception("Challenge no retornado correctamente: '$output'"); } return "Verificación exitosa retorna challenge"; } catch (Exception $e) { ob_end_clean(); throw $e; } }); $this->webhookTest("Rechazo con token incorrecto", function() { $_SERVER['REQUEST_METHOD'] = 'GET'; $_GET = [ 'hub_mode' => 'subscribe', 'hub_verify_token' => 'token_incorrecto', 'hub_challenge' => 'test_challenge_123' ]; ob_start(); try { $webhook = new WhatsAppWebhook(); $reflection = new ReflectionClass($webhook); $method = $reflection->getMethod('verifyWebhook'); $method->setAccessible(true); $method->invoke($webhook); $output = ob_get_clean(); // Debería devolver error, no el challenge if ($output === 'test_challenge_123') { throw new Exception("Token incorrecto fue aceptado"); } return "Token incorrecto rechazado correctamente"; } catch (Exception $e) { ob_end_clean(); // Si hay excepción, está bien (debería rechazar) if (strpos($e->getMessage(), 'Token de verificación inválido') !== false) { return "Token incorrecto rechazado con mensaje apropiado"; } throw $e; } }); $this->webhookTest("Validación de parámetros requeridos", function() { $_SERVER['REQUEST_METHOD'] = 'GET'; $_GET = [ 'hub_mode' => 'subscribe', // Falta hub_verify_token 'hub_challenge' => 'test_challenge_123' ]; ob_start(); try { $webhook = new WhatsAppWebhook(); $reflection = new ReflectionClass($webhook); $method = $reflection->getMethod('verifyWebhook'); $method->setAccessible(true); $method->invoke($webhook); $output = ob_get_clean(); if ($output === 'test_challenge_123') { throw new Exception("Parámetros faltantes fueron aceptados"); } return "Parámetros faltantes rechazados correctamente"; } catch (Exception $e) { ob_end_clean(); return "Parámetros faltantes rechazados (excepción esperada)"; } }); } private function testWebhookMessageProcessing() { echo "

📨 Tests de Procesamiento de Mensajes

"; $this->webhookTest("Estructura de payload de WhatsApp", function() { $validPayload = [ 'object' => 'whatsapp_business_account', 'entry' => [ [ 'id' => '123456789', 'changes' => [ [ 'value' => [ 'messaging_product' => 'whatsapp', 'metadata' => [ 'display_phone_number' => '573001234567', 'phone_number_id' => WHATSAPP_PHONE_NUMBER_ID ], 'conversations' => [ [ 'from' => '573009876543', 'id' => 'wamid.test123', 'timestamp' => time(), 'text' => [ 'body' => 'Hola, mensaje de prueba' ], 'type' => 'text' ] ] ], 'field' => 'conversations' ] ] ] ] ]; // Verificar estructura básica if (!isset($validPayload['object']) || $validPayload['object'] !== 'whatsapp_business_account') { throw new Exception("Estructura de payload incorrecta"); } if (!isset($validPayload['entry'][0]['changes'][0]['value']['conversations'])) { throw new Exception("Estructura de mensajes incorrecta"); } return "Estructura de payload de WhatsApp es válida"; }); $this->webhookTest("Procesamiento de mensaje de texto", function() { $testPhone = '573009999999'; // Limpiar usuario de prueba si existe $this->db->execute("DELETE FROM users WHERE phone_number = :phone", ['phone' => $testPhone]); $_SERVER['REQUEST_METHOD'] = 'POST'; $payload = [ 'object' => 'whatsapp_business_account', 'entry' => [ [ 'changes' => [ [ 'value' => [ 'conversations' => [ [ 'from' => $testPhone, 'id' => 'test_message_id', 'timestamp' => time(), 'text' => ['body' => 'hola'], 'type' => 'text' ] ] ] ] ] ] ] ]; // Simular php://input $GLOBALS['HTTP_RAW_POST_DATA'] = json_encode($payload); ob_start(); try { $webhook = new WhatsAppWebhook(); $reflection = new ReflectionClass($webhook); $method = $reflection->getMethod('processIncomingMessage'); $method->setAccessible(true); $method->invoke($webhook); $output = ob_get_clean(); // Verificar que el usuario se creó $user = $this->db->fetch( "SELECT * FROM users WHERE phone_number = :phone", ['phone' => $testPhone] ); if (!$user) { throw new Exception("Usuario no fue creado automáticamente"); } // Limpiar $this->db->execute("DELETE FROM users WHERE id = :id", ['id' => $user['id']]); return "Mensaje de texto procesado y usuario creado"; } catch (Exception $e) { ob_end_clean(); throw $e; } finally { unset($GLOBALS['HTTP_RAW_POST_DATA']); } }); $this->webhookTest("Guardado de conversación", function() { $testPhone = '573008888888'; // Crear usuario de prueba $userId = $this->db->insert('users', [ 'phone_number' => $testPhone, 'name' => 'Test Conversation User', 'status' => 'active' ]); // Simular guardado de conversación $webhook = new WhatsAppWebhook(); $reflection = new ReflectionClass($webhook); $method = $reflection->getMethod('saveMessage'); $method->setAccessible(true); $messageData = [ 'user_id' => $userId, 'message_id' => 'test_msg_123', 'direction' => 'incoming', 'message_type' => 'text', 'content' => 'Mensaje de prueba', 'status' => 'received', 'created_at' => date('Y-m-d H:i:s') ]; $conversationId = $method->invokeArgs($webhook, [$messageData]); if (!$conversationId) { throw new Exception("Conversación no se guardó"); } // Verificar que se guardó correctamente $conversation = $this->db->fetch( "SELECT * FROM conversations WHERE id = :id", ['id' => $conversationId] ); if ($conversation['content'] !== 'Mensaje de prueba') { throw new Exception("Contenido de conversación incorrecto"); } // Limpiar $this->db->execute("DELETE FROM conversations WHERE id = :id", ['id' => $conversationId]); $this->db->execute("DELETE FROM users WHERE id = :id", ['id' => $userId]); return "Conversación guardada correctamente"; }); $this->webhookTest("Procesamiento de reacción entrante", function() { $testPhone = '573007777000'; // Crear usuario y mensaje referenciado $userId = $this->db->insert('users', ['phone_number' => $testPhone, 'name' => 'Reactor', 'status' => 'active']); $origMsgId = '1001'; $this->db->insert('conversations', [ 'user_id' => $userId, 'message_id' => $origMsgId, 'direction' => 'incoming', 'message_type' => 'text', 'content' => 'Original msg', 'status' => 'received', 'created_at' => date('Y-m-d H:i:s') ]); $payload = [ 'object' => 'whatsapp_business_account', 'entry' => [ [ 'changes' => [ [ 'field' => 'conversations', 'value' => [ 'conversations' => [ [ 'from' => $testPhone, 'id' => '2001', 'timestamp' => time(), 'type' => 'reaction', 'reaction' => ['message_id' => $origMsgId, 'emoji' => '👍'] ] ] ] ] ] ] ] ]; $webhook = new WhatsAppWebhook(); $result = $webhook->processPayload($payload); $conv = $this->db->fetch("SELECT * FROM conversations WHERE message_id = :mid", ['mid' => '2001']); // Limpiar $this->db->execute("DELETE FROM conversations WHERE message_id = :mid", ['mid' => '2001']); $this->db->execute("DELETE FROM conversations WHERE message_id = :mid", ['mid' => $origMsgId]); $this->db->execute("DELETE FROM users WHERE id = :id", ['id' => $userId]); if (!$conv) throw new Exception("Reacción no se guardó"); if (intval($conv['reaction_to_message_id']) !== intval($origMsgId)) throw new Exception("reaction_to_message_id no guardado correctamente"); if ($conv['reaction_emoji'] !== '👍') throw new Exception("reaction_emoji incorrecto"); return "Reacción procesada y guardada correctamente"; }); $this->webhookTest("Procesamiento de reply/context entrante", function() { $testPhone = '573007777111'; $userId = $this->db->insert('users', ['phone_number' => $testPhone, 'name' => 'Replier', 'status' => 'active']); $origMsgId = '1002'; $this->db->insert('conversations', [ 'user_id' => $userId, 'message_id' => $origMsgId, 'direction' => 'incoming', 'message_type' => 'text', 'content' => 'Original for reply', 'status' => 'received', 'created_at' => date('Y-m-d H:i:s') ]); $payload = [ 'object' => 'whatsapp_business_account', 'entry' => [ [ 'changes' => [ [ 'field' => 'conversations', 'value' => [ 'conversations' => [ [ 'from' => $testPhone, 'id' => '2002', 'timestamp' => time(), 'type' => 'text', 'text' => ['body' => 'Reply message'], 'context' => ['id' => $origMsgId] ] ] ] ] ] ] ] ]; $webhook = new WhatsAppWebhook(); $result = $webhook->processPayload($payload); $conv = $this->db->fetch("SELECT * FROM conversations WHERE message_id = :mid", ['mid' => '2002']); // Limpiar $this->db->execute("DELETE FROM conversations WHERE message_id = :mid", ['mid' => '2002']); $this->db->execute("DELETE FROM conversations WHERE message_id = :mid", ['mid' => $origMsgId]); $this->db->execute("DELETE FROM users WHERE id = :id", ['id' => $userId]); if (!$conv) throw new Exception("Reply no se guardó"); if ($conv['reply_to_message_id'] != $origMsgId) throw new Exception("reply_to_message_id no guardado correctamente"); return "Reply/context procesado y guardado correctamente"; }); } private function testWebhookSecurity() { echo "

🔒 Tests de Seguridad de Webhook

"; $this->webhookTest("Validación de origen de petición", function() { // Test con User-Agent no esperado $_SERVER['HTTP_USER_AGENT'] = 'SuspiciousBot/1.0'; $_SERVER['REQUEST_METHOD'] = 'POST'; // En un webhook real, esto debería validarse // Por ahora solo verificamos que el webhook no explote return "Validación de User-Agent (implementación básica)"; }); $this->webhookTest("Resistencia a payloads malformados", function() { $_SERVER['REQUEST_METHOD'] = 'POST'; // Payload inválido (JSON malformado) $GLOBALS['HTTP_RAW_POST_DATA'] = '{"invalid": json malformed}'; ob_start(); try { $webhook = new WhatsAppWebhook(); $reflection = new ReflectionClass($webhook); $method = $reflection->getMethod('processIncomingMessage'); $method->setAccessible(true); $method->invoke($webhook); $output = ob_get_clean(); // No debería explotar, debería manejar el error return "Payload malformado manejado sin errores críticos"; } catch (Exception $e) { ob_end_clean(); // Si maneja la excepción apropiadamente, está bien return "Payload malformado rechazado apropiadamente"; } finally { unset($GLOBALS['HTTP_RAW_POST_DATA']); } }); $this->webhookTest("Prevención de inyección SQL", function() { $maliciousPhone = "'; DROP TABLE users; --"; // Intentar crear usuario con payload malicioso try { $webhook = new WhatsAppWebhook(); $reflection = new ReflectionClass($webhook); $method = $reflection->getMethod('createUser'); $method->setAccessible(true); // Esto debería usar parámetros preparados y no ser vulnerable $result = $method->invokeArgs($webhook, [$maliciousPhone]); // Verificar que la tabla users sigue existiendo $tablesCheck = $this->db->fetchAll("SHOW TABLES LIKE 'users'"); if (empty($tablesCheck)) { throw new Exception("Posible inyección SQL - tabla eliminada"); } // Limpiar si se creó algo $this->db->execute( "DELETE FROM users WHERE phone_number = :phone", ['phone' => $maliciousPhone] ); return "Resistente a inyección SQL básica"; } catch (Exception $e) { // Si hay error en la inserción por caracteres inválidos, está bien return "Inyección SQL prevenida (error esperado en inserción)"; } }); } private function testWebhookLogging() { echo "

📋 Tests de Logging de Webhook

"; $this->webhookTest("Verificar tabla webhook_logs", function() { $tables = $this->db->fetchAll("SHOW TABLES LIKE 'webhook_logs'"); if (empty($tables)) { throw new Exception("Tabla webhook_logs no existe"); } // Verificar estructura $columns = $this->db->fetchAll("DESCRIBE webhook_logs"); $requiredColumns = ['id', 'request_body', 'response_body', 'status_code', 'ip_address', 'created_at']; $existingColumns = array_column($columns, 'Field'); foreach ($requiredColumns as $col) { if (!in_array($col, $existingColumns)) { throw new Exception("Columna requerida '$col' faltante en webhook_logs"); } } return "Tabla webhook_logs existe con estructura correcta"; }); $this->webhookTest("Logging de webhooks", function() { $webhook = new WhatsAppWebhook(); $reflection = new ReflectionClass($webhook); if ($reflection->hasMethod('logWebhook')) { $method = $reflection->getMethod('logWebhook'); $method->setAccessible(true); $testRequest = '{"test": "request"}'; $testResponse = '{"test": "response"}'; $testStatus = 200; $logId = $method->invokeArgs($webhook, [$testRequest, $testResponse, $testStatus]); if ($logId) { // Verificar que se guardó $log = $this->db->fetch( "SELECT * FROM webhook_logs WHERE id = :id", ['id' => $logId] ); if ($log && $log['status_code'] == 200) { // Limpiar $this->db->execute("DELETE FROM webhook_logs WHERE id = :id", ['id' => $logId]); return "Logging de webhook funciona correctamente"; } } } return "Método de logging no existe o no funciona (feature opcional)"; }); } private function testWebhookErrorHandling() { echo "

⚠️ Tests de Manejo de Errores

"; $this->webhookTest("Manejo de payload vacío", function() { $_SERVER['REQUEST_METHOD'] = 'POST'; $GLOBALS['HTTP_RAW_POST_DATA'] = ''; ob_start(); try { $webhook = new WhatsAppWebhook(); $webhook->handleRequest(); $output = ob_get_clean(); // Debería manejar el payload vacío sin explotar return "Payload vacío manejado sin errores críticos"; } catch (Exception $e) { ob_end_clean(); return "Payload vacío genera excepción controlada"; } finally { unset($GLOBALS['HTTP_RAW_POST_DATA']); } }); $this->webhookTest("Manejo de método HTTP inválido", function() { $_SERVER['REQUEST_METHOD'] = 'DELETE'; ob_start(); try { $webhook = new WhatsAppWebhook(); $webhook->handleRequest(); $output = ob_get_clean(); // Debería rechazar método inválido return "Método HTTP inválido rechazado apropiadamente"; } catch (Exception $e) { ob_end_clean(); return "Método inválido genera error controlado"; } }); $this->webhookTest("Resistencia a concurrencia", function() { // Test básico - verificar que no hay race conditions obvios $testPhone = '573007777777'; // Simular múltiples mensajes del mismo usuario for ($i = 0; $i < 3; $i++) { try { $webhook = new WhatsAppWebhook(); $reflection = new ReflectionClass($webhook); $method = $reflection->getMethod('getUserByPhone'); $method->setAccessible(true); $user = $method->invokeArgs($webhook, [$testPhone]); // No debería explotar en llamadas concurrentes } catch (Exception $e) { // Errores esperados en concurrencia } } return "Resistente a llamadas concurrentes básicas"; }); } private function webhookTest($name, $callable) { try { $result = $callable(); echo "
$name: $result
"; } catch (Exception $e) { echo "
$name: " . $e->getMessage() . "
"; } } } // No ejecutar si es incluido por el runner principal if (basename($_SERVER['PHP_SELF']) === 'webhook_tests.php' || php_sapi_name() === 'cli') { $configPath = __DIR__ . '/../config/config.php'; if (file_exists($configPath)) { require_once $configPath; } // Asegurar que la clase WhatsAppWebhook esté disponible cuando se ejecutan tests desde CLI $webhookPath = __DIR__ . '/../api/webhook.php'; if (file_exists($webhookPath)) { require_once $webhookPath; } $suite = new WebhookTestSuite(); echo "RUNNING WEBHOOK TESTS\n"; $suite->runAllWebhookTests(); } ?>