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:
parent
24d5c8f54d
commit
9d08cefe2c
19 changed files with 799 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
37
alembic/versions/0034_growth_events_locale.py
Normal file
37
alembic/versions/0034_growth_events_locale.py
Normal 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")
|
||||
42
alembic/versions/0035_admin_audit_log.py
Normal file
42
alembic/versions/0035_admin_audit_log.py
Normal 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")
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
341
docs/ROADMAP.md
341
docs/ROADMAP.md
|
|
@ -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
|
||||
l’information 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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
27
models/admin.py
Normal 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
|
||||
)
|
||||
|
|
@ -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
26
services/admin/audit.py
Normal 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 {},
|
||||
)
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
232
tests/test_admin_g15.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue