Files
office_translator/_bmad-output/implementation-artifacts/3-7-webhook-specification-url.md
Sepehr Ramezani 26bd096a06 feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure:
- Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud)
- Admin panel: user management, pricing, settings
- Glossary system with CSV import/export
- Subscription and tier quota management
- Security hardening (rate limiting, API key auth, path traversal fixes)
- Docker compose for dev, prod, and IONOS deployment
- Alembic migrations for new tables
- Frontend: dashboard, pricing page, landing page, i18n (en/fr)
- Test suite and verification scripts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-25 15:01:47 +02:00

665 lines
24 KiB
Markdown

# 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