feat(admin): G15/ADM-00 — billing fix, droit effectif, audit log, rate limit

Débloque le chantier d'isolation admin (G15). ADM-R2 : la suppression admin
annule désormais l'abonnement Stripe avant d'effacer le compte, sans bloquer
sur un échec fournisseur (journalisé). ADM-R3 : premium_active_clause()
remplace is_premium brut dans les comptages/filtres admin. Ajoute
admin_audit_log (sans FK), la limitation de débit sur /api/admin, le refus
de démarrage sous 32 caractères pour ADMIN_TOKEN, les en-têtes Caddy dédiés,
et la segmentation locale sur growth_events (migrations 0034/0035).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Gautier Stefanini 2026-08-14 11:27:04 +00:00
parent 24d5c8f54d
commit 9d08cefe2c
19 changed files with 799 additions and 19 deletions

View file

@ -36,3 +36,8 @@ MAX_FREE_ACTIVE_SERVICES=3
# Dashboard first-party, sans interface admin
OPERATIONS_TOKEN=replace-with-a-random-operations-token
# Interface admin (/admin, /api/admin/*) — jeton unique, refusé au démarrage
# s'il fait moins de 32 caractères. Générer avec :
# python3 -c "import secrets; print(secrets.token_urlsafe(32))"
ADMIN_TOKEN=replace-with-a-random-admin-token-min-32-chars

View file

