feat: complete security quality and activation milestones

This commit is contained in:
Gautier Stefanini 2026-08-10 15:36:48 +00:00
parent 5e271a3e10
commit 2f3ec2cc2e
95 changed files with 4109 additions and 459 deletions

21
.dockerignore Normal file
View file

@ -0,0 +1,21 @@
.env
.env.*
!.env.example
.git
.gitignore
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
node_modules/
frontend/node_modules/
frontend/dist/
frontend/.vite/
dist/
build/
.claude/
.codex/
.agents/

39
.github/workflows/quality.yml vendored Normal file
View file

@ -0,0 +1,39 @@
name: quality
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Tests backend et migrations PostgreSQL
run: docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from tests
- name: Nettoyage environnement de test
if: always()
run: docker compose -p kankwa-test -f docker-compose.test.yml down -v
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Tests Vitest
run: >-
docker run --rm
--mount type=bind,src=$GITHUB_WORKSPACE/frontend,target=/workspace
--workdir /workspace node:20-alpine
sh -c "npm ci && npm run test"
- name: E2E inscription, partage et invité
run: >-
docker run --rm --network host
--mount type=bind,src=$GITHUB_WORKSPACE/frontend,target=/workspace
--workdir /workspace mcr.microsoft.com/playwright:v1.62.1-noble
sh -c "npm ci && npm run test:e2e"
- name: TypeScript, i18n, build et pré-rendu
run: docker build --tag kankwa-frontend-ci ./frontend

27
AGENTS.md Normal file
View file

@ -0,0 +1,27 @@
# Kankwa — instructions partagées Codex / Claude Code
Ce fichier est le point d'entrée de Codex. Claude Code utilise `CLAUDE.md`.
Les deux agents doivent appliquer les mêmes sources de vérité :
1. lire `CLAUDE.md` pour le contexte technique et les invariants du projet ;
2. consulter par défaut `/home/miaw/.claude/projects/-home-miaw-kankwa/memory/MEMORY.md`, puis uniquement les mémoires thématiques utiles au chantier ;
3. lire `docs/ROADMAP.md` avant de commencer un chantier lié à l'audit ;
4. consulter `docs/audits/2026-08-code-produit.md` pour le diagnostic et les preuves ;
5. suivre `docs/AI_COLLABORATION.md` pour les passations et mises à jour de statut.
## Règle de suivi
- Le code et les migrations font foi sur le comportement réel.
- La mémoire `.claude` apporte le contexte historique ; en cas de divergence, le code et les documents versionnés les plus récents prévalent.
- `docs/ROADMAP.md` est la source unique pour l'avancement de l'audit.
- Ne jamais marquer un item `DONE` sans satisfaire ses critères d'acceptation et
inscrire une preuve vérifiable dans la roadmap (tests, commit, PR ou fichiers).
- Mettre à jour la documentation dans la même modification que le code concerné.
- Ne jamais copier de secret ou le contenu de `.env` dans la documentation, les
prompts, les logs ou les commits.
## Priorité actuelle
Tant que le jalon `G0 — sécurité critique` de `docs/ROADMAP.md` n'est pas validé,
les correctifs P0 priment sur la refonte marketing, le SEO et les nouvelles
fonctionnalités.

View file

@ -1,5 +1,22 @@
# Kankwa — Contexte projet
## Travail croisé Codex / Claude Code
Les deux agents utilisent les mêmes sources de vérité :
- `AGENTS.md` — point d'entrée Codex et règles communes ;
- `docs/audits/2026-08-code-produit.md` — audit daté et preuves ;
- `docs/ROADMAP.md` — source unique des priorités et de l'avancement ;
- `docs/AI_COLLABORATION.md` — protocole de prise en charge et de passation.
Avant tout chantier issu de l'audit, lire la roadmap et passer l'item concerné à
`IN_PROGRESS`. Ne le marquer `DONE` qu'avec ses critères d'acceptation satisfaits
et des preuves renseignées. Mettre à jour la roadmap et la documentation technique
dans la même modification que le code.
**Priorité active :** le jalon G0 sécurité critique précède la refonte marketing,
le SEO, la growth et toute nouvelle fonctionnalité.
Plateforme de services du quotidien (wishlist, sondages, Secret Santa, partage de frais...).
Alternatives éthiques aux outils grand public, hébergées sur serveur privé, RGPD-friendly.
Modèle : abonnement Premium + freemium (3 services actifs max).
@ -81,6 +98,13 @@ kankwa/
- **Pages privées** : toute page accessible par lien de partage doit passer `noindex: true` à `usePageMeta`, + header `X-Robots-Tag` côté Caddy (cf. `Caddyfile.example`) pour les crawlers sans JS.
- Le sitemap est servi par l'**API** (`/sitemap.xml`), pas en statique — Caddy route ce chemin vers `kankwa-api:8000`.
## Activation produit
- Le CTA public principal est « Créer mon événement » et cible `/hub/new` après authentification. La destination interne `next` doit être conservée dans tous les flux (mot de passe, code email et magic link) et validée comme chemin local pour empêcher les redirections ouvertes.
- Un Hub nouvellement créé ouvre `/hub/:id?onboarding=1` avec une checklist création → premier module → partage. Ne pas retirer laccès direct aux actions de module ni au lien public.
- `/demo` est la démonstration publique bilingue, sans compte ni données artificielles persistées ; la route appartient à `seo-routes.json` et doit rester pré-rendue.
- `GuestConversionCTA` est la source commune du CTA invité→organisateur. Il saffiche uniquement après la réussite de laction invitée et jamais pour un utilisateur connecté.
---
## i18n bilingue FR/EN

View file

