feat(02-03): add MediaAttachment model, WhatsApp normalizer, and signature verification
- Add MediaType(StrEnum) and MediaAttachment(BaseModel) to shared/models/message.py - Add media: list[MediaAttachment] field to MessageContent - Add whatsapp_app_secret, whatsapp_verify_token, and MinIO settings to shared/config.py - Add normalize_whatsapp_event() to gateway/normalize.py (text, image, document support) - Create whatsapp.py adapter with verify_whatsapp_signature() and verify_hub_challenge() - 30 new passing tests (signature verification + normalizer)
This commit is contained in:
365
tests/unit/test_whatsapp_normalize.py
Normal file
365
tests/unit/test_whatsapp_normalize.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
Unit tests for WhatsApp event normalization to KonstructMessage.
|
||||
|
||||
Tests CHAN-03: Messages are normalized to the unified KonstructMessage format.
|
||||
|
||||
Covers:
|
||||
- Text message normalizes correctly
|
||||
- Image message normalizes with MediaAttachment
|
||||
- Document message normalizes with MediaAttachment
|
||||
- Correct field mapping: sender, channel, metadata
|
||||
- ChannelType includes 'whatsapp'
|
||||
- thread_id set to sender wa_id (WhatsApp has no threads)
|
||||
- tenant_id is None after normalization
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from shared.models.message import ChannelType, MediaAttachment, MediaType
|
||||
|
||||
|
||||
def make_text_webhook_payload(
|
||||
phone_number_id: str = "12345678901",
|
||||
wa_id: str = "5511987654321",
|
||||
message_id: str = "wamid.abc123",
|
||||
text: str = "Hello, I need help with my order",
|
||||
timestamp: str = "1711234567",
|
||||
) -> dict:
|
||||
"""Minimal valid Meta Cloud API v20.0 text message webhook payload."""
|
||||
return {
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [
|
||||
{
|
||||
"id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
|
||||
"changes": [
|
||||
{
|
||||
"value": {
|
||||
"messaging_product": "whatsapp",
|
||||
"metadata": {
|
||||
"display_phone_number": "15550000000",
|
||||
"phone_number_id": phone_number_id,
|
||||
},
|
||||
"contacts": [
|
||||
{
|
||||
"profile": {"name": "John Customer"},
|
||||
"wa_id": wa_id,
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"from": wa_id,
|
||||
"id": message_id,
|
||||
"timestamp": timestamp,
|
||||
"text": {"body": text},
|
||||
"type": "text",
|
||||
}
|
||||
],
|
||||
},
|
||||
"field": "messages",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def make_image_webhook_payload(
|
||||
phone_number_id: str = "12345678901",
|
||||
wa_id: str = "5511987654321",
|
||||
message_id: str = "wamid.img456",
|
||||
media_id: str = "media_id_xyz789",
|
||||
mime_type: str = "image/jpeg",
|
||||
) -> dict:
|
||||
"""Minimal valid Meta Cloud API v20.0 image message webhook payload."""
|
||||
return {
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [
|
||||
{
|
||||
"id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
|
||||
"changes": [
|
||||
{
|
||||
"value": {
|
||||
"messaging_product": "whatsapp",
|
||||
"metadata": {
|
||||
"display_phone_number": "15550000000",
|
||||
"phone_number_id": phone_number_id,
|
||||
},
|
||||
"contacts": [
|
||||
{
|
||||
"profile": {"name": "Jane Customer"},
|
||||
"wa_id": wa_id,
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"from": wa_id,
|
||||
"id": message_id,
|
||||
"timestamp": "1711234567",
|
||||
"image": {
|
||||
"id": media_id,
|
||||
"mime_type": mime_type,
|
||||
"sha256": "abc123hash",
|
||||
},
|
||||
"type": "image",
|
||||
}
|
||||
],
|
||||
},
|
||||
"field": "messages",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def make_document_webhook_payload(
|
||||
phone_number_id: str = "12345678901",
|
||||
wa_id: str = "5511987654321",
|
||||
message_id: str = "wamid.doc789",
|
||||
media_id: str = "media_id_doc001",
|
||||
filename: str = "invoice.pdf",
|
||||
) -> dict:
|
||||
"""Minimal valid Meta Cloud API v20.0 document message webhook payload."""
|
||||
return {
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [
|
||||
{
|
||||
"id": "WHATSAPP_BUSINESS_ACCOUNT_ID",
|
||||
"changes": [
|
||||
{
|
||||
"value": {
|
||||
"messaging_product": "whatsapp",
|
||||
"metadata": {
|
||||
"display_phone_number": "15550000000",
|
||||
"phone_number_id": phone_number_id,
|
||||
},
|
||||
"contacts": [
|
||||
{
|
||||
"profile": {"name": "Bob Customer"},
|
||||
"wa_id": wa_id,
|
||||
}
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"from": wa_id,
|
||||
"id": message_id,
|
||||
"timestamp": "1711234567",
|
||||
"document": {
|
||||
"id": media_id,
|
||||
"mime_type": "application/pdf",
|
||||
"filename": filename,
|
||||
},
|
||||
"type": "document",
|
||||
}
|
||||
],
|
||||
},
|
||||
"field": "messages",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestChannelTypeWhatsApp:
|
||||
"""Tests for ChannelType enum including WhatsApp."""
|
||||
|
||||
def test_whatsapp_in_channel_type(self) -> None:
|
||||
"""ChannelType enum must include 'whatsapp' value."""
|
||||
assert ChannelType.WHATSAPP == "whatsapp"
|
||||
assert "whatsapp" in [c.value for c in ChannelType]
|
||||
|
||||
def test_whatsapp_channel_type_str(self) -> None:
|
||||
"""ChannelType.WHATSAPP must equal string 'whatsapp'."""
|
||||
assert str(ChannelType.WHATSAPP) == "whatsapp"
|
||||
|
||||
|
||||
class TestMediaAttachmentModel:
|
||||
"""Tests for the MediaAttachment model."""
|
||||
|
||||
def test_media_attachment_image_fields(self) -> None:
|
||||
"""MediaAttachment must accept image type with required fields."""
|
||||
attachment = MediaAttachment(
|
||||
media_type=MediaType.IMAGE,
|
||||
mime_type="image/jpeg",
|
||||
)
|
||||
assert attachment.media_type == MediaType.IMAGE
|
||||
assert attachment.mime_type == "image/jpeg"
|
||||
assert attachment.url is None
|
||||
assert attachment.storage_key is None
|
||||
|
||||
def test_media_attachment_document_with_filename(self) -> None:
|
||||
"""MediaAttachment must accept document type with filename."""
|
||||
attachment = MediaAttachment(
|
||||
media_type=MediaType.DOCUMENT,
|
||||
mime_type="application/pdf",
|
||||
filename="report.pdf",
|
||||
size_bytes=102400,
|
||||
)
|
||||
assert attachment.media_type == MediaType.DOCUMENT
|
||||
assert attachment.filename == "report.pdf"
|
||||
assert attachment.size_bytes == 102400
|
||||
|
||||
def test_media_attachment_all_fields_optional_except_type(self) -> None:
|
||||
"""MediaAttachment requires only media_type; all other fields are optional."""
|
||||
attachment = MediaAttachment(media_type=MediaType.AUDIO)
|
||||
assert attachment.media_type == MediaType.AUDIO
|
||||
assert attachment.url is None
|
||||
assert attachment.storage_key is None
|
||||
assert attachment.mime_type is None
|
||||
assert attachment.filename is None
|
||||
assert attachment.size_bytes is None
|
||||
|
||||
|
||||
class TestNormalizeWhatsAppText:
|
||||
"""Tests for normalizing WhatsApp text messages."""
|
||||
|
||||
def test_channel_type_is_whatsapp(self) -> None:
|
||||
"""Normalized message must have channel=whatsapp."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_text_webhook_payload()
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.channel == ChannelType.WHATSAPP
|
||||
|
||||
def test_sender_user_id_is_wa_id(self) -> None:
|
||||
"""sender.user_id must be the WhatsApp ID (wa_id) of the sender."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_text_webhook_payload(wa_id="5511987654321")
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.sender.user_id == "5511987654321"
|
||||
|
||||
def test_content_text_extracted(self) -> None:
|
||||
"""content.text must contain the message body text."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_text_webhook_payload(text="Can you help me track my order?")
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.content.text == "Can you help me track my order?"
|
||||
|
||||
def test_channel_metadata_phone_number_id(self) -> None:
|
||||
"""channel_metadata must contain phone_number_id."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_text_webhook_payload(phone_number_id="99988877766")
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.channel_metadata["phone_number_id"] == "99988877766"
|
||||
|
||||
def test_channel_metadata_message_id(self) -> None:
|
||||
"""channel_metadata must contain the WhatsApp message ID (wamid)."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_text_webhook_payload(message_id="wamid.unique123")
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.channel_metadata["message_id"] == "wamid.unique123"
|
||||
|
||||
def test_thread_id_is_sender_wa_id(self) -> None:
|
||||
"""thread_id must be the sender's wa_id (WhatsApp has no threads)."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_text_webhook_payload(wa_id="5511999998888")
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.thread_id == "5511999998888"
|
||||
|
||||
def test_tenant_id_none_after_normalization(self) -> None:
|
||||
"""tenant_id must be None immediately after normalization."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_text_webhook_payload()
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.tenant_id is None
|
||||
|
||||
def test_timestamp_is_utc(self) -> None:
|
||||
"""timestamp must be a timezone-aware datetime in UTC."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_text_webhook_payload(timestamp="1711234567")
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.timestamp.tzinfo is not None
|
||||
assert msg.timestamp.tzinfo == timezone.utc
|
||||
|
||||
def test_no_media_attachments_for_text_message(self) -> None:
|
||||
"""Text message must not produce any media attachments."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_text_webhook_payload()
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.content.media == []
|
||||
|
||||
|
||||
class TestNormalizeWhatsAppImage:
|
||||
"""Tests for normalizing WhatsApp image messages."""
|
||||
|
||||
def test_image_message_has_media_attachment(self) -> None:
|
||||
"""Image message must produce a MediaAttachment in content.media."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_image_webhook_payload()
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert len(msg.content.media) == 1
|
||||
|
||||
def test_image_attachment_type_is_image(self) -> None:
|
||||
"""MediaAttachment media_type must be IMAGE for image messages."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_image_webhook_payload()
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.content.media[0].media_type == MediaType.IMAGE
|
||||
|
||||
def test_image_attachment_mime_type(self) -> None:
|
||||
"""MediaAttachment mime_type must be preserved from webhook payload."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_image_webhook_payload(mime_type="image/png")
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.content.media[0].mime_type == "image/png"
|
||||
|
||||
def test_image_attachment_url_contains_media_id(self) -> None:
|
||||
"""MediaAttachment url must contain the media_id as a placeholder for later download."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_image_webhook_payload(media_id="media_id_xyz789")
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.content.media[0].url is not None
|
||||
assert "media_id_xyz789" in msg.content.media[0].url
|
||||
|
||||
def test_image_message_text_is_empty_string(self) -> None:
|
||||
"""Image message with no caption must have empty string as text content."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_image_webhook_payload()
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.content.text == ""
|
||||
|
||||
|
||||
class TestNormalizeWhatsAppDocument:
|
||||
"""Tests for normalizing WhatsApp document messages."""
|
||||
|
||||
def test_document_message_has_media_attachment(self) -> None:
|
||||
"""Document message must produce a MediaAttachment in content.media."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_document_webhook_payload()
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert len(msg.content.media) == 1
|
||||
|
||||
def test_document_attachment_type_is_document(self) -> None:
|
||||
"""MediaAttachment media_type must be DOCUMENT for document messages."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_document_webhook_payload()
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.content.media[0].media_type == MediaType.DOCUMENT
|
||||
|
||||
def test_document_attachment_filename(self) -> None:
|
||||
"""MediaAttachment filename must be preserved from webhook payload."""
|
||||
from gateway.normalize import normalize_whatsapp_event
|
||||
|
||||
payload = make_document_webhook_payload(filename="contract.pdf")
|
||||
msg = normalize_whatsapp_event(payload)
|
||||
assert msg.content.media[0].filename == "contract.pdf"
|
||||
143
tests/unit/test_whatsapp_verify.py
Normal file
143
tests/unit/test_whatsapp_verify.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Unit tests for WhatsApp webhook signature verification.
|
||||
|
||||
Tests CHAN-03: Webhook signature is verified via HMAC-SHA256 on raw body bytes
|
||||
before any JSON parsing.
|
||||
|
||||
Covers:
|
||||
- Valid signature passes (returns raw body bytes)
|
||||
- Invalid signature raises HTTPException(403)
|
||||
- Timing-safe comparison used (hmac.compare_digest)
|
||||
- Hub challenge verification (GET endpoint)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def make_valid_signature(body: bytes, app_secret: str) -> str:
|
||||
"""Helper: generate a valid X-Hub-Signature-256 header value."""
|
||||
sig = hmac.new(app_secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
return f"sha256={sig}"
|
||||
|
||||
|
||||
class TestWhatsAppSignatureVerification:
|
||||
"""Tests for verify_whatsapp_signature function."""
|
||||
|
||||
def test_valid_signature_returns_body(self) -> None:
|
||||
"""verify_whatsapp_signature must return raw body bytes when signature is valid."""
|
||||
from gateway.channels.whatsapp import verify_whatsapp_signature
|
||||
|
||||
app_secret = "test_app_secret_abc123"
|
||||
body = b'{"object":"whatsapp_business_account"}'
|
||||
sig = make_valid_signature(body, app_secret)
|
||||
|
||||
result = verify_whatsapp_signature(body, sig, app_secret)
|
||||
assert result == body
|
||||
|
||||
def test_invalid_signature_raises_403(self) -> None:
|
||||
"""verify_whatsapp_signature must raise HTTPException(403) when signature is invalid."""
|
||||
from gateway.channels.whatsapp import verify_whatsapp_signature
|
||||
|
||||
app_secret = "test_app_secret_abc123"
|
||||
body = b'{"object":"whatsapp_business_account"}'
|
||||
bad_sig = "sha256=deadbeef0000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
verify_whatsapp_signature(body, bad_sig, app_secret)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
def test_missing_sha256_prefix_raises_403(self) -> None:
|
||||
"""Signature without 'sha256=' prefix must raise HTTPException(403)."""
|
||||
from gateway.channels.whatsapp import verify_whatsapp_signature
|
||||
|
||||
app_secret = "test_app_secret_abc123"
|
||||
body = b'{"test": "data"}'
|
||||
# No 'sha256=' prefix
|
||||
bad_sig = "abcdef1234567890" * 4
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
verify_whatsapp_signature(body, bad_sig, app_secret)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
def test_empty_body_valid_signature(self) -> None:
|
||||
"""verify_whatsapp_signature works on empty body with correct signature."""
|
||||
from gateway.channels.whatsapp import verify_whatsapp_signature
|
||||
|
||||
app_secret = "secret"
|
||||
body = b""
|
||||
sig = make_valid_signature(body, app_secret)
|
||||
|
||||
result = verify_whatsapp_signature(body, sig, app_secret)
|
||||
assert result == body
|
||||
|
||||
def test_tampered_body_raises_403(self) -> None:
|
||||
"""Signature computed on original body fails if body is modified."""
|
||||
from gateway.channels.whatsapp import verify_whatsapp_signature
|
||||
|
||||
app_secret = "secret"
|
||||
original_body = b'{"message": "hello"}'
|
||||
sig = make_valid_signature(original_body, app_secret)
|
||||
|
||||
# Tamper with the body
|
||||
tampered_body = b'{"message": "injected"}'
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
verify_whatsapp_signature(tampered_body, sig, app_secret)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
class TestWhatsAppHubVerification:
|
||||
"""Tests for WhatsApp webhook GET verification (hub challenge handshake)."""
|
||||
|
||||
def test_valid_token_returns_challenge(self) -> None:
|
||||
"""GET webhook with valid verify_token must return hub.challenge as plain text."""
|
||||
from gateway.channels.whatsapp import verify_hub_challenge
|
||||
|
||||
token = "my_verify_token_xyz"
|
||||
challenge = "challenge_abc_123"
|
||||
|
||||
result = verify_hub_challenge(
|
||||
hub_mode="subscribe",
|
||||
hub_verify_token=token,
|
||||
hub_challenge=challenge,
|
||||
expected_token=token,
|
||||
)
|
||||
assert result == challenge
|
||||
|
||||
def test_invalid_token_raises_403(self) -> None:
|
||||
"""GET webhook with wrong verify_token must raise HTTPException(403)."""
|
||||
from gateway.channels.whatsapp import verify_hub_challenge
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
verify_hub_challenge(
|
||||
hub_mode="subscribe",
|
||||
hub_verify_token="wrong_token",
|
||||
hub_challenge="challenge_123",
|
||||
expected_token="correct_token",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
def test_wrong_mode_raises_403(self) -> None:
|
||||
"""GET webhook with mode != 'subscribe' must raise HTTPException(403)."""
|
||||
from gateway.channels.whatsapp import verify_hub_challenge
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
verify_hub_challenge(
|
||||
hub_mode="unsubscribe",
|
||||
hub_verify_token="correct_token",
|
||||
hub_challenge="challenge_123",
|
||||
expected_token="correct_token",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
Reference in New Issue
Block a user