654 lines
26 KiB
PHP
654 lines
26 KiB
PHP
<?php
|
|
/**
|
|
* Tests específicos para Webhook de WhatsApp
|
|
* Fecha: 3 de enero de 2026
|
|
*/
|
|
|
|
class WebhookTestSuite {
|
|
private $db;
|
|
private $originalServer;
|
|
private $originalGet;
|
|
private $originalPost;
|
|
|
|
public function __construct() {
|
|
$this->db = Database::getInstance();
|
|
$this->backupGlobals();
|
|
}
|
|
|
|
public function runAllWebhookTests() {
|
|
echo "<h2>🔗 Tests Exhaustivos de Webhook</h2>";
|
|
|
|
$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 "<h3>✅ Tests de Verificación de Webhook</h3>";
|
|
|
|
$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 "<h3>📨 Tests de Procesamiento de Mensajes</h3>";
|
|
|
|
$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
|
|
],
|
|
'messages' => [
|
|
[
|
|
'from' => '573009876543',
|
|
'id' => 'wamid.test123',
|
|
'timestamp' => time(),
|
|
'text' => [
|
|
'body' => 'Hola, mensaje de prueba'
|
|
],
|
|
'type' => 'text'
|
|
]
|
|
]
|
|
],
|
|
'field' => 'messages'
|
|
]
|
|
]
|
|
]
|
|
]
|
|
];
|
|
|
|
// 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']['messages'])) {
|
|
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' => [
|
|
'messages' => [
|
|
[
|
|
'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' => 'messages',
|
|
'value' => [
|
|
'messages' => [
|
|
[
|
|
'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' => 'messages',
|
|
'value' => [
|
|
'messages' => [
|
|
[
|
|
'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 "<h3>🔒 Tests de Seguridad de Webhook</h3>";
|
|
|
|
$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 "<h3>📋 Tests de Logging de Webhook</h3>";
|
|
|
|
$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 "<h3>⚠️ Tests de Manejo de Errores</h3>";
|
|
|
|
$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 "<div class='alert alert-success'><i class='fas fa-check'></i> <strong>$name:</strong> $result</div>";
|
|
} catch (Exception $e) {
|
|
echo "<div class='alert alert-danger'><i class='fas fa-times'></i> <strong>$name:</strong> " . $e->getMessage() . "</div>";
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
?>
|