@ -1,13 +1,22 @@
tondomaine.com {
encode zstd gzip
# Défense en profondeur : mêmes politiques que lAPI, appliquées aussi au SPA.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), geolocation=(), microphone=()"
Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' https://js.stripe.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://api.stripe.com https://r.stripe.com; frame-src https://js.stripe.com https://hooks.stripe.com; upgrade-insecure-requests"
}
# Le sitemap est généré par l'API (routes + articles), pas servi en statique.
handle /sitemap.xml {
reverse_proxy localhost:8000
reverse_proxy kankwa-api:8000
}
handle /api/* {
reverse_proxy localhost:8000
reverse_proxy kankwa-api:8000
}
# Anciens slugs indexés : 301 réelle plutôt que la redirection React côté client
@ -22,6 +31,6 @@ tondomaine.com {
header @private X-Robots-Tag "noindex, nofollow"
handle {
reverse_proxy localhost:3000
reverse_proxy kankwa-frontend:3001
}
}

View file

@ -1,17 +1,20 @@
FROM python:3.12-slim
FROM python:3.12-slim@sha256:229a2c5bfa27522db7815ea81f9bed70af17ccb9de9fc7ad142b1877b5830d36
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev \
&& rm -rf /var/lib/apt/lists/*
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system app \
&& useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY --chown=app:app . .
ENV PYTHONPATH=/app
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "from urllib.request import urlopen; urlopen('http://127.0.0.1:8000/health', timeout=3)"
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -64,6 +64,18 @@ app.add_middleware(
)
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("Permissions-Policy", "camera=(), geolocation=(), microphone=()")
if request.url.path.startswith("/api/auth/"):
response.headers.setdefault("Cache-Control", "no-store")
return response
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.time()

View file

@ -1,7 +1,7 @@
import logging
import re
from datetime import datetime
from typing import Literal
from urllib.parse import urlencode
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
@ -12,12 +12,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.config import settings
from models.user import User
from shared.auth.dependencies import get_current_user
from shared.auth.premium_check import is_premium_active
from shared.auth.jwt import create_access_token, create_email_change_token, decode_email_change_token
from shared.auth.magic_link import create_magic_token, verify_magic_token
from shared.auth.verification_code import create_verification_code, verify_code
from shared.auth.password import hash_password, verify_password
from shared.auth.password import hash_password, validate_password_strength, verify_password
from shared.database.connection import get_db
from shared.email import sender as email_sender
from shared.payments import stripe_client
from shared.email.sender import safe_send
logger = logging.getLogger(__name__)
@ -34,20 +36,7 @@ class RegisterRequest(BaseModel):
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
errors = []
if len(v) < 8:
errors.append("8 caractères minimum")
if not re.search(r'[A-Z]', v):
errors.append("une majuscule")
if not re.search(r'[a-z]', v):
errors.append("une minuscule")
if not re.search(r'\d', v):
errors.append("un chiffre")
if not re.search(r'[^A-Za-z0-9]', v):
errors.append("un caractère spécial")
if errors:
raise ValueError("Le mot de passe doit contenir : " + ", ".join(errors))
return v
return validate_password_strength(v)
class LoginRequest(BaseModel):
@ -57,6 +46,14 @@ class LoginRequest(BaseModel):
class MagicLinkRequest(BaseModel):
email: EmailStr
next: str = Field(default="/hub/new", max_length=500)
@field_validator("next")
@classmethod
def safe_next_path(cls, value: str) -> str:
if not value.startswith("/") or value.startswith("//"):
raise ValueError("next must be an internal path")
return value
class VerifyEmailRequest(BaseModel):
@ -94,6 +91,10 @@ class TokenResponse(BaseModel):
user: UserResponse
def _user_response(user: User) -> UserResponse:
return UserResponse.model_validate(user).model_copy(update={"is_premium": is_premium_active(user)})
class MessageResponse(BaseModel):
message: str
@ -108,9 +109,7 @@ class SetPasswordRequest(BaseModel):
@field_validator("password")
@classmethod
def password_min_length(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Le mot de passe doit faire au moins 8 caractères")
return v
return validate_password_strength(v)
# ── Routes ────────────────────────────────────────────────────────────────────
@ -121,10 +120,11 @@ async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
user = result.scalar_one_or_none()
if user:
if user.password_hash and user.email_verified:
# Une adresse vérifiée peut provenir dun magic link ou dune invitation :
# ne jamais permettre à une simple réinscription de prendre le compte.
if user.email_verified:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="email_already_used")
user.password_hash = hash_password(body.password)
await db.commit()
# Le mot de passe initial nest pas réécrit lors dune relance dinscription.
else:
user = User(email=body.email, password_hash=hash_password(body.password), lang=body.lang)
db.add(user)
@ -146,14 +146,14 @@ async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
code = await create_verification_code(user.email, db)
await safe_send(email_sender.send_email_verification(user.email, code, lang=user.lang), logger)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="email_not_verified")
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
return TokenResponse(access_token=create_access_token(user.id), user=_user_response(user))
@router.post("/magic-link", response_model=MessageResponse)
async def request_magic_link(body: MagicLinkRequest, db: AsyncSession = Depends(get_db)):
existing = (await db.execute(select(User).where(User.email == body.email))).scalar_one_or_none()
token = await create_magic_token(body.email, db)
magic_url = f"{settings.frontend_url}/magic?token={token}"
magic_url = f"{settings.frontend_url}/magic?{urlencode({'token': token, 'next': body.next})}"
if settings.environment != "production":
logger.info("DEV — magic link pour %s : %s", body.email, magic_url)
await safe_send(email_sender.send_magic_link(body.email, magic_url, lang=existing.lang if existing else "fr"), logger)
@ -183,7 +183,7 @@ async def verify_magic_link(token: str, lang: Literal["fr", "en"] = "fr", db: As
set_password_url = f"{settings.frontend_url}/magic?token={set_password_token}&next=/set-password"
await safe_send(email_sender.send_welcome_email(email, set_password_url, lang=user.lang), logger)
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
return TokenResponse(access_token=create_access_token(user.id), user=_user_response(user))
@router.post("/verify-email", response_model=TokenResponse)
@ -193,13 +193,14 @@ async def verify_email(body: VerifyEmailRequest, db: AsyncSession = Depends(get_
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="account_not_found")
if user.email_verified:
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
# Un code est à usage unique : un compte déjà vérifié ne reçoit jamais de JWT ici.
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="email_already_verified")
ok = await verify_code(user.email, body.code, db)
if not ok:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_or_expired_code")
user.email_verified = True
await db.commit()
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
return TokenResponse(access_token=create_access_token(user.id), user=_user_response(user))
@router.post("/resend-verification", response_model=MessageResponse)
@ -233,7 +234,7 @@ async def set_password(
@router.get("/me", response_model=UserResponse)
async def me(current_user: User = Depends(get_current_user)):
return UserResponse.model_validate(current_user)
return _user_response(current_user)
@router.patch("/lang", response_model=MessageResponse)
@ -281,7 +282,7 @@ async def me_usage(current_user: User = Depends(get_current_user), db: AsyncSess
"services": service_data,
"total": total,
"max": settings.max_free_active_services,
"is_premium": current_user.is_premium,
"is_premium": is_premium_active(current_user),
}
@ -358,7 +359,7 @@ async def confirm_email_change(token: str, db: AsyncSession = Depends(get_db)):
user.email = new_email
await db.commit()
await db.refresh(user)
return TokenResponse(access_token=create_access_token(user.id), user=UserResponse.model_validate(user))
return TokenResponse(access_token=create_access_token(user.id), user=_user_response(user))
class DeleteAccountRequest(BaseModel):
@ -373,6 +374,20 @@ async def delete_account(
):
if body.confirmation not in ("SUPPRIMER", "DELETE"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="confirmation_incorrect")
try:
# Stripe est annulé avant leffacement local afin de ne jamais perdre la
# possibilité de stopper une facturation active. La fonction est idempotente.
await stripe_client.cancel_active_subscriptions(
customer_id=current_user.stripe_customer_id,
known_subscription_id=current_user.stripe_subscription_id,
)
except Exception:
logger.exception("Annulation Stripe échouée avant suppression user=%s", current_user.id)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="subscription_cancellation_failed",
)
await db.delete(current_user)
await db.commit()
return MessageResponse(message="Compte supprimé")
return MessageResponse(message="Compte et abonnement supprimés")

View file

@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.user import User
from shared.auth.dependencies import get_current_user
from shared.auth.premium_check import is_premium_active
from shared.database.connection import get_db
from shared.email import sender as email_sender
from shared.email.sender import safe_send
@ -56,7 +57,7 @@ async def create_checkout(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if current_user.is_premium:
if is_premium_active(current_user):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Abonnement déjà actif")
price_id = stripe_client.PRICE_BY_PLAN.get(body.plan)
if not price_id:

View file

@ -1,3 +1,4 @@
import asyncio
import html as html_lib
import ipaddress
import json
@ -12,7 +13,7 @@ import httpx
from bs4 import BeautifulSoup, Tag
from fastapi import APIRouter, Depends, HTTPException, Query, status
from price_parser import Price
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -112,8 +113,8 @@ class ScrapeResult(BaseModel):
class ParseRequest(BaseModel):
url: str
html: str
url: str = Field(min_length=1, max_length=2048)
html: str = Field(min_length=1, max_length=2_000_000)
# ── Price parsing (price-parser) ───────────────────────────────────────────────
@ -597,51 +598,86 @@ async def _enrich_with_affiliates(url: str, result: ScrapeResult) -> ScrapeResul
# ── HTML fetcher (FlareSolverr + fallback httpx) ──────────────────────────────
_FLARESOLVERR_URL = "http://kankwa-scraper:8191/v1"
_MAX_HTML_BYTES = 2_000_000
_MAX_REDIRECTS = 3
_scrape_semaphore = asyncio.Semaphore(4)
def _ensure_payload_size(payload: str) -> str | None:
if len(payload.encode("utf-8")) > _MAX_HTML_BYTES:
logger.warning("Réponse scraper refusée : payload trop volumineux")
return None
return payload
async def _read_limited_response(response: httpx.Response) -> str | None:
declared_size = response.headers.get("content-length")
if declared_size and declared_size.isdigit() and int(declared_size) > _MAX_HTML_BYTES:
logger.warning("Réponse httpx refusée : Content-Length trop volumineux")
return None
payload = bytearray()
async for chunk in response.aiter_bytes():
payload.extend(chunk)
if len(payload) > _MAX_HTML_BYTES:
logger.warning("Réponse httpx refusée : payload trop volumineux")
return None
return payload.decode(response.encoding or "utf-8", errors="replace")
async def _fetch_html(url: str) -> str | None:
"""
Fetch via FlareSolverr (JS rendering + bypass Cloudflare).
Fallback httpx si FlareSolverr est indisponible.
"""
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
_FLARESOLVERR_URL,
json={"cmd": "request.get", "url": url, "maxTimeout": 45000},
)
resp.raise_for_status()
data = resp.json()
if data.get("status") == "ok":
html = data["solution"]["response"]
logger.info("FlareSolverr OK %s (%d bytes)", url, len(html))
return html
logger.warning("FlareSolverr status=%s %s", data.get("status"), url)
except Exception as e:
logger.warning("FlareSolverr unavailable (%s), fallback httpx: %s", url, e)
# Fallback httpx — pour les sites sans anti-bot ou si FlareSolverr est down.
# Redirections suivies manuellement pour revalider chaque cible (anti-SSRF par redirect).
try:
async with httpx.AsyncClient(follow_redirects=False, timeout=15) as client:
current = url
for _ in range(5):
resp = await client.get(current, headers=HEADERS)
if resp.is_redirect:
location = resp.headers.get("location")
if not location:
break
current = str(resp.url.join(location))
_validate_url(current) # lève si la redirection pointe vers l'interne
continue
"""Récupère du HTML sous limites de temps, taille et concurrence."""
async with _scrape_semaphore:
try:
timeout = httpx.Timeout(35.0, connect=5.0)
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
_FLARESOLVERR_URL,
json={"cmd": "request.get", "url": url, "maxTimeout": 30_000},
)
resp.raise_for_status()
logger.info("httpx fallback OK %s", url)
return resp.text
logger.warning("httpx fallback : trop de redirections %s", url)
return None
except Exception as e:
logger.warning("httpx fallback failed %s: %s", url, e)
return None
data = resp.json()
solution = data.get("solution") if isinstance(data, dict) else None
if data.get("status") == "ok" and isinstance(solution, dict):
final_url = solution.get("url") or solution.get("responseUrl")
if final_url:
try:
_validate_url(str(final_url))
except HTTPException:
logger.warning("FlareSolverr final URL refused for %s", url)
return None
html = solution.get("response")
if isinstance(html, str):
bounded_html = _ensure_payload_size(html)
if bounded_html is not None:
logger.info("FlareSolverr OK %s (%d bytes)", url, len(bounded_html.encode("utf-8")))
return bounded_html
logger.warning("FlareSolverr response invalid for %s", url)
except Exception as exc:
logger.warning("FlareSolverr unavailable (%s), fallback httpx: %s", url, exc)
# Redirections manuelles : chaque URL est résolue et validée contre SSRF.
try:
timeout = httpx.Timeout(15.0, connect=5.0)
async with httpx.AsyncClient(follow_redirects=False, timeout=timeout) as client:
current = url
for _ in range(_MAX_REDIRECTS + 1):
async with client.stream("GET", current, headers=HEADERS) as resp:
if resp.is_redirect:
location = resp.headers.get("location")
if not location:
return None
current = str(resp.url.join(location))
_validate_url(current)
continue
resp.raise_for_status()
html = await _read_limited_response(resp)
if html is not None:
logger.info("httpx fallback OK %s", url)
return html
logger.warning("httpx fallback : trop de redirections %s", url)
except Exception as exc:
logger.warning("httpx fallback failed %s: %s", url, exc)
return None
# ── Endpoints ─────────────────────────────────────────────────────────────────
@ -663,7 +699,7 @@ async def get_affiliate_programs():
@router.get("/scrape", response_model=ScrapeResult, dependencies=[Depends(get_current_user)])
async def scrape_url(url: str = Query(...)):
async def scrape_url(url: str = Query(..., min_length=1, max_length=2048)):
_validate_url(url)
html = await _fetch_html(url)
if not html:

36
docker-compose.test.yml Normal file
View file

@ -0,0 +1,36 @@
name: kankwa-test
services:
db-test:
image: postgres:16-alpine
environment:
POSTGRES_USER: kankwa_test
POSTGRES_PASSWORD: kankwa_test_password
POSTGRES_DB: kankwa_test
POSTGRES_HOST_AUTH_METHOD: scram-sha-256
command: postgres -c password_encryption=scram-sha-256
healthcheck:
test: ["CMD-SHELL", "pg_isready -U kankwa_test -d kankwa_test"]
interval: 2s
timeout: 3s
retries: 20
tmpfs:
- /var/lib/postgresql/data
tests:
build: .
environment:
DATABASE_URL: postgresql+asyncpg://kankwa_test:kankwa_test_password@db-test:5432/kankwa_test
SECRET_KEY: test-only-secret-key-not-for-production
ENVIRONMENT: test
FRONTEND_URL: http://testserver
PYTHONDONTWRITEBYTECODE: "1"
HOME: /tmp
PATH: /tmp/.local/bin:$PATH
depends_on:
db-test:
condition: service_healthy
command: >-
sh -c "python -m pip install --no-cache-dir --user -r requirements-dev.txt &&
python -m alembic upgrade head &&
python -m pytest -q -p no:cacheprovider tests"

View file

@ -1,6 +1,6 @@
services:
db:
image: postgres:16-alpine
image: postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229
container_name: kankwa-db
environment:
POSTGRES_USER: ${POSTGRES_USER}
@ -13,6 +13,7 @@ services:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
@ -29,15 +30,25 @@ services:
PYTHONPATH: /app
ports:
- "127.0.0.1:8000:8000"
healthcheck:
test: ["CMD", "python", "-c", "from urllib.request import urlopen; urlopen('http://127.0.0.1:8000/health', timeout=3)"]
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped
networks:
- ai-net
scraper:
image: ghcr.io/flaresolverr/flaresolverr:latest
image: ghcr.io/flaresolverr/flaresolverr@sha256:139dfee1c6f89249c8d665d1333a42e8ec74ec0a86bc6bb1c8461e10d3a66a47
container_name: kankwa-scraper
environment:
LOG_LEVEL: info
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8191/ >/dev/null"]
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped
networks:
- ai-net
@ -47,6 +58,11 @@ services:
container_name: kankwa-frontend
ports:
- "127.0.0.1:3001:3001"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped
networks:
- ai-net

65
docs/AI_COLLABORATION.md Normal file
View file

@ -0,0 +1,65 @@
# Collaboration Codex / Claude Code
Ce document définit le protocole commun pour qu'un chantier commencé avec un
agent puisse être repris sans perte de contexte par l'autre.
## Sources de vérité
| Sujet | Source canonique |
|---|---|
| Comportement applicatif | Code, migrations et tests |
| Architecture et invariants | `CLAUDE.md` |
| Audit daté | `docs/audits/2026-08-code-produit.md` |
| Avancement et priorités | `docs/ROADMAP.md` |
| Mémoire Claude Code | `/home/miaw/.claude/projects/-home-miaw-kankwa/memory/` |
La mémoire Claude Code sert d'index et de contexte court. Elle ne doit pas
dupliquer la roadmap complète : les documents versionnés dans Git restent la
référence commune.
## Début d'un chantier
1. Lire `git status` et préserver les modifications de l'autre agent.
2. Lire l'item de roadmap visé, ses dépendances et ses critères d'acceptation.
3. Passer son statut à `IN_PROGRESS` et renseigner `Agent` et `Début`.
4. Ajouter une courte note dans le journal si une hypothèse ou décision est prise.
5. Ne traiter qu'un périmètre testable ; créer un nouvel item si le besoin dépasse
sensiblement le périmètre initial.
## Fin ou passation
Mettre à jour l'item avec :
- le statut réel : `DONE`, `BLOCKED` ou `TODO` si le travail est abandonné ;
- les fichiers modifiés ;
- les commandes de validation exécutées et leur résultat ;
- les limites de validation de l'environnement ;
- la prochaine action précise si l'item n'est pas terminé.
Une implémentation sans test requis, ou dont le test n'a pas pu être exécuté,
reste `IN_PROGRESS` ou `BLOCKED` selon la cause. Elle n'est pas `DONE`.
## Statuts autorisés
| Statut | Signification |
|---|---|
| `TODO` | Prêt mais non commencé |
| `IN_PROGRESS` | Travail actif, un agent identifié |
| `BLOCKED` | Bloqué avec cause et condition de reprise documentées |
| `DONE` | Critères d'acceptation satisfaits et preuves renseignées |
| `WONT_DO` | Abandon explicite avec décision documentée |
## Décisions
Toute décision qui modifie la sécurité, les données, le contrat API, le SEO,
l'infrastructure ou un invariant transverse doit être inscrite dans le journal de
`docs/ROADMAP.md` et répercutée dans `CLAUDE.md` ou la mémoire thématique adaptée.
## Sécurité
- Ne jamais publier de procédure d'exploitation détaillée dans une interface
utilisateur ou un contenu public.
- Ne jamais lire ou reproduire les valeurs de `.env` si les noms des variables
suffisent.
- Si une image Docker contenant potentiellement `.env` a été distribuée, considérer
les secrets comme compromis et documenter leur rotation sans consigner leur valeur.

286
docs/ROADMAP.md Normal file
View file

@ -0,0 +1,286 @@
# Roadmap de remédiation et de croissance
Dernière mise à jour : 10 août 2026
Source : `docs/audits/2026-08-code-produit.md`
Ce fichier est le tableau de suivi commun à Codex et Claude Code. Mettre à jour les
statuts, preuves et décisions dans la même modification que le code.
## Vue des jalons
| Jalon | Objectif | État | Condition de sortie |
|---|---|---|---|
| G0 | Sécurité critique | `DONE` | Tous les P0 corrigés et testés ; risque externe explicitement accepté |
| G1 | Socle qualité et exploitation | `DONE` | CI verte, tests critiques, sauvegarde restaurable |
| G2 | Activation produit | `DONE` | Parcours CTA → Hub → module → partage validé |
| G3 | Mesure first-party | `TODO` | Funnel et cycle de vie mesurables sans PII inutile |
| G4 | Refonte marketing | `TODO` | Homepage orientée scénario et démonstration réelle |
| G5 | Fondations SEO corrigées | `TODO` | Canonical/hreflang/JSON-LD testés sur FR et EN |
| G6 | Acquisition SEO | `TODO` | Premier cluster de pages utiles indexable |
| G7 | Boucles de croissance | `TODO` | Conversion invité→organisateur mesurée et optimisée |
## G0 — Sécurité critique
### P0-SEC-01 — corriger `/verify-email`
- Statut : `DONE`
- Agent : Codex
- Début : 10 août 2026
- Dépendances : aucune
- Travail : toujours valider le code avant émission du JWT ; réponse sûre pour un
compte déjà vérifié ; invalider les codes consommés.
- Acceptation : tests compte vérifié + code faux, code expiré, code réutilisé et
code valide ; aucun token n'est renvoyé dans les trois premiers cas.
- Preuves : `api/routers/auth.py`, `tests/test_auth_security.py` ; `docker run … kankwa-api:g0-audit … pytest -q -p no:cacheprovider tests` : 18 passed (10 août 2026).
### P0-SEC-02 — protéger les comptes magic-link/passwordless
- Statut : `DONE`
- Agent : Codex
- Début : 10 août 2026
- Dépendances : P0-SEC-01
- Travail : refuser la réinscription de tout compte vérifié ; réserver la définition
initiale du mot de passe à une session authentifiée ; mutualiser sa validation.
- Acceptation : impossible de définir ou remplacer un mot de passe avec seulement
une adresse email ; tests inscription, magic link, invitation et set-password.
- Preuves : `shared/auth/password.py`, `api/routers/auth.py`, `tests/test_auth_security.py` ; `docker run … kankwa-api:g0-audit … pytest -q -p no:cacheprovider tests` : 18 passed (10 août 2026).
### P0-SEC-03 — séparer les schémas publics Kount
- Statut : `DONE`
- Agent : Codex
- Début : 10 août 2026
- Dépendances : aucune
- Travail : créer des schémas publics sans email ni `cancel_token` et réserver les
données de gestion aux réponses owner/co-owner.
- Acceptation : test de contrat sur `GET /groups/shared/{token}` ; recherche JSON
confirmant l'absence d'email et de tout jeton d'annulation ; annulation toujours
possible par le créateur via le token reçu à la création.
- Preuves : `services/kount/{schemas,service,router}.py`, `tests/test_kount_public_contract.py` ; `docker run … kankwa-api:g0-audit … pytest -q -p no:cacheprovider tests` : 18 passed (10 août 2026).
### P0-INFRA-01 — exclure les secrets des images Docker
- Statut : `DONE`
- Agent : Codex
- Début : 10 août 2026
- Dépendances : aucune
- Travail : ajouter `.dockerignore`, réduire les contextes et inspecter les images
existantes sans afficher les secrets.
- Acceptation : `.env`, `.git`, caches et `node_modules` absents du contexte/image ;
décision de rotation consignée si une image potentiellement contaminée a été
distribuée.
- Preuves : `.dockerignore`, `frontend/.dockerignore` ; images `kankwa-api:g0-audit` et `kankwa-frontend:g0-audit` construites et inspectées sans `.env`, `.git`, caches ni `node_modules` ; rotation locale appliquée le 10 août 2026 ; rotations fournisseur non effectuées, risque explicitement accepté : `docs/runbooks/secret-rotation.md`.
- Décision de risque : le propriétaire accepte explicitement le risque résiduel et choisit de ne pas révoquer Stripe, SMTP ni TradeDoubler ; ces rotations ne conditionnent plus la clôture de P0-INFRA-01.
### P0-BILL-01 — suppression de compte et abonnement Stripe
- Statut : `DONE`
- Agent : Codex
- Début : 10 août 2026
- Dépendances : aucune
- Travail : définir puis implémenter la politique d'annulation avant effacement,
avec idempotence et message utilisateur explicite.
- Acceptation : tests webhook/API ; aucun abonnement actif orphelin après suppression ;
politique reflétée dans les CGU et la confidentialité.
- Preuves : `shared/payments/stripe_client.py`, `api/routers/auth.py`, CGU/confidentialité et `tests/test_billing_deletion.py` ; `docker run … kankwa-api:g0-audit … pytest -q -p no:cacheprovider tests` : 18 passed (10 août 2026).
## G1 — Socle qualité et exploitation
| ID | Travail | Statut | Acceptation synthétique | Preuves |
|---|---|---|---|---|
| QLT-01 | Installer Pytest + PostgreSQL de test | `DONE` | Auth, droits et routes publiques testables | `docker-compose.test.yml`, `tests/integration/test_postgres_flows.py` ; migrations Alembic + 19 passed (10 août 2026) |
| QLT-02 | Installer Vitest/Testing Library | `DONE` | Hooks et composants critiques couverts | `vitest.config.ts`, 4 tests Vitest verts (10 août 2026) |
| QLT-03 | Ajouter Playwright | `DONE` | E2E inscription→partage→invité | `frontend/e2e/critical-flow.spec.ts`, Playwright Chromium : 1 passed (10 août 2026) |
| QLT-04 | Ajouter CI | `DONE` | Syntaxe, types, i18n, tests, build et migrations verts | `.github/workflows/quality.yml` ; backend, Vitest, Playwright et build Docker câblés (10 août 2026) |
| SEC-04 | Durcir sessions et headers | `DONE` | CSP/headers ; stratégie HttpOnly documentée et testée | `api/main.py`, `Caddyfile.example`, décision session et tests headers ; 23 Pytest verts (10 août 2026) |
| SEC-05 | Refaire le rate limiting | `DONE` | Clés normalisées, limites globales, stockage borné | `shared/rate_limit/middleware.py`, `tests/test_g1_security.py` ; 23 Pytest verts (10 août 2026) |
| SEC-06 | Isoler et borner le scraper | `DONE` | Redirects/egress/payload/concurrence contrôlés | `api/routers/scrape.py`, `tests/test_g1_security.py` ; 23 Pytest verts (10 août 2026) |
| DATA-01 | Harmoniser validation API/SQL | `DONE` | Entrées invalides → 422, jamais 500 SQL | `shared/validation.py`, schémas des services, `tests/test_input_validation.py` ; 36 Pytest verts (10 août 2026) |
| DATA-02 | Traiter les courses métier | `DONE` | Limite freemium et collectes atomiques | verrous SQL dans `shared/auth/premium_check.py` et `services/kdo/router.py` ; concurrence PostgreSQL testée, 36 Pytest verts (10 août 2026) |
| BILL-02 | Unifier Premium effectif | `DONE` | API et UI utilisent la même fonction détat | `is_premium_active`, réponses auth, checkout et dépendance Premium ; `tests/test_premium_effective.py`, 36 Pytest verts (10 août 2026) |
| OPS-01 | Corriger et durcir Docker/Caddy | `DONE` | Ports cohérents, images figées, non-root, healthchecks | `Dockerfile`, `frontend/Dockerfile`, `docker-compose.yml`, `Caddyfile.example` ; builds `g1`, users 999/100, Compose et Caddy valides (10 août 2026) |
| OPS-02 | Sauvegarde/restauration PostgreSQL | `DONE` | Restauration testée et datée | Runbook + scripts ; restauration PostgreSQL isolée au niveau Alembic `0026` (10 août 2026) |
| FE-01 | Ajouter un ErrorBoundary | `DONE` | Erreur de chunk/rendu récupérable | `ErrorBoundary.tsx`, `App.tsx` ; Vitest, i18n et TypeScript verts (10 août 2026) |
| A11Y-01 | Labels, focus et modales | `DONE` | Parcours clavier et audit axe sans critique | labels inscription/création, `Modal.test.tsx`, axe dans `critical-flow.spec.ts` ; Vitest 4 et Playwright 1 verts (10 août 2026) |
## G2 — Activation produit
| ID | Travail | Statut | Acceptation synthétique | Preuves |
|---|---|---|---|---|
| ACT-01 | CTA « Créer mon événement » | `DONE` | Intention claire sur homepage et vitrines | `Landing.tsx`, `ServiceVitrine.tsx` ; CTA commun vers le Hub (10 août 2026) |
| ACT-02 | Conserver `next` dans l'auth | `DONE` | Retour vers `/hub/new`, jamais vers `/` par défaut | `authRedirect.ts`, auth password/code/magic-link, tests Vitest/Pytest et E2E (10 août 2026) |
| ACT-03 | Onboarding Hub guidé | `DONE` | Hub + premier module + partage sans impasse | `HubNew.tsx`, `HubEdit.tsx` ; Playwright CTA→Hub→Kdo→copie : vert (10 août 2026) |
| ACT-04 | Démo produit publique | `DONE` | Hub et parcours invité compréhensibles sans compte | `/demo` bilingue et SSG ; Playwright vues invité/organisateur ; build 32 pages (10 août 2026) |
| ACT-05 | CTA invité post-action | `DONE` | Proposition après succès, sans interrompre l'action | `GuestConversionCTA.tsx`, intégré après succès Kdo/Kontrib/Kount/Kal/Kwiz ; test Vitest (10 août 2026) |
## G3 — Mesure first-party
| ID | Travail | Statut | Acceptation synthétique | Preuves |
|---|---|---|---|---|
| ANA-01 | ADR analytics respectueux des données | `TODO` | Finalités, rétention et PII documentées | — |
| ANA-02 | Cycle de vie du Hub | `TODO` | draft/active/completed/archived définis et migrés | — |
| ANA-03 | Événements du funnel | `TODO` | 9 événements minimaux reçus et dédupliqués | — |
| ANA-04 | Dashboard activation | `TODO` | Taux étape par étape et segmentation source/device | — |
| ANA-05 | Métrique virale | `TODO` | invités/organisateur et conversion invité→organisateur | — |
## G4 — Refonte marketing
| ID | Travail | Statut | Acceptation synthétique | Preuves |
|---|---|---|---|---|
| MKT-01 | Hero centré sur « un seul lien » | `TODO` | USP et absence d'installation visibles sans scroll | — |
| MKT-02 | Scénario problème→solution | `TODO` | Modules secondaires par rapport au Hub | — |
| MKT-03 | Screenshots/vidéo courte | `TODO` | Desktop + mobile + vue invité, accessibles et optimisés | — |
| MKT-04 | Confiance factuelle | `TODO` | Hébergement, données et suivi précisément prouvés | — |
| MKT-05 | Preuve sociale réelle | `TODO` | Aucun chiffre ou témoignage non vérifiable | — |
## G5 — Fondations SEO
| ID | Travail | Statut | Acceptation synthétique | Preuves |
|---|---|---|---|---|
| SEO-01 | Corriger URL JSON-LD des articles | `TODO` | `mainEntityOfPage` égale l'URL canonique réelle | — |
| SEO-02 | Mapper les slugs FR/EN | `TODO` | hreflang et sélecteur pointent vers la traduction réelle | — |
| SEO-03 | Tests du pré-rendu | `TODO` | h1, canonical, lang, alternates, OG, JSON-LD validés | — |
| SEO-04 | Métadonnées des vitrines | `TODO` | bénéfice/requête avant nom K | — |
| PERF-01 | Séparer registre metadata/composants | `TODO` | bundle initial mesuré et réduit | — |
## G6 — Acquisition SEO
Créer un template de landing page bilingue, piloté par configuration et inscrit
dans la source unique du pré-rendu/sitemap. Premier lot recommandé :
| ID | Page | Statut |
|---|---|---|
| SEO-LP-01 | `/organiser/anniversaire` | `TODO` |
| SEO-LP-02 | `/organiser/week-end-entre-amis` | `TODO` |
| SEO-LP-03 | `/organiser/repas` | `TODO` |
| SEO-LP-04 | `/organiser/voyage` | `TODO` |
| SEO-LP-05 | `/outils/sondage-date` | `TODO` |
| SEO-LP-06 | `/outils/liste-cadeaux` | `TODO` |
| SEO-LP-07 | `/outils/qui-apporte-quoi` | `TODO` |
| SEO-LP-08 | `/outils/partage-depenses` | `TODO` |
| SEO-LP-09 | Comparatif Doodle | `TODO` |
| SEO-LP-10 | Comparatif Splitwise/Tricount | `TODO` |
Critères communs : intention distincte, contenu non dupliqué, démonstration produit,
CTA contextualisé, maillage, FR/EN, SSG, sitemap, canonical et données structurées.
## G7 — Boucles de croissance
| ID | Travail | Statut | Acceptation synthétique | Preuves |
|---|---|---|---|---|
| GRW-01 | Conversion invité→organisateur | `TODO` | Mesurée avant optimisation | — |
| GRW-02 | Templates par cas d'usage | `TODO` | Hub préconfiguré sans contenu artificiel | — |
| GRW-03 | Dupliquer un événement | `TODO` | Nouveau Hub sans données personnelles invitées | — |
| GRW-04 | Relance post-événement | `TODO` | Consentement, fréquence et désinscription conformes | — |
| GRW-05 | Expériences A/B | `TODO` | Hypothèse, métrique, puissance et décision consignées | — |
## Indicateurs de pilotage
- Sécurité : zéro vulnérabilité critique connue ; temps de correction P0.
- Fiabilité : taux de CI, erreurs 5xx, restauration vérifiée.
- Activation : visite→CTA, inscription→Hub, Hub→module, module→partage,
partage→premier invité.
- Viralité : invités actifs par organisateur et taux invité→organisateur.
- Rétention : deuxième événement créé à 30/90 jours.
- SEO : pages valides/indexées, impressions non-brand, CTR et création de Hub par
landing page.
- Performance : LCP < 2,5 s, CLS < 0,1 et absence de régression du bundle initial.
## Journal des décisions et passations
### 2026-08-10 — création du suivi commun
- Décision : G0 sécurité devient un préalable à l'acquisition.
- Décision : les documents versionnés dans `docs/` sont canoniques ; la mémoire
`/home/miaw/.claude` ne contient qu'un index et du contexte court.
- Audit initial réalisé par Codex ; aucune remédiation de code incluse.
- Validation disponible : analyse syntaxique Python réussie ; build frontend non
exécuté car `npm` absent.
### 2026-08-10 — G0 lancé (Codex)
- `P0-SEC-01` : `/verify-email` ne renvoie plus de JWT pour un compte déjà vérifié ; les codes erronés, expirés et consommés restent rejetés.
- `P0-SEC-02` : la réinscription ne modifie plus un compte existant et vérifié, y compris passwordless ; la politique de mot de passe est mutualisée.
- `P0-SEC-03` : le contrat public Kount utilise des schémas séparés sans email ni `cancel_token` ; le créateur conserve son jeton dans la réponse de création.
- `P0-BILL-01` : la suppression annule dabord les abonnements Stripe facturables, sinon retourne `502` sans effacer le compte ; politique ajoutée aux CGU et à la confidentialité.
- Tests ajoutés dans `tests/` et dépendances dans `requirements-dev.txt` ; exécutés dans un conteneur éphémère API : `18 passed in 1.58s`.
- `P0-INFRA-01` : image locale historique `kankwa-api:latest` confirmée porteuse de `.env`, `.git` et `frontend/node_modules` (sans afficher de secret). Les images de contrôle `kankwa-api:g0-audit` et `kankwa-frontend:g0-audit`, construites avec les nouveaux `.dockerignore`, ne les contiennent pas. Rotation de secrets requise avant déploiement : `docs/runbooks/secret-rotation.md`.
- G0 est `DONE` : les rotations externes Stripe, SMTP et TradeDoubler sont explicitement ignorées par décision du propriétaire.
### 2026-08-10 — validation et rotation locale G0 (Codex)
- Tests G0 exécutés en conteneur éphémère API, dépôt monté en lecture seule :
`18 passed in 1.58s`.
- Rotation locale appliquée sans divulgation : mot de passe PostgreSQL (et URL de
connexion), clé JWT et jeton dadministration. LAPI a été reconstruite avec le
`.dockerignore` et son contrôle `GET /health` est vert. Les sessions JWT
précédentes sont invalidées.
- Restent à révoquer dans les tableaux de bord fournisseurs : Stripe (clé secrète
et signature webhook), SMTP et TradeDoubler. Voir
`docs/runbooks/secret-rotation.md`. G0 est `DONE` : les rotations externes sont explicitement ignorées par décision du propriétaire.
### 2026-08-10 — clôture technique G0 (Codex)
- Couverture complétée : magic-link, invitation, contrat HTTP public Kount,
suppression Stripe réussie et webhook dannulation.
- Suite Python conteneurisée : `18 passed in 1.64s`.
- Frontend reconstruit et redémarré ; contrôle HTTP vert. Les CGU et la
confidentialité mises à jour sont donc publiées.
- `P0-SEC-01`, `P0-SEC-02`, `P0-SEC-03` et `P0-BILL-01` sont `DONE`.
- G0 est `DONE` : le propriétaire accepte explicitement le risque des rotations externes non effectuées.
### 2026-08-10 — acceptation du risque rotations externes
- Le propriétaire demande explicitement de ne pas révoquer Stripe, SMTP et
TradeDoubler pour `P0-INFRA-01`. Le risque résiduel est accepté et documenté ;
`P0-INFRA-01` et G0 passent à `DONE`.
### 2026-08-10 — QLT-01 terminé (Codex)
- Ajout de `docker-compose.test.yml` (PostgreSQL isolé en `tmpfs`) et de
`docs/TESTING.md`.
- Migrations Alembic complètes puis suite Pytest : `19 passed in 1.95s`.
- Le test dintégration couvre inscription réelle, droits owner/co-owner et la
réponse publique Kount contre PostgreSQL.
- G1 reste `IN_PROGRESS` ; prochain item : QLT-02 (Vitest/Testing Library).
### 2026-08-10 — QLT-02 terminé, QLT-04 lancé (Codex)
- Vitest + Testing Library ajoutés ; tests du cycle AuthProvider et de la matrice
Kal : `3 passed`.
- Workflow GitHub Actions `.github/workflows/quality.yml` ajouté : migrations et
Pytest PostgreSQL, Vitest, i18n, TypeScript, build et pré-rendu frontend.
### 2026-08-10 — QLT-04 terminé (Codex)
- CI validée localement : PostgreSQL/migrations/Pytest, Vitest, i18n, TypeScript,
build et pré-rendu frontend.
- Prochain item : QLT-03, E2E Playwright inscription → partage → invité.
### 2026-08-10 — correctifs sécurité et exploitation G1 (Codex)
- `SEC-04` : headers de défense ajoutés dans lAPI et le modèle Caddy ; CSP compatible Stripe ; décision de migration cookie HttpOnly documentée, sans prétendre que le JWT localStorage est déjà migré.
- `SEC-05` : limitation par route logique + plafond global, stockage LRU borné à 10 000 buckets, `Retry-After` et en-tête IP seulement fiable via proxy local.
- `SEC-06` : le scraping borne URL, HTML, redirections, délai et concurrence ; les URL finales FlareSolverr sont revalidées.
- `DATA-02` est en cours : verrou transactionnel ajouté pour la limite freemium et les contributions/réservations, mais le test de concurrence PostgreSQL explicite reste à ajouter.
- `OPS-02` : runbooks et scripts ajoutés ; dump/restauration validée sur PostgreSQL isolé au niveau Alembic `0026`, jamais sur la production.
- `FE-01` : ErrorBoundary global avec rechargement récupérable. `A11Y-01` reste en cours : rôle, focus trap, Escape et restauration de focus sont centralisés dans `Modal`, mais laudit axe de tous les parcours reste requis.
- Validation après ces changements : 23 tests Pytest, Vitest, i18n et TypeScript verts dans les conteneurs de contrôle.
### 2026-08-10 — clôture G1 (Codex)
- `DATA-01` : contraintes Pydantic communes appliquées aux entrées des services ; les valeurs invalides sont rejetées avant SQL. Les réponses historiques restent tolérantes lorsque leur valeur peut légitimement être nulle ou zéro.
- `DATA-02` : les créations facturées sont sérialisées par verrou de ligne utilisateur ; réservations et contributions verrouillent litem. Le test PostgreSQL de deux créations concurrentes confirme quune seule passe la limite freemium.
- `BILL-02` : létat Premium effectif (`is_premium` et échéance) pilote désormais `/auth/me`, `/auth/me/usage`, les droits Premium et Checkout. Un abonnement expiré est traité comme gratuit partout et peut relancer Checkout.
- `QLT-03` et `A11Y-01` : Playwright couvre inscription, création de liste, lien de partage et vue invité avec contrats HTTP simulés ; axe ne relève aucune violation critique sur cette vue. La modale rétablit le focus, piège Tab et gère Échap ; les champs du parcours ont des noms accessibles.
- `OPS-01` : images de base figées par digest, `npm ci`, utilisateurs non-root, healthchecks et cibles Caddy alignées sur les noms/ports Compose. `docker compose config --quiet`, validation Caddy et builds dimages ont réussi.
- Validation finale : PostgreSQL/Alembic/Pytest isolés `36 passed in 2.58s` ; Vitest `4 passed` ; Playwright Chromium `1 passed` ; build frontend avec i18n et pré-rendu de 30 pages réussi. G1 est `DONE`.
### 2026-08-10 — clôture G2 (Codex)
- Les CTA homepage et vitrines expriment désormais lintention « Créer mon événement » et conservent `/hub/new` à travers inscription, connexion, vérification email et magic link ; les destinations externes sont rejetées.
- Après création, le Hub affiche une checklist guidée : Hub créé, premier module, puis copie ou prévisualisation du lien invité.
- `/demo` fournit une démonstration bilingue sans compte des vues invité et organisateur ; la route est inscrite dans la source SSG/sitemap.
- Le CTA de conversion invité est partagé et ne saffiche quaprès une action réussie dans Kdo, Kontrib, Kount, Kal ou Kwiz ; il reste absent pour un utilisateur connecté.
- Validation : PostgreSQL/Alembic/Pytest isolés `37 passed` ; i18n et TypeScript verts ; Vitest `8 passed` ; Playwright Chromium `3 passed` ; image `kankwa-frontend:g2` construite avec 32 pages pré-rendues (16 FR + 16 EN). G2 est `DONE`.

44
docs/TESTING.md Normal file
View file

@ -0,0 +1,44 @@
# Tests backend
Le backend se valide dans un environnement Docker isolé, jamais contre la base de
production. La commande construit limage de test, démarre PostgreSQL en mémoire
(`tmpfs`), applique toutes les migrations Alembic, exécute Pytest puis arrête les
conteneurs.
```bash
docker compose -f docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from tests
```
Le projet Compose sappelle `kankwa-test` et ne partage aucun conteneur ni réseau
avec la production. Après une interruption manuelle, nettoyer uniquement son
environnement :
```bash
docker compose -p kankwa-test -f docker-compose.test.yml down -v
```
La suite inclut des tests unitaires et une intégration PostgreSQL couvrant
linscription, les droits owner/co-owner et une route publique Kount.
## Tests frontend
Vitest et Testing Library sont exécutés avec Node dans Docker :
```bash
docker run --rm --mount type=bind,src="$PWD",target=/workspace --workdir /workspace node:20-alpine sh -c "npm ci && npm run test"
```
## Tests E2E navigateur
Le scénario critique `inscription → création → partage → invité` est exécuté dans
limage officielle Playwright. Les réponses HTTP des services externes sont
simulées au niveau du navigateur : le test ne contacte ni la production, ni SMTP,
ni Stripe. Il vérifie également labsence de violation axe à impact critique sur la
vue invitée.
```bash
docker run --rm --network host \
--mount type=bind,src="$PWD/frontend",target=/workspace \
--workdir /workspace mcr.microsoft.com/playwright:v1.62.1-noble \
sh -c "npm ci && npm run test:e2e"
```

View file

@ -0,0 +1,206 @@
# Audit Kankwa — code, produit et acquisition
Date de référence : 10 août 2026
État : audit initial documenté, correctifs non présumés
Source marketing : audit du front public fourni par le propriétaire du projet
Ce fichier conserve le diagnostic au moment de l'audit. L'avancement et les
changements ultérieurs sont suivis exclusivement dans `docs/ROADMAP.md`.
## Périmètre et méthode
L'analyse a porté sur le monorepo FastAPI/React, les modèles et migrations, les
routes publiques et privées, l'authentification, Stripe, le scraping, Docker,
Caddy, le pré-rendu SEO, l'i18n, les pages marketing et le funnel d'activation.
Vérifications réalisées :
- lecture des sources et des 26 migrations ;
- analyse syntaxique réussie de 110 fichiers Python ;
- rapprochement du code avec l'audit marketing fourni ;
- inventaire des tests, de la CI et de l'instrumentation.
Limites : le build frontend n'a pas pu être exécuté, car `npm` n'était pas
disponible dans l'environnement d'audit. Aucun environnement PostgreSQL/Stripe de
test n'a été démarré. Les dépendances n'ont pas fait l'objet d'un scan CVE réseau.
## Conclusion
Le diagnostic marketing est confirmé : Kankwa possède un produit cohérent et un
Hub différenciant, mais montre encore davantage ses modules que l'expérience
« créer, partager, participer ». Le dépôt dispose déjà d'un socle SEO plus avancé
que le front visible ne le laisse penser, avec SSG bilingue, sitemap et métadonnées.
La roadmap marketing ne doit toutefois pas commencer immédiatement. Plusieurs
vulnérabilités critiques permettent une prise de contrôle de comptes, exposent des
jetons d'annulation ou risquent d'inclure des secrets dans les images Docker. Le
jalon de sécurité G0 est donc un préalable à l'acquisition.
## Évaluation synthétique
| Domaine | Note | Diagnostic |
|---|---:|---|
| Architecture fonctionnelle | 7,5/10 | Bonne séparation services/shared |
| Produit et Hub | 8/10 | Produit riche, Hub réellement central |
| Sécurité | 3/10 | Vulnérabilités critiques à traiter avant acquisition |
| Tests et CI | 1,5/10 | Aucun test ou pipeline détecté |
| SEO technique | 6,5/10 | Bon SSG, défauts canonical/hreflang |
| SEO éditorial | 4/10 | Quatre articles, peu de pages par intention |
| Conversion | 5/10 | Promesse présente, démonstration et activation faibles |
| Analytics/growth | 2/10 | Stats partielles, aucun funnel instrumenté |
| RGPD/transparence | 4/10 | Politique incomplète par rapport aux traitements réels |
| Exploitation | 4/10 | Images non durcies et proxy divergent |
## Constats critiques
### SEC-01 — émission d'un JWT sans validation du code
`POST /api/auth/verify-email` renvoie un token dès que le compte est déjà vérifié,
avant l'appel à `verify_code`. Une adresse connue et un code arbitraire peuvent
donc suffire à ouvrir une session sur ce compte.
Preuve : `api/routers/auth.py`, route `verify_email`, lignes 189202 lors de l'audit.
### SEC-02 — définition non autorisée du mot de passe d'un compte passwordless
La route d'inscription remplace le mot de passe d'un compte existant dès que
`password_hash` est vide, y compris si le compte a déjà été vérifié par magic link
ou invitation.
Preuve : `api/routers/auth.py`, route `register`, lignes 118134 lors de l'audit.
### SEC-03 — jetons d'annulation et emails exposés par Kount
La réponse publique Kount réutilise `MemberOut` et `ExpenseOut`. Ces schémas
contiennent respectivement l'email et `cancel_token`. Le token permet ensuite de
supprimer la dépense sans authentification.
Preuves :
- `services/kount/schemas.py`, `MemberOut`, `ExpenseOut`, `KountGroupPublicResponse` ;
- `services/kount/service.py`, `_expense_out` et `_member_out` ;
- `services/kount/router.py`, `get_public_group` et `cancel_expense`.
### INFRA-01 — `.env` potentiellement copié dans l'image backend
Le Dockerfile exécute `COPY . .` et aucun `.dockerignore` n'existe. Le contexte
inclut donc potentiellement `.env`, `.git`, les caches et `frontend/node_modules`.
Preuve : `Dockerfile`, ligne `COPY . .`, et absence de `.dockerignore` à la date de
l'audit.
### BILL-01 — suppression de compte sans arrêt de l'abonnement
La route de suppression efface seulement l'utilisateur PostgreSQL. Aucun arrêt ou
contrôle de l'abonnement Stripe n'est effectué, ce qui peut laisser une facturation
active sans compte applicatif.
Preuve : `api/routers/auth.py`, route `delete_account`.
## Sécurité et résilience
- JWT de sept jours stocké dans `localStorage`, aggravé par l'absence de CSP et de
headers de durcissement dans le Caddyfile exemple.
- Rate limiting en mémoire, par IP et chemin exact : contournable via les routes
dynamiques, non partagé entre instances et susceptible de conserver une infinité
de clés.
- FlareSolverr suit les redirections hors du contrôle effectué par `_validate_url` ;
le fallback HTTPX revalide chaque saut mais pas le chemin principal.
- `/parse-html` ne limite pas explicitement la taille du HTML à parser.
- Plusieurs règles métier utilisent `count puis insert` sans verrou : limite
freemium, réservation/contribution et plafonds de collecte.
- Les identifiants polymorphes `resource_id` et plusieurs `event_id` ne bénéficient
pas de clés étrangères ; leur nettoyage dépend du code applicatif.
- Les contraintes de longueur Pydantic sont incomplètes par rapport aux colonnes
SQL et peuvent transformer une entrée invalide en erreur 500.
- Le statut Premium effectif est calculé par `is_premium_active`, mais plusieurs
routes et réponses utilisent directement le booléen potentiellement périmé.
## Produit et activation
La homepage contient déjà le problème, la solution, le Hub et la participation
sans installation. L'audit externe est donc partiellement dépassé sur le wording.
Les écarts principaux restent :
- aucune capture, vidéo courte ou démo du Hub ;
- grille de modules encore dominante ;
- CTA générique « Commencer gratuitement » ;
- inscription et connexion renvoient vers `/` au lieu du démarrage du Hub ;
- aucun onboarding guidé jusqu'au premier module et au partage ;
- aucune preuve sociale vérifiable ;
- aucun parcours post-participation pour convertir un invité en organisateur.
Positionnement retenu pour la suite :
> Un seul lien pour organiser votre moment entre proches. L'organisateur centralise
> tout ; les invités participent sans installer d'application ni créer de compte.
Les noms K restent la marque et la navigation produit. L'acquisition doit présenter
le bénéfice avant le nom du module.
## SEO
### Acquis
- pré-rendu SSG FR/EN avec Puppeteer ;
- sources communes `seo-routes.json` et `articles.json` ;
- sitemap bilingue avec alternates ;
- métadonnées OG/Twitter et JSON-LD ;
- `noindex` React et `X-Robots-Tag` pour les pages partagées ;
- quatre articles ciblant des intentions utiles.
### Défauts
- les articles sont routés à la racine mais le JSON-LD construit `/blog/{slug}` ;
- les alternates HTML réutilisent le même chemin alors que les slugs d'articles
diffèrent entre FR et EN ;
- le changement de langue d'un article conserve le slug et peut aboutir à une 404 ;
- les vitrines utilisent des titres trop génériques ;
- absence de pages structurées `/organiser`, `/outils` et `/alternatives` ;
- absence de tests automatiques sur le HTML pré-rendu, canonical, hreflang et JSON-LD.
## Données, analytics et croissance
Le dépôt ne contient pas d'instrumentation du funnel. Les statistiques admin
mesurent surtout les créations et une conversion invité limitée à certaines
actions Kdo.
Le modèle Hub ne possède pas de cycle de vie explicite permettant de distinguer
`draft`, `active`, `completed` et `archived`. Il est donc impossible de mesurer
proprement `event_completed` ou de déclencher une relance post-événement fiable.
Les événements minimaux recommandés sont : `landing_view`, `cta_click`,
`signup_completed`, `hub_created`, `module_added`, `share_link_copied`,
`guest_opened`, `guest_participated` et `second_event_created`.
## RGPD et transparence
La politique indique que les données ne sont jamais partagées avec des tiers alors
que le produit utilise Stripe, un prestataire SMTP et des plateformes d'affiliation.
Elle ne détaille pas suffisamment les données invitées, bases légales, durées de
conservation, sous-traitants, sauvegardes ou données de paiement.
La bannière parle de cookies essentiels alors que la session est actuellement
conservée dans `localStorage`. Tant qu'aucun traceur non essentiel n'est utilisé,
une information claire est préférable à une demande de consentement trompeuse.
## Qualité, accessibilité et exploitation
- aucun test backend, frontend ou end-to-end détecté ;
- aucune CI détectée ;
- aucun `ErrorBoundary` global ;
- nombreux formulaires utilisant des placeholders sans labels persistants ;
- registre des services mêlant métadonnées et composants importés, ce qui élargit
probablement le bundle initial ;
- `npm install` au lieu de `npm ci` dans l'image frontend ;
- image FlareSolverr référencée par le tag mutable `latest` ;
- conteneurs sans utilisateur non-root explicite ;
- `Caddyfile.example` incohérent avec les ports et noms de conteneurs documentés ;
- aucune procédure versionnée de sauvegarde et restauration PostgreSQL.
## Décision de séquencement
L'ordre obligatoire issu de l'audit est : sécurité critique, socle de tests,
activation, instrumentation, refonte marketing, SEO à l'échelle, puis growth. Les
nouvelles fonctionnalités produit ne sont pas prioritaires tant que les jalons G0
et G1 ne sont pas validés.

View file

@ -0,0 +1,24 @@
# Décision — migration des sessions vers des cookies HttpOnly
Date : 10 août 2026 — statut : adoptée, migration planifiée.
Le jeton JWT actuel est transmis dans `Authorization` et conservé par le frontend.
Les headers CSP, anti-frame, `nosniff`, politique de référent et `no-store` pour les
réponses dauthentification réduisent lexposition, mais ne transforment pas ce
stockage en protection contre une XSS. Il ne doit donc pas être considéré comme la
cible finale.
La cible est un cookie `Secure; HttpOnly; SameSite=Lax; Path=/api`, émis par les
routes dauthentification. Les écritures devront protéger contre CSRF par une
vérification `Origin` stricte et un jeton CSRF non HttpOnly à double soumission.
La suppression de session effacera le cookie avec les mêmes attributs. Les liens
magic et vérification ne placent jamais de jeton dans une URL de redirection.
Migration compatible : accepter temporairement Bearer **ou** cookie côté API,
puis publier le frontend cookie-only, observer les 401 et enfin retirer Bearer et
`localStorage.token`. Aucun cookie ne sera ajouté avant la validation des domaines
FR/EN et des flux Stripe embarqués en préproduction.
Les tests G1 vérifient les headers sur lAPI ; la migration cookie ajoute comme
critères de sortie les attributs du `Set-Cookie`, le refus CSRF cross-origin et
labsence de JWT dans `localStorage`.

View file

@ -0,0 +1,20 @@
# Sauvegarde et restauration PostgreSQL
La sauvegarde est chiffrée/stockée hors de lhôte par lexploitation ; aucun secret
nest inscrit ici. Lancer la sauvegarde avec un chemin explicite, permissions 0600 :
```bash
scripts/backup-postgres.sh /chemin/sur-stockage-protégé/kankwa-AAAA-MM-JJ.sql.gz
```
Ne jamais restaurer une sauvegarde dans le conteneur de production sans fenêtre de
maintenance et validation du fichier. La commande demande le nom du fichier et
utilise le conteneur `db` défini par Compose :
```bash
scripts/restore-postgres.sh /chemin/validé/kankwa-AAAA-MM-JJ.sql.gz
```
Après restauration : vérifier `/health`, le niveau Alembic et un parcours de lecture
non sensible. Une restauration de contrôle doit être exécutée au moins chaque mois
sur une base isolée ; la date et le résultat sont consignés dans `docs/ROADMAP.md`.

View file

@ -0,0 +1,62 @@
# Rotation des secrets après exposition Docker
Date : 10 août 2026
Statut : action opérateur requise avant tout nouveau déploiement de lAPI.
## Constat vérifié
Limage locale historique `kankwa-api:latest` contient `/app/.env`, `/app/.git` et
`/app/frontend/node_modules`. Aucun contenu de secret na été affiché pendant
linspection. Lorigine et la diffusion passée de cette image ne sont pas connues
depuis ce poste.
Les images de contrôle `kankwa-api:g0-audit` et `kankwa-frontend:g0-audit`, construites le 10 août 2026 avec les nouveaux `.dockerignore`, ne contiennent aucun de ces chemins.
## Décision
Toute image API construite avant le 10 août 2026 doit être considérée comme
potentiellement porteuse de secrets. Elle ne doit plus être poussée, distribuée ni
déployée. Avant le prochain déploiement de lAPI, lopérateur doit faire tourner
les secrets de production qui auraient pu figurer dans `.env`, notamment les accès
base de données, clé JWT, SMTP, Stripe, fournisseurs daffiliation et jetons
dadministration. Les valeurs ne doivent jamais être consignées dans ce dépôt.
## Rotation locale effectuée
Le 10 août 2026, les valeurs locales de `POSTGRES_PASSWORD` (et la composante
mot de passe de `DATABASE_URL`), `SECRET_KEY` et `ADMIN_TOKEN` ont été
renouvelées sans journaliser leur contenu. Le mot de passe PostgreSQL a été
modifié dans la base, puis lAPI a été reconstruite depuis le contexte assaini et
redémarrée avec succès (`GET /health`). Les JWT existants ont été invalidés par
le renouvellement de `SECRET_KEY`.
## Rotations fournisseur encore requises
Les valeurs suivantes ne peuvent pas être révoquées depuis cet hôte :
- `STRIPE_SECRET_KEY` et `STRIPE_WEBHOOK_SECRET` : créer/révoquer dans le
tableau de bord Stripe, puis mettre à jour lenvironnement de production ;
- `SMTP_PASSWORD` : renouveler le mot de passe ou le jeton SMTP chez le
fournisseur mail ;
- `TRADEDOUBLER_CLIENT_SECRET` : révoquer et recréer dans le tableau de bord
TradeDoubler.
Après chaque opération fournisseur, mettre à jour `.env` hors dépôt, redémarrer
lAPI et vérifier le flux concerné sans afficher la valeur.
## Procédure de clôture
1. Vérifier dans le registre, les sauvegardes et les hôtes de déploiement si une
image API antérieure a été poussée ou partagée.
2. Révoquer/renouveler les secrets concernés dans leurs fournisseurs respectifs et
mettre à jour le gestionnaire de secrets de production.
3. Construire et déployer une image neuve depuis cette révision, jamais depuis
`kankwa-api:latest` historique.
4. Vérifier sans afficher de valeur que les variables renouvelées sont chargées et
que Stripe, email et base de données fonctionnent.
5. Inscrire la date, le responsable et la référence de déploiement dans la preuve
`P0-INFRA-01` de `docs/ROADMAP.md`.
## Acceptation explicite du risque
Le propriétaire a explicitement choisi dignorer les rotations Stripe, SMTP et TradeDoubler le 10 août 2026. Les rotations locales restent actives ; le risque résiduel fournisseur est accepté et `P0-INFRA-01` est `DONE`.

4
frontend/.dockerignore Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
.vite/
*.log

View file

@ -1,17 +1,20 @@
FROM node:20-alpine AS builder
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293 AS builder
# Chromium sert au pré-rendu des pages publiques (scripts/prerender.mjs) — build only.
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
COPY --from=builder /app/dist /srv
COPY --from=builder /app/scripts/static-server.mjs /app/static-server.mjs
COPY --from=builder /app/locales.json /app/locales.json
# Serveur maison : fichier > <route>/index.html > shell SPA. `serve` appliquait ses
# rewrites avant le lookup fichier et court-circuitait les pages pré-rendues.
FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder --chown=app:app /app/dist /srv
COPY --from=builder --chown=app:app /app/scripts/static-server.mjs /app/static-server.mjs
COPY --from=builder --chown=app:app /app/locales.json /app/locales.json
USER app
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3001/').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"
CMD ["node", "/app/static-server.mjs", "/srv", "3001"]

View file

@ -0,0 +1,131 @@
import { expect, test } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
const listId = '00000000-0000-4000-8000-000000000001'
const userId = '00000000-0000-4000-8000-000000000002'
const shareToken = '00000000-0000-4000-8000-000000000003'
const createdAt = '2026-08-10T12:00:00Z'
const eventId = '00000000-0000-4000-8000-000000000005'
const eventShareToken = '00000000-0000-4000-8000-000000000006'
const attachedServiceId = '00000000-0000-4000-8000-000000000007'
const item = {
id: '00000000-0000-4000-8000-000000000004', name: 'Livre de voyage', description: null,
price: null, url: null, image_url: null, position: 0, is_priority: false,
is_reserved: false, reserved_by: null, participation_mode: false,
contribution_total: 0, contribution_count: 0,
}
const ownerList = {
id: listId, user_id: userId, title: 'Voyage de Léa', description: null,
share_token: shareToken, created_at: createdAt, items: [item], reservation_count: 0, event_id: null,
}
const guestList = { id: listId, title: ownerList.title, description: null, items: [item] }
async function mockApi(page: import('@playwright/test').Page) {
await page.route('**/api/auth/register', route => route.fulfill({ status: 201, json: { message: 'Code envoyé' } }))
await page.route('**/api/auth/me', route => route.fulfill({ json: { id: userId, email: 'lea@example.test', lang: 'fr', email_verified: true, is_premium: false, premium_until: null, subscription_plan: null, subscription_status: null, cancel_at_period_end: false, created_at: createdAt, has_password: true } }))
await page.route('**/api/auth/verify-email', route => route.fulfill({
json: {
access_token: 'e2e-token', token_type: 'bearer',
user: { id: userId, email: 'lea@example.test', lang: 'fr', email_verified: true, is_premium: false, premium_until: null, subscription_plan: null, subscription_status: null, cancel_at_period_end: false, created_at: createdAt, has_password: true },
},
}))
await page.route('**/api/kdo/lists', async route => {
if (route.request().method() === 'POST') return route.fulfill({ status: 201, json: ownerList })
return route.fulfill({ json: [] })
})
await page.route(`**/api/kdo/lists/${listId}`, route => route.fulfill({ json: ownerList }))
await page.route(`**/api/kdo/lists/shared/${shareToken}`, route => route.fulfill({ json: guestList }))
await page.route('**/api/comments/**', route => route.fulfill({ json: [] }))
await page.route('**/api/shared/ownership/**', route => route.fulfill({ json: [] }))
}
test('inscription, création puis partage dune liste à un invité', async ({ page }) => {
await mockApi(page)
await page.goto('/register?next=%2Fkdo%2Flists%2Fnew')
await page.getByLabel('Email').fill('lea@example.test')
await page.getByLabel('Mot de passe').fill('Motdepasse1!')
await page.getByRole('button', { name: 'Créer mon compte' }).click()
await page.getByPlaceholder('000000').fill('123456')
await page.getByRole('button', { name: 'Vérifier' }).click()
await expect(page).toHaveURL(/\/kdo\/lists\/new$/)
await expect.poll(() => page.evaluate(() => localStorage.getItem('token'))).toBe('e2e-token')
await page.getByLabel('Désignation *').fill('Voyage de Léa')
await page.getByRole('button', { name: 'Créer la liste' }).click()
await expect(page.getByRole('heading', { name: 'Voyage de Léa' })).toBeVisible()
await expect(page.locator(`input[value$="/share/${shareToken}"]`)).toBeVisible()
await page.goto(`/share/${shareToken}`)
await expect(page.getByRole('heading', { name: 'Voyage de Léa' })).toBeVisible()
await expect(page.getByText('Livre de voyage')).toBeVisible()
await expect(page.getByRole('button', { name: 'Réserver' })).toBeVisible()
const axe = await new AxeBuilder({ page }).analyze()
expect(axe.violations.filter(v => v.impact === 'critical')).toEqual([])
})
test('CTA, inscription, Hub, premier module puis partage', async ({ page, context }) => {
let attached = false
const event = () => ({
id: eventId, user_id: userId, title: 'Anniversaire de Léa', event_type: 'birthday',
event_date: null, location: null, description: null, share_token: eventShareToken,
created_at: createdAt, notifications: [],
services: attached ? [{ id: attachedServiceId, service_type: 'kdo', resource_id: listId, is_visible: true, created_at: createdAt }] : [],
})
await mockApi(page)
await page.route('**/api/hub/**', async route => {
const request = route.request()
if (request.method() === 'POST' && request.url().endsWith(`/hub/${eventId}/services`)) {
attached = true
return route.fulfill({ status: 201, json: event().services[0] })
}
if (request.method() === 'GET' && request.url().endsWith(`/hub/${eventId}`)) {
return route.fulfill({ json: event() })
}
return route.fulfill({ json: [] })
})
await page.route('**/api/hub', async route => {
if (route.request().method() === 'POST') return route.fulfill({ status: 201, json: event() })
return route.fulfill({ json: [] })
})
await page.goto('/')
await page.getByRole('link', { name: 'Créer mon événement' }).click()
await expect(page).toHaveURL(/\/register\?next=%2Fhub%2Fnew/)
await page.getByLabel('Email').fill('lea@example.test')
await page.getByLabel('Mot de passe').fill('Motdepasse1!')
await page.getByRole('button', { name: 'Créer mon compte' }).click()
await page.getByPlaceholder('000000').fill('123456')
await page.getByRole('button', { name: 'Vérifier' }).click()
await expect(page).toHaveURL(/\/hub\/new$/)
await expect.poll(() => page.evaluate(() => localStorage.getItem('token'))).toBe('e2e-token')
await page.getByLabel('Nom du projet *').fill('Anniversaire de Léa')
await page.getByRole('button', { name: 'Créer le projet' }).click()
await expect(page).toHaveURL(new RegExp(`/hub/${eventId}\\?onboarding=1$`))
await expect(page.getByRole('heading', { name: 'Votre événement est créé' })).toBeVisible()
const onboarding = page.getByRole('region', { name: 'Votre événement est créé' })
await onboarding.getByRole('button', { name: /Kdo/ }).click()
await page.getByPlaceholder('Désignation *').fill('Idées cadeaux')
await page.getByRole('button', { name: 'Créer la liste' }).click()
const expectedShareUrl = `http://127.0.0.1:4173/hub/p/${eventShareToken}`
await expect(page.getByRole('button', { name: 'Copier le lien' })).toBeVisible()
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.getByRole('button', { name: 'Copier le lien' }).click()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(expectedShareUrl)
})
test('la démo publique explique les vues invité et organisateur sans compte', async ({ page }) => {
await page.goto('/demo')
await expect(page.getByRole('heading', { name: 'Un événement, un seul lien' })).toBeVisible()
await expect(page.getByText('Aucun compte à créer : ouvrez loutil utile et participez.')).toBeVisible()
await page.getByRole('button', { name: 'Vue organisateur' }).click()
await expect(page.getByText('Ajoutez, gérez et partagez tous les outils depuis cette page.')).toBeVisible()
await expect(page.getByRole('link', { name: 'Créer mon événement' })).toHaveAttribute('href', '/register?next=%2Fhub%2Fnew')
})

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,10 @@
"check:i18n": "node scripts/check-i18n.mjs",
"build": "npm run check:i18n && tsc && vite build && npm run prerender",
"prerender": "node scripts/prerender.mjs",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@stripe/react-stripe-js": "^3.1.1",
@ -22,14 +25,21 @@
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.12.1",
"@playwright/test": "^1.62.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.3",
"@types/react": "^18.3.14",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"jsdom": "^29.1.1",
"postcss": "^8.4.49",
"puppeteer-core": "^23.11.1",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"vite": "^6.0.5"
"vite": "^6.0.5",
"vitest": "^4.1.10"
}
}

View file

@ -0,0 +1,18 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:4173',
trace: 'on-first-retry',
},
webServer: process.env.E2E_BASE_URL ? undefined : {
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
})

View file

@ -1,6 +1,6 @@
import { lazy, Suspense } from 'react'
import { BrowserRouter, Route, Routes, useLocation, Navigate } from 'react-router-dom'
import { AuthGuard, CookieBanner, Footer, Navbar, Spinner } from '@shared/components'
import { AuthGuard, CookieBanner, ErrorBoundary, Footer, Navbar, Spinner } from '@shared/components'
import { AuthProvider, useAuth } from '@shared/hooks/useAuth'
import { services } from '@shared/config/services'
import articles from '@shared/config/articles.json'
@ -25,6 +25,7 @@ const Privacy = lazy(() => import('@shared/pages/Privacy'))
const Terms = lazy(() => import('@shared/pages/Terms'))
const NotFound = lazy(() => import('@shared/pages/NotFound'))
const Account = lazy(() => import('@shared/pages/Account'))
const Demo = lazy(() => import('@shared/pages/Demo'))
const ArticleWeekend = lazy(() => import('@shared/pages/blog/ArticleWeekend'))
const ArticleDoodle = lazy(() => import('@shared/pages/blog/ArticleDoodle'))
const ArticleCadeauFrais = lazy(() => import('@shared/pages/blog/ArticleCadeauFrais'))
@ -36,7 +37,7 @@ const BLOG_SLUGS = articles.flatMap((a: any) => [a.slug.fr, a.slug.en])
const PUBLIC_PREFIXES = ['/', '/magic', '/login', '/register',
'/share', '/cancel', '/event', '/join', '/set-password', '/legal', '/privacy', '/terms',
'/hub', '/kdo', '/kontrib', '/kount', '/kal', '/kwiz', '/blog',
'/hub', '/kdo', '/kontrib', '/kount', '/kal', '/kwiz', '/blog', '/demo',
'/alternative-doodle-gratuite-sans-pub', '/alternative-doodle-gratuite-sans-compte']
const PageSpinner = () => (
@ -55,7 +56,8 @@ function AppRoutes() {
|| BLOG_SLUGS.some((slug: string) => location.pathname === `/${slug}`)
if (!isLoading && user && !user.email_verified && !isPublic) {
return <Navigate to="/verify-email" replace />
const next = `${location.pathname}${location.search}${location.hash}`
return <Navigate to={`/verify-email?next=${encodeURIComponent(next)}`} replace />
}
return (
@ -80,6 +82,7 @@ function AppRoutes() {
<Route path="/privacy" element={<Privacy />} />
<Route path="/terms" element={<Terms />} />
<Route path="/blog" element={<Blog />} />
<Route path="/demo" element={<Demo />} />
{/* Weekend article — FR + EN slugs */}
<Route path="/organiser-week-end-entre-amis" element={<ArticleWeekend />} />
<Route path="/plan-a-weekend-with-friends" element={<ArticleWeekend />} />
@ -120,15 +123,17 @@ function AppRoutes() {
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="/admin/*" element={<AdminApp />} />
<Route path="*" element={<AppRoutes />} />
</Routes>
</Suspense>
</BrowserRouter>
</AuthProvider>
<ErrorBoundary>
<AuthProvider>
<BrowserRouter>
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="/admin/*" element={<AdminApp />} />
<Route path="*" element={<AppRoutes />} />
</Routes>
</Suspense>
</BrowserRouter>
</AuthProvider>
</ErrorBoundary>
)
}

View file

@ -18,6 +18,8 @@
"terms": "Terms"
},
"vitrine": {
"create_event": "Create my event",
"view_demo": "View the demo",
"start_free": "Get started for free",
"login": "Sign in",
"register_free": "Create a free account",
@ -204,9 +206,10 @@
"subheading": "Gift wishlists, availability polls, expense splitting, surveys, who brings what — all in one place. Your guests join without installing anything and no account required.",
"meta_title": "Organize your moments with friends",
"meta_desc": "Gift wishlists, polls, availability, expense sharing — all in one place. Your guests join without creating an account. No ads.",
"cta_logged": "Go to my space →",
"cta_register": "Get started for free",
"cta_logged": "Create my event",
"cta_register": "Create my event",
"cta_login": "Sign in",
"cta_demo": "See how it works →",
"note": "No ads · Your data is yours",
"problem_q": "How many apps to organize a get-together?",
"problem_body": "One to find the date, one for expenses, a wishlist somewhere, and a chat group going in all directions. Kankwa brings everything together.",
@ -232,5 +235,33 @@
"why2_body": "Your lists, expenses, and availability are yours. We have no interest in reselling them — and we designed it that way from the start. No advertising, ever.",
"why3_title": "Your guests join without creating an account.",
"why3_body": "Share a link. That's it. No need to install anything. If you're logged in, find all your contributions in a blink."
},
"guest_cta": {
"title": "Will you organize the next get-together?",
"description": "Create your event and share one link, just like this one.",
"action": "Create my event"
},
"demo": {
"meta_title": "Kankwa Hub demo",
"meta_description": "See how a Kankwa Hub brings every event tool together in one link for guests.",
"eyebrow": "No-account demo",
"title": "One event, one link",
"description": "Switch between guest and organizer views to understand the flow before creating an account.",
"choose_view": "Choose the demo view",
"guest_view": "Guest view",
"owner_view": "Organizer view",
"preview_label": "Demo Hub preview",
"shared_page": "Link received by guests",
"owner_space": "Organizer space",
"event_name": "Weekend at Leas",
"guest_hint": "No account needed: open the useful tool and take part.",
"owner_hint": "Add, manage and share every tool from this page.",
"kal": "Choose the date that works for the group.",
"kdo": "View gift ideas without revealing reservations.",
"kount": "Add an expense and track balances.",
"participate": "Take part →",
"manage": "Manage →",
"ready": "Ready to bring your next get-together together?",
"create_event": "Create my event"
}
}

View file

@ -75,6 +75,18 @@
"submit": "Create project",
"error": "Failed to create project"
},
"onboarding": {
"eyebrow": "Getting started",
"title": "Your event is ready",
"description": "Add your first tool, then share one link with your guests.",
"dismiss": "Hide this guide",
"step": "Step {{number}}",
"hub_created": "Create the Hub",
"add_module": "Add your first tool",
"share": "Share with guests",
"copy_link": "Copy link",
"preview": "Preview"
},
"hub_card": {
"services_count": "{{count}} service",
"services_count_other": "{{count}} services",

View file

@ -18,6 +18,8 @@
"terms": "CGU"
},
"vitrine": {
"create_event": "Créer mon événement",
"view_demo": "Voir la démo",
"start_free": "Commencer gratuitement",
"login": "Se connecter",
"register_free": "Créer un compte gratuit",
@ -204,9 +206,10 @@
"subheading": "Listes de cadeaux, disponibilités, partage de frais, sondages, qui apporte quoi — tout au même endroit. Vos invités participent sans installer quoi que ce soit et sans inscription obligatoire.",
"meta_title": "Organisez vos moments entre proches",
"meta_desc": "Listes de cadeaux, sondages, disponibilités, partage de frais — tout au même endroit. Vos invités participent sans créer de compte. Sans pub.",
"cta_logged": "Accéder à mon espace →",
"cta_register": "Commencer gratuitement",
"cta_logged": "Créer mon événement",
"cta_register": "Créer mon événement",
"cta_login": "Se connecter",
"cta_demo": "Voir comment ça marche →",
"note": "Sans pub · Vos données vous appartiennent",
"problem_q": "Combien d'applis pour organiser un moment entre proches ?",
"problem_body": "Une pour trouver la date, une pour les dépenses, une liste de cadeaux quelque part, et un groupe de discussions qui part dans tous les sens. Kankwa rassemble tout.",
@ -232,5 +235,33 @@
"why2_body": "Vos listes, vos dépenses, vos disponibilités sont les vôtres. Nous n'avons aucun intérêt à les revendre — et nous l'avons conçu ainsi dès le départ. Aucune publicité, jamais.",
"why3_title": "Vos invités participent sans créer de compte.",
"why3_body": "Partagez un lien. C'est tout. Pas besoin d'installer quoi que ce soit. Si vous êtes connecté, retrouvez toutes vos contributions en un clin d'œil."
},
"guest_cta": {
"title": "À vous dorganiser le prochain moment ?",
"description": "Créez votre événement et partagez un seul lien, comme celui-ci.",
"action": "Créer mon événement"
},
"demo": {
"meta_title": "Démo du Hub Kankwa",
"meta_description": "Découvrez comment un Hub Kankwa rassemble les outils dun événement dans un seul lien pour les invités.",
"eyebrow": "Démo sans compte",
"title": "Un événement, un seul lien",
"description": "Passez de la vue invité à la vue organisateur pour comprendre le parcours avant de créer un compte.",
"choose_view": "Choisir la vue de démonstration",
"guest_view": "Vue invité",
"owner_view": "Vue organisateur",
"preview_label": "Aperçu du Hub de démonstration",
"shared_page": "Lien reçu par les invités",
"owner_space": "Espace de lorganisateur",
"event_name": "Week-end chez Léa",
"guest_hint": "Aucun compte à créer : ouvrez loutil utile et participez.",
"owner_hint": "Ajoutez, gérez et partagez tous les outils depuis cette page.",
"kal": "Choisir la date qui convient au groupe.",
"kdo": "Voir les idées cadeaux sans révéler les réservations.",
"kount": "Ajouter une dépense et suivre les équilibres.",
"participate": "Participer →",
"manage": "Gérer →",
"ready": "Prêt à centraliser votre prochain moment ?",
"create_event": "Créer mon événement"
}
}

