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();
}
?>