# Story 3.7: Webhook - Spécification URL

Status: ready-for-dev

<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->

## Story

En tant qu'**utilisateur**,
Je veux **pouvoir spécifier une URL de webhook dans ma requête de traduction**,
de sorte que **je puisse être notifié automatiquement quand la traduction est terminée**.

## Acceptance Criteria

1. **Paramètre webhook_url accepté**: POST `/api/v1/translate` accepte un paramètre `webhook_url` optionnel. (FR36, FR65)
2. **Validation format URL**: Si `webhook_url` est fourni, il doit être une URL HTTP/HTTPS valide.
3. **Erreur INVALID_WEBHOOK_URL**: Si le format est invalide, retourne 400 avec error code `INVALID_WEBHOOK_URL`.
4. **Stockage avec le job**: `webhook_url` est stocké avec le job de traduction pour utilisation ultérieure.
5. **Paramètre optionnel**: `webhook_url` est optionnel - la traduction fonctionne sans webhook. (FR65)
6. **Documentation OpenAPI**: Le paramètre `webhook_url` est documenté avec description et exemple.

## Tasks / Subtasks

- [ ] **Task 1: Améliorer la validation du webhook_url** (AC: #2, #3)
  - [ ] 1.1 Créer une fonction `validate_webhook_url()` dédiée dans `middleware/validation.py`
  - [ ] 1.2 Valider le format URL (http/https, domaine valide, pas d'IP privée)
  - [ ] 1.3 Retourner l'erreur `INVALID_WEBHOOK_URL` avec détails appropriés
  - [ ] 1.4 Ajouter des tests unitaires pour la validation

- [ ] **Task 2: Mettre à jour le endpoint translate** (AC: #1, #4)
  - [ ] 2.1 Vérifier que `webhook_url` est déjà accepté comme paramètre Form
  - [ ] 2.2 Intégrer la nouvelle validation dans le flux du endpoint
  - [ ] 2.3 S'assurer que `webhook_url` est stocké dans `_translation_jobs[job_id]`
  - [ ] 2.4 Vérifier que le code existant de notification webhook (Story 3.8) utilise bien cette valeur

- [ ] **Task 3: Ajouter le code d'erreur INVALID_WEBHOOK_URL** (AC: #3)
  - [ ] 3.1 Vérifier que `INVALID_WEBHOOK_URL` existe déjà dans `schemas/errors.py`
  - [ ] 3.2 Ajouter un exemple d'erreur dans `ERROR_EXAMPLES` si manquant
  - [ ] 3.3 Documenter le code d'erreur dans la docstring du endpoint

- [ ] **Task 4: Documenter le paramètre dans OpenAPI** (AC: #6)
  - [ ] 4.1 Ajouter une description complète au paramètre `webhook_url` dans le Form
  - [ ] 4.2 Ajouter un exemple de requête avec webhook_url dans la documentation
  - [ ] 4.3 Documenter le comportement Fire & Forget (Story 3.8)

- [ ] **Task 5: Tests d'intégration** (AC: Tous)
  - [ ] 5.1 Tester une requête avec webhook_url valide
  - [ ] 5.2 Tester une requête avec webhook_url invalide (mauvais format)
  - [ ] 5.3 Tester une requête avec webhook_url invalide (URL non HTTP/HTTPS)
  - [ ] 5.4 Tester une requête sans webhook_url (devrait réussir)
  - [ ] 5.5 Vérifier que le webhook_url est bien stocké dans le job

## Dev Notes

### État Actuel de l'Implémentation

**CE QUI EXISTE DÉJÀ** dans `routes/translate_routes.py`:

```python
# Ligne ~350 - Paramètre déjà accepté
webhook_url: Optional[str] = Form(None, description="Webhook URL for notification"),

# Ligne ~400 - Validation basique existante
if webhook_url:
    if not re.match(r"^https?://", webhook_url, re.IGNORECASE):
        raise TranslateEndpointError(
            code="INVALID_FORMAT",  # ⚠️ Devrait être INVALID_WEBHOOK_URL
            message="URL webhook invalide. Doit commencer par http:// ou https://",
            details={"field": "webhook_url"},
        )

# Ligne ~470 - Stockage dans le job
"webhook_url": webhook_url,

# Ligne ~560-575 - Notification webhook existante (Story 3.8)
if webhook_url:
    try:
        async with httpx.AsyncClient(timeout=10) as client:
            await client.post(
                webhook_url,
                json={
                    "translation_id": job_id,
                    "status": job["status"],
                    "timestamp": datetime.now(timezone.utc).isoformat(),
                    "file_name": job.get("file_name"),
                    "error_message": job.get("error_message"),
                },
            )
    except Exception as e:
        logger.warning(f"Job {job_id}: Webhook notification failed - {e}")
```

**CE QUI MANQUE**:
- ❌ Code d'erreur `INVALID_WEBHOOK_URL` au lieu de `INVALID_FORMAT`
- ❌ Validation plus robuste (domaine valide, pas d'IP privée pour sécurité)
- ❌ Documentation OpenAPI complète pour le paramètre
- ❌ Tests unitaires dédiés à la validation webhook

### Architecture Patterns

**Validation Pattern** (depuis `middleware/validation.py`):

```python
class ValidationError(Exception):
    """Exception for validation errors with structured error codes."""
    def __init__(self, code: str, message: str, details: Optional[dict] = None):
        self.code = code
        self.message = message
        self.details = details or {}

class WebhookURLValidator:
    """Validator for webhook URLs."""
    
    def __init__(self, allowed_schemes: tuple = ("http", "https")):
        self.allowed_schemes = allowed_schemes
    
    def validate(self, url: str) -> ValidationResult:
        """Validate webhook URL format and security."""
        # Implementation
        pass
```

**Error Response Format** (depuis `schemas/errors.py`):

```python
class ErrorCode(str, Enum):
    # ...
    INVALID_WEBHOOK_URL = "INVALID_WEBHOOK_URL"
    # ...

ERROR_EXAMPLES = {
    # ...
    "INVALID_WEBHOOK_URL": {
        "summary": "URL webhook invalide",
        "value": {
            "error": "INVALID_WEBHOOK_URL",
            "message": "L'URL du webhook doit être une URL HTTP/HTTPS valide.",
            "details": {
                "field": "webhook_url",
                "hint": "L'URL doit commencer par http:// ou https://"
            }
        }
    }
}
```

### Sécurité - Validation Webhook URL

**Risques de sécurité à considérer:**

1. **SSRF (Server-Side Request Forgery)**: 
   - Ne pas permettre les URLs vers des IPs privées (10.x, 172.16-31.x, 192.168.x)
   - Ne pas permettre localhost (127.0.0.1, ::1)
   - Ne pas permettre les URLs avec credentials intégrés

2. **Validation recommandée**:
```python
import ipaddress
from urllib.parse import urlparse

def validate_webhook_url(url: str) -> tuple[bool, Optional[str]]:
    """
    Validate webhook URL for security.
    
    Returns: (is_valid, error_message)
    """
    try:
        parsed = urlparse(url)
        
        # Check scheme
        if parsed.scheme not in ("http", "https"):
            return False, "L'URL doit utiliser http:// ou https://"
        
        # Check for credentials in URL
        if parsed.username or parsed.password:
            return False, "L'URL ne doit pas contenir de credentials"
        
        # Resolve hostname and check for private IPs
        import socket
        hostname = parsed.hostname
        if hostname:
            # Block localhost
            if hostname.lower() in ("localhost", "127.0.0.1", "::1"):
                return False, "Les URLs localhost ne sont pas autorisées"
            
            # Resolve and check IP
            try:
                ip_str = socket.gethostbyname(hostname)
                ip = ipaddress.ip_address(ip_str)
                if ip.is_private or ip.is_loopback or ip.is_link_local:
                    return False, "Les adresses IP privées ne sont pas autorisées"
            except socket.gaierror:
                pass  # DNS resolution failed, let it through (will fail at webhook time)
        
        return True, None
        
    except Exception as e:
        return False, f"Format d'URL invalide: {str(e)}"
```

### Project Structure Notes

- **Fichier principal**: `routes/translate_routes.py` (modifier validation existante)
- **Validation**: `middleware/validation.py` (ajouter WebhookURLValidator)
- **Erreurs**: `schemas/errors.py` (vérifier INVALID_WEBHOOK_URL)
- **Tests**: `tests/test_webhook_validation.py` (nouveau fichier)

### Références

- [Source: _bmad-output/planning-artifacts/epics.md#Story 3.7]
- [Source: _bmad-output/planning-artifacts/architecture.md#API & Communication Patterns]
- [Source: _bmad-output/planning-artifacts/architecture.md#Error Handling]
- [Source: FR36, FR65, FR66 - Webhook requirements]
- [Source: routes/translate_routes.py - Implémentation actuelle]

## Intelligence des Stories Précédentes (Epic 3)

### Story 3.1 (API Key Generation) - Enseignements

1. **Routeur api_key_routes.py** utilise `prefix="/api/v1/api-keys"` ✅
2. **Pattern de réponse** `{data: {...}, meta: {...}}` établi
3. **Tests complets** avec fixtures dans `tests/conftest.py`

### Story 3.2 (API Key Revocation User) - Enseignements

1. **Soft delete** avec `is_active=False`
2. **Fonction `get_user_by_api_key`** dans `services/auth_service.py`
3. **Format d'erreur structuré** `{error, message, details?}`

### Story 3.3 (Admin API Key Revocation) - Enseignements

1. **Routes admin dans main.py** - à migrer vers `routes/admin_routes.py`
2. **Dépendance `require_admin`** pour l'authentification admin
3. **Audit logging** avec `logger.info()`

### Story 3.4 (API Auth X-API-Key) - Enseignements

1. **Coexistence JWT + API Key** dans `get_authenticated_user`
2. **Middleware d'auth** dans `routes/translate_routes.py`
3. **Priorité API key sur JWT** si les deux présents

### Story 3.5 (API Versioning) - Enseignements

1. **Tous les endpoints** sont maintenant sous `/api/v1/` ✅
2. **Exceptions documentées**: `/health`, `/ready`, `/docs`, `/redoc`
3. **Routeur principal** `routes/api_v1_router.py`

### Story 3.6 (Documentation OpenAPI) - Enseignements

1. **Schémas Pydantic** dans `schemas/` pour documentation ✅
2. **Codes d'erreur** documentés dans `schemas/errors.py` avec `ERROR_EXAMPLES` ✅
3. **`INVALID_WEBHOOK_URL`** existe déjà dans `ErrorCode` enum ✅
4. **Descriptions complètes** dans les docstrings des endpoints

## Contexte Métier

### Epic 3: API & Automation (Pro)

Cette story est la **septième de l'Epic 3** qui permet aux utilisateurs Pro d'automatiser les traductions:

1. ~~API Keys - Génération~~ (Story 3.1 ✅)
2. ~~API Keys - Révocation User~~ (Story 3.2 ✅)
3. ~~API Keys - Révocation Admin~~ (Story 3.3 ✅)
4. ~~Authentification X-API-Key~~ (Story 3.4 ✅)
5. ~~API Versioning~~ (Story 3.5 ✅)
6. ~~Documentation OpenAPI~~ (Story 3.6 ✅)
7. **Webhook - Spécification URL** (cette story)
8. Webhook - Envoi POST Fire & Forget (Story 3.8 - backlog)
9. Glossaires (Stories 3.9-3.10 - backlog)
10. Custom Prompts (Stories 3.11-3.12 - backlog)

### Valeur Business

Le paramètre webhook_url est critique pour:
- **Automation**: Thomas (Pro user) peut enchaîner les traductions dans ses workflows n8n
- **Intégration CI/CD**: Notification automatique quand une traduction termine
- **Architecture événementielle**: Base pour les futures fonctionnalités temps réel
- **Expérience Pro**: Justification de l'abonnement Pro

### Dépendances

- **Stories 3.1-3.6** (prérequis): API versionnée, authentifiée, documentée ✅
- **Story 3.8** (suivante): Envoi du webhook - utilisera le webhook_url stocké
- **Stories futures**: Glossaires et Custom Prompts pourront aussi utiliser les webhooks

## Guardrails Développeur

### ❌ À NE PAS FAIRE

1. **NE PAS** utiliser le code d'erreur `INVALID_FORMAT` pour les webhooks - utiliser `INVALID_WEBHOOK_URL`
2. **NE PAS** permettre les URLs vers des IPs privées (risque SSRF)
3. **NE PAS** permettre les URLs avec credentials intégrés (`http://user:pass@...`)
4. **NE PAS** bloquer la traduction si le webhook échoue (Fire & Forget)
5. **NE PAS** stocker le webhook_url en base de données - seulement en mémoire avec le job
6. **NE PAS** oublier de documenter le paramètre dans OpenAPI

### ✅ À FAIRE

1. **TOUJOURS** valider le format URL (http/https, domaine valide)
2. **TOUJOURS** utiliser le code d'erreur `INVALID_WEBHOOK_URL` pour les erreurs de validation
3. **TOUJOURS** rendre le paramètre optionnel (la traduction fonctionne sans webhook)
4. **TOUJOURS** stocker le webhook_url avec le job pour la Story 3.8
5. **CRÉER** une fonction de validation dédiée dans `middleware/validation.py`
6. **AJOUTER** des tests unitaires pour la validation webhook
7. **DOCUMENTER** le paramètre avec description et exemple dans OpenAPI

## Code Suggéré

### Fichier `middleware/validation.py` - Ajouter WebhookURLValidator

```python
import re
import ipaddress
import socket
from urllib.parse import urlparse
from typing import Optional, Tuple


class WebhookURLValidator:
    """
    Validator for webhook URLs with security checks.
    
    Prevents SSRF attacks by blocking private IPs and localhost.
    """
    
    # Allowed URL schemes
    ALLOWED_SCHEMES = ("http", "https")
    
    # Blocked hostnames
    BLOCKED_HOSTNAMES = {"localhost", "127.0.0.1", "::1", "0.0.0.0"}
    
    def __init__(
        self,
        allowed_schemes: Tuple[str, ...] = ALLOWED_SCHEMES,
        block_private_ips: bool = True
    ):
        self.allowed_schemes = allowed_schemes
        self.block_private_ips = block_private_ips
    
    def validate(self, url: str) -> Tuple[bool, Optional[str], Optional[dict]]:
        """
        Validate webhook URL format and security.
        
        Args:
            url: The webhook URL to validate
            
        Returns:
            Tuple of (is_valid, error_message, details)
        """
        if not url:
            return True, None, None
        
        try:
            parsed = urlparse(url)
            
            # Check scheme
            if parsed.scheme.lower() not in self.allowed_schemes:
                return False, (
                    f"L'URL doit utiliser {' ou '.join(self.allowed_schemes)}",
                    {
                        "field": "webhook_url",
                        "allowed_schemes": list(self.allowed_schemes),
                        "detected_scheme": parsed.scheme or "none"
                    }
                )
            
            # Check for credentials in URL
            if parsed.username or parsed.password:
                return False, (
                    "L'URL ne doit pas contenir de credentials",
                    {"field": "webhook_url", "reason": "credentials_in_url"}
                )
            
            # Check hostname
            hostname = parsed.hostname
            if not hostname:
                return False, (
                    "URL invalide: hostname manquant",
                    {"field": "webhook_url", "reason": "missing_hostname"}
                )
            
            # Block localhost and common local addresses
            if hostname.lower() in self.BLOCKED_HOSTNAMES:
                return False, (
                    "Les URLs localhost ne sont pas autorisées",
                    {"field": "webhook_url", "reason": "localhost_blocked"}
                )
            
            # Check for private IPs (SSRF protection)
            if self.block_private_ips:
                try:
                    # Try to parse as IP directly
                    try:
                        ip = ipaddress.ip_address(hostname)
                        if self._is_blocked_ip(ip):
                            return False, (
                                "Les adresses IP privées ne sont pas autorisées",
                                {"field": "webhook_url", "reason": "private_ip_blocked"}
                            )
                    except ValueError:
                        # Not an IP, try DNS resolution
                        ip_str = socket.gethostbyname(hostname)
                        ip = ipaddress.ip_address(ip_str)
                        if self._is_blocked_ip(ip):
                            return False, (
                                "Les adresses IP privées ne sont pas autorisées",
                                {"field": "webhook_url", "reason": "private_ip_blocked"}
                            )
                except socket.gaierror:
                    # DNS resolution failed - let it through
                    # Will fail at webhook send time
                    pass
                except Exception:
                    pass
            
            return True, None, None
            
        except Exception as e:
            return False, (
                f"Format d'URL invalide: {str(e)}",
                {"field": "webhook_url", "error": str(e)}
            )
    
    def _is_blocked_ip(self, ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
        """Check if IP is private, loopback, or link-local."""
        return (
            ip.is_private or
            ip.is_loopback or
            ip.is_link_local or
            ip.is_reserved or
            ip.is_multicast
        )


# Singleton instance
webhook_validator = WebhookURLValidator()
```

### Modification de `routes/translate_routes.py`

```python
# Ajouter l'import
from middleware.validation import webhook_validator

# Remplacer la validation existante (ligne ~400)
# AVANT:
if webhook_url:
    if not re.match(r"^https?://", webhook_url, re.IGNORECASE):
        raise TranslateEndpointError(
            code="INVALID_FORMAT",
            message="URL webhook invalide. Doit commencer par http:// ou https://",
            details={"field": "webhook_url"},
        )

# APRÈS:
if webhook_url:
    is_valid, error_msg, error_details = webhook_validator.validate(webhook_url)
    if not is_valid:
        raise TranslateEndpointError(
            code="INVALID_WEBHOOK_URL",
            message=error_msg,
            details=error_details,
        )
```

### Ajout dans `schemas/errors.py` - ERROR_EXAMPLES

```python
ERROR_EXAMPLES = {
    # ... existing examples ...
    
    "INVALID_WEBHOOK_URL": {
        "summary": "URL webhook invalide",
        "value": {
            "error": "INVALID_WEBHOOK_URL",
            "message": "L'URL du webhook doit être une URL HTTP/HTTPS valide.",
            "details": {
                "field": "webhook_url",
                "allowed_schemes": ["http", "https"],
                "hint": "L'URL doit commencer par http:// ou https://"
            }
        }
    },
    "WEBHOOK_PRIVATE_IP": {
        "summary": "IP privée non autorisée",
        "value": {
            "error": "INVALID_WEBHOOK_URL",
            "message": "Les adresses IP privées ne sont pas autorisées.",
            "details": {
                "field": "webhook_url",
                "reason": "private_ip_blocked"
            }
        }
    },
    "WEBHOOK_LOCALHOST": {
        "summary": "Localhost non autorisé",
        "value": {
            "error": "INVALID_WEBHOOK_URL",
            "message": "Les URLs localhost ne sont pas autorisées.",
            "details": {
                "field": "webhook_url",
                "reason": "localhost_blocked"
            }
        }
    }
}
```

### Fichier de tests `tests/test_webhook_validation.py`

```python
"""
Tests for webhook URL validation.
Story 3.7: Webhook - Spécification URL
"""

import pytest
from middleware.validation import webhook_validator, WebhookURLValidator


class TestWebhookURLValidator:
    """Tests for WebhookURLValidator class."""
    
    def test_valid_https_url(self):
        """Valid HTTPS URL should pass."""
        url = "https://example.com/webhook"
        is_valid, error, details = webhook_validator.validate(url)
        assert is_valid is True
        assert error is None
        assert details is None
    
    def test_valid_http_url(self):
        """Valid HTTP URL should pass."""
        url = "http://example.com/webhook"
        is_valid, error, details = webhook_validator.validate(url)
        assert is_valid is True
        assert error is None
    
    def test_invalid_scheme_ftp(self):
        """FTP URL should be rejected."""
        url = "ftp://example.com/webhook"
        is_valid, error, details = webhook_validator.validate(url)
        assert is_valid is False
        assert "http" in error.lower()
    
    def test_invalid_scheme_no_scheme(self):
        """URL without scheme should be rejected."""
        url = "example.com/webhook"
        is_valid, error, details = webhook_validator.validate(url)
        assert is_valid is False
    
    def test_localhost_blocked(self):
        """Localhost should be blocked."""
        urls = [
            "http://localhost/webhook",
            "http://127.0.0.1/webhook",
            "http://[::1]/webhook",
            "http://0.0.0.0/webhook",
        ]
        for url in urls:
            is_valid, error, details = webhook_validator.validate(url)
            assert is_valid is False, f"URL {url} should be blocked"
            assert "localhost" in error.lower() or "priv" in error.lower()
    
    def test_credentials_in_url_blocked(self):
        """URLs with credentials should be blocked."""
        url = "https://user:password@example.com/webhook"
        is_valid, error, details = webhook_validator.validate(url)
        assert is_valid is False
        assert "credentials" in error.lower()
    
    def test_empty_url_valid(self):
        """Empty URL should be valid (optional parameter)."""
        is_valid, error, details = webhook_validator.validate("")
        assert is_valid is True
    
    def test_none_url_valid(self):
        """None URL should be valid (optional parameter)."""
        is_valid, error, details = webhook_validator.validate(None)
        # Note: The validator should handle None gracefully
        # Implementation may vary
    
    def test_url_with_port(self):
        """URL with port should be valid."""
        url = "https://example.com:8080/webhook"
        is_valid, error, details = webhook_validator.validate(url)
        assert is_valid is True
    
    def test_url_with_query_params(self):
        """URL with query parameters should be valid."""
        url = "https://example.com/webhook?token=abc123&source=api"
        is_valid, error, details = webhook_validator.validate(url)
        assert is_valid is True
    
    def test_url_with_path(self):
        """URL with path should be valid."""
        url = "https://example.com/api/v1/notifications/translation-complete"
        is_valid, error, details = webhook_validator.validate(url)
        assert is_valid is True


class TestWebhookURLIntegration:
    """Integration tests for webhook URL in translate endpoint."""
    
    @pytest.mark.asyncio
    async def test_translate_with_valid_webhook(self, client, auth_headers, sample_file):
        """Translation with valid webhook_url should succeed."""
        # Implementation
        pass
    
    @pytest.mark.asyncio
    async def test_translate_with_invalid_webhook(self, client, auth_headers, sample_file):
        """Translation with invalid webhook_url should return 400."""
        # Implementation
        pass
    
    @pytest.mark.asyncio
    async def test_translate_without_webhook(self, client, auth_headers, sample_file):
        """Translation without webhook_url should succeed."""
        # Implementation
        pass
```

## Dev Agent Record

### Agent Model Used

{{agent_model_name_version}}

### Debug Log References

(À remplir lors de l'implémentation)

### Completion Notes List

- ✅ Analyse exhaustive du contexte terminée
- ✅ Code existant identifié dans `routes/translate_routes.py`
- ✅ Validation basique existante à améliorer
- ✅ Code d'erreur `INVALID_WEBHOOK_URL` déjà présent dans `schemas/errors.py`
- ✅ Tests à créer dans `tests/test_webhook_validation.py`

### File List

- `middleware/validation.py` - À MODIFIER - Ajouter WebhookURLValidator
- `routes/translate_routes.py` - À MODIFIER - Utiliser nouvelle validation
- `schemas/errors.py` - À VÉRIFIER - ERROR_EXAMPLES pour INVALID_WEBHOOK_URL
- `tests/test_webhook_validation.py` - À CRÉER - Tests unitaires validation webhook

## Change Log

- 2026-02-22: Story créée avec contexte complet et analyse exhaustive

## Checklist de Validation

Avant de marquer cette story comme terminée, vérifier:

- [ ] Le paramètre `webhook_url` est accepté dans POST `/api/v1/translate`
- [ ] La validation utilise le code d'erreur `INVALID_WEBHOOK_URL` (pas `INVALID_FORMAT`)
- [ ] Les URLs localhost et IPs privées sont bloquées (sécurité SSRF)
- [ ] Les URLs avec credentials sont bloquées
- [ ] Le paramètre est optionnel (la traduction fonctionne sans)
- [ ] Le `webhook_url` est stocké avec le job pour la Story 3.8
- [ ] Le paramètre est documenté dans OpenAPI
- [ ] Les tests unitaires passent
- [ ] Les tests d'intégration passent