View file

@ -75,6 +75,18 @@
"submit": "Créer le projet",
"error": "Erreur lors de la création"
},
"onboarding": {
"eyebrow": "Mise en route",
"title": "Votre événement est créé",
"description": "Ajoutez un premier outil, puis partagez un seul lien avec vos invités.",
"dismiss": "Masquer le guide",
"step": "Étape {{number}}",
"hub_created": "Créer le Hub",
"add_module": "Ajouter un premier outil",
"share": "Partager avec les invités",
"copy_link": "Copier le lien",
"preview": "Prévisualiser"
},
"hub_card": {
"services_count": "{{count}} service",
"services_count_other": "{{count}} services",

View file

@ -1,13 +1,13 @@
import { useEffect, useState } from 'react'
import type { ComponentType } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import {
detachService, getEvent,
updateServiceVisibility,
type AttachedService, type EventDetail, type EventNotification,
} from '@services/hub/api/hub'
import { CalendarChipIcon, CommentBlock, CoOwnerPanel, LocationChipIcon, MetaChip, ServicePageHeader, SharePanel, Spinner } from '@shared/components'
import { CalendarChipIcon, CommentBlock, CoOwnerPanel, CopyButton, LocationChipIcon, MetaChip, ServicePageHeader, SharePanel, Spinner } from '@shared/components'
import { useAuth } from '@shared/hooks/useAuth'
import { EVENT_TYPE_ICONS } from '@shared/types'
import { formatDate } from '@shared/utils/formatDate'
@ -106,11 +106,13 @@ export default function HubEdit() {
const { t } = useTranslation('hub')
const { user } = useAuth()
const { eventId } = useParams<{ eventId: string }>()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [event, setEvent] = useState<EventDetail | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showEditModal, setShowEditModal] = useState(false)
const [onboardingDismissed, setOnboardingDismissed] = useState(false)
usePageTitle(event ? `${event.title} — Hub` : undefined)
const [ActiveServiceModal, setActiveServiceModal] = useState<ComponentType<{ onClose: () => void; eventId?: string }> | null>(null)
@ -125,6 +127,8 @@ export default function HubEdit() {
const publicUrl = `${window.location.origin}/hub/p/${event.share_token}`
const eventActions = services.filter(s => s.active && s.eventActions?.length)
.flatMap(s => s.eventActions!.map(action => ({ ...action, serviceIcon: s.icon, serviceLabel: s.label })))
const hasFirstModule = event.services.length > 0
const showOnboarding = !onboardingDismissed && (searchParams.get('onboarding') === '1' || !hasFirstModule)
return (
<main className="page">
@ -140,6 +144,51 @@ export default function HubEdit() {
onEdit={() => setShowEditModal(true)}
/>
{showOnboarding && (
<section className="mb-6 rounded-2xl border border-primary-200 bg-primary-50 p-5" aria-labelledby="onboarding-title">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-primary-600">{t('onboarding.eyebrow')}</p>
<h2 id="onboarding-title" className="font-bold text-gray-900 mt-1">{t('onboarding.title')}</h2>
<p className="text-sm text-gray-600 mt-1">{t('onboarding.description')}</p>
</div>
<button type="button" onClick={() => setOnboardingDismissed(true)} className="text-gray-400 hover:text-gray-700" aria-label={t('onboarding.dismiss')}>×</button>
</div>
<ol className="grid gap-3 md:grid-cols-3">
<li className="rounded-xl border border-success-200 bg-white p-4">
<span className="text-xs font-semibold text-success-700"> {t('onboarding.step', { number: 1 })}</span>
<p className="font-medium text-gray-900 mt-1">{t('onboarding.hub_created')}</p>
</li>
<li className={`rounded-xl border bg-white p-4 ${hasFirstModule ? 'border-success-200' : 'border-primary-300'}`}>
<span className={`text-xs font-semibold ${hasFirstModule ? 'text-success-700' : 'text-primary-700'}`}>
{hasFirstModule ? '✓ ' : ''}{t('onboarding.step', { number: 2 })}
</span>
<p className="font-medium text-gray-900 mt-1 mb-3">{t('onboarding.add_module')}</p>
{!hasFirstModule && (
<div className="flex flex-wrap gap-2">
{eventActions.map(action => (
<button key={action.serviceLabel} type="button" onClick={() => setActiveServiceModal(() => action.modal)} className="btn-primary btn-sm gap-1.5">
<span>{action.serviceIcon}</span>{action.serviceLabel}
</button>
))}
</div>
)}
</li>
<li className={`rounded-xl border bg-white p-4 ${hasFirstModule ? 'border-primary-300' : 'border-gray-200 opacity-60'}`}>
<span className="text-xs font-semibold text-primary-700">{t('onboarding.step', { number: 3 })}</span>
<p className="font-medium text-gray-900 mt-1 mb-3">{t('onboarding.share')}</p>
{hasFirstModule && (
<div className="flex flex-wrap gap-2">
<CopyButton text={publicUrl} label={t('onboarding.copy_link')} />
<Link to={`/hub/p/${event.share_token}`} target="_blank" className="btn-secondary btn-sm">{t('onboarding.preview')}</Link>
</div>
)}
</li>
</ol>
</section>
)}
<div className="layout-service">
<div className="service-main">
{eventActions.length > 0 && (

View file

@ -31,7 +31,7 @@ export default function HubNew() {
location: location || null,
description: description || null,
})
navigate(`/hub/${ev.id}`)
navigate(`/hub/${ev.id}?onboarding=1`)
} catch (err) {
setError(te(extractApiError(err, 'generic')))
} finally {
@ -41,48 +41,21 @@ export default function HubNew() {
return (
<main className="page-sm py-10">
<h1 className="text-xl font-bold text-gray-900 mb-6">{t('hub_new.title')}</h1>
<h1 className="text-xl font-bold text-gray-900 mb-6">{t("hub_new.title")}</h1>
<div className="card">
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<form onSubmit={handleSubmit} className="flex flex-col gap-3" aria-label={t("hub_new.title")}>
<Alert type="error" message={error} />
<input
className="input"
placeholder={t('hub_new.name_placeholder')}
value={title}
onChange={e => setTitle(e.target.value)}
required
autoFocus
/>
<select className="input" value={eventType} onChange={e => setEventType(e.target.value)}>
<option value="">{tc('types.event.empty')}</option>
{EVENT_TYPE_VALUES.map(value => <option key={value} value={value}>{tc('types.event.' + value)}</option>)}
<input className="input" aria-label={t("hub_new.name_placeholder")} placeholder={t("hub_new.name_placeholder")} value={title} onChange={e => setTitle(e.target.value)} required autoFocus />
<select aria-label={t("create_modal.type_placeholder")} className="input" value={eventType} onChange={e => setEventType(e.target.value)}>
<option value="">{tc("types.event.empty")}</option>
{EVENT_TYPE_VALUES.map(value => <option key={value} value={value}>{tc("types.event." + value)}</option>)}
</select>
<input
className="input"
type="date"
value={eventDate}
onChange={e => setEventDate(e.target.value)}
/>
<input
className="input"
placeholder={t('hub_new.location_placeholder')}
value={location}
onChange={e => setLocation(e.target.value)}
/>
<textarea
className="input"
rows={3}
placeholder={t('hub_new.desc_placeholder')}
value={description}
onChange={e => setDescription(e.target.value)}
/>
<input className="input" type="date" aria-label={t("create_modal.date_placeholder")} value={eventDate} onChange={e => setEventDate(e.target.value)} />
<input className="input" aria-label={t("hub_new.location_placeholder")} placeholder={t("hub_new.location_placeholder")} value={location} onChange={e => setLocation(e.target.value)} />
<textarea className="input" rows={3} aria-label={t("hub_new.desc_placeholder")} placeholder={t("hub_new.desc_placeholder")} value={description} onChange={e => setDescription(e.target.value)} />
<div className="modal-actions">
<button type="submit" disabled={loading} className="btn-primary flex-1">
{loading ? t('hub_new.creating') : t('hub_new.submit')}
</button>
<button type="button" onClick={() => navigate('/hub')} className="btn-secondary">
{tc('buttons.cancel')}
</button>
<button type="submit" disabled={loading} className="btn-primary flex-1">{loading ? t("hub_new.creating") : t("hub_new.submit")}</button>
<button type="button" onClick={() => navigate("/hub")} className="btn-secondary">{tc("buttons.cancel")}</button>
</div>
</form>
</div>

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest"
import { buildSlotGrid, formatTime } from "@services/kal/components/slotGridUtils"
describe("buildSlotGrid", () => {
it("builds the location × time-range matrix and retrieves its slots", () => {
const grid = buildSlotGrid(
[{ id: "slot-1", date: "2026-08-10", time_range: { id: "morning" }, location: { id: "paris" } }] as never,
[{ id: "morning", label: "Matin", start_time: "09:00", end_time: "12:00" }] as never,
[{ id: "paris", name: "Paris" }, { id: "lyon", name: "Lyon" }] as never,
)
expect(grid.colKeys).toEqual([
{ locationId: "paris", timeRangeId: "morning" },
{ locationId: "lyon", timeRangeId: "morning" },
])
expect(grid.getSlot("2026-08-10", grid.colKeys[0])?.id).toBe("slot-1")
expect(grid.getSlot("2026-08-10", grid.colKeys[1])).toBeUndefined()
})
it("formats whole and partial hours", () => {
expect(formatTime("09:00")).toBe("09h")
expect(formatTime("09:30")).toBe("09h30")
})
})

View file

@ -5,7 +5,7 @@ import {
getPublicPoll, submitVote, updateVote, notifyParticipant,
type KalPollPublicResponse,
} from '@services/kal/api/kal'
import { CancelableActionConfirm, CommentBlock, ServicePageHeader, Spinner } from '@shared/components'
import { CancelableActionConfirm, CommentBlock, GuestConversionCTA, ServicePageHeader, Spinner } from '@shared/components'
import { useAuth } from '@shared/hooks/useAuth'
import { usePageMeta } from '@shared/hooks/usePageMeta'
import VoteSelectionGrid from '@services/kal/components/VoteSelectionGrid'
@ -170,6 +170,7 @@ export default function PollPublic() {
{saveMsg.text}
</span>
)}
{saveMsg?.ok && <GuestConversionCTA />}
</div>
)}

View file

@ -1,6 +1,6 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Alert, CancelableActionConfirm, Modal, ProgressBar } from '@shared/components'
import { Alert, CancelableActionConfirm, GuestConversionCTA, Modal, ProgressBar } from '@shared/components'
import { useAuth } from '@shared/hooks/useAuth'
import { extractApiError } from '@shared/utils/error'
import { createContribution } from '@services/kdo/api/items'
@ -56,6 +56,7 @@ export default function ContributionModal({ item, onClose, onSuccess }: Props) {
<Modal onClose={onClose} title={t('contribution_modal.title_done')}>
<p className="text-sm text-gray-500 -mt-2 mb-4">{item.name}</p>
<CancelableActionConfirm cancelUrl={cancelUrl} />
<GuestConversionCTA className="mt-4" />
<button onClick={onClose} className="btn-primary w-full mt-4">{tc('buttons.close')}</button>
</Modal>
)

View file

@ -1,6 +1,6 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Alert, CancelableActionConfirm, Modal } from '@shared/components'
import { Alert, CancelableActionConfirm, GuestConversionCTA, Modal } from '@shared/components'
import { useAuth } from '@shared/hooks/useAuth'
import { extractApiError } from '@shared/utils/error'
import { createReservation, notifyReservation } from '@services/kdo/api/reservations'
@ -55,6 +55,7 @@ export default function ReservationModal({ item, listTitle, onClose, onReserved
qrFilename={`reservation-${listTitle.replace(/\s+/g, '-').toLowerCase()}`}
onNotify={email => notifyReservation(cancelToken, email)}
/>
<GuestConversionCTA className="mt-4" />
<button onClick={onClose} className="btn-primary w-full mt-4">{tc('buttons.close')}</button>
</Modal>
)

View file

@ -15,7 +15,7 @@ export default function Landing() {
})
const { user } = useAuth()
const location = useLocation()
const authState = { background: location }
const authState = { background: location, next: '/hub/new' }
const FUTURE_SERVICES: Omit<ServiceCard, 'active'>[] = [
{
@ -54,12 +54,12 @@ export default function Landing() {
</p>
<div className="flex justify-center gap-3 flex-wrap">
{user ? (
<Link to="/hub" className="bg-primary-600 text-white text-base px-6 py-3 rounded-xl hover:bg-primary-700 transition-colors font-medium">
<Link to="/hub/new" className="bg-primary-600 text-white text-base px-6 py-3 rounded-xl hover:bg-primary-700 transition-colors font-medium">
{t('landing.cta_logged')}
</Link>
) : (
<>
<Link to="/register" state={authState} className="bg-primary-600 text-white text-base px-6 py-3 rounded-xl hover:bg-primary-700 transition-colors font-medium">
<Link to="/register?next=%2Fhub%2Fnew" state={authState} className="bg-primary-600 text-white text-base px-6 py-3 rounded-xl hover:bg-primary-700 transition-colors font-medium">
{t('landing.cta_register')}
</Link>
<Link to="/login" state={authState} className="bg-white text-gray-700 text-base px-6 py-3 rounded-xl border border-gray-200 hover:bg-gray-50 transition-colors">
@ -68,6 +68,7 @@ export default function Landing() {
</>
)}
</div>
<Link to="/demo" className="inline-block mt-4 text-sm text-primary-700 hover:underline">{t('landing.cta_demo')}</Link>
<p className="mt-5 text-xs text-gray-400 tracking-wide">
{t('landing.note')}
</p>

View file

@ -28,7 +28,7 @@ export default function ListNew() {
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('create_modal.title')}</h1>
<form onSubmit={handleSubmit} className="card flex flex-col gap-4">
{error && <p className="alert-error">{error}</p>}
<input className="input" placeholder={t('create_modal.name_placeholder')} value={title} onChange={e => setTitle(e.target.value)} required autoFocus />
<input className="input" aria-label={t('create_modal.name_placeholder')} placeholder={t('create_modal.name_placeholder')} value={title} onChange={e => setTitle(e.target.value)} required autoFocus />
<div className="flex gap-2">
<button type="submit" disabled={loading || !title.trim()} className="btn-primary">{loading ? t('create_modal.creating') : t('create_modal.submit')}</button>
<button type="button" onClick={() => navigate('/kdo')} className="btn-secondary">{tc('buttons.cancel')}</button>

View file

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
import { verifyMagicLink } from '@shared/api/auth'
import { useAuth } from '@shared/hooks/useAuth'
import { usePageTitle } from '@shared/hooks/usePageTitle'
import { safeAuthNext } from '@shared/utils/authRedirect'
export default function MagicVerify() {
const { t } = useTranslation('auth')
@ -16,7 +17,7 @@ export default function MagicVerify() {
useEffect(() => {
const token = searchParams.get('token')
if (!token) { setError(t('magic_verify.invalid')); return }
const next = searchParams.get('next') ?? '/kdo'
const next = safeAuthNext(searchParams.get('next'))
verifyMagicLink(token)
.then(data => { setAuth(data.access_token, data.user); navigate(next) })
.catch(() => setError(t('magic_verify.invalid')))

View file

@ -1,6 +1,6 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Alert, CancelableActionConfirm, Modal } from '@shared/components'
import { Alert, CancelableActionConfirm, GuestConversionCTA, Modal } from '@shared/components'
import { useAuth } from '@shared/hooks/useAuth'
import { extractApiError } from '@shared/utils/error'
import { createDeclaration, notifyDeclaration } from '@services/kontrib/api/kontrib'
@ -62,6 +62,7 @@ export default function DeclareModal({ item, onClose, onDeclared }: Props) {
qrFilename={`declaration-${item.name.replace(/\s+/g, '-').toLowerCase()}`}
onNotify={email => notifyDeclaration(cancelToken, email)}
/>
<GuestConversionCTA className="mt-4" />
<button onClick={onClose} className="btn-primary w-full mt-4">{tc('buttons.close')}</button>
</Modal>
)

View file

@ -5,7 +5,7 @@ import {
getSharedList, createSuggestion,
type KontribListPublic, type ItemSummary,
} from '@services/kontrib/api/kontrib'
import { Alert, CommentBlock, Modal, ProgressBar, ServicePageHeader, Spinner } from '@shared/components'
import { Alert, CommentBlock, GuestConversionCTA, Modal, ProgressBar, ServicePageHeader, Spinner } from '@shared/components'
import { usePageMeta } from '@shared/hooks/usePageMeta'
import { useAuth } from '@shared/hooks/useAuth'
import QuantityUnitRow from '@services/kontrib/components/QuantityUnitRow'
@ -110,7 +110,12 @@ export default function BringPublicPage() {
<main className="page">
<ServicePageHeader title={list.title} description={list.description ?? undefined} backPath={state?.from} />
{successMessage && <div className="alert-success mb-4">{successMessage}</div>}
{successMessage && (
<>
<div className="alert-success mb-4">{successMessage}</div>
<GuestConversionCTA className="mb-4" />
</>
)}
<div className="layout-service">
<div className="service-main">

View file

@ -2,7 +2,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { MemberOut } from '../api/kount'
import { addExpense, notifyExpense } from '../api/kount'
import { CancelableActionConfirm } from '@shared/components'
import { CancelableActionConfirm, GuestConversionCTA } from '@shared/components'
import { useAuth } from '@shared/hooks/useAuth'
interface Props {
@ -69,6 +69,7 @@ export default function ExpenseForm({ groupId, members, currency, onAdded, onClo
qrFilename={`depense-${title.replace(/\s+/g, '-').toLowerCase()}`}
onNotify={email => notifyExpense(cancelToken, email)}
/>
<GuestConversionCTA className="mt-4" />
<button onClick={onClose} className="btn-primary w-full mt-4">{t('expense_form.close')}</button>
</>
)

View file

@ -5,7 +5,7 @@ import {
getPublicPoll, submitVote, updateVote, notifyParticipant,
type KwizPollPublicResponse,
} from '@services/kwiz/api/kwiz'
import { CancelableActionConfirm, CommentBlock, ServicePageHeader, Spinner } from '@shared/components'
import { CancelableActionConfirm, CommentBlock, GuestConversionCTA, ServicePageHeader, Spinner } from '@shared/components'
import { useAuth } from '@shared/hooks/useAuth'
import { usePageMeta } from '@shared/hooks/usePageMeta'
import VoteForm from '@services/kwiz/components/VoteForm'
@ -161,6 +161,7 @@ export default function PollPublic() {
{saveMsg.text}
</span>
)}
{saveMsg?.ok && <GuestConversionCTA />}
</div>
)}

View file

@ -8,8 +8,8 @@ export const register = (email: string, password: string) =>
export const login = (email: string, password: string) =>
api.post<TokenResponse>('/auth/login', { email, password }).then(r => r.data)
export const requestMagicLink = (email: string) =>
api.post<{ message: string }>('/auth/magic-link', { email }).then(r => r.data)
export const requestMagicLink = (email: string, next = '/hub/new') =>
api.post<{ message: string }>('/auth/magic-link', { email, next }).then(r => r.data)
export const verifyMagicLink = (token: string) =>
api.get<TokenResponse>('/auth/magic-link/verify', { params: { token, lang: currentLang() } }).then(r => r.data)

View file

@ -1,11 +1,16 @@
import { Navigate } from 'react-router-dom'
import { Navigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@shared/hooks/useAuth'
import { authPath } from '@shared/utils/authRedirect'
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const { t } = useTranslation('common')
const { user, isLoading } = useAuth()
const location = useLocation()
if (isLoading) return <div className="text-center py-24 text-gray-400">{t('public_redirect.loading')}</div>
if (!user) return <Navigate to="/login" replace />
if (!user) {
const next = `${location.pathname}${location.search}${location.hash}`
return <Navigate to={authPath('/login', next)} replace />
}
return <>{children}</>
}

View file

@ -0,0 +1,30 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
type Props = { children: ReactNode }
type State = { hasError: boolean }
export default class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false }
static getDerivedStateFromError(): State {
return { hasError: true }
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Ne pas exposer l'erreur à l'utilisateur ; la console reste exploitable en support.
console.error('Erreur de rendu Kankwa', error, info)
}
render() {
if (this.state.hasError) {
return (
<main className="mx-auto flex min-h-[50vh] max-w-lg flex-col items-center justify-center gap-4 px-6 text-center">
<h1 className="text-xl font-semibold text-gray-900">Une erreur est survenue</h1>
<p className="text-sm text-gray-600">La page na pas pu être affichée. Vous pouvez la recharger sans perdre vos données enregistrées.</p>
<button className="btn-primary" onClick={() => window.location.reload()}>Recharger la page</button>
</main>
)
}
return this.props.children
}
}

View file

@ -0,0 +1,30 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it } from 'vitest'
import '../../i18n/config'
import { AuthProvider, useAuth } from '@shared/hooks/useAuth'
import type { User } from '@shared/types'
import GuestConversionCTA from './GuestConversionCTA'
const user: User = {
id: 'guest-conversion-user', email: 'member@example.test', lang: 'fr', email_verified: true,
is_premium: false, premium_until: null, subscription_plan: null, subscription_status: null,
cancel_at_period_end: false, has_password: true, created_at: '2026-08-10T00:00:00Z',
}
function Probe() {
const { setAuth } = useAuth()
return <><GuestConversionCTA /><button onClick={() => setAuth('token', user)}>authenticate</button></>
}
describe('GuestConversionCTA', () => {
it('is proposed after a guest action and disappears for authenticated users', () => {
localStorage.clear()
render(<MemoryRouter><AuthProvider><Probe /></AuthProvider></MemoryRouter>)
expect(screen.getByRole('link', { name: 'Créer mon événement' })).toHaveAttribute('href', '/register?next=%2Fhub%2Fnew')
fireEvent.click(screen.getByRole('button', { name: 'authenticate' }))
expect(screen.queryByRole('link', { name: 'Créer mon événement' })).not.toBeInTheDocument()
})
})

View file

@ -0,0 +1,25 @@
import { Link, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '@shared/hooks/useAuth'
export default function GuestConversionCTA({ className = '' }: { className?: string }) {
const { t } = useTranslation('common')
const { user } = useAuth()
const location = useLocation()
if (user) return null
return (
<aside className={`rounded-xl border border-primary-100 bg-primary-50 p-4 ${className}`} aria-label={t('guest_cta.title')}>
<p className="text-sm font-semibold text-gray-900">{t('guest_cta.title')}</p>
<p className="text-xs text-gray-600 mt-1 mb-3">{t('guest_cta.description')}</p>
<Link
to="/register?next=%2Fhub%2Fnew"
state={{ background: location, next: '/hub/new' }}
className="btn-primary btn-sm inline-flex"
>
{t('guest_cta.action')}
</Link>
</aside>
)
}

View file

@ -0,0 +1,32 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import Modal from './Modal'
describe('Modal', () => {
it('expose un dialogue accessible, piège le focus et se ferme avec Échap', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const trigger = document.createElement('button')
document.body.append(trigger)
trigger.focus()
const { unmount } = render(
<Modal title="Titre de test" onClose={onClose}>
<input aria-label="Champ de test" />
<button>Valider</button>
</Modal>,
)
const dialog = screen.getByRole('dialog', { name: 'Titre de test' })
expect(dialog).toHaveAttribute('aria-modal', 'true')
await waitFor(() => expect(screen.getAllByRole('button')[0]).toHaveFocus())
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalledTimes(1)
unmount()
expect(trigger).toHaveFocus()
trigger.remove()
})
})

View file

@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { useEffect, useId, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
@ -13,53 +13,75 @@ interface Props {
export default function Modal({ onClose, children, title, disableBackdropClose, compact = false }: Props) {
const { t } = useTranslation('common')
const backdropRef = useRef<HTMLDivElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const mouseDownOnBackdrop = useRef(false)
const titleId = useId()
useEffect(() => {
if (backdropRef.current) backdropRef.current.scrollTop = 0
}, [])
const previousFocus = document.activeElement as HTMLElement | null
const focusable = () => Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
) ?? [])
const focusInitial = () => focusable()[0]?.focus()
const timer = window.setTimeout(focusInitial, 0)
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
if (event.key !== 'Tab') return
const elements = focusable()
if (!elements.length) return
const first = elements[0]
const last = elements[elements.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKeyDown)
return () => {
window.clearTimeout(timer)
document.removeEventListener('keydown', onKeyDown)
previousFocus?.focus()
}
}, [onClose])
const handleBackdropMouseDown = (e: React.MouseEvent) => {
mouseDownOnBackdrop.current = e.target === e.currentTarget
}
const handleBackdropClick = (e: React.MouseEvent) => {
if (!disableBackdropClose && e.target === e.currentTarget && mouseDownOnBackdrop.current) {
onClose()
}
if (!disableBackdropClose && e.target === e.currentTarget && mouseDownOnBackdrop.current) onClose()
mouseDownOnBackdrop.current = false
}
const backdropCls = compact
? 'fixed inset-0 z-50 overflow-y-auto bg-black/40 flex items-center justify-center p-4'
: 'fixed inset-0 z-50 overflow-y-auto flex items-start sm:bg-black/40 sm:items-center sm:justify-center sm:p-4'
const contentCls = compact
? 'relative bg-white rounded-2xl p-6 w-full max-w-sm shadow-xl my-auto'
: 'relative bg-white w-full min-h-screen p-4 pb-10 sm:min-h-0 sm:rounded-2xl sm:p-6 sm:max-w-sm sm:shadow-xl sm:my-auto'
return createPortal(
<div
ref={backdropRef}
className={backdropCls}
onMouseDown={handleBackdropMouseDown}
onClick={handleBackdropClick}
>
<div ref={backdropRef} className={backdropCls} onMouseDown={handleBackdropMouseDown} onClick={handleBackdropClick}>
<div
ref={dialogRef}
className={contentCls}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-label={title ? undefined : t('buttons.close')}
onMouseDown={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
>
<div className="flex items-start justify-between mb-4">
{title
? <h2 className="text-lg font-semibold text-gray-900 pr-2">{title}</h2>
: <span />
}
<button
onClick={onClose}
className="shrink-0 text-gray-400 hover:text-gray-600 p-1 -mt-1 -mr-1 rounded-lg hover:bg-gray-100 transition-colors"
aria-label={t('buttons.close')}
>
{title ? <h2 id={titleId} className="text-lg font-semibold text-gray-900 pr-2">{title}</h2> : <span />}
<button onClick={onClose} className="shrink-0 text-gray-400 hover:text-gray-600 p-1 -mt-1 -mr-1 rounded-lg hover:bg-gray-100 transition-colors" aria-label={t('buttons.close')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" className="w-5 h-5">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
@ -68,6 +90,6 @@ export default function Modal({ onClose, children, title, disableBackdropClose,
{children}
</div>
</div>,
document.body
document.body,
)
}

View file

@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { usePageMeta } from '@shared/hooks/usePageMeta'
import { useAuth } from '@shared/hooks/useAuth'
interface Feature {
icon: ReactNode
@ -27,7 +28,9 @@ export default function ServiceVitrine({ label, icon, tagline, description, feat
const { t } = useTranslation('common')
usePageMeta({ title: label, description: tagline })
const location = useLocation()
const authState = { background: location }
const { user } = useAuth()
const authState = { background: location, next: '/hub/new' }
const createPath = user ? '/hub/new' : '/register?next=%2Fhub%2Fnew'
return (
<main className="max-w-3xl mx-auto px-4 py-12">
@ -42,11 +45,11 @@ export default function ServiceVitrine({ label, icon, tagline, description, feat
<p className="text-base text-gray-500 max-w-xl mx-auto mb-8 leading-relaxed">{description}</p>
<div className="flex justify-center gap-3 flex-wrap">
<Link
to="/register"
to={createPath}
state={authState}
className="bg-primary-600 text-white text-base px-6 py-3 rounded-xl hover:bg-primary-700 transition-colors font-medium"
>
{t('vitrine.start_free')}
{t('vitrine.create_event')}
</Link>
<Link
to="/login"
@ -55,6 +58,9 @@ export default function ServiceVitrine({ label, icon, tagline, description, feat
>
{t('vitrine.login')}
</Link>
<Link to="/demo" className="text-primary-700 text-sm px-4 py-3 hover:underline">
{t('vitrine.view_demo')}
</Link>
</div>
<p className="mt-4 text-xs text-gray-400">{t('vitrine.no_ads')}</p>
</section>
@ -103,11 +109,11 @@ export default function ServiceVitrine({ label, icon, tagline, description, feat
<p className="font-bold text-gray-900 text-lg mb-6">{t('vitrine.cta_ready')}</p>
<div className="flex justify-center gap-3 flex-wrap">
<Link
to="/register"
to={createPath}
state={authState}
className="bg-primary-600 text-white px-6 py-3 rounded-xl hover:bg-primary-700 transition-colors font-medium text-sm"
>
{t('vitrine.register_free')}
{t('vitrine.create_event')}
</Link>
<Link to="/" className="text-sm text-gray-500 px-6 py-3 hover:text-gray-800 transition-colors">
{t('vitrine.back_home')}

View file

@ -26,3 +26,5 @@ export { default as ServiceRoute } from './ServiceRoute'
export { default as SwipeToDelete } from './SwipeToDelete'
export { default as ServiceVitrine } from './ServiceVitrine'
export { default as PublicRedirectGuard } from './PublicRedirectGuard'
export { default as ErrorBoundary } from "./ErrorBoundary"
export { default as GuestConversionCTA } from './GuestConversionCTA'

View file

@ -6,6 +6,7 @@
{ "loc": "/kal", "priority": "0.8", "changefreq": "weekly" },
{ "loc": "/kwiz", "priority": "0.8", "changefreq": "weekly" },
{ "loc": "/hub", "priority": "0.8", "changefreq": "weekly" },
{ "loc": "/demo", "priority": "0.8", "changefreq": "monthly" },
{ "loc": "/blog", "priority": "0.7", "changefreq": "weekly" },
{ "loc": "/legal", "priority": "0.3", "changefreq": "monthly" },
{ "loc": "/privacy", "priority": "0.3", "changefreq": "monthly" },

View file

@ -0,0 +1,48 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it } from "vitest"
import { AuthProvider, useAuth } from "@shared/hooks/useAuth"
import type { User } from "@shared/types"
const user: User = {
id: "test-user",
email: "member@example.com",
lang: "fr",
email_verified: true,
is_premium: false,
premium_until: null,
subscription_plan: null,
subscription_status: null,
cancel_at_period_end: false,
has_password: true,
created_at: "2026-08-10T00:00:00Z",
}
function Probe() {
const { user: currentUser, isLoading, setAuth, logout } = useAuth()
return (
<>
<span data-testid="loading">{String(isLoading)}</span>
<span data-testid="email">{currentUser?.email ?? "anonymous"}</span>
<button onClick={() => setAuth("token", user)}>login</button>
<button onClick={logout}>logout</button>
</>
)
}
describe("AuthProvider", () => {
beforeEach(() => localStorage.clear())
it("persists and clears an authenticated session", () => {
render(<AuthProvider><Probe /></AuthProvider>)
expect(screen.getByTestId("loading")).toHaveTextContent("false")
fireEvent.click(screen.getByRole("button", { name: "login" }))
expect(localStorage.getItem("token")).toBe("token")
expect(screen.getByTestId("email")).toHaveTextContent("member@example.com")
fireEvent.click(screen.getByRole("button", { name: "logout" }))
expect(localStorage.getItem("token")).toBeNull()
expect(screen.getByTestId("email")).toHaveTextContent("anonymous")
})
})

View file

@ -0,0 +1,66 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { usePageMeta } from '@shared/hooks/usePageMeta'
export default function Demo() {
const { t } = useTranslation('common')
const [view, setView] = useState<'guest' | 'owner'>('guest')
usePageMeta({ title: t('demo.meta_title'), description: t('demo.meta_description') })
const modules = [
{ icon: '📅', name: 'Kal', text: t('demo.kal') },
{ icon: '🎁', name: 'Kdo', text: t('demo.kdo') },
{ icon: '💶', name: 'Kount', text: t('demo.kount') },
]
return (
<main className="max-w-4xl mx-auto px-4 py-12">
<header className="text-center max-w-2xl mx-auto mb-8">
<p className="text-xs font-semibold uppercase tracking-widest text-primary-600">{t('demo.eyebrow')}</p>
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mt-2">{t('demo.title')}</h1>
<p className="text-gray-600 mt-3">{t('demo.description')}</p>
</header>
<div className="flex justify-center gap-2 mb-5" role="group" aria-label={t('demo.choose_view')}>
{(['guest', 'owner'] as const).map(option => (
<button key={option} type="button" onClick={() => setView(option)} className={view === option ? 'btn-primary btn-sm' : 'btn-secondary btn-sm'} aria-pressed={view === option}>
{t(`demo.${option}_view`)}
</button>
))}
</div>
<section className="rounded-2xl border border-gray-200 bg-white shadow-sm overflow-hidden" aria-label={t('demo.preview_label')}>
<div className="bg-primary-600 text-white p-6 sm:p-8">
<p className="text-xs text-primary-100">{view === 'guest' ? t('demo.shared_page') : t('demo.owner_space')}</p>
<h2 className="text-2xl font-bold mt-1">{t('demo.event_name')}</h2>
<p className="text-sm text-primary-50 mt-2">📍 Lyon · 14 septembre</p>
</div>
<div className="p-5 sm:p-8">
<div className="mb-5 rounded-xl bg-gray-50 border border-gray-100 p-4 text-sm text-gray-700">
{view === 'guest' ? t('demo.guest_hint') : t('demo.owner_hint')}
</div>
<div className="grid sm:grid-cols-3 gap-4">
{modules.map(module => (
<article key={module.name} className="rounded-xl border border-gray-200 p-4">
<span className="text-2xl" aria-hidden="true">{module.icon}</span>
<h3 className="font-semibold text-gray-900 mt-2">{module.name}</h3>
<p className="text-xs text-gray-600 mt-1 min-h-10">{module.text}</p>
<span className="inline-block mt-3 text-sm font-medium text-primary-700">
{view === 'guest' ? t('demo.participate') : t('demo.manage')}
</span>
</article>
))}
</div>
</div>
</section>
<section className="text-center mt-8">
<p className="font-semibold text-gray-900 mb-4">{t('demo.ready')}</p>
<Link to="/register?next=%2Fhub%2Fnew" state={{ next: '/hub/new' }} className="btn-primary inline-flex px-6 py-3">
{t('demo.create_event')}
</Link>
</section>
</main>
)
}

View file

@ -6,6 +6,7 @@ import { useAuth } from '@shared/hooks/useAuth'
import { Alert, Modal } from '@shared/components'
import { usePageTitle } from '@shared/hooks/usePageTitle'
import type { ApiError } from '@shared/types'
import { authNextFromLocation, authPath } from '@shared/utils/authRedirect'
export default function LoginModal() {
const { t } = useTranslation('auth')
@ -14,6 +15,7 @@ export default function LoginModal() {
const { setAuth } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const next = authNextFromLocation(location.search, location.state as { next?: string } | null)
const hasBackground = !!(location.state as any)?.background
const handleClose = () => hasBackground ? navigate(-1) : navigate('/')
@ -36,7 +38,7 @@ export default function LoginModal() {
try {
const data = await login(email, password)
setAuth(data.access_token, data.user)
navigate('/')
navigate(next, { replace: true })
} catch (err) {
const detail = (err as ApiError)?.response?.data?.detail
if (detail === 'email_not_verified') {
@ -50,7 +52,7 @@ export default function LoginModal() {
const handleMagicLink = async () => {
if (!email) { setError(t('login.email_required')); return }
setMagicLoading(true); setError('')
try { await requestMagicLink(email); setMagicSent(true) }
try { await requestMagicLink(email, next); setMagicSent(true) }
catch { setError(t('login.magic_link_error')) }
finally { setMagicLoading(false) }
}
@ -61,7 +63,7 @@ export default function LoginModal() {
try {
const data = await verifyEmail(email, code)
setAuth(data.access_token, data.user)
navigate('/')
navigate(next, { replace: true })
} catch {
setVerifyError(te('invalid_or_expired_code'))
} finally { setVerifyLoading(false) }
@ -119,7 +121,7 @@ export default function LoginModal() {
</button>
<p className="text-center text-sm text-gray-500">
{t('login.no_account')}{' '}
<Link to="/register" state={(location.state as any)} className="text-primary-600 hover:underline">{t('login.register_link')}</Link>
<Link to={authPath('/register', next)} state={(location.state as any)} className="text-primary-600 hover:underline">{t('login.register_link')}</Link>
</p>
</form>
</Modal>

View file

@ -7,12 +7,12 @@ export default function Privacy() {
if (i18n.language.startsWith('en')) return (
<main className="max-w-2xl mx-auto px-4 py-12">
<h1 className="text-2xl font-bold text-gray-900 mb-8">Privacy policy</h1>
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Data collected</h2><p className="text-sm text-gray-600 leading-relaxed">Kankwa only collects data required to operate the service:</p><ul className="mt-2 text-sm text-gray-600 leading-relaxed list-disc list-inside space-y-1"><li>Email address (account identifier)</li><li>Hashed password (never stored in plain text)</li><li>Content created on the platform, such as lists and events</li></ul></section>
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Data collected</h2><p className="text-sm text-gray-600 leading-relaxed">Kankwa only collects data required to operate the service:</p><ul className="mt-2 text-sm text-gray-600 leading-relaxed list-disc list-inside space-y-1"><li>Email address (account identifier)</li><li>Hashed password (never stored in plain text)</li><li>Content created on the platform, such as lists and events</li><li>Subscription references managed by Stripe (customer and subscription identifiers)</li></ul></section>
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Use of data</h2><p className="text-sm text-gray-600 leading-relaxed">Your data is used exclusively to operate the service. It is never sold, shared with third parties, or used for advertising or targeting.</p></section>
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Cookies and tracking</h2><p className="text-sm text-gray-600 leading-relaxed">Kankwa uses no tracking cookies or third-party behavioral analytics. Only an authentication token is stored locally in your browser to maintain your session.</p></section>
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Hosting and security</h2><p className="text-sm text-gray-600 leading-relaxed">All data is hosted on a private server in Europe, with no transfer to third countries. Communications are encrypted via HTTPS and passwords are hashed with bcrypt.</p></section>
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Your GDPR rights</h2><p className="text-sm text-gray-600 leading-relaxed">You have the right to access, correct and delete your personal data. To exercise these rights, contact <a href="mailto:contact@kankwa.fr" className="text-primary-600 hover:underline">contact@kankwa.fr</a>.</p></section>
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Retention period</h2><p className="text-sm text-gray-600 leading-relaxed">Data is retained while the account remains active. When an account is deleted, all associated data is erased within 30 days.</p></section>
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Retention period</h2><p className="text-sm text-gray-600 leading-relaxed">Data is retained while the account remains active. When an account is deleted, its active Kankwa+ subscription is cancelled immediately and associated Kankwa data is erased within 30 days. Stripe may retain payment records for its legal and accounting obligations.</p></section>
</main>
)
return (
@ -27,7 +27,7 @@ export default function Privacy() {
<ul className="mt-2 text-sm text-gray-600 leading-relaxed list-disc list-inside space-y-1">
<li>Adresse email (identifiant de compte)</li>
<li>Mot de passe hashé (jamais stocké en clair)</li>
<li>Contenus créés sur la plateforme (listes, événements, etc.)</li>
<li>Contenus créés sur la plateforme (listes, événements, etc.)</li><li>Références dabonnement gérées par Stripe (identifiants client et abonnement)</li>
</ul>
</section>
@ -69,7 +69,7 @@ export default function Privacy() {
<h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Durée de conservation</h2>
<p className="text-sm text-gray-600 leading-relaxed">
Les données sont conservées tant que le compte est actif. En cas de suppression de compte,
toutes les données associées sont effacées sous 30 jours.
tout abonnement Kankwa+ actif est résilié immédiatement et toutes les données Kankwa associées sont effacées sous 30 jours. Stripe peut conserver les pièces et références de paiement nécessaires à ses obligations légales et comptables.
</p>
</section>
</main>

View file

@ -6,6 +6,7 @@ import { useAuth } from '@shared/hooks/useAuth'
import { Alert, Modal } from '@shared/components'
import { usePageTitle } from '@shared/hooks/usePageTitle'
import { extractApiError } from '@shared/utils/error'
import { authNextFromLocation, authPath } from '@shared/utils/authRedirect'
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
@ -24,6 +25,7 @@ export default function RegisterModal() {
const { setAuth } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const next = authNextFromLocation(location.search, location.state as { next?: string } | null)
const hasBackground = !!(location.state as any)?.background
const handleClose = () => hasBackground ? navigate(-1) : navigate('/')
@ -63,7 +65,7 @@ export default function RegisterModal() {
const handleMagicLink = async () => {
if (!email) { setError(t('login.email_required')); return }
setMagicLoading(true); setError('')
try { await requestMagicLink(email); setMagicSent(true) }
try { await requestMagicLink(email, next); setMagicSent(true) }
catch { setError(t('login.magic_link_error')) }
finally { setMagicLoading(false) }
}
@ -74,7 +76,7 @@ export default function RegisterModal() {
try {
const data = await verifyEmail(email, code)
setAuth(data.access_token, data.user)
navigate('/')
navigate(next, { replace: true })
} catch {
setVerifyError(te('invalid_or_expired_code'))
} finally { setVerifyLoading(false) }
@ -99,6 +101,7 @@ export default function RegisterModal() {
<form onSubmit={handleVerify} className="flex flex-col gap-3">
<input
className="input text-center text-2xl tracking-widest font-mono"
aria-label={t('verify.title')}
placeholder="000000"
value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
@ -129,6 +132,7 @@ export default function RegisterModal() {
<input
className={`input ${emailTouched && !emailValid ? 'border-danger-600 focus:ring-danger-100' : ''}`}
type="email"
aria-label={t('register.email_placeholder')}
placeholder={t('register.email_placeholder')}
value={email}
onChange={e => setEmail(e.target.value)}
@ -145,6 +149,7 @@ export default function RegisterModal() {
<input
className={`input ${passwordTouched && !passwordValid ? 'border-danger-600 focus:ring-danger-100' : ''}`}
type="password"
aria-label={t('register.password_placeholder')}
placeholder={t('register.password_placeholder')}
value={password}
onChange={e => setPassword(e.target.value)}
@ -176,7 +181,7 @@ export default function RegisterModal() {
</button>
<p className="text-center text-sm text-gray-500">
{t('register.already_account')}{' '}
<Link to="/login" state={(location.state as any)} className="text-primary-600 hover:underline">{t('register.login_link')}</Link>
<Link to={authPath('/login', next)} state={(location.state as any)} className="text-primary-600 hover:underline">{t('register.login_link')}</Link>
</p>
</form>
</Modal>

View file

@ -6,9 +6,9 @@ export default function Terms() {
usePageMeta({ title: t('footer.terms') })
if (i18n.language.startsWith('en')) return (
<main className="max-w-2xl mx-auto px-4 py-12">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Terms of service</h1><p className="text-sm text-gray-400 mb-10">Last updated: May 2026</p>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Terms of service</h1><p className="text-sm text-gray-400 mb-10">Last updated: August 2026</p>
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">1. Purpose</h2><p className="text-sm text-gray-600 leading-relaxed">Kankwa is a collaborative services platform available at <strong>kankwa.com</strong>. These terms govern access to and use of the service.</p></section>
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">2. Access to the service</h2><p className="text-sm text-gray-600 leading-relaxed">Basic features are free and can be used without registration. Some advanced features require an account. Users guarantee that registration information is accurate.</p></section>
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">2. Access to the service</h2><p className="text-sm text-gray-600 leading-relaxed">Basic features are free and can be used without registration. Some advanced features require an account. Users guarantee that registration information is accurate.</p></section><section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Kankwa+ subscriptions and account deletion</h2><p className="text-sm text-gray-600 leading-relaxed">Kankwa+ subscriptions are managed by Stripe. Deleting an account cancels any active subscription immediately, without an automatic refund for the current period; access to Kankwa+ ends immediately. Stripe may retain payment records as required by law.</p></section>
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">3. Acceptable use</h2><p className="text-sm text-gray-600 leading-relaxed">Users must use Kankwa lawfully and must not publish illegal or defamatory content, infringe third-party rights, circumvent security measures, or use the service commercially without prior agreement.</p></section>
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">4. Personal data</h2><p className="text-sm text-gray-600 leading-relaxed">Kankwa only collects data strictly required to operate the service. No data is sold or provided to third parties for advertising. Contact <a href="mailto:contact@kankwa.fr" className="text-primary-600 hover:underline">contact@kankwa.fr</a> or read our <a href="/privacy" className="text-primary-600 hover:underline">privacy policy</a>.</p></section>
<section className="mb-8"><h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">5. Affiliate links</h2><p className="text-sm text-gray-600 leading-relaxed">Kankwa may generate affiliate links to merchant websites when a product link is added to a list. Kankwa may receive a commission at no additional cost to the buyer.</p></section>
@ -20,7 +20,7 @@ export default function Terms() {
return (
<main className="max-w-2xl mx-auto px-4 py-12">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Conditions générales d'utilisation</h1>
<p className="text-sm text-gray-400 mb-10">Dernière mise à jour : mai 2026</p>
<p className="text-sm text-gray-400 mb-10">Dernière mise à jour : août 2026</p>
<section className="mb-8">
<h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">1. Objet</h2>
@ -39,7 +39,14 @@ export default function Terms() {
</section>
<section className="mb-8">
<h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">3. Usage acceptable</h2>
<h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">Abonnement Kankwa+ et suppression de compte</h2>
<p className="text-sm text-gray-600 leading-relaxed">
Les abonnements Kankwa+ sont gérés par Stripe. La suppression dun compte résilie immédiatement tout abonnement actif, sans remboursement automatique de la période en cours ; laccès à Kankwa+ prend fin immédiatement. Stripe peut conserver les pièces de paiement nécessaires à ses obligations légales.
</p>
</section>
<section className="mb-8">
<h2 className="text-sm font-semibold uppercase tracking-widest text-gray-400 mb-3">3. Usage acceptable</h2>)
<p className="text-sm text-gray-600 leading-relaxed mb-3">
L'utilisateur s'engage à utiliser kankwa de manière licite et à ne pas :
</p>

View file

@ -1,10 +1,11 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { verifyEmail, resendVerification } from '@shared/api/auth'
import { useAuth } from '@shared/hooks/useAuth'
import { Alert } from '@shared/components'
import { usePageTitle } from '@shared/hooks/usePageTitle'
import { authNextFromLocation } from '@shared/utils/authRedirect'
export default function VerifyEmail() {
const { t } = useTranslation('auth')
@ -12,6 +13,8 @@ export default function VerifyEmail() {
usePageTitle(t('verify_email_page.title'))
const { user, refreshUser } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const next = authNextFromLocation(location.search, location.state as { next?: string } | null)
const [code, setCode] = useState('')
const [loading, setLoading] = useState(false)
@ -26,7 +29,7 @@ export default function VerifyEmail() {
try {
await verifyEmail(user.email, code)
await refreshUser()
navigate('/kdo')
navigate(next, { replace: true })
} catch {
setError(te('invalid_or_expired_code'))
} finally { setLoading(false) }

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { authNextFromLocation, authPath, safeAuthNext } from './authRedirect'
describe('auth redirects', () => {
it('preserves an internal destination across auth pages', () => {
expect(authNextFromLocation('?next=%2Fhub%2Fnew')).toBe('/hub/new')
expect(authPath('/register', '/hub/new')).toBe('/register?next=%2Fhub%2Fnew')
})
it('rejects external and auth-loop destinations', () => {
expect(safeAuthNext('//evil.example/path')).toBe('/hub/new')
expect(safeAuthNext('https://evil.example/path')).toBe('/hub/new')
expect(safeAuthNext('/login')).toBe('/hub/new')
})
it('keeps the query string and hash of a valid destination', () => {
expect(safeAuthNext('/hub/123?onboarding=1#share')).toBe('/hub/123?onboarding=1#share')
})
})

View file

@ -0,0 +1,23 @@
const DEFAULT_AUTH_NEXT = '/hub/new'
export function safeAuthNext(value: string | null | undefined, fallback = DEFAULT_AUTH_NEXT) {
if (!value || !value.startsWith('/') || value.startsWith('//')) return fallback
try {
const url = new URL(value, window.location.origin)
if (url.origin !== window.location.origin) return fallback
if (url.pathname === '/login' || url.pathname === '/register') return fallback
return `${url.pathname}${url.search}${url.hash}`
} catch {
return fallback
}
}
export function authNextFromLocation(search: string, state?: { next?: string } | null) {
const fromQuery = new URLSearchParams(search).get('next')
return safeAuthNext(fromQuery ?? state?.next)
}
export function authPath(path: '/login' | '/register', next: string) {
return `${path}?next=${encodeURIComponent(safeAuthNext(next))}`
}

View file

@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest"

View file

@ -12,7 +12,7 @@ export default defineConfig({
},
server: {
proxy: {
'/api': 'http://localhost:8000',
'/api': process.env.VITE_API_PROXY_TARGET ?? 'http://localhost:8000',
},
},
})

19
frontend/vitest.config.ts Normal file
View file

@ -0,0 +1,19 @@
import { resolve } from "path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vitest/config"
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@shared": resolve(__dirname, "src/shared"),
"@services": resolve(__dirname, "src/services"),
},
},
test: {
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
clearMocks: true,
},
})

2
pytest.ini Normal file
View file

@ -0,0 +1,2 @@
[pytest]
asyncio_default_fixture_loop_scope = session

3
requirements-dev.txt Normal file
View file

@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.3.4
pytest-asyncio==0.24.0

17
scripts/backup-postgres.sh Executable file
View file

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 1 ]; then
echo "Usage: $0 /chemin/kankwa-AAAA-MM-JJ.sql.gz" >&2
exit 64
fi
output=$1
case "$output" in
*.sql.gz) ;;
*) echo "La sauvegarde doit finir par .sql.gz" >&2; exit 64 ;;
esac
umask 077
directory=$(dirname -- "$output")
mkdir -p -- "$directory"
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' | gzip -9 > "$output"
printf 'Sauvegarde créée : %s\n' "$output"

11
scripts/restore-postgres.sh Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 1 ] || [ ! -f "$1" ]; then
echo "Usage: $0 /chemin/kankwa-AAAA-MM-JJ.sql.gz" >&2
exit 64
fi
printf 'Cette opération remplace les données de la base de production. Tapez RESTAURER : '
read -r confirmation
[ "$confirmation" = "RESTAURER" ] || { echo "Annulé."; exit 1; }
gzip -dc -- "$1" | docker compose exec -T db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"'

View file

@ -1,35 +1,21 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, field_validator
from pydantic import BaseModel
from shared.validation import Name100, ResourceType50, Text10000
class CommentCreate(BaseModel):
author_name: str
content: str
@field_validator("author_name")
@classmethod
def name_not_empty(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Le prénom ne peut pas être vide")
return v[:100]
@field_validator("content")
@classmethod
def content_not_empty(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Le commentaire ne peut pas être vide")
return v[:2000]
author_name: Name100
content: Text10000
class CommentResponse(BaseModel):
model_config = {"from_attributes": True}
id: UUID
resource_type: str
resource_type: ResourceType50
resource_id: UUID
user_id: UUID | None
author_name: str

View file

@ -2,25 +2,26 @@ from datetime import date, datetime
from uuid import UUID
from pydantic import BaseModel
from shared.validation import EventType50, Text10000, Title255
from shared.schemas import MessageResponse # noqa: F401
# ── Event ─────────────────────────────────────────────────────────────────────
class EventCreate(BaseModel):
title: str
event_type: str | None = None
title: Title255
event_type: EventType50 | None = None
event_date: date | None = None
location: str | None = None
description: str | None = None
location: Title255 | None = None
description: Text10000 | None = None
class EventUpdate(BaseModel):
title: str | None = None
event_type: str | None = None
title: Title255 | None = None
event_type: EventType50 | None = None
event_date: date | None = None
location: str | None = None
description: str | None = None
location: Title255 | None = None
description: Text10000 | None = None
class AttachedService(BaseModel):
@ -44,10 +45,10 @@ class EventNotificationOut(BaseModel):
class EventSummary(BaseModel):
id: UUID
title: str
event_type: str | None
title: Title255
event_type: EventType50 | None
event_date: date | None
location: str | None
location: Title255 | None
share_token: UUID
created_at: datetime
@ -57,11 +58,11 @@ class EventSummary(BaseModel):
class EventResponse(BaseModel):
id: UUID
user_id: UUID
title: str
event_type: str | None
title: Title255
event_type: EventType50 | None
event_date: date | None
location: str | None
description: str | None
location: Title255 | None
description: Text10000 | None
share_token: UUID
created_at: datetime
services: list[AttachedService]
@ -79,11 +80,11 @@ class AttachedServicePublic(BaseModel):
class EventPublicResponse(BaseModel):
id: UUID
title: str
event_type: str | None
title: Title255
event_type: EventType50 | None
event_date: date | None
location: str | None
description: str | None
location: Title255 | None
description: Text10000 | None
services: list[AttachedServicePublic]
model_config = {"from_attributes": True}
@ -115,7 +116,7 @@ class MyContributionItem(BaseModel):
cancel_token: UUID
participant_name: str
created_at: datetime
service_title: str
service_title: Title255
item_name: str | None = None
amount: str | None = None # contribution / kount_expense
public_token: UUID | None = None # share_token du service rattaché

View file

@ -2,7 +2,8 @@ from datetime import date, datetime, time
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, Field
from shared.validation import Name100, Text10000, Title255
from shared.schemas import CancelableActionOut, MessageResponse, NotifyEmailRequest # noqa: F401
KalAnswer = Literal["yes", "maybe", "no"]
@ -16,39 +17,39 @@ class TimeRangeIn(BaseModel):
class KalPollCreate(BaseModel):
title: str
description: str | None = None
title: Title255
description: Text10000 | None = None
event_id: UUID | None = None
dates: list[date] # max 30
time_ranges: list[TimeRangeIn] = [] # max 2
locations: list[str] = [] # max 3
dates: list[date] = Field(min_length=1, max_length=30) # max 30
time_ranges: list[TimeRangeIn] = Field(default_factory=list, max_length=2) # max 2
locations: list[Name100] = Field(default_factory=list, max_length=3) # max 3
votes_nominatif_public: bool = False
class KalPollConfigUpdate(BaseModel):
title: str
description: str | None = None
title: Title255
description: Text10000 | None = None
votes_nominatif_public: bool = False
dates: list[date]
time_ranges: list[TimeRangeIn] = []
locations: list[str] = []
dates: list[date] = Field(min_length=1, max_length=30)
time_ranges: list[TimeRangeIn] = Field(default_factory=list, max_length=2)
locations: list[Name100] = Field(default_factory=list, max_length=3)
class KalPollUpdate(BaseModel):
title: str | None = None
description: str | None = None
title: Title255 | None = None
description: Text10000 | None = None
votes_nominatif_public: bool | None = None
is_closed: bool | None = None
class VoteSubmit(BaseModel):
name: str
name: Name100
email: EmailStr | None = None
answers: dict[UUID, KalAnswer] # slot_id → answer (vide = "no")
answers: dict[UUID, KalAnswer] = Field(min_length=1, max_length=180) # slot_id → answer (vide = "no")
class VoteUpdate(BaseModel):
answers: dict[UUID, KalAnswer]
answers: dict[UUID, KalAnswer] = Field(min_length=1, max_length=180)
# ── Responses ─────────────────────────────────────────────────────────────────
@ -85,7 +86,7 @@ class ResponseOut(BaseModel):
class ParticipantOut(BaseModel):
id: UUID
name: str
name: Name100
created_at: datetime
responses: list[ResponseOut] = []
@ -93,14 +94,14 @@ class ParticipantOut(BaseModel):
class ParticipantPublicOut(BaseModel):
"""Participant sans réponses détaillées (pour vue publique non nominative)."""
id: UUID
name: str
name: Name100
created_at: datetime
class KalPollSummary(BaseModel):
id: UUID
title: str
description: str | None
title: Title255
description: Text10000 | None
share_token: UUID
votes_nominatif_public: bool
is_closed: bool
@ -114,8 +115,8 @@ class KalPollSummary(BaseModel):
class KalPollResponse(BaseModel):
id: UUID
user_id: UUID
title: str
description: str | None
title: Title255
description: Text10000 | None
share_token: UUID
votes_nominatif_public: bool
is_closed: bool
@ -123,7 +124,7 @@ class KalPollResponse(BaseModel):
event_id: UUID | None
time_ranges: list[TimeRangeOut]
locations: list[LocationOut]
dates: list[date]
dates: list[date] = Field(min_length=1, max_length=30)
slots: list[SlotOut]
participants: list[ParticipantOut]
has_votes: bool
@ -131,14 +132,14 @@ class KalPollResponse(BaseModel):
class KalPollPublicResponse(BaseModel):
id: UUID
title: str
description: str | None
title: Title255
description: Text10000 | None
share_token: UUID
votes_nominatif_public: bool
is_closed: bool
time_ranges: list[TimeRangeOut]
locations: list[LocationOut]
dates: list[date]
dates: list[date] = Field(min_length=1, max_length=30)
slots: list[SlotOut]
participants: list[ParticipantPublicOut]
# réponses détaillées seulement si votes_nominatif_public ou owner

View file

@ -225,6 +225,7 @@ async def create_reservation(body: ReservationCreate, current_user=Depends(get_o
result = await db.execute(
select(KdoItem)
.where(KdoItem.id == body.kdo_item_id)
.with_for_update()
.options(
selectinload(KdoItem.reservation),
selectinload(KdoItem.kdo_list).selectinload(KdoList.owner),
@ -330,6 +331,7 @@ async def create_contribution(body: ContributionCreate, current_user=Depends(get
result = await db.execute(
select(KdoItem)
.where(KdoItem.id == body.kdo_item_id)
.with_for_update()
.options(
selectinload(KdoItem.contributions),
selectinload(KdoItem.kdo_list).selectinload(KdoList.owner),
@ -345,7 +347,7 @@ async def create_contribution(body: ContributionCreate, current_user=Depends(get
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="positive_amount")
total = sum(c.amount for c in item.contributions)
if item.price and total >= item.price:
if item.price and total + body.amount > item.price:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="already_fully_funded")
guest_email = None if current_user else body.participant_email

View file

@ -2,7 +2,8 @@ from datetime import datetime
from decimal import Decimal
from uuid import UUID
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, Field
from shared.validation import Amount, Name100, Text10000, Title255, Url2048
from shared.schemas import MessageResponse, CancelableActionOut, NotifyEmailRequest # noqa: F401
@ -10,21 +11,21 @@ from shared.schemas import MessageResponse, CancelableActionOut, NotifyEmailRequ
class KdoItemCreate(BaseModel):
list_id: UUID
name: str
description: str | None = None
price: Decimal | None = None
url: str | None = None
image_url: str | None = None
name: Title255
description: Text10000 | None = None
price: Amount | None = None
url: Url2048 | None = None
image_url: Url2048 | None = None
is_priority: bool = False
participation_mode: bool = False
class KdoItemUpdate(BaseModel):
name: str | None = None
description: str | None = None
price: Decimal | None = None
url: str | None = None
image_url: str | None = None
name: Title255 | None = None
description: Text10000 | None = None
price: Amount | None = None
url: Url2048 | None = None
image_url: Url2048 | None = None
position: int | None = None
is_priority: bool | None = None
participation_mode: bool | None = None
@ -32,11 +33,11 @@ class KdoItemUpdate(BaseModel):
class KdoItemSummary(BaseModel):
id: UUID
name: str
description: str | None
price: Decimal | None
url: str | None
image_url: str | None = None
name: Title255
description: Text10000 | None
price: Amount | None
url: Url2048 | None
image_url: Url2048 | None = None
position: int
is_priority: bool
is_reserved: bool
@ -49,11 +50,11 @@ class KdoItemSummary(BaseModel):
class KdoItemResponse(BaseModel):
id: UUID
list_id: UUID
name: str
description: str | None
price: Decimal | None
url: str | None
image_url: str | None = None
name: Title255
description: Text10000 | None
price: Amount | None
url: Url2048 | None
image_url: Url2048 | None = None
position: int
is_priority: bool
participation_mode: bool
@ -66,21 +67,21 @@ class KdoItemResponse(BaseModel):
# ── KdoList ───────────────────────────────────────────────────────────────────
class ListCreate(BaseModel):
title: str
description: str | None = None
title: Title255
description: Text10000 | None = None
event_id: UUID | None = None
class ListUpdate(BaseModel):
title: str | None = None
description: str | None = None
title: Title255 | None = None
description: Text10000 | None = None
class ListSummary(BaseModel):
"""Used in GET /api/kdo/lists (dashboard)."""
id: UUID
title: str
description: str | None
title: Title255
description: Text10000 | None
share_token: UUID
created_at: datetime
item_count: int
@ -91,8 +92,8 @@ class ListResponse(BaseModel):
"""Owner detail view."""
id: UUID
user_id: UUID
title: str
description: str | None
title: Title255
description: Text10000 | None
share_token: UUID
created_at: datetime
items: list[KdoItemSummary]
@ -103,8 +104,8 @@ class ListResponse(BaseModel):
class ListPublicResponse(BaseModel):
"""Participant view — no share_token, no reservation details."""
id: UUID
title: str
description: str | None
title: Title255
description: Text10000 | None
items: list[KdoItemSummary]
@ -112,7 +113,7 @@ class ListPublicResponse(BaseModel):
class ReservationCreate(BaseModel):
kdo_item_id: UUID
participant_name: str
participant_name: Name100
participant_email: EmailStr | None = None
reveal_to_owner: bool = False
@ -124,9 +125,9 @@ ReservationOut = CancelableActionOut
class ContributionCreate(BaseModel):
kdo_item_id: UUID
participant_name: str
participant_name: Name100
participant_email: EmailStr | None = None
amount: Decimal
amount: Amount
ContributionOut = CancelableActionOut

View file

@ -3,56 +3,57 @@ from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, Field
from shared.validation import Amount, Name100, Text10000, Title255, Unit30, Url2048
from shared.schemas import MessageResponse, CancelableActionOut, NotifyEmailRequest # noqa: F401
# ── Requests ──────────────────────────────────────────────────────────────────
class KontribListCreate(BaseModel):
title: str
description: str | None = None
title: Title255
description: Text10000 | None = None
event_id: UUID | None = None
auto_validate_suggestions: bool = False
class KontribListUpdate(BaseModel):
title: str | None = None
description: str | None = None
title: Title255 | None = None
description: Text10000 | None = None
auto_validate_suggestions: bool | None = None
class KontribItemCreate(BaseModel):
kontrib_list_id: UUID
name: str
description: str | None = None
url: str | None = None
quantity: Decimal | None = None
unit: str | None = None
name: Title255
description: Text10000 | None = None
url: Url2048 | None = None
quantity: Amount | None = None
unit: Unit30 | None = None
position: int = 0
class KontribItemUpdate(BaseModel):
name: str | None = None
description: str | None = None
url: str | None = None
quantity: Decimal | None = None
unit: str | None = None
name: Title255 | None = None
description: Text10000 | None = None
url: Url2048 | None = None
quantity: Amount | None = None
unit: Unit30 | None = None
class KontribDeclarationCreate(BaseModel):
item_id: UUID
participant_name: str
participant_name: Name100
quantity_declared: Decimal | None = None
class KontribSuggestionCreate(BaseModel):
kontrib_list_id: UUID
participant_name: str
name: str
description: str | None = None
quantity: Decimal | None = None
unit: str | None = None
participant_name: Name100
name: Title255
description: Text10000 | None = None
quantity: Amount | None = None
unit: Unit30 | None = None
class SuggestionStatusUpdate(BaseModel):
@ -63,7 +64,7 @@ class SuggestionStatusUpdate(BaseModel):
class DeclarationOut(BaseModel):
id: UUID
participant_name: str
participant_name: Name100
quantity_declared: Decimal | None
cancel_token: UUID
created_at: datetime
@ -71,11 +72,11 @@ class DeclarationOut(BaseModel):
class ItemSummary(BaseModel):
id: UUID
name: str
description: str | None
url: str | None
quantity: Decimal | None
unit: str | None
name: Title255
description: Text10000 | None
url: Url2048 | None
quantity: Amount | None
unit: Unit30 | None
position: int
quantity_declared_total: Decimal | None
declaration_count: int
@ -88,19 +89,19 @@ class ItemDetail(ItemSummary):
class SuggestionOut(BaseModel):
id: UUID
participant_name: str
name: str
description: str | None
quantity: Decimal | None
unit: str | None
participant_name: Name100
name: Title255
description: Text10000 | None
quantity: Amount | None
unit: Unit30 | None
status: str
created_at: datetime
class KontribListSummary(BaseModel):
id: UUID
title: str
description: str | None
title: Title255
description: Text10000 | None
share_token: UUID
created_at: datetime
item_count: int
@ -111,8 +112,8 @@ class KontribListSummary(BaseModel):
class KontribListResponse(BaseModel):
id: UUID
user_id: UUID
title: str
description: str | None
title: Title255
description: Text10000 | None
share_token: UUID
created_at: datetime
auto_validate_suggestions: bool
@ -123,8 +124,8 @@ class KontribListResponse(BaseModel):
class KontribListPublicResponse(BaseModel):
id: UUID
title: str
description: str | None
title: Title255
description: Text10000 | None
auto_validate_suggestions: bool
items: list[ItemSummary]

View file

@ -10,7 +10,7 @@ from api.config import settings
from models.event import EventService
from models.kount import KountExpense, KountExpenseSplit, KountGroup, KountGroupDefaultSplit, KountMember
from services.kount.schemas import (
ExpenseOut,
ExpenseOut, PublicExpenseOut, PublicMemberOut,
KountExpenseCreate, KountExpenseOut, KountExpenseUpdate,
KountGroupCreate, KountGroupPublicResponse, KountGroupResponse,
KountGroupSummary, KountGroupUpdate,
@ -18,7 +18,7 @@ from services.kount.schemas import (
SplitIn, SplitOut,
)
from services.kount.service import (
_expense_out, _member_out, compute_balances, get_owned_group,
_expense_out, _member_out, _public_expense_out, _public_member_out, compute_balances, get_owned_group,
)
from models.ownership import ResourceCoOwner
from models.user import User
@ -127,8 +127,8 @@ async def get_public_group(share_token: UUID, db: AsyncSession = Depends(get_db)
title=group.title,
description=group.description,
currency=group.currency,
members=[_member_out(m) for m in group.members],
expenses=[_expense_out(e) for e in sorted(group.expenses, key=lambda e: (e.expense_date, e.created_at), reverse=True)],
members=[_public_member_out(m) for m in group.members],
expenses=[_public_expense_out(e) for e in sorted(group.expenses, key=lambda e: (e.expense_date, e.created_at), reverse=True)],
balances=balances,
settlements=settlements,
default_splits=[SplitOut(member_id=s.member_id, share_pct=float(s.share_pct)) for s in group.default_splits],

View file

@ -1,31 +1,32 @@
from datetime import date, datetime
from typing import Annotated
from uuid import UUID
from pydantic import BaseModel, EmailStr, StringConstraints
from pydantic import BaseModel, EmailStr, Field
from shared.validation import Currency10, Name100, Text10000, Title255
from shared.schemas import MessageResponse, CancelableActionOut, NotifyEmailRequest # noqa: F401
# ── Requests owner ────────────────────────────────────────────────────────────
Currency = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=10)]
Currency = Currency10
class KountGroupCreate(BaseModel):
title: str
description: str | None = None
title: Title255
description: Text10000 | None = None
currency: Currency = "EUR"
event_id: UUID | None = None
class KountGroupUpdate(BaseModel):
title: str | None = None
description: str | None = None
title: Title255 | None = None
description: Text10000 | None = None
currency: Currency | None = None
class KountMemberCreate(BaseModel):
group_id: UUID
name: str
name: Name100
email: EmailStr | None = None
@ -34,55 +35,75 @@ class KountMemberCreate(BaseModel):
class KountExpenseCreate(BaseModel):
group_id: UUID
paid_by_member_id: UUID
title: str
amount: float
title: Title255
amount: float = Field(gt=0, le=10_000_000)
expense_date: date
class KountExpenseUpdate(BaseModel):
title: str | None = None
amount: float | None = None
title: Title255 | None = None
amount: float | None = Field(default=None, gt=0, le=10_000_000)
expense_date: date | None = None
paid_by_member_id: UUID | None = None
class SplitIn(BaseModel):
member_id: UUID
share_pct: float
share_pct: float = Field(gt=0, le=100)
class SplitOut(BaseModel):
member_id: UUID
share_pct: float
share_pct: float = Field(gt=0, le=100)
# ── Responses ─────────────────────────────────────────────────────────────────
class MemberOut(BaseModel):
id: UUID
name: str
name: Name100
email: str | None
is_owner: bool = False
is_co_owner: bool = False
created_at: datetime
class PublicMemberOut(BaseModel):
"""Identité strictement nécessaire à la vue partagée dun groupe."""
id: UUID
name: Name100
created_at: datetime
class ExpenseOut(BaseModel):
id: UUID
paid_by_member_id: UUID | None
paid_by_name: str | None
title: str
paid_by_name: Name100 | None
title: Title255
amount: float
expense_date: date
participant_name: str | None
participant_name: Name100 | None
cancel_token: UUID
created_at: datetime
splits: list[SplitOut] = []
class PublicExpenseOut(BaseModel):
"""Dépense visible via un lien partagé, sans jeton de gestion."""
id: UUID
paid_by_member_id: UUID | None
paid_by_name: Name100 | None
title: Title255
amount: float
expense_date: date
participant_name: Name100 | None
created_at: datetime
splits: list[SplitOut] = []
class BalanceEntry(BaseModel):
member_id: UUID
member_name: str
member_name: Name100
paid: float
owed: float
net: float # positif = on lui doit ; négatif = il doit
@ -90,16 +111,16 @@ class BalanceEntry(BaseModel):
class Settlement(BaseModel):
from_member_id: UUID
from_member_name: str
from_member_name: Name100
to_member_id: UUID
to_member_name: str
to_member_name: Name100
amount: float
class KountGroupSummary(BaseModel):
id: UUID
title: str
description: str | None
title: Title255
description: Text10000 | None
currency: str
share_token: UUID
created_at: datetime
@ -111,8 +132,8 @@ class KountGroupSummary(BaseModel):
class KountGroupResponse(BaseModel):
id: UUID
user_id: UUID
title: str
description: str | None
title: Title255
description: Text10000 | None
currency: str
share_token: UUID
created_at: datetime
@ -126,11 +147,11 @@ class KountGroupResponse(BaseModel):
class KountGroupPublicResponse(BaseModel):
id: UUID
title: str
description: str | None
title: Title255
description: Text10000 | None
currency: str
members: list[MemberOut]
expenses: list[ExpenseOut]
members: list[PublicMemberOut]
expenses: list[PublicExpenseOut]
balances: list[BalanceEntry]
settlements: list[Settlement]
default_splits: list[SplitOut] = []

View file

@ -5,7 +5,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models.kount import KountExpense, KountExpenseSplit, KountGroup, KountGroupDefaultSplit, KountMember
from services.kount.schemas import BalanceEntry, ExpenseOut, MemberOut, Settlement, SplitOut
from services.kount.schemas import (
BalanceEntry, ExpenseOut, MemberOut, PublicExpenseOut, PublicMemberOut, Settlement, SplitOut,
)
from shared.auth.co_owner import get_owned_resource
@ -38,6 +40,26 @@ def _expense_out(expense: KountExpense) -> ExpenseOut:
)
def _public_expense_out(expense: KountExpense) -> PublicExpenseOut:
"""Sérialise une dépense pour le lien partagé sans capacité dannulation."""
return PublicExpenseOut(
id=expense.id,
paid_by_member_id=expense.paid_by_member_id,
paid_by_name=expense.paid_by_member.name if expense.paid_by_member else None,
title=expense.title,
amount=expense.amount,
expense_date=expense.expense_date,
participant_name=expense.participant_name,
created_at=expense.created_at,
splits=[SplitOut(member_id=s.member_id, share_pct=float(s.share_pct)) for s in expense.splits],
)
def _public_member_out(member: KountMember) -> PublicMemberOut:
"""Sérialise un membre pour le lien partagé sans son adresse email."""
return PublicMemberOut(id=member.id, name=member.name, created_at=member.created_at)
def _member_out(member: KountMember, owner_email: str | None = None, co_owner_emails: set[str] | None = None) -> MemberOut:
email = member.email
return MemberOut(

View file

@ -1,44 +1,45 @@
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, Field
from shared.validation import Name100, Text10000, Title255, Title500
from shared.schemas import CancelableActionOut, MessageResponse, NotifyEmailRequest # noqa: F401
# ── Requests ──────────────────────────────────────────────────────────────────
class KwizPollCreate(BaseModel):
title: str
description: str | None = None
title: Title500
description: Text10000 | None = None
event_id: UUID | None = None
options: list[str] # 2 à 10 labels
options: list[Title255] = Field(min_length=2, max_length=10) # 2 à 10 labels
allow_multiple: bool = False
results_public: bool = False
class KwizPollConfigUpdate(BaseModel):
"""Rebuild complet des options — interdit si votes existants."""
title: str
description: str | None = None
options: list[str]
title: Title500
description: Text10000 | None = None
options: list[Title255] = Field(min_length=2, max_length=10)
allow_multiple: bool = False
class KwizPollUpdate(BaseModel):
title: str | None = None
description: str | None = None
title: Title500 | None = None
description: Text10000 | None = None
results_public: bool | None = None
is_closed: bool | None = None
class VoteSubmit(BaseModel):
name: str
name: Name100
email: EmailStr | None = None
option_ids: list[UUID] # 1 pour single, 1..N pour multiple
option_ids: list[UUID] = Field(min_length=1, max_length=10) # 1 pour single, 1..N pour multiple
class VoteUpdate(BaseModel):
option_ids: list[UUID]
option_ids: list[UUID] = Field(min_length=1, max_length=10)
# ── Responses ─────────────────────────────────────────────────────────────────
@ -53,14 +54,14 @@ class OptionOut(BaseModel):
class ParticipantVoteOut(BaseModel):
"""Votes nominatifs d'un participant (owner only ou results_public)."""
participant_id: UUID
name: str
option_ids: list[UUID]
name: Name100
option_ids: list[UUID] = Field(min_length=1, max_length=10)
class KwizPollSummary(BaseModel):
id: UUID
title: str
description: str | None
title: Title500
description: Text10000 | None
share_token: UUID
allow_multiple: bool
results_public: bool
@ -75,8 +76,8 @@ class KwizPollSummary(BaseModel):
class KwizPollResponse(BaseModel):
id: UUID
user_id: UUID
title: str
description: str | None
title: Title500
description: Text10000 | None
share_token: UUID
allow_multiple: bool
results_public: bool
@ -91,14 +92,14 @@ class KwizPollResponse(BaseModel):
class ParticipantPublicOut(BaseModel):
id: UUID
name: str
name: Name100
created_at: datetime
class KwizPollPublicResponse(BaseModel):
id: UUID
title: str
description: str | None
title: Title500
description: Text10000 | None
share_token: UUID
allow_multiple: bool
results_public: bool

View file

@ -2,18 +2,19 @@ from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, EmailStr
from shared.schemas import MessageResponse # noqa: F401
from shared.validation import ResourceType50
class InviteCoOwnerRequest(BaseModel):
resource_type: str
resource_type: ResourceType50
resource_id: UUID
email: EmailStr
class CoOwnerResponse(BaseModel):
model_config = {"from_attributes": True}
user_id: UUID
email: str
created_at: datetime
@ -24,5 +25,3 @@ class AcceptInviteResponse(BaseModel):
token_type: str = "bearer"
resource_type: str
resource_id: UUID

View file

@ -7,6 +7,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.auth.jwt import decode_access_token
from shared.auth.premium_check import is_premium_active
from shared.database.connection import get_db
bearer_scheme = HTTPBearer()
@ -49,6 +50,6 @@ async def get_optional_user(
async def require_premium(current_user=Depends(get_current_user)):
if not current_user.is_premium:
if not is_premium_active(current_user):
raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail="Abonnement premium requis")
return current_user

View file

@ -1,6 +1,26 @@
import re
import bcrypt
def validate_password_strength(password: str) -> str:
"""Applique la politique de mot de passe commune aux parcours authentifiés."""
errors = []
if len(password) < 8:
errors.append("8 caractères minimum")
if not re.search(r"[A-Z]", password):
errors.append("une majuscule")
if not re.search(r"[a-z]", password):
errors.append("une minuscule")
if not re.search(r"\d", password):
errors.append("un chiffre")
if not re.search(r"[^A-Za-z0-9]", password):
errors.append("un caractère spécial")
if errors:
raise ValueError("Le mot de passe doit contenir : " + ", ".join(errors))
return password
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()

View file

@ -51,6 +51,16 @@ async def enforce_premium_limit(user, db: AsyncSession) -> None:
"""
if is_premium_active(user):
return
# Sérialise les créations facturées du même compte : le comptage et
# linsertion restent dans la même transaction jusquau commit du routeur.
from models.user import User
await db.execute(select(User.id).where(User.id == user.id).with_for_update())
# La session passée par FastAPI conserve ce verrou jusquau commit.
# Ne pas déplacer ce contrôle après db.add().
total = 0
for q in _counted_service_count_queries(user.id):
total += (await db.execute(q)).scalar() or 0

View file

@ -77,3 +77,46 @@ def construct_event(payload: bytes, sig_header: str):
async def retrieve_subscription(subscription_id: str):
"""Récupère un objet Subscription (utilisé par certains webhooks)."""
return await asyncio.to_thread(stripe.Subscription.retrieve, subscription_id)
_ACTIVE_SUBSCRIPTION_STATUSES = {"active", "trialing", "past_due", "unpaid", "paused"}
def _field(value, name: str, default=None):
"""Lit indifféremment un objet Stripe ou un dictionnaire de test."""
if isinstance(value, dict):
return value.get(name, default)
return getattr(value, name, default)
async def cancel_active_subscriptions(
customer_id: str | None,
known_subscription_id: str | None = None,
) -> int:
"""Annule immédiatement les abonnements facturables dun client Stripe.
La liste Stripe couvre le cas le webhook na pas encore persisté
``stripe_subscription_id``. Annuler une ressource déjà annulée est évité, ce
qui rend la suppression de compte relançable sans double effet.
"""
subscription_ids: set[str] = set()
if known_subscription_id:
subscription_ids.add(known_subscription_id)
if customer_id:
subscriptions = await asyncio.to_thread(
stripe.Subscription.list, customer=customer_id, status="all", limit=100
)
for subscription in _field(subscriptions, "data", []):
subscription_id = _field(subscription, "id")
subscription_status = _field(subscription, "status")
if subscription_id and subscription_status in _ACTIVE_SUBSCRIPTION_STATUSES:
subscription_ids.add(subscription_id)
cancelled = 0
for subscription_id in subscription_ids:
subscription = await asyncio.to_thread(stripe.Subscription.retrieve, subscription_id)
if _field(subscription, "status") in _ACTIVE_SUBSCRIPTION_STATUSES:
await asyncio.to_thread(stripe.Subscription.cancel, subscription_id)
cancelled += 1
return cancelled

View file

@ -1,6 +1,7 @@
import logging
from collections import defaultdict
from datetime import datetime, timedelta, timezone
import time
from collections import OrderedDict, deque
from math import ceil
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
@ -10,69 +11,92 @@ logger = logging.getLogger(__name__)
# (max_requests, window_seconds)
_LIMITS: dict[str, tuple[int, int]] = {
"/api/auth/magic-link": (5, 3600), # 5/heure — anti-spam
"/api/auth/register": (10, 3600), # 10/heure
"/api/auth/login": (20, 3600), # 20/heure
"/api/auth/verify-email": (20, 3600), # 20/heure — anti-brute-force code 6 chiffres
"/api/auth/resend-verification": (5, 3600), # 5/heure — anti-spam email
"/api/shared/ownership/invite": (10, 3600), # 10/heure — anti-spam invitations
"/api/shared/comments": (30, 60), # 30/min
"/api/kdo/reservations/": (10, 3600), # 10/heure — notify envoie un email
"/api/kdo/contributions/": (10, 3600), # 10/heure
"/api/kontrib/declarations/": (10, 3600), # 10/heure
"/api/kount/expenses": (30, 3600), # 30/heure — dépenses + splits + cancel
"/api/kount/members": (20, 3600), # 20/heure — ajout membres
"/api/kwiz/public/": (30, 3600), # 30/heure — votes
"/api/kwiz/participants/": (10, 3600), # 10/heure — notify envoie un email
"_default": (120, 60), # 120/min global
"/api/auth/magic-link": (5, 3600),
"/api/auth/register": (10, 3600),
"/api/auth/login": (20, 3600),
"/api/auth/verify-email": (20, 3600),
"/api/auth/resend-verification": (5, 3600),
"/api/shared/ownership/invite": (10, 3600),
"/api/shared/comments": (30, 60),
"/api/kdo/reservations/": (10, 3600),
"/api/kdo/contributions/": (10, 3600),
"/api/kontrib/declarations/": (10, 3600),
"/api/kount/expenses": (30, 3600),
"/api/kount/members": (20, 3600),
"/api/kwiz/public/": (30, 3600),
"/api/kwiz/participants/": (10, 3600),
"_default": (120, 60),
}
_MAX_BUCKETS = 10_000
_TRUSTED_PROXY_IPS = {"127.0.0.1", "::1"}
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Rate limiter en mémoire par IP. Single-server, reset au redémarrage."""
"""Limiteur local borné, par IP et route logique.
def __init__(self, app):
Le déploiement actuel comporte un seul processus API. Une évolution multi-réplicas
devra remplacer ce stockage local par Redis, sans changer les clés de route.
"""
def __init__(self, app, max_buckets: int = _MAX_BUCKETS):
super().__init__(app)
self._buckets: dict[str, list[datetime]] = defaultdict(list)
self._buckets: OrderedDict[str, deque[float]] = OrderedDict()
self._max_buckets = max_buckets
def _client_ip(self, request: Request) -> str:
# X-Real-IP est positionné exclusivement par Caddy ({remote_host}) — fiable
peer_ip = request.client.host if request.client else "unknown"
# Seul Caddy local est autorisé à transmettre l'adresse du visiteur.
real_ip = request.headers.get("X-Real-IP")
if real_ip:
if real_ip and peer_ip in _TRUSTED_PROXY_IPS:
return real_ip.strip()
# Fallback : connexion directe hors Caddy (tests locaux)
return request.client.host if request.client else "unknown"
return peer_ip
def _limit_for(self, path: str) -> tuple[int, int]:
def _limit_for(self, path: str) -> tuple[str, tuple[int, int]]:
for prefix, limit in _LIMITS.items():
if prefix != "_default" and path.startswith(prefix):
return limit
return _LIMITS["_default"]
return prefix, limit
return "_default", _LIMITS["_default"]
def _is_allowed(self, key: str, max_req: int, window: int) -> bool:
now = datetime.now(timezone.utc)
cutoff = now - timedelta(seconds=window)
self._buckets[key] = [t for t in self._buckets[key] if t > cutoff]
if len(self._buckets[key]) >= max_req:
return False
self._buckets[key].append(now)
return True
def _check_limit(self, key: str, max_requests: int, window: int) -> tuple[bool, int]:
now = time.monotonic()
bucket = self._buckets.get(key)
if bucket is None:
if len(self._buckets) >= self._max_buckets:
self._buckets.popitem(last=False)
bucket = deque()
self._buckets[key] = bucket
else:
self._buckets.move_to_end(key)
cutoff = now - window
while bucket and bucket[0] <= cutoff:
bucket.popleft()
if len(bucket) >= max_requests:
return False, max(1, ceil(bucket[0] + window - now))
bucket.append(now)
return True, 0
async def dispatch(self, request: Request, call_next):
path = request.url.path
# Webhook Stripe : serveur→serveur (signature vérifiée), peut arriver en
# rafales depuis quelques IPs Stripe — jamais rate-limité.
# Webhook signé, reçu en rafales depuis Stripe : sa signature est le contrôle.
if path == "/api/billing/webhook":
return await call_next(request)
ip = self._client_ip(request)
max_req, window = self._limit_for(path)
route, (route_max, route_window) = self._limit_for(path)
if not self._is_allowed(f"{ip}:{path}", max_req, window):
logger.warning("Rate limit dépassé — IP=%s path=%s", ip, path)
# Une limite spécifique protège l'action sensible ; la limite globale borne
# aussi tout le reste, sans créer une entrée par UUID ou jeton d'URL.
allowed, retry_after = self._check_limit(f"{ip}:global", *_LIMITS["_default"])
if allowed and route != "_default":
allowed, retry_after = self._check_limit(f"{ip}:route:{route}", route_max, route_window)
if not allowed:
logger.warning("Rate limit dépassé — IP=%s route=%s", ip, route)
return Response(
content='{"detail":"Trop de requêtes. Réessayez dans un moment."}',
status_code=429,
media_type="application/json",
headers={"Retry-After": str(retry_after)},
)
return await call_next(request)

21
shared/validation.py Normal file
View file

@ -0,0 +1,21 @@
"""Contraintes Pydantic partagées pour les entrées HTTP.
Elles reflètent les limites SQL des colonnes `String` ; les champs `Text` sont
bornés à 10 000 caractères pour éviter les charges disproportionnées.
"""
from decimal import Decimal
from typing import Annotated
from pydantic import Field, StringConstraints
Title255 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=255)]
Title500 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=500)]
Name100 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=100)]
Text10000 = Annotated[str, StringConstraints(strip_whitespace=True, max_length=10_000)]
Url2048 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=2048)]
Unit30 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=30)]
EventType50 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)]
ResourceType50 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=50)]
Currency10 = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=10)]
Amount = Annotated[Decimal, Field(gt=0, max_digits=10, decimal_places=2)]
SharePercent = Annotated[Decimal, Field(gt=0, le=100, max_digits=5, decimal_places=2)]

4
tests/conftest.py Normal file
View file

@ -0,0 +1,4 @@
import os
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test")
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-security-regressions")

View file

@ -0,0 +1,115 @@
from datetime import date, datetime, timezone
from decimal import Decimal
from uuid import uuid4
import pytest
from fastapi import HTTPException
from sqlalchemy import select, text
from api.routers import auth
from models.kount import KountExpense, KountGroup, KountMember
from models.user import User
from services.kount.router import get_public_group
from services.kount.service import get_owned_group
from shared.database.connection import AsyncSessionLocal
@pytest.mark.asyncio
async def test_auth_rights_and_public_kount_route_against_postgres(monkeypatch):
async def no_email(*_args, **_kwargs):
return None
monkeypatch.setattr(auth.email_sender, "send_email_verification", no_email)
email = f"quality-{uuid4().hex}@example.com"
async with AsyncSessionLocal() as db:
schema_check = await db.execute(text("SELECT 1"))
assert schema_check.scalar_one() == 1
await auth.register(
auth.RegisterRequest(email=email, password="Correct1!"),
db,
)
user = (await db.execute(select(User).where(User.email == email))).scalar_one()
assert user.password_hash
assert user.email_verified is False
group = KountGroup(
id=uuid4(), user_id=user.id, title="Qualité", description=None,
currency="EUR", share_token=uuid4(), created_at=datetime.now(timezone.utc),
)
member = KountMember(
id=uuid4(), group_id=group.id, name="Alice", email=email,
created_at=datetime.now(timezone.utc),
)
expense = KountExpense(
id=uuid4(), group_id=group.id, paid_by_member_id=member.id,
title="Courses", amount=Decimal("12.50"), expense_date=date.today(),
participant_name=None, cancel_token=uuid4(), created_at=datetime.now(timezone.utc),
)
group.members = [member]
group.expenses = [expense]
expense.paid_by_member = member
db.add(group)
await db.commit()
public_response = await get_public_group(group.share_token, db)
public_payload = public_response.model_dump(mode="json")
assert "email" not in str(public_payload).lower()
assert "cancel_token" not in str(public_payload).lower()
stranger = User(
id=uuid4(), email=f"stranger-{uuid4().hex}@example.com", lang="fr",
email_verified=True, is_premium=False, cancel_at_period_end=False,
created_at=datetime.now(timezone.utc),
)
db.add(stranger)
await db.commit()
with pytest.raises(HTTPException) as exc:
await get_owned_group(group.id, stranger.id, db)
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_aaa_freemium_limit_is_atomic_under_concurrent_creations():
"""Deux créations simultanées depuis 2/3 services ne peuvent pas aboutir toutes deux."""
import asyncio
from services.kdo.router import create_list as create_kdo_list
from services.kdo.schemas import ListCreate
from services.kontrib.router import create_list as create_kontrib_list
from services.kontrib.schemas import KontribListCreate
from api.config import settings
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
engine = create_async_engine(settings.database_url)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
email = f"atomic-{uuid4().hex}@example.com"
async with session_factory() as db:
user = User(
id=uuid4(), email=email, lang="fr", email_verified=True,
is_premium=False, cancel_at_period_end=False, created_at=datetime.now(timezone.utc),
)
db.add(user)
db.add_all([
KountGroup(id=uuid4(), user_id=user.id, title="Un", currency="EUR", share_token=uuid4(), created_at=datetime.now(timezone.utc)),
KountGroup(id=uuid4(), user_id=user.id, title="Deux", currency="EUR", share_token=uuid4(), created_at=datetime.now(timezone.utc)),
])
await db.commit()
user_id = user.id
async def create_kdo():
async with session_factory() as db:
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one()
return await create_kdo_list(ListCreate(title="Trois"), user, db)
async def create_kontrib():
async with session_factory() as db:
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one()
return await create_kontrib_list(KontribListCreate(title="Quatre"), user, db)
results = await asyncio.gather(create_kdo(), create_kontrib(), return_exceptions=True)
assert sum(not isinstance(result, Exception) for result in results) == 1
errors = [result for result in results if isinstance(result, HTTPException)]
assert len(errors) == 1
assert errors[0].status_code == 403
await engine.dispose()

166
tests/test_auth_security.py Normal file
View file

@ -0,0 +1,166 @@
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from fastapi import HTTPException
from api.routers import auth
from models.user import User
from services.ownership import service as ownership_service
class _Result:
def __init__(self, value):
self.value = value
def scalar_one_or_none(self):
return self.value
class _Db:
def __init__(self, user):
self.user = user
self.commits = 0
self.added = []
async def execute(self, _query):
return _Result(self.user)
async def commit(self):
self.commits += 1
def add(self, value):
self.added.append(value)
if isinstance(value, User):
self.user = value
async def refresh(self, _value):
return None
def _user(*, verified: bool, password_hash: str | None = None) -> User:
return User(
id=uuid4(), email="member@example.com", lang="fr", email_verified=verified,
password_hash=password_hash, is_premium=False, cancel_at_period_end=False, created_at=datetime.now(timezone.utc),
)
@pytest.mark.asyncio
async def test_verified_account_never_gets_a_token_from_verify_email():
db = _Db(_user(verified=True))
with pytest.raises(HTTPException) as exc:
await auth.verify_email(auth.VerifyEmailRequest(email="member@example.com", code="123456"), db)
assert exc.value.status_code == 400
assert exc.value.detail == "email_already_verified"
assert db.commits == 0
@pytest.mark.asyncio
@pytest.mark.parametrize("reason", ["wrong", "expired", "reused"])
async def test_invalid_verification_codes_never_issue_a_token(monkeypatch, reason):
db = _Db(_user(verified=False))
async def invalid_code(*_args):
return False
monkeypatch.setattr(auth, "verify_code", invalid_code)
with pytest.raises(HTTPException) as exc:
await auth.verify_email(auth.VerifyEmailRequest(email="member@example.com", code=reason), db)
assert exc.value.status_code == 400
assert exc.value.detail == "invalid_or_expired_code"
assert db.user.email_verified is False
@pytest.mark.asyncio
async def test_valid_verification_code_verifies_and_returns_a_token(monkeypatch):
db = _Db(_user(verified=False))
async def valid_code(*_args):
return True
monkeypatch.setattr(auth, "verify_code", valid_code)
response = await auth.verify_email(auth.VerifyEmailRequest(email="member@example.com", code="123456"), db)
assert response.access_token
assert db.user.email_verified is True
assert db.commits == 1
@pytest.mark.asyncio
async def test_register_cannot_claim_verified_passwordless_account():
user = _user(verified=True, password_hash=None)
db = _Db(user)
request = auth.RegisterRequest(email="member@example.com", password="Correct1!")
with pytest.raises(HTTPException) as exc:
await auth.register(request, db)
assert exc.value.status_code == 409
assert user.password_hash is None
assert db.commits == 0
@pytest.mark.asyncio
async def test_set_password_requires_authenticated_user_and_shared_policy():
user = _user(verified=True, password_hash=None)
db = _Db(user)
response = await auth.set_password(auth.SetPasswordRequest(password="Correct1!"), user, db)
assert response.message
assert user.password_hash
assert db.commits == 1
@pytest.mark.asyncio
async def test_magic_link_account_cannot_be_claimed_by_registration(monkeypatch):
user = _user(verified=True, password_hash=None)
db = _Db(user)
async def valid_magic_link(_token, _db):
return user.email
monkeypatch.setattr(auth, "verify_magic_token", valid_magic_link)
response = await auth.verify_magic_link("magic-token", db=db)
assert response.user.has_password is False
with pytest.raises(HTTPException) as exc:
await auth.register(auth.RegisterRequest(email=user.email, password="Correct1!"), db)
assert exc.value.status_code == 409
@pytest.mark.asyncio
async def test_invitation_passwordless_account_cannot_be_claimed_by_registration():
db = _Db(None)
invited_user, is_new = await ownership_service.get_or_create_user("invitee@example.com", db)
assert is_new is True
assert invited_user.email_verified is True
assert invited_user.password_hash is None
with pytest.raises(HTTPException) as exc:
await auth.register(auth.RegisterRequest(email=invited_user.email, password="Correct1!"), db)
assert exc.value.status_code == 409
def test_set_password_reuses_register_password_policy():
with pytest.raises(ValueError):
auth.SetPasswordRequest(password="onlyeight")
@pytest.mark.asyncio
async def test_magic_link_preserves_a_safe_internal_next(monkeypatch):
captured = {}
async def create_token(_email, _db):
return "magic-token"
async def send_magic(_email, magic_url, lang="fr"):
captured["url"] = magic_url
captured["lang"] = lang
async def deliver(coro, _logger=None):
await coro
monkeypatch.setattr(auth, "create_magic_token", create_token)
monkeypatch.setattr(auth.email_sender, "send_magic_link", send_magic)
monkeypatch.setattr(auth, "safe_send", deliver)
await auth.request_magic_link(
auth.MagicLinkRequest(email="member@example.com", next="/hub/new?source=homepage"),
_Db(_user(verified=True)),
)
assert "next=%2Fhub%2Fnew%3Fsource%3Dhomepage" in captured["url"]
with pytest.raises(ValueError):
auth.MagicLinkRequest(email="member@example.com", next="//evil.example/path")

View file

@ -0,0 +1,120 @@
import pytest
from fastapi import HTTPException
from api.routers import auth, billing
from datetime import datetime, timezone
from uuid import uuid4
from starlette.requests import Request
from models.user import User
from shared.payments import stripe_client
@pytest.mark.asyncio
async def test_stripe_cancellation_is_idempotent_and_covers_customer_subscriptions(monkeypatch):
calls = []
class Subscription:
@staticmethod
def list(**_kwargs):
return {"data": [{"id": "sub_active", "status": "active"}, {"id": "sub_old", "status": "canceled"}]}
@staticmethod
def retrieve(subscription_id):
return {"id": subscription_id, "status": "active" if subscription_id == "sub_active" else "canceled"}
@staticmethod
def cancel(subscription_id):
calls.append(subscription_id)
async def to_thread(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(stripe_client.stripe, "Subscription", Subscription)
monkeypatch.setattr(stripe_client.asyncio, "to_thread", to_thread)
cancelled = await stripe_client.cancel_active_subscriptions("cus_123", "sub_old")
assert cancelled == 1
assert calls == ["sub_active"]
class _Db:
def __init__(self):
self.deleted, self.commits = [], 0
async def delete(self, user):
self.deleted.append(user)
async def commit(self):
self.commits += 1
class _User:
id = "user-1"
stripe_customer_id = "cus_123"
stripe_subscription_id = "sub_123"
@pytest.mark.asyncio
async def test_account_is_deleted_after_stripe_cancellation(monkeypatch):
calls = []
async def cancel(customer_id, known_subscription_id):
calls.append((customer_id, known_subscription_id))
return 1
monkeypatch.setattr(auth.stripe_client, "cancel_active_subscriptions", cancel)
db = _Db()
user = _User()
response = await auth.delete_account(auth.DeleteAccountRequest(confirmation="SUPPRIMER"), user, db)
assert response.message == "Compte et abonnement supprimés"
assert db.deleted == [user]
assert db.commits == 1
assert calls == [("cus_123", "sub_123")]
class _WebhookResult:
def __init__(self, user):
self.user = user
def scalar_one_or_none(self):
return self.user
class _WebhookDb:
def __init__(self, user):
self.user = user
self.commits = 0
async def execute(self, _query):
return _WebhookResult(self.user)
async def commit(self):
self.commits += 1
def _request():
async def receive():
return {"type": "http.request", "body": b"{}"}
return Request({"type": "http", "method": "POST", "path": "/webhook", "headers": []}, receive)
@pytest.mark.asyncio
async def test_subscription_deleted_webhook_removes_premium_access(monkeypatch):
user = User(id=uuid4(), email="stripe.com", lang="fr", email_verified=True, is_premium=True, cancel_at_period_end=True, created_at=datetime.now(timezone.utc), stripe_customer_id="cus_123")
db = _WebhookDb(user)
monkeypatch.setattr(billing.stripe_client, "construct_event", lambda *_args: {"type": "customer.subscription.deleted", "data": {"object": {"customer": "cus_123"}}})
result = await billing.webhook(_request(), db)
assert result == {"received": True}
assert user.is_premium is False
assert user.subscription_status == "canceled"
assert user.cancel_at_period_end is False
assert db.commits == 1
@pytest.mark.asyncio
async def test_account_is_not_deleted_when_stripe_cancellation_fails(monkeypatch):
async def fail(*_args, **_kwargs):
raise RuntimeError("Stripe unavailable")
monkeypatch.setattr(auth.stripe_client, "cancel_active_subscriptions", fail)
db = _Db()
with pytest.raises(HTTPException) as exc:
await auth.delete_account(auth.DeleteAccountRequest(confirmation="SUPPRIMER"), _User(), db)
assert exc.value.status_code == 502
assert db.deleted == []
assert db.commits == 0

View file

@ -0,0 +1,11 @@
from pathlib import Path
def test_docker_context_excludes_secrets_and_bulk_dependencies():
ignored = set(Path(".dockerignore").read_text().splitlines())
assert {".env", ".git", "frontend/node_modules/", "__pycache__/"}.issubset(ignored)
def test_frontend_docker_context_excludes_node_modules():
ignored = set(Path("frontend/.dockerignore").read_text().splitlines())
assert "node_modules/" in ignored

47
tests/test_g1_security.py Normal file
View file

@ -0,0 +1,47 @@
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
from api.main import app
from api.routers.scrape import ParseRequest, _MAX_HTML_BYTES, _ensure_payload_size
from shared.rate_limit.middleware import RateLimitMiddleware
def test_rate_limit_uses_normalized_routes_and_bounded_storage():
limiter = RateLimitMiddleware(FastAPI(), max_buckets=2)
route, _ = limiter._limit_for("/api/kount/expenses/8f86e6fa-82b0-4f4f-a5da-6d734a7a4e82")
assert route == "/api/kount/expenses"
assert limiter._limit_for("/api/hub/any-id")[0] == "_default"
assert limiter._check_limit("one", 2, 60) == (True, 0)
assert limiter._check_limit("two", 2, 60) == (True, 0)
assert limiter._check_limit("three", 2, 60) == (True, 0)
assert list(limiter._buckets) == ["two", "three"]
def test_rate_limit_returns_retry_after_when_bucket_is_full():
limiter = RateLimitMiddleware(FastAPI())
assert limiter._check_limit("visitor:global", 1, 60) == (True, 0)
allowed, retry_after = limiter._check_limit("visitor:global", 1, 60)
assert allowed is False
assert 1 <= retry_after <= 60
def test_scraper_rejects_oversized_html_before_parsing():
assert _ensure_payload_size("x" * (_MAX_HTML_BYTES + 1)) is None
try:
ParseRequest(url="https://example.org", html="x" * (_MAX_HTML_BYTES + 1))
except ValidationError as exc:
assert "string_too_long" in str(exc)
else:
raise AssertionError("Le modèle doit refuser le HTML trop long")
def test_api_security_headers_are_set_on_health():
with TestClient(app) as client:
response = client.get("/health")
assert response.status_code == 200
assert response.headers["x-content-type-options"] == "nosniff"
assert response.headers["x-frame-options"] == "DENY"
assert response.headers["referrer-policy"] == "strict-origin-when-cross-origin"

View file

@ -0,0 +1,31 @@
from datetime import date
from uuid import uuid4
import pytest
from pydantic import ValidationError
from services.comments.schemas import CommentCreate
from services.hub.schemas import EventCreate
from services.kdo.schemas import ContributionCreate, KdoItemCreate, ListCreate
from services.kontrib.schemas import KontribItemCreate
from services.kount.schemas import KountExpenseCreate, KountGroupCreate
from services.kwiz.schemas import KwizPollCreate
@pytest.mark.parametrize(
("factory", "payload"),
[
(ListCreate, {"title": "x" * 256}),
(KdoItemCreate, {"list_id": uuid4(), "name": "x" * 256}),
(ContributionCreate, {"kdo_item_id": uuid4(), "participant_name": "A", "amount": "0"}),
(KontribItemCreate, {"kontrib_list_id": uuid4(), "name": "A", "url": "x" * 2049}),
(KountGroupCreate, {"title": "A", "currency": "x" * 11}),
(KountExpenseCreate, {"group_id": uuid4(), "paid_by_member_id": uuid4(), "title": "A", "amount": 0, "expense_date": date.today()}),
(EventCreate, {"title": "A", "event_type": "x" * 51}),
(KwizPollCreate, {"title": "A", "options": ["one"]}),
(CommentCreate, {"author_name": " ", "content": "hello"}),
],
)
def test_invalid_api_input_is_rejected_before_database(factory, payload):
with pytest.raises(ValidationError):
factory(**payload)

View file

@ -0,0 +1,58 @@
from datetime import date, datetime, timezone
from decimal import Decimal
from uuid import uuid4
from fastapi import FastAPI
from fastapi.testclient import TestClient
from shared.database.connection import get_db
from services.kount.router import router as kount_router
from models.kount import KountExpense, KountMember
from services.kount.service import _expense_out, _public_expense_out, _public_member_out
def test_public_kount_payload_never_contains_email_or_cancel_token():
member = KountMember(id=uuid4(), group_id=uuid4(), name="Alice", email="alice@example.com", created_at=datetime.now(timezone.utc))
expense = KountExpense(id=uuid4(), group_id=member.group_id, paid_by_member_id=member.id, paid_by_member=member, title="Courses", amount=Decimal("12.50"), expense_date=date.today(), participant_name=None, cancel_token=uuid4(), created_at=datetime.now(timezone.utc), splits=[])
public_payload = {"members": [_public_member_out(member).model_dump(mode="json")], "expenses": [_public_expense_out(expense).model_dump(mode="json")]}
private_expense = _expense_out(expense)
serialized = str(public_payload).lower()
assert "alice@example.com" not in serialized
assert "cancel_token" not in serialized
assert private_expense.cancel_token == expense.cancel_token
class _Result:
def __init__(self, group):
self.group = group
def scalar_one_or_none(self):
return self.group
class _Db:
def __init__(self, group):
self.group = group
async def execute(self, _query):
return _Result(self.group)
def test_public_kount_http_contract_hides_private_fields():
member = KountMember(id=uuid4(), group_id=uuid4(), name="Alice", email="alice.com", created_at=datetime.now(timezone.utc))
expense = KountExpense(id=uuid4(), group_id=member.group_id, paid_by_member_id=member.id, paid_by_member=member, title="Courses", amount=Decimal("12.50"), expense_date=date.today(), participant_name=None, cancel_token=uuid4(), created_at=datetime.now(timezone.utc), splits=[])
from models.kount import KountGroup
group = KountGroup(id=member.group_id, user_id=uuid4(), title="Week-end", description=None, currency="EUR", share_token=uuid4(), created_at=datetime.now(timezone.utc), members=[member], expenses=[expense], default_splits=[])
db = _Db(group)
app = FastAPI()
app.include_router(kount_router, prefix="/api/kount")
async def override_db():
yield db
app.dependency_overrides[get_db] = override_db
response = TestClient(app).get(f"/api/kount/groups/shared/{group.share_token}")
assert response.status_code == 200
payload = response.json()
assert "email" not in str(payload).lower()
assert "cancel_token" not in str(payload).lower()

View file

@ -0,0 +1,56 @@
from datetime import datetime, timedelta, timezone
from uuid import uuid4
import pytest
from fastapi import HTTPException
from api.routers.auth import _user_response
from api.routers.billing import CheckoutRequest, create_checkout
from models.user import User
from shared.auth.dependencies import require_premium
from shared.auth import premium_check
def make_user(*, premium_until: datetime | None, is_premium: bool = True) -> User:
return User(
id=uuid4(),
email=f"premium-{uuid4().hex}@example.com",
lang="fr",
email_verified=True,
is_premium=is_premium,
premium_until=premium_until,
cancel_at_period_end=False,
created_at=datetime.now(timezone.utc),
)
def test_expired_premium_is_false_in_api_user_response():
user = make_user(premium_until=datetime.now(timezone.utc) - timedelta(seconds=1))
assert premium_check.is_premium_active(user) is False
assert _user_response(user).is_premium is False
@pytest.mark.asyncio
async def test_expired_premium_cannot_access_premium_route():
user = make_user(premium_until=datetime.now(timezone.utc) - timedelta(seconds=1))
with pytest.raises(HTTPException) as error:
await require_premium(current_user=user)
assert error.value.status_code == 402
@pytest.mark.asyncio
async def test_expired_premium_can_start_a_new_checkout(monkeypatch):
user = make_user(premium_until=datetime.now(timezone.utc) - timedelta(seconds=1))
async def create_session(*_args, **_kwargs):
return "checkout_secret"
monkeypatch.setattr("api.routers.billing.stripe_client.PRICE_BY_PLAN", {"monthly": "price_test"})
monkeypatch.setattr("api.routers.billing.stripe_client.create_checkout_session", create_session)
response = await create_checkout(CheckoutRequest(plan="monthly"), current_user=user, db=object())
assert response.client_secret == "checkout_secret"