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>
665 lines
24 KiB
Markdown
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 |