@ -43,6 +43,16 @@ tondomaine.com {
@private path /share/* /cancel/* /hub/p/* /kontrib/* /kount/* /join /compte* /admin*
header @private X-Robots-Tag "noindex, nofollow"
# Interface admin (ADM-00) : au-delà des politiques globales ci-dessus,
# aucun referrer ne doit fuiter vers un tiers embarqué (Recharts/nivo/day-picker
# ne chargent rien d'externe, mais l'outil reste un point d'entrée sensible).
@admin path /admin*
header @admin {
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
}
handle {
reverse_proxy kankwa-frontend:3001
}

View file

@ -0,0 +1,37 @@
"""G15/ADM-00: locale segmentation on growth_events.
Revision ID: 0034
Revises: 0033
Create Date: 2026-08-14
"""
import sqlalchemy as sa
from alembic import op
revision = "0034"
down_revision = "0033"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"growth_events",
sa.Column("locale", sa.String(length=5), server_default="", nullable=False),
)
op.create_index("ix_growth_events_locale", "growth_events", ["locale"])
# Rattrapage approximatif : seuls les événements déjà associés à un compte
# peuvent être segmentés rétroactivement, via la langue déclarée du compte.
op.execute(
"""
UPDATE growth_events AS event
SET locale = users.lang
FROM users
WHERE event.user_id = users.id AND event.locale = ''
"""
)
def downgrade() -> None:
op.drop_index("ix_growth_events_locale", table_name="growth_events")
op.drop_column("growth_events", "locale")

View file

@ -0,0 +1,42 @@
"""G15/ADM-00: admin audit log.
Revision ID: 0035
Revises: 0034
Create Date: 2026-08-14
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0035"
down_revision = "0034"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"admin_audit_log",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("action", sa.String(length=50), nullable=False),
sa.Column("target_id", sa.String(length=255), nullable=True),
sa.Column("target_type", sa.String(length=50), nullable=False),
sa.Column("payload", postgresql.JSONB, nullable=False, server_default="{}"),
sa.Column(
"occurred_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
# Volontairement sans clé étrangère vers users : une entrée d'audit doit
# survivre à la suppression de sa propre cible.
)
op.create_index("ix_admin_audit_log_occurred_at", "admin_audit_log", ["occurred_at"])
op.create_index("ix_admin_audit_log_action", "admin_audit_log", ["action"])
def downgrade() -> None:
op.drop_index("ix_admin_audit_log_action", table_name="admin_audit_log")
op.drop_index("ix_admin_audit_log_occurred_at", table_name="admin_audit_log")
op.drop_table("admin_audit_log")

View file

@ -1,3 +1,4 @@
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@ -50,5 +51,14 @@ class Settings(BaseSettings):
admin_token: str = ""
operations_token: str = ""
@field_validator("admin_token")
@classmethod
def _admin_token_min_length(cls, value: str) -> str:
# Un jeton court se force par répétition ; un jeton non défini se contente
# de laisser require_admin refuser toutes les requêtes (403).
if value and len(value) < 32:
raise ValueError("admin_token doit faire au moins 32 caractères")
return value
settings = Settings()

View file

@ -1,6 +1,6 @@
# Roadmap de remédiation et de croissance
Dernière mise à jour : 13 août 2026
Dernière mise à jour : 14 août 2026
Sources : `docs/audits/2026-08-code-produit.md`,
`docs/audits/2026-08-style-visuel-produit.md`
@ -26,7 +26,7 @@ statuts, preuves et décisions dans la même modification que le code.
| G12 | Intégrité, confidentialité et billing | `IN_PROGRESS` | Socle technique validé ; visa juridique/DPO de la politique encore requis |
| G13 | Finition produit hors administration | `DONE` | Devise et langue bout en bout, Hub recomposé et grille Kal mobile arbitrée |
| G14 | Preuve opérationnelle et mesure | `DONE` | E2E réel, readiness, migrations, restauration, observabilité et G3 exploitables |
| G15 | Administration isolée | `TODO` | Dette fonctionnelle et visuelle admin reprise dans un chantier dédié ultérieur |
| G15 | Administration isolée | `IN_PROGRESS` | Admin sur le design system, delete sans client Stripe orphelin, droit Premium effectif, agrégats G3 filtrables sans jeton exposé, et recette clavier/axe/responsive propre |
## G0 — Sécurité critique
@ -513,19 +513,254 @@ périmètre ; ses constats sont conservés dans G15.
| OPS-R3 | Ajouter observabilité minimale | `DONE` | request ID, erreurs 5xx, latence et alerte de santé exploitables sans donnée personnelle inutile | logs JSON request ID/méthode/chemin/statut/latence, 5xx en erreur, `/health`, `/ready`, tests de non-divulgation et `docs/runbooks/api-operations.md` (13 août 2026) |
| ANA-R1 | Reprendre G3 | `DONE` | cycle de vie Hub et neuf événements du funnel dédupliqués alimentent un dashboard activation | migration `0033`, instrumentation first-party, endpoint protégé `/api/growth/activation`, ADR et `test_g14_operations_analytics.py` ; neuf événements, segments et métriques virales vérifiés (13 août 2026) |
## G15 — Administration isolée (différée)
## G15 — Administration isolée
Décision propriétaire du 13 août 2026 : aucun changement de l'interface admin
dans G11 à G14. Le chantier sera repris ultérieurement comme une refonte isolée.
Le report ne vaut pas acceptation des risques fonctionnels associés.
Différé par décision propriétaire le 13 août 2026 (aucun changement admin dans G11
à G14), **spécifié le 14 août 2026** et ouvert comme chantier dédié. Le report
n'avait pas valeur d'acceptation des risques : `ADM-R2` est un défaut de
facturation actif, traité en premier lot.
| ID | Enseignement conservé | Statut | Acceptation future |
Le jalon reprend les cinq enseignements d'audit `ADM-R1` à `ADM-R5`, y ajoute la
dette technique propre de l'interface (graphiques SVG écrits à la main, absence
d'audit log, absence de limitation de débit) et la segmentation par domaine
demandée par le propriétaire. Il ne crée aucun service métier et ne modifie aucun
contrat public : le périmètre est l'interface d'administration et les routes
`/api/admin/*`.
### Décisions arrêtées (14 août 2026)
- **Pas de framework d'administration.** React Admin, Refine et AdminJS
dupliqueraient l'authentification et la logique d'ownership déjà éprouvées.
L'admin reste une route `/admin/*` de l'application, montée en `React.lazy`.
- **Quatre dépendances chirurgicales**, toutes confinées à cette route et sans
effet sur le chunk public : `recharts` (graphiques), `@tanstack/react-table`
(tableaux headless), `@nivo/funnel` (entonnoir, chargé à la demande sur la seule
vue Acquisition) et `react-day-picker` (plage de dates). Aucune n'impose de CSS,
ce qui est la condition pour `ADM-R1`.
- **Langue : français uniquement.** L'admin est un outil interne mono-utilisateur ;
aucun namespace i18n, aucun sélecteur de langue. La décision est explicite et
ferme le volet « décider explicitement sa langue » d'`ADM-R4` — le silence ne
vaut pas décision.
- **Un seul jeton.** `X-Operations-Token` disparaît de la surface consommée par le
navigateur : `GET /api/growth/activation` migre sous `/api/admin/growth/funnel`,
protégé par `require_admin` comme le reste (`ADM-R5`).
- **Segmentation par domaine** : impossible aujourd'hui, `growth_events` ne porte
pas la langue. La colonne `locale` est ajoutée par migration et alimentée par
`currentLang()` ; c'est un prérequis bloquant de la vue Acquisition.
- **Numérotation des migrations** : la dernière migration appliquée est `0033` ;
le lot ouvre donc `0034` et `0035`.
### Enseignements d'audit conservés
| ID | Enseignement | Statut | Item porteur |
|---|---|---|---|
| ADM-R1 | L'interface admin reste hors design system : gris/rayons/hexadécimaux locaux, densité `text-xs`, composants et graphiques spécifiques | `TODO` | Migrer shell, champs, cartes, tableaux et graphiques vers les tokens/primitives sans modifier les contrats admin |
| ADM-R2 | La suppression admin contourne l'annulation Stripe | `TODO` | Mutualiser la suppression sécurisée avec le parcours compte ; aucun client facturable orphelin |
| ADM-R3 | Statistiques et filtres utilisent `is_premium` brut | `TODO` | Les vues admin reflètent `is_premium_active` et distinguent statut Stripe, période payée et droit effectif |
| ADM-R4 | L'admin est uniquement en français et peu couvert par les contrats visuels/accessibles | `TODO` | Décider explicitement sa langue, ajouter tests clavier/axe/responsive et recette séparée |
| ADM-R5 | Le nouveau cycle de vie Hub et les agrégats activation/viral devront nourrir administration sans exposer le jeton opérations au navigateur | `TODO` | Consommer uniquement des agrégats protégés via serveur, filtrer source/device, présenter cycle de vie et métriques virales ; aucune donnée personnelle ni secret côté client |
| ADM-R1 | L'interface admin reste hors design system : gris/rayons/hexadécimaux locaux, densité `text-xs`, composants et graphiques spécifiques | `TODO` | ADM-01, puis chaque vue |
| ADM-R2 | La suppression admin contourne l'annulation Stripe | `DONE` | ADM-00 |
| ADM-R3 | Statistiques et filtres utilisent `is_premium` brut | `DONE` | ADM-00 |
| ADM-R4 | L'admin est uniquement en français et peu couvert par les contrats visuels/accessibles | `TODO` | Langue tranchée ci-dessus ; contrats en ADM-06 |
| ADM-R5 | Le cycle de vie Hub et les agrégats activation/viral doivent nourrir l'administration sans exposer le jeton opérations au navigateur | `TODO` | ADM-02 |
### Lots d'implémentation
Chaque lot est livrable et déployable seul. `ADM-00` conditionne tous les autres.
| ID | Travail | Statut | Dépendances | Acceptation synthétique |
|---|---|---|---|---|
| ADM-00 | Fondations : migrations, correction billing, droit effectif, limitation de débit | `DONE` | — | `0034`/`0035` appliquées ; aucun abonnement Stripe orphelin après suppression admin ; tous les comptages Premium passent par le droit effectif ; `/api/admin/*` limité et journalisé |
| ADM-01 | Dashboard : Recharts et design system | `TODO` | ADM-00 | Plus aucun SVG écrit à la main ni classe Tailwind brute dans le shell et le tableau de bord ; période sélectionnable et portée par l'URL |
| ADM-02 | Acquisition : entonnoir filtrable | `TODO` | ADM-00 | Les neuf étapes du funnel se lisent segmentées par domaine, device et source ; le jeton opérations n'est plus employé côté navigateur |
| ADM-03 | Revenus et Services | `TODO` | ADM-00 | MRR calculé depuis les plans réels et le droit effectif, jamais `premium × 1,99 €` ; santé des six services sur leurs palettes canoniques |
| ADM-04 | Utilisateurs : tableau headless et filtres étendus | `TODO` | ADM-00, ADM-01 | Tri, pagination et filtres `lang`/`verified`/`has_stripe` ; suppression et passage Premium journalisés |
| ADM-05 | Système : journal d'audit et maintenance | `TODO` | ADM-00 | Toute action destructive est consultable avec sa cible et son avant/après ; purge de rétention déclenchable |
| ADM-06 | Recette admin : clavier, axe, responsive, bundle | `TODO` | ADM-00 à ADM-05 | Suites Vitest et Playwright propres à l'admin ; axe sans violation sur les vues principales ; chunk public inchangé |
### ADM-00 — Fondations, billing et droit effectif
- Statut : `DONE`
- Dépendances : aucune
- Travail :
- migration `0034` — colonne `locale VARCHAR(5) NOT NULL DEFAULT ''` sur
`growth_events`, index dédié et rattrapage approximatif depuis `users.lang`
pour les événements déjà associés à un compte ;
- migration `0035` — table `admin_audit_log` (`action`, `target_id`,
`target_type`, `payload JSONB`, `occurred_at`), sans clé étrangère afin de
survivre à la suppression de sa cible, avec index sur `occurred_at` et
`action` ;
- `locale` ajouté à `GrowthEventCreate` et à `record_growth_event()`, transmis
par `growthVisitor.ts` depuis `currentLang()` ;
- **`ADM-R2`** — `DELETE /api/admin/users/{user_id}` annule l'abonnement Stripe
avant l'effacement, en réutilisant le client de `P0-BILL-01` ; l'échec
fournisseur est journalisé sans bloquer l'administrateur, mais il est visible ;
- **`ADM-R3`** — extraction d'une clause unique de droit effectif dans
`shared/auth/premium_check.py` (`is_premium` **et** échéance non expirée),
appliquée à tous les comptages et filtres admin ;
- helper `services/admin/audit.py` (`log_action()`), appelé dans la même
transaction que l'action journalisée ;
- limitation de débit de 20 requêtes par minute et par IP sur `/api/admin/*` via
le middleware existant, avec `Retry-After` ;
- refus de démarrage si `ADMIN_TOKEN` fait moins de 32 caractères, et procédure
de génération documentée dans `.env.example` ;
- en-têtes `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff` et
`Referrer-Policy: no-referrer` posés sur le préfixe `/admin*` dans le modèle
Caddy.
- Acceptation : un compte Premium supprimé depuis l'admin n'a plus d'abonnement
actif chez Stripe, vérifié par test ; un compte au flag `is_premium` vrai mais à
l'échéance dépassée n'est plus compté comme abonné dans `/api/admin/stats` ; la
suppression et le passage Premium laissent une entrée d'audit lisible ; la
vingt-et-unième requête d'une minute reçoit `429` ; un jeton court empêche le
démarrage.
- Preuves (14 août 2026) : `tests/test_admin_g15.py` (15 tests, ajoutés à la suite
existante) couvre l'annulation Stripe avant suppression avec entrée d'audit,
le mode non bloquant quand Stripe échoue (`stripe_cancellation_failed: true`
dans le payload), l'audit conditionnel de `PATCH /users/{id}` (seulement si
`is_premium`/`premium_until` changent réellement), l'équivalence
`premium_active_clause()` / `is_premium_active()` sur un compte au flag vrai
mais expiré, le contrat `locale` de `GrowthEventCreate` (deux lettres
minuscules, défaut `""`) et son transport jusqu'à l'`INSERT`, le refus de
démarrage sous 32 caractères pour `ADMIN_TOKEN`, et le blocage de la
vingt-et-unième requête par minute sur `/api/admin`. Suite complète exécutée
via `docker compose -f docker-compose.test.yml up --build` : migrations `0034`
et `0035` appliquées proprement sur Postgres 16 réel, **93 tests passés, 0
échec** (unitaires + intégration). Côté frontend : `growth.test.ts` (4 tests)
passe avec `locale` dans le corps de `trackGrowthEvent()`, `npm run
check:i18n` valide la parité des catalogues (2 langues, 10 namespaces
inchangés), et `tsc --noEmit` ne remonte aucune erreur.
### ADM-01 — Dashboard, Recharts et design system
- Statut : `TODO`
- Dépendances : ADM-00
- Travail : remplacer les deux graphiques SVG écrits à la main par Recharts ;
introduire `AdminKpiCard` (en remplacement de `StatCard`), `AdminChartWrapper` et
`useChartColors`, qui lit les variables CSS de la marque plutôt que des
hexadécimaux figés ; retokeniser le shell, la barre latérale et le tableau de
bord sur `card`, `btn-quiet`, `badge-*`, `ink-*`, `surface-*` et `line-*` ;
sélecteur de période 7 j / 30 j / 90 j / 1 an porté par un paramètre d'URL.
- Acceptation : aucune occurrence de `text-gray-*`, `bg-gray-*`, `border-gray-*`,
`amber-*` ni d'hexadécimal local dans `AdminApp` et `AdminDashboard` ; les
couleurs des graphiques changent avec les tokens de marque sans modifier de
composant ; `UserCumulativeChart` est absorbé par `UserGrowthChart`, les deux
séries partageant un seul graphique.
### ADM-02 — Acquisition, entonnoir filtrable
- Statut : `TODO`
- Dépendances : ADM-00
- Travail : ouvrir `GET /api/admin/growth/funnel` avec les filtres `locale`,
`device`, `source` et `days`, puis retirer `GET /api/growth/activation` de la
surface consommée par le navigateur (`ADM-R5`) ; ouvrir
`GET /api/admin/stats/growth-events-over-time` pour suivre un événement dans le
temps ; construire `AdminFilterBar`, `AdminDateRange` (react-day-picker) et
`useAdminFilters`, dont l'état est porté par l'URL ; rendre les neuf étapes de
`FUNNEL_EVENTS` avec `@nivo/funnel` chargé à la demande ; présenter les
métriques virales (invités par organisateur, invité → organisateur) et la
répartition par device et par source ; reprendre les agrégats d'expériences A/B
déjà exposés par `/api/admin/stats`.
- Acceptation : le même entonnoir se lit successivement pour `kankwa.fr` et
`kankwa.com` et les deux totaux se recomposent en tout ; un filtre est
partageable par simple copie d'URL ; aucune donnée personnelle et aucun jeton ne
transitent vers le navigateur ; les résultats A/B restent masqués tant que le
seuil de 686 expositions par variante de `GRW-05` n'est pas atteint.
- Réserve connue : le rattrapage de `locale` en `0034` ne couvre que les
événements déjà associés à un compte. Les visiteurs restés anonymes avant la
migration conservent `locale = ''` et se lisent en « inconnu » ; la segmentation
n'est donc fiable qu'à partir de la date de déploiement, ce que la vue doit
indiquer.
### ADM-03 — Revenus et Services
- Statut : `TODO`
- Dépendances : ADM-00
- Travail : enrichir `/api/admin/stats` avec la répartition par langue et le détail
d'abonnement (mensuels, annuels, MRR réel) ; ouvrir
`GET /api/admin/stats/services-over-time` ; construire la vue Revenus (MRR, ARR,
évolution, répartition mensuel/annuel normalisée) et la vue Services (six
services, créations 7 j et 30 j, séries temporelles).
- Acceptation : le MRR additionne 1,99 € par abonné mensuel et 19,99 € / 12 par
abonné annuel, en ne retenant que le droit effectif d'`ADM-R3` ; l'absence de
configuration Stripe est dite explicitement plutôt que compensée par une
estimation muette ; les couleurs de service proviennent de `serviceThemes.ts`,
jamais d'une palette recréée pour l'admin.
### ADM-04 — Utilisateurs, tableau headless et filtres
- Statut : `TODO`
- Dépendances : ADM-00, ADM-01
- Travail : migrer le tableau vers TanStack Table (`AdminTable`) ; ajouter les
colonnes `lang` et `subscription_plan` ainsi que les filtres `lang`, `verified`
et `has_stripe` côté API et interface ; export CSV des lignes filtrées, produit
côté navigateur depuis les données déjà chargées ; afficher dans le panneau de
détail l'historique d'audit du compte consulté.
- Acceptation : tri, pagination et filtres fonctionnent sans rechargement complet ;
la base est segmentable entre comptes francophones et anglophones ; la
suppression et le passage Premium déclenchent l'annulation Stripe et l'entrée
d'audit d'`ADM-00`.
### ADM-05 — Système, journal d'audit et maintenance
- Statut : `TODO`
- Dépendances : ADM-00
- Travail : ouvrir `GET /api/admin/audit-log` (paginé, filtrable par action) et
`POST /api/admin/maintenance/purge-growth`, qui appelle la purge de rétention
déjà écrite ; construire la vue correspondante avec le détail avant/après et les
indicateurs de configuration (Stripe, SMTP, jeton admin).
- Acceptation : une suppression effectuée trois semaines plus tôt reste
consultable avec sa cible et son abonnement d'origine ; la purge indique le
nombre d'événements supprimés ; les indicateurs ne divulguent aucune valeur de
secret, seulement leur présence.
### ADM-06 — Recette admin
- Statut : `TODO`
- Dépendances : ADM-00 à ADM-05
- Travail : ferme le volet contrats d'`ADM-R4`. Suite Vitest propre à l'admin
(cartes de mesure, barre de filtres, tableau) ; suite Playwright couvrant
connexion, tableau de bord, filtrage, détail utilisateur et déconnexion ; analyse
axe sur le tableau de bord et la liste d'utilisateurs ; contrôle de lisibilité à
768 px ; mesure du chunk initial contre un build de référence du `HEAD` courant.
- Acceptation : aucune violation axe sur les deux vues principales ; l'admin reste
utilisable au clavier de bout en bout ; le chunk public est inchangé, les quatre
dépendances demeurant confinées à la route `/admin/*` ; la mesure est comparée à
un build de référence, jamais entre méthodes différentes.
### Limites de périmètre G15
Sont autorisés : les routes `/api/admin/*`, les fichiers sous
`frontend/src/services/admin/`, les deux migrations `0034` et `0035`, l'ajout de
la clause de droit effectif partagée, l'ajout de `locale` au contrat de mesure
first-party, et les quatre dépendances listées.
Sont exclus sans nouvel item et validation explicite :
- toute modification d'un contrat public ou d'un parcours utilisateur final ;
- toute modification des limites Premium ou du tarif ;
- l'ajout d'une propriété libre, d'un email ou d'un identifiant publicitaire au
contrat de mesure first-party, verrouillé par `ANA-01` ;
- l'internationalisation de l'admin, écartée par décision ci-dessus ;
- l'authentification par compte utilisateur pour l'admin, qui resterait à
spécifier séparément ;
- toute action d'administration en masse (suppression multiple, envoi groupé).
### Checkpoints de validation G15
1. **Fondations** : après `ADM-00`, revue de la correction billing et du droit
effectif sur des données réelles, avant d'ouvrir les vues. C'est le seul
checkpoint bloquant : `ADM-R2` est un défaut de facturation.
2. **Mesure** : après `ADM-02`, vérifier que la segmentation par domaine produit
des totaux cohérents et que la réserve de rattrapage est bien affichée.
3. **Sortie** : `ADM-06` consolide les preuves, la recette accessible et la mesure
de bundle.
### Preuves minimales par item G15
- fichiers et routes réellement modifiés ;
- commandes de validation et résultats chiffrés ;
- pour `ADM-00`, preuve explicite qu'aucun abonnement Stripe ne survit à une
suppression admin, et qu'un Premium expiré n'est plus compté ;
- pour `ADM-02`, capture des deux segments de domaine et de leur recomposition ;
- résultat axe sur les vues concernées ;
- mesure du chunk initial contre un build de référence du `HEAD` courant ;
- limite connue ou décision de risque, le cas échéant.
## Indicateurs de pilotage
@ -1733,3 +1968,85 @@ Le report ne vaut pas acceptation des risques fonctionnels associés.
sont consignés comme conditions futures, sans rouvrir les jalons actuels.
- AUD-R est synchronisé à `DONE` avec UX-R1. La preuve PRIV-R2 précise désormais
linformation FR/EN sur la mesure first-party active par défaut et son refus.
### 2026-08-14 — spécification G15 arrêtée (Claude Code)
- **G15 sort du différé et devient un chantier spécifié.** Le report du 13 août
n'avait pas valeur d'acceptation : `ADM-R2` — la suppression admin qui
n'annule pas l'abonnement Stripe — est un défaut de facturation actif, traité
en tête de chantier par `ADM-00`, seul checkpoint bloquant du jalon.
- **Approche retenue : pas de framework d'administration.** React Admin, Refine
et AdminJS ont été écartés parce qu'ils dupliqueraient l'authentification et
l'ownership déjà éprouvés. L'admin reste une route `/admin/*` de
l'application, et n'emprunte que quatre dépendances sans CSS imposé —
`recharts`, `@tanstack/react-table`, `@nivo/funnel` (chargé à la demande) et
`react-day-picker` — confinées à cette route déjà `React.lazy`, donc sans
effet sur le chunk public. L'absence de CSS imposé est la condition qui rend
`ADM-R1` tenable : l'admin consomme les mêmes tokens que le front public, et
`useChartColors` lit les variables CSS de marque au lieu d'hexadécimaux figés,
de sorte qu'un changement de palette se propage sans toucher un composant.
- **Langue tranchée : français uniquement.** Aucun namespace i18n, aucun
sélecteur. La décision est explicite, ce que demandait `ADM-R4` — le silence
ne valait pas décision. Le volet contrats du même enseignement est porté par
`ADM-06` (Vitest, Playwright, axe, responsive 768 px).
- **Deux migrations bloquantes, numérotées après `0033`** : `0034` ajoute
`locale` à `growth_events`, sans quoi la segmentation `.fr` / `.com` demandée
est impossible ; `0035` crée `admin_audit_log`, volontairement sans clé
étrangère afin de survivre à la suppression de sa propre cible.
- **Réserve consignée sur la mesure :** le rattrapage de `locale` ne couvre que
les événements déjà associés à un compte. La segmentation par domaine n'est
fiable qu'à partir du déploiement de `0034`, et la vue Acquisition doit le
dire plutôt que laisser lire un historique tronqué comme un total.
- **Jeton unique :** `GET /api/growth/activation` et son `X-Operations-Token`
quittent la surface consommée par le navigateur au profit de
`/api/admin/growth/funnel` sous `require_admin`, ce que réclamait `ADM-R5`.
- Sept items ouverts en `TODO` : `ADM-00` fondations, `ADM-01` dashboard,
`ADM-02` acquisition, `ADM-03` revenus et services, `ADM-04` utilisateurs,
`ADM-05` système, `ADM-06` recette. Aucun code n'est encore écrit ; les cinq
enseignements `ADM-R1` à `ADM-R5` restent `TODO` et sont rattachés à leur item
porteur.
### 2026-08-14 — ADM-00 livré, G15 déverrouillé (Claude Code)
- **`ADM-00` passe `DONE` : le blocage du jalon est levé.** Les six lots
restants (`ADM-01` à `ADM-06`) peuvent démarrer indépendamment, chacun sur
son propre périmètre frontend/backend.
- **`ADM-R2` clos.** `DELETE /api/admin/users/{user_id}` réutilise
`stripe_client.cancel_active_subscriptions()` (le même helper que
`P0-BILL-01`) avant d'effacer le compte. L'échec fournisseur est capturé,
journalisé (`logger.exception`) et consigné dans l'audit
(`stripe_cancellation_failed: true`) sans jamais bloquer l'administrateur —
choix délibérément distinct du parcours self-service `delete_account`, qui
lui abandonne la suppression sur un 502 Stripe : un administrateur doit
pouvoir agir même quand le fournisseur de paiement est indisponible.
- **`ADM-R3` clos.** `premium_active_clause()` (`shared/auth/premium_check.py`)
est l'équivalent SQL de `is_premium_active()``is_premium` **et**
`premium_until` non expiré — et remplace `User.is_premium == True` dans le
comptage `/api/admin/stats` et les filtres `premium`/`freemium` de
`/api/admin/users`. Un compte au flag vrai mais à l'échéance dépassée n'est
plus compté comme abonné actif.
- Portée volontairement restreinte aux agrégats et filtres : les champs
bruts `is_premium`/`premium_until` restent affichés tels quels dans le
détail utilisateur, ce qui n'est pas trompeur puisque l'échéance est
visible à côté. Le reflet du droit effectif dans le reste de l'UI (badges,
listes) est du ressort d'`ADM-01`/`ADM-04`, pas d'`ADM-00`.
- **Audit log sans clé étrangère, écrit dans la même transaction.**
`services/admin/audit.py::log_action()` est appelé avant le `commit()` de
l'action auditée (suppression, passage Premium), jamais après — un audit
écrit après coup pourrait survivre à une action qui échoue, ou l'inverse.
- **Fondations fermées dans le même lot** : limitation de débit dédiée
(`/api/admin`, 20 requêtes/minute/IP, `Retry-After`), refus de démarrage sous
32 caractères pour `ADMIN_TOKEN` (`api/config.py`, procédure dans
`.env.example`), et en-têtes `X-Frame-Options`/`X-Content-Type-Options`/
`Referrer-Policy: no-referrer` sur `/admin*` dans `Caddyfile.example`.
- **Preuve** : `tests/test_admin_g15.py` (15 tests) ; suite complète — unitaire
et intégration — exécutée via `docker compose -f docker-compose.test.yml up
--build`, migrations `0034`/`0035` appliquées sur Postgres 16 réel, **93
tests passés, 0 échec**. Côté frontend : `growth.test.ts` (4 tests) vert avec
`locale` dans `trackGrowthEvent()`, `check:i18n` stable (2 langues, 10
namespaces), `tsc --noEmit` sans erreur.
- **G15 reste `IN_PROGRESS`.** Six lots ouverts en `TODO` : `ADM-01` dashboard,
`ADM-02` acquisition, `ADM-03` revenus et services, `ADM-04` utilisateurs,
`ADM-05` système, `ADM-06` recette — tous désormais peuvent démarrer, leur
seule dépendance commune étant satisfaite.

View file

@ -1,6 +1,7 @@
import api from './client'
import { guestCtaCopyExperiment } from '@shared/config/experiments'
import { getGrowthVisitorId, isGrowthMeasurementEnabled } from '@shared/utils/growthVisitor'
import { currentLang } from '../../i18n/config'
export type GrowthSource = 'kdo' | 'kontrib' | 'kount' | 'kal' | 'kwiz' | 'hub'
export type GrowthEventName =
@ -54,6 +55,7 @@ export function trackGrowthEvent(
event_name: eventName,
source,
device: window.innerWidth < 768 ? 'mobile' : 'desktop',
locale: currentLang(),
...(includeExperiment ? {
experiment_key: experiment.key,
variant,

View file

@ -12,6 +12,7 @@ from models.kount import KountGroup, KountMember, KountExpense
from models.kwiz import KwizPoll, KwizOption, KwizParticipant, KwizVote
from models.growth import GrowthEvent
from models.stripe_event import StripeWebhookEvent
from models.admin import AdminAuditLog
__all__ = [
"User", "AuthSession", "MagicLink", "EmailVerification",
@ -24,4 +25,5 @@ __all__ = [
"KountGroup", "KountMember", "KountExpense",
"KwizPoll", "KwizOption", "KwizParticipant", "KwizVote",
"GrowthEvent", "StripeWebhookEvent",
"AdminAuditLog",
]

27
models/admin.py Normal file
View file

@ -0,0 +1,27 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, String, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from shared.database.base_model import Base
class AdminAuditLog(Base):
"""Journal des actions destructives ou sensibles de l'admin.
Volontairement sans clé étrangère : une entrée doit rester consultable
après la suppression de sa propre cible (ADM-05).
"""
__tablename__ = "admin_audit_log"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
action: Mapped[str] = mapped_column(String(50), nullable=False)
target_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
target_type: Mapped[str] = mapped_column(String(50), nullable=False)
payload: Mapped[dict] = mapped_column(JSONB, default=dict, server_default="{}", nullable=False)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

View file

@ -23,6 +23,7 @@ class GrowthEvent(Base):
experiment_key: Mapped[str] = mapped_column(String(80), default="", nullable=False)
variant: Mapped[str] = mapped_column(String(30), default="", nullable=False)
device: Mapped[str] = mapped_column(String(10), default="unknown", server_default="unknown", nullable=False)
locale: Mapped[str] = mapped_column(String(5), default="", server_default="", nullable=False)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)

26
services/admin/audit.py Normal file
View file

@ -0,0 +1,26 @@
from sqlalchemy.ext.asyncio import AsyncSession
from models.admin import AdminAuditLog
async def log_action(
db: AsyncSession,
*,
action: str,
target_type: str,
target_id: str | None = None,
payload: dict | None = None,
) -> None:
"""Journalise une action admin sensible dans la même transaction que l'action.
Sans clé étrangère vers sa cible (ADM-05) : l'entrée doit rester consultable
après la suppression du compte qu'elle décrit.
"""
db.add(
AdminAuditLog(
action=action,
target_type=target_type,
target_id=target_id,
payload=payload or {},
)
)

View file

@ -1,4 +1,5 @@
import hmac
import logging
import uuid
from datetime import datetime, timezone
@ -9,6 +10,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.config import settings
from shared.auth.password import hash_password
from shared.auth.premium_check import premium_active_clause
from shared.payments import stripe_client
from services.admin.audit import log_action
from models.kontrib import KontribDeclaration, KontribList
from models.growth import GrowthEvent
from models.event import Event
@ -20,6 +24,8 @@ from models.ownership import ResourceCoOwner
from models.user import User
from shared.service_registry import SERVICES, HUB
logger = logging.getLogger(__name__)
# Dérivé de shared/service_registry.py — ajouter un service là-bas uniquement.
_MODEL_MAP = {"hub": Event, "kdo": KdoList, "kontrib": KontribList, "kount": KountGroup, "kal": KalPoll, "kwiz": KwizPoll}
SERVICE_REGISTRY = [
@ -45,7 +51,9 @@ async def get_stats(db: AsyncSession = Depends(get_db)):
# Users
total_users = await scalar(select(func.count(User.id)))
premium_users = await scalar(select(func.count(User.id)).where(User.is_premium == True))
# Droit effectif (ADM-R3) : un flag is_premium resté vrai après l'échéance
# ne compte plus comme un abonné actif.
premium_users = await scalar(select(func.count(User.id)).where(premium_active_clause()))
verified_users = await scalar(select(func.count(User.id)).where(User.email_verified == True))
new_7d = await scalar(
select(func.count(User.id)).where(
@ -209,9 +217,9 @@ async def list_users(
if q:
base = base.where(User.email.ilike(f"%{q}%"))
if filter == "premium":
base = base.where(User.is_premium == True)
base = base.where(premium_active_clause())
elif filter == "freemium":
base = base.where(User.is_premium == False)
base = base.where(~premium_active_clause())
total_row = await db.execute(select(func.count()).select_from(base.subquery()))
total = total_row.scalar() or 0
@ -358,11 +366,23 @@ async def update_user(user_id: uuid.UUID, body: UserUpdate, db: AsyncSession = D
if not user:
raise HTTPException(status_code=404, detail="Utilisateur introuvable")
if body.is_premium is not None:
changes: dict = {}
if body.is_premium is not None and body.is_premium != user.is_premium:
changes["is_premium"] = {"from": user.is_premium, "to": body.is_premium}
user.is_premium = body.is_premium
if body.premium_until is not None:
if body.premium_until is not None and body.premium_until != user.premium_until:
changes["premium_until"] = {
"from": user.premium_until.isoformat() if user.premium_until else None,
"to": body.premium_until.isoformat(),
}
user.premium_until = body.premium_until
if changes:
await log_action(
db, action="user_premium_updated", target_type="user",
target_id=str(user_id), payload=changes,
)
await db.commit()
await db.refresh(user)
return {"message": "Utilisateur mis à jour", "is_premium": user.is_premium, "premium_until": user.premium_until}
@ -375,6 +395,30 @@ async def delete_user(user_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
user = await db.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="Utilisateur introuvable")
# Stripe est annulé avant l'effacement local afin de ne jamais laisser un
# abonnement actif orphelin (ADM-R2) ; la fonction est idempotente.
stripe_cancellation_failed = False
try:
await stripe_client.cancel_active_subscriptions(
customer_id=user.stripe_customer_id,
known_subscription_id=user.stripe_subscription_id,
)
except Exception:
stripe_cancellation_failed = True
logger.exception("Annulation Stripe échouée avant suppression admin user=%s", user_id)
await log_action(
db,
action="user_deleted",
target_type="user",
target_id=str(user_id),
payload={
"email": user.email,
"had_stripe_customer": bool(user.stripe_customer_id),
"stripe_cancellation_failed": stripe_cancellation_failed,
},
)
await db.delete(user)
await db.commit()

View file

@ -28,6 +28,7 @@ async def create_growth_event(
device=body.device,
experiment_key=body.experiment_key,
variant=body.variant,
locale=body.locale,
)
await db.commit()
return {"accepted": True}

View file

@ -28,6 +28,7 @@ class GrowthEventCreate(BaseModel):
device: GrowthDevice = "unknown"
experiment_key: str | None = Field(default=None, min_length=1, max_length=80, pattern=r"^[a-z0-9_-]+$")
variant: str | None = Field(default=None, min_length=1, max_length=30, pattern=r"^[a-z0-9_-]+$")
locale: str = Field(default="", max_length=5, pattern=r"^([a-z]{2})?$")
@model_validator(mode="after")
def coherent_experiment(self):

View file

@ -32,6 +32,7 @@ async def record_growth_event(
device: str | None = None,
experiment_key: str | None = None,
variant: str | None = None,
locale: str | None = None,
) -> None:
"""Insère au plus une occurrence par visiteur, événement, source et expérience."""
@ -45,6 +46,7 @@ async def record_growth_event(
device=device if device in {"mobile", "desktop"} else "unknown",
experiment_key=experiment_key or "",
variant=variant or "",
locale=locale or "",
)
.on_conflict_do_update(
constraint="uq_growth_event_dedupe",
@ -52,6 +54,7 @@ async def record_growth_event(
"user_id": user_id,
"device": device if device in {"mobile", "desktop"} else "unknown",
"variant": variant or "",
"locale": locale or "",
},
)
)

View file

@ -246,11 +246,13 @@ async def create_event(
await record_growth_event(
db, anonymous_id=x_kankwa_visitor, user_id=current_user.id,
event_name="hub_created", source="hub", device=x_kankwa_device,
locale=current_user.lang,
)
if prior_events >= 1:
await record_growth_event(
db, anonymous_id=x_kankwa_visitor, user_id=current_user.id,
event_name="second_event_created", source="hub", device=x_kankwa_device,
locale=current_user.lang,
)
await db.commit()
await db.refresh(event, ["services", "notifications"])
@ -269,7 +271,7 @@ async def duplicate_event(
if isinstance(x_kankwa_visitor, UUID):
await record_growth_event(
db, anonymous_id=x_kankwa_visitor, user_id=current_user.id,
event_name="hub_duplicated", source="hub",
event_name="hub_duplicated", source="hub", locale=current_user.lang,
)
await db.commit()
await db.refresh(target, ["services", "notifications"])
@ -424,6 +426,7 @@ async def attach_service(
await record_growth_event(
db, anonymous_id=x_kankwa_visitor, user_id=current_user.id,
event_name="module_added", source="hub", device=x_kankwa_device,
locale=current_user.lang,
)
await db.commit()
await db.refresh(svc)
@ -458,6 +461,7 @@ async def create_modules(
await record_growth_event(
db, anonymous_id=x_kankwa_visitor, user_id=current_user.id,
event_name="module_added", source="hub", device=x_kankwa_device,
locale=current_user.lang,
)
await db.commit()
await db.refresh(event, ["services", "notifications"])

View file

@ -2,7 +2,7 @@ from datetime import datetime, timezone
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.config import settings
@ -21,6 +21,21 @@ def is_premium_active(user) -> bool:
return user.premium_until > datetime.now(timezone.utc)
def premium_active_clause():
"""Équivalent SQL de `is_premium_active` pour les comptages et filtres.
Source unique du droit effectif côté requête (ADM-R3) : `User.is_premium`
brut ne suffit pas, un flag resté vrai après une échéance dépassée ne doit
plus compter comme abonné.
"""
from models.user import User
return and_(
User.is_premium.is_(True),
or_(User.premium_until.is_(None), User.premium_until > func.now()),
)
def _counted_service_count_queries(user_id: UUID) -> list:
"""Source unique de vérité : les services qui comptent dans la limite freemium.

View file

@ -26,6 +26,7 @@ _LIMITS: dict[str, tuple[int, int]] = {
"/api/kount/members": (20, 3600),
"/api/kwiz/public": (30, 3600),
"/api/kwiz/participants": (10, 3600),
"/api/admin": (20, 60),
"_default": (120, 60),
}

232
tests/test_admin_g15.py Normal file
View file

@ -0,0 +1,232 @@
from datetime import datetime, timedelta, timezone
from uuid import uuid4
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from sqlalchemy.dialects import postgresql
from api.config import Settings
from models.admin import AdminAuditLog
from models.user import User
from services.admin import router as admin_router
from services.growth.schemas import GrowthEventCreate
from services.growth.service import record_growth_event
from shared.auth import premium_check
from shared.rate_limit.middleware import RateLimitMiddleware, _LIMITS
def make_user(**overrides) -> User:
defaults = dict(
id=uuid4(),
email=f"admin-g15-{uuid4().hex}@example.com",
lang="fr",
email_verified=True,
is_premium=False,
premium_until=None,
cancel_at_period_end=False,
created_at=datetime.now(timezone.utc),
)
defaults.update(overrides)
return User(**defaults)
class _AdminDb:
def __init__(self, user):
self.user = user
self.added: list = []
self.deleted: list = []
self.commits = 0
async def get(self, _model, _id):
return self.user
def add(self, obj):
self.added.append(obj)
async def delete(self, obj):
self.deleted.append(obj)
async def commit(self):
self.commits += 1
async def refresh(self, _obj, *_args):
return None
# ─── ADM-R2 — annulation Stripe avant suppression admin ─────────────────────
@pytest.mark.asyncio
async def test_delete_user_cancels_stripe_before_deletion_and_logs_audit(monkeypatch):
calls = []
async def cancel(customer_id, known_subscription_id):
calls.append((customer_id, known_subscription_id))
return 1
monkeypatch.setattr(admin_router.stripe_client, "cancel_active_subscriptions", cancel)
user = make_user(is_premium=True, stripe_customer_id="cus_1", stripe_subscription_id="sub_1")
db = _AdminDb(user)
await admin_router.delete_user(user.id, db)
assert calls == [("cus_1", "sub_1")]
assert db.deleted == [user]
assert db.commits == 1
assert len(db.added) == 1
entry = db.added[0]
assert isinstance(entry, AdminAuditLog)
assert entry.action == "user_deleted"
assert entry.target_id == str(user.id)
assert entry.payload["had_stripe_customer"] is True
assert entry.payload["stripe_cancellation_failed"] is False
@pytest.mark.asyncio
async def test_delete_user_proceeds_when_stripe_cancellation_fails(monkeypatch):
async def fail(*_args, **_kwargs):
raise RuntimeError("Stripe unavailable")
monkeypatch.setattr(admin_router.stripe_client, "cancel_active_subscriptions", fail)
user = make_user()
db = _AdminDb(user)
await admin_router.delete_user(user.id, db)
# L'échec fournisseur ne bloque pas l'admin : la suppression est actée,
# mais l'échec reste visible dans l'entrée d'audit.
assert db.deleted == [user]
assert db.commits == 1
assert db.added[0].payload["stripe_cancellation_failed"] is True
@pytest.mark.asyncio
async def test_delete_user_404_when_missing():
db = _AdminDb(None)
with pytest.raises(HTTPException) as error:
await admin_router.delete_user(uuid4(), db)
assert error.value.status_code == 404
assert db.added == []
assert db.deleted == []
# ─── ADM-R3 — droit effectif appliqué aux mutations admin ───────────────────
@pytest.mark.asyncio
async def test_update_user_logs_audit_only_when_premium_actually_changes(monkeypatch):
user = make_user(is_premium=False, premium_until=None)
db = _AdminDb(user)
await admin_router.update_user(user.id, admin_router.UserUpdate(is_premium=True), db)
assert db.commits == 1
assert len(db.added) == 1
assert db.added[0].action == "user_premium_updated"
assert db.added[0].payload["is_premium"] == {"from": False, "to": True}
assert user.is_premium is True
@pytest.mark.asyncio
async def test_update_user_skips_audit_when_nothing_changes():
user = make_user(is_premium=True, premium_until=None)
db = _AdminDb(user)
await admin_router.update_user(user.id, admin_router.UserUpdate(is_premium=True), db)
assert db.added == []
assert db.commits == 1
def test_premium_active_clause_checks_flag_and_expiry():
clause = premium_check.premium_active_clause()
compiled = str(clause.compile(dialect=postgresql.dialect()))
assert "is_premium" in compiled
assert "premium_until" in compiled
def test_premium_active_clause_matches_python_helper_semantics():
expired = make_user(is_premium=True, premium_until=datetime.now(timezone.utc) - timedelta(days=1))
active = make_user(is_premium=True, premium_until=datetime.now(timezone.utc) + timedelta(days=1))
flagged_only = make_user(is_premium=False, premium_until=None)
assert premium_check.is_premium_active(expired) is False
assert premium_check.is_premium_active(active) is True
assert premium_check.is_premium_active(flagged_only) is False
# ─── Migration de contrat : locale sur les événements de mesure ─────────────
def test_growth_event_locale_accepts_two_letter_lowercase_codes():
event = GrowthEventCreate(anonymous_id=uuid4(), event_name="landing_view", locale="en")
assert event.locale == "en"
def test_growth_event_locale_defaults_to_empty_string():
event = GrowthEventCreate(anonymous_id=uuid4(), event_name="landing_view")
assert event.locale == ""
def test_growth_event_locale_rejects_uppercase_or_free_text():
with pytest.raises(ValidationError):
GrowthEventCreate(anonymous_id=uuid4(), event_name="landing_view", locale="FR")
with pytest.raises(ValidationError):
GrowthEventCreate(anonymous_id=uuid4(), event_name="landing_view", locale="french")
@pytest.mark.asyncio
async def test_record_growth_event_threads_locale_into_the_insert():
class FakeDb:
def __init__(self):
self.statement = None
async def execute(self, statement):
self.statement = statement
db = FakeDb()
await record_growth_event(
db, anonymous_id=uuid4(), event_name="landing_view", locale="en",
)
compiled = str(
db.statement.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})
)
assert "locale" in compiled
assert "'en'" in compiled
# ─── Fondations : jeton admin et limitation de débit ─────────────────────────
def _settings_kwargs(**overrides):
base = dict(database_url="postgresql+asyncpg://u:p@h/d", secret_key="x" * 40)
base.update(overrides)
return base
def test_admin_token_startup_refuses_short_values():
with pytest.raises(ValidationError):
Settings(**_settings_kwargs(admin_token="too-short"))
def test_admin_token_startup_accepts_empty_or_long_values():
Settings(**_settings_kwargs(admin_token=""))
Settings(**_settings_kwargs(admin_token="y" * 32))
def test_admin_routes_have_a_dedicated_rate_limit():
assert _LIMITS["/api/admin"] == (20, 60)
def test_admin_rate_limit_blocks_the_twenty_first_request_in_a_minute():
middleware = RateLimitMiddleware(app=None)
key = "203.0.113.5:route:/api/admin"
max_requests, window = _LIMITS["/api/admin"]
for _ in range(max_requests):
allowed, _retry_after = middleware._check_limit(key, max_requests, window)
assert allowed is True
allowed, retry_after = middleware._check_limit(key, max_requests, window)
assert allowed is False
assert retry_after >= 1