Merge branch 'wip_headless' into 'main' Screen-select See merge request ebii/rustwarden!7
Eric BOUHANA <eric@ebii.fr> committé le 2026-08-04 08:31
b8d6c461d61083f526f94ecf7a120376afea76f2
2 parent(s)
37 fichiers modifiés
+4675
-201
M
Cargo.lock
+7
-7
@@ -4156,7 +4156,7 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
| 4156 | 4156 | |
| 4157 | 4157 | [[package]] |
| 4158 | 4158 | name = "rustwarden-app" |
| 4159 | -version = "0.1.0" | |
| 4159 | +version = "1.0.0" | |
| 4160 | 4160 | dependencies = [ |
| 4161 | 4161 | "anyhow", |
| 4162 | 4162 | "async-trait", |
@@ -4178,7 +4178,7 @@ dependencies = [
| 4178 | 4178 | |
| 4179 | 4179 | [[package]] |
| 4180 | 4180 | name = "rustwarden-cli" |
| 4181 | -version = "0.1.0" | |
| 4181 | +version = "1.0.0" | |
| 4182 | 4182 | dependencies = [ |
| 4183 | 4183 | "chrono", |
| 4184 | 4184 | "clap", |
@@ -4189,7 +4189,7 @@ dependencies = [
| 4189 | 4189 | |
| 4190 | 4190 | [[package]] |
| 4191 | 4191 | name = "rustwarden-core" |
| 4192 | -version = "0.1.0" | |
| 4192 | +version = "1.0.0" | |
| 4193 | 4193 | dependencies = [ |
| 4194 | 4194 | "aes-gcm", |
| 4195 | 4195 | "anyhow", |
@@ -4240,7 +4240,7 @@ dependencies = [
| 4240 | 4240 | |
| 4241 | 4241 | [[package]] |
| 4242 | 4242 | name = "rustwarden-fediverse" |
| 4243 | -version = "0.1.0" | |
| 4243 | +version = "1.0.0" | |
| 4244 | 4244 | dependencies = [ |
| 4245 | 4245 | "activitypub_federation", |
| 4246 | 4246 | "ammonia", |
@@ -4276,7 +4276,7 @@ dependencies = [
| 4276 | 4276 | |
| 4277 | 4277 | [[package]] |
| 4278 | 4278 | name = "rustwarden-fediverse-guard" |
| 4279 | -version = "0.1.0" | |
| 4279 | +version = "1.0.0" | |
| 4280 | 4280 | dependencies = [ |
| 4281 | 4281 | "askama", |
| 4282 | 4282 | "async-trait", |
@@ -4298,7 +4298,7 @@ dependencies = [
| 4298 | 4298 | |
| 4299 | 4299 | [[package]] |
| 4300 | 4300 | name = "rustwarden-fediverse-ui" |
| 4301 | -version = "0.1.0" | |
| 4301 | +version = "1.0.0" | |
| 4302 | 4302 | dependencies = [ |
| 4303 | 4303 | "askama", |
| 4304 | 4304 | "async-trait", |
@@ -4324,7 +4324,7 @@ dependencies = [
| 4324 | 4324 | |
| 4325 | 4325 | [[package]] |
| 4326 | 4326 | name = "rustwarden-ui" |
| 4327 | -version = "0.1.0" | |
| 4327 | +version = "1.0.0" | |
| 4328 | 4328 | dependencies = [ |
| 4329 | 4329 | "askama", |
| 4330 | 4330 | "async-trait", |
M
README.md
+113
-42
@@ -16,6 +16,8 @@ Permet de creer rapidement des applications web avec authentification JWT, RBAC
| 16 | 16 | - Rate limiting configurable |
| 17 | 17 | - Internationalisation (fr, en, de, es) |
| 18 | 18 | - Middleware d'authentification et d'autorisation |
| 19 | +- Mode headless (API-only) cle en main : routeur d'auth JSON canonique `rustwarden_core::auth::auth_api_routes()` monte en une ligne, comportement d'echec d'auth configurable (redirection vers `login_path` ou `401` JSON) | |
| 20 | +- Outil `screen-select` : genere le plan des ecrans UI a coder selon les fonctionnalites activees (`docs/plans/ui-screens.md`) puis recoche le realise par analyse statique (`-- --check`) — pour les UIs custom/headless | |
| 19 | 21 | - Conformite ANSSI (complexite des mots de passe, HttpOnly cookies, etc.) |
| 20 | 22 | - OAuth2 / OpenID Connect (Google, GitHub, Discord, Microsoft) avec architecture extensible |
| 21 | 23 | - Federation ActivityPub optionnelle (RFC 0001+) : WebFinger, NodeInfo, login AP-native, invitations d'equipe cross-instance, modele de confiance allowlist/blocklist — fournie par le crate `rustwarden-fediverse` |
@@ -26,9 +28,11 @@ Permet de creer rapidement des applications web avec authentification JWT, RBAC
| 26 | 28 | ``` |
| 27 | 29 | rustwarden/ |
| 28 | 30 | ├── crates/ |
| 29 | -│ ├── rustwarden-core/ # Library : auth, RBAC, services, middleware, i18n, hooks | |
| 30 | -│ ├── rustwarden-ui/ # UI optionnelle : templates HTML, admin panel, handlers | |
| 31 | -│ └── rustwarden-fediverse/ # Federation ActivityPub optionnelle (RFC 0001+) | |
| 31 | +│ ├── rustwarden-core/ # Library : auth, RBAC, services, middleware, i18n, hooks | |
| 32 | +│ ├── rustwarden-ui/ # UI optionnelle : templates HTML, admin panel, handlers | |
| 33 | +│ ├── rustwarden-fediverse/ # Federation ActivityPub optionnelle (RFC 0001+) | |
| 34 | +│ ├── rustwarden-fediverse-ui/ # UI HTML de reference pour la federation | |
| 35 | +│ └── rustwarden-fediverse-guard/ # Detection d'abus et bans pour la federation (RFC 0028+) | |
| 32 | 36 | ├── app/ # Application de reference (12 lignes de main.rs) |
| 33 | 37 | ├── tools/ |
| 34 | 38 | │ └── rustwarden-cli/ # Wizard interactif pour creer de nouveaux projets |
@@ -45,6 +49,8 @@ rustwarden/
| 45 | 49 | | `rustwarden-core` | Authentification JWT, modeles SeaORM, services (users, roles, permissions, email), middleware (auth, admin, CSRF, rate-limit), i18n, background tasks, hooks | |
| 46 | 50 | | `rustwarden-ui` | Interface HTML par defaut (login, profil, admin panel) avec Askama, DaisyUI, HTMX | |
| 47 | 51 | | `rustwarden-fediverse` | Federation ActivityPub (WebFinger, NodeInfo, actor, inbox, signatures HTTP, auth AP-native, invitations cross-instance, worker outbound, modele de confiance allowlist/blocklist) — optionnel via feature Cargo `fediverse` | |
| 52 | +| `rustwarden-fediverse-ui` | UI HTML de reference pour la federation : handlers admin/login/consent/invite, templates Askama, carte dashboard injectable | | |
| 53 | +| `rustwarden-fediverse-guard` | Detection d'abus temps reel et gestion de bans pour la couche federation (RFC 0028+) — optionnel | | |
| 48 | 54 | | `rustwarden-app` | Binaire de reference utilisant le builder | |
| 49 | 55 | | `rustwarden-cli` | Wizard CLI interactif pour scaffolder des applications autonomes | |
| 50 | 56 |
@@ -78,7 +84,7 @@ Le wizard demande interactivement :
| 78 | 84 | 2. **Nom affiche** (APP_NAME dans l'interface) |
| 79 | 85 | 3. **Description** |
| 80 | 86 | 4. **Dossier cible** |
| 81 | -5. **Mode** (interface web ou API seule) | |
| 87 | +5. **Mode** (interface web ou API seule, federation optionnelle : aucune / headless / avec UI de reference) | |
| 82 | 88 | 6. **Migrations automatiques** (oui/non) |
| 83 | 89 | 7. **Port du serveur** |
| 84 | 90 | 8. **Base de donnees** (nom, utilisateur, mot de passe) |
@@ -131,56 +137,62 @@ async fn main() -> anyhow::Result<()> {
| 131 | 137 | ```rust |
| 132 | 138 | RustwardenApp::builder() |
| 133 | 139 | .from_env() |
| 134 | - .headless() | |
| 135 | - .merge_routes(my_api_routes()) | |
| 140 | + .headless() // API-only : echec d'auth = 401 JSON (au lieu d'une redirection) | |
| 141 | + .merge_machine_routes(rustwarden_core::auth::auth_api_routes()) // API d'auth /api/v1/auth/* prete a l'emploi | |
| 142 | + .merge_routes(my_api_routes()) // vos routes metier | |
| 136 | 143 | .build() |
| 137 | 144 | .await? |
| 138 | 145 | .run() |
| 139 | 146 | .await |
| 140 | 147 | ``` |
| 141 | 148 | |
| 149 | +En mode headless, **aucun prefixe protege n'est enregistre par defaut** : declarez | |
| 150 | +les votres via `.register_protected_prefix("/…")`. Le chemin de login (`login_path`) | |
| 151 | +et le mode d'echec (`auth_failure_mode`) sont configurables — voir la section | |
| 152 | +[Configuration](#configuration-env). | |
| 153 | + | |
| 154 | +Pour planifier les ecrans d'une UI custom, le binaire `screen-select` (livre avec | |
| 155 | +`rustwarden-core`) genere `docs/plans/ui-screens.md` — la liste des ecrans a coder | |
| 156 | +selon les fonctionnalites activees — puis recoche le realise par analyse statique | |
| 157 | +des routes : | |
| 158 | + | |
| 159 | +```bash | |
| 160 | +cargo run -p rustwarden-core --bin screen-select # wizard : selection des fonctionnalites → plan | |
| 161 | +cargo run -p rustwarden-core --bin screen-select -- --check # controle de couverture (recoche le realise) | |
| 162 | +``` | |
| 163 | + | |
| 164 | +Catalogue des ecrans et limites de detection : [docs/SERVICES_REFERENCE.md](docs/SERVICES_REFERENCE.md) §22. | |
| 165 | + | |
| 142 | 166 | --- |
| 143 | 167 | |
| 144 | 168 | ## Builder API |
| 145 | 169 | |
| 146 | 170 | ```rust |
| 147 | 171 | RustwardenApp::builder() |
| 148 | - .from_env() // charge .env + init logging | |
| 149 | - .database_url("postgres://...") // ou .database(db_connection) | |
| 150 | - .address("0.0.0.0:8080") // adresse du serveur | |
| 151 | - .with_ui(rustwarden_ui::default_routes) // UI par defaut | |
| 152 | - .headless() // ou mode API-only (pas d'UI) | |
| 153 | - .merge_routes(custom_router) // routes metier supplementaires | |
| 154 | - .hooks(MyHooks) // lifecycle hooks personnalises | |
| 155 | - .auto_migrate(true) // migrations auto au demarrage | |
| 156 | - .without_email() // desactiver l'email | |
| 157 | - .without_background_tasks() // desactiver les taches de fond | |
| 158 | - .locale_dir("my_locales") // repertoire de traductions | |
| 159 | - .supported_locales(vec!["fr", "en"]) // locales supportees | |
| 160 | - .default_locale("fr") // locale par defaut | |
| 172 | + .from_env() // charge .env + init logging | |
| 173 | + .database_url("postgres://...") // ou .database(db_connection) | |
| 174 | + .address("0.0.0.0:8080") // adresse du serveur | |
| 175 | + .with_ui(rustwarden_ui::browser_routes) // UI SSR (canal browser, CSRF) | |
| 176 | + .merge_machine_routes(rustwarden_ui::api_routes()) // API JSON de l'UI (canal machine, sans CSRF) | |
| 177 | + .headless() // OU mode API-only (pas d'UI) → echec d'auth = 401 JSON | |
| 178 | + .login_path("/connexion") // chemin de redirection de login (defaut "/login") | |
| 179 | + .auth_failure_mode(AuthFailureMode::Json401) // Redirect (defaut UI) ou Json401 (defaut headless) | |
| 180 | + .merge_browser_routes(custom_ui_router) // pages SSR mutantes → CSRF applique | |
| 181 | + .merge_machine_routes(custom_api_router) // API JSON / webhooks → sans CSRF | |
| 182 | + .register_protected_prefix("/espace") // auth requise sur ce prefixe | |
| 183 | + .hooks(MyHooks) // lifecycle hooks personnalises | |
| 184 | + .auto_migrate(true) // migrations auto au demarrage | |
| 185 | + .without_email() // desactiver l'email | |
| 186 | + .without_background_tasks() // desactiver les taches de fond | |
| 187 | + .locale_dir("my_locales") // repertoire de traductions | |
| 188 | + .supported_locales(vec!["fr", "en"]) // locales supportees | |
| 189 | + .default_locale("fr") // locale par defaut | |
| 161 | 190 | .build().await? |
| 162 | 191 | .run().await |
| 163 | 192 | ``` |
| 164 | -### Procédure manuelle actuelle | |
| 165 | - | |
| 166 | - 1. Remplacer les crates du framework dans le projet consommateur : | |
| 167 | - # Depuis le dossier du projet consommateur | |
| 168 | - rm -rf crates/rustwarden-core crates/rustwarden-ui | |
| 169 | - cp -r /chemin/vers/Rustwarden/crates/rustwarden-core crates/ | |
| 170 | - cp -r /chemin/vers/Rustwarden/crates/rustwarden-ui crates/ | |
| 171 | - 2. Copier les fichiers partagés (si modifiés) : | |
| 172 | - cp -r /chemin/vers/Rustwarden/locales ./ | |
| 173 | - cp -r /chemin/vers/Rustwarden/static ./ | |
| 174 | - 3. Vérifier les breaking changes : | |
| 175 | - - Les exports du prelude (signatures de RustwardenApp, AuthUser, Claims, etc.) | |
| 176 | - - Les migrations : s'assurer que core_migrations() dans src/migrations.rs est à jour | |
| 177 | - - Le contrat des templates (LayoutContext variables) | |
| 178 | - - Les dépendances dans Cargo.toml (les versions récentes ont changé : thiserror 2.0, askama 0.15, jsonwebtoken 10, rand 0.9, etc.) | |
| 179 | - 4. Compiler et corriger : | |
| 180 | - cargo check | |
| 181 | - cargo clippy -- -D warnings | |
| 182 | - | |
| 183 | - | |
| 193 | + | |
| 194 | +> `with_ui`/`merge_browser_routes` = canal **browser** (CSRF applique) ; `merge_machine_routes` = canal **machine** (API Bearer/JSON, sans CSRF). `rustwarden_ui::default_routes` est **deprecie** (remettrait `/api/v1/*` sous CSRF) → utiliser `browser_routes()` + `api_routes()`. | |
| 195 | + | |
| 184 | 196 | ### Sequencement interne du builder |
| 185 | 197 | |
| 186 | 198 | 1. Connexion a la base PostgreSQL |
@@ -194,6 +206,53 @@ RustwardenApp::builder()
| 194 | 206 | |
| 195 | 207 | --- |
| 196 | 208 | |
| 209 | +## Mise a jour du framework (projets generes) | |
| 210 | + | |
| 211 | +Les projets generes par le wizard embarquent les crates du framework en local (vendored). | |
| 212 | +Pour les resynchroniser sur une version plus recente : | |
| 213 | + | |
| 214 | +```bash | |
| 215 | +# Depuis le depot Rustwarden (le wizard demande le dossier du projet cible) | |
| 216 | +cargo run -p rustwarden-cli -- update-project | |
| 217 | +``` | |
| 218 | + | |
| 219 | +La commande synchronise les crates presentes dans le projet (`rustwarden-core`, | |
| 220 | +`rustwarden-ui`, `rustwarden-fediverse*` selon le cas — binaire `screen-select` inclus), | |
| 221 | +preserve les templates personnalises, supprime les fichiers retires du framework, | |
| 222 | +propose d'ajouter la federation si elle est absente, puis rappelle les actions | |
| 223 | +post-update (`cargo check`, migrations, `screen-select`). | |
| 224 | + | |
| 225 | +<details> | |
| 226 | +<summary>Procedure manuelle (fallback)</summary> | |
| 227 | + | |
| 228 | +```bash | |
| 229 | +# Depuis le dossier du projet consommateur | |
| 230 | +rm -rf crates/rustwarden-core crates/rustwarden-ui | |
| 231 | +cp -r /chemin/vers/Rustwarden/crates/rustwarden-core crates/ | |
| 232 | +cp -r /chemin/vers/Rustwarden/crates/rustwarden-ui crates/ | |
| 233 | +# Fichiers partages (si modifies) | |
| 234 | +cp -r /chemin/vers/Rustwarden/locales ./ | |
| 235 | +cp -r /chemin/vers/Rustwarden/static ./ | |
| 236 | +``` | |
| 237 | + | |
| 238 | +Verifier ensuite les breaking changes : | |
| 239 | + | |
| 240 | +- exports du prelude (signatures de `RustwardenApp`, `AuthUser`, `Claims`, etc.) | |
| 241 | +- migrations : `core_migrations()` dans `src/migrations.rs` a jour | |
| 242 | +- contrat des templates (variables `LayoutContext`) | |
| 243 | +- versions des dependances dans `Cargo.toml` (les versions recentes ont change : thiserror 2.0, askama 0.15, jsonwebtoken 10, rand 0.9, etc.) | |
| 244 | + | |
| 245 | +Puis compiler : | |
| 246 | + | |
| 247 | +```bash | |
| 248 | +cargo check | |
| 249 | +cargo clippy -- -D warnings | |
| 250 | +``` | |
| 251 | + | |
| 252 | +</details> | |
| 253 | + | |
| 254 | +--- | |
| 255 | + | |
| 197 | 256 | ## Routes disponibles |
| 198 | 257 | |
| 199 | 258 | ### Routes publiques |
@@ -221,6 +280,9 @@ RustwardenApp::builder()
| 221 | 280 | | GET | `/api/v1/auth/verify-email/{token}` | Verifier l'adresse email | |
| 222 | 281 | | POST | `/api/v1/auth/resend-verification` | Renvoyer le mail de verification | |
| 223 | 282 | | POST | `/api/v1/auth/resend-verification/by-username` | Idem, par username | |
| 283 | +| POST | `/api/v1/auth/2fa/verify` | Verifier le code 2FA (challenge de login) | | |
| 284 | + | |
| 285 | +> Cet ensemble est fourni cle en main par `rustwarden_core::auth::auth_api_routes()` (a monter via `merge_machine_routes`). En mode UI, `rustwarden_ui::api_routes()` monte exactement le meme jeu de routes. | |
| 224 | 286 | |
| 225 | 287 | ### API REST (OAuth) *(feature `oauth`)* |
| 226 | 288 |
@@ -454,6 +516,13 @@ Le panneau d'administration (`/admin/roles`) permet de :
| 454 | 516 | | `COOKIE_SECURE` | Cookies securises (HTTPS only) | `false` | |
| 455 | 517 | | `COOKIE_SAME_SITE` | Politique SameSite | `Lax` | |
| 456 | 518 | |
| 519 | +### Comportement d'authentification | |
| 520 | + | |
| 521 | +| Variable | Description | Defaut | | |
| 522 | +|----------|-------------|--------| | |
| 523 | +| `LOGIN_PATH` | Chemin de redirection de login emis par le middleware d'auth (decouple de l'UI) | `/login` | | |
| 524 | +| `AUTH_FAILURE_MODE` | Reponse sur route protegee sans auth : `redirect` (302 vers `LOGIN_PATH`) ou `json401` (401 JSON). `.headless()` bascule le defaut sur `json401` | `redirect` | | |
| 525 | + | |
| 457 | 526 | ### Admin initial |
| 458 | 527 | |
| 459 | 528 | | Variable | Description | Defaut | |
@@ -637,15 +706,16 @@ La locale est detectee automatiquement dans cet ordre :
| 637 | 706 | |----------|-------------| |
| 638 | 707 | | `cargo run -p rustwarden-app` | Lancer l'application de reference | |
| 639 | 708 | | `cargo run -p rustwarden-cli -- start-project` | Wizard de creation de projet | |
| 709 | +| `cargo run -p rustwarden-cli -- update-project` | Resynchroniser le framework vendored d'un projet genere | | |
| 640 | 710 | | `cargo run --bin migrate` | Executer les migrations | |
| 641 | 711 | | `cargo run --bin seed` | Creer/mettre a jour l'admin initial | |
| 642 | 712 | | `cargo run --bin hash_password` | Hasher un mot de passe bcrypt | |
| 643 | -| `cargo run --bin change_password` | Changer le mot de passe d'un utilisateur | | |
| 713 | +| `cargo run --bin change_password` | Changer le mot de passe d'un utilisateur (ex. `cargo run --bin change_password -- admin "Admin123!"`) | | |
| 714 | +| `cargo run -p rustwarden-core --bin screen-select` | Generer le plan des ecrans UI a coder (depuis un projet consommateur) | | |
| 715 | +| `cargo run -p rustwarden-core --bin screen-select -- --check` | Recocher les ecrans realises par analyse statique | | |
| 644 | 716 | | `cargo test` | Lancer les tests | |
| 645 | 717 | | `cargo build --release` | Build optimise pour la production | |
| 646 | 718 | |
| 647 | - cargo run --bin change_password -- admin "Admin123!" | |
| 648 | - | |
| 649 | 719 | --- |
| 650 | 720 | |
| 651 | 721 | ## Documentation complementaire |
@@ -653,6 +723,7 @@ La locale est detectee automatiquement dans cet ordre :
| 653 | 723 | | Document | Description | |
| 654 | 724 | |----------|-------------| |
| 655 | 725 | | [DEVELOPER.md](DEVELOPER.md) | Guide detaille pour les developpeurs (architecture, hooks, ajout de routes/modeles) | |
| 726 | +| [docs/SERVICES_REFERENCE.md](docs/SERVICES_REFERENCE.md) | Reference exhaustive des services et API pour projets consommateurs (§22 : binaire `screen-select`) | | |
| 656 | 727 | | [docs/ANSSI_COMPLIANCE_REPORT.md](docs/ANSSI_COMPLIANCE_REPORT.md) | Rapport de conformite aux recommandations ANSSI | |
| 657 | 728 | | [docs/CSS_STYLE_GUIDE.md](docs/CSS_STYLE_GUIDE.md) | Guide de style CSS/DaisyUI | |
| 658 | 729 | | [docs/configuration-flows.md](docs/configuration-flows.md) | Diagrammes des flux de configuration | |
M
app/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | [package] |
| 2 | 2 | name = "rustwarden-app" |
| 3 | -version = "0.1.0" | |
| 3 | +version = "1.0.0" | |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | rust-version.workspace = true |
| 6 | 6 | description = "Reference application using the Rustwarden framework" |
M
crates/rustwarden-core/Cargo.toml
+5
-1
@@ -1,6 +1,6 @@
| 1 | 1 | [package] |
| 2 | 2 | name = "rustwarden-core" |
| 3 | -version = "0.1.0" | |
| 3 | +version = "1.0.0" | |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | rust-version.workspace = true |
| 6 | 6 | description = "Core library for Rustwarden – authentication, RBAC, user management framework" |
@@ -94,6 +94,10 @@ path = "src/bin/seed.rs"
| 94 | 94 | name = "change_password" |
| 95 | 95 | path = "src/bin/change_password.rs" |
| 96 | 96 | |
| 97 | +[[bin]] | |
| 98 | +name = "screen-select" | |
| 99 | +path = "src/bin/screen-select/main.rs" | |
| 100 | + | |
| 97 | 101 | [dev-dependencies] |
| 98 | 102 | hyper = { version = "1", features = ["full"] } |
| 99 | 103 | hyper-util = { version = "0.1", features = ["full"] } |
M
crates/rustwarden-core/src/app.rs
+108
-16
@@ -145,8 +145,9 @@ pub struct RustwardenBuilder {
| 145 | 145 | supported_locales: Vec<String>, |
| 146 | 146 | default_locale: String, |
| 147 | 147 | /// URL path prefixes that the authentication middleware must protect. |
| 148 | - /// Filled in by `rustwarden-ui` (and by `rustwarden-fediverse` when | |
| 149 | - /// the consumer mounts its routes). See `register_protected_prefix`. | |
| 148 | + /// Empty by default; [`RustwardenBuilder::with_ui`] registers the default | |
| 149 | + /// UI areas (`/home`, `/profile`, `/settings`, `/admin`) and companion | |
| 150 | + /// crates / consumers add their own via `register_protected_prefix`. | |
| 150 | 151 | pub(crate) protected_prefixes: Vec<String>, |
| 151 | 152 | /// Optional revocation checker consulted by `AuthService` for remote |
| 152 | 153 | /// JWTs (RFC 0018 / F10). Injected by `rustwarden-fediverse` when |
@@ -159,6 +160,15 @@ pub struct RustwardenBuilder {
| 159 | 160 | /// `Extension<Vec<DashboardCardDTO>>` so `admin_dashboard` can pick |
| 160 | 161 | /// them up at render time. |
| 161 | 162 | pub(crate) dashboard_cards: Vec<DashboardCardDTO>, |
| 163 | + /// Override for [`crate::config::auth::AuthConfig::login_path`] applied at | |
| 164 | + /// build time. `None` keeps the value resolved from the environment. | |
| 165 | + login_path: Option<String>, | |
| 166 | + /// Override for [`crate::config::auth::AuthConfig::auth_failure_mode`]. | |
| 167 | + /// `None` keeps the env/default value, unless `headless` flips it. | |
| 168 | + auth_failure_mode: Option<crate::config::auth::AuthFailureMode>, | |
| 169 | + /// Set by [`RustwardenBuilder::headless`]; flips the default auth-failure | |
| 170 | + /// mode to `Json401` when no explicit mode was requested. | |
| 171 | + headless: bool, | |
| 162 | 172 | } |
| 163 | 173 | |
| 164 | 174 | impl Default for RustwardenBuilder { |
@@ -177,15 +187,15 @@ impl Default for RustwardenBuilder {
| 177 | 187 | locale_dirs: Vec::new(), |
| 178 | 188 | supported_locales: vec!["fr".into(), "en".into(), "de".into(), "es".into()], |
| 179 | 189 | default_locale: "fr".into(), |
| 180 | - protected_prefixes: vec![ | |
| 181 | - "/home".into(), | |
| 182 | - "/profile".into(), | |
| 183 | - "/test-base".into(), | |
| 184 | - "/settings".into(), | |
| 185 | - "/admin".into(), | |
| 186 | - ], | |
| 190 | + // Empty by default so headless (API-only) apps carry no | |
| 191 | + // UI-specific prefixes. `with_ui` registers the default UI set; | |
| 192 | + // consumers add their own via `register_protected_prefix`. | |
| 193 | + protected_prefixes: Vec::new(), | |
| 187 | 194 | revocation_checker: None, |
| 188 | 195 | dashboard_cards: Vec::new(), |
| 196 | + login_path: None, | |
| 197 | + auth_failure_mode: None, | |
| 198 | + headless: false, | |
| 189 | 199 | } |
| 190 | 200 | } |
| 191 | 201 | } |
@@ -263,11 +273,21 @@ impl RustwardenBuilder {
| 263 | 273 | |
| 264 | 274 | /// Register the default UI routes (login, profile, admin panel). |
| 265 | 275 | /// Pass the function that returns the UI router (from `rustwarden_ui::default_routes`). |
| 276 | + /// | |
| 277 | + /// This also registers the authenticated UI areas (`/home`, `/profile`, | |
| 278 | + /// `/settings`, `/admin`) as protected prefixes, so the default UI stays | |
| 279 | + /// protected without the core hard-wiring these paths for every app. | |
| 280 | + /// Headless apps, which never call `with_ui`, do not inherit them. | |
| 266 | 281 | pub fn with_ui( |
| 267 | 282 | mut self, |
| 268 | 283 | routes_fn: impl FnOnce() -> Router<DatabaseConnection> + Send + 'static, |
| 269 | 284 | ) -> Self { |
| 270 | 285 | self.ui_routes_fn = Some(Box::new(routes_fn)); |
| 286 | + for prefix in ["/home", "/profile", "/settings", "/admin"] { | |
| 287 | + if !self.protected_prefixes.iter().any(|p| p == prefix) { | |
| 288 | + self.protected_prefixes.push(prefix.to_string()); | |
| 289 | + } | |
| 290 | + } | |
| 271 | 291 | self |
| 272 | 292 | } |
| 273 | 293 |
@@ -278,17 +298,44 @@ impl RustwardenBuilder {
| 278 | 298 | } |
| 279 | 299 | |
| 280 | 300 | /// Run in headless mode (API-only, no UI). |
| 301 | + /// | |
| 302 | + /// Besides dropping the default UI routes, this flips the default | |
| 303 | + /// authentication-failure behaviour to [`AuthFailureMode::Json401`] so | |
| 304 | + /// protected routes answer unauthenticated requests with `401` JSON | |
| 305 | + /// instead of redirecting to a `/login` page that does not exist. Call | |
| 306 | + /// [`RustwardenBuilder::auth_failure_mode`] afterwards to override. | |
| 307 | + /// | |
| 308 | + /// [`AuthFailureMode::Json401`]: crate::config::auth::AuthFailureMode::Json401 | |
| 281 | 309 | pub fn headless(mut self) -> Self { |
| 282 | 310 | self.ui_routes_fn = None; |
| 311 | + self.headless = true; | |
| 312 | + self | |
| 313 | + } | |
| 314 | + | |
| 315 | + /// Override the browser login redirect path | |
| 316 | + /// ([`crate::config::auth::AuthConfig::login_path`], default `/login`). | |
| 317 | + pub fn login_path(mut self, path: impl Into<String>) -> Self { | |
| 318 | + self.login_path = Some(path.into()); | |
| 319 | + self | |
| 320 | + } | |
| 321 | + | |
| 322 | + /// Override how the auth middleware answers unauthenticated requests to | |
| 323 | + /// protected routes | |
| 324 | + /// ([`crate::config::auth::AuthConfig::auth_failure_mode`]). Takes | |
| 325 | + /// precedence over the default flipped by [`RustwardenBuilder::headless`]. | |
| 326 | + pub fn auth_failure_mode(mut self, mode: crate::config::auth::AuthFailureMode) -> Self { | |
| 327 | + self.auth_failure_mode = Some(mode); | |
| 283 | 328 | self |
| 284 | 329 | } |
| 285 | 330 | |
| 286 | 331 | /// Register a URL path prefix that the authentication middleware must |
| 287 | - /// protect (redirect anonymous visitors to `/login`). The default set | |
| 288 | - /// (`/home`, `/profile`, `/test-base`, `/settings`, `/admin`) is | |
| 289 | - /// installed in [`Default::default`]; consumers and companion crates | |
| 290 | - /// add more on top — for instance `rustwarden-fediverse::mount` adds | |
| 291 | - /// `/fediverse/consent` and `/fediverse/invite`. | |
| 332 | + /// protect (redirect anonymous visitors to the login page, or reply | |
| 333 | + /// `401` in headless mode). The set is empty by default; | |
| 334 | + /// [`RustwardenBuilder::with_ui`] registers the default UI areas | |
| 335 | + /// (`/home`, `/profile`, `/settings`, `/admin`), and consumers and | |
| 336 | + /// companion crates add more on top — for instance | |
| 337 | + /// `rustwarden-fediverse::mount` adds `/fediverse/consent` and | |
| 338 | + /// `/fediverse/invite`. | |
| 292 | 339 | pub fn register_protected_prefix(mut self, prefix: impl Into<String>) -> Self { |
| 293 | 340 | self.protected_prefixes.push(prefix.into()); |
| 294 | 341 | self |
@@ -359,7 +406,7 @@ impl RustwardenBuilder {
| 359 | 406 | |
| 360 | 407 | /// Build the application. Connects to the database, initializes services, |
| 361 | 408 | /// and assembles the router with all middleware. |
| 362 | - pub async fn build(self) -> anyhow::Result<RustwardenApp> { | |
| 409 | + pub async fn build(mut self) -> anyhow::Result<RustwardenApp> { | |
| 363 | 410 | tracing::info!("Building Rustwarden application..."); |
| 364 | 411 | |
| 365 | 412 | // 1. Database connection |
@@ -393,8 +440,19 @@ impl RustwardenBuilder {
| 393 | 440 | tracing::info!("Default admin and app settings initialized"); |
| 394 | 441 | |
| 395 | 442 | // 3. Auth config & session (SEC-M1: load once, share via Extension) |
| 396 | - let auth_config = | |
| 443 | + let mut auth_config = | |
| 397 | 444 | AuthConfig::from_env().map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?; |
| 445 | + // Apply builder overrides: an explicit setter wins; otherwise | |
| 446 | + // `.headless()` flips the default failure mode to Json401 so API-only | |
| 447 | + // apps return 401 instead of redirecting to a non-existent /login. | |
| 448 | + if let Some(path) = self.login_path.take() { | |
| 449 | + auth_config.login_path = path; | |
| 450 | + } | |
| 451 | + if let Some(mode) = self.auth_failure_mode { | |
| 452 | + auth_config.auth_failure_mode = mode; | |
| 453 | + } else if self.headless { | |
| 454 | + auth_config.auth_failure_mode = crate::config::auth::AuthFailureMode::Json401; | |
| 455 | + } | |
| 398 | 456 | let session_layer = SessionConfig::create_memory_layer(&auth_config); |
| 399 | 457 | let auth_config = Arc::new(auth_config); |
| 400 | 458 | tracing::info!("Session management configured"); |
@@ -867,3 +925,37 @@ async fn locale_middleware(
| 867 | 925 | let request = Request::from_parts(parts, body); |
| 868 | 926 | next.run(request).await |
| 869 | 927 | } |
| 928 | + | |
| 929 | +#[cfg(test)] | |
| 930 | +mod builder_tests { | |
| 931 | + use super::*; | |
| 932 | + | |
| 933 | + #[test] | |
| 934 | + fn default_builder_has_no_protected_prefixes() { | |
| 935 | + assert!(RustwardenBuilder::default().protected_prefixes.is_empty()); | |
| 936 | + } | |
| 937 | + | |
| 938 | + #[test] | |
| 939 | + fn with_ui_registers_default_ui_prefixes() { | |
| 940 | + let builder = RustwardenBuilder::default().with_ui(Router::<DatabaseConnection>::new); | |
| 941 | + for prefix in ["/home", "/profile", "/settings", "/admin"] { | |
| 942 | + assert!( | |
| 943 | + builder.protected_prefixes.iter().any(|p| p == prefix), | |
| 944 | + "with_ui must register {prefix}" | |
| 945 | + ); | |
| 946 | + } | |
| 947 | + } | |
| 948 | + | |
| 949 | + #[test] | |
| 950 | + fn headless_sets_flag_without_ui_prefixes() { | |
| 951 | + let builder = RustwardenBuilder::default().headless(); | |
| 952 | + assert!(builder.headless); | |
| 953 | + assert!(builder.protected_prefixes.is_empty()); | |
| 954 | + } | |
| 955 | + | |
| 956 | + #[test] | |
| 957 | + fn register_protected_prefix_appends_custom_prefix() { | |
| 958 | + let builder = RustwardenBuilder::default().register_protected_prefix("/custom"); | |
| 959 | + assert!(builder.protected_prefixes.iter().any(|p| p == "/custom")); | |
| 960 | + } | |
| 961 | +} |
M
crates/rustwarden-core/src/auth/mod.rs
+4
-0
@@ -11,6 +11,10 @@ pub mod extractors;
| 11 | 11 | pub mod handlers; |
| 12 | 12 | /// JWT creation and validation service. |
| 13 | 13 | pub mod jwt; |
| 14 | +/// Ready-to-mount router for the built-in JSON authentication API. | |
| 15 | +pub mod routes; | |
| 16 | + | |
| 17 | +pub use routes::auth_api_routes; | |
| 14 | 18 | /// Axum extractor that validates a Bearer JWT and provides the authenticated user. |
| 15 | 19 | pub mod jwt_extractor; |
| 16 | 20 | /// OAuth 2.0 / OIDC handlers (redirect initiation and callback processing). |
A
crates/rustwarden-core/src/auth/routes.rs
+92
-0
@@ -0,0 +1,92 @@
| 1 | +//! Ready-to-mount router for the built-in JSON authentication API. | |
| 2 | + | |
| 3 | +use axum::{ | |
| 4 | + Router, | |
| 5 | + routing::{get, post}, | |
| 6 | +}; | |
| 7 | +use sea_orm::DatabaseConnection; | |
| 8 | + | |
| 9 | +use crate::auth::handlers; | |
| 10 | + | |
| 11 | +/// Build the JSON authentication API router (`/api/v1/auth/*`). | |
| 12 | +/// | |
| 13 | +/// Mounts the framework's built-in auth handlers: login, register, refresh, | |
| 14 | +/// logout, current-user, password reset, email verification, and 2FA | |
| 15 | +/// verification. These endpoints authenticate by `Authorization: Bearer` (or | |
| 16 | +/// are pre-session, for login/register) and are **not** CSRF-vulnerable, so | |
| 17 | +/// mount this on the machine channel of the application builder | |
| 18 | +/// ([`crate::app::RustwardenBuilder::merge_machine_routes`]) — never behind | |
| 19 | +/// the CSRF layer. | |
| 20 | +/// | |
| 21 | +/// Headless (UI-less) applications get a complete, working authentication API | |
| 22 | +/// simply by wiring this router; `rustwarden-ui` mounts the same set inside | |
| 23 | +/// its own `api_routes()`. | |
| 24 | +/// | |
| 25 | +/// # Example | |
| 26 | +/// | |
| 27 | +/// ```rust,ignore | |
| 28 | +/// RustwardenApp::builder() | |
| 29 | +/// .from_env() | |
| 30 | +/// .headless() | |
| 31 | +/// .merge_machine_routes(rustwarden_core::auth::auth_api_routes()) | |
| 32 | +/// .build() | |
| 33 | +/// .await?; | |
| 34 | +/// ``` | |
| 35 | +pub fn auth_api_routes() -> Router<DatabaseConnection> { | |
| 36 | + Router::new() | |
| 37 | + .route("/api/v1/auth/login", post(handlers::login)) | |
| 38 | + .route("/api/v1/auth/register", post(handlers::register)) | |
| 39 | + .route("/api/v1/auth/refresh", post(handlers::refresh_token)) | |
| 40 | + .route("/api/v1/auth/logout", post(handlers::logout)) | |
| 41 | + .route("/api/v1/auth/me", get(handlers::get_current_user)) | |
| 42 | + .route( | |
| 43 | + "/api/v1/auth/forgot-password", | |
| 44 | + post(handlers::forgot_password), | |
| 45 | + ) | |
| 46 | + .route( | |
| 47 | + "/api/v1/auth/reset-password", | |
| 48 | + post(handlers::reset_password), | |
| 49 | + ) | |
| 50 | + .route( | |
| 51 | + "/api/v1/auth/verify-email/{token}", | |
| 52 | + get(handlers::verify_email), | |
| 53 | + ) | |
| 54 | + .route( | |
| 55 | + "/api/v1/auth/resend-verification", | |
| 56 | + post(handlers::resend_verification), | |
| 57 | + ) | |
| 58 | + .route( | |
| 59 | + "/api/v1/auth/resend-verification/by-username", | |
| 60 | + post(handlers::resend_verification_by_username), | |
| 61 | + ) | |
| 62 | + .route("/api/v1/auth/2fa/verify", post(handlers::verify_2fa)) | |
| 63 | +} | |
| 64 | + | |
| 65 | +#[cfg(test)] | |
| 66 | +mod tests { | |
| 67 | + use super::auth_api_routes; | |
| 68 | + use axum::{Json, Router, routing::get}; | |
| 69 | + use sea_orm::DatabaseConnection; | |
| 70 | + | |
| 71 | + /// Mirrors the protected handler the CLI scaffolds into headless | |
| 72 | + /// projects. Kept in sync so the generated code's interface (the | |
| 73 | + /// `AuthUser` extractor composing into a `Router<DatabaseConnection>` | |
| 74 | + /// with a `Json<serde_json::Value>` response) is verified at compile | |
| 75 | + /// time against the real framework surface. | |
| 76 | + async fn whoami(user: crate::auth::extractors::AuthUser) -> Json<serde_json::Value> { | |
| 77 | + Json(serde_json::json!({ | |
| 78 | + "user_id": user.user_id, | |
| 79 | + "username": user.username, | |
| 80 | + "roles": user.roles, | |
| 81 | + })) | |
| 82 | + } | |
| 83 | + | |
| 84 | + #[test] | |
| 85 | + fn auth_api_routes_compose_with_scaffolded_protected_handler() { | |
| 86 | + // Type-checks the exact wiring the headless scaffold emits: | |
| 87 | + // .merge_machine_routes(auth_api_routes()) | |
| 88 | + // .merge_routes(app_routes()) // app_routes mounts `whoami` | |
| 89 | + let _router: Router<DatabaseConnection> = | |
| 90 | + auth_api_routes().merge(Router::new().route("/api/whoami", get(whoami))); | |
| 91 | + } | |
| 92 | +} |
A
crates/rustwarden-core/src/bin/screen-select/catalogue.rs
+356
-0
@@ -0,0 +1,356 @@
| 1 | +//! Catalogue statique fonctionnalité → écrans (source de vérité de complétude). | |
| 2 | + | |
| 3 | +/// Canal de montage d'un écran. | |
| 4 | +#[derive(Clone, Copy, PartialEq, Eq, Debug)] | |
| 5 | +#[allow(dead_code)] // `Machine` réservé à de futurs écrans (aucun aujourd'hui) | |
| 6 | +pub enum Channel { | |
| 7 | + /// Route SSR pilotée par cookie de session (CSRF appliqué). | |
| 8 | + Browser, | |
| 9 | + /// API JSON / Bearer (hors CSRF). | |
| 10 | + Machine, | |
| 11 | +} | |
| 12 | + | |
| 13 | +impl Channel { | |
| 14 | + /// Libellé court pour le tableau. | |
| 15 | + pub fn label(self) -> &'static str { | |
| 16 | + match self { | |
| 17 | + Channel::Browser => "browser", | |
| 18 | + Channel::Machine => "machine", | |
| 19 | + } | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +/// Niveau d'autorisation attendu pour monter l'écran (contrat de sécurité). | |
| 24 | +#[derive(Clone, Copy, PartialEq, Eq, Debug)] | |
| 25 | +pub enum AuthLevel { | |
| 26 | + /// Accessible sans session (flux publics : login, reset, vérification…). | |
| 27 | + Public, | |
| 28 | + /// Session valide requise (n'importe quel utilisateur authentifié). | |
| 29 | + Authenticated, | |
| 30 | + /// Authentifié + l'écran manipule une ressource nominative : vérifier | |
| 31 | + /// l'appartenance avant lecture/mutation (défense IDOR). | |
| 32 | + ResourceOwner, | |
| 33 | + /// Réservé aux administrateurs : `ensure_admin()` en défense en profondeur. | |
| 34 | + Admin, | |
| 35 | +} | |
| 36 | + | |
| 37 | +impl AuthLevel { | |
| 38 | + /// Libellé pour le contrat de sécurité par écran. | |
| 39 | + #[allow(dead_code)] // consommé par le rendu de la section sécurité (tâche ultérieure) | |
| 40 | + pub fn label(self) -> &'static str { | |
| 41 | + match self { | |
| 42 | + AuthLevel::Public => "Public", | |
| 43 | + AuthLevel::Authenticated => "Authentifié", | |
| 44 | + AuthLevel::ResourceOwner => "Propriétaire de ressource", | |
| 45 | + AuthLevel::Admin => "Administrateur", | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + /// Reporté tel quel dans la colonne « Protégé » du tableau : | |
| 50 | + /// tout niveau non public exige une session. | |
| 51 | + pub fn is_protected(self) -> bool { | |
| 52 | + !matches!(self, AuthLevel::Public) | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +/// Un écran requis quand sa fonctionnalité est activée. | |
| 57 | +#[derive(Clone, Copy, Debug)] | |
| 58 | +pub struct Screen { | |
| 59 | + pub name: &'static str, | |
| 60 | + pub suggested_route: &'static str, | |
| 61 | + pub channel: Channel, | |
| 62 | + pub auth_level: AuthLevel, | |
| 63 | + pub core_methods: &'static str, | |
| 64 | + pub io: &'static str, | |
| 65 | + /// Pièges de sécurité spécifiques à cet écran (imprimés dans le plan). | |
| 66 | + #[allow(dead_code)] // consommé par le rendu de la section sécurité (tâche ultérieure) | |
| 67 | + pub security_notes: &'static [&'static str], | |
| 68 | +} | |
| 69 | + | |
| 70 | +/// Une fonctionnalité activable et ses écrans. | |
| 71 | +#[derive(Clone, Copy, Debug)] | |
| 72 | +pub struct Functionality { | |
| 73 | + pub key: &'static str, | |
| 74 | + pub label: &'static str, | |
| 75 | + pub always: bool, | |
| 76 | + pub screens: &'static [Screen], | |
| 77 | +} | |
| 78 | + | |
| 79 | +/// Toutes les fonctionnalités du catalogue. | |
| 80 | +pub fn all() -> &'static [Functionality] { | |
| 81 | + CATALOGUE | |
| 82 | +} | |
| 83 | + | |
| 84 | +/// Recherche par clé stable. | |
| 85 | +#[allow(dead_code)] // utilisé par les tests des autres modules du binaire | |
| 86 | +pub fn by_key(key: &str) -> Option<&'static Functionality> { | |
| 87 | + CATALOGUE.iter().find(|f| f.key == key) | |
| 88 | +} | |
| 89 | + | |
| 90 | +const CATALOGUE: &[Functionality] = &[ | |
| 91 | + Functionality { key: "authentication", label: "Authentication", always: true, screens: &[ | |
| 92 | + Screen { name: "Login", suggested_route: "/login", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 93 | + core_methods: "AuthService, JwtService, POST /api/v1/auth/login", io: "form login → cookie JWT / challenge 2FA", | |
| 94 | + security_notes: &[ | |
| 95 | + "redirect post-login : n'autoriser qu'un chemin relatif same-origin (rejeter les URL absolues et `//host`) — bloque l'open redirect `?redirect=`", | |
| 96 | + "flux public : ne pas divulguer si l'identifiant/email existe (message d'échec générique)", | |
| 97 | + ] }, | |
| 98 | + ]}, | |
| 99 | + Functionality { key: "registration", label: "Registration", always: false, screens: &[ | |
| 100 | + Screen { name: "Register", suggested_route: "/register", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 101 | + core_methods: "UserService, AppSettingsService(allow_registration), POST /api/v1/auth/register", io: "form inscription", | |
| 102 | + security_notes: &[ | |
| 103 | + "respecter `allow_registration` (403 si désactivé) ; valider/normaliser l'email côté serveur", | |
| 104 | + "flux public : ne pas révéler qu'un email est déjà enregistré", | |
| 105 | + ] }, | |
| 106 | + ]}, | |
| 107 | + Functionality { key: "password_reset", label: "PasswordReset", always: false, screens: &[ | |
| 108 | + Screen { name: "ForgotPassword", suggested_route: "/forgot-password", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 109 | + core_methods: "PasswordResetService, POST /api/v1/auth/forgot-password", io: "email → lien reset", | |
| 110 | + security_notes: &[ | |
| 111 | + "réponse identique que l'email existe ou non (anti-énumération)", | |
| 112 | + ] }, | |
| 113 | + Screen { name: "ResetPassword", suggested_route: "/reset-password", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 114 | + core_methods: "PasswordResetService, POST /api/v1/auth/reset-password", io: "token + nouveau mdp", | |
| 115 | + security_notes: &[ | |
| 116 | + "le token ne doit JAMAIS être interpolé dans un `<script>` : le passer par un attribut `data-*` et le lire via `dataset` (l'escaping HTML d'Askama n'échappe pas le contexte JS ni le backslash)", | |
| 117 | + "consommer le token en un seul usage ; ne pas confirmer si le token est valide avant soumission", | |
| 118 | + ] }, | |
| 119 | + ]}, | |
| 120 | + Functionality { key: "email_validation", label: "EmailValidation", always: false, screens: &[ | |
| 121 | + Screen { name: "ResendVerification", suggested_route: "/resend-verification", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 122 | + core_methods: "EmailValidationService, POST /api/v1/auth/resend-verification", io: "renvoi email (résultat rendu par le core)", | |
| 123 | + security_notes: &[ | |
| 124 | + "réponse identique quel que soit l'état du compte (anti-énumération) ; rate-limit les renvois", | |
| 125 | + ] }, | |
| 126 | + ]}, | |
| 127 | + Functionality { key: "two_factor", label: "TwoFactor", always: false, screens: &[ | |
| 128 | + Screen { name: "TwoFactorChallenge", suggested_route: "/login/2fa", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 129 | + core_methods: "TotpService, POST /api/v1/auth/2fa/verify", io: "code TOTP ou backup", | |
| 130 | + security_notes: &[ | |
| 131 | + "étape intermédiaire liée à une pré-session : rate-limit strict, ne pas révéler la validité partielle du code", | |
| 132 | + ] }, | |
| 133 | + Screen { name: "TwoFactorSetup", suggested_route: "/settings/2fa", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 134 | + core_methods: "TotpService::generate_setup/confirm/disable", io: "QR + confirmation", | |
| 135 | + security_notes: &[ | |
| 136 | + "toute redirection de sécurité doit parser et whitelister les query params attendus — ne jamais forwarder un `RawQuery` brut", | |
| 137 | + "exiger une confirmation par code avant activation ; le secret TOTP reste chiffré (CryptoService)", | |
| 138 | + ] }, | |
| 139 | + Screen { name: "TwoFactorBackupCodes", suggested_route: "/settings/2fa/backup", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 140 | + core_methods: "TotpService (backup codes)", io: "génération/affichage codes", | |
| 141 | + security_notes: &[ | |
| 142 | + "codes affichés une seule fois, jamais logués ; ne stocker que leur hash", | |
| 143 | + ] }, | |
| 144 | + ]}, | |
| 145 | + Functionality { key: "account_profile", label: "AccountProfile", always: false, screens: &[ | |
| 146 | + Screen { name: "Profile", suggested_route: "/profile", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 147 | + core_methods: "UserService", io: "voir/éditer profil", | |
| 148 | + security_notes: &[ | |
| 149 | + "n'exposer et n'éditer que le profil de `claims.sub`", | |
| 150 | + ] }, | |
| 151 | + ]}, | |
| 152 | + Functionality { key: "password_change", label: "PasswordChange", always: false, screens: &[ | |
| 153 | + Screen { name: "PasswordChange", suggested_route: "/settings/password", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 154 | + core_methods: "PasswordChangeService", io: "changement mdp (confirmation email)", | |
| 155 | + security_notes: &[ | |
| 156 | + "exiger le mot de passe courant ; invalider les autres sessions après changement", | |
| 157 | + ] }, | |
| 158 | + Screen { name: "ConfirmPasswordChange", suggested_route: "/confirm-password-change", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 159 | + core_methods: "PasswordChangeService::find_by_token/confirm_request", io: "confirme via token", | |
| 160 | + security_notes: &[ | |
| 161 | + "valider le token côté core ; token à usage unique et expirant", | |
| 162 | + ] }, | |
| 163 | + ]}, | |
| 164 | + Functionality { key: "session_management", label: "SessionManagement", always: false, screens: &[ | |
| 165 | + Screen { name: "SessionsList", suggested_route: "/settings/sessions", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner, | |
| 166 | + core_methods: "RefreshTokenService::list_active/revoke, JwtBlacklistService", io: "liste sessions + révoquer", | |
| 167 | + security_notes: &[ | |
| 168 | + "IDOR : lister et révoquer uniquement les sessions dont l'`id` appartient à `claims.sub` (vérifier l'appartenance avant révocation)", | |
| 169 | + ] }, | |
| 170 | + ]}, | |
| 171 | + Functionality { key: "preferences", label: "Preferences", always: false, screens: &[ | |
| 172 | + Screen { name: "Preferences", suggested_route: "/settings/preferences", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 173 | + core_methods: "UserPreferencesService", io: "préférences utilisateur", | |
| 174 | + security_notes: &[ | |
| 175 | + "ne modifier que les préférences de `claims.sub`", | |
| 176 | + ] }, | |
| 177 | + ]}, | |
| 178 | + Functionality { key: "oauth_linking", label: "OAuthLinking", always: false, screens: &[ | |
| 179 | + Screen { name: "OAuthAccountLinking", suggested_route: "/settings/oauth", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 180 | + core_methods: "OAuthService, GET /api/v1/auth/oauth/{provider}/redirect", io: "lier/délier comptes (+ boutons login)", | |
| 181 | + security_notes: &[ | |
| 182 | + "vérifier le paramètre `state` anti-CSRF au retour du provider", | |
| 183 | + "données renvoyées par l'API en `textContent`, jamais `innerHTML` ; URLs construites via `encodeURIComponent`", | |
| 184 | + ] }, | |
| 185 | + ]}, | |
| 186 | + Functionality { key: "teams", label: "Teams", always: false, screens: &[ | |
| 187 | + Screen { name: "TeamsManagement", suggested_route: "/teams", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner, | |
| 188 | + core_methods: "TeamService (create/add_member/…), TeamRole", io: "équipes + membres (owner/admin/member)", | |
| 189 | + security_notes: &[ | |
| 190 | + "IDOR/RBAC : vérifier le rôle de l'appelant dans CETTE équipe (`role_of`) avant chaque mutation ; owner non ré-attribuable", | |
| 191 | + "à l'ajout d'un membre : message générique si l'utilisateur est introuvable (anti-énumération)", | |
| 192 | + ] }, | |
| 193 | + ]}, | |
| 194 | + Functionality { key: "personal_access_tokens", label: "PersonalAccessTokens", always: false, screens: &[ | |
| 195 | + Screen { name: "PatManagement", suggested_route: "/settings/tokens", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner, | |
| 196 | + core_methods: "PersonalTokenService::create/list_for_user/revoke", io: "créer/révoquer jetons (plaintext 1 fois)", | |
| 197 | + security_notes: &[ | |
| 198 | + "jeton en clair affiché une seule fois, jamais reconsultable ni logué", | |
| 199 | + "IDOR : révocation et listage filtrés par `claims.sub`", | |
| 200 | + ] }, | |
| 201 | + ]}, | |
| 202 | + Functionality { key: "admin_users", label: "Admin · Users", always: false, screens: &[ | |
| 203 | + Screen { name: "AdminUsers", suggested_route: "/admin/users", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 204 | + core_methods: "UserService, RoleService, PasswordResetService, TotpService", io: "liste + CRUD + reset", | |
| 205 | + security_notes: &[ | |
| 206 | + "`ensure_admin()` dans le handler en plus du middleware (défense en profondeur)", | |
| 207 | + "empêcher l'auto-suppression ; message générique si utilisateur introuvable", | |
| 208 | + ] }, | |
| 209 | + ]}, | |
| 210 | + Functionality { key: "admin_roles", label: "Admin · Roles", always: false, screens: &[ | |
| 211 | + Screen { name: "AdminRoles", suggested_route: "/admin/roles", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 212 | + core_methods: "RoleService, PermissionService", io: "liste + CRUD rôles/permissions", | |
| 213 | + security_notes: &[ | |
| 214 | + "`ensure_admin()` en défense en profondeur ; protéger le dernier admin", | |
| 215 | + "ne pas permettre l'auto-escalade de permissions", | |
| 216 | + ] }, | |
| 217 | + ]}, | |
| 218 | + Functionality { key: "admin_settings", label: "Admin · Settings", always: false, screens: &[ | |
| 219 | + Screen { name: "AdminSettings", suggested_route: "/admin/settings", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 220 | + core_methods: "AppSettingsService, CryptoService", io: "réglages applicatifs", | |
| 221 | + security_notes: &[ | |
| 222 | + "`ensure_admin()` en défense en profondeur ; secrets chiffrés via CryptoService, jamais rendus en clair", | |
| 223 | + ] }, | |
| 224 | + ]}, | |
| 225 | + Functionality { key: "admin_error_log", label: "Admin · ErrorLog", always: false, screens: &[ | |
| 226 | + Screen { name: "AdminErrorLog", suggested_route: "/admin/errors", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 227 | + core_methods: "error_handler, I18nService", io: "journal d'erreurs", | |
| 228 | + security_notes: &[ | |
| 229 | + "`ensure_admin()` en défense en profondeur ; ne pas exposer de stack traces / chemins internes en production", | |
| 230 | + ] }, | |
| 231 | + ]}, | |
| 232 | + Functionality { key: "admin_email_queue", label: "Admin · EmailQueue", always: false, screens: &[ | |
| 233 | + Screen { name: "AdminEmailQueue", suggested_route: "/admin/emails", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 234 | + core_methods: "EmailQueueService", io: "file d'emails", | |
| 235 | + security_notes: &[ | |
| 236 | + "`ensure_admin()` en défense en profondeur ; ne pas afficher de contenu d'email sensible en clair", | |
| 237 | + ] }, | |
| 238 | + ]}, | |
| 239 | + Functionality { key: "admin_dashboard", label: "Admin · Dashboard", always: false, screens: &[ | |
| 240 | + Screen { name: "AdminDashboard", suggested_route: "/admin", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 241 | + core_methods: "cartes DashboardCardDTO", io: "accueil admin", | |
| 242 | + security_notes: &[ | |
| 243 | + "`ensure_admin()` en défense en profondeur ; cartes injectées correctement échappées", | |
| 244 | + ] }, | |
| 245 | + ]}, | |
| 246 | +]; | |
| 247 | + | |
| 248 | +#[cfg(test)] | |
| 249 | +mod tests { | |
| 250 | + use super::*; | |
| 251 | + | |
| 252 | + #[test] | |
| 253 | + fn catalogue_invariants() { | |
| 254 | + assert!(!all().is_empty(), "catalogue non vide"); | |
| 255 | + // clés uniques | |
| 256 | + for (i, f) in all().iter().enumerate() { | |
| 257 | + assert!(!f.key.is_empty() && !f.label.is_empty()); | |
| 258 | + assert!(!f.screens.is_empty(), "{} a au moins un écran", f.key); | |
| 259 | + assert!( | |
| 260 | + all().iter().skip(i + 1).all(|g| g.key != f.key), | |
| 261 | + "clé dupliquée: {}", | |
| 262 | + f.key | |
| 263 | + ); | |
| 264 | + } | |
| 265 | + // exactement une fonctionnalité "always" (Authentication) | |
| 266 | + assert_eq!(all().iter().filter(|f| f.always).count(), 1); | |
| 267 | + } | |
| 268 | + | |
| 269 | + #[test] | |
| 270 | + fn catalogue_couvre_les_fonctionnalites_attendues() { | |
| 271 | + let expected = [ | |
| 272 | + "authentication", "registration", "password_reset", "email_validation", | |
| 273 | + "two_factor", "account_profile", "password_change", "session_management", | |
| 274 | + "preferences", "oauth_linking", "teams", "personal_access_tokens", | |
| 275 | + "admin_users", "admin_roles", "admin_settings", "admin_error_log", | |
| 276 | + "admin_email_queue", "admin_dashboard", | |
| 277 | + ]; | |
| 278 | + let keys: Vec<&str> = all().iter().map(|f| f.key).collect(); | |
| 279 | + for k in expected { | |
| 280 | + assert!(keys.contains(&k), "fonctionnalité manquante au catalogue : {k}"); | |
| 281 | + } | |
| 282 | + assert_eq!(keys.len(), expected.len(), "nombre de fonctionnalités inattendu"); | |
| 283 | + } | |
| 284 | + | |
| 285 | + #[test] | |
| 286 | + fn auth_level_preserve_la_protection_existante() { | |
| 287 | + // Public ⇒ non protégé ; tout le reste ⇒ protégé (colonne du tableau inchangée). | |
| 288 | + assert!(!AuthLevel::Public.is_protected()); | |
| 289 | + assert!(AuthLevel::Authenticated.is_protected()); | |
| 290 | + assert!(AuthLevel::ResourceOwner.is_protected()); | |
| 291 | + assert!(AuthLevel::Admin.is_protected()); | |
| 292 | + // Login reste public ; tous les écrans admin restent en niveau Admin. | |
| 293 | + assert_eq!( | |
| 294 | + by_key("authentication").unwrap().screens[0].auth_level, | |
| 295 | + AuthLevel::Public | |
| 296 | + ); | |
| 297 | + assert!( | |
| 298 | + all() | |
| 299 | + .iter() | |
| 300 | + .filter(|f| f.key.starts_with("admin_")) | |
| 301 | + .flat_map(|f| f.screens) | |
| 302 | + .all(|s| s.auth_level == AuthLevel::Admin), | |
| 303 | + "tous les écrans admin doivent être AuthLevel::Admin" | |
| 304 | + ); | |
| 305 | + } | |
| 306 | + | |
| 307 | + #[test] | |
| 308 | + fn contrats_de_securite_couvrent_les_pieges_de_l_audit() { | |
| 309 | + let note = |key: &str, screen: &str, needle: &str| { | |
| 310 | + by_key(key) | |
| 311 | + .unwrap() | |
| 312 | + .screens | |
| 313 | + .iter() | |
| 314 | + .find(|s| s.name == screen) | |
| 315 | + .unwrap_or_else(|| panic!("écran {screen} introuvable")) | |
| 316 | + .security_notes | |
| 317 | + .iter() | |
| 318 | + .any(|n| n.to_lowercase().contains(needle)) | |
| 319 | + }; | |
| 320 | + // #1 XSS — token jamais dans un <script>, passer par data-*. | |
| 321 | + assert!(note("password_reset", "ResetPassword", "data-")); | |
| 322 | + // #2 open redirect ?redirect=. | |
| 323 | + assert!(note("authentication", "Login", "redirect")); | |
| 324 | + // #4 RawQuery pass-through. | |
| 325 | + assert!(note("two_factor", "TwoFactorSetup", "rawquery")); | |
| 326 | + | |
| 327 | + // Invariants par niveau + présence d'au moins une note partout. | |
| 328 | + for f in all() { | |
| 329 | + for s in f.screens { | |
| 330 | + assert!( | |
| 331 | + !s.security_notes.is_empty(), | |
| 332 | + "{} doit porter au moins une note de sécurité", | |
| 333 | + s.name | |
| 334 | + ); | |
| 335 | + match s.auth_level { | |
| 336 | + AuthLevel::Admin => assert!( | |
| 337 | + s.security_notes.iter().any(|n| n.contains("ensure_admin")), | |
| 338 | + "{} (Admin) doit rappeler ensure_admin()", | |
| 339 | + s.name | |
| 340 | + ), | |
| 341 | + AuthLevel::ResourceOwner => assert!( | |
| 342 | + s.security_notes.iter().any(|n| { | |
| 343 | + let l = n.to_lowercase(); | |
| 344 | + l.contains("idor") | |
| 345 | + || l.contains("claims.sub") | |
| 346 | + || l.contains("appartenance") | |
| 347 | + }), | |
| 348 | + "{} (ResourceOwner) doit rappeler le contrôle d'appartenance (IDOR)", | |
| 349 | + s.name | |
| 350 | + ), | |
| 351 | + _ => {} | |
| 352 | + } | |
| 353 | + } | |
| 354 | + } | |
| 355 | + } | |
| 356 | +} |
A
crates/rustwarden-core/src/bin/screen-select/check.rs
+95
-0
@@ -0,0 +1,95 @@
| 1 | +//! Contrôle statique du réalisé : une route du todo est « faite » si un | |
| 2 | +//! `.route("<chemin>")` (chaîne exacte) apparaît dans les sources du projet. | |
| 3 | + | |
| 4 | +use std::path::Path; | |
| 5 | + | |
| 6 | +use crate::todo::TodoRow; | |
| 7 | + | |
| 8 | +/// (pur) Marque `done` selon la présence de `"<route>"` dans `haystack`. | |
| 9 | +pub fn mark(rows: &mut [TodoRow], haystack: &str) { | |
| 10 | + for r in rows.iter_mut() { | |
| 11 | + // chaîne littérale exacte, guillemet fermant inclus → évite les | |
| 12 | + // faux positifs de préfixe (/settings vs /settings/sessions). | |
| 13 | + let needle = format!("\"{}\"", r.route); | |
| 14 | + r.done = haystack.contains(&needle); | |
| 15 | + } | |
| 16 | +} | |
| 17 | + | |
| 18 | +/// (I/O) Concatène le contenu des `.rs` sous `root`, en ignorant `target/` | |
| 19 | +/// et les crates vendored `rustwarden-*`. | |
| 20 | +pub fn collect_sources(root: &Path) -> String { | |
| 21 | + let mut out = String::new(); | |
| 22 | + walk(root, &mut out); | |
| 23 | + out | |
| 24 | +} | |
| 25 | + | |
| 26 | +fn walk(dir: &Path, out: &mut String) { | |
| 27 | + let Ok(entries) = std::fs::read_dir(dir) else { | |
| 28 | + return; | |
| 29 | + }; | |
| 30 | + for entry in entries.flatten() { | |
| 31 | + let path = entry.path(); | |
| 32 | + let name = entry.file_name(); | |
| 33 | + let name = name.to_string_lossy(); | |
| 34 | + if path.is_dir() { | |
| 35 | + if name == "target" || name.starts_with("rustwarden-") { | |
| 36 | + continue; | |
| 37 | + } | |
| 38 | + walk(&path, out); | |
| 39 | + } else if path.extension().is_some_and(|e| e == "rs") { | |
| 40 | + if let Ok(s) = std::fs::read_to_string(&path) { | |
| 41 | + out.push_str(&s); | |
| 42 | + out.push('\n'); | |
| 43 | + } | |
| 44 | + } | |
| 45 | + } | |
| 46 | +} | |
| 47 | + | |
| 48 | +#[cfg(test)] | |
| 49 | +mod tests { | |
| 50 | + use super::*; | |
| 51 | + | |
| 52 | + fn row(route: &str) -> TodoRow { | |
| 53 | + TodoRow { | |
| 54 | + done: false, functionality: "F".into(), screen: "S".into(), | |
| 55 | + route: route.into(), channel: "browser".into(), protected: true, | |
| 56 | + methods: "".into(), io: "".into(), | |
| 57 | + } | |
| 58 | + } | |
| 59 | + | |
| 60 | + #[test] | |
| 61 | + fn mark_detecte_route_presente() { | |
| 62 | + let mut rows = vec![row("/profile"), row("/settings/sessions")]; | |
| 63 | + let haystack = r#"router.route("/profile", get(profile))"#; | |
| 64 | + mark(&mut rows, haystack); | |
| 65 | + assert!(rows[0].done, "/profile trouvée"); | |
| 66 | + assert!(!rows[1].done, "/settings/sessions absente"); | |
| 67 | + } | |
| 68 | + | |
| 69 | + #[test] | |
| 70 | + fn mark_evite_les_faux_positifs_de_prefixe() { | |
| 71 | + // "/settings" ne doit PAS matcher "/settings/sessions" | |
| 72 | + let mut rows = vec![row("/settings")]; | |
| 73 | + let haystack = r#".route("/settings/sessions", get(h))"#; | |
| 74 | + mark(&mut rows, haystack); | |
| 75 | + assert!(!rows[0].done); | |
| 76 | + } | |
| 77 | + | |
| 78 | + #[test] | |
| 79 | + fn collect_sources_ignore_target_et_vendored() { | |
| 80 | + let base = std::env::temp_dir().join(format!("scrsel_{}", std::process::id())); | |
| 81 | + let _ = std::fs::remove_dir_all(&base); | |
| 82 | + std::fs::create_dir_all(base.join("src")).unwrap(); | |
| 83 | + std::fs::create_dir_all(base.join("crates/rustwarden-core/src")).unwrap(); | |
| 84 | + std::fs::create_dir_all(base.join("target")).unwrap(); | |
| 85 | + std::fs::write(base.join("src/app.rs"), r#".route("/mine", get(h))"#).unwrap(); | |
| 86 | + std::fs::write(base.join("crates/rustwarden-core/src/x.rs"), r#".route("/vendored", get(h))"#).unwrap(); | |
| 87 | + std::fs::write(base.join("target/y.rs"), r#".route("/built", get(h))"#).unwrap(); | |
| 88 | + | |
| 89 | + let hay = collect_sources(&base); | |
| 90 | + assert!(hay.contains("/mine")); | |
| 91 | + assert!(!hay.contains("/vendored")); | |
| 92 | + assert!(!hay.contains("/built")); | |
| 93 | + let _ = std::fs::remove_dir_all(&base); | |
| 94 | + } | |
| 95 | +} |
A
crates/rustwarden-core/src/bin/screen-select/generate.rs
+127
-0
@@ -0,0 +1,127 @@
| 1 | +//! Construction des lignes de todo et rendu du plan Markdown complet. | |
| 2 | + | |
| 3 | +use crate::catalogue::Functionality; | |
| 4 | +use crate::todo::{self, TodoRow}; | |
| 5 | +use crate::{section, security}; | |
| 6 | + | |
| 7 | +/// Entrées de génération du plan. | |
| 8 | +pub struct PlanInputs<'a> { | |
| 9 | + pub project: &'a str, | |
| 10 | + pub selected: Vec<&'static Functionality>, | |
| 11 | + pub design_md: Option<String>, | |
| 12 | + pub layout: Option<String>, | |
| 13 | +} | |
| 14 | + | |
| 15 | +/// Aplati les fonctionnalités sélectionnées en lignes de todo (non faites). | |
| 16 | +pub fn rows_for(selected: &[&'static Functionality]) -> Vec<TodoRow> { | |
| 17 | + let mut rows = Vec::new(); | |
| 18 | + for f in selected { | |
| 19 | + for s in f.screens { | |
| 20 | + rows.push(TodoRow { | |
| 21 | + done: false, | |
| 22 | + functionality: f.label.to_string(), | |
| 23 | + screen: s.name.to_string(), | |
| 24 | + route: s.suggested_route.to_string(), | |
| 25 | + channel: s.channel.label().to_string(), | |
| 26 | + protected: s.auth_level.is_protected(), | |
| 27 | + methods: s.core_methods.to_string(), | |
| 28 | + io: s.io.to_string(), | |
| 29 | + }); | |
| 30 | + } | |
| 31 | + } | |
| 32 | + rows | |
| 33 | +} | |
| 34 | + | |
| 35 | +/// Rend le document complet : titre, charte, structure, tableau. | |
| 36 | +pub fn render_plan(inputs: &PlanInputs, rows: &[TodoRow]) -> String { | |
| 37 | + let mut out = String::new(); | |
| 38 | + out.push_str(&format!("# Plan UI — {}\n\n", inputs.project)); | |
| 39 | + out.push_str("> Généré par `cargo run -p rustwarden-core --bin screen-select`. Recochez le réalisé via `cargo run -p rustwarden-core --bin screen-select -- --check`.\n\n"); | |
| 40 | + out.push_str(&format!( | |
| 41 | + "Codez ces écrans dans une crate dédiée **`{}-ui`** consommant `rustwarden-core`, montée dans le routeur principal.\n\n", | |
| 42 | + inputs.project | |
| 43 | + )); | |
| 44 | + // Garde-fous transversaux + conventions de rendu sûr (statique, vu en premier). | |
| 45 | + out.push_str(§ion::block(security::GUIDES_ID, &security::guides_body())); | |
| 46 | + out.push_str("\n\n"); | |
| 47 | + if let Some(design) = &inputs.design_md { | |
| 48 | + out.push_str("## Charte graphique (à respecter)\n\n"); | |
| 49 | + out.push_str(design.trim()); | |
| 50 | + out.push_str("\n\n"); | |
| 51 | + } | |
| 52 | + if let Some(layout) = &inputs.layout { | |
| 53 | + out.push_str("## Structure de page\n\n"); | |
| 54 | + out.push_str(layout.trim()); | |
| 55 | + out.push_str("\n\n"); | |
| 56 | + } | |
| 57 | + out.push_str("## Écrans à coder\n\n"); | |
| 58 | + out.push_str(&todo::render(rows)); | |
| 59 | + out.push('\n'); | |
| 60 | + // Contrat de sécurité par écran (dérivé du catalogue, en référence sous le tableau). | |
| 61 | + out.push_str(§ion::block(security::PER_SCREEN_ID, &security::reference_body(&inputs.selected))); | |
| 62 | + out.push('\n'); | |
| 63 | + out | |
| 64 | +} | |
| 65 | + | |
| 66 | +#[cfg(test)] | |
| 67 | +mod tests { | |
| 68 | + use super::*; | |
| 69 | + use crate::catalogue; | |
| 70 | + | |
| 71 | + #[test] | |
| 72 | + fn rows_for_aplati_les_ecrans() { | |
| 73 | + let two_factor = catalogue::by_key("two_factor").unwrap(); | |
| 74 | + let rows = rows_for(&[two_factor]); | |
| 75 | + assert_eq!(rows.len(), 3); // challenge + setup + backup | |
| 76 | + assert!(rows.iter().all(|r| !r.done)); | |
| 77 | + assert!(rows.iter().any(|r| r.screen == "TwoFactorSetup")); | |
| 78 | + } | |
| 79 | + | |
| 80 | + #[test] | |
| 81 | + fn render_plan_inclut_charte_et_tableau() { | |
| 82 | + let auth = catalogue::by_key("authentication").unwrap(); | |
| 83 | + let inputs = PlanInputs { | |
| 84 | + project: "demo", | |
| 85 | + selected: vec![auth], | |
| 86 | + design_md: Some("Police: Inter. Couleur: #123456".into()), | |
| 87 | + layout: Some("navbar: gauche; 2 colonnes".into()), | |
| 88 | + }; | |
| 89 | + let rows = rows_for(&inputs.selected); | |
| 90 | + let out = render_plan(&inputs, &rows); | |
| 91 | + assert!(out.contains("# Plan UI — demo")); | |
| 92 | + assert!(out.contains("**`demo-ui`**"), "l'intro doit nommer la crate <projet>-ui"); | |
| 93 | + assert!(out.contains("Police: Inter")); | |
| 94 | + assert!(out.contains("navbar: gauche")); | |
| 95 | + assert!(out.contains("| Statut | Fonctionnalité |")); | |
| 96 | + assert!(out.contains("Login")); | |
| 97 | + } | |
| 98 | + | |
| 99 | + #[test] | |
| 100 | + fn rows_for_derive_protected_du_niveau_auth() { | |
| 101 | + // Login est Public ⇒ non protégé ; TwoFactorSetup est Authentifié ⇒ protégé. | |
| 102 | + let auth = catalogue::by_key("authentication").unwrap(); | |
| 103 | + let tf = catalogue::by_key("two_factor").unwrap(); | |
| 104 | + let login = &rows_for(&[auth])[0]; | |
| 105 | + assert!(!login.protected); | |
| 106 | + let setup = rows_for(&[tf]).into_iter().find(|r| r.screen == "TwoFactorSetup").unwrap(); | |
| 107 | + assert!(setup.protected); | |
| 108 | + } | |
| 109 | + | |
| 110 | + #[test] | |
| 111 | + fn render_plan_inclut_les_sections_securite() { | |
| 112 | + let auth = catalogue::by_key("authentication").unwrap(); | |
| 113 | + let inputs = PlanInputs { project: "demo", selected: vec![auth], design_md: None, layout: None }; | |
| 114 | + let rows = rows_for(&inputs.selected); | |
| 115 | + let out = render_plan(&inputs, &rows); | |
| 116 | + assert!(out.contains("<!-- screen-select:security-guides -->")); | |
| 117 | + assert!(out.contains("Sécurité transversale")); | |
| 118 | + assert!(out.contains("Conventions de rendu sûr")); | |
| 119 | + assert!(out.contains("<!-- screen-select:security-per-screen -->")); | |
| 120 | + assert!(out.contains("## Contrat de sécurité par écran")); | |
| 121 | + assert!(out.contains("### Login (`/login`) — Public")); | |
| 122 | + // le bloc statique doit précéder le tableau (vu en premier par le dev) | |
| 123 | + let guides = out.find("Sécurité transversale").unwrap(); | |
| 124 | + let table = out.find("| Statut").unwrap(); | |
| 125 | + assert!(guides < table, "les garde-fous transversaux doivent précéder le tableau"); | |
| 126 | + } | |
| 127 | +} |
A
crates/rustwarden-core/src/bin/screen-select/main.rs
+125
-0
@@ -0,0 +1,125 @@
| 1 | +//! `screen-select` : génère/complète le plan des écrans à coder pour une UI | |
| 2 | +//! custom consommant rustwarden-core, et recoche le réalisé (`--check`). | |
| 3 | + | |
| 4 | +mod catalogue; | |
| 5 | +mod check; | |
| 6 | +mod generate; | |
| 7 | +mod section; | |
| 8 | +mod security; | |
| 9 | +mod todo; | |
| 10 | + | |
| 11 | +use std::io::{self, Write}; | |
| 12 | +use std::path::Path; | |
| 13 | + | |
| 14 | +const PLAN_PATH: &str = "docs/plans/ui-screens.md"; | |
| 15 | + | |
| 16 | +fn main() -> io::Result<()> { | |
| 17 | + if std::env::args().any(|a| a == "--check") { | |
| 18 | + run_check() | |
| 19 | + } else { | |
| 20 | + run_wizard() | |
| 21 | + } | |
| 22 | +} | |
| 23 | + | |
| 24 | +/// Lit une ligne au clavier (chaîne vide si EOF). | |
| 25 | +fn prompt(msg: &str) -> io::Result<String> { | |
| 26 | + print!("{msg}"); | |
| 27 | + io::stdout().flush()?; | |
| 28 | + let mut line = String::new(); | |
| 29 | + io::stdin().read_line(&mut line)?; | |
| 30 | + Ok(line.trim().to_string()) | |
| 31 | +} | |
| 32 | + | |
| 33 | +fn run_wizard() -> io::Result<()> { | |
| 34 | + println!("== screen-select : écrans à coder ==\n"); | |
| 35 | + | |
| 36 | + // 1. Sélection des fonctionnalités (les "always" sont incluses d'office). | |
| 37 | + let choosable: Vec<&catalogue::Functionality> = | |
| 38 | + catalogue::all().iter().filter(|f| !f.always).collect(); | |
| 39 | + println!("Fonctionnalités activées ? (numéros séparés par des virgules)\n"); | |
| 40 | + for (i, f) in choosable.iter().enumerate() { | |
| 41 | + println!(" {:>2}. {}", i + 1, f.label); | |
| 42 | + } | |
| 43 | + let raw = prompt("\nSélection : ")?; | |
| 44 | + let picked: Vec<usize> = raw | |
| 45 | + .split(',') | |
| 46 | + .filter_map(|s| s.trim().parse::<usize>().ok()) | |
| 47 | + .filter(|n| *n >= 1 && *n <= choosable.len()) | |
| 48 | + .map(|n| n - 1) | |
| 49 | + .collect(); | |
| 50 | + | |
| 51 | + let mut selected: Vec<&catalogue::Functionality> = | |
| 52 | + catalogue::all().iter().filter(|f| f.always).collect(); | |
| 53 | + for i in picked { | |
| 54 | + selected.push(choosable[i]); | |
| 55 | + } | |
| 56 | + | |
| 57 | + // 2. Entrées optionnelles. | |
| 58 | + let design_path = prompt("Chemin d'un design.md (charte graphique, vide si aucun) : ")?; | |
| 59 | + let design_md = if design_path.is_empty() { | |
| 60 | + None | |
| 61 | + } else { | |
| 62 | + Some(std::fs::read_to_string(&design_path)?) | |
| 63 | + }; | |
| 64 | + let layout = prompt("Structure de page (ex. 'navbar: gauche; 2 colonnes', vide si aucune) : ")?; | |
| 65 | + let layout = if layout.is_empty() { None } else { Some(layout) }; | |
| 66 | + | |
| 67 | + let project = std::env::current_dir()? | |
| 68 | + .file_name() | |
| 69 | + .map(|n| n.to_string_lossy().into_owned()) | |
| 70 | + .unwrap_or_else(|| "projet".into()); | |
| 71 | + | |
| 72 | + let inputs = generate::PlanInputs { project: &project, selected, design_md, layout }; | |
| 73 | + let desired = generate::rows_for(&inputs.selected); | |
| 74 | + | |
| 75 | + // 3. Fusion si un plan existe déjà (préserve statuts + routes éditées). | |
| 76 | + let path = Path::new(PLAN_PATH); | |
| 77 | + let content = if path.exists() { | |
| 78 | + let existing = std::fs::read_to_string(path)?; | |
| 79 | + let prev = todo::parse(&existing); | |
| 80 | + let merged = todo::merge(&prev, &desired); | |
| 81 | + // conserve l'en-tête existant, remplace le tableau… | |
| 82 | + let mut c = todo::replace_table(&existing, &merged); | |
| 83 | + // …puis régénère les sections sécurité (idempotent, suit la sélection courante). | |
| 84 | + c = section::upsert(&c, security::GUIDES_ID, &security::guides_body()); | |
| 85 | + c = section::upsert(&c, security::PER_SCREEN_ID, &security::reference_body(&inputs.selected)); | |
| 86 | + c | |
| 87 | + } else { | |
| 88 | + generate::render_plan(&inputs, &desired) | |
| 89 | + }; | |
| 90 | + | |
| 91 | + if let Some(parent) = path.parent() { | |
| 92 | + std::fs::create_dir_all(parent)?; | |
| 93 | + } | |
| 94 | + std::fs::write(path, content)?; | |
| 95 | + println!("\n✓ Plan écrit dans {PLAN_PATH} ({} écrans).", desired.len()); | |
| 96 | + println!(" Contrôlez le réalisé : cargo run -p rustwarden-core --bin screen-select -- --check"); | |
| 97 | + Ok(()) | |
| 98 | +} | |
| 99 | + | |
| 100 | +fn run_check() -> io::Result<()> { | |
| 101 | + let path = Path::new(PLAN_PATH); | |
| 102 | + if !path.exists() { | |
| 103 | + eprintln!("Aucun plan à {PLAN_PATH} — lancez d'abord `screen-select` sans --check."); | |
| 104 | + std::process::exit(1); | |
| 105 | + } | |
| 106 | + let content = std::fs::read_to_string(path)?; | |
| 107 | + let mut rows = todo::parse(&content); | |
| 108 | + let haystack = check::collect_sources(&std::env::current_dir()?); | |
| 109 | + check::mark(&mut rows, &haystack); | |
| 110 | + let updated = todo::replace_table(&content, &rows); | |
| 111 | + std::fs::write(path, updated)?; | |
| 112 | + | |
| 113 | + let done = rows.iter().filter(|r| r.done).count(); | |
| 114 | + let missing: Vec<&todo::TodoRow> = rows.iter().filter(|r| !r.done).collect(); | |
| 115 | + println!("Couverture : {}/{} écrans réalisés.", done, rows.len()); | |
| 116 | + if missing.is_empty() { | |
| 117 | + println!("✓ Tous les écrans des fonctionnalités activées sont câblés."); | |
| 118 | + } else { | |
| 119 | + println!("Écrans manquants :"); | |
| 120 | + for r in missing { | |
| 121 | + println!(" ☐ {} — {} ({})", r.functionality, r.screen, r.route); | |
| 122 | + } | |
| 123 | + } | |
| 124 | + Ok(()) | |
| 125 | +} |
A
crates/rustwarden-core/src/bin/screen-select/section.rs
+63
-0
@@ -0,0 +1,63 @@
| 1 | +//! Remplacement idempotent d'une section délimitée par des marqueurs HTML. | |
| 2 | +//! Permet de régénérer les blocs de sécurité lors d'un re-run du wizard sans | |
| 3 | +//! écraser le reste du document (charte, tableau, prose ajoutée par le dev). | |
| 4 | + | |
| 5 | +/// Bloc encadré : `<!-- id -->\n{body}\n<!-- /id -->`. | |
| 6 | +pub fn block(id: &str, body: &str) -> String { | |
| 7 | + format!("<!-- {id} -->\n{}\n<!-- /{id} -->", body.trim_end()) | |
| 8 | +} | |
| 9 | + | |
| 10 | +/// Insère (à la fin) ou remplace en place la section `id` par `body`. | |
| 11 | +pub fn upsert(content: &str, id: &str, body: &str) -> String { | |
| 12 | + let block = block(id, body); | |
| 13 | + let open = format!("<!-- {id} -->"); | |
| 14 | + let close = format!("<!-- /{id} -->"); | |
| 15 | + if let (Some(start), Some(close_at)) = (content.find(&open), content.find(&close)) { | |
| 16 | + if start < close_at { | |
| 17 | + let end = close_at + close.len(); | |
| 18 | + let mut out = String::with_capacity(content.len() + block.len()); | |
| 19 | + out.push_str(&content[..start]); | |
| 20 | + out.push_str(&block); | |
| 21 | + out.push_str(&content[end..]); | |
| 22 | + return out; | |
| 23 | + } | |
| 24 | + } | |
| 25 | + let mut out = content.to_string(); | |
| 26 | + if !out.ends_with('\n') { | |
| 27 | + out.push('\n'); | |
| 28 | + } | |
| 29 | + out.push('\n'); | |
| 30 | + out.push_str(&block); | |
| 31 | + out.push('\n'); | |
| 32 | + out | |
| 33 | +} | |
| 34 | + | |
| 35 | +#[cfg(test)] | |
| 36 | +mod tests { | |
| 37 | + use super::*; | |
| 38 | + | |
| 39 | + #[test] | |
| 40 | + fn block_encadre_le_corps() { | |
| 41 | + let b = block("demo:x", "corps\n"); | |
| 42 | + assert_eq!(b, "<!-- demo:x -->\ncorps\n<!-- /demo:x -->"); | |
| 43 | + } | |
| 44 | + | |
| 45 | + #[test] | |
| 46 | + fn upsert_ajoute_a_la_fin_si_absent() { | |
| 47 | + let out = upsert("# Titre\n", "demo:x", "corps"); | |
| 48 | + assert!(out.starts_with("# Titre\n")); | |
| 49 | + assert!(out.contains("<!-- demo:x -->\ncorps\n<!-- /demo:x -->")); | |
| 50 | + } | |
| 51 | + | |
| 52 | + #[test] | |
| 53 | + fn upsert_remplace_en_place_et_est_idempotent() { | |
| 54 | + let base = format!("A\n\n{}\n\nB\n", block("demo:x", "v1")); | |
| 55 | + let once = upsert(&base, "demo:x", "v2"); | |
| 56 | + assert!(once.contains("A\n"), "préfixe préservé"); | |
| 57 | + assert!(once.contains("B\n"), "suffixe préservé"); | |
| 58 | + assert!(once.contains("v2")); | |
| 59 | + assert!(!once.contains("v1"), "ancien corps remplacé"); | |
| 60 | + // idempotence : ré-appliquer avec le même corps ne change rien. | |
| 61 | + assert_eq!(upsert(&once, "demo:x", "v2"), once); | |
| 62 | + } | |
| 63 | +} |
A
crates/rustwarden-core/src/bin/screen-select/security.rs
+89
-0
@@ -0,0 +1,89 @@
| 1 | +//! Contenu de sécurité injecté dans le plan : garde-fous transversaux (statiques) | |
| 2 | +//! et contrat de sécurité par écran (dérivé du catalogue). | |
| 3 | + | |
| 4 | +use crate::catalogue::{Channel, Functionality}; | |
| 5 | + | |
| 6 | +/// Identifiant de section pour le bloc statique (transversal + conventions). | |
| 7 | +pub const GUIDES_ID: &str = "screen-select:security-guides"; | |
| 8 | +/// Identifiant de section pour le contrat de sécurité par écran. | |
| 9 | +pub const PER_SCREEN_ID: &str = "screen-select:security-per-screen"; | |
| 10 | + | |
| 11 | +/// Corps statique : sécurité transversale (obligatoire, hors écran) + conventions | |
| 12 | +/// de rendu sûr. Indépendant de la sélection. | |
| 13 | +pub fn guides_body() -> String { | |
| 14 | + let mut s = String::new(); | |
| 15 | + s.push_str("## Sécurité transversale (obligatoire, hors écran)\n\n"); | |
| 16 | + s.push_str("- [ ] Middleware de headers sur TOUTES les réponses : `Content-Security-Policy` (viser sans `unsafe-inline`/`unsafe-eval`), `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`.\n"); | |
| 17 | + s.push_str("- [ ] Validation de configuration au démarrage : refuser `ADMIN_PASSWORD=password` ; avertir si `APP_DEBUG=true`, `COOKIE_SECURE=false` ou `RUST_LOG=debug` en profil production ; secrets uniquement via variables d'environnement.\n"); | |
| 18 | + s.push_str("- [ ] Cookies de session : `HttpOnly`, `Secure` en production, `SameSite`.\n"); | |
| 19 | + s.push_str("- [ ] `cargo audit` régulier ; confirmer que le JWT est signé en **HMAC** (HS256/384/512), pas RSA (neutralise RUSTSEC-2023-0071) ; `accept_invalid_certs` SMTP/IMAP à `false` en production.\n\n"); | |
| 20 | + s.push_str("## Conventions de rendu sûr\n\n"); | |
| 21 | + s.push_str("- **Escaping contextuel** : ne jamais interpoler une valeur contrôlée par l'utilisateur dans un contexte JavaScript (`<script>`). L'échappement HTML d'Askama n'échappe PAS le contexte JS (le backslash notamment). Passer la valeur par un attribut `data-*` et la lire via `dataset`.\n"); | |
| 22 | + s.push_str("- Données dynamiques d'API côté client : `textContent`, jamais `innerHTML`.\n"); | |
| 23 | + s.push_str("- URLs : `encodeURIComponent` / helper `urlenc()` du core ; aucune donnée personnelle en query string.\n"); | |
| 24 | + s.push_str("- Aucun filtre `|safe` Askama sur une valeur contrôlée par l'utilisateur.\n"); | |
| 25 | + s | |
| 26 | +} | |
| 27 | + | |
| 28 | +/// Corps dérivé du catalogue : un paragraphe par écran des fonctionnalités | |
| 29 | +/// sélectionnées (niveau d'auth, CSRF si browser, pièges spécifiques). | |
| 30 | +pub fn reference_body(selected: &[&'static Functionality]) -> String { | |
| 31 | + let mut s = String::new(); | |
| 32 | + s.push_str("## Contrat de sécurité par écran\n\n"); | |
| 33 | + for f in selected { | |
| 34 | + for sc in f.screens { | |
| 35 | + s.push_str(&format!( | |
| 36 | + "### {} (`{}`) — {}\n\n", | |
| 37 | + sc.name, | |
| 38 | + sc.suggested_route, | |
| 39 | + sc.auth_level.label() | |
| 40 | + )); | |
| 41 | + if matches!(sc.channel, Channel::Browser) { | |
| 42 | + s.push_str("- Canal *browser* ⇒ valider le token CSRF sur chaque `POST` (`validate_csrf_token`).\n"); | |
| 43 | + } | |
| 44 | + for note in sc.security_notes { | |
| 45 | + s.push_str(&format!("- {note}\n")); | |
| 46 | + } | |
| 47 | + s.push('\n'); | |
| 48 | + } | |
| 49 | + } | |
| 50 | + s | |
| 51 | +} | |
| 52 | + | |
| 53 | +#[cfg(test)] | |
| 54 | +mod tests { | |
| 55 | + use super::*; | |
| 56 | + use crate::catalogue; | |
| 57 | + | |
| 58 | + #[test] | |
| 59 | + fn guides_couvre_les_axes_transversaux() { | |
| 60 | + let g = guides_body(); | |
| 61 | + assert!(g.contains("Sécurité transversale")); | |
| 62 | + assert!(g.contains("Content-Security-Policy")); | |
| 63 | + assert!(g.contains("X-Frame-Options")); | |
| 64 | + assert!(g.contains("ADMIN_PASSWORD")); | |
| 65 | + assert!(g.contains("HMAC")); | |
| 66 | + assert!(g.contains("Conventions de rendu sûr")); | |
| 67 | + assert!(g.contains("textContent")); | |
| 68 | + } | |
| 69 | + | |
| 70 | + #[test] | |
| 71 | + fn reference_rend_le_contrat_par_ecran() { | |
| 72 | + let two_factor = catalogue::by_key("two_factor").unwrap(); | |
| 73 | + let r = reference_body(&[two_factor]); | |
| 74 | + assert!(r.contains("## Contrat de sécurité par écran")); | |
| 75 | + assert!(r.contains("### TwoFactorSetup (`/settings/2fa`) — Authentifié")); | |
| 76 | + // canal browser ⇒ rappel CSRF automatique | |
| 77 | + assert!(r.contains("CSRF")); | |
| 78 | + // piège spécifique repris du catalogue | |
| 79 | + assert!(r.to_lowercase().contains("rawquery")); | |
| 80 | + } | |
| 81 | + | |
| 82 | + #[test] | |
| 83 | + fn reference_marque_le_niveau_admin() { | |
| 84 | + let admin = catalogue::by_key("admin_users").unwrap(); | |
| 85 | + let r = reference_body(&[admin]); | |
| 86 | + assert!(r.contains("— Administrateur")); | |
| 87 | + assert!(r.contains("ensure_admin")); | |
| 88 | + } | |
| 89 | +} |
A
crates/rustwarden-core/src/bin/screen-select/todo.rs
+207
-0
@@ -0,0 +1,207 @@
| 1 | +//! Rendu / lecture / fusion du tableau de todo Markdown. | |
| 2 | + | |
| 3 | +/// Une ligne du tableau de todo. | |
| 4 | +#[derive(Clone, Debug, PartialEq, Eq)] | |
| 5 | +pub struct TodoRow { | |
| 6 | + pub done: bool, | |
| 7 | + pub functionality: String, | |
| 8 | + pub screen: String, | |
| 9 | + pub route: String, | |
| 10 | + pub channel: String, | |
| 11 | + pub protected: bool, | |
| 12 | + pub methods: String, | |
| 13 | + pub io: String, | |
| 14 | +} | |
| 15 | + | |
| 16 | +const HEADER: &str = | |
| 17 | + "| Statut | Fonctionnalité | Écran | Route | Canal | Protégé | Méthodes core | I/O |"; | |
| 18 | +const SEP: &str = "|---|---|---|---|---|---|---|---|"; | |
| 19 | + | |
| 20 | +/// Rend les lignes en tableau Markdown (en-tête + séparateur + lignes). | |
| 21 | +pub fn render(rows: &[TodoRow]) -> String { | |
| 22 | + let mut out = String::new(); | |
| 23 | + out.push_str(HEADER); | |
| 24 | + out.push('\n'); | |
| 25 | + out.push_str(SEP); | |
| 26 | + out.push('\n'); | |
| 27 | + for r in rows { | |
| 28 | + out.push_str(&format!( | |
| 29 | + "| {} | {} | {} | `{}` | {} | {} | {} | {} |\n", | |
| 30 | + if r.done { "☑" } else { "☐" }, | |
| 31 | + r.functionality, | |
| 32 | + r.screen, | |
| 33 | + r.route, | |
| 34 | + r.channel, | |
| 35 | + if r.protected { "oui" } else { "non" }, | |
| 36 | + r.methods, | |
| 37 | + r.io, | |
| 38 | + )); | |
| 39 | + } | |
| 40 | + out | |
| 41 | +} | |
| 42 | + | |
| 43 | +/// Extrait les lignes de données du premier bloc-tableau rencontré. | |
| 44 | +pub fn parse(content: &str) -> Vec<TodoRow> { | |
| 45 | + let mut rows = Vec::new(); | |
| 46 | + for line in content.lines() { | |
| 47 | + let line = line.trim(); | |
| 48 | + if !line.starts_with('|') { | |
| 49 | + continue; | |
| 50 | + } | |
| 51 | + // ignorer en-tête et séparateur | |
| 52 | + if line.starts_with("| Statut") || line.starts_with("|---") { | |
| 53 | + continue; | |
| 54 | + } | |
| 55 | + let cells: Vec<&str> = line | |
| 56 | + .trim_matches('|') | |
| 57 | + .split('|') | |
| 58 | + .map(|c| c.trim()) | |
| 59 | + .collect(); | |
| 60 | + if cells.len() != 8 { | |
| 61 | + continue; | |
| 62 | + } | |
| 63 | + rows.push(TodoRow { | |
| 64 | + done: cells[0].contains('☑'), | |
| 65 | + functionality: cells[1].to_string(), | |
| 66 | + screen: cells[2].to_string(), | |
| 67 | + route: cells[3].trim_matches('`').to_string(), | |
| 68 | + channel: cells[4].to_string(), | |
| 69 | + protected: cells[5] == "oui", | |
| 70 | + methods: cells[6].to_string(), | |
| 71 | + io: cells[7].to_string(), | |
| 72 | + }); | |
| 73 | + } | |
| 74 | + rows | |
| 75 | +} | |
| 76 | + | |
| 77 | +/// Clé d'identité d'une ligne (indépendante de la route, éditable). | |
| 78 | +fn key(r: &TodoRow) -> (&str, &str) { | |
| 79 | + (r.functionality.as_str(), r.screen.as_str()) | |
| 80 | +} | |
| 81 | + | |
| 82 | +/// Produit les lignes pour l'ensemble `desired`, en conservant `done` et | |
| 83 | +/// `route` des lignes `existing` de même (fonctionnalité, écran). | |
| 84 | +pub fn merge(existing: &[TodoRow], desired: &[TodoRow]) -> Vec<TodoRow> { | |
| 85 | + desired | |
| 86 | + .iter() | |
| 87 | + .map(|d| match existing.iter().find(|e| key(e) == key(d)) { | |
| 88 | + Some(prev) => TodoRow { | |
| 89 | + done: prev.done, | |
| 90 | + route: prev.route.clone(), | |
| 91 | + ..d.clone() | |
| 92 | + }, | |
| 93 | + None => d.clone(), | |
| 94 | + }) | |
| 95 | + .collect() | |
| 96 | +} | |
| 97 | + | |
| 98 | +/// Remplace le premier bloc-tableau (`|`…) de `content` par le rendu de `rows`. | |
| 99 | +pub fn replace_table(content: &str, rows: &[TodoRow]) -> String { | |
| 100 | + let lines: Vec<&str> = content.lines().collect(); | |
| 101 | + let start = lines.iter().position(|l| l.trim_start().starts_with('|')); | |
| 102 | + let Some(start) = start else { | |
| 103 | + // pas de tableau existant : on ajoute à la fin | |
| 104 | + let mut out = content.to_string(); | |
| 105 | + if !out.ends_with('\n') { | |
| 106 | + out.push('\n'); | |
| 107 | + } | |
| 108 | + out.push_str(&render(rows)); | |
| 109 | + return out; | |
| 110 | + }; | |
| 111 | + let mut end = start; | |
| 112 | + while end < lines.len() && lines[end].trim_start().starts_with('|') { | |
| 113 | + end += 1; | |
| 114 | + } | |
| 115 | + let mut out = String::new(); | |
| 116 | + for l in &lines[..start] { | |
| 117 | + out.push_str(l); | |
| 118 | + out.push('\n'); | |
| 119 | + } | |
| 120 | + out.push_str(&render(rows)); | |
| 121 | + for l in &lines[end..] { | |
| 122 | + out.push_str(l); | |
| 123 | + out.push('\n'); | |
| 124 | + } | |
| 125 | + out | |
| 126 | +} | |
| 127 | + | |
| 128 | +#[cfg(test)] | |
| 129 | +mod tests { | |
| 130 | + use super::*; | |
| 131 | + | |
| 132 | + fn row() -> TodoRow { | |
| 133 | + TodoRow { | |
| 134 | + done: false, | |
| 135 | + functionality: "TwoFactor".into(), | |
| 136 | + screen: "TwoFactorSetup".into(), | |
| 137 | + route: "/settings/2fa".into(), | |
| 138 | + channel: "browser".into(), | |
| 139 | + protected: true, | |
| 140 | + methods: "TotpService".into(), | |
| 141 | + io: "QR".into(), | |
| 142 | + } | |
| 143 | + } | |
| 144 | + | |
| 145 | + #[test] | |
| 146 | + fn render_contient_entete_et_ligne() { | |
| 147 | + let out = render(&[row()]); | |
| 148 | + assert!(out.contains(HEADER)); | |
| 149 | + assert!(out.contains(SEP)); | |
| 150 | + assert!(out.contains("| ☐ | TwoFactor | TwoFactorSetup | `/settings/2fa` | browser | oui | TotpService | QR |")); | |
| 151 | + } | |
| 152 | + | |
| 153 | + #[test] | |
| 154 | + fn render_coche_les_faits() { | |
| 155 | + let mut r = row(); | |
| 156 | + r.done = true; | |
| 157 | + assert!(render(&[r]).contains("| ☑ |")); | |
| 158 | + } | |
| 159 | + | |
| 160 | + #[test] | |
| 161 | + fn parse_round_trip() { | |
| 162 | + let mut a = row(); | |
| 163 | + a.done = true; | |
| 164 | + let b = TodoRow { screen: "SessionsList".into(), route: "/settings/sessions".into(), ..row() }; | |
| 165 | + let rendered = render(&[a.clone(), b.clone()]); | |
| 166 | + let parsed = parse(&rendered); | |
| 167 | + assert_eq!(parsed, vec![a, b]); | |
| 168 | + } | |
| 169 | + | |
| 170 | + #[test] | |
| 171 | + fn parse_ignore_hors_tableau() { | |
| 172 | + let md = format!("# Titre\n\ntexte\n\n{}", render(&[row()])); | |
| 173 | + assert_eq!(parse(&md).len(), 1); | |
| 174 | + } | |
| 175 | + | |
| 176 | + #[test] | |
| 177 | + fn merge_preserve_statut_et_route() { | |
| 178 | + let mut existing = row(); | |
| 179 | + existing.done = true; | |
| 180 | + existing.route = "/custom/2fa".into(); // route éditée par le dev | |
| 181 | + // "desired" = catalogue (route suggérée par défaut), même clé (func+screen) | |
| 182 | + let desired = row(); | |
| 183 | + let merged = merge(&[existing.clone()], &[desired.clone()]); | |
| 184 | + assert_eq!(merged.len(), 1); | |
| 185 | + assert!(merged[0].done, "statut conservé"); | |
| 186 | + assert_eq!(merged[0].route, "/custom/2fa", "route éditée conservée"); | |
| 187 | + } | |
| 188 | + | |
| 189 | + #[test] | |
| 190 | + fn merge_ajoute_les_nouveaux() { | |
| 191 | + let desired_new = TodoRow { functionality: "Teams".into(), screen: "TeamsManagement".into(), ..row() }; | |
| 192 | + let merged = merge(&[], &[row(), desired_new.clone()]); | |
| 193 | + assert_eq!(merged.len(), 2); | |
| 194 | + assert!(merged.iter().any(|r| r.screen == "TeamsManagement" && !r.done)); | |
| 195 | + } | |
| 196 | + | |
| 197 | + #[test] | |
| 198 | + fn replace_table_remplace_le_bloc() { | |
| 199 | + let content = format!("# Titre\n\n## Écrans\n\n{}\n(fin)\n", render(&[row()])); | |
| 200 | + let mut r = row(); | |
| 201 | + r.done = true; | |
| 202 | + let out = replace_table(&content, &[r]); | |
| 203 | + assert!(out.contains("# Titre"), "en-tête préservé"); | |
| 204 | + assert!(out.contains("(fin)"), "suffixe préservé"); | |
| 205 | + assert!(out.contains("| ☑ |"), "tableau mis à jour"); | |
| 206 | + } | |
| 207 | +} |
M
crates/rustwarden-core/src/config/auth.rs
+95
-0
@@ -62,6 +62,17 @@ pub struct AuthConfig {
| 62 | 62 | pub cookie_secure: bool, |
| 63 | 63 | /// Value of the `SameSite` cookie attribute (env: `COOKIE_SAME_SITE`, default: `"Strict"`). |
| 64 | 64 | pub cookie_same_site: String, |
| 65 | + /// Path the auth middleware redirects anonymous browser visitors to | |
| 66 | + /// (env: `LOGIN_PATH`, default: `"/login"`). Decoupled from the UI so | |
| 67 | + /// consumers with a custom login route — or none, in headless mode — | |
| 68 | + /// are not forced onto `rustwarden-ui`'s `/login`. | |
| 69 | + pub login_path: String, | |
| 70 | + /// How the auth middleware answers a protected route reached without | |
| 71 | + /// valid credentials (env: `AUTH_FAILURE_MODE`, default: `Redirect`). | |
| 72 | + /// Headless apps set this to [`AuthFailureMode::Json401`] so browsers | |
| 73 | + /// and API clients receive a `401` instead of a redirect to a login | |
| 74 | + /// page that does not exist. | |
| 75 | + pub auth_failure_mode: AuthFailureMode, | |
| 65 | 76 | } |
| 66 | 77 | |
| 67 | 78 | impl AuthConfig { |
@@ -191,6 +202,19 @@ impl AuthConfig {
| 191 | 202 | let cookie_same_site = |
| 192 | 203 | env::var("COOKIE_SAME_SITE").unwrap_or_else(|_| "Strict".to_string()); |
| 193 | 204 | |
| 205 | + // Decouple the login redirect target from the UI crate. | |
| 206 | + let login_path = env::var("LOGIN_PATH").unwrap_or_else(|_| "/login".to_string()); | |
| 207 | + | |
| 208 | + // Auth-failure behaviour; invalid values fall back to the default | |
| 209 | + // (Redirect) after a warning rather than aborting startup. | |
| 210 | + let auth_failure_mode = match env::var("AUTH_FAILURE_MODE") { | |
| 211 | + Ok(raw) => raw.parse::<AuthFailureMode>().unwrap_or_else(|err| { | |
| 212 | + tracing::warn!("{err}; falling back to AuthFailureMode::Redirect"); | |
| 213 | + AuthFailureMode::Redirect | |
| 214 | + }), | |
| 215 | + Err(_) => AuthFailureMode::Redirect, | |
| 216 | + }; | |
| 217 | + | |
| 194 | 218 | tracing::info!( |
| 195 | 219 | "Auth configuration loaded: JWT expiration={}min, Session backend={:?}, Session timeout={}min", |
| 196 | 220 | jwt_expiration_minutes, |
@@ -218,6 +242,8 @@ impl AuthConfig {
| 218 | 242 | rate_limit_general_per_minute, |
| 219 | 243 | cookie_secure, |
| 220 | 244 | cookie_same_site, |
| 245 | + login_path, | |
| 246 | + auth_failure_mode, | |
| 221 | 247 | }) |
| 222 | 248 | } |
| 223 | 249 |
@@ -239,3 +265,72 @@ impl AuthConfig {
| 239 | 265 | chrono::Duration::minutes(self.session_timeout_minutes) |
| 240 | 266 | } |
| 241 | 267 | } |
| 268 | + | |
| 269 | +/// Behaviour of the authentication middleware when a protected route is | |
| 270 | +/// reached without valid credentials. | |
| 271 | +/// | |
| 272 | +/// Selected per application: SSR/UI apps keep the default [`Redirect`], while | |
| 273 | +/// headless (API-only) apps switch to [`Json401`] via | |
| 274 | +/// [`crate::app::RustwardenBuilder::headless`] or the | |
| 275 | +/// [`crate::app::RustwardenBuilder::auth_failure_mode`] setter. | |
| 276 | +/// | |
| 277 | +/// [`Redirect`]: AuthFailureMode::Redirect | |
| 278 | +/// [`Json401`]: AuthFailureMode::Json401 | |
| 279 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 280 | +pub enum AuthFailureMode { | |
| 281 | + /// Reply with `303 See Other` to [`AuthConfig::login_path`], carrying the | |
| 282 | + /// original path in a `redirect` query parameter. Suited to browser UIs. | |
| 283 | + #[default] | |
| 284 | + Redirect, | |
| 285 | + /// Reply with `401 Unauthorized` and a JSON body. Suited to SPAs, mobile | |
| 286 | + /// clients, and machine APIs where a redirect to an HTML login page is | |
| 287 | + /// meaningless. | |
| 288 | + Json401, | |
| 289 | +} | |
| 290 | + | |
| 291 | +impl std::str::FromStr for AuthFailureMode { | |
| 292 | + type Err = String; | |
| 293 | + | |
| 294 | + /// Parse the `AUTH_FAILURE_MODE` environment value (case-insensitive). | |
| 295 | + fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 296 | + match s.trim().to_ascii_lowercase().as_str() { | |
| 297 | + "redirect" => Ok(Self::Redirect), | |
| 298 | + "json401" | "json_401" | "json" | "401" => Ok(Self::Json401), | |
| 299 | + other => Err(format!( | |
| 300 | + "invalid AUTH_FAILURE_MODE '{other}' (expected 'redirect' or 'json401')" | |
| 301 | + )), | |
| 302 | + } | |
| 303 | + } | |
| 304 | +} | |
| 305 | + | |
| 306 | +#[cfg(test)] | |
| 307 | +mod tests { | |
| 308 | + use super::AuthFailureMode; | |
| 309 | + use std::str::FromStr; | |
| 310 | + | |
| 311 | + #[test] | |
| 312 | + fn auth_failure_mode_defaults_to_redirect() { | |
| 313 | + assert_eq!(AuthFailureMode::default(), AuthFailureMode::Redirect); | |
| 314 | + } | |
| 315 | + | |
| 316 | + #[test] | |
| 317 | + fn auth_failure_mode_parses_known_values_case_insensitively() { | |
| 318 | + assert_eq!( | |
| 319 | + AuthFailureMode::from_str("redirect").unwrap(), | |
| 320 | + AuthFailureMode::Redirect | |
| 321 | + ); | |
| 322 | + assert_eq!( | |
| 323 | + AuthFailureMode::from_str("Json401").unwrap(), | |
| 324 | + AuthFailureMode::Json401 | |
| 325 | + ); | |
| 326 | + assert_eq!( | |
| 327 | + AuthFailureMode::from_str(" JSON ").unwrap(), | |
| 328 | + AuthFailureMode::Json401 | |
| 329 | + ); | |
| 330 | + } | |
| 331 | + | |
| 332 | + #[test] | |
| 333 | + fn auth_failure_mode_rejects_unknown_value() { | |
| 334 | + assert!(AuthFailureMode::from_str("nope").is_err()); | |
| 335 | + } | |
| 336 | +} |
M
crates/rustwarden-core/src/middleware/admin_extractor.rs
+5
-5
@@ -43,11 +43,11 @@ where
| 43 | 43 | async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> { |
| 44 | 44 | // Get Claims from extensions (added by admin_required middleware) |
| 45 | 45 | let claims = parts.extensions.get::<Claims>().ok_or_else(|| { |
| 46 | - // If Claims not in extensions, middleware didn't run | |
| 47 | - // This should not happen if middleware is properly applied | |
| 48 | - let uri = parts.uri.clone(); | |
| 49 | - let redirect_url = format!("/login?redirect={}", uri); | |
| 50 | - axum::response::Redirect::to(&redirect_url).into_response() | |
| 46 | + // Claims absent → the auth middleware did not run for this route. | |
| 47 | + crate::middleware::auth_service::auth_failure_from_extensions( | |
| 48 | + &parts.extensions, | |
| 49 | + &parts.uri, | |
| 50 | + ) | |
| 51 | 51 | })?; |
| 52 | 52 | |
| 53 | 53 | // Verify admin role |
M
crates/rustwarden-core/src/middleware/admin_required.rs
+9
-5
@@ -7,7 +7,7 @@ use axum::{
| 7 | 7 | extract::{Request, State}, |
| 8 | 8 | http::{StatusCode, Uri}, |
| 9 | 9 | middleware::Next, |
| 10 | - response::{IntoResponse, Redirect, Response}, | |
| 10 | + response::{IntoResponse, Response}, | |
| 11 | 11 | }; |
| 12 | 12 | use sea_orm::DatabaseConnection; |
| 13 | 13 |
@@ -72,8 +72,10 @@ pub async fn admin_required(
| 72 | 72 | Some(claims) => claims, |
| 73 | 73 | None => { |
| 74 | 74 | tracing::warn!("Unauthenticated access attempt to admin route: {}", uri); |
| 75 | - let redirect_url = crate::middleware::auth_service::sanitize_redirect(&uri.to_string()); | |
| 76 | - return Redirect::to(&redirect_url).into_response(); | |
| 75 | + return crate::middleware::auth_service::auth_failure_from_extensions( | |
| 76 | + request.extensions(), | |
| 77 | + &uri, | |
| 78 | + ); | |
| 77 | 79 | } |
| 78 | 80 | }; |
| 79 | 81 |
@@ -111,8 +113,10 @@ pub async fn admin_required_impl(
| 111 | 113 | Some(claims) => claims, |
| 112 | 114 | None => { |
| 113 | 115 | tracing::warn!("Unauthenticated access attempt to admin route: {}", uri); |
| 114 | - let redirect_url = crate::middleware::auth_service::sanitize_redirect(&uri.to_string()); | |
| 115 | - return Ok(Redirect::to(&redirect_url).into_response()); | |
| 116 | + return Ok(crate::middleware::auth_service::auth_failure_from_extensions( | |
| 117 | + request.extensions(), | |
| 118 | + &uri, | |
| 119 | + )); | |
| 116 | 120 | } |
| 117 | 121 | }; |
| 118 | 122 |
M
crates/rustwarden-core/src/middleware/admin_service.rs
+26
-31
@@ -60,39 +60,15 @@ where
| 60 | 60 | // We use parts to extract JWT (no need for body) |
| 61 | 61 | let (parts, body) = req.into_parts(); |
| 62 | 62 | |
| 63 | - // Extract JWT token from parts | |
| 64 | - let jwt_token = | |
| 65 | - match crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) { | |
| 66 | - Ok(token) => { | |
| 67 | - tracing::debug!("AdminService: JWT token found"); | |
| 68 | - token | |
| 69 | - } | |
| 70 | - Err(_) => { | |
| 71 | - tracing::warn!( | |
| 72 | - "AdminService: No JWT token found, redirecting to login" | |
| 73 | - ); | |
| 74 | - let redirect_url = auth_service::sanitize_redirect(uri.path()); | |
| 75 | - return Ok(axum::response::Redirect::to(&redirect_url).into_response()); | |
| 76 | - } | |
| 77 | - }; | |
| 78 | - | |
| 79 | - // Validate JWT and extract claims | |
| 80 | 63 | use crate::auth::jwt::JwtService; |
| 81 | 64 | use crate::config::auth::AuthConfig; |
| 82 | 65 | use crate::services::jwt_blacklist_service::JwtBlacklistService; |
| 83 | 66 | |
| 84 | - if JwtBlacklistService::is_blacklisted(&db, &jwt_token) | |
| 85 | - .await | |
| 86 | - .is_ok_and(|b| b) | |
| 87 | - { | |
| 88 | - let redirect_url = auth_service::sanitize_redirect(uri.path()); | |
| 89 | - return Ok(axum::response::Redirect::to(&redirect_url).into_response()); | |
| 90 | - } | |
| 91 | - | |
| 92 | - // Load auth config from request extensions; fall back to | |
| 93 | - // `AuthConfig::from_env()` when the layer order puts the | |
| 94 | - // Extension layer further inside the stack (same regression | |
| 95 | - // as `AuthService` since commit `d4d6828`). | |
| 67 | + // Resolve auth config up-front so every auth-failure branch | |
| 68 | + // honours `login_path` / `auth_failure_mode`. Prefer the | |
| 69 | + // injected `Extension`; fall back to `AuthConfig::from_env()` | |
| 70 | + // when the layer order puts the Extension further inside the | |
| 71 | + // stack (same regression as `AuthService` since `d4d6828`). | |
| 96 | 72 | let auth_config = match parts |
| 97 | 73 | .extensions |
| 98 | 74 | .get::<std::sync::Arc<AuthConfig>>() |
@@ -114,11 +90,30 @@ where
| 114 | 90 | }, |
| 115 | 91 | }; |
| 116 | 92 | |
| 93 | + // Extract JWT token from parts | |
| 94 | + let jwt_token = | |
| 95 | + match crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) { | |
| 96 | + Ok(token) => { | |
| 97 | + tracing::debug!("AdminService: JWT token found"); | |
| 98 | + token | |
| 99 | + } | |
| 100 | + Err(_) => { | |
| 101 | + tracing::warn!("AdminService: No JWT token found"); | |
| 102 | + return Ok(auth_service::auth_failure_response(&auth_config, &uri)); | |
| 103 | + } | |
| 104 | + }; | |
| 105 | + | |
| 106 | + if JwtBlacklistService::is_blacklisted(&db, &jwt_token) | |
| 107 | + .await | |
| 108 | + .is_ok_and(|b| b) | |
| 109 | + { | |
| 110 | + return Ok(auth_service::auth_failure_response(&auth_config, &uri)); | |
| 111 | + } | |
| 112 | + | |
| 117 | 113 | let claims = match JwtService::validate_token(&auth_config, &jwt_token) { |
| 118 | 114 | Ok(claims) => claims, |
| 119 | 115 | Err(_) => { |
| 120 | - let redirect_url = auth_service::sanitize_redirect(uri.path()); | |
| 121 | - return Ok(axum::response::Redirect::to(&redirect_url).into_response()); | |
| 116 | + return Ok(auth_service::auth_failure_response(&auth_config, &uri)); | |
| 122 | 117 | } |
| 123 | 118 | }; |
| 124 | 119 |
M
crates/rustwarden-core/src/middleware/auth_required.rs
+13
-7
@@ -7,7 +7,7 @@ use axum::{
| 7 | 7 | extract::{Request, State}, |
| 8 | 8 | http::Uri, |
| 9 | 9 | middleware::Next, |
| 10 | - response::{IntoResponse, Redirect, Response}, | |
| 10 | + response::{IntoResponse, Response}, | |
| 11 | 11 | }; |
| 12 | 12 | use sea_orm::DatabaseConnection; |
| 13 | 13 |
@@ -139,16 +139,20 @@ pub async fn auth_required_impl(
| 139 | 139 | Ok(token) => token, |
| 140 | 140 | Err(_) => { |
| 141 | 141 | tracing::warn!("Unauthenticated access attempt to: {} (no token)", uri); |
| 142 | - let redirect_url = crate::middleware::auth_service::sanitize_redirect(&uri.to_string()); | |
| 143 | - return Ok(Redirect::to(&redirect_url).into_response()); | |
| 142 | + return Ok(crate::middleware::auth_service::auth_failure_response( | |
| 143 | + &auth_config, | |
| 144 | + &uri, | |
| 145 | + )); | |
| 144 | 146 | } |
| 145 | 147 | }; |
| 146 | 148 | |
| 147 | 149 | // Check if token is blacklisted |
| 148 | 150 | if JwtBlacklistService::is_blacklisted(db, &jwt_token).await? { |
| 149 | 151 | tracing::warn!("Blacklisted JWT token used for: {}", uri); |
| 150 | - let redirect_url = crate::middleware::auth_service::sanitize_redirect(&uri.to_string()); | |
| 151 | - return Ok(Redirect::to(&redirect_url).into_response()); | |
| 152 | + return Ok(crate::middleware::auth_service::auth_failure_response( | |
| 153 | + &auth_config, | |
| 154 | + &uri, | |
| 155 | + )); | |
| 152 | 156 | } |
| 153 | 157 | |
| 154 | 158 | // Validate JWT token |
@@ -156,8 +160,10 @@ pub async fn auth_required_impl(
| 156 | 160 | Ok(claims) => claims, |
| 157 | 161 | Err(e) => { |
| 158 | 162 | tracing::warn!("Invalid JWT token for {}: {}", uri, e); |
| 159 | - let redirect_url = crate::middleware::auth_service::sanitize_redirect(&uri.to_string()); | |
| 160 | - return Ok(Redirect::to(&redirect_url).into_response()); | |
| 163 | + return Ok(crate::middleware::auth_service::auth_failure_response( | |
| 164 | + &auth_config, | |
| 165 | + &uri, | |
| 166 | + )); | |
| 161 | 167 | } |
| 162 | 168 | }; |
| 163 | 169 |
M
crates/rustwarden-core/src/middleware/auth_service.rs
+158
-40
@@ -4,7 +4,8 @@
| 4 | 4 | /// This works around Axum 0.8 Sync limitations with from_fn_with_state. |
| 5 | 5 | use axum::{ |
| 6 | 6 | extract::Request, |
| 7 | - response::{IntoResponse, Response}, | |
| 7 | + http::{StatusCode, Uri}, | |
| 8 | + response::{IntoResponse, Redirect, Response}, | |
| 8 | 9 | }; |
| 9 | 10 | use sea_orm::DatabaseConnection; |
| 10 | 11 | use std::{ |
@@ -15,15 +16,65 @@ use std::{
| 15 | 16 | }; |
| 16 | 17 | use tower::Service; |
| 17 | 18 | |
| 19 | +use crate::config::auth::{AuthConfig, AuthFailureMode}; | |
| 20 | + | |
| 18 | 21 | /// SEC-C4: Sanitize redirect URL to prevent open redirect attacks. |
| 19 | 22 | /// Only allows relative paths starting with '/' (not '//'). |
| 20 | 23 | /// The path is URL-encoded to prevent parameter injection. |
| 21 | -pub(crate) fn sanitize_redirect(uri: &str) -> String { | |
| 24 | +/// | |
| 25 | +/// `login_path` is the destination anonymous visitors are sent to, | |
| 26 | +/// configurable via [`AuthConfig::login_path`] so the middleware is not | |
| 27 | +/// hard-wired to `rustwarden-ui`'s `/login`. The original target is preserved | |
| 28 | +/// in a `redirect` query parameter when it is a safe relative path. | |
| 29 | +pub(crate) fn sanitize_redirect(uri: &str, login_path: &str) -> String { | |
| 22 | 30 | let path = uri.to_string(); |
| 23 | 31 | if path.starts_with('/') && !path.starts_with("//") && !path.contains('\0') { |
| 24 | - format!("/login?redirect={}", urlencoding::encode(&path)) | |
| 32 | + format!("{login_path}?redirect={}", urlencoding::encode(&path)) | |
| 25 | 33 | } else { |
| 26 | - "/login".to_string() | |
| 34 | + login_path.to_string() | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +/// Response returned when a protected route is reached without valid | |
| 39 | +/// credentials, honouring [`AuthConfig::auth_failure_mode`]. | |
| 40 | +/// | |
| 41 | +/// - [`AuthFailureMode::Redirect`] → `303 See Other` to | |
| 42 | +/// [`AuthConfig::login_path`] (browser UIs). | |
| 43 | +/// - [`AuthFailureMode::Json401`] → `401 Unauthorized` with a JSON body | |
| 44 | +/// (headless / API apps, where redirecting to an HTML login page is | |
| 45 | +/// meaningless). | |
| 46 | +pub(crate) fn auth_failure_response(auth_config: &AuthConfig, uri: &Uri) -> Response { | |
| 47 | + match auth_config.auth_failure_mode { | |
| 48 | + AuthFailureMode::Json401 => ( | |
| 49 | + StatusCode::UNAUTHORIZED, | |
| 50 | + axum::Json(serde_json::json!({ "error": "unauthorized" })), | |
| 51 | + ) | |
| 52 | + .into_response(), | |
| 53 | + AuthFailureMode::Redirect => { | |
| 54 | + let url = sanitize_redirect(uri.path(), &auth_config.login_path); | |
| 55 | + Redirect::to(&url).into_response() | |
| 56 | + } | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +/// Same as [`auth_failure_response`], for call sites that only hold request | |
| 61 | +/// extensions (extractors, `from_fn` middlewares that don't pre-resolve the | |
| 62 | +/// config). Resolves [`AuthConfig`] from the injected `Extension`, else from | |
| 63 | +/// the environment, else falls back to a default `Redirect` to `/login`. | |
| 64 | +pub(crate) fn auth_failure_from_extensions( | |
| 65 | + extensions: &axum::http::Extensions, | |
| 66 | + uri: &Uri, | |
| 67 | +) -> Response { | |
| 68 | + let config = extensions | |
| 69 | + .get::<Arc<AuthConfig>>() | |
| 70 | + .cloned() | |
| 71 | + .or_else(|| AuthConfig::from_env().ok().map(Arc::new)); | |
| 72 | + match config { | |
| 73 | + Some(cfg) => auth_failure_response(&cfg, uri), | |
| 74 | + None => { | |
| 75 | + let url = sanitize_redirect(uri.path(), "/login"); | |
| 76 | + Redirect::to(&url).into_response() | |
| 77 | + } | |
| 27 | 78 | } |
| 28 | 79 | } |
| 29 | 80 |
@@ -136,39 +187,15 @@ where
| 136 | 187 | Box::pin(async move { |
| 137 | 188 | let (parts, body) = req.into_parts(); |
| 138 | 189 | |
| 139 | - let jwt_token = | |
| 140 | - match crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) { | |
| 141 | - Ok(token) => { | |
| 142 | - tracing::debug!("AuthService: JWT token found"); | |
| 143 | - token | |
| 144 | - } | |
| 145 | - Err(_) => { | |
| 146 | - tracing::warn!("AuthService: No JWT token found, redirecting to login"); | |
| 147 | - let redirect_url = sanitize_redirect(uri.path()); | |
| 148 | - return Ok(axum::response::Redirect::to(&redirect_url).into_response()); | |
| 149 | - } | |
| 150 | - }; | |
| 151 | - | |
| 152 | 190 | use crate::auth::jwt::JwtService; |
| 153 | - use crate::config::auth::AuthConfig; | |
| 154 | 191 | use crate::services::jwt_blacklist_service::JwtBlacklistService; |
| 155 | 192 | |
| 156 | - if JwtBlacklistService::is_blacklisted(&db, &jwt_token) | |
| 157 | - .await | |
| 158 | - .is_ok_and(|b| b) | |
| 159 | - { | |
| 160 | - tracing::warn!("AuthService: JWT token is blacklisted, redirecting to login"); | |
| 161 | - let redirect_url = sanitize_redirect(uri.path()); | |
| 162 | - return Ok(axum::response::Redirect::to(&redirect_url).into_response()); | |
| 163 | - } | |
| 164 | - | |
| 165 | - // Read AuthConfig from request extensions when the | |
| 166 | - // outer router injects it (`.layer(Extension(auth_config))`), | |
| 167 | - // otherwise fall back to `AuthConfig::from_env()` so this | |
| 168 | - // middleware keeps working when the layer order puts the | |
| 193 | + // Resolve AuthConfig up-front so every authentication-failure | |
| 194 | + // branch below can honour `login_path` / `auth_failure_mode`. | |
| 195 | + // Prefer the injected `Extension`; fall back to | |
| 196 | + // `AuthConfig::from_env()` when the layer order puts the | |
| 169 | 197 | // Extension layer further inside the stack — the regression |
| 170 | - // commit `d4d6828` flipped this path to a hard 500 by | |
| 171 | - // mistake. | |
| 198 | + // commit `d4d6828` flipped this path to a hard 500 by mistake. | |
| 172 | 199 | let auth_config = match parts |
| 173 | 200 | .extensions |
| 174 | 201 | .get::<std::sync::Arc<AuthConfig>>() |
@@ -190,12 +217,31 @@ where
| 190 | 217 | }, |
| 191 | 218 | }; |
| 192 | 219 | |
| 220 | + let jwt_token = | |
| 221 | + match crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) { | |
| 222 | + Ok(token) => { | |
| 223 | + tracing::debug!("AuthService: JWT token found"); | |
| 224 | + token | |
| 225 | + } | |
| 226 | + Err(_) => { | |
| 227 | + tracing::warn!("AuthService: No JWT token found"); | |
| 228 | + return Ok(auth_failure_response(&auth_config, &uri)); | |
| 229 | + } | |
| 230 | + }; | |
| 231 | + | |
| 232 | + if JwtBlacklistService::is_blacklisted(&db, &jwt_token) | |
| 233 | + .await | |
| 234 | + .is_ok_and(|b| b) | |
| 235 | + { | |
| 236 | + tracing::warn!("AuthService: JWT token is blacklisted"); | |
| 237 | + return Ok(auth_failure_response(&auth_config, &uri)); | |
| 238 | + } | |
| 239 | + | |
| 193 | 240 | let claims = match JwtService::validate_token(&auth_config, &jwt_token) { |
| 194 | 241 | Ok(claims) => claims, |
| 195 | 242 | Err(_) => { |
| 196 | - tracing::warn!("AuthService: Invalid JWT token, redirecting to login"); | |
| 197 | - let redirect_url = sanitize_redirect(uri.path()); | |
| 198 | - return Ok(axum::response::Redirect::to(&redirect_url).into_response()); | |
| 243 | + tracing::warn!("AuthService: Invalid JWT token"); | |
| 244 | + return Ok(auth_failure_response(&auth_config, &uri)); | |
| 199 | 245 | } |
| 200 | 246 | }; |
| 201 | 247 |
@@ -217,10 +263,7 @@ where
| 217 | 263 | "AuthService: remote_identity {} is revoked, rejecting JWT", |
| 218 | 264 | rid |
| 219 | 265 | ); |
| 220 | - let redirect_url = sanitize_redirect(uri.path()); | |
| 221 | - return Ok( | |
| 222 | - axum::response::Redirect::to(&redirect_url).into_response() | |
| 223 | - ); | |
| 266 | + return Ok(auth_failure_response(&auth_config, &uri)); | |
| 224 | 267 | } |
| 225 | 268 | } |
| 226 | 269 | } |
@@ -381,3 +424,78 @@ where
| 381 | 424 | } |
| 382 | 425 | } |
| 383 | 426 | } |
| 427 | + | |
| 428 | +#[cfg(test)] | |
| 429 | +mod tests { | |
| 430 | + use super::{auth_failure_response, sanitize_redirect}; | |
| 431 | + use crate::config::auth::{AuthConfig, AuthFailureMode, SessionBackend}; | |
| 432 | + use axum::http::{StatusCode, Uri, header::LOCATION}; | |
| 433 | + | |
| 434 | + fn test_config(mode: AuthFailureMode, login_path: &str) -> AuthConfig { | |
| 435 | + AuthConfig { | |
| 436 | + jwt_secret: "0123456789abcdef0123456789abcdef".to_string(), | |
| 437 | + jwt_expiration_minutes: 15, | |
| 438 | + jwt_issuer: "test".to_string(), | |
| 439 | + refresh_token_expiration_days: 7, | |
| 440 | + remember_me_expiration_days: 30, | |
| 441 | + session_timeout_minutes: 30, | |
| 442 | + session_backend: SessionBackend::Memory, | |
| 443 | + redis_url: None, | |
| 444 | + rate_limit_login_per_minute: 5, | |
| 445 | + rate_limit_refresh_per_minute: 10, | |
| 446 | + rate_limit_general_per_minute: 100, | |
| 447 | + cookie_secure: false, | |
| 448 | + cookie_same_site: "Strict".to_string(), | |
| 449 | + login_path: login_path.to_string(), | |
| 450 | + auth_failure_mode: mode, | |
| 451 | + } | |
| 452 | + } | |
| 453 | + | |
| 454 | + #[test] | |
| 455 | + fn sanitize_redirect_preserves_default_login_path() { | |
| 456 | + assert_eq!( | |
| 457 | + sanitize_redirect("/settings", "/login"), | |
| 458 | + "/login?redirect=%2Fsettings" | |
| 459 | + ); | |
| 460 | + } | |
| 461 | + | |
| 462 | + #[test] | |
| 463 | + fn sanitize_redirect_honors_custom_login_path() { | |
| 464 | + assert_eq!( | |
| 465 | + sanitize_redirect("/settings", "/auth/signin"), | |
| 466 | + "/auth/signin?redirect=%2Fsettings" | |
| 467 | + ); | |
| 468 | + } | |
| 469 | + | |
| 470 | + #[test] | |
| 471 | + fn sanitize_redirect_reduces_unsafe_target_to_bare_login_path() { | |
| 472 | + assert_eq!( | |
| 473 | + sanitize_redirect("//evil.example", "/auth/signin"), | |
| 474 | + "/auth/signin" | |
| 475 | + ); | |
| 476 | + } | |
| 477 | + | |
| 478 | + #[test] | |
| 479 | + fn auth_failure_redirect_mode_points_to_configured_login_path() { | |
| 480 | + let uri: Uri = "/settings".parse().unwrap(); | |
| 481 | + let resp = auth_failure_response(&test_config(AuthFailureMode::Redirect, "/auth/signin"), &uri); | |
| 482 | + assert_eq!(resp.status(), StatusCode::SEE_OTHER); | |
| 483 | + let location = resp | |
| 484 | + .headers() | |
| 485 | + .get(LOCATION) | |
| 486 | + .and_then(|v| v.to_str().ok()) | |
| 487 | + .unwrap_or_default(); | |
| 488 | + assert!( | |
| 489 | + location.starts_with("/auth/signin"), | |
| 490 | + "unexpected Location header: {location}" | |
| 491 | + ); | |
| 492 | + } | |
| 493 | + | |
| 494 | + #[test] | |
| 495 | + fn auth_failure_json401_mode_returns_401_without_location() { | |
| 496 | + let uri: Uri = "/settings".parse().unwrap(); | |
| 497 | + let resp = auth_failure_response(&test_config(AuthFailureMode::Json401, "/login"), &uri); | |
| 498 | + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); | |
| 499 | + assert!(resp.headers().get(LOCATION).is_none()); | |
| 500 | + } | |
| 501 | +} |
M
crates/rustwarden-fediverse-guard/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | [package] |
| 2 | 2 | name = "rustwarden-fediverse-guard" |
| 3 | -version = "0.1.0" | |
| 3 | +version = "1.0.0" | |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | rust-version.workspace = true |
| 6 | 6 | description = "Real-time abuse detection and ban management for the Rustwarden ActivityPub federation layer (RFC 0028+)" |
M
crates/rustwarden-fediverse-ui/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | [package] |
| 2 | 2 | name = "rustwarden-fediverse-ui" |
| 3 | -version = "0.1.0" | |
| 3 | +version = "1.0.0" | |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | rust-version.workspace = true |
| 6 | 6 | description = "Reference HTML UI for Rustwarden ActivityPub federation — handlers, templates, dashboard card" |
M
crates/rustwarden-fediverse/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | [package] |
| 2 | 2 | name = "rustwarden-fediverse" |
| 3 | -version = "0.1.0" | |
| 3 | +version = "1.0.0" | |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | rust-version.workspace = true |
| 6 | 6 | description = "ActivityPub federation for Rustwarden — WebFinger, actor, inbox, HTTP signatures, AP-native auth, cross-instance team invitations" |
M
crates/rustwarden-fediverse/src/auth_routes.rs
+2
-0
@@ -413,6 +413,8 @@ mod tests {
| 413 | 413 | rate_limit_general_per_minute: 100, |
| 414 | 414 | cookie_secure: false, |
| 415 | 415 | cookie_same_site: "Strict".to_string(), |
| 416 | + login_path: "/login".to_string(), | |
| 417 | + auth_failure_mode: rustwarden_core::config::auth::AuthFailureMode::Redirect, | |
| 416 | 418 | } |
| 417 | 419 | } |
| 418 | 420 |
M
crates/rustwarden-fediverse/src/services/fediverse_auth_service.rs
+2
-0
@@ -606,6 +606,8 @@ mod tests {
| 606 | 606 | rate_limit_general_per_minute: 100, |
| 607 | 607 | cookie_secure: false, |
| 608 | 608 | cookie_same_site: "Strict".to_string(), |
| 609 | + login_path: "/login".to_string(), | |
| 610 | + auth_failure_mode: rustwarden_core::config::auth::AuthFailureMode::Redirect, | |
| 609 | 611 | } |
| 610 | 612 | } |
| 611 | 613 |
M
crates/rustwarden-ui/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | [package] |
| 2 | 2 | name = "rustwarden-ui" |
| 3 | -version = "0.1.0" | |
| 3 | +version = "1.0.0" | |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | rust-version.workspace = true |
| 6 | 6 | description = "Default UI for Rustwarden – HTML templates, handlers, admin panel" |
M
crates/rustwarden-ui/src/routes.rs
+6
-30
@@ -9,7 +9,6 @@ use crate::handlers::settings::{preferences, profile, security};
| 9 | 9 | use crate::handlers::{ |
| 10 | 10 | admin_handler, health, home_handler, login_handler, root_handler, totp_handler, user_handler, |
| 11 | 11 | }; |
| 12 | -use rustwarden_core::auth::handlers as auth_handlers; | |
| 13 | 12 | |
| 14 | 13 | /// Build the **browser** family of UI routes (SSR, session-cookie driven). |
| 15 | 14 | /// |
@@ -124,39 +123,16 @@ pub fn browser_routes() -> Router<DatabaseConnection> {
| 124 | 123 | /// on the machine channel of the application builder |
| 125 | 124 | /// (`merge_machine_routes`), never behind the CSRF layer. |
| 126 | 125 | pub fn api_routes() -> Router<DatabaseConnection> { |
| 127 | - let auth_routes = Router::new() | |
| 128 | - .route("/api/v1/auth/login", post(auth_handlers::login)) | |
| 129 | - .route("/api/v1/auth/register", post(auth_handlers::register)) | |
| 130 | - .route("/api/v1/auth/refresh", post(auth_handlers::refresh_token)) | |
| 131 | - .route("/api/v1/auth/logout", post(auth_handlers::logout)) | |
| 132 | - .route("/api/v1/auth/me", get(auth_handlers::get_current_user)) | |
| 133 | - .route( | |
| 134 | - "/api/v1/auth/forgot-password", | |
| 135 | - post(auth_handlers::forgot_password), | |
| 136 | - ) | |
| 137 | - .route( | |
| 138 | - "/api/v1/auth/reset-password", | |
| 139 | - post(auth_handlers::reset_password), | |
| 140 | - ) | |
| 141 | - .route( | |
| 142 | - "/api/v1/auth/verify-email/{token}", | |
| 143 | - get(auth_handlers::verify_email), | |
| 144 | - ) | |
| 145 | - .route( | |
| 146 | - "/api/v1/auth/resend-verification", | |
| 147 | - post(auth_handlers::resend_verification), | |
| 148 | - ) | |
| 149 | - .route( | |
| 150 | - "/api/v1/auth/resend-verification/by-username", | |
| 151 | - post(auth_handlers::resend_verification_by_username), | |
| 152 | - ) | |
| 153 | - .route("/api/v1/auth/2fa/verify", post(auth_handlers::verify_2fa)); | |
| 154 | - | |
| 126 | + // Auth endpoints are provided canonically by the framework core | |
| 127 | + // (`rustwarden_core::auth::auth_api_routes`); mount that set and add the | |
| 128 | + // UI crate's own user-management API on top. | |
| 155 | 129 | let user_routes = Router::new() |
| 156 | 130 | .route("/api/v1/users", post(user_handler::create_user)) |
| 157 | 131 | .route("/api/v1/users", get(user_handler::list_users)); |
| 158 | 132 | |
| 159 | - Router::new().merge(auth_routes).merge(user_routes) | |
| 133 | + Router::new() | |
| 134 | + .merge(rustwarden_core::auth::auth_api_routes()) | |
| 135 | + .merge(user_routes) | |
| 160 | 136 | } |
| 161 | 137 | |
| 162 | 138 | /// Create the main application router for Rustwarden. |
M
docs/SERVICES_REFERENCE.md
+94
-0
@@ -37,6 +37,7 @@
| 37 | 37 | 19. [Utilitaires](#19-utilitaires) |
| 38 | 38 | 20. [Patterns communs](#20-patterns-communs) |
| 39 | 39 | 21. [Fédération ActivityPub (features `fediverse*`)](#21-federation-activitypub-features-fediverse) |
| 40 | +22. [Binaire `screen-select` — plan des écrans UI](#22-binaire-screen-select--plan-des-ecrans-ui) | |
| 40 | 41 | |
| 41 | 42 | --- |
| 42 | 43 |
@@ -1086,6 +1087,31 @@ Wrapper pratiques par-dessus `RateLimitService` :
| 1086 | 1087 | - `auth_service::auth_middleware` — auth primaire (extraction JWT + validation + injection `Claims` en Extension). |
| 1087 | 1088 | - `password_complexity::enforce_password_complexity` — helper de validation. |
| 1088 | 1089 | |
| 1090 | +#### Préfixes protégés & mode d'échec (⚠ changement 2026-08) | |
| 1091 | + | |
| 1092 | +L'auth middleware ne protège que les **préfixes enregistrés**. **Depuis 2026-08, l'ensemble par défaut est vide** (auparavant `/home`, `/profile`, `/test-base`, `/settings`, `/admin`) : | |
| 1093 | + | |
| 1094 | +- `with_ui(..)` enregistre automatiquement les zones UI `/home`, `/profile`, `/settings`, `/admin` — une app UI reste protégée sans rien changer. | |
| 1095 | +- `/admin` et `/api/v1/admin` restent **en plus** protégés indépendamment par `AdminService`. | |
| 1096 | +- Une app **headless** (sans `with_ui`) ne protège **que** ce qu'elle déclare via `register_protected_prefix("/…")`. | |
| 1097 | + | |
| 1098 | +> **⚠ Migration au prochain `update-project` (apps headless uniquement)** : si votre app sert des pages sous `/home`, `/profile` ou `/settings` en comptant sur les anciens défauts, ajoutez les `register_protected_prefix(..)` correspondants — sinon ces pages deviennent **publiques**. `/admin/*` reste couvert par `AdminService`. Contrôle : `grep -rn 'register_protected_prefix' src/` puis comparer avec vos `.route("/home | /profile | /settings …")`. | |
| 1099 | + | |
| 1100 | +Mode d'échec sur préfixe protégé atteint sans credentials valides — `AuthConfig.auth_failure_mode` : | |
| 1101 | +- `Redirect` (défaut UI) : `303` vers `login_path` (défaut `/login`, env `LOGIN_PATH` ou builder `.login_path("/…")`). | |
| 1102 | +- `Json401` : `401 {"error":"unauthorized"}`. **`.headless()` bascule le défaut sur `Json401`** ; override explicite via `.auth_failure_mode(AuthFailureMode::…)` (env `AUTH_FAILURE_MODE=redirect|json401`). | |
| 1103 | + | |
| 1104 | +#### Router auth JSON prêt à monter | |
| 1105 | + | |
| 1106 | +`rustwarden_core::auth::auth_api_routes() -> Router<DatabaseConnection>` monte les endpoints `/api/v1/auth/*` (login, register, refresh, logout, me, forgot/reset-password, verify-email, resend-verification, 2fa/verify). À poser sur le canal **machine** : | |
| 1107 | + | |
| 1108 | +```rust | |
| 1109 | +.headless() | |
| 1110 | +.merge_machine_routes(rustwarden_core::auth::auth_api_routes()) | |
| 1111 | +``` | |
| 1112 | + | |
| 1113 | +Les projets générés par le CLI en mode headless câblent déjà ce router (+ un endpoint protégé d'exemple `whoami` via l'extracteur `AuthUser`). `rustwarden_ui::api_routes()` monte le même set. | |
| 1114 | + | |
| 1089 | 1115 | --- |
| 1090 | 1116 | |
| 1091 | 1117 | ## 19. Utilitaires |
@@ -1569,3 +1595,71 @@ Pour le câblage complet du `main.rs`, les variables d'environnement, les migrat
| 1569 | 1595 | - **Toujours `tokio::spawn(OutboundWorker::run())`** au démarrage, avec un `HttpSignedDelivery` injecté — `NoopDelivery` ne fait que logger. |
| 1570 | 1596 | - **Toujours s'abonner au target tracing `rustwarden::fediverse::outbound`** — sinon vous découvrirez les `dead` par les clients qui râlent. |
| 1571 | 1597 | - **Étendre via les 5 traits** (`LocalUserResolver`, `SignatureVerifier`, `FediverseEventObserver`, `RemoteIdentityRevocationChecker`, `OutboundDelivery`) plutôt que forker. Les anti-patterns sont listés dans [`fediverse-consumer-guide.md` §10](fediverse-consumer-guide.md#10-anti-patterns-specifiques-federation). |
| 1598 | + | |
| 1599 | +--- | |
| 1600 | + | |
| 1601 | +## 22. Binaire `screen-select` — plan des écrans UI | |
| 1602 | + | |
| 1603 | +Le binaire `screen-select` est livré avec `rustwarden-core`. Il cible les projets consommateurs qui n'embarquent **pas** `rustwarden-ui` (mode headless / UI custom) et permet de planifier puis d'auditer la couverture des écrans à réaliser. | |
| 1604 | + | |
| 1605 | +```bash | |
| 1606 | +# Depuis le répertoire racine du projet consommateur | |
| 1607 | +cargo run -p rustwarden-core --bin screen-select # mode wizard (interactif) | |
| 1608 | +cargo run -p rustwarden-core --bin screen-select -- --check # mode vérification (non-interactif) | |
| 1609 | +``` | |
| 1610 | + | |
| 1611 | +### Mode wizard | |
| 1612 | + | |
| 1613 | +Sans argument, `screen-select` démarre un assistant interactif sur stdin : | |
| 1614 | + | |
| 1615 | +- Sélection parmi 17 fonctionnalités optionnelles du catalogue (Authentification est toujours incluse — 18 fonctionnalités au total, 22 écrans). | |
| 1616 | +- Chemin optionnel vers un fichier `design.md` et chaîne de layout personnalisée. | |
| 1617 | +- Génère `docs/plans/ui-screens.md` dans le projet consommateur — tableau Markdown avec colonnes : Statut | Fonctionnalité | Écran | Route | Canal | Protégé | Méthodes core | I/O. | |
| 1618 | + | |
| 1619 | +**Comportement de fusion (re-run)** : les statuts (☑/☐) et les routes éditées à la main sont préservés par identité (fonctionnalité + écran). Les nouveaux écrans sont ajoutés ; ceux des fonctionnalités désélectionnées sont retirés. | |
| 1620 | + | |
| 1621 | +### Mode `--check` | |
| 1622 | + | |
| 1623 | +Scanne les sources `.rs` du projet consommateur en excluant `target/` et les crates `rustwarden-*` vendorisées, à la recherche de littéraux de route exacts (`"<route>"`). Met à jour la colonne Statut du tableau (☐/☑) et affiche : | |
| 1624 | + | |
| 1625 | +``` | |
| 1626 | +Couverture : N/M écrans réalisés. | |
| 1627 | +Écrans manquants : | |
| 1628 | + ☐ Fonctionnalité — Écran (/route/suggérée) | |
| 1629 | +``` | |
| 1630 | + | |
| 1631 | +### Source de vérité | |
| 1632 | + | |
| 1633 | +Le catalogue (fonctionnalité → écrans, routes suggérées, méthodes core, canal browser/machine, protection) réside dans `crates/rustwarden-core/src/bin/screen-select/catalogue.rs` en tant que `const CATALOGUE`. Des tests (`catalogue_invariants`, `catalogue_couvre_les_fonctionnalites_attendues`) en gardent la complétude. | |
| 1634 | + | |
| 1635 | +### Limites connues | |
| 1636 | + | |
| 1637 | +| Limite | Détail | | |
| 1638 | +|--------|--------| | |
| 1639 | +| Détection textuelle | Un `.route("/x")` commenté est compté comme implémenté. | | |
| 1640 | +| Fichiers non-Rust | Les routes déclarées hors `.rs` (TOML, YAML, macros procédurales séparées) ne sont pas vues. | | |
| 1641 | +| Écrasement des ☑ manuels | `--check` réécrit le statut à partir du scan — les coches posées à la main sont réinitialisées à chaque exécution. | | |
| 1642 | + | |
| 1643 | +Le CLI `rustwarden-cli` invite à exécuter `screen-select` à l'étape 4 de `start-project` et dans les notes finales de `update-project`. | |
| 1644 | + | |
| 1645 | +### Sections de sécurité du plan | |
| 1646 | + | |
| 1647 | +Le plan généré porte, en plus du tableau des écrans : | |
| 1648 | + | |
| 1649 | +- **Sécurité transversale (obligatoire, hors écran)** — garde-fous globaux à | |
| 1650 | + câbler une fois : middleware de headers (CSP, `X-Frame-Options`, | |
| 1651 | + `X-Content-Type-Options`, `Referrer-Policy`), validation de configuration au | |
| 1652 | + démarrage (refus de `ADMIN_PASSWORD=password`, alerte `APP_DEBUG`/`COOKIE_SECURE`), | |
| 1653 | + cookies `HttpOnly`/`Secure`/`SameSite`, `cargo audit` + JWT en HMAC. | |
| 1654 | +- **Conventions de rendu sûr** — escaping contextuel (jamais de valeur utilisateur | |
| 1655 | + dans un `<script>` : `data-*` + `dataset`), `textContent` plutôt qu'`innerHTML`, | |
| 1656 | + `encodeURIComponent`/`urlenc()`, pas de filtre `|safe`. | |
| 1657 | +- **Contrat de sécurité par écran** — pour chaque écran sélectionné : niveau | |
| 1658 | + d'autorisation (Public / Authentifié / Propriétaire de ressource / Administrateur), | |
| 1659 | + rappel CSRF automatique pour les écrans *browser*, et pièges spécifiques | |
| 1660 | + (IDOR/ownership, `ensure_admin()`, anti-énumération, token de reset hors contexte JS…). | |
| 1661 | + | |
| 1662 | +Ces sections sont délimitées par des marqueurs HTML | |
| 1663 | +(`<!-- screen-select:security-guides -->`, `<!-- screen-select:security-per-screen -->`) | |
| 1664 | +et régénérées à chaque exécution du wizard. `--check` ne modifie que le tableau : | |
| 1665 | +les sections de sécurité sont préservées telles quelles. |
A
docs/plans/2026-08-01-headless-dx-decouplage-scaffold-skill.md
+276
-0
@@ -0,0 +1,276 @@
| 1 | +# Améliorer l'expérience agent en mode headless (A + B + C) | |
| 2 | + | |
| 3 | +> Statut : **Phase 1B — direction validée le 2026-08-01.** Formulation Gherkin | |
| 4 | +> tranche par tranche, juste avant implémentation. Décisions verrouillées : | |
| 5 | +> - Scaffold headless (B) = **JSON-first** (cookies JWT, aucun HTML/Askama). | |
| 6 | +> - `.headless()` bascule le défaut de l'échec d'auth sur **401 JSON** (A2). | |
| 7 | + | |
| 8 | +## Contexte & valeur | |
| 9 | + | |
| 10 | +Un projet consommateur de `rustwarden-core` en **mode headless** (API-only, sans | |
| 11 | +`rustwarden-ui`) reste coûteux à faire évoluer pour un agent, même avec la skill | |
| 12 | +`rustwarden-boilerplate`. Le diagnostic sur le code réel (et non sur la mémoire) | |
| 13 | +montre que **le framework est déjà mûr** sur plusieurs points qu'on croyait | |
| 14 | +manquants : | |
| 15 | + | |
| 16 | +- `RustwardenBuilder` expose déjà `.headless()`, `.merge_browser_routes()` | |
| 17 | + (CSRF auto), `.merge_machine_routes()` (sans CSRF), `.register_protected_prefix()`. | |
| 18 | + Le split browser/machine est propre (`app.rs:126-309`). | |
| 19 | +- Le CSRF est **déjà un layer automatique** (`middleware/csrf.rs`) : génération | |
| 20 | + de token en session, comparaison constant-time, exemption `Authorization: | |
| 21 | + Bearer`. En headless (API JSON / SPA via `X-CSRF-Token`), **aucun câblage | |
| 22 | + manuel n'est requis**. | |
| 23 | +- La skill est **déjà décomposée** : `SKILL.md` (142 l.) + 5 références | |
| 24 | + (`routes-ecrans-sans-ui.md`, `services-api.md`, `env-vars.md`, `pieges.md`, | |
| 25 | + `fediverse.md`). | |
| 26 | + | |
| 27 | +La douleur résiduelle, elle, est précise et se résume à trois écarts : | |
| 28 | + | |
| 29 | +1. **Couplage UI dans l'échec d'authentification.** `sanitize_redirect` | |
| 30 | + (`middleware/auth_service.rs:21-28`) code en dur `/login`. En headless, un JWT | |
| 31 | + absent/invalide sur une route protégée renvoie un **302 vers `/login`** | |
| 32 | + (inexistant) au lieu d'un **401 JSON**. Les `protected_prefixes` par défaut | |
| 33 | + (`app.rs:180-186`) sont des chemins UI en dur, dont `/test-base` (artefact de | |
| 34 | + dev qui fuite dans les valeurs par défaut). | |
| 35 | +2. **Scaffold headless vide.** Le CLI génère en headless un `main.rs` réduit à | |
| 36 | + `.headless()` et **aucun handler d'authentification** | |
| 37 | + (`start_project.rs:761`, `templates.rs:main_rs`). Le dev/agent réimplémente | |
| 38 | + login / 2FA / logout / changement de mot de passe **from scratch**, guidé par | |
| 39 | + ~149 lignes de prose. C'est le principal poste de coût. | |
| 40 | +3. **Skill descriptive plutôt que génératrice.** `routes-ecrans-sans-ui.md` | |
| 41 | + décrit en prose ce que l'agent doit écrire. Documenter la complexité ne la | |
| 42 | + supprime pas ; une fois (2) livré, la référence doit pointer vers un squelette | |
| 43 | + **généré et compilable** plutôt que la re-décrire. | |
| 44 | + | |
| 45 | +**Valeur cible :** un projet headless neuf démarre **vert et fonctionnel** | |
| 46 | +(auth de bout en bout), le core ne présuppose plus d'UI, et la skill oriente vers | |
| 47 | +une source canonique unique — l'agent complète au lieu de reconstruire. | |
| 48 | + | |
| 49 | +## Principes directeurs | |
| 50 | + | |
| 51 | +- **Zéro régression pour le mode UI.** Tout défaut préservé : `login_path` par | |
| 52 | + défaut `/login`, comportement d'échec = redirect pour les routes browser. | |
| 53 | +- **Sécurité intacte.** Les changements touchent des chemins auth/CSRF sensibles : | |
| 54 | + chaque comportement (301/302 vs 401, blacklist JWT, challenge 2FA, anti-fixation | |
| 55 | + de session) est verrouillé par un test avant modification. | |
| 56 | +- **Pas de raw SQL, `///` sur toute API publique, zéro warning clippy, pas de | |
| 57 | + `unwrap`/`expect` hors tests** (constitution). | |
| 58 | +- **Le scaffold généré est du vrai code de production**, pas des stubs 501. | |
| 59 | + | |
| 60 | +## Notes techniques (hors comportement) | |
| 61 | + | |
| 62 | +- **A — core.** `AuthConfig` (`config/auth.rs:37`) gagne le paramétrage de | |
| 63 | + l'échec auth. `sanitize_redirect` devient paramétré par le `login_path`. La | |
| 64 | + branche protégée du middleware Tower (`auth_service.rs`, plus | |
| 65 | + `admin_service.rs`, `auth_required.rs`, `admin_required.rs`, | |
| 66 | + `admin_extractor.rs`) consulte un mode d'échec (Redirect | Json401). Le mode | |
| 67 | + est porté par l'`Extension<Arc<AuthConfig>>` déjà posée par | |
| 68 | + `RustwardenBuilder::build`. | |
| 69 | +- **B — CLI.** Nouveau template `templates::auth_routes_rs(cfg)` généré | |
| 70 | + uniquement en headless, écrit dans `src/auth_routes.rs` ; câblage dans | |
| 71 | + `main_rs` via `merge_browser_routes` / `merge_machine_routes` + | |
| 72 | + `register_protected_prefix`. Handlers en **JSON-first** (pas de HTML/Askama) : | |
| 73 | + login, refresh, logout, verify-2fa, change-password, `GET /api/me` protégé. | |
| 74 | + Réutilise les services de `rustwarden-core` (`services-api.md` fait foi). | |
| 75 | +- **C — skill.** `routes-ecrans-sans-ui.md` réécrit pour pointer vers le scaffold | |
| 76 | + généré (fichiers, services appelés) ; `SKILL.md` documente | |
| 77 | + `login_path` / mode d'échec 401 et le fait que headless démarre vert. | |
| 78 | +- **Tests.** Unitaires `#[cfg(test)]` à côté du code (core) ; acceptation via | |
| 79 | + `tower::ServiceExt::oneshot` sur le `Router` ; QA e2e du projet généré par un | |
| 80 | + agent (`cargo check` + smoke test des routes) — reproduit le protocole déjà | |
| 81 | + utilisé (obs S326). | |
| 82 | + | |
| 83 | +## Journal d'implémentation | |
| 84 | + | |
| 85 | +### Workstream A — **FAIT** (2026-08-01) | |
| 86 | + | |
| 87 | +A1 et A2 partageant les 9 mêmes sites d'échec d'auth, ils ont été câblés | |
| 88 | +ensemble via deux fonctions pures dans | |
| 89 | +[`middleware/auth_service.rs`](../../crates/rustwarden-core/src/middleware/auth_service.rs) : | |
| 90 | +`sanitize_redirect(uri, login_path)` et | |
| 91 | +`auth_failure_response(&AuthConfig, &Uri)` (+ `auth_failure_from_extensions` | |
| 92 | +pour les extracteurs). La résolution de l'`AuthConfig` a été remontée en tête | |
| 93 | +des branches protégées d'`AuthService`/`AdminService` pour que **tous** les | |
| 94 | +chemins d'échec honorent la config. | |
| 95 | + | |
| 96 | +- **A1** : `AuthConfig::login_path` (env `LOGIN_PATH`, défaut `/login`) + | |
| 97 | + setter builder `.login_path()`. Les 5 fichiers de redirection consomment la | |
| 98 | + valeur (`auth_service`, `admin_service`, `auth_required`, `admin_required`, | |
| 99 | + `admin_extractor`). | |
| 100 | +- **A2** : enum `AuthFailureMode { Redirect, Json401 }` (env `AUTH_FAILURE_MODE`) | |
| 101 | + + `AuthConfig::auth_failure_mode` + setter `.auth_failure_mode()`. | |
| 102 | + `.headless()` bascule le défaut sur `Json401` (override explicite prioritaire), | |
| 103 | + appliqué dans `build()`. | |
| 104 | +- **A3** : `protected_prefixes` par défaut désormais **vide** ; `with_ui()` | |
| 105 | + enregistre `/home`,`/profile`,`/settings`,`/admin` (les apps UI restent | |
| 106 | + protégées, le headless est propre). `/test-base` (artefact de dev) supprimé. | |
| 107 | + `/admin` reste protégé indépendamment par `AdminService`. | |
| 108 | + | |
| 109 | +**Vérification** : 12 tests unitaires + 4 tests d'intégration verts, `cargo | |
| 110 | +clippy --workspace` sans warning (hors `imap-proto` préexistant). Deux nouvelles | |
| 111 | +variables d'env à documenter en C : `LOGIN_PATH`, `AUTH_FAILURE_MODE`. | |
| 112 | + | |
| 113 | +### Workstream B — **FAIT** (2026-08-01) | |
| 114 | + | |
| 115 | +Découverte décisive : `rustwarden-core` **contient déjà** tous les handlers d'auth | |
| 116 | +JSON implémentés ([`auth/handlers.rs`](../../crates/rustwarden-core/src/auth/handlers.rs) | |
| 117 | +— login, register, refresh, logout, get_current_user, 2fa/verify, password reset, | |
| 118 | +email verify). B ne réécrit donc **aucune** logique de sécurité : il **monte** ces | |
| 119 | +handlers existants. | |
| 120 | + | |
| 121 | +- **Core** : nouveau [`auth/routes.rs`](../../crates/rustwarden-core/src/auth/routes.rs) | |
| 122 | + exposant `rustwarden_core::auth::auth_api_routes() -> Router` (assemble les | |
| 123 | + handlers sur `/api/v1/auth/*`), source **canonique** unique. | |
| 124 | +- **UI (DRY)** : `rustwarden-ui::api_routes()` délègue désormais à | |
| 125 | + `auth_api_routes()` (suppression de la liste dupliquée). | |
| 126 | +- **CLI (B1/B2)** : en mode headless, `main.rs` généré monte | |
| 127 | + `.merge_machine_routes(rustwarden_core::auth::auth_api_routes())` (canal machine, | |
| 128 | + Bearer/JSON, hors CSRF) + un endpoint protégé d'exemple `whoami` utilisant | |
| 129 | + l'extracteur `AuthUser`. Dépendance `serde_json` ajoutée au `Cargo.toml` généré. | |
| 130 | + | |
| 131 | +**Vérification** : | |
| 132 | +- Test de composition dans le core (interface du code généré verrouillée) : vert. | |
| 133 | +- `cargo clippy --workspace` : 0 warning (hors `imap-proto` préexistant). | |
| 134 | +- **B3 e2e réel** (agent qa-tester, wizard piloté via tmux) : projet headless | |
| 135 | + généré → `cargo check` **PASS** (exit 0, 2m53), `main.rs` contient bien le | |
| 136 | + wiring `auth_api_routes()` + `whoami`/`AuthUser`. | |
| 137 | + | |
| 138 | +Un projet headless neuf démarre donc **vert et fonctionnel** : login / 2FA / refresh | |
| 139 | +/ logout / me opérationnels d'emblée, plus un exemple protégé à étendre. | |
| 140 | + | |
| 141 | +### Workstream C — **FAIT** (2026-08-01) | |
| 142 | + | |
| 143 | +Skill `rustwarden-boilerplate` (`~/.claude/skills/`) alignée sur le nouveau réel — | |
| 144 | +écritures directes (doc, hors dépôt, pas de git) : | |
| 145 | + | |
| 146 | +- `references/env-vars.md` : ajout de `LOGIN_PATH` et `AUTH_FAILURE_MODE`. | |
| 147 | +- `references/routes-ecrans-sans-ui.md` : §2 réécrit (le core fournit | |
| 148 | + `auth_api_routes()` canonique, le scaffold headless le câble automatiquement → | |
| 149 | + démarre vert ; suppression du bloc copié-collé à recréer), §3 réécrit | |
| 150 | + (échec d'auth = `redirect` vers `login_path` **ou** `json401`), préfixes par | |
| 151 | + défaut corrigés (aucun / `with_ui`), checklist + greps de revalidation à jour. | |
| 152 | +- `references/pieges.md` + `references/services-api.md` : préfixes par défaut et | |
| 153 | + comportement d'échec corrigés (`/test-base` retiré, `login_path`/401). | |
| 154 | +- `SKILL.md` : squelette headless (`auth_api_routes()`), fin de la mention | |
| 155 | + « `.headless()` ne monte rien », recette routes protégées (login_path/401). | |
| 156 | + | |
| 157 | +**Vérification** : grep de cohérence — plus aucune référence obsolète à `/test-base` | |
| 158 | +comme défaut actif ni au « router auth à recréer à la main » ; les 4 fichiers | |
| 159 | +référencent la source canonique et les 2 nouvelles env vars. | |
| 160 | + | |
| 161 | +--- | |
| 162 | + | |
| 163 | +### Audit d'impact A3 sur les consommateurs (2026-08-01) | |
| 164 | + | |
| 165 | +A3 (défauts `protected_prefixes` vidés) est un **changement de comportement** pour | |
| 166 | +les apps headless qui servaient des pages sous les anciens défauts. Audit des 5 | |
| 167 | +consommateurs de `~/Documents/devel` : | |
| 168 | + | |
| 169 | +| Projet | Verdict | | |
| 170 | +|---|---| | |
| 171 | +| TaxeFonciereNet | ✅ headless trivial, aucune page protégée | | |
| 172 | +| rs_pasterman | ✅ `.with_ui()` → réenregistrement auto | | |
| 173 | +| Ebii_MarketPlace | ✅ seulement `/admin/*` (couvert par `AdminService`) + préfixes propres | | |
| 174 | +| **gitrust** | ⚠️ `/settings/*` (SSR headless) — plan créé | | |
| 175 | +| **BotPlace** | ⚠️ `/home`, `/settings/*` (SSR headless) — plan créé | | |
| 176 | + | |
| 177 | +Décision (utilisateur) : garder A3 vide, documenter dans le | |
| 178 | +`docs/SERVICES_REFERENCE.md` **du framework** (§18, se propage aux consommateurs | |
| 179 | +via `fs::copy` à l'`update-project`), et créer un plan de migration post-update | |
| 180 | +dans chaque projet exposé. Les plans couvrent **A3** (`register_protected_prefix`) | |
| 181 | +**et A2** (ces apps sont SSR-en-headless → restaurer `auth_failure_mode(Redirect)` | |
| 182 | +sinon 401 JSON au lieu du redirect login) : | |
| 183 | +- `gitrust/docs/plans/rustwarden-a3-protected-prefixes-migration.md` | |
| 184 | +- `BotPlace/docs/plans/005-rustwarden-a3-protected-prefixes-migration.md` | |
| 185 | + | |
| 186 | +## Bilan A + B + C | |
| 187 | + | |
| 188 | +Le mode headless n'est plus « complexe pour un agent » : le core est découplé de | |
| 189 | +l'UI (échec 401 JSON configurable), un projet headless neuf démarre **vert avec | |
| 190 | +une auth fonctionnelle** (source canonique `auth_api_routes()`), et la skill | |
| 191 | +oriente vers ce réel au lieu de décrire une complexité disparue. Working tree | |
| 192 | +laissé à l'utilisateur pour commit (aucune opération git effectuée). | |
| 193 | + | |
| 194 | +## Tranches | |
| 195 | + | |
| 196 | +Séquencement : **A → B → C** (B câble le mode 401 introduit en A ; C documente le | |
| 197 | +résultat de B). | |
| 198 | + | |
| 199 | +### Tranche A1 — `login_path` configurable · statut : formulée | |
| 200 | +Rendre le chemin de redirection de login injectable via `AuthConfig` | |
| 201 | +(défaut `/login`), et faire consommer cette valeur par `sanitize_redirect` et | |
| 202 | +tous les sites de redirection. Comportement UI inchangé par défaut. | |
| 203 | + | |
| 204 | +```gherkin | |
| 205 | +Feature: Chemin de redirection de login configurable | |
| 206 | + | |
| 207 | + Background: | |
| 208 | + Given une route protégée par le middleware d'authentification | |
| 209 | + | |
| 210 | + # --- Boucle interne : la fonction pure sanitize_redirect --- | |
| 211 | + Scenario: Chemin de login par défaut préservé | |
| 212 | + Given aucune configuration de login_path (défaut "/login") | |
| 213 | + When une cible relative sûre "/settings" doit être sanitisée | |
| 214 | + Then le résultat est "/login?redirect=%2Fsettings" | |
| 215 | + | |
| 216 | + Scenario: Chemin de login personnalisé honoré | |
| 217 | + Given un login_path configuré à "/auth/signin" | |
| 218 | + When une cible relative sûre "/settings" doit être sanitisée | |
| 219 | + Then le résultat est "/auth/signin?redirect=%2Fsettings" | |
| 220 | + | |
| 221 | + Scenario: Cible non sûre réduite au chemin de login nu | |
| 222 | + Given un login_path configuré à "/auth/signin" | |
| 223 | + When une cible dangereuse "//evil.example" doit être sanitisée | |
| 224 | + Then le résultat est "/auth/signin" sans paramètre redirect | |
| 225 | + | |
| 226 | + # --- Boucle externe : comportement observable du middleware --- | |
| 227 | + Scenario: Redirection anonyme vers le chemin de login configuré | |
| 228 | + Given un login_path configuré à "/auth/signin" | |
| 229 | + And aucun JWT valide sur la requête | |
| 230 | + When un client browser demande "/settings" | |
| 231 | + Then la réponse est une redirection dont l'en-tête Location commence par "/auth/signin" | |
| 232 | +``` | |
| 233 | + | |
| 234 | +### Tranche A2 — Mode d'échec auth Redirect | Json401 · statut : à formuler | |
| 235 | +Introduire un mode d'échec d'authentification. Par défaut `Redirect` (302 vers | |
| 236 | +`login_path`, comportement actuel). En `Json401`, la branche protégée renvoie | |
| 237 | +`401` + corps JSON `{ "error": "unauthorized" }` sans redirection. Exposé via un | |
| 238 | +`RustwardenBuilder`/`AuthConfig` setter ; `.headless()` bascule le défaut sur | |
| 239 | +`Json401`. | |
| 240 | + | |
| 241 | +### Tranche A3 — Défauts `protected_prefixes` propres · statut : à formuler | |
| 242 | +Retirer `/test-base` des défauts ; le set de préfixes UI (`/home`, `/profile`, | |
| 243 | +`/settings`, `/admin`) devient l'apanage de `with_ui`/`rustwarden-ui`, de sorte | |
| 244 | +qu'un projet headless ne protège que ce qu'il déclare explicitement. Compat | |
| 245 | +préservée pour les consommateurs UI existants. | |
| 246 | + | |
| 247 | +### Tranche B1 — Scaffold login/refresh/logout JSON · statut : à formuler | |
| 248 | +Générer en headless `src/auth_routes.rs` avec les handlers `POST /api/login` | |
| 249 | +(mot de passe → cookie JWT + refresh, anti-fixation de session, détection du | |
| 250 | +challenge 2FA), `POST /api/refresh`, `POST /api/logout` (blacklist JWT), câblés | |
| 251 | +dans `main.rs`. Projet généré compile. | |
| 252 | + | |
| 253 | +### Tranche B2 — Scaffold 2FA + changement de mot de passe · statut : à formuler | |
| 254 | +Ajouter au scaffold `POST /api/2fa/verify` (challenge TOTP) et | |
| 255 | +`POST /api/password` (changement, avec `must_change_password`), plus un | |
| 256 | +`GET /api/me` protégé démontrant `AuthUser`/`Claims`. | |
| 257 | + | |
| 258 | +### Tranche B3 — Vert e2e du projet headless généré · statut : à formuler | |
| 259 | +Le projet headless généré passe `cargo check`, `cargo clippy` (zéro warning) et | |
| 260 | +un smoke test des routes auth (login → me → logout) via un agent QA. Documenté. | |
| 261 | + | |
| 262 | +### Tranche C1 — Référence skill = scaffold canonique · statut : à formuler | |
| 263 | +Réécrire `references/routes-ecrans-sans-ui.md` pour décrire le scaffold généré | |
| 264 | +(fichiers, routes, services appelés) au lieu de re-spécifier en prose ; élaguer | |
| 265 | +la redondance avec `services-api.md`. | |
| 266 | + | |
| 267 | +### Tranche C2 — SKILL.md aligné · statut : à formuler | |
| 268 | +Documenter dans `SKILL.md` le mode d'échec `Json401` / `login_path` et le fait | |
| 269 | +qu'un projet headless démarre désormais vert ; renvoyer vers la référence C1. | |
| 270 | + | |
| 271 | +## Hors périmètre (explicite) | |
| 272 | + | |
| 273 | +- Pas de refonte du CSRF (déjà propre). | |
| 274 | +- Pas de re-découpage de la skill (déjà décomposée). | |
| 275 | +- Pas de génération d'UI HTML en headless (le scaffold est JSON-first). | |
| 276 | +- Aucune opération git (working tree laissé à l'utilisateur). |
A
docs/plans/2026-08-02-screen-coverage-completude.md
+240
-0
@@ -0,0 +1,240 @@
| 1 | +# Spec — Wizard `screen-select` : pense-bête exhaustif des écrans à coder | |
| 2 | + | |
| 3 | +> Statut : **design validé (itéré le 2026-08-02)**, prêt pour un plan d'implémentation. | |
| 4 | +> Modèle retenu : **wizard générateur de todo** (pas de trait/registre à compiler). | |
| 5 | + | |
| 6 | +## Problème | |
| 7 | + | |
| 8 | +Un projet qui consomme `rustwarden-core` avec une **UI custom** (design propre, | |
| 9 | +sans `rustwarden-ui`) doit coder lui-même les écrans exposant les fonctionnalités | |
| 10 | +du framework — surtout des fonctionnalités de **sécurité** : login, inscription, | |
| 11 | +2FA (challenge + setup + backup codes), changement/reset de mot de passe, | |
| 12 | +validation d'email, gestion des sessions, liaison OAuth, teams, jetons d'accès | |
| 13 | +personnels, et toute l'admin (utilisateurs, rôles, réglages, journal d'erreurs). | |
| 14 | + | |
| 15 | +**En pratique on en oublie**, et on le découvre après coup — ce qui ne fait pas | |
| 16 | +sérieux vu le poids sécurité de ces écrans. La doc existe | |
| 17 | +(`references/routes-ecrans-sans-ui.md`, `SERVICES_REFERENCE.md`) **mais ne garantit | |
| 18 | +rien** ; elle repose sur la mémoire du dev. Preuve récente : même un plan de | |
| 19 | +migration rédigé à la main a raté `/profile`. | |
| 20 | + | |
| 21 | +## Objectif | |
| 22 | + | |
| 23 | +Un **wizard à lancer** — un **binaire de `rustwarden-core`** (`cargo run --bin | |
| 24 | +screen-select`) — qui, **selon les fonctionnalités que le projet active**, produit | |
| 25 | +la **liste exhaustive des écrans à coder** — un **pense-bête pour développeur ou | |
| 26 | +agent de code** — et permet de **contrôler le réalisé** (recocher automatiquement | |
| 27 | +ce qui est déjà implémenté). | |
| 28 | + | |
| 29 | +On **ne génère pas d'UI** : on génère le **plan + le tableau de todo** à suivre, | |
| 30 | +avec, pour chaque écran, les **routes et méthodes `rustwarden-core` d'exemple** | |
| 31 | +rattachées au code réel des crates. | |
| 32 | + | |
| 33 | +### Contrainte d'indépendance (décisive pour le placement) | |
| 34 | + | |
| 35 | +L'outil vit **dans `rustwarden-core`**, pas dans `rustwarden-cli`. Raison : le | |
| 36 | +CLI ne sert qu'au scaffolding/sync et **n'est pas forcément présent** chez le dev | |
| 37 | +qui reprend un projet déjà généré. `rustwarden-core` étant **vendored (copié)** | |
| 38 | +dans le projet, un binaire `src/bin/screen-select.rs` (comme les bins existants | |
| 39 | +`migrate`, `seed`, `hash_password`, `change_password`) **voyage avec le projet** : | |
| 40 | +il tourne via `cargo run --bin screen-select` **sans aucune dépendance au dépôt | |
| 41 | +framework ni au `rustwarden-cli`**. Le **catalogue** (fonctionnalité→écrans→ | |
| 42 | +méthodes) vit lui aussi dans `rustwarden-core`, donc il est resynchronisé | |
| 43 | +automatiquement par `update-project` (comme le reste de la crate). | |
| 44 | + | |
| 45 | +## Non-goals (YAGNI) | |
| 46 | + | |
| 47 | +- **Aucune UI générée**, aucun style, aucun chemin imposé. | |
| 48 | +- **Aucune contrainte compilateur** (pas de trait/registre exhaustif à | |
| 49 | + implémenter — abandonné à l'itération design). | |
| 50 | +- Le **contrôle du réalisé est best-effort** (analyse statique des routes du | |
| 51 | + projet ; « si c'est possible » — un écran présent mais stub ne peut pas être | |
| 52 | + jugé sur le fond). | |
| 53 | +- Ne remplace pas l'API JSON fournie par le core (`auth_api_routes()`). | |
| 54 | + | |
| 55 | +## Base de connaissance — le catalogue fonctionnalités → écrans | |
| 56 | + | |
| 57 | +Cœur du dispositif : un **catalogue structuré**, dérivé du **code** (recoupé avec | |
| 58 | +les 24 modules de `crates/rustwarden-core/src/services/mod.rs`, audit 2026-08-02), | |
| 59 | +maintenu **dans le dépôt framework** et consommé par le wizard. Pour chaque | |
| 60 | +**fonctionnalité activable**, il liste ses **écrans**, et pour chaque écran : | |
| 61 | + | |
| 62 | +- une **route suggérée** (défaut sensé, librement modifiable par le dev) ; | |
| 63 | +- les **méthodes / routes `rustwarden-core`** à appeler (rattachement au code) ; | |
| 64 | +- un résumé du **contrat I/O** ; | |
| 65 | +- le **canal** (browser SSR / machine) et si l'écran est **protégé**. | |
| 66 | + | |
| 67 | +Représentation : **données statiques Rust (`const`) typées, dans un module de | |
| 68 | +`rustwarden-core`** (une source, testée), exportables aussi en Markdown pour | |
| 69 | +lecture humaine. L'« enum par fonctionnalité » reste l'ossature conceptuelle ; | |
| 70 | +ce n'est plus un trait que le consommateur implémente, mais la **table de vérité** | |
| 71 | +qu'exploite le binaire `screen-select` (embarqué dans la crate vendored). | |
| 72 | + | |
| 73 | +### Fonctionnalités activables et leurs écrans | |
| 74 | + | |
| 75 | +| Fonctionnalité (activable) | Écrans requis quand activée | Services core source | | |
| 76 | +|---|---|---| | |
| 77 | +| **Authentication** *(toujours)* | Login | `AuthService`, `JwtService`, `auth_api_routes()` | | |
| 78 | +| **Registration** | Register (formulaire) *(le toggle admin vit dans AdminSettings)* | `UserService`, `AppSettingsService` (`allow_registration`) | | |
| 79 | +| **PasswordReset** | ForgotPassword, ResetPassword | `PasswordResetService` | | |
| 80 | +| **EmailValidation** | ResendVerification *(page de résultat rendue par le core)* | `EmailValidationService` | | |
| 81 | +| **TwoFactor** | TwoFactorChallenge, TwoFactorSetup, TwoFactorBackupCodes | `TotpService` | | |
| 82 | +| **AccountProfile** | Profile | `UserService` | | |
| 83 | +| **PasswordChange** | PasswordChange, ConfirmPasswordChange | `PasswordChangeService` | | |
| 84 | +| **SessionManagement** | SessionManagement | `RefreshTokenService`, `JwtBlacklistService` | | |
| 85 | +| **Preferences** | Preferences | `UserPreferencesService` | | |
| 86 | +| **OAuthLinking** *(feature `oauth`)* | OAuthAccountLinking (+ boutons sur Login) | `OAuthService` | | |
| 87 | +| **Teams** | TeamsManagement | `TeamService` | | |
| 88 | +| **PersonalAccessTokens** | PatManagement | `PersonalTokenService<S>` | | |
| 89 | +| **Admin · Users** | AdminUsers | `UserService`, `RoleService`, `PasswordResetService`, `TotpService` | | |
| 90 | +| **Admin · Roles** | AdminRoles | `RoleService`, `PermissionService` | | |
| 91 | +| **Admin · Settings** | AdminSettings | `AppSettingsService`, `CryptoService` | | |
| 92 | +| **Admin · ErrorLog** | AdminErrorLog | `error_handler`, `I18nService` | | |
| 93 | +| **Admin · EmailQueue** | AdminEmailQueue | `EmailQueueService` | | |
| 94 | +| **Admin · Dashboard** *(requis si ≥1 bloc admin activé)* | AdminDashboard | cartes `DashboardCardDTO` | | |
| 95 | + | |
| 96 | +**Primitives sans écran fixe** (documentées, hors catalogue) : `ResourceService` | |
| 97 | +(le partage se greffe sur *tes* entités métier) et `NotificationBus`/SSE | |
| 98 | +(transport temps réel). Mécanismes purs sans écran : `PasswordService`, | |
| 99 | +`CryptoService`, `RateLimitService`, `EmailService`, `AuthLoggingService`, i18n, | |
| 100 | +hooks, processors de tâches de fond. | |
| 101 | + | |
| 102 | +### Exemple d'entrée de catalogue (une ligne détaillée) | |
| 103 | + | |
| 104 | +``` | |
| 105 | +SessionManagement (fonctionnalité) | |
| 106 | + écran: SessionsList | |
| 107 | + route_suggérée: GET /settings/sessions (browser, protégé) | |
| 108 | + core: | |
| 109 | + - RefreshTokenService::list_active(&db, user_id) -> Vec<ActiveSession> | |
| 110 | + - RefreshTokenService::revoke(&db, token_id) | |
| 111 | + - (déconnexion globale) POST /api/v1/auth/logout | |
| 112 | + io: rend la liste des sessions (device, ip, date) + action révoquer | |
| 113 | +``` | |
| 114 | + | |
| 115 | +## Le wizard `screen-select` (binaire de `rustwarden-core`) | |
| 116 | + | |
| 117 | +**Entrées (prompts) :** | |
| 118 | +1. **Fonctionnalités activées** (multi-sélection, comme on active oauth/i18n) : | |
| 119 | + Registration, PasswordReset, EmailValidation, TwoFactor, AccountProfile, | |
| 120 | + PasswordChange, SessionManagement, Preferences, OAuthLinking *(proposé si la | |
| 121 | + feature `oauth` est détectée)*, Teams, PersonalAccessTokens, Administration | |
| 122 | + (+ AdminEmailQueue). `Authentication` est toujours incluse. | |
| 123 | +2. **`design.md`** (optionnel) : **charte graphique** (couleurs, **police**, ton | |
| 124 | + visuel) reprise en tête du plan comme **contraintes de style à respecter** — | |
| 125 | + elle n'influence pas la liste des écrans requis. | |
| 126 | +3. **Structure de page** (optionnel) : position navbar (haut/gauche), nombre de | |
| 127 | + colonnes, layout — injecté comme contexte de mise en page dans le plan. | |
| 128 | + | |
| 129 | +**Sorties générées :** | |
| 130 | +- **`docs/plans/ui-<projet>.md`** — un **plan UI** : rappel de la charte | |
| 131 | + (design.md), structure de page choisie, puis le **tableau de todo exhaustif**. | |
| 132 | +- Le **tableau de todo** — une ligne par écran des fonctionnalités activées : | |
| 133 | + | |
| 134 | + | Statut | Fonctionnalité | Écran | Route (à confirmer) | Canal | Protégé | Méthodes core d'exemple | Contrat I/O | | |
| 135 | + |---|---|---|---|---|---|---|---| | |
| 136 | + | ☐ | SessionManagement | SessionsList | `GET /settings/sessions` | browser | oui | `RefreshTokenService::list_active`, `::revoke` | liste + révoquer | | |
| 137 | + | ☐ | TwoFactor | TotpSetup | `GET /settings/2fa` | browser | oui | `TotpService::generate_setup`, `::confirm` | QR + code | | |
| 138 | + | … | | | | | | | | | |
| 139 | + | |
| 140 | +Le dev/agent code chaque écran, ajuste la route si besoin **dans le tableau**, et | |
| 141 | +coche `☑`. | |
| 142 | + | |
| 143 | +## Contrôle du réalisé (`screen-select --check`) | |
| 144 | + | |
| 145 | +Re-jouable à tout moment. Pour chaque ligne du todo : | |
| 146 | +- **analyse statique** des routes du projet (`grep`/parse des `.route("…")` dans | |
| 147 | + `src/` et les crates du projet) : la route inscrite dans le todo est-elle | |
| 148 | + montée ? → marque **réalisé** / **manquant**. | |
| 149 | +- met à jour la **colonne Statut** du `docs/plans/ui-<projet>.md` (ou imprime un | |
| 150 | + rapport), et **liste les écrans manquants**. | |
| 151 | + | |
| 152 | +Best-effort assumé : détecte « route absente » (l'oubli visé), pas « handler | |
| 153 | +stub ». Pas de DB requise (statique). *(Option future : sonde runtime `oneshot` | |
| 154 | +≠404 si on veut aller plus loin — hors périmètre initial.)* | |
| 155 | + | |
| 156 | +## Placement & cycle de vie | |
| 157 | + | |
| 158 | +- **Binaire** dans `crates/rustwarden-core/src/bin/screen-select.rs` + déclaration | |
| 159 | + `[[bin]]` dans le `Cargo.toml` de core (même pattern que `migrate` / `seed` / | |
| 160 | + `hash_password` / `change_password`). **Aucun code** dans `rustwarden-cli` en | |
| 161 | + dehors des messages d'invite ci-dessous. | |
| 162 | +- **`start-project` et `update-project` expliquent comment le lancer** : dans leur | |
| 163 | + section « Prochaines étapes » finale, ils impriment | |
| 164 | + `cargo run --bin screen-select` (générer/compléter le plan d'écrans) et | |
| 165 | + `cargo run --bin screen-select -- --check` (contrôler le réalisé). | |
| 166 | +- **À la création** : le dev lance `screen-select` quand il attaque son UI. | |
| 167 | +- **Après `update-project`** : binaire et catalogue sont resynchronisés avec la | |
| 168 | + crate ; relancer `screen-select` **complète** le todo avec les écrans des | |
| 169 | + **nouvelles fonctionnalités** (nouvelles lignes `☐`), **sans écraser les statuts | |
| 170 | + existants** (merge par clé fonctionnalité+écran). | |
| 171 | +- Le **catalogue** (dans `rustwarden-core`) est la **source de vérité** de la | |
| 172 | + complétude ; `references/routes-ecrans-sans-ui.md` y renvoie. | |
| 173 | + | |
| 174 | +## Décisions verrouillées | |
| 175 | + | |
| 176 | +- **Binaire de `rustwarden-core`** (`src/bin/screen-select.rs`), vendored avec le | |
| 177 | + projet → **indépendant du framework et du `rustwarden-cli`** ; le CLI ne fait | |
| 178 | + qu'imprimer l'invite de lancement. | |
| 179 | +- Wizard **`screen-select`** exploitant le catalogue (enum-idée) | |
| 180 | + fonctionnalité→écrans→méthodes. | |
| 181 | +- **Activation par fonctionnalité** (sélection dans le wizard), pas de waiver | |
| 182 | + écran par écran. | |
| 183 | +- Entrées optionnelles **`design.md`** + **structure de page**. | |
| 184 | +- Sortie = **plan + tableau de todo** (routes + méthodes core d'exemple). | |
| 185 | +- **Todo seul** (pas de trait/test compile) + **contrôle du réalisé** par analyse | |
| 186 | + statique re-jouable. | |
| 187 | + | |
| 188 | +## Décisions ouvertes (pour le plan) | |
| 189 | + | |
| 190 | +1. **Support du catalogue** : ✅ tranché — `const` Rust typé dans un module de | |
| 191 | + `rustwarden-core` (une source, testée), embarqué dans le binaire ; export MD | |
| 192 | + optionnel pour lecture humaine. | |
| 193 | +2. **Format du todo** : ✅ tranché — **Markdown** (diffable, éditable à la main ; | |
| 194 | + cases `☐/☑` relisibles par `--check`). | |
| 195 | +3. **Granularité admin** : ✅ tranché — **sous-fonctionnalités** activables | |
| 196 | + séparément (Users / Roles / Settings / ErrorLog / EmailQueue), + `AdminDashboard` | |
| 197 | + requis dès qu'un bloc admin est activé. | |
| 198 | +4. **Portée du `--check`** : ✅ tranché — **Niveau 1 statique seul** (analyse du | |
| 199 | + code source, sans base de données). La sonde runtime (Niveau 2) reste une piste | |
| 200 | + future explicitement **hors périmètre**. | |
| 201 | +5. **`design.md` / structure de page** : ✅ tranché — `design.md` = **charte | |
| 202 | + graphique** (couleurs, police, ton) reprise comme **contraintes de style** en | |
| 203 | + tête du plan ; la structure de page (navbar/colonnes) = contexte de mise en | |
| 204 | + page. **Aucune influence** sur les écrans requis (issus du catalogue). | |
| 205 | + | |
| 206 | +### Portée du `--check` — ✅ tranché : Niveau 1 (statique seul) | |
| 207 | + | |
| 208 | +Question : **quand `--check` déclare un écran « fait », à quel point veut-on en | |
| 209 | +être sûr ?** Trois profondeurs possibles ; **le Niveau 1 est retenu**, les | |
| 210 | +Niveaux 2 et 3 sont hors périmètre. | |
| 211 | + | |
| 212 | +- **Niveau 1 — statique (analyse du code source)** : `--check` cherche dans les | |
| 213 | + sources du projet une route `.route("<chemin du todo>")`. Trouvée → ☑, sinon ☐. | |
| 214 | + *Pour* : instantané, hors-ligne, sans base de données ; attrape le cas visé | |
| 215 | + (« j'ai complètement oublié l'écran »). *Contre* : prouve seulement qu'une route | |
| 216 | + est **déclarée** dans le code — pas qu'elle est réellement **montée** dans le | |
| 217 | + routeur final, ni que le handler n'est pas un stub. | |
| 218 | +- **Niveau 2 — runtime (sonde HTTP)** : `--check` **démarre l'app** (donc base | |
| 219 | + requise) et fait `GET <chemin>` en vérifiant ≠ 404. *Pour* : prouve que la | |
| 220 | + route est **effectivement montée/atteignable** (attrape « déclaré mais pas | |
| 221 | + mergé »). *Contre* : nécessite une base de dev + le boot de l'app ; un stub qui | |
| 222 | + répond 200 passerait quand même. | |
| 223 | +- **Niveau 3 — contenu** : vérifier que l'écran *fait la bonne chose* | |
| 224 | + (rend les bons éléments, appelle le bon service) → **hors périmètre** (non | |
| 225 | + automatisable de façon fiable). | |
| 226 | + | |
| 227 | +Reco : **Niveau 1 par défaut** (léger, couvre l'oubli — le vrai problème), avec le | |
| 228 | +**Niveau 2 en option** via un flag `--check --runtime` pour qui veut la garantie | |
| 229 | +« vraiment câblé ». Le Niveau 3 reste explicitement exclu. | |
| 230 | + | |
| 231 | +## Stratégie de test (du wizard lui-même) | |
| 232 | + | |
| 233 | +- Tests unitaires : le catalogue couvre toutes les fonctionnalités UI (cohérence | |
| 234 | + avec une liste de référence dérivée de `services/mod.rs`) ; génération du todo | |
| 235 | + pour un set de fonctionnalités donné → tableau attendu ; `--check` sur un projet | |
| 236 | + factice (une route présente, une absente) → statuts attendus ; merge après | |
| 237 | + « update » (nouvelle fonctionnalité) → nouvelles lignes sans perte de statut. | |
| 238 | +- Test e2e : `start-project` (UI custom) → `screen-select` → todo généré → | |
| 239 | + implémenter une route → `--check` la voit réalisée (réutilise le harnais type | |
| 240 | + B3). |
A
docs/plans/2026-08-02-screen-select-implementation.md
+1175
-0
@@ -0,0 +1,1175 @@
| 1 | +# screen-select Implementation Plan | |
| 2 | + | |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | +> | |
| 5 | +> **Politique git de ce projet** : versioning **manuel**. Les étapes « Commit » sont des **points de checkpoint** — le développeur committe s'il le souhaite ; **un agent ne committe jamais sans accord explicite**. Ne pas `git add/commit/push` automatiquement. | |
| 6 | + | |
| 7 | +**Goal:** Fournir un binaire `screen-select` dans `rustwarden-core` (vendored) qui génère, selon les fonctionnalités activées, un plan Markdown listant tous les écrans à coder, et recoche le réalisé par analyse statique des routes. | |
| 8 | + | |
| 9 | +**Architecture:** Un binaire à modules bin-locaux (`src/bin/screen-select/*`) — le **catalogue** (fonctionnalité→écrans, données `const`) et la logique (render/parse/merge/check) restent **hors de l'API publique** de la lib. Wizard interactif en `std::io` (aucune dépendance ajoutée). Le CLI (`start-project`/`update-project`) imprime seulement l'invite de lancement. | |
| 10 | + | |
| 11 | +**Tech Stack:** Rust 2024, `std` uniquement (pas de nouvelle crate), `cargo`. | |
| 12 | + | |
| 13 | +**Réf spec :** `docs/plans/2026-08-02-screen-coverage-completude.md` | |
| 14 | + | |
| 15 | +--- | |
| 16 | + | |
| 17 | +## Structure des fichiers | |
| 18 | + | |
| 19 | +| Fichier | Responsabilité | | |
| 20 | +|---|---| | |
| 21 | +| `crates/rustwarden-core/Cargo.toml` | déclaration `[[bin]] name = "screen-select"` | | |
| 22 | +| `crates/rustwarden-core/src/bin/screen-select/main.rs` | binaire : args (`--check`), wizard stdin, I/O fichiers | | |
| 23 | +| `.../screen-select/catalogue.rs` | types + données `const CATALOGUE` (source de vérité) | | |
| 24 | +| `.../screen-select/todo.rs` | render / parse / replace_table / merge du tableau Markdown | | |
| 25 | +| `.../screen-select/generate.rs` | `rows_for` + `render_plan` (charte + structure + tableau) | | |
| 26 | +| `.../screen-select/check.rs` | `mark` (pur) + `collect_sources` (I/O) — contrôle statique | | |
| 27 | +| `tools/rustwarden-cli/src/start_project.rs` | invite de lancement en « Prochaines étapes » | | |
| 28 | +| `tools/rustwarden-cli/src/update_project.rs` | idem après update | | |
| 29 | + | |
| 30 | +Le fichier généré est **`docs/plans/ui-screens.md`** (chemin fixe, pour que `--check` retrouve le même fichier). | |
| 31 | + | |
| 32 | +--- | |
| 33 | + | |
| 34 | +## Task 1: Squelette du binaire + catalogue (types & données) | |
| 35 | + | |
| 36 | +**Files:** | |
| 37 | +- Modify: `crates/rustwarden-core/Cargo.toml` (ajout `[[bin]]`) | |
| 38 | +- Create: `crates/rustwarden-core/src/bin/screen-select/main.rs` | |
| 39 | +- Create: `crates/rustwarden-core/src/bin/screen-select/catalogue.rs` | |
| 40 | + | |
| 41 | +- [ ] **Step 1: Déclarer le binaire dans Cargo.toml** | |
| 42 | + | |
| 43 | +Ajouter à la fin de la section des `[[bin]]` de `crates/rustwarden-core/Cargo.toml` : | |
| 44 | + | |
| 45 | +```toml | |
| 46 | +[[bin]] | |
| 47 | +name = "screen-select" | |
| 48 | +path = "src/bin/screen-select/main.rs" | |
| 49 | +``` | |
| 50 | + | |
| 51 | +- [ ] **Step 2: Créer le main minimal** | |
| 52 | + | |
| 53 | +`crates/rustwarden-core/src/bin/screen-select/main.rs` : | |
| 54 | + | |
| 55 | +```rust | |
| 56 | +//! `screen-select` : génère/complète le plan des écrans à coder pour une UI | |
| 57 | +//! custom consommant rustwarden-core, et recoche le réalisé (`--check`). | |
| 58 | + | |
| 59 | +mod catalogue; | |
| 60 | + | |
| 61 | +fn main() { | |
| 62 | + println!("screen-select : {} fonctionnalités au catalogue", catalogue::all().len()); | |
| 63 | +} | |
| 64 | +``` | |
| 65 | + | |
| 66 | +- [ ] **Step 3: Écrire le test d'invariants du catalogue (rouge)** | |
| 67 | + | |
| 68 | +Dans `catalogue.rs`, prévoir le module de test AVANT les données pour piloter la forme. Créer `crates/rustwarden-core/src/bin/screen-select/catalogue.rs` avec seulement les types + `CATALOGUE` vide + ce test : | |
| 69 | + | |
| 70 | +```rust | |
| 71 | +//! Catalogue statique fonctionnalité → écrans (source de vérité de complétude). | |
| 72 | + | |
| 73 | +/// Canal de montage d'un écran. | |
| 74 | +#[derive(Clone, Copy, PartialEq, Eq, Debug)] | |
| 75 | +#[allow(dead_code)] // `Machine` réservé à de futurs écrans (aucun aujourd'hui) | |
| 76 | +pub enum Channel { | |
| 77 | + /// Route SSR pilotée par cookie de session (CSRF appliqué). | |
| 78 | + Browser, | |
| 79 | + /// API JSON / Bearer (hors CSRF). | |
| 80 | + Machine, | |
| 81 | +} | |
| 82 | + | |
| 83 | +impl Channel { | |
| 84 | + /// Libellé court pour le tableau. | |
| 85 | + pub fn label(self) -> &'static str { | |
| 86 | + match self { | |
| 87 | + Channel::Browser => "browser", | |
| 88 | + Channel::Machine => "machine", | |
| 89 | + } | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +/// Un écran requis quand sa fonctionnalité est activée. | |
| 94 | +#[derive(Clone, Copy, Debug)] | |
| 95 | +pub struct Screen { | |
| 96 | + pub name: &'static str, | |
| 97 | + pub suggested_route: &'static str, | |
| 98 | + pub channel: Channel, | |
| 99 | + pub protected: bool, | |
| 100 | + pub core_methods: &'static str, | |
| 101 | + pub io: &'static str, | |
| 102 | +} | |
| 103 | + | |
| 104 | +/// Une fonctionnalité activable et ses écrans. | |
| 105 | +#[derive(Clone, Copy, Debug)] | |
| 106 | +pub struct Functionality { | |
| 107 | + pub key: &'static str, | |
| 108 | + pub label: &'static str, | |
| 109 | + pub always: bool, | |
| 110 | + pub screens: &'static [Screen], | |
| 111 | +} | |
| 112 | + | |
| 113 | +/// Toutes les fonctionnalités du catalogue. | |
| 114 | +pub fn all() -> &'static [Functionality] { | |
| 115 | + CATALOGUE | |
| 116 | +} | |
| 117 | + | |
| 118 | +/// Recherche par clé stable. | |
| 119 | +#[allow(dead_code)] // utilisé par les tests des autres modules du binaire | |
| 120 | +pub fn by_key(key: &str) -> Option<&'static Functionality> { | |
| 121 | + CATALOGUE.iter().find(|f| f.key == key) | |
| 122 | +} | |
| 123 | + | |
| 124 | +const CATALOGUE: &[Functionality] = &[]; | |
| 125 | + | |
| 126 | +#[cfg(test)] | |
| 127 | +mod tests { | |
| 128 | + use super::*; | |
| 129 | + | |
| 130 | + #[test] | |
| 131 | + fn catalogue_invariants() { | |
| 132 | + assert!(!all().is_empty(), "catalogue non vide"); | |
| 133 | + // clés uniques | |
| 134 | + for (i, f) in all().iter().enumerate() { | |
| 135 | + assert!(!f.key.is_empty() && !f.label.is_empty()); | |
| 136 | + assert!(!f.screens.is_empty(), "{} a au moins un écran", f.key); | |
| 137 | + assert!( | |
| 138 | + all().iter().skip(i + 1).all(|g| g.key != f.key), | |
| 139 | + "clé dupliquée: {}", | |
| 140 | + f.key | |
| 141 | + ); | |
| 142 | + } | |
| 143 | + // exactement une fonctionnalité "always" (Authentication) | |
| 144 | + assert_eq!(all().iter().filter(|f| f.always).count(), 1); | |
| 145 | + } | |
| 146 | +} | |
| 147 | +``` | |
| 148 | + | |
| 149 | +- [ ] **Step 4: Lancer le test — il échoue (catalogue vide)** | |
| 150 | + | |
| 151 | +Run: `cargo test -p rustwarden-core --bin screen-select -- catalogue_invariants` | |
| 152 | +Expected: FAIL sur `assert!(!all().is_empty())`. | |
| 153 | + | |
| 154 | +- [ ] **Step 5: Remplir le catalogue** | |
| 155 | + | |
| 156 | +Remplacer `const CATALOGUE: &[Functionality] = &[];` par les données (transcription exacte du tableau du catalogue de la spec) : | |
| 157 | + | |
| 158 | +```rust | |
| 159 | +const CATALOGUE: &[Functionality] = &[ | |
| 160 | + Functionality { key: "authentication", label: "Authentication", always: true, screens: &[ | |
| 161 | + Screen { name: "Login", suggested_route: "/login", channel: Channel::Browser, protected: false, | |
| 162 | + core_methods: "AuthService, JwtService, POST /api/v1/auth/login", io: "form login → cookie JWT / challenge 2FA" }, | |
| 163 | + ]}, | |
| 164 | + Functionality { key: "registration", label: "Registration", always: false, screens: &[ | |
| 165 | + Screen { name: "Register", suggested_route: "/register", channel: Channel::Browser, protected: false, | |
| 166 | + core_methods: "UserService, AppSettingsService(allow_registration), POST /api/v1/auth/register", io: "form inscription" }, | |
| 167 | + ]}, | |
| 168 | + Functionality { key: "password_reset", label: "PasswordReset", always: false, screens: &[ | |
| 169 | + Screen { name: "ForgotPassword", suggested_route: "/forgot-password", channel: Channel::Browser, protected: false, | |
| 170 | + core_methods: "PasswordResetService, POST /api/v1/auth/forgot-password", io: "email → lien reset" }, | |
| 171 | + Screen { name: "ResetPassword", suggested_route: "/reset-password", channel: Channel::Browser, protected: false, | |
| 172 | + core_methods: "PasswordResetService, POST /api/v1/auth/reset-password", io: "token + nouveau mdp" }, | |
| 173 | + ]}, | |
| 174 | + Functionality { key: "email_validation", label: "EmailValidation", always: false, screens: &[ | |
| 175 | + Screen { name: "ResendVerification", suggested_route: "/resend-verification", channel: Channel::Browser, protected: false, | |
| 176 | + core_methods: "EmailValidationService, POST /api/v1/auth/resend-verification", io: "renvoi email (résultat rendu par le core)" }, | |
| 177 | + ]}, | |
| 178 | + Functionality { key: "two_factor", label: "TwoFactor", always: false, screens: &[ | |
| 179 | + Screen { name: "TwoFactorChallenge", suggested_route: "/login/2fa", channel: Channel::Browser, protected: false, | |
| 180 | + core_methods: "TotpService, POST /api/v1/auth/2fa/verify", io: "code TOTP ou backup" }, | |
| 181 | + Screen { name: "TwoFactorSetup", suggested_route: "/settings/2fa", channel: Channel::Browser, protected: true, | |
| 182 | + core_methods: "TotpService::generate_setup/confirm/disable", io: "QR + confirmation" }, | |
| 183 | + Screen { name: "TwoFactorBackupCodes", suggested_route: "/settings/2fa/backup", channel: Channel::Browser, protected: true, | |
| 184 | + core_methods: "TotpService (backup codes)", io: "génération/affichage codes" }, | |
| 185 | + ]}, | |
| 186 | + Functionality { key: "account_profile", label: "AccountProfile", always: false, screens: &[ | |
| 187 | + Screen { name: "Profile", suggested_route: "/profile", channel: Channel::Browser, protected: true, | |
| 188 | + core_methods: "UserService", io: "voir/éditer profil" }, | |
| 189 | + ]}, | |
| 190 | + Functionality { key: "password_change", label: "PasswordChange", always: false, screens: &[ | |
| 191 | + Screen { name: "PasswordChange", suggested_route: "/settings/password", channel: Channel::Browser, protected: true, | |
| 192 | + core_methods: "PasswordChangeService", io: "changement mdp (confirmation email)" }, | |
| 193 | + Screen { name: "ConfirmPasswordChange", suggested_route: "/confirm-password-change", channel: Channel::Browser, protected: false, | |
| 194 | + core_methods: "PasswordChangeService::find_by_token/confirm_request", io: "confirme via token" }, | |
| 195 | + ]}, | |
| 196 | + Functionality { key: "session_management", label: "SessionManagement", always: false, screens: &[ | |
| 197 | + Screen { name: "SessionsList", suggested_route: "/settings/sessions", channel: Channel::Browser, protected: true, | |
| 198 | + core_methods: "RefreshTokenService::list_active/revoke, JwtBlacklistService", io: "liste sessions + révoquer" }, | |
| 199 | + ]}, | |
| 200 | + Functionality { key: "preferences", label: "Preferences", always: false, screens: &[ | |
| 201 | + Screen { name: "Preferences", suggested_route: "/settings/preferences", channel: Channel::Browser, protected: true, | |
| 202 | + core_methods: "UserPreferencesService", io: "préférences utilisateur" }, | |
| 203 | + ]}, | |
| 204 | + Functionality { key: "oauth_linking", label: "OAuthLinking", always: false, screens: &[ | |
| 205 | + Screen { name: "OAuthAccountLinking", suggested_route: "/settings/oauth", channel: Channel::Browser, protected: true, | |
| 206 | + core_methods: "OAuthService, GET /api/v1/auth/oauth/{provider}/redirect", io: "lier/délier comptes (+ boutons login)" }, | |
| 207 | + ]}, | |
| 208 | + Functionality { key: "teams", label: "Teams", always: false, screens: &[ | |
| 209 | + Screen { name: "TeamsManagement", suggested_route: "/teams", channel: Channel::Browser, protected: true, | |
| 210 | + core_methods: "TeamService (create/add_member/…), TeamRole", io: "équipes + membres (owner/admin/member)" }, | |
| 211 | + ]}, | |
| 212 | + Functionality { key: "personal_access_tokens", label: "PersonalAccessTokens", always: false, screens: &[ | |
| 213 | + Screen { name: "PatManagement", suggested_route: "/settings/tokens", channel: Channel::Browser, protected: true, | |
| 214 | + core_methods: "PersonalTokenService::create/list_for_user/revoke", io: "créer/révoquer jetons (plaintext 1 fois)" }, | |
| 215 | + ]}, | |
| 216 | + Functionality { key: "admin_users", label: "Admin · Users", always: false, screens: &[ | |
| 217 | + Screen { name: "AdminUsers", suggested_route: "/admin/users", channel: Channel::Browser, protected: true, | |
| 218 | + core_methods: "UserService, RoleService, PasswordResetService, TotpService", io: "liste + CRUD + reset" }, | |
| 219 | + ]}, | |
| 220 | + Functionality { key: "admin_roles", label: "Admin · Roles", always: false, screens: &[ | |
| 221 | + Screen { name: "AdminRoles", suggested_route: "/admin/roles", channel: Channel::Browser, protected: true, | |
| 222 | + core_methods: "RoleService, PermissionService", io: "liste + CRUD rôles/permissions" }, | |
| 223 | + ]}, | |
| 224 | + Functionality { key: "admin_settings", label: "Admin · Settings", always: false, screens: &[ | |
| 225 | + Screen { name: "AdminSettings", suggested_route: "/admin/settings", channel: Channel::Browser, protected: true, | |
| 226 | + core_methods: "AppSettingsService, CryptoService", io: "réglages applicatifs" }, | |
| 227 | + ]}, | |
| 228 | + Functionality { key: "admin_error_log", label: "Admin · ErrorLog", always: false, screens: &[ | |
| 229 | + Screen { name: "AdminErrorLog", suggested_route: "/admin/errors", channel: Channel::Browser, protected: true, | |
| 230 | + core_methods: "error_handler, I18nService", io: "journal d'erreurs" }, | |
| 231 | + ]}, | |
| 232 | + Functionality { key: "admin_email_queue", label: "Admin · EmailQueue", always: false, screens: &[ | |
| 233 | + Screen { name: "AdminEmailQueue", suggested_route: "/admin/emails", channel: Channel::Browser, protected: true, | |
| 234 | + core_methods: "EmailQueueService", io: "file d'emails" }, | |
| 235 | + ]}, | |
| 236 | + Functionality { key: "admin_dashboard", label: "Admin · Dashboard", always: false, screens: &[ | |
| 237 | + Screen { name: "AdminDashboard", suggested_route: "/admin", channel: Channel::Browser, protected: true, | |
| 238 | + core_methods: "cartes DashboardCardDTO", io: "accueil admin" }, | |
| 239 | + ]}, | |
| 240 | +]; | |
| 241 | +``` | |
| 242 | + | |
| 243 | +- [ ] **Step 6: Lancer le test + le binaire** | |
| 244 | + | |
| 245 | +Run: `cargo test -p rustwarden-core --bin screen-select -- catalogue_invariants` | |
| 246 | +Expected: PASS. | |
| 247 | +Run: `cargo run -p rustwarden-core --bin screen-select` | |
| 248 | +Expected: affiche `screen-select : 18 fonctionnalités au catalogue`. | |
| 249 | + | |
| 250 | +- [ ] **Step 7: Commit (checkpoint)** | |
| 251 | + | |
| 252 | +```bash | |
| 253 | +git add crates/rustwarden-core/Cargo.toml crates/rustwarden-core/src/bin/screen-select/ | |
| 254 | +git commit -m "feat(screen-select): binaire + catalogue fonctionnalités→écrans" | |
| 255 | +``` | |
| 256 | + | |
| 257 | +--- | |
| 258 | + | |
| 259 | +## Task 2: Rendu Markdown du tableau (`todo::render`) | |
| 260 | + | |
| 261 | +**Files:** | |
| 262 | +- Create: `crates/rustwarden-core/src/bin/screen-select/todo.rs` | |
| 263 | +- Modify: `crates/rustwarden-core/src/bin/screen-select/main.rs` (déclarer `mod todo;`) | |
| 264 | + | |
| 265 | +- [ ] **Step 1: Déclarer le module** | |
| 266 | + | |
| 267 | +Dans `main.rs`, ajouter sous `mod catalogue;` : | |
| 268 | + | |
| 269 | +```rust | |
| 270 | +mod todo; | |
| 271 | +``` | |
| 272 | + | |
| 273 | +- [ ] **Step 2: Écrire le test de rendu (rouge)** | |
| 274 | + | |
| 275 | +Créer `todo.rs` avec le type + un `render` vide + ce test : | |
| 276 | + | |
| 277 | +```rust | |
| 278 | +//! Rendu / lecture / fusion du tableau de todo Markdown. | |
| 279 | + | |
| 280 | +/// Une ligne du tableau de todo. | |
| 281 | +#[derive(Clone, Debug, PartialEq, Eq)] | |
| 282 | +pub struct TodoRow { | |
| 283 | + pub done: bool, | |
| 284 | + pub functionality: String, | |
| 285 | + pub screen: String, | |
| 286 | + pub route: String, | |
| 287 | + pub channel: String, | |
| 288 | + pub protected: bool, | |
| 289 | + pub methods: String, | |
| 290 | + pub io: String, | |
| 291 | +} | |
| 292 | + | |
| 293 | +const HEADER: &str = | |
| 294 | + "| Statut | Fonctionnalité | Écran | Route | Canal | Protégé | Méthodes core | I/O |"; | |
| 295 | +const SEP: &str = "|---|---|---|---|---|---|---|---|"; | |
| 296 | + | |
| 297 | +/// Rend les lignes en tableau Markdown (en-tête + séparateur + lignes). | |
| 298 | +pub fn render(rows: &[TodoRow]) -> String { | |
| 299 | + String::new() // remplacé au Step 4 | |
| 300 | +} | |
| 301 | + | |
| 302 | +#[cfg(test)] | |
| 303 | +mod tests { | |
| 304 | + use super::*; | |
| 305 | + | |
| 306 | + fn row() -> TodoRow { | |
| 307 | + TodoRow { | |
| 308 | + done: false, | |
| 309 | + functionality: "TwoFactor".into(), | |
| 310 | + screen: "TwoFactorSetup".into(), | |
| 311 | + route: "/settings/2fa".into(), | |
| 312 | + channel: "browser".into(), | |
| 313 | + protected: true, | |
| 314 | + methods: "TotpService".into(), | |
| 315 | + io: "QR".into(), | |
| 316 | + } | |
| 317 | + } | |
| 318 | + | |
| 319 | + #[test] | |
| 320 | + fn render_contient_entete_et_ligne() { | |
| 321 | + let out = render(&[row()]); | |
| 322 | + assert!(out.contains(HEADER)); | |
| 323 | + assert!(out.contains(SEP)); | |
| 324 | + assert!(out.contains("| ☐ | TwoFactor | TwoFactorSetup | `/settings/2fa` | browser | oui | TotpService | QR |")); | |
| 325 | + } | |
| 326 | + | |
| 327 | + #[test] | |
| 328 | + fn render_coche_les_faits() { | |
| 329 | + let mut r = row(); | |
| 330 | + r.done = true; | |
| 331 | + assert!(render(&[r]).contains("| ☑ |")); | |
| 332 | + } | |
| 333 | +} | |
| 334 | +``` | |
| 335 | + | |
| 336 | +- [ ] **Step 3: Lancer — échoue** | |
| 337 | + | |
| 338 | +Run: `cargo test -p rustwarden-core --bin screen-select -- todo::tests::render` | |
| 339 | +Expected: FAIL (render renvoie une chaîne vide). | |
| 340 | + | |
| 341 | +- [ ] **Step 4: Implémenter `render`** | |
| 342 | + | |
| 343 | +```rust | |
| 344 | +pub fn render(rows: &[TodoRow]) -> String { | |
| 345 | + let mut out = String::new(); | |
| 346 | + out.push_str(HEADER); | |
| 347 | + out.push('\n'); | |
| 348 | + out.push_str(SEP); | |
| 349 | + out.push('\n'); | |
| 350 | + for r in rows { | |
| 351 | + out.push_str(&format!( | |
| 352 | + "| {} | {} | {} | `{}` | {} | {} | {} | {} |\n", | |
| 353 | + if r.done { "☑" } else { "☐" }, | |
| 354 | + r.functionality, | |
| 355 | + r.screen, | |
| 356 | + r.route, | |
| 357 | + r.channel, | |
| 358 | + if r.protected { "oui" } else { "non" }, | |
| 359 | + r.methods, | |
| 360 | + r.io, | |
| 361 | + )); | |
| 362 | + } | |
| 363 | + out | |
| 364 | +} | |
| 365 | +``` | |
| 366 | + | |
| 367 | +- [ ] **Step 5: Lancer — passe** | |
| 368 | + | |
| 369 | +Run: `cargo test -p rustwarden-core --bin screen-select -- todo::tests::render` | |
| 370 | +Expected: PASS (2 tests). | |
| 371 | + | |
| 372 | +- [ ] **Step 6: Commit (checkpoint)** | |
| 373 | + | |
| 374 | +```bash | |
| 375 | +git add crates/rustwarden-core/src/bin/screen-select/ | |
| 376 | +git commit -m "feat(screen-select): rendu Markdown du tableau de todo" | |
| 377 | +``` | |
| 378 | + | |
| 379 | +--- | |
| 380 | + | |
| 381 | +## Task 3: Lecture du tableau (`todo::parse`) | |
| 382 | + | |
| 383 | +**Files:** | |
| 384 | +- Modify: `crates/rustwarden-core/src/bin/screen-select/todo.rs` | |
| 385 | + | |
| 386 | +- [ ] **Step 1: Test round-trip (rouge)** | |
| 387 | + | |
| 388 | +Ajouter dans `todo.rs` (module `tests`) : | |
| 389 | + | |
| 390 | +```rust | |
| 391 | + #[test] | |
| 392 | + fn parse_round_trip() { | |
| 393 | + let mut a = row(); | |
| 394 | + a.done = true; | |
| 395 | + let b = TodoRow { screen: "SessionsList".into(), route: "/settings/sessions".into(), ..row() }; | |
| 396 | + let rendered = render(&[a.clone(), b.clone()]); | |
| 397 | + let parsed = parse(&rendered); | |
| 398 | + assert_eq!(parsed, vec![a, b]); | |
| 399 | + } | |
| 400 | + | |
| 401 | + #[test] | |
| 402 | + fn parse_ignore_hors_tableau() { | |
| 403 | + let md = format!("# Titre\n\ntexte\n\n{}", render(&[row()])); | |
| 404 | + assert_eq!(parse(&md).len(), 1); | |
| 405 | + } | |
| 406 | +``` | |
| 407 | + | |
| 408 | +- [ ] **Step 2: Ajouter la signature `parse` vide** | |
| 409 | + | |
| 410 | +```rust | |
| 411 | +/// Extrait les lignes de données du premier bloc-tableau rencontré. | |
| 412 | +pub fn parse(content: &str) -> Vec<TodoRow> { | |
| 413 | + Vec::new() // remplacé au Step 4 | |
| 414 | +} | |
| 415 | +``` | |
| 416 | + | |
| 417 | +- [ ] **Step 3: Lancer — échoue** | |
| 418 | + | |
| 419 | +Run: `cargo test -p rustwarden-core --bin screen-select -- todo::tests::parse` | |
| 420 | +Expected: FAIL. | |
| 421 | + | |
| 422 | +- [ ] **Step 4: Implémenter `parse`** | |
| 423 | + | |
| 424 | +```rust | |
| 425 | +pub fn parse(content: &str) -> Vec<TodoRow> { | |
| 426 | + let mut rows = Vec::new(); | |
| 427 | + for line in content.lines() { | |
| 428 | + let line = line.trim(); | |
| 429 | + if !line.starts_with('|') { | |
| 430 | + continue; | |
| 431 | + } | |
| 432 | + // ignorer en-tête et séparateur | |
| 433 | + if line.starts_with("| Statut") || line.starts_with("|---") { | |
| 434 | + continue; | |
| 435 | + } | |
| 436 | + let cells: Vec<&str> = line | |
| 437 | + .trim_matches('|') | |
| 438 | + .split('|') | |
| 439 | + .map(|c| c.trim()) | |
| 440 | + .collect(); | |
| 441 | + if cells.len() != 8 { | |
| 442 | + continue; | |
| 443 | + } | |
| 444 | + rows.push(TodoRow { | |
| 445 | + done: cells[0].contains('☑'), | |
| 446 | + functionality: cells[1].to_string(), | |
| 447 | + screen: cells[2].to_string(), | |
| 448 | + route: cells[3].trim_matches('`').to_string(), | |
| 449 | + channel: cells[4].to_string(), | |
| 450 | + protected: cells[5] == "oui", | |
| 451 | + methods: cells[6].to_string(), | |
| 452 | + io: cells[7].to_string(), | |
| 453 | + }); | |
| 454 | + } | |
| 455 | + rows | |
| 456 | +} | |
| 457 | +``` | |
| 458 | + | |
| 459 | +- [ ] **Step 5: Lancer — passe** | |
| 460 | + | |
| 461 | +Run: `cargo test -p rustwarden-core --bin screen-select -- todo::tests::parse` | |
| 462 | +Expected: PASS. | |
| 463 | + | |
| 464 | +- [ ] **Step 6: Commit (checkpoint)** | |
| 465 | + | |
| 466 | +```bash | |
| 467 | +git add crates/rustwarden-core/src/bin/screen-select/todo.rs | |
| 468 | +git commit -m "feat(screen-select): lecture du tableau Markdown" | |
| 469 | +``` | |
| 470 | + | |
| 471 | +--- | |
| 472 | + | |
| 473 | +## Task 4: Fusion & remplacement du tableau (`merge`, `replace_table`) | |
| 474 | + | |
| 475 | +**Files:** | |
| 476 | +- Modify: `crates/rustwarden-core/src/bin/screen-select/todo.rs` | |
| 477 | + | |
| 478 | +- [ ] **Step 1: Tests (rouge)** | |
| 479 | + | |
| 480 | +Ajouter dans le module `tests` : | |
| 481 | + | |
| 482 | +```rust | |
| 483 | + #[test] | |
| 484 | + fn merge_preserve_statut_et_route() { | |
| 485 | + let mut existing = row(); | |
| 486 | + existing.done = true; | |
| 487 | + existing.route = "/custom/2fa".into(); // route éditée par le dev | |
| 488 | + // "desired" = catalogue (route suggérée par défaut), même clé (func+screen) | |
| 489 | + let desired = row(); | |
| 490 | + let merged = merge(&[existing.clone()], &[desired.clone()]); | |
| 491 | + assert_eq!(merged.len(), 1); | |
| 492 | + assert!(merged[0].done, "statut conservé"); | |
| 493 | + assert_eq!(merged[0].route, "/custom/2fa", "route éditée conservée"); | |
| 494 | + } | |
| 495 | + | |
| 496 | + #[test] | |
| 497 | + fn merge_ajoute_les_nouveaux() { | |
| 498 | + let desired_new = TodoRow { functionality: "Teams".into(), screen: "TeamsManagement".into(), ..row() }; | |
| 499 | + let merged = merge(&[], &[row(), desired_new.clone()]); | |
| 500 | + assert_eq!(merged.len(), 2); | |
| 501 | + assert!(merged.iter().any(|r| r.screen == "TeamsManagement" && !r.done)); | |
| 502 | + } | |
| 503 | + | |
| 504 | + #[test] | |
| 505 | + fn replace_table_remplace_le_bloc() { | |
| 506 | + let content = format!("# Titre\n\n## Écrans\n\n{}\n(fin)\n", render(&[row()])); | |
| 507 | + let mut r = row(); | |
| 508 | + r.done = true; | |
| 509 | + let out = replace_table(&content, &[r]); | |
| 510 | + assert!(out.contains("# Titre"), "en-tête préservé"); | |
| 511 | + assert!(out.contains("(fin)"), "suffixe préservé"); | |
| 512 | + assert!(out.contains("| ☑ |"), "tableau mis à jour"); | |
| 513 | + } | |
| 514 | +``` | |
| 515 | + | |
| 516 | +- [ ] **Step 2: Signatures vides** | |
| 517 | + | |
| 518 | +```rust | |
| 519 | +/// Clé d'identité d'une ligne (indépendante de la route, éditable). | |
| 520 | +fn key(r: &TodoRow) -> (&str, &str) { | |
| 521 | + (r.functionality.as_str(), r.screen.as_str()) | |
| 522 | +} | |
| 523 | + | |
| 524 | +/// Produit les lignes pour l'ensemble `desired`, en conservant `done` et | |
| 525 | +/// `route` des lignes `existing` de même (fonctionnalité, écran). | |
| 526 | +pub fn merge(existing: &[TodoRow], desired: &[TodoRow]) -> Vec<TodoRow> { | |
| 527 | + Vec::new() // remplacé au Step 4 | |
| 528 | +} | |
| 529 | + | |
| 530 | +/// Remplace le premier bloc-tableau (`|`…) de `content` par le rendu de `rows`. | |
| 531 | +pub fn replace_table(content: &str, rows: &[TodoRow]) -> String { | |
| 532 | + String::new() // remplacé au Step 4 | |
| 533 | +} | |
| 534 | +``` | |
| 535 | + | |
| 536 | +- [ ] **Step 3: Lancer — échoue** | |
| 537 | + | |
| 538 | +Run: `cargo test -p rustwarden-core --bin screen-select -- todo::tests::merge todo::tests::replace_table` | |
| 539 | +Expected: FAIL. | |
| 540 | + | |
| 541 | +- [ ] **Step 4: Implémenter** | |
| 542 | + | |
| 543 | +```rust | |
| 544 | +pub fn merge(existing: &[TodoRow], desired: &[TodoRow]) -> Vec<TodoRow> { | |
| 545 | + desired | |
| 546 | + .iter() | |
| 547 | + .map(|d| match existing.iter().find(|e| key(e) == key(d)) { | |
| 548 | + Some(prev) => TodoRow { | |
| 549 | + done: prev.done, | |
| 550 | + route: prev.route.clone(), | |
| 551 | + ..d.clone() | |
| 552 | + }, | |
| 553 | + None => d.clone(), | |
| 554 | + }) | |
| 555 | + .collect() | |
| 556 | +} | |
| 557 | + | |
| 558 | +pub fn replace_table(content: &str, rows: &[TodoRow]) -> String { | |
| 559 | + let lines: Vec<&str> = content.lines().collect(); | |
| 560 | + let start = lines.iter().position(|l| l.trim_start().starts_with('|')); | |
| 561 | + let Some(start) = start else { | |
| 562 | + // pas de tableau existant : on ajoute à la fin | |
| 563 | + let mut out = content.to_string(); | |
| 564 | + if !out.ends_with('\n') { | |
| 565 | + out.push('\n'); | |
| 566 | + } | |
| 567 | + out.push_str(&render(rows)); | |
| 568 | + return out; | |
| 569 | + }; | |
| 570 | + let mut end = start; | |
| 571 | + while end < lines.len() && lines[end].trim_start().starts_with('|') { | |
| 572 | + end += 1; | |
| 573 | + } | |
| 574 | + let mut out = String::new(); | |
| 575 | + for l in &lines[..start] { | |
| 576 | + out.push_str(l); | |
| 577 | + out.push('\n'); | |
| 578 | + } | |
| 579 | + out.push_str(&render(rows)); | |
| 580 | + for l in &lines[end..] { | |
| 581 | + out.push_str(l); | |
| 582 | + out.push('\n'); | |
| 583 | + } | |
| 584 | + out | |
| 585 | +} | |
| 586 | +``` | |
| 587 | + | |
| 588 | +- [ ] **Step 5: Lancer — passe** | |
| 589 | + | |
| 590 | +Run: `cargo test -p rustwarden-core --bin screen-select -- todo::tests` | |
| 591 | +Expected: PASS (tous les tests todo). | |
| 592 | + | |
| 593 | +- [ ] **Step 6: Commit (checkpoint)** | |
| 594 | + | |
| 595 | +```bash | |
| 596 | +git add crates/rustwarden-core/src/bin/screen-select/todo.rs | |
| 597 | +git commit -m "feat(screen-select): fusion + remplacement du bloc-tableau" | |
| 598 | +``` | |
| 599 | + | |
| 600 | +--- | |
| 601 | + | |
| 602 | +## Task 5: Génération du plan (`generate`) | |
| 603 | + | |
| 604 | +**Files:** | |
| 605 | +- Create: `crates/rustwarden-core/src/bin/screen-select/generate.rs` | |
| 606 | +- Modify: `main.rs` (`mod generate;`) | |
| 607 | + | |
| 608 | +- [ ] **Step 1: Déclarer le module** | |
| 609 | + | |
| 610 | +Dans `main.rs`, ajouter `mod generate;`. | |
| 611 | + | |
| 612 | +- [ ] **Step 2: Tests (rouge)** | |
| 613 | + | |
| 614 | +Créer `generate.rs` : | |
| 615 | + | |
| 616 | +```rust | |
| 617 | +//! Construction des lignes de todo et rendu du plan Markdown complet. | |
| 618 | + | |
| 619 | +use crate::catalogue::Functionality; | |
| 620 | +use crate::todo::{self, TodoRow}; | |
| 621 | + | |
| 622 | +/// Entrées de génération du plan. | |
| 623 | +pub struct PlanInputs<'a> { | |
| 624 | + pub project: &'a str, | |
| 625 | + pub selected: Vec<&'static Functionality>, | |
| 626 | + pub design_md: Option<String>, | |
| 627 | + pub layout: Option<String>, | |
| 628 | +} | |
| 629 | + | |
| 630 | +/// Aplati les fonctionnalités sélectionnées en lignes de todo (non faites). | |
| 631 | +pub fn rows_for(selected: &[&'static Functionality]) -> Vec<TodoRow> { | |
| 632 | + Vec::new() // remplacé au Step 4 | |
| 633 | +} | |
| 634 | + | |
| 635 | +/// Rend le document complet : titre, charte, structure, tableau. | |
| 636 | +pub fn render_plan(inputs: &PlanInputs, rows: &[TodoRow]) -> String { | |
| 637 | + String::new() // remplacé au Step 4 | |
| 638 | +} | |
| 639 | + | |
| 640 | +#[cfg(test)] | |
| 641 | +mod tests { | |
| 642 | + use super::*; | |
| 643 | + use crate::catalogue; | |
| 644 | + | |
| 645 | + #[test] | |
| 646 | + fn rows_for_aplati_les_ecrans() { | |
| 647 | + let two_factor = catalogue::by_key("two_factor").unwrap(); | |
| 648 | + let rows = rows_for(&[two_factor]); | |
| 649 | + assert_eq!(rows.len(), 3); // challenge + setup + backup | |
| 650 | + assert!(rows.iter().all(|r| !r.done)); | |
| 651 | + assert!(rows.iter().any(|r| r.screen == "TwoFactorSetup")); | |
| 652 | + } | |
| 653 | + | |
| 654 | + #[test] | |
| 655 | + fn render_plan_inclut_charte_et_tableau() { | |
| 656 | + let auth = catalogue::by_key("authentication").unwrap(); | |
| 657 | + let inputs = PlanInputs { | |
| 658 | + project: "demo", | |
| 659 | + selected: vec![auth], | |
| 660 | + design_md: Some("Police: Inter. Couleur: #123456".into()), | |
| 661 | + layout: Some("navbar: gauche; 2 colonnes".into()), | |
| 662 | + }; | |
| 663 | + let rows = rows_for(&inputs.selected); | |
| 664 | + let out = render_plan(&inputs, &rows); | |
| 665 | + assert!(out.contains("# Plan UI — demo")); | |
| 666 | + assert!(out.contains("Police: Inter")); | |
| 667 | + assert!(out.contains("navbar: gauche")); | |
| 668 | + assert!(out.contains("| Statut | Fonctionnalité |")); | |
| 669 | + assert!(out.contains("Login")); | |
| 670 | + } | |
| 671 | +} | |
| 672 | +``` | |
| 673 | + | |
| 674 | +- [ ] **Step 3: Lancer — échoue** | |
| 675 | + | |
| 676 | +Run: `cargo test -p rustwarden-core --bin screen-select -- generate::tests` | |
| 677 | +Expected: FAIL. | |
| 678 | + | |
| 679 | +- [ ] **Step 4: Implémenter** | |
| 680 | + | |
| 681 | +```rust | |
| 682 | +pub fn rows_for(selected: &[&'static Functionality]) -> Vec<TodoRow> { | |
| 683 | + let mut rows = Vec::new(); | |
| 684 | + for f in selected { | |
| 685 | + for s in f.screens { | |
| 686 | + rows.push(TodoRow { | |
| 687 | + done: false, | |
| 688 | + functionality: f.label.to_string(), | |
| 689 | + screen: s.name.to_string(), | |
| 690 | + route: s.suggested_route.to_string(), | |
| 691 | + channel: s.channel.label().to_string(), | |
| 692 | + protected: s.protected, | |
| 693 | + methods: s.core_methods.to_string(), | |
| 694 | + io: s.io.to_string(), | |
| 695 | + }); | |
| 696 | + } | |
| 697 | + } | |
| 698 | + rows | |
| 699 | +} | |
| 700 | + | |
| 701 | +pub fn render_plan(inputs: &PlanInputs, rows: &[TodoRow]) -> String { | |
| 702 | + let mut out = String::new(); | |
| 703 | + out.push_str(&format!("# Plan UI — {}\n\n", inputs.project)); | |
| 704 | + out.push_str("> Généré par `cargo run --bin screen-select`. Recochez le réalisé via `cargo run --bin screen-select -- --check`.\n\n"); | |
| 705 | + if let Some(design) = &inputs.design_md { | |
| 706 | + out.push_str("## Charte graphique (à respecter)\n\n"); | |
| 707 | + out.push_str(design.trim()); | |
| 708 | + out.push_str("\n\n"); | |
| 709 | + } | |
| 710 | + if let Some(layout) = &inputs.layout { | |
| 711 | + out.push_str("## Structure de page\n\n"); | |
| 712 | + out.push_str(layout.trim()); | |
| 713 | + out.push_str("\n\n"); | |
| 714 | + } | |
| 715 | + out.push_str("## Écrans à coder\n\n"); | |
| 716 | + out.push_str(&todo::render(rows)); | |
| 717 | + out | |
| 718 | +} | |
| 719 | +``` | |
| 720 | + | |
| 721 | +- [ ] **Step 5: Lancer — passe** | |
| 722 | + | |
| 723 | +Run: `cargo test -p rustwarden-core --bin screen-select -- generate::tests` | |
| 724 | +Expected: PASS. | |
| 725 | + | |
| 726 | +- [ ] **Step 6: Commit (checkpoint)** | |
| 727 | + | |
| 728 | +```bash | |
| 729 | +git add crates/rustwarden-core/src/bin/screen-select/generate.rs crates/rustwarden-core/src/bin/screen-select/main.rs | |
| 730 | +git commit -m "feat(screen-select): génération du plan Markdown" | |
| 731 | +``` | |
| 732 | + | |
| 733 | +--- | |
| 734 | + | |
| 735 | +## Task 6: Contrôle statique du réalisé (`check`) | |
| 736 | + | |
| 737 | +**Files:** | |
| 738 | +- Create: `crates/rustwarden-core/src/bin/screen-select/check.rs` | |
| 739 | +- Modify: `main.rs` (`mod check;`) | |
| 740 | + | |
| 741 | +- [ ] **Step 1: Déclarer le module** | |
| 742 | + | |
| 743 | +Dans `main.rs`, ajouter `mod check;`. | |
| 744 | + | |
| 745 | +- [ ] **Step 2: Tests (rouge)** | |
| 746 | + | |
| 747 | +Créer `check.rs` : | |
| 748 | + | |
| 749 | +```rust | |
| 750 | +//! Contrôle statique du réalisé : une route du todo est « faite » si un | |
| 751 | +//! `.route("<chemin>")` (chaîne exacte) apparaît dans les sources du projet. | |
| 752 | + | |
| 753 | +use std::path::Path; | |
| 754 | + | |
| 755 | +use crate::todo::TodoRow; | |
| 756 | + | |
| 757 | +/// (pur) Marque `done` selon la présence de `"<route>"` dans `haystack`. | |
| 758 | +pub fn mark(rows: &mut [TodoRow], haystack: &str) { | |
| 759 | + // remplacé au Step 4 | |
| 760 | +} | |
| 761 | + | |
| 762 | +/// (I/O) Concatène le contenu des `.rs` sous `root`, en ignorant `target/` | |
| 763 | +/// et les crates vendored `rustwarden-*`. | |
| 764 | +pub fn collect_sources(root: &Path) -> String { | |
| 765 | + String::new() // remplacé au Step 4 | |
| 766 | +} | |
| 767 | + | |
| 768 | +#[cfg(test)] | |
| 769 | +mod tests { | |
| 770 | + use super::*; | |
| 771 | + | |
| 772 | + fn row(route: &str) -> TodoRow { | |
| 773 | + TodoRow { | |
| 774 | + done: false, functionality: "F".into(), screen: "S".into(), | |
| 775 | + route: route.into(), channel: "browser".into(), protected: true, | |
| 776 | + methods: "".into(), io: "".into(), | |
| 777 | + } | |
| 778 | + } | |
| 779 | + | |
| 780 | + #[test] | |
| 781 | + fn mark_detecte_route_presente() { | |
| 782 | + let mut rows = vec![row("/profile"), row("/settings/sessions")]; | |
| 783 | + let haystack = r#"router.route("/profile", get(profile))"#; | |
| 784 | + mark(&mut rows, haystack); | |
| 785 | + assert!(rows[0].done, "/profile trouvée"); | |
| 786 | + assert!(!rows[1].done, "/settings/sessions absente"); | |
| 787 | + } | |
| 788 | + | |
| 789 | + #[test] | |
| 790 | + fn mark_evite_les_faux_positifs_de_prefixe() { | |
| 791 | + // "/settings" ne doit PAS matcher "/settings/sessions" | |
| 792 | + let mut rows = vec![row("/settings")]; | |
| 793 | + let haystack = r#".route("/settings/sessions", get(h))"#; | |
| 794 | + mark(&mut rows, haystack); | |
| 795 | + assert!(!rows[0].done); | |
| 796 | + } | |
| 797 | + | |
| 798 | + #[test] | |
| 799 | + fn collect_sources_ignore_target_et_vendored() { | |
| 800 | + let base = std::env::temp_dir().join(format!("scrsel_{}", std::process::id())); | |
| 801 | + let _ = std::fs::remove_dir_all(&base); | |
| 802 | + std::fs::create_dir_all(base.join("src")).unwrap(); | |
| 803 | + std::fs::create_dir_all(base.join("crates/rustwarden-core/src")).unwrap(); | |
| 804 | + std::fs::create_dir_all(base.join("target")).unwrap(); | |
| 805 | + std::fs::write(base.join("src/app.rs"), r#".route("/mine", get(h))"#).unwrap(); | |
| 806 | + std::fs::write(base.join("crates/rustwarden-core/src/x.rs"), r#".route("/vendored", get(h))"#).unwrap(); | |
| 807 | + std::fs::write(base.join("target/y.rs"), r#".route("/built", get(h))"#).unwrap(); | |
| 808 | + | |
| 809 | + let hay = collect_sources(&base); | |
| 810 | + assert!(hay.contains("/mine")); | |
| 811 | + assert!(!hay.contains("/vendored")); | |
| 812 | + assert!(!hay.contains("/built")); | |
| 813 | + let _ = std::fs::remove_dir_all(&base); | |
| 814 | + } | |
| 815 | +} | |
| 816 | +``` | |
| 817 | + | |
| 818 | +- [ ] **Step 3: Lancer — échoue** | |
| 819 | + | |
| 820 | +Run: `cargo test -p rustwarden-core --bin screen-select -- check::tests` | |
| 821 | +Expected: FAIL. | |
| 822 | + | |
| 823 | +- [ ] **Step 4: Implémenter** | |
| 824 | + | |
| 825 | +```rust | |
| 826 | +pub fn mark(rows: &mut [TodoRow], haystack: &str) { | |
| 827 | + for r in rows.iter_mut() { | |
| 828 | + // chaîne littérale exacte, guillemet fermant inclus → évite les | |
| 829 | + // faux positifs de préfixe (/settings vs /settings/sessions). | |
| 830 | + let needle = format!("\"{}\"", r.route); | |
| 831 | + r.done = haystack.contains(&needle); | |
| 832 | + } | |
| 833 | +} | |
| 834 | + | |
| 835 | +pub fn collect_sources(root: &Path) -> String { | |
| 836 | + let mut out = String::new(); | |
| 837 | + walk(root, &mut out); | |
| 838 | + out | |
| 839 | +} | |
| 840 | + | |
| 841 | +fn walk(dir: &Path, out: &mut String) { | |
| 842 | + let Ok(entries) = std::fs::read_dir(dir) else { | |
| 843 | + return; | |
| 844 | + }; | |
| 845 | + for entry in entries.flatten() { | |
| 846 | + let path = entry.path(); | |
| 847 | + let name = entry.file_name(); | |
| 848 | + let name = name.to_string_lossy(); | |
| 849 | + if path.is_dir() { | |
| 850 | + if name == "target" || name.starts_with("rustwarden-") { | |
| 851 | + continue; | |
| 852 | + } | |
| 853 | + walk(&path, out); | |
| 854 | + } else if path.extension().is_some_and(|e| e == "rs") { | |
| 855 | + if let Ok(s) = std::fs::read_to_string(&path) { | |
| 856 | + out.push_str(&s); | |
| 857 | + out.push('\n'); | |
| 858 | + } | |
| 859 | + } | |
| 860 | + } | |
| 861 | +} | |
| 862 | +``` | |
| 863 | + | |
| 864 | +- [ ] **Step 5: Lancer — passe** | |
| 865 | + | |
| 866 | +Run: `cargo test -p rustwarden-core --bin screen-select -- check::tests` | |
| 867 | +Expected: PASS (3 tests). | |
| 868 | + | |
| 869 | +- [ ] **Step 6: Commit (checkpoint)** | |
| 870 | + | |
| 871 | +```bash | |
| 872 | +git add crates/rustwarden-core/src/bin/screen-select/check.rs crates/rustwarden-core/src/bin/screen-select/main.rs | |
| 873 | +git commit -m "feat(screen-select): contrôle statique du réalisé" | |
| 874 | +``` | |
| 875 | + | |
| 876 | +--- | |
| 877 | + | |
| 878 | +## Task 7: Câblage du binaire (wizard stdin + `--check`) | |
| 879 | + | |
| 880 | +**Files:** | |
| 881 | +- Modify: `crates/rustwarden-core/src/bin/screen-select/main.rs` | |
| 882 | + | |
| 883 | +- [ ] **Step 1: Écrire le main complet** | |
| 884 | + | |
| 885 | +Remplacer intégralement `main.rs` par : | |
| 886 | + | |
| 887 | +```rust | |
| 888 | +//! `screen-select` : génère/complète le plan des écrans à coder pour une UI | |
| 889 | +//! custom consommant rustwarden-core, et recoche le réalisé (`--check`). | |
| 890 | + | |
| 891 | +mod catalogue; | |
| 892 | +mod check; | |
| 893 | +mod generate; | |
| 894 | +mod todo; | |
| 895 | + | |
| 896 | +use std::io::{self, Write}; | |
| 897 | +use std::path::Path; | |
| 898 | + | |
| 899 | +const PLAN_PATH: &str = "docs/plans/ui-screens.md"; | |
| 900 | + | |
| 901 | +fn main() -> io::Result<()> { | |
| 902 | + if std::env::args().any(|a| a == "--check") { | |
| 903 | + run_check() | |
| 904 | + } else { | |
| 905 | + run_wizard() | |
| 906 | + } | |
| 907 | +} | |
| 908 | + | |
| 909 | +/// Lit une ligne au clavier (chaîne vide si EOF). | |
| 910 | +fn prompt(msg: &str) -> io::Result<String> { | |
| 911 | + print!("{msg}"); | |
| 912 | + io::stdout().flush()?; | |
| 913 | + let mut line = String::new(); | |
| 914 | + io::stdin().read_line(&mut line)?; | |
| 915 | + Ok(line.trim().to_string()) | |
| 916 | +} | |
| 917 | + | |
| 918 | +fn run_wizard() -> io::Result<()> { | |
| 919 | + println!("== screen-select : écrans à coder ==\n"); | |
| 920 | + | |
| 921 | + // 1. Sélection des fonctionnalités (les "always" sont incluses d'office). | |
| 922 | + let choosable: Vec<&catalogue::Functionality> = | |
| 923 | + catalogue::all().iter().filter(|f| !f.always).collect(); | |
| 924 | + println!("Fonctionnalités activées ? (numéros séparés par des virgules)\n"); | |
| 925 | + for (i, f) in choosable.iter().enumerate() { | |
| 926 | + println!(" {:>2}. {}", i + 1, f.label); | |
| 927 | + } | |
| 928 | + let raw = prompt("\nSélection : ")?; | |
| 929 | + let picked: Vec<usize> = raw | |
| 930 | + .split(',') | |
| 931 | + .filter_map(|s| s.trim().parse::<usize>().ok()) | |
| 932 | + .filter(|n| *n >= 1 && *n <= choosable.len()) | |
| 933 | + .map(|n| n - 1) | |
| 934 | + .collect(); | |
| 935 | + | |
| 936 | + let mut selected: Vec<&catalogue::Functionality> = | |
| 937 | + catalogue::all().iter().filter(|f| f.always).collect(); | |
| 938 | + for i in picked { | |
| 939 | + selected.push(choosable[i]); | |
| 940 | + } | |
| 941 | + | |
| 942 | + // 2. Entrées optionnelles. | |
| 943 | + let design_path = prompt("Chemin d'un design.md (charte graphique, vide si aucun) : ")?; | |
| 944 | + let design_md = if design_path.is_empty() { | |
| 945 | + None | |
| 946 | + } else { | |
| 947 | + Some(std::fs::read_to_string(&design_path)?) | |
| 948 | + }; | |
| 949 | + let layout = prompt("Structure de page (ex. 'navbar: gauche; 2 colonnes', vide si aucune) : ")?; | |
| 950 | + let layout = if layout.is_empty() { None } else { Some(layout) }; | |
| 951 | + | |
| 952 | + let project = std::env::current_dir()? | |
| 953 | + .file_name() | |
| 954 | + .map(|n| n.to_string_lossy().into_owned()) | |
| 955 | + .unwrap_or_else(|| "projet".into()); | |
| 956 | + | |
| 957 | + let inputs = generate::PlanInputs { project: &project, selected, design_md, layout }; | |
| 958 | + let desired = generate::rows_for(&inputs.selected); | |
| 959 | + | |
| 960 | + // 3. Fusion si un plan existe déjà (préserve statuts + routes éditées). | |
| 961 | + let path = Path::new(PLAN_PATH); | |
| 962 | + let content = if path.exists() { | |
| 963 | + let existing = std::fs::read_to_string(path)?; | |
| 964 | + let prev = todo::parse(&existing); | |
| 965 | + let merged = todo::merge(&prev, &desired); | |
| 966 | + // conserve l'en-tête existant, remplace le tableau | |
| 967 | + todo::replace_table(&existing, &merged) | |
| 968 | + } else { | |
| 969 | + generate::render_plan(&inputs, &desired) | |
| 970 | + }; | |
| 971 | + | |
| 972 | + if let Some(parent) = path.parent() { | |
| 973 | + std::fs::create_dir_all(parent)?; | |
| 974 | + } | |
| 975 | + std::fs::write(path, content)?; | |
| 976 | + println!("\n✓ Plan écrit dans {PLAN_PATH} ({} écrans).", desired.len()); | |
| 977 | + println!(" Contrôlez le réalisé : cargo run --bin screen-select -- --check"); | |
| 978 | + Ok(()) | |
| 979 | +} | |
| 980 | + | |
| 981 | +fn run_check() -> io::Result<()> { | |
| 982 | + let path = Path::new(PLAN_PATH); | |
| 983 | + if !path.exists() { | |
| 984 | + eprintln!("Aucun plan à {PLAN_PATH} — lancez d'abord `screen-select` sans --check."); | |
| 985 | + std::process::exit(1); | |
| 986 | + } | |
| 987 | + let content = std::fs::read_to_string(path)?; | |
| 988 | + let mut rows = todo::parse(&content); | |
| 989 | + let haystack = check::collect_sources(&std::env::current_dir()?); | |
| 990 | + check::mark(&mut rows, &haystack); | |
| 991 | + let updated = todo::replace_table(&content, &rows); | |
| 992 | + std::fs::write(path, updated)?; | |
| 993 | + | |
| 994 | + let done = rows.iter().filter(|r| r.done).count(); | |
| 995 | + let missing: Vec<&todo::TodoRow> = rows.iter().filter(|r| !r.done).collect(); | |
| 996 | + println!("Couverture : {}/{} écrans réalisés.", done, rows.len()); | |
| 997 | + if missing.is_empty() { | |
| 998 | + println!("✓ Tous les écrans des fonctionnalités activées sont câblés."); | |
| 999 | + } else { | |
| 1000 | + println!("Écrans manquants :"); | |
| 1001 | + for r in missing { | |
| 1002 | + println!(" ☐ {} — {} ({})", r.functionality, r.screen, r.route); | |
| 1003 | + } | |
| 1004 | + } | |
| 1005 | + Ok(()) | |
| 1006 | +} | |
| 1007 | +``` | |
| 1008 | + | |
| 1009 | +- [ ] **Step 2: Compiler + clippy** | |
| 1010 | + | |
| 1011 | +Run: `cargo clippy -p rustwarden-core --bin screen-select -- -D warnings` | |
| 1012 | +Expected: 0 warning. | |
| 1013 | + | |
| 1014 | +- [ ] **Step 3: Smoke test manuel (génération)** | |
| 1015 | + | |
| 1016 | +Run (depuis la racine du dépôt, dans un sous-dossier jetable pour ne pas polluer) : | |
| 1017 | +```bash | |
| 1018 | +cd "$(mktemp -d)" && printf '2\n\n\n' | cargo run -p rustwarden-core --bin screen-select --manifest-path "$OLDPWD/crates/rustwarden-core/Cargo.toml" | |
| 1019 | +``` | |
| 1020 | +Expected : crée `docs/plans/ui-screens.md` avec Authentication + PasswordReset (choix `2`), affiche « Plan écrit ». *(Note : en usage réel, la commande se lance depuis le projet consommateur : `cargo run --bin screen-select`.)* | |
| 1021 | + | |
| 1022 | +- [ ] **Step 4: Commit (checkpoint)** | |
| 1023 | + | |
| 1024 | +```bash | |
| 1025 | +git add crates/rustwarden-core/src/bin/screen-select/main.rs | |
| 1026 | +git commit -m "feat(screen-select): wizard stdin + commande --check" | |
| 1027 | +``` | |
| 1028 | + | |
| 1029 | +--- | |
| 1030 | + | |
| 1031 | +## Task 8: Invite de lancement dans le CLI | |
| 1032 | + | |
| 1033 | +**Files:** | |
| 1034 | +- Modify: `tools/rustwarden-cli/src/start_project.rs` (section « Prochaines etapes » de `print_summary`, ~ligne 849) | |
| 1035 | +- Modify: `tools/rustwarden-cli/src/update_project.rs` (message final) | |
| 1036 | + | |
| 1037 | +- [ ] **Step 1: Ajouter l'invite dans `start_project.rs`** | |
| 1038 | + | |
| 1039 | +Dans `print_summary`, après l'étape « Lancer l'application » (repérer le bloc `println!(" {} Lancer l'application :", style("3.")…)` et les lignes qui suivent), ajouter une nouvelle étape : | |
| 1040 | + | |
| 1041 | +```rust | |
| 1042 | + println!( | |
| 1043 | + " {} Générer la liste des écrans d'UI à coder :", | |
| 1044 | + style("4.").cyan().bold() | |
| 1045 | + ); | |
| 1046 | + println!(" {}", style("cargo run --bin screen-select").green()); | |
| 1047 | + println!( | |
| 1048 | + " puis {} pour recocher le réalisé", | |
| 1049 | + style("cargo run --bin screen-select -- --check").green() | |
| 1050 | + ); | |
| 1051 | +``` | |
| 1052 | + | |
| 1053 | +- [ ] **Step 2: Ajouter l'invite dans `update_project.rs`** | |
| 1054 | + | |
| 1055 | +Repérer le bloc de messages finaux de `update_project.rs` (après la resync, là où sont listées les actions post-update) et ajouter : | |
| 1056 | + | |
| 1057 | +```rust | |
| 1058 | + println!( | |
| 1059 | + " • Nouveaux écrans éventuels : {} (puis {})", | |
| 1060 | + style("cargo run --bin screen-select").green(), | |
| 1061 | + style("-- --check").green() | |
| 1062 | + ); | |
| 1063 | +``` | |
| 1064 | + | |
| 1065 | +- [ ] **Step 3: Compiler le CLI** | |
| 1066 | + | |
| 1067 | +Run: `cargo check -p rustwarden-cli` | |
| 1068 | +Expected: OK, 0 erreur. | |
| 1069 | + | |
| 1070 | +- [ ] **Step 4: Commit (checkpoint)** | |
| 1071 | + | |
| 1072 | +```bash | |
| 1073 | +git add tools/rustwarden-cli/src/start_project.rs tools/rustwarden-cli/src/update_project.rs | |
| 1074 | +git commit -m "feat(cli): invite de lancement screen-select après start/update" | |
| 1075 | +``` | |
| 1076 | + | |
| 1077 | +--- | |
| 1078 | + | |
| 1079 | +## Task 9: Test de complétude du catalogue + vérif finale | |
| 1080 | + | |
| 1081 | +**Files:** | |
| 1082 | +- Modify: `crates/rustwarden-core/src/bin/screen-select/catalogue.rs` | |
| 1083 | + | |
| 1084 | +- [ ] **Step 1: Test de garde-fou (rouge/vert)** | |
| 1085 | + | |
| 1086 | +Ce test fige la liste attendue de fonctionnalités : si un futur dev ajoute un | |
| 1087 | +service UI sans écran au catalogue, ou renomme une clé, le test tombe et force la | |
| 1088 | +mise à jour consciente. Ajouter au module `tests` de `catalogue.rs` : | |
| 1089 | + | |
| 1090 | +```rust | |
| 1091 | + #[test] | |
| 1092 | + fn catalogue_couvre_les_fonctionnalites_attendues() { | |
| 1093 | + let expected = [ | |
| 1094 | + "authentication", "registration", "password_reset", "email_validation", | |
| 1095 | + "two_factor", "account_profile", "password_change", "session_management", | |
| 1096 | + "preferences", "oauth_linking", "teams", "personal_access_tokens", | |
| 1097 | + "admin_users", "admin_roles", "admin_settings", "admin_error_log", | |
| 1098 | + "admin_email_queue", "admin_dashboard", | |
| 1099 | + ]; | |
| 1100 | + let keys: Vec<&str> = all().iter().map(|f| f.key).collect(); | |
| 1101 | + for k in expected { | |
| 1102 | + assert!(keys.contains(&k), "fonctionnalité manquante au catalogue : {k}"); | |
| 1103 | + } | |
| 1104 | + assert_eq!(keys.len(), expected.len(), "nombre de fonctionnalités inattendu"); | |
| 1105 | + } | |
| 1106 | +``` | |
| 1107 | + | |
| 1108 | +- [ ] **Step 2: Lancer toute la suite du binaire** | |
| 1109 | + | |
| 1110 | +Run: `cargo test -p rustwarden-core --bin screen-select` | |
| 1111 | +Expected: PASS (catalogue, todo, generate, check). | |
| 1112 | + | |
| 1113 | +- [ ] **Step 3: Vérification workspace complète** | |
| 1114 | + | |
| 1115 | +Run: `cargo clippy --workspace -- -D warnings && cargo test -p rustwarden-core --bin screen-select` | |
| 1116 | +Expected: 0 warning ; tous les tests du binaire verts. | |
| 1117 | + | |
| 1118 | +- [ ] **Step 4: Vérification e2e (génération → implémentation fictive → check)** | |
| 1119 | + | |
| 1120 | +```bash | |
| 1121 | +tmp="$(mktemp -d)"; cd "$tmp" | |
| 1122 | +# génère un plan avec SessionManagement (n°7 des fonctionnalités choisissables — | |
| 1123 | +# à adapter à l'ordre réellement affiché par le wizard) | |
| 1124 | +printf '7\n\n\n' | cargo run -p rustwarden-core --bin screen-select --manifest-path "$OLDPWD/crates/rustwarden-core/Cargo.toml" | |
| 1125 | +# simule l'implémentation de l'écran à la route par défaut | |
| 1126 | +mkdir -p src && printf 'fn r(){ .route("/settings/sessions", get(h)); }\n' > src/ui.rs | |
| 1127 | +printf 'x\n' | cargo run -p rustwarden-core --bin screen-select --manifest-path "$OLDPWD/crates/rustwarden-core/Cargo.toml" -- --check | |
| 1128 | +``` | |
| 1129 | +Expected: le second appel affiche `SessionsList` comme **réalisé** (☑) et le | |
| 1130 | +recoche dans `docs/plans/ui-screens.md`. | |
| 1131 | + | |
| 1132 | +- [ ] **Step 5: Commit (checkpoint)** | |
| 1133 | + | |
| 1134 | +```bash | |
| 1135 | +git add crates/rustwarden-core/src/bin/screen-select/catalogue.rs | |
| 1136 | +git commit -m "test(screen-select): garde-fou de complétude du catalogue" | |
| 1137 | +``` | |
| 1138 | + | |
| 1139 | +--- | |
| 1140 | + | |
| 1141 | +## Suivi post-implémentation (hors code) | |
| 1142 | + | |
| 1143 | +- [x] Mettre à jour `references/routes-ecrans-sans-ui.md` (skill) pour pointer vers le | |
| 1144 | + catalogue de `screen-select` comme source de vérité de complétude. | |
| 1145 | +- [x] Documenter `screen-select` dans `docs/SERVICES_REFERENCE.md` (nouvelle sous-section §22). | |
| 1146 | +- [x] Vérifier que `update-project` copie bien `src/bin/screen-select/` (il resynchronise | |
| 1147 | + les fichiers présents de la crate — le binaire voyage donc avec la crate). | |
| 1148 | + | |
| 1149 | +--- | |
| 1150 | + | |
| 1151 | +## Journal de réalisation (2026-08-02) | |
| 1152 | + | |
| 1153 | +Plan exécuté intégralement en subagent-driven development : un implémenteur par tâche, | |
| 1154 | +puis double revue (conformité spec, qualité de code) par des agents distincts, avec | |
| 1155 | +boucle de correction jusqu'à approbation. Les 9 tâches sont **terminées et approuvées**. | |
| 1156 | + | |
| 1157 | +**Vérifications finales** : 14/14 tests du binaire verts ; `cargo clippy --workspace -- -D warnings` | |
| 1158 | +sans warning ; e2e wizard→check validé en répertoire isolé (`Couverture : 1/2`, | |
| 1159 | +`SessionsList` ☐→☑) ; aucun commit effectué (versioning manuel, tout est en working tree). | |
| 1160 | + | |
| 1161 | +**Écarts au plan (assumés)** : | |
| 1162 | +- Task 8 : `start_project.rs` avait déjà une étape 4 (« Ouvrir dans le navigateur ») — | |
| 1163 | + insérée en 4, l'existante renumérotée en 5. | |
| 1164 | +- Étapes « Commit (checkpoint) » sautées conformément à la politique git du projet. | |
| 1165 | +- Doc §22 : décompte corrigé à 22 écrans (le catalogue en contient 22, pas 21). | |
| 1166 | + | |
| 1167 | +**Findings mineurs non bloquants (au choix du mainteneur)** : | |
| 1168 | +- Wizard : une sélection dupliquée (`2,2`) produit des lignes en double (dédup absente du plan). | |
| 1169 | +- Wizard : sur re-run avec plan existant, les invites design/layout sont posées mais ignorées. | |
| 1170 | +- `parse` lit toutes les lignes `|` du fichier, pas seulement le premier bloc (sans effet | |
| 1171 | + sur `ui-screens.md` qui n'a qu'un tableau). | |
| 1172 | +- Clé de fusion = label affiché (renommer un label du catalogue casse la continuité du merge). | |
| 1173 | +- Détection `--check` textuelle : routes commentées comptées, routes hors `.rs` invisibles, | |
| 1174 | + ☑ manuels écrasés à chaque `--check` (documenté en §22 de SERVICES_REFERENCE). | |
| 1175 | +- `ui-screens.md` généré non gitignoré (décision projet à prendre). |
A
docs/plans/2026-08-02-test-support-harness.md
+170
-0
@@ -0,0 +1,170 @@
| 1 | +# Spec — Harnais de test à base éphémère (`rustwarden-core` feature `test-support`) | |
| 2 | + | |
| 3 | +> Statut : **design validé (brainstorming 2026-08-02)**, prêt pour un plan. | |
| 4 | +> Socle retenu : **template PostgreSQL + clone par test** (Option 1). | |
| 5 | + | |
| 6 | +## Problème | |
| 7 | + | |
| 8 | +Les tests d'intégration tapent aujourd'hui la **base de développement** (via le | |
| 9 | +`.env` global). Deux douleurs : | |
| 10 | + | |
| 11 | +1. **Résidu** : les comptes/données créés par les tests s'accumulent dans la base | |
| 12 | + de dev. | |
| 13 | +2. **Config figée** : impossible d'évaluer d'autres configurations (autre | |
| 14 | + `auth_failure_mode`, `JWT_SECRET`, timeouts…) sans éditer le `.env`. | |
| 15 | + | |
| 16 | +Le même problème frappe les projets consommateurs (gitrust, BotPlace). | |
| 17 | + | |
| 18 | +## Objectif | |
| 19 | + | |
| 20 | +Fournir un **harnais de test** qui donne à chaque test une **base PostgreSQL | |
| 21 | +éphémère, migrée et seedée à neuf**, isolée de la base de dev, avec une **config | |
| 22 | +de test découplée du `.env`** — et **réutilisable par les projets consommateurs**. | |
| 23 | + | |
| 24 | +## Non-goals (YAGNI) | |
| 25 | + | |
| 26 | +- Pas de **testcontainers** (Option 2 écartée pour ce socle). | |
| 27 | +- Pas de **rollback-par-transaction** (Option 3 écartée). | |
| 28 | +- Suppose un **PostgreSQL accessible** avec un rôle pouvant `CREATE DATABASE` (le | |
| 29 | + `database/` docker-compose existant convient ; idéalement un port/DB de test | |
| 30 | + dédié via `.env.test`). | |
| 31 | +- Ne fournit pas d'UI ni de données métier — juste le seed baseline framework + | |
| 32 | + un point d'extension pour les fixtures du consommateur. | |
| 33 | + | |
| 34 | +## Placement | |
| 35 | + | |
| 36 | +- **Feature Cargo `test-support`** dans `rustwarden-core`, gated | |
| 37 | + `#[cfg(feature = "test-support")]`. Module `rustwarden_core::test_support`. | |
| 38 | +- Consommé en `dev-dependencies` : | |
| 39 | + `rustwarden-core = { path = "…", features = ["test-support"] }`. | |
| 40 | +- Vendored → resynchronisé par `update-project`, réutilisable par les | |
| 41 | + consommateurs. Dépendances additionnelles (ex. `sqlx`/`tokio-postgres` pour la | |
| 42 | + connexion de maintenance) **gated par la feature** pour ne pas alourdir la lib | |
| 43 | + en usage normal. | |
| 44 | + | |
| 45 | +## Configuration de test — `.env.test` | |
| 46 | + | |
| 47 | +- Le harnais charge **`.env.test`** via `dotenvy::from_filename(".env.test")` | |
| 48 | + (jamais `.env`). Les tests **n'appellent PAS `.from_env()`** (qui lit `.env` + | |
| 49 | + init le logging). | |
| 50 | +- `.env.test` porte le **socle** : URL du serveur de test (rôle `CREATE | |
| 51 | + DATABASE`), `JWT_SECRET` de test, `APP_DEBUG=true`, etc. | |
| 52 | +- **Variations ponctuelles** par test via les setters existants | |
| 53 | + (`.auth_failure_mode(...)`, `.login_path(...)`). Pas de `.auth_config()` ajouté. | |
| 54 | +- `DATABASE_URL` de `.env.test` désigne le **serveur** ; le nom de base réel est | |
| 55 | + injecté par le harnais (`.database(clone_conn)`), pas lu du `.env.test`. | |
| 56 | + | |
| 57 | +## Mécanique — template + clone (sans course entre processus) | |
| 58 | + | |
| 59 | +`cargo test` lance plusieurs binaires de test **en parallèle** (processus | |
| 60 | +distincts). Invariants : | |
| 61 | + | |
| 62 | +1. **Nom de template versionné** : `rw_tmpl_<hash>` où `<hash>` est un hash | |
| 63 | + court et stable des **identifiants de migration** (`core_migrations()` + | |
| 64 | + migrations projet). Tous les binaires d'un même `cargo test` calculent le | |
| 65 | + **même** hash → même template. Changer les migrations change le hash → nouveau | |
| 66 | + template bâti automatiquement, **sans jamais dropper un template en cours | |
| 67 | + d'usage**. | |
| 68 | +2. **Prune au démarrage** (au 1er `fresh_db()` du processus, init `OnceLock` | |
| 69 | + async) : supprime — **uniquement parmi les bases sans connexion active** | |
| 70 | + (vérifié via `pg_stat_activity`, pour ne JAMAIS dropper un clone en cours | |
| 71 | + d'usage par un binaire de test concurrent) — les clones résiduels `rw_test_%` | |
| 72 | + et les templates `rw_tmpl_%` **de hash différent** (résidus de runs | |
| 73 | + précédents / de panics). Préfixes disjoints (`rw_test_` ≠ `rw_tmpl_`) : le | |
| 74 | + prune des clones ne touche pas le template courant. | |
| 75 | +3. **Création idempotente du template**, protégée par **`pg_advisory_lock(key)`** | |
| 76 | + (key dérivée du hash) : le premier processus prend le verrou, crée | |
| 77 | + `rw_tmpl_<hash>` si absent (applique migrations + `seed_baseline`), | |
| 78 | + relâche ; les autres attendent puis constatent qu'il existe. **Aucune | |
| 79 | + connexion n'est laissée ouverte** au template (sinon les clones se bloquent). | |
| 80 | +4. **Clone par test** : `CREATE DATABASE rw_test_<uuid> TEMPLATE | |
| 81 | + rw_tmpl_<hash>` (quelques ms → **données initiales à neuf**). Retry court | |
| 82 | + sur l'erreur transitoire « source database is being accessed by other users ». | |
| 83 | +5. **Nettoyage** : prune au run suivant (robuste aux panics) **+** DROP | |
| 84 | + best-effort en fin de test via le guard (voir API). Pas de `Drop` async — le | |
| 85 | + drop best-effort passe par une connexion de maintenance dédiée, l'échec est | |
| 86 | + toléré (le prune rattrape). | |
| 87 | + | |
| 88 | +### Connexion de maintenance | |
| 89 | + | |
| 90 | +Les `CREATE/DROP/TEMPLATE` et `pg_advisory_lock` s'exécutent sur la base | |
| 91 | +d'administration **`postgres`** du même serveur (host/port/rôle dérivés de l'URL | |
| 92 | +`.env.test`). Séparée de la connexion applicative (qui pointe sur le clone). | |
| 93 | + | |
| 94 | +## Seed baseline | |
| 95 | + | |
| 96 | +Rejoué **à la création du template** (donc présent dans chaque clone) : | |
| 97 | + | |
| 98 | +- admin par défaut (`default_admin_service::ensure_default_admin`), | |
| 99 | +- `app_settings` par défaut (`AppSettingsService::initialize_default_settings`), | |
| 100 | +- rôles/permissions de base si applicable. | |
| 101 | + | |
| 102 | +Extension consommateur : variante `fresh_db_seeded(seed_fn)` où `seed_fn: | |
| 103 | +FnOnce(&DatabaseConnection) -> Future` ajoute les fixtures métier **dans le | |
| 104 | +clone** (après le baseline). *(Note : les fixtures via `fresh_db_seeded` | |
| 105 | +s'appliquent au clone, pas au template — donc pas partagées ; pour des fixtures | |
| 106 | +lourdes communes, une évolution future pourrait les intégrer au template.)* | |
| 107 | + | |
| 108 | +## API côté test | |
| 109 | + | |
| 110 | +```rust | |
| 111 | +use rustwarden_core::test_support; | |
| 112 | + | |
| 113 | +#[tokio::test] | |
| 114 | +async fn login_redirige_en_mode_ui() { | |
| 115 | + let db = test_support::fresh_db().await; // clone migré + seedé | |
| 116 | + let app = RustwardenApp::builder() | |
| 117 | + .database(db.conn()) // pas de DATABASE_URL du .env | |
| 118 | + .auth_failure_mode(AuthFailureMode::Redirect) // override ponctuel | |
| 119 | + .without_email() | |
| 120 | + .build().await.unwrap(); | |
| 121 | + // … oneshot(app.into_router(), GET /settings) → 303 … | |
| 122 | +} // db: DROP best-effort ; sinon prune au prochain run | |
| 123 | +``` | |
| 124 | + | |
| 125 | +- `fresh_db() -> TestDb` : garantit `.env.test` chargé, template prêt (une fois), | |
| 126 | + prune (une fois), puis clone. Idempotent et concurrent-safe. | |
| 127 | +- `TestDb::conn() -> DatabaseConnection` (clone applicatif). | |
| 128 | +- `TestDb` porte le nom du clone ; son `Drop` tente un DROP best-effort. | |
| 129 | +- `fresh_db_seeded(seed_fn)` : idem + fixtures métier dans le clone. | |
| 130 | + | |
| 131 | +## Documentation (demandée) | |
| 132 | + | |
| 133 | +- Rustdoc `///` sur toute l'API `test_support` (anglais, comme la lib). | |
| 134 | +- Section « Tests isolés » dans `docs/SERVICES_REFERENCE.md` (propagée aux | |
| 135 | + consommateurs) : mise en place de `.env.test`, `dev-dependencies` avec la | |
| 136 | + feature, exemple, et la note « ne pas utiliser en prod ». | |
| 137 | +- Un `.env.test.example` à la racine du dépôt + généré par le wizard | |
| 138 | + `start-project`. | |
| 139 | + | |
| 140 | +## Décisions verrouillées | |
| 141 | + | |
| 142 | +- Socle **template + clone** ; feature **`test-support`** dans `rustwarden-core` ; | |
| 143 | + config via **`.env.test`** + setters ; nettoyage par **prune au démarrage** ; | |
| 144 | + template unique versionné par **hash de migrations**, création sous **verrou | |
| 145 | + d'avis**. | |
| 146 | + | |
| 147 | +## Décisions ouvertes (pour le plan) | |
| 148 | + | |
| 149 | +1. **Client de maintenance** : `sqlx::PgPool` (déjà transitif) vs | |
| 150 | + `tokio-postgres`. Reco : `sqlx` (cohérent avec SeaORM, déjà présent), gated par | |
| 151 | + la feature. | |
| 152 | +2. **Forme du hash de migrations** : hash des `name()` de `core_migrations()` (le | |
| 153 | + consommateur ajoute ses migrations → le hash doit les inclure aussi). À définir : | |
| 154 | + API pour que le consommateur fournisse sa liste de migrations au harnais. | |
| 155 | +3. **Génération de `.env.test`** par `start-project` : systématique vs opt-in. | |
| 156 | +4. **Emplacement du seed baseline** : réutiliser tel quel `ensure_default_admin` / | |
| 157 | + `initialize_default_settings`, ou un `seed_baseline()` dédié qui les compose. | |
| 158 | + | |
| 159 | +## Stratégie de test (du harnais lui-même) | |
| 160 | + | |
| 161 | +- Test d'intégration `rustwarden-core` (feature `test-support` activée) : | |
| 162 | + - `fresh_db()` deux fois en parallèle → deux bases distinctes, toutes deux | |
| 163 | + migrées+seedées (admin présent) ; | |
| 164 | + - une écriture dans un clone n'est **pas** visible dans l'autre (isolation) ; | |
| 165 | + - un nouveau `fresh_db()` après écriture → données **baseline à neuf** (pas de | |
| 166 | + résidu) ; | |
| 167 | + - le prune supprime un `rw_test_%` résiduel créé à la main. | |
| 168 | +- Test de bout en bout : un test qui build l'app via `.database(db.conn())` + | |
| 169 | + `.auth_failure_mode(Json401)` et vérifie le 401 sur route protégée — prouve la | |
| 170 | + variation de config sans `.env`. |
A
docs/plans/2026-08-03-screen-select-securite-ui.md
+930
-0
@@ -0,0 +1,930 @@
| 1 | +# screen-select — Enrichissement sécurité du plan généré | |
| 2 | + | |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | +> | |
| 5 | +> **Politique git de ce projet** : versioning **manuel**. Les étapes « Commit » sont des **points de checkpoint** — le développeur committe s'il le souhaite ; **un agent ne committe jamais sans accord explicite**. Ne pas `git add/commit/push` automatiquement. | |
| 6 | + | |
| 7 | +**Goal:** Enrichir le binaire `screen-select` pour que le plan Markdown généré porte, dès le départ, le **contrat de sécurité de chaque écran** (niveau d'auth, pièges spécifiques) plus deux sections transversales fixes (garde-fous globaux + conventions de rendu sûr), de sorte que les findings récurrents d'un audit d'UI d'administration soient pré-imprimés au lieu d'être découverts après coup. | |
| 8 | + | |
| 9 | +**Architecture:** On ne touche NI au parseur/tableau (`todo.rs`) NI au contrôle statique (`check.rs`). Dans `catalogue.rs`, `protected: bool` devient `auth_level: AuthLevel` (la colonne « Protégé » du tableau en dérive à l'identique via `AuthLevel::is_protected()`), et chaque `Screen` gagne `security_notes: &'static [&'static str]`. Deux nouveaux modules bin-locaux : `security.rs` (contenu de sécurité : bloc statique + contrat par écran dérivé du catalogue) et `section.rs` (remplacement **idempotent** de sections délimitées par marqueurs HTML, pour que le re-run wizard régénère proprement). Le tableau reste la source de vérité de `--check` ; les sections sécurité vivent hors tableau et ne sont jamais touchées par `--check`. | |
| 10 | + | |
| 11 | +**Tech Stack:** Rust 2024, `std` uniquement (aucune nouvelle crate), `cargo`. | |
| 12 | + | |
| 13 | +**Réf :** rapport d'audit UI (XSS token en contexte JS, open redirect `?redirect=`, `RawQuery` pass-through, `.env` dangereux, headers manquants, énumération username) + « Positive Findings » de l'audit transformés en invariants imprimés (CSRF POST browser, IDOR/ownership, `ensure_admin()`, anti-énumération, `textContent`). | |
| 14 | + | |
| 15 | +--- | |
| 16 | + | |
| 17 | +## Structure des fichiers | |
| 18 | + | |
| 19 | +| Fichier | Responsabilité | Action | | |
| 20 | +|---|---|---| | |
| 21 | +| `crates/rustwarden-core/src/bin/screen-select/catalogue.rs` | `AuthLevel` + `security_notes` sur `Screen` ; données des 18 fonctionnalités enrichies ; tests garde-fous | Modify | | |
| 22 | +| `.../screen-select/security.rs` | bloc statique (transversal + conventions) + contrat de sécurité par écran (dérivé du catalogue) | Create | | |
| 23 | +| `.../screen-select/section.rs` | `block()` / `upsert()` : sections idempotentes à marqueurs HTML | Create | | |
| 24 | +| `.../screen-select/generate.rs` | `rows_for` (dérive `protected` de `auth_level`) + `render_plan` (tisse les sections) | Modify | | |
| 25 | +| `.../screen-select/main.rs` | `mod security; mod section;` + re-run : `upsert` des sections sécurité | Modify | | |
| 26 | +| `.../screen-select/todo.rs` | **inchangé** (le tableau garde sa colonne « Protégé » oui/non) | — | | |
| 27 | +| `.../screen-select/check.rs` | **inchangé** (`--check` ne touche que le tableau) | — | | |
| 28 | +| `docs/SERVICES_REFERENCE.md` | §22 : documenter les nouvelles sections | Modify | | |
| 29 | + | |
| 30 | +**Invariant clé de non-régression :** `AuthLevel::is_protected()` reproduit exactement les anciens booléens `protected` (`Public ⇒ false`, tout le reste ⇒ `true`). La colonne « Protégé » du tableau, le round-trip `parse`/`render`/`merge` et `--check` restent donc identiques bit à bit. | |
| 31 | + | |
| 32 | +--- | |
| 33 | + | |
| 34 | +## Task 1: `AuthLevel` + `security_notes` dans le catalogue | |
| 35 | + | |
| 36 | +**Files:** | |
| 37 | +- Modify: `crates/rustwarden-core/src/bin/screen-select/catalogue.rs` | |
| 38 | + | |
| 39 | +- [ ] **Step 1: Écrire les tests garde-fous (rouge)** | |
| 40 | + | |
| 41 | +Ajouter au module `tests` de `catalogue.rs` (après `catalogue_couvre_les_fonctionnalites_attendues`) : | |
| 42 | + | |
| 43 | +```rust | |
| 44 | + #[test] | |
| 45 | + fn auth_level_preserve_la_protection_existante() { | |
| 46 | + // Public ⇒ non protégé ; tout le reste ⇒ protégé (colonne du tableau inchangée). | |
| 47 | + assert!(!AuthLevel::Public.is_protected()); | |
| 48 | + assert!(AuthLevel::Authenticated.is_protected()); | |
| 49 | + assert!(AuthLevel::ResourceOwner.is_protected()); | |
| 50 | + assert!(AuthLevel::Admin.is_protected()); | |
| 51 | + // Login reste public ; tous les écrans admin restent en niveau Admin. | |
| 52 | + assert_eq!( | |
| 53 | + by_key("authentication").unwrap().screens[0].auth_level, | |
| 54 | + AuthLevel::Public | |
| 55 | + ); | |
| 56 | + assert!( | |
| 57 | + all() | |
| 58 | + .iter() | |
| 59 | + .filter(|f| f.key.starts_with("admin_")) | |
| 60 | + .flat_map(|f| f.screens) | |
| 61 | + .all(|s| s.auth_level == AuthLevel::Admin), | |
| 62 | + "tous les écrans admin doivent être AuthLevel::Admin" | |
| 63 | + ); | |
| 64 | + } | |
| 65 | + | |
| 66 | + #[test] | |
| 67 | + fn contrats_de_securite_couvrent_les_pieges_de_l_audit() { | |
| 68 | + let note = |key: &str, screen: &str, needle: &str| { | |
| 69 | + by_key(key) | |
| 70 | + .unwrap() | |
| 71 | + .screens | |
| 72 | + .iter() | |
| 73 | + .find(|s| s.name == screen) | |
| 74 | + .unwrap_or_else(|| panic!("écran {screen} introuvable")) | |
| 75 | + .security_notes | |
| 76 | + .iter() | |
| 77 | + .any(|n| n.to_lowercase().contains(needle)) | |
| 78 | + }; | |
| 79 | + // #1 XSS — token jamais dans un <script>, passer par data-*. | |
| 80 | + assert!(note("password_reset", "ResetPassword", "data-")); | |
| 81 | + // #2 open redirect ?redirect=. | |
| 82 | + assert!(note("authentication", "Login", "redirect")); | |
| 83 | + // #4 RawQuery pass-through. | |
| 84 | + assert!(note("two_factor", "TwoFactorSetup", "rawquery")); | |
| 85 | + | |
| 86 | + // Invariants par niveau + présence d'au moins une note partout. | |
| 87 | + for f in all() { | |
| 88 | + for s in f.screens { | |
| 89 | + assert!( | |
| 90 | + !s.security_notes.is_empty(), | |
| 91 | + "{} doit porter au moins une note de sécurité", | |
| 92 | + s.name | |
| 93 | + ); | |
| 94 | + match s.auth_level { | |
| 95 | + AuthLevel::Admin => assert!( | |
| 96 | + s.security_notes.iter().any(|n| n.contains("ensure_admin")), | |
| 97 | + "{} (Admin) doit rappeler ensure_admin()", | |
| 98 | + s.name | |
| 99 | + ), | |
| 100 | + AuthLevel::ResourceOwner => assert!( | |
| 101 | + s.security_notes.iter().any(|n| { | |
| 102 | + let l = n.to_lowercase(); | |
| 103 | + l.contains("idor") | |
| 104 | + || l.contains("claims.sub") | |
| 105 | + || l.contains("appartenance") | |
| 106 | + }), | |
| 107 | + "{} (ResourceOwner) doit rappeler le contrôle d'appartenance (IDOR)", | |
| 108 | + s.name | |
| 109 | + ), | |
| 110 | + _ => {} | |
| 111 | + } | |
| 112 | + } | |
| 113 | + } | |
| 114 | + } | |
| 115 | +``` | |
| 116 | + | |
| 117 | +- [ ] **Step 2: Lancer — échec de compilation attendu** | |
| 118 | + | |
| 119 | +Run: `cargo test -p rustwarden-core --bin screen-select -- catalogue::tests` | |
| 120 | +Expected: FAIL (compilation) — `AuthLevel`, `is_protected`, `auth_level`, `security_notes` n'existent pas encore. | |
| 121 | + | |
| 122 | +- [ ] **Step 3: Déclarer `AuthLevel`** | |
| 123 | + | |
| 124 | +Dans `catalogue.rs`, juste après le bloc `impl Channel { … }` (avant `struct Screen`), insérer : | |
| 125 | + | |
| 126 | +```rust | |
| 127 | +/// Niveau d'autorisation attendu pour monter l'écran (contrat de sécurité). | |
| 128 | +#[derive(Clone, Copy, PartialEq, Eq, Debug)] | |
| 129 | +pub enum AuthLevel { | |
| 130 | + /// Accessible sans session (flux publics : login, reset, vérification…). | |
| 131 | + Public, | |
| 132 | + /// Session valide requise (n'importe quel utilisateur authentifié). | |
| 133 | + Authenticated, | |
| 134 | + /// Authentifié + l'écran manipule une ressource nominative : vérifier | |
| 135 | + /// l'appartenance avant lecture/mutation (défense IDOR). | |
| 136 | + ResourceOwner, | |
| 137 | + /// Réservé aux administrateurs : `ensure_admin()` en défense en profondeur. | |
| 138 | + Admin, | |
| 139 | +} | |
| 140 | + | |
| 141 | +impl AuthLevel { | |
| 142 | + /// Libellé pour le contrat de sécurité par écran. | |
| 143 | + pub fn label(self) -> &'static str { | |
| 144 | + match self { | |
| 145 | + AuthLevel::Public => "Public", | |
| 146 | + AuthLevel::Authenticated => "Authentifié", | |
| 147 | + AuthLevel::ResourceOwner => "Propriétaire de ressource", | |
| 148 | + AuthLevel::Admin => "Administrateur", | |
| 149 | + } | |
| 150 | + } | |
| 151 | + | |
| 152 | + /// Reporté tel quel dans la colonne « Protégé » du tableau : | |
| 153 | + /// tout niveau non public exige une session. | |
| 154 | + pub fn is_protected(self) -> bool { | |
| 155 | + !matches!(self, AuthLevel::Public) | |
| 156 | + } | |
| 157 | +} | |
| 158 | +``` | |
| 159 | + | |
| 160 | +- [ ] **Step 4: Étendre le `struct Screen`** | |
| 161 | + | |
| 162 | +Remplacer la définition actuelle : | |
| 163 | + | |
| 164 | +```rust | |
| 165 | +#[derive(Clone, Copy, Debug)] | |
| 166 | +pub struct Screen { | |
| 167 | + pub name: &'static str, | |
| 168 | + pub suggested_route: &'static str, | |
| 169 | + pub channel: Channel, | |
| 170 | + pub protected: bool, | |
| 171 | + pub core_methods: &'static str, | |
| 172 | + pub io: &'static str, | |
| 173 | +} | |
| 174 | +``` | |
| 175 | + | |
| 176 | +par : | |
| 177 | + | |
| 178 | +```rust | |
| 179 | +#[derive(Clone, Copy, Debug)] | |
| 180 | +pub struct Screen { | |
| 181 | + pub name: &'static str, | |
| 182 | + pub suggested_route: &'static str, | |
| 183 | + pub channel: Channel, | |
| 184 | + pub auth_level: AuthLevel, | |
| 185 | + pub core_methods: &'static str, | |
| 186 | + pub io: &'static str, | |
| 187 | + /// Pièges de sécurité spécifiques à cet écran (imprimés dans le plan). | |
| 188 | + pub security_notes: &'static [&'static str], | |
| 189 | +} | |
| 190 | +``` | |
| 191 | + | |
| 192 | +- [ ] **Step 5: Réécrire les données `CATALOGUE`** | |
| 193 | + | |
| 194 | +Remplacer intégralement le `const CATALOGUE: &[Functionality] = &[ … ];` par : | |
| 195 | + | |
| 196 | +```rust | |
| 197 | +const CATALOGUE: &[Functionality] = &[ | |
| 198 | + Functionality { key: "authentication", label: "Authentication", always: true, screens: &[ | |
| 199 | + Screen { name: "Login", suggested_route: "/login", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 200 | + core_methods: "AuthService, JwtService, POST /api/v1/auth/login", io: "form login → cookie JWT / challenge 2FA", | |
| 201 | + security_notes: &[ | |
| 202 | + "redirect post-login : n'autoriser qu'un chemin relatif same-origin (rejeter les URL absolues et `//host`) — bloque l'open redirect `?redirect=`", | |
| 203 | + "flux public : ne pas divulguer si l'identifiant/email existe (message d'échec générique)", | |
| 204 | + ] }, | |
| 205 | + ]}, | |
| 206 | + Functionality { key: "registration", label: "Registration", always: false, screens: &[ | |
| 207 | + Screen { name: "Register", suggested_route: "/register", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 208 | + core_methods: "UserService, AppSettingsService(allow_registration), POST /api/v1/auth/register", io: "form inscription", | |
| 209 | + security_notes: &[ | |
| 210 | + "respecter `allow_registration` (403 si désactivé) ; valider/normaliser l'email côté serveur", | |
| 211 | + "flux public : ne pas révéler qu'un email est déjà enregistré", | |
| 212 | + ] }, | |
| 213 | + ]}, | |
| 214 | + Functionality { key: "password_reset", label: "PasswordReset", always: false, screens: &[ | |
| 215 | + Screen { name: "ForgotPassword", suggested_route: "/forgot-password", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 216 | + core_methods: "PasswordResetService, POST /api/v1/auth/forgot-password", io: "email → lien reset", | |
| 217 | + security_notes: &[ | |
| 218 | + "réponse identique que l'email existe ou non (anti-énumération)", | |
| 219 | + ] }, | |
| 220 | + Screen { name: "ResetPassword", suggested_route: "/reset-password", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 221 | + core_methods: "PasswordResetService, POST /api/v1/auth/reset-password", io: "token + nouveau mdp", | |
| 222 | + security_notes: &[ | |
| 223 | + "le token ne doit JAMAIS être interpolé dans un `<script>` : le passer par un attribut `data-*` et le lire via `dataset` (l'escaping HTML d'Askama n'échappe pas le contexte JS ni le backslash)", | |
| 224 | + "consommer le token en un seul usage ; ne pas confirmer si le token est valide avant soumission", | |
| 225 | + ] }, | |
| 226 | + ]}, | |
| 227 | + Functionality { key: "email_validation", label: "EmailValidation", always: false, screens: &[ | |
| 228 | + Screen { name: "ResendVerification", suggested_route: "/resend-verification", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 229 | + core_methods: "EmailValidationService, POST /api/v1/auth/resend-verification", io: "renvoi email (résultat rendu par le core)", | |
| 230 | + security_notes: &[ | |
| 231 | + "réponse identique quel que soit l'état du compte (anti-énumération) ; rate-limit les renvois", | |
| 232 | + ] }, | |
| 233 | + ]}, | |
| 234 | + Functionality { key: "two_factor", label: "TwoFactor", always: false, screens: &[ | |
| 235 | + Screen { name: "TwoFactorChallenge", suggested_route: "/login/2fa", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 236 | + core_methods: "TotpService, POST /api/v1/auth/2fa/verify", io: "code TOTP ou backup", | |
| 237 | + security_notes: &[ | |
| 238 | + "étape intermédiaire liée à une pré-session : rate-limit strict, ne pas révéler la validité partielle du code", | |
| 239 | + ] }, | |
| 240 | + Screen { name: "TwoFactorSetup", suggested_route: "/settings/2fa", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 241 | + core_methods: "TotpService::generate_setup/confirm/disable", io: "QR + confirmation", | |
| 242 | + security_notes: &[ | |
| 243 | + "toute redirection de sécurité doit parser et whitelister les query params attendus — ne jamais forwarder un `RawQuery` brut", | |
| 244 | + "exiger une confirmation par code avant activation ; le secret TOTP reste chiffré (CryptoService)", | |
| 245 | + ] }, | |
| 246 | + Screen { name: "TwoFactorBackupCodes", suggested_route: "/settings/2fa/backup", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 247 | + core_methods: "TotpService (backup codes)", io: "génération/affichage codes", | |
| 248 | + security_notes: &[ | |
| 249 | + "codes affichés une seule fois, jamais logués ; ne stocker que leur hash", | |
| 250 | + ] }, | |
| 251 | + ]}, | |
| 252 | + Functionality { key: "account_profile", label: "AccountProfile", always: false, screens: &[ | |
| 253 | + Screen { name: "Profile", suggested_route: "/profile", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 254 | + core_methods: "UserService", io: "voir/éditer profil", | |
| 255 | + security_notes: &[ | |
| 256 | + "n'exposer et n'éditer que le profil de `claims.sub`", | |
| 257 | + ] }, | |
| 258 | + ]}, | |
| 259 | + Functionality { key: "password_change", label: "PasswordChange", always: false, screens: &[ | |
| 260 | + Screen { name: "PasswordChange", suggested_route: "/settings/password", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 261 | + core_methods: "PasswordChangeService", io: "changement mdp (confirmation email)", | |
| 262 | + security_notes: &[ | |
| 263 | + "exiger le mot de passe courant ; invalider les autres sessions après changement", | |
| 264 | + ] }, | |
| 265 | + Screen { name: "ConfirmPasswordChange", suggested_route: "/confirm-password-change", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 266 | + core_methods: "PasswordChangeService::find_by_token/confirm_request", io: "confirme via token", | |
| 267 | + security_notes: &[ | |
| 268 | + "valider le token côté core ; token à usage unique et expirant", | |
| 269 | + ] }, | |
| 270 | + ]}, | |
| 271 | + Functionality { key: "session_management", label: "SessionManagement", always: false, screens: &[ | |
| 272 | + Screen { name: "SessionsList", suggested_route: "/settings/sessions", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner, | |
| 273 | + core_methods: "RefreshTokenService::list_active/revoke, JwtBlacklistService", io: "liste sessions + révoquer", | |
| 274 | + security_notes: &[ | |
| 275 | + "IDOR : lister et révoquer uniquement les sessions dont l'`id` appartient à `claims.sub` (vérifier l'appartenance avant révocation)", | |
| 276 | + ] }, | |
| 277 | + ]}, | |
| 278 | + Functionality { key: "preferences", label: "Preferences", always: false, screens: &[ | |
| 279 | + Screen { name: "Preferences", suggested_route: "/settings/preferences", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 280 | + core_methods: "UserPreferencesService", io: "préférences utilisateur", | |
| 281 | + security_notes: &[ | |
| 282 | + "ne modifier que les préférences de `claims.sub`", | |
| 283 | + ] }, | |
| 284 | + ]}, | |
| 285 | + Functionality { key: "oauth_linking", label: "OAuthLinking", always: false, screens: &[ | |
| 286 | + Screen { name: "OAuthAccountLinking", suggested_route: "/settings/oauth", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 287 | + core_methods: "OAuthService, GET /api/v1/auth/oauth/{provider}/redirect", io: "lier/délier comptes (+ boutons login)", | |
| 288 | + security_notes: &[ | |
| 289 | + "vérifier le paramètre `state` anti-CSRF au retour du provider", | |
| 290 | + "données renvoyées par l'API en `textContent`, jamais `innerHTML` ; URLs construites via `encodeURIComponent`", | |
| 291 | + ] }, | |
| 292 | + ]}, | |
| 293 | + Functionality { key: "teams", label: "Teams", always: false, screens: &[ | |
| 294 | + Screen { name: "TeamsManagement", suggested_route: "/teams", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner, | |
| 295 | + core_methods: "TeamService (create/add_member/…), TeamRole", io: "équipes + membres (owner/admin/member)", | |
| 296 | + security_notes: &[ | |
| 297 | + "IDOR/RBAC : vérifier le rôle de l'appelant dans CETTE équipe (`role_of`) avant chaque mutation ; owner non ré-attribuable", | |
| 298 | + "à l'ajout d'un membre : message générique si l'utilisateur est introuvable (anti-énumération)", | |
| 299 | + ] }, | |
| 300 | + ]}, | |
| 301 | + Functionality { key: "personal_access_tokens", label: "PersonalAccessTokens", always: false, screens: &[ | |
| 302 | + Screen { name: "PatManagement", suggested_route: "/settings/tokens", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner, | |
| 303 | + core_methods: "PersonalTokenService::create/list_for_user/revoke", io: "créer/révoquer jetons (plaintext 1 fois)", | |
| 304 | + security_notes: &[ | |
| 305 | + "jeton en clair affiché une seule fois, jamais reconsultable ni logué", | |
| 306 | + "IDOR : révocation et listage filtrés par `claims.sub`", | |
| 307 | + ] }, | |
| 308 | + ]}, | |
| 309 | + Functionality { key: "admin_users", label: "Admin · Users", always: false, screens: &[ | |
| 310 | + Screen { name: "AdminUsers", suggested_route: "/admin/users", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 311 | + core_methods: "UserService, RoleService, PasswordResetService, TotpService", io: "liste + CRUD + reset", | |
| 312 | + security_notes: &[ | |
| 313 | + "`ensure_admin()` dans le handler en plus du middleware (défense en profondeur)", | |
| 314 | + "empêcher l'auto-suppression ; message générique si utilisateur introuvable", | |
| 315 | + ] }, | |
| 316 | + ]}, | |
| 317 | + Functionality { key: "admin_roles", label: "Admin · Roles", always: false, screens: &[ | |
| 318 | + Screen { name: "AdminRoles", suggested_route: "/admin/roles", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 319 | + core_methods: "RoleService, PermissionService", io: "liste + CRUD rôles/permissions", | |
| 320 | + security_notes: &[ | |
| 321 | + "`ensure_admin()` en défense en profondeur ; protéger le dernier admin", | |
| 322 | + "ne pas permettre l'auto-escalade de permissions", | |
| 323 | + ] }, | |
| 324 | + ]}, | |
| 325 | + Functionality { key: "admin_settings", label: "Admin · Settings", always: false, screens: &[ | |
| 326 | + Screen { name: "AdminSettings", suggested_route: "/admin/settings", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 327 | + core_methods: "AppSettingsService, CryptoService", io: "réglages applicatifs", | |
| 328 | + security_notes: &[ | |
| 329 | + "`ensure_admin()` en défense en profondeur ; secrets chiffrés via CryptoService, jamais rendus en clair", | |
| 330 | + ] }, | |
| 331 | + ]}, | |
| 332 | + Functionality { key: "admin_error_log", label: "Admin · ErrorLog", always: false, screens: &[ | |
| 333 | + Screen { name: "AdminErrorLog", suggested_route: "/admin/errors", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 334 | + core_methods: "error_handler, I18nService", io: "journal d'erreurs", | |
| 335 | + security_notes: &[ | |
| 336 | + "`ensure_admin()` en défense en profondeur ; ne pas exposer de stack traces / chemins internes en production", | |
| 337 | + ] }, | |
| 338 | + ]}, | |
| 339 | + Functionality { key: "admin_email_queue", label: "Admin · EmailQueue", always: false, screens: &[ | |
| 340 | + Screen { name: "AdminEmailQueue", suggested_route: "/admin/emails", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 341 | + core_methods: "EmailQueueService", io: "file d'emails", | |
| 342 | + security_notes: &[ | |
| 343 | + "`ensure_admin()` en défense en profondeur ; ne pas afficher de contenu d'email sensible en clair", | |
| 344 | + ] }, | |
| 345 | + ]}, | |
| 346 | + Functionality { key: "admin_dashboard", label: "Admin · Dashboard", always: false, screens: &[ | |
| 347 | + Screen { name: "AdminDashboard", suggested_route: "/admin", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 348 | + core_methods: "cartes DashboardCardDTO", io: "accueil admin", | |
| 349 | + security_notes: &[ | |
| 350 | + "`ensure_admin()` en défense en profondeur ; cartes injectées correctement échappées", | |
| 351 | + ] }, | |
| 352 | + ]}, | |
| 353 | +]; | |
| 354 | +``` | |
| 355 | + | |
| 356 | +- [ ] **Step 6: Lancer — passe** | |
| 357 | + | |
| 358 | +Run: `cargo test -p rustwarden-core --bin screen-select -- catalogue::tests` | |
| 359 | +Expected: PASS (les 4 tests du catalogue, dont les 2 nouveaux garde-fous). | |
| 360 | + | |
| 361 | +- [ ] **Step 7: Commit (checkpoint)** | |
| 362 | + | |
| 363 | +```bash | |
| 364 | +git add crates/rustwarden-core/src/bin/screen-select/catalogue.rs | |
| 365 | +git commit -m "feat(screen-select): AuthLevel + notes de sécurité par écran au catalogue" | |
| 366 | +``` | |
| 367 | + | |
| 368 | +--- | |
| 369 | + | |
| 370 | +## Task 2: Module `section` — sections idempotentes à marqueurs | |
| 371 | + | |
| 372 | +**Files:** | |
| 373 | +- Create: `crates/rustwarden-core/src/bin/screen-select/section.rs` | |
| 374 | +- Modify: `crates/rustwarden-core/src/bin/screen-select/main.rs` (`mod section;`) | |
| 375 | + | |
| 376 | +- [ ] **Step 1: Déclarer le module** | |
| 377 | + | |
| 378 | +Dans `main.rs`, ajouter la ligne `mod section;` au groupe des `mod` (ordre alphabétique, après `mod generate;`). | |
| 379 | + | |
| 380 | +- [ ] **Step 2: Écrire `section.rs` avec tests (rouge)** | |
| 381 | + | |
| 382 | +Créer `section.rs` avec les signatures vides + tests : | |
| 383 | + | |
| 384 | +```rust | |
| 385 | +//! Remplacement idempotent d'une section délimitée par des marqueurs HTML. | |
| 386 | +//! Permet de régénérer les blocs de sécurité lors d'un re-run du wizard sans | |
| 387 | +//! écraser le reste du document (charte, tableau, prose ajoutée par le dev). | |
| 388 | + | |
| 389 | +/// Bloc encadré : `<!-- id -->\n{body}\n<!-- /id -->`. | |
| 390 | +pub fn block(id: &str, body: &str) -> String { | |
| 391 | + String::new() // remplacé au Step 4 | |
| 392 | +} | |
| 393 | + | |
| 394 | +/// Insère (à la fin) ou remplace en place la section `id` par `body`. | |
| 395 | +pub fn upsert(content: &str, id: &str, body: &str) -> String { | |
| 396 | + String::new() // remplacé au Step 4 | |
| 397 | +} | |
| 398 | + | |
| 399 | +#[cfg(test)] | |
| 400 | +mod tests { | |
| 401 | + use super::*; | |
| 402 | + | |
| 403 | + #[test] | |
| 404 | + fn block_encadre_le_corps() { | |
| 405 | + let b = block("demo:x", "corps\n"); | |
| 406 | + assert_eq!(b, "<!-- demo:x -->\ncorps\n<!-- /demo:x -->"); | |
| 407 | + } | |
| 408 | + | |
| 409 | + #[test] | |
| 410 | + fn upsert_ajoute_a_la_fin_si_absent() { | |
| 411 | + let out = upsert("# Titre\n", "demo:x", "corps"); | |
| 412 | + assert!(out.starts_with("# Titre\n")); | |
| 413 | + assert!(out.contains("<!-- demo:x -->\ncorps\n<!-- /demo:x -->")); | |
| 414 | + } | |
| 415 | + | |
| 416 | + #[test] | |
| 417 | + fn upsert_remplace_en_place_et_est_idempotent() { | |
| 418 | + let base = format!("A\n\n{}\n\nB\n", block("demo:x", "v1")); | |
| 419 | + let once = upsert(&base, "demo:x", "v2"); | |
| 420 | + assert!(once.contains("A\n"), "préfixe préservé"); | |
| 421 | + assert!(once.contains("B\n"), "suffixe préservé"); | |
| 422 | + assert!(once.contains("v2")); | |
| 423 | + assert!(!once.contains("v1"), "ancien corps remplacé"); | |
| 424 | + // idempotence : ré-appliquer avec le même corps ne change rien. | |
| 425 | + assert_eq!(upsert(&once, "demo:x", "v2"), once); | |
| 426 | + } | |
| 427 | +} | |
| 428 | +``` | |
| 429 | + | |
| 430 | +- [ ] **Step 3: Lancer — échoue** | |
| 431 | + | |
| 432 | +Run: `cargo test -p rustwarden-core --bin screen-select -- section::tests` | |
| 433 | +Expected: FAIL (les fonctions renvoient une chaîne vide). | |
| 434 | + | |
| 435 | +- [ ] **Step 4: Implémenter** | |
| 436 | + | |
| 437 | +Remplacer les deux fonctions par : | |
| 438 | + | |
| 439 | +```rust | |
| 440 | +pub fn block(id: &str, body: &str) -> String { | |
| 441 | + format!("<!-- {id} -->\n{}\n<!-- /{id} -->", body.trim_end()) | |
| 442 | +} | |
| 443 | + | |
| 444 | +pub fn upsert(content: &str, id: &str, body: &str) -> String { | |
| 445 | + let block = block(id, body); | |
| 446 | + let open = format!("<!-- {id} -->"); | |
| 447 | + let close = format!("<!-- /{id} -->"); | |
| 448 | + if let (Some(start), Some(close_at)) = (content.find(&open), content.find(&close)) { | |
| 449 | + if start < close_at { | |
| 450 | + let end = close_at + close.len(); | |
| 451 | + let mut out = String::with_capacity(content.len() + block.len()); | |
| 452 | + out.push_str(&content[..start]); | |
| 453 | + out.push_str(&block); | |
| 454 | + out.push_str(&content[end..]); | |
| 455 | + return out; | |
| 456 | + } | |
| 457 | + } | |
| 458 | + let mut out = content.to_string(); | |
| 459 | + if !out.ends_with('\n') { | |
| 460 | + out.push('\n'); | |
| 461 | + } | |
| 462 | + out.push('\n'); | |
| 463 | + out.push_str(&block); | |
| 464 | + out.push('\n'); | |
| 465 | + out | |
| 466 | +} | |
| 467 | +``` | |
| 468 | + | |
| 469 | +Note : `close` étant préfixé par `<!-- /`, `content.find(&open)` peut aussi matcher le début de `close` ? Non : `open` = `<!-- id -->`, `close` = `<!-- /id -->` (le `/` après l'espace diffère), donc `find(&open)` ne tombe jamais sur le marqueur de fermeture. | |
| 470 | + | |
| 471 | +- [ ] **Step 5: Lancer — passe** | |
| 472 | + | |
| 473 | +Run: `cargo test -p rustwarden-core --bin screen-select -- section::tests` | |
| 474 | +Expected: PASS (3 tests). | |
| 475 | + | |
| 476 | +- [ ] **Step 6: Commit (checkpoint)** | |
| 477 | + | |
| 478 | +```bash | |
| 479 | +git add crates/rustwarden-core/src/bin/screen-select/section.rs crates/rustwarden-core/src/bin/screen-select/main.rs | |
| 480 | +git commit -m "feat(screen-select): sections Markdown idempotentes à marqueurs" | |
| 481 | +``` | |
| 482 | + | |
| 483 | +--- | |
| 484 | + | |
| 485 | +## Task 3: Module `security` — contenu de sécurité | |
| 486 | + | |
| 487 | +**Files:** | |
| 488 | +- Create: `crates/rustwarden-core/src/bin/screen-select/security.rs` | |
| 489 | +- Modify: `crates/rustwarden-core/src/bin/screen-select/main.rs` (`mod security;`) | |
| 490 | + | |
| 491 | +- [ ] **Step 1: Déclarer le module** | |
| 492 | + | |
| 493 | +Dans `main.rs`, ajouter `mod security;` au groupe des `mod` (après `mod section;`). | |
| 494 | + | |
| 495 | +- [ ] **Step 2: Écrire `security.rs` avec tests (rouge)** | |
| 496 | + | |
| 497 | +Créer `security.rs` : | |
| 498 | + | |
| 499 | +```rust | |
| 500 | +//! Contenu de sécurité injecté dans le plan : garde-fous transversaux (statiques) | |
| 501 | +//! et contrat de sécurité par écran (dérivé du catalogue). | |
| 502 | + | |
| 503 | +use crate::catalogue::{Channel, Functionality}; | |
| 504 | + | |
| 505 | +/// Identifiant de section pour le bloc statique (transversal + conventions). | |
| 506 | +pub const GUIDES_ID: &str = "screen-select:security-guides"; | |
| 507 | +/// Identifiant de section pour le contrat de sécurité par écran. | |
| 508 | +pub const PER_SCREEN_ID: &str = "screen-select:security-per-screen"; | |
| 509 | + | |
| 510 | +/// Corps statique : sécurité transversale (obligatoire, hors écran) + conventions | |
| 511 | +/// de rendu sûr. Indépendant de la sélection. | |
| 512 | +pub fn guides_body() -> String { | |
| 513 | + String::new() // remplacé au Step 4 | |
| 514 | +} | |
| 515 | + | |
| 516 | +/// Corps dérivé du catalogue : un paragraphe par écran des fonctionnalités | |
| 517 | +/// sélectionnées (niveau d'auth, CSRF si browser, pièges spécifiques). | |
| 518 | +pub fn reference_body(selected: &[&'static Functionality]) -> String { | |
| 519 | + let _ = Channel::Browser; // remplacé au Step 4 | |
| 520 | + String::new() | |
| 521 | +} | |
| 522 | + | |
| 523 | +#[cfg(test)] | |
| 524 | +mod tests { | |
| 525 | + use super::*; | |
| 526 | + use crate::catalogue; | |
| 527 | + | |
| 528 | + #[test] | |
| 529 | + fn guides_couvre_les_axes_transversaux() { | |
| 530 | + let g = guides_body(); | |
| 531 | + assert!(g.contains("Sécurité transversale")); | |
| 532 | + assert!(g.contains("Content-Security-Policy")); | |
| 533 | + assert!(g.contains("X-Frame-Options")); | |
| 534 | + assert!(g.contains("ADMIN_PASSWORD")); | |
| 535 | + assert!(g.contains("HMAC")); | |
| 536 | + assert!(g.contains("Conventions de rendu sûr")); | |
| 537 | + assert!(g.contains("textContent")); | |
| 538 | + } | |
| 539 | + | |
| 540 | + #[test] | |
| 541 | + fn reference_rend_le_contrat_par_ecran() { | |
| 542 | + let two_factor = catalogue::by_key("two_factor").unwrap(); | |
| 543 | + let r = reference_body(&[two_factor]); | |
| 544 | + assert!(r.contains("## Contrat de sécurité par écran")); | |
| 545 | + assert!(r.contains("### TwoFactorSetup (`/settings/2fa`) — Authentifié")); | |
| 546 | + // canal browser ⇒ rappel CSRF automatique | |
| 547 | + assert!(r.contains("CSRF")); | |
| 548 | + // piège spécifique repris du catalogue | |
| 549 | + assert!(r.to_lowercase().contains("rawquery")); | |
| 550 | + } | |
| 551 | + | |
| 552 | + #[test] | |
| 553 | + fn reference_marque_le_niveau_admin() { | |
| 554 | + let admin = catalogue::by_key("admin_users").unwrap(); | |
| 555 | + let r = reference_body(&[admin]); | |
| 556 | + assert!(r.contains("— Administrateur")); | |
| 557 | + assert!(r.contains("ensure_admin")); | |
| 558 | + } | |
| 559 | +} | |
| 560 | +``` | |
| 561 | + | |
| 562 | +- [ ] **Step 3: Lancer — échoue** | |
| 563 | + | |
| 564 | +Run: `cargo test -p rustwarden-core --bin screen-select -- security::tests` | |
| 565 | +Expected: FAIL (corps vides). | |
| 566 | + | |
| 567 | +- [ ] **Step 4: Implémenter** | |
| 568 | + | |
| 569 | +Remplacer les deux fonctions par : | |
| 570 | + | |
| 571 | +```rust | |
| 572 | +pub fn guides_body() -> String { | |
| 573 | + let mut s = String::new(); | |
| 574 | + s.push_str("## Sécurité transversale (obligatoire, hors écran)\n\n"); | |
| 575 | + s.push_str("- [ ] Middleware de headers sur TOUTES les réponses : `Content-Security-Policy` (viser sans `unsafe-inline`/`unsafe-eval`), `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`.\n"); | |
| 576 | + s.push_str("- [ ] Validation de configuration au démarrage : refuser `ADMIN_PASSWORD=password` ; avertir si `APP_DEBUG=true`, `COOKIE_SECURE=false` ou `RUST_LOG=debug` en profil production ; secrets uniquement via variables d'environnement.\n"); | |
| 577 | + s.push_str("- [ ] Cookies de session : `HttpOnly`, `Secure` en production, `SameSite`.\n"); | |
| 578 | + s.push_str("- [ ] `cargo audit` régulier ; confirmer que le JWT est signé en **HMAC** (HS256/384/512), pas RSA (neutralise RUSTSEC-2023-0071) ; `accept_invalid_certs` SMTP/IMAP à `false` en production.\n\n"); | |
| 579 | + s.push_str("## Conventions de rendu sûr\n\n"); | |
| 580 | + s.push_str("- **Escaping contextuel** : ne jamais interpoler une valeur contrôlée par l'utilisateur dans un contexte JavaScript (`<script>`). L'échappement HTML d'Askama n'échappe PAS le contexte JS (le backslash notamment). Passer la valeur par un attribut `data-*` et la lire via `dataset`.\n"); | |
| 581 | + s.push_str("- Données dynamiques d'API côté client : `textContent`, jamais `innerHTML`.\n"); | |
| 582 | + s.push_str("- URLs : `encodeURIComponent` / helper `urlenc()` du core ; aucune donnée personnelle en query string.\n"); | |
| 583 | + s.push_str("- Aucun filtre `|safe` Askama sur une valeur contrôlée par l'utilisateur.\n"); | |
| 584 | + s | |
| 585 | +} | |
| 586 | + | |
| 587 | +pub fn reference_body(selected: &[&'static Functionality]) -> String { | |
| 588 | + let mut s = String::new(); | |
| 589 | + s.push_str("## Contrat de sécurité par écran\n\n"); | |
| 590 | + for f in selected { | |
| 591 | + for sc in f.screens { | |
| 592 | + s.push_str(&format!( | |
| 593 | + "### {} (`{}`) — {}\n\n", | |
| 594 | + sc.name, | |
| 595 | + sc.suggested_route, | |
| 596 | + sc.auth_level.label() | |
| 597 | + )); | |
| 598 | + if matches!(sc.channel, Channel::Browser) { | |
| 599 | + s.push_str("- Canal *browser* ⇒ valider le token CSRF sur chaque `POST` (`validate_csrf_token`).\n"); | |
| 600 | + } | |
| 601 | + for note in sc.security_notes { | |
| 602 | + s.push_str(&format!("- {note}\n")); | |
| 603 | + } | |
| 604 | + s.push('\n'); | |
| 605 | + } | |
| 606 | + } | |
| 607 | + s | |
| 608 | +} | |
| 609 | +``` | |
| 610 | + | |
| 611 | +- [ ] **Step 5: Lancer — passe** | |
| 612 | + | |
| 613 | +Run: `cargo test -p rustwarden-core --bin screen-select -- security::tests` | |
| 614 | +Expected: PASS (3 tests). | |
| 615 | + | |
| 616 | +- [ ] **Step 6: Commit (checkpoint)** | |
| 617 | + | |
| 618 | +```bash | |
| 619 | +git add crates/rustwarden-core/src/bin/screen-select/security.rs crates/rustwarden-core/src/bin/screen-select/main.rs | |
| 620 | +git commit -m "feat(screen-select): contenu de sécurité (transversal + contrat par écran)" | |
| 621 | +``` | |
| 622 | + | |
| 623 | +--- | |
| 624 | + | |
| 625 | +## Task 4: Tisser les sections dans `generate::render_plan` | |
| 626 | + | |
| 627 | +**Files:** | |
| 628 | +- Modify: `crates/rustwarden-core/src/bin/screen-select/generate.rs` | |
| 629 | + | |
| 630 | +- [ ] **Step 1: Mettre à jour le test de rendu existant + ajouter le test des sections (rouge)** | |
| 631 | + | |
| 632 | +Dans `generate.rs`, module `tests`, remplacer le test `render_plan_inclut_charte_et_tableau` par une version qui compile avec la nouvelle API et vérifie aussi les sections. Remplacer : | |
| 633 | + | |
| 634 | +```rust | |
| 635 | + #[test] | |
| 636 | + fn render_plan_inclut_charte_et_tableau() { | |
| 637 | + let auth = catalogue::by_key("authentication").unwrap(); | |
| 638 | + let inputs = PlanInputs { | |
| 639 | + project: "demo", | |
| 640 | + selected: vec![auth], | |
| 641 | + design_md: Some("Police: Inter. Couleur: #123456".into()), | |
| 642 | + layout: Some("navbar: gauche; 2 colonnes".into()), | |
| 643 | + }; | |
| 644 | + let rows = rows_for(&inputs.selected); | |
| 645 | + let out = render_plan(&inputs, &rows); | |
| 646 | + assert!(out.contains("# Plan UI — demo")); | |
| 647 | + assert!(out.contains("Police: Inter")); | |
| 648 | + assert!(out.contains("navbar: gauche")); | |
| 649 | + assert!(out.contains("| Statut | Fonctionnalité |")); | |
| 650 | + assert!(out.contains("Login")); | |
| 651 | + } | |
| 652 | +``` | |
| 653 | + | |
| 654 | +par : | |
| 655 | + | |
| 656 | +```rust | |
| 657 | + #[test] | |
| 658 | + fn render_plan_inclut_charte_et_tableau() { | |
| 659 | + let auth = catalogue::by_key("authentication").unwrap(); | |
| 660 | + let inputs = PlanInputs { | |
| 661 | + project: "demo", | |
| 662 | + selected: vec![auth], | |
| 663 | + design_md: Some("Police: Inter. Couleur: #123456".into()), | |
| 664 | + layout: Some("navbar: gauche; 2 colonnes".into()), | |
| 665 | + }; | |
| 666 | + let rows = rows_for(&inputs.selected); | |
| 667 | + let out = render_plan(&inputs, &rows); | |
| 668 | + assert!(out.contains("# Plan UI — demo")); | |
| 669 | + assert!(out.contains("Police: Inter")); | |
| 670 | + assert!(out.contains("navbar: gauche")); | |
| 671 | + assert!(out.contains("| Statut | Fonctionnalité |")); | |
| 672 | + assert!(out.contains("Login")); | |
| 673 | + } | |
| 674 | + | |
| 675 | + #[test] | |
| 676 | + fn rows_for_derive_protected_du_niveau_auth() { | |
| 677 | + // Login est Public ⇒ non protégé ; TwoFactorSetup est Authentifié ⇒ protégé. | |
| 678 | + let auth = catalogue::by_key("authentication").unwrap(); | |
| 679 | + let tf = catalogue::by_key("two_factor").unwrap(); | |
| 680 | + let login = &rows_for(&[auth])[0]; | |
| 681 | + assert!(!login.protected); | |
| 682 | + let setup = rows_for(&[tf]).into_iter().find(|r| r.screen == "TwoFactorSetup").unwrap(); | |
| 683 | + assert!(setup.protected); | |
| 684 | + } | |
| 685 | + | |
| 686 | + #[test] | |
| 687 | + fn render_plan_inclut_les_sections_securite() { | |
| 688 | + let auth = catalogue::by_key("authentication").unwrap(); | |
| 689 | + let inputs = PlanInputs { project: "demo", selected: vec![auth], design_md: None, layout: None }; | |
| 690 | + let rows = rows_for(&inputs.selected); | |
| 691 | + let out = render_plan(&inputs, &rows); | |
| 692 | + assert!(out.contains("<!-- screen-select:security-guides -->")); | |
| 693 | + assert!(out.contains("Sécurité transversale")); | |
| 694 | + assert!(out.contains("Conventions de rendu sûr")); | |
| 695 | + assert!(out.contains("<!-- screen-select:security-per-screen -->")); | |
| 696 | + assert!(out.contains("## Contrat de sécurité par écran")); | |
| 697 | + assert!(out.contains("### Login (`/login`) — Public")); | |
| 698 | + // le bloc statique doit précéder le tableau (vu en premier par le dev) | |
| 699 | + let guides = out.find("Sécurité transversale").unwrap(); | |
| 700 | + let table = out.find("| Statut").unwrap(); | |
| 701 | + assert!(guides < table, "les garde-fous transversaux doivent précéder le tableau"); | |
| 702 | + } | |
| 703 | +``` | |
| 704 | + | |
| 705 | +- [ ] **Step 2: Lancer — échoue (compilation)** | |
| 706 | + | |
| 707 | +Run: `cargo test -p rustwarden-core --bin screen-select -- generate::tests` | |
| 708 | +Expected: FAIL — `render_plan` n'émet pas encore les sections (et `rows_for` référence encore `s.protected`). | |
| 709 | + | |
| 710 | +- [ ] **Step 3: Adapter `rows_for` (dériver `protected`) et importer les modules** | |
| 711 | + | |
| 712 | +En tête de `generate.rs`, sous le `use` existant, ajouter : | |
| 713 | + | |
| 714 | +```rust | |
| 715 | +use crate::{section, security}; | |
| 716 | +``` | |
| 717 | + | |
| 718 | +Dans `rows_for`, remplacer la ligne : | |
| 719 | + | |
| 720 | +```rust | |
| 721 | + protected: s.protected, | |
| 722 | +``` | |
| 723 | + | |
| 724 | +par : | |
| 725 | + | |
| 726 | +```rust | |
| 727 | + protected: s.auth_level.is_protected(), | |
| 728 | +``` | |
| 729 | + | |
| 730 | +- [ ] **Step 4: Émettre les sections dans `render_plan`** | |
| 731 | + | |
| 732 | +Remplacer intégralement la fonction `render_plan` par : | |
| 733 | + | |
| 734 | +```rust | |
| 735 | +pub fn render_plan(inputs: &PlanInputs, rows: &[TodoRow]) -> String { | |
| 736 | + let mut out = String::new(); | |
| 737 | + out.push_str(&format!("# Plan UI — {}\n\n", inputs.project)); | |
| 738 | + out.push_str("> Généré par `cargo run -p rustwarden-core --bin screen-select`. Recochez le réalisé via `cargo run -p rustwarden-core --bin screen-select -- --check`.\n\n"); | |
| 739 | + // Garde-fous transversaux + conventions de rendu sûr (statique, vu en premier). | |
| 740 | + out.push_str(§ion::block(security::GUIDES_ID, &security::guides_body())); | |
| 741 | + out.push_str("\n\n"); | |
| 742 | + if let Some(design) = &inputs.design_md { | |
| 743 | + out.push_str("## Charte graphique (à respecter)\n\n"); | |
| 744 | + out.push_str(design.trim()); | |
| 745 | + out.push_str("\n\n"); | |
| 746 | + } | |
| 747 | + if let Some(layout) = &inputs.layout { | |
| 748 | + out.push_str("## Structure de page\n\n"); | |
| 749 | + out.push_str(layout.trim()); | |
| 750 | + out.push_str("\n\n"); | |
| 751 | + } | |
| 752 | + out.push_str("## Écrans à coder\n\n"); | |
| 753 | + out.push_str(&todo::render(rows)); | |
| 754 | + out.push('\n'); | |
| 755 | + // Contrat de sécurité par écran (dérivé du catalogue, en référence sous le tableau). | |
| 756 | + out.push_str(§ion::block(security::PER_SCREEN_ID, &security::reference_body(&inputs.selected))); | |
| 757 | + out.push('\n'); | |
| 758 | + out | |
| 759 | +} | |
| 760 | +``` | |
| 761 | + | |
| 762 | +- [ ] **Step 5: Lancer — passe** | |
| 763 | + | |
| 764 | +Run: `cargo test -p rustwarden-core --bin screen-select -- generate::tests` | |
| 765 | +Expected: PASS (tous les tests `generate`, dont les 2 nouveaux). | |
| 766 | + | |
| 767 | +- [ ] **Step 6: Commit (checkpoint)** | |
| 768 | + | |
| 769 | +```bash | |
| 770 | +git add crates/rustwarden-core/src/bin/screen-select/generate.rs | |
| 771 | +git commit -m "feat(screen-select): tissage des sections sécurité dans le plan" | |
| 772 | +``` | |
| 773 | + | |
| 774 | +--- | |
| 775 | + | |
| 776 | +## Task 5: Re-run wizard — régénérer les sections + vérif e2e | |
| 777 | + | |
| 778 | +**Files:** | |
| 779 | +- Modify: `crates/rustwarden-core/src/bin/screen-select/main.rs` | |
| 780 | + | |
| 781 | +- [ ] **Step 1: Régénérer les sections sécurité au re-run** | |
| 782 | + | |
| 783 | +Dans `run_wizard` de `main.rs`, localiser le bloc de fusion (plan existant) : | |
| 784 | + | |
| 785 | +```rust | |
| 786 | + let path = Path::new(PLAN_PATH); | |
| 787 | + let content = if path.exists() { | |
| 788 | + let existing = std::fs::read_to_string(path)?; | |
| 789 | + let prev = todo::parse(&existing); | |
| 790 | + let merged = todo::merge(&prev, &desired); | |
| 791 | + // conserve l'en-tête existant, remplace le tableau | |
| 792 | + todo::replace_table(&existing, &merged) | |
| 793 | + } else { | |
| 794 | + generate::render_plan(&inputs, &desired) | |
| 795 | + }; | |
| 796 | +``` | |
| 797 | + | |
| 798 | +et le remplacer par : | |
| 799 | + | |
| 800 | +```rust | |
| 801 | + let path = Path::new(PLAN_PATH); | |
| 802 | + let content = if path.exists() { | |
| 803 | + let existing = std::fs::read_to_string(path)?; | |
| 804 | + let prev = todo::parse(&existing); | |
| 805 | + let merged = todo::merge(&prev, &desired); | |
| 806 | + // conserve l'en-tête existant, remplace le tableau… | |
| 807 | + let mut c = todo::replace_table(&existing, &merged); | |
| 808 | + // …puis régénère les sections sécurité (idempotent, suit la sélection courante). | |
| 809 | + c = section::upsert(&c, security::GUIDES_ID, &security::guides_body()); | |
| 810 | + c = section::upsert(&c, security::PER_SCREEN_ID, &security::reference_body(&inputs.selected)); | |
| 811 | + c | |
| 812 | + } else { | |
| 813 | + generate::render_plan(&inputs, &desired) | |
| 814 | + }; | |
| 815 | +``` | |
| 816 | + | |
| 817 | +- [ ] **Step 2: Compiler + clippy** | |
| 818 | + | |
| 819 | +Run: `cargo clippy -p rustwarden-core --bin screen-select -- -D warnings` | |
| 820 | +Expected: 0 warning. | |
| 821 | + | |
| 822 | +- [ ] **Step 3: Smoke test — génération neuve porte les sections** | |
| 823 | + | |
| 824 | +Run (depuis la racine du dépôt, dans un dossier jetable) : | |
| 825 | +```bash | |
| 826 | +tmp="$(mktemp -d)"; cd "$tmp" | |
| 827 | +printf '4\n\n\n' | cargo run -p rustwarden-core --bin screen-select --manifest-path "$OLDPWD/crates/rustwarden-core/Cargo.toml" | |
| 828 | +grep -q "Sécurité transversale" docs/plans/ui-screens.md && echo "OK transversal" | |
| 829 | +grep -q "Contrat de sécurité par écran" docs/plans/ui-screens.md && echo "OK par écran" | |
| 830 | +``` | |
| 831 | +Expected : les deux `echo` s'affichent ; le plan contient le bloc garde-fous et le contrat par écran (choix `4` = PasswordReset → écrans `ForgotPassword`/`ResetPassword`, dont la note « data-* » sur le token). *(L'ordre exact des fonctionnalités choisissables est celui affiché par le wizard ; ajuster le numéro si besoin.)* | |
| 832 | + | |
| 833 | +- [ ] **Step 4: Vérification e2e — `--check` n'altère pas les sections sécurité** | |
| 834 | + | |
| 835 | +```bash | |
| 836 | +cd "$tmp" | |
| 837 | +# simule l'implémentation d'un écran à sa route par défaut | |
| 838 | +mkdir -p src && printf 'fn r(){ .route("/reset-password", get(h)); }\n' > src/ui.rs | |
| 839 | +printf 'x\n' | cargo run -p rustwarden-core --bin screen-select --manifest-path "$OLDPWD/crates/rustwarden-core/Cargo.toml" -- --check | |
| 840 | +grep -q "Contrat de sécurité par écran" docs/plans/ui-screens.md && echo "sections préservées" | |
| 841 | +grep -q "| ☑ |" docs/plans/ui-screens.md && echo "route recochée" | |
| 842 | +``` | |
| 843 | +Expected : `ResetPassword` passe à ☑ dans le tableau **et** la section « Contrat de sécurité par écran » (+ garde-fous transversaux) est toujours présente intacte — `--check` ne touche que le tableau. | |
| 844 | + | |
| 845 | +- [ ] **Step 5: Commit (checkpoint)** | |
| 846 | + | |
| 847 | +```bash | |
| 848 | +git add crates/rustwarden-core/src/bin/screen-select/main.rs | |
| 849 | +git commit -m "feat(screen-select): re-run régénère les sections sécurité" | |
| 850 | +``` | |
| 851 | + | |
| 852 | +--- | |
| 853 | + | |
| 854 | +## Task 6: Documentation + vérification finale | |
| 855 | + | |
| 856 | +**Files:** | |
| 857 | +- Modify: `docs/SERVICES_REFERENCE.md` (§22, section `screen-select`) | |
| 858 | + | |
| 859 | +- [ ] **Step 1: Documenter les sections sécurité dans §22** | |
| 860 | + | |
| 861 | +Repérer la section 22 (« screen-select ») de `docs/SERVICES_REFERENCE.md` et, à la fin de cette section, ajouter le sous-bloc suivant : | |
| 862 | + | |
| 863 | +```markdown | |
| 864 | +#### Sections de sécurité du plan | |
| 865 | + | |
| 866 | +Le plan généré porte, en plus du tableau des écrans : | |
| 867 | + | |
| 868 | +- **Sécurité transversale (obligatoire, hors écran)** — garde-fous globaux à | |
| 869 | + câbler une fois : middleware de headers (CSP, `X-Frame-Options`, | |
| 870 | + `X-Content-Type-Options`, `Referrer-Policy`), validation de configuration au | |
| 871 | + démarrage (refus de `ADMIN_PASSWORD=password`, alerte `APP_DEBUG`/`COOKIE_SECURE`), | |
| 872 | + cookies `HttpOnly`/`Secure`/`SameSite`, `cargo audit` + JWT en HMAC. | |
| 873 | +- **Conventions de rendu sûr** — escaping contextuel (jamais de valeur utilisateur | |
| 874 | + dans un `<script>` : `data-*` + `dataset`), `textContent` plutôt qu'`innerHTML`, | |
| 875 | + `encodeURIComponent`/`urlenc()`, pas de filtre `|safe`. | |
| 876 | +- **Contrat de sécurité par écran** — pour chaque écran sélectionné : niveau | |
| 877 | + d'autorisation (Public / Authentifié / Propriétaire de ressource / Administrateur), | |
| 878 | + rappel CSRF automatique pour les écrans *browser*, et pièges spécifiques | |
| 879 | + (IDOR/ownership, `ensure_admin()`, anti-énumération, token de reset hors contexte JS…). | |
| 880 | + | |
| 881 | +Ces sections sont délimitées par des marqueurs HTML | |
| 882 | +(`<!-- screen-select:security-guides -->`, `<!-- screen-select:security-per-screen -->`) | |
| 883 | +et régénérées à chaque exécution du wizard. `--check` ne modifie que le tableau : | |
| 884 | +les sections de sécurité sont préservées telles quelles. | |
| 885 | +``` | |
| 886 | + | |
| 887 | +- [ ] **Step 2: Suite de tests complète du binaire** | |
| 888 | + | |
| 889 | +Run: `cargo test -p rustwarden-core --bin screen-select` | |
| 890 | +Expected: PASS (catalogue + section + security + todo + generate + check). | |
| 891 | + | |
| 892 | +- [ ] **Step 3: Vérification workspace** | |
| 893 | + | |
| 894 | +Run: `cargo clippy --workspace -- -D warnings && cargo test -p rustwarden-core --bin screen-select` | |
| 895 | +Expected: 0 warning ; tous les tests du binaire verts. | |
| 896 | + | |
| 897 | +- [ ] **Step 4: Commit (checkpoint)** | |
| 898 | + | |
| 899 | +```bash | |
| 900 | +git add docs/SERVICES_REFERENCE.md | |
| 901 | +git commit -m "docs(screen-select): sections de sécurité du plan (§22)" | |
| 902 | +``` | |
| 903 | + | |
| 904 | +--- | |
| 905 | + | |
| 906 | +## Couverture spec ↔ findings de l'audit | |
| 907 | + | |
| 908 | +| Finding audit | Où c'est adressé dans le plan généré | | |
| 909 | +|---|---| | |
| 910 | +| #1 XSS token en contexte JS | Note `ResetPassword` (`data-*` + `dataset`) + section « Conventions de rendu sûr » | | |
| 911 | +| #2 Open redirect `?redirect=` | Note `Login` (chemin relatif same-origin) | | |
| 912 | +| #3 `.env` dangereux | Section transversale (validation config au démarrage) | | |
| 913 | +| #4 `RawQuery` pass-through | Note `TwoFactorSetup` (whitelist des params) | | |
| 914 | +| #5 RSA Marvin (RUSTSEC-2023-0071) | Section transversale (`cargo audit` + confirmer JWT HMAC) | | |
| 915 | +| #6 Headers manquants (CSP, X-Frame-Options…) | Section transversale (middleware headers) | | |
| 916 | +| #7 Énumération username (admin/teams) | Notes `AdminUsers` + `TeamsManagement` (message générique) | | |
| 917 | +| Positive: CSRF POST browser | Rappel CSRF auto pour tout écran *browser* | | |
| 918 | +| Positive: IDOR sessions/tokens/teams | Niveau `ResourceOwner` + notes IDOR (garde-fou testé) | | |
| 919 | +| Positive: `ensure_admin()` défense en profondeur | Niveau `Admin` + note `ensure_admin()` (garde-fou testé) | | |
| 920 | +| Positive: anti-énumération flux publics | Notes `ForgotPassword`/`ResendVerification`/`Register` | | |
| 921 | +| Positive: `textContent`/`encodeURIComponent` | Note `OAuthAccountLinking` + section « Conventions » | | |
| 922 | + | |
| 923 | +**Hors périmètre (assumé) :** correctifs upstream des dépendances du core (#5 `rsa`, #8 `lexical-core`) — le plan génère seulement le rappel de vérification, pas le fix ; durcissement effectif des valeurs `.env` (dépend du déploiement). | |
| 924 | + | |
| 925 | +--- | |
| 926 | + | |
| 927 | +## Suivi post-implémentation (hors code) | |
| 928 | + | |
| 929 | +- [ ] Vérifier que `update-project` propage `security.rs` et `section.rs` (il resynchronise les fichiers présents de la crate — les nouveaux modules bin voyagent avec). | |
| 930 | +- [ ] Envisager d'aligner la référence `routes-ecrans-sans-ui.md` (skill `rustwarden-boilerplate`) pour pointer le contrat de sécurité comme partie intégrante du plan. |
M
tools/rustwarden-cli/Cargo.toml
+1
-1
@@ -1,6 +1,6 @@
| 1 | 1 | [package] |
| 2 | 2 | name = "rustwarden-cli" |
| 3 | -version = "0.1.0" | |
| 3 | +version = "1.0.0" | |
| 4 | 4 | edition.workspace = true |
| 5 | 5 | rust-version.workspace = true |
| 6 | 6 | description = "CLI wizard to scaffold new Rustwarden applications" |
M
tools/rustwarden-cli/src/start_project.rs
+13
-1
@@ -849,11 +849,23 @@ fn print_summary(cfg: &ProjectConfig, project_dir: &Path) {
| 849 | 849 | println!(" {} Lancer l'application :", style("3.").cyan().bold()); |
| 850 | 850 | println!(" {}", style("cargo run").green()); |
| 851 | 851 | println!( |
| 852 | - " {} Ouvrir dans le navigateur :", | |
| 852 | + " {} Générer la liste des écrans d'UI à coder :", | |
| 853 | 853 | style("4.").cyan().bold() |
| 854 | 854 | ); |
| 855 | 855 | println!( |
| 856 | 856 | " {}", |
| 857 | + style("cargo run -p rustwarden-core --bin screen-select").green() | |
| 858 | + ); | |
| 859 | + println!( | |
| 860 | + " puis {} pour recocher le réalisé", | |
| 861 | + style("cargo run -p rustwarden-core --bin screen-select -- --check").green() | |
| 862 | + ); | |
| 863 | + println!( | |
| 864 | + " {} Ouvrir dans le navigateur :", | |
| 865 | + style("5.").cyan().bold() | |
| 866 | + ); | |
| 867 | + println!( | |
| 868 | + " {}", | |
| 857 | 869 | style(format!("http://localhost:{}", cfg.port)).underlined() |
| 858 | 870 | ); |
| 859 | 871 |
M
tools/rustwarden-cli/src/templates.rs
+50
-8
@@ -187,6 +187,7 @@ tracing = "0.1"
| 187 | 187 | anyhow = "1.0" |
| 188 | 188 | async-trait = "0.1" |
| 189 | 189 | uuid = {{ version = "1.0", features = ["v4"] }} |
| 190 | +serde_json = "1.0" | |
| 190 | 191 | |
| 191 | 192 | [lints.clippy] |
| 192 | 193 | empty_line_after_doc_comments = "allow" |
@@ -209,7 +210,11 @@ pub fn main_rs(cfg: &ProjectConfig) -> String {
| 209 | 210 | // `/api/v1/*` JSON API rides the machine channel (no CSRF). |
| 210 | 211 | " .with_ui(rustwarden_ui::browser_routes)\n .merge_machine_routes(rustwarden_ui::api_routes())\n" |
| 211 | 212 | } else { |
| 212 | - " .headless()\n" | |
| 213 | + // Headless: mount the framework's built-in JSON auth API so the | |
| 214 | + // project ships a complete, working authentication surface | |
| 215 | + // (login, refresh, logout, me, 2FA verify, ...) on the machine | |
| 216 | + // channel (Bearer/JSON, not CSRF-vulnerable). | |
| 217 | + " .headless()\n .merge_machine_routes(rustwarden_core::auth::auth_api_routes())\n" | |
| 213 | 218 | }; |
| 214 | 219 | |
| 215 | 220 | let migrate_line = if cfg.auto_migrate { |
@@ -321,12 +326,27 @@ async fn main() -> anyhow::Result<()> {{
| 321 | 326 | fn app_routes() -> Router<DatabaseConnection> {{ |
| 322 | 327 | Router::new() |
| 323 | 328 | .route("/api/hello", get(hello)) |
| 329 | + .route("/api/whoami", get(whoami)) | |
| 324 | 330 | }} |
| 325 | 331 | |
| 326 | 332 | async fn hello() -> &'static str {{ |
| 327 | 333 | "Hello from {name}!" |
| 328 | 334 | }} |
| 329 | 335 | |
| 336 | +/// Example protected endpoint. The `AuthUser` extractor validates the JWT | |
| 337 | +/// (`Authorization: Bearer <token>` or the auth cookie) before this handler | |
| 338 | +/// runs — unauthenticated requests get `401`. Obtain a token via | |
| 339 | +/// `POST /api/v1/auth/login`. | |
| 340 | +async fn whoami( | |
| 341 | + user: rustwarden_core::auth::extractors::AuthUser, | |
| 342 | +) -> axum::Json<serde_json::Value> {{ | |
| 343 | + axum::Json(serde_json::json!({{ | |
| 344 | + "user_id": user.user_id, | |
| 345 | + "username": user.username, | |
| 346 | + "roles": user.roles, | |
| 347 | + }})) | |
| 348 | +}} | |
| 349 | + | |
| 330 | 350 | // ---------------------------------------------------------------------------- |
| 331 | 351 | // Primitives framework optionnelles (Teams, PAT, SSE, Hooks) |
| 332 | 352 | // |
@@ -782,14 +802,36 @@ fn app_routes() -> Router<DatabaseConnection> {{
| 782 | 802 | }} |
| 783 | 803 | ``` |
| 784 | 804 | |
| 805 | +## Planifier l'interface (screen-select) | |
| 806 | + | |
| 807 | +Le binaire `screen-select` genere un plan Markdown (`docs/plans/ui-screens.md`) des | |
| 808 | +ecrans d'interface a coder selon les fonctionnalites activees. Pour chaque ecran il | |
| 809 | +precise le niveau d'autorisation (Public / Authentifie / Proprietaire de ressource / | |
| 810 | +Admin) et les points de securite a respecter (CSRF, IDOR, anti-enumeration, token de | |
| 811 | +reset hors contexte JS...), plus deux sections transversales (headers de securite, | |
| 812 | +conventions de rendu sur). Utile surtout pour une UI custom ou en mode API seule. | |
| 813 | + | |
| 814 | +```bash | |
| 815 | +# Generer / completer le plan des ecrans a coder | |
| 816 | +cargo run -p rustwarden-core --bin screen-select | |
| 817 | + | |
| 818 | +# Recocher automatiquement les ecrans deja cables (analyse des routes) | |
| 819 | +cargo run -p rustwarden-core --bin screen-select -- --check | |
| 820 | +``` | |
| 821 | + | |
| 822 | +Codez ces ecrans dans une crate dediee `{name}-ui` consommant `rustwarden-core`, | |
| 823 | +montee dans le routeur principal. | |
| 824 | + | |
| 785 | 825 | ## Commandes utiles |
| 786 | 826 | |
| 787 | 827 | | Commande | Description | |
| 788 | 828 | |----------|-------------| |
| 789 | 829 | | `cargo run` | Lancer l'application | |
| 790 | -| `cargo run --bin migrate` | Executer les migrations manuellement | | |
| 791 | -| `cargo run --bin seed` | Creer l'utilisateur admin | | |
| 792 | -| `cargo run --bin hash_password` | Hasher un mot de passe | | |
| 830 | +| `cargo run -p rustwarden-core --bin migrate` | Executer les migrations manuellement | | |
| 831 | +| `cargo run -p rustwarden-core --bin seed` | Creer l'utilisateur admin | | |
| 832 | +| `cargo run -p rustwarden-core --bin hash_password` | Hasher un mot de passe | | |
| 833 | +| `cargo run -p rustwarden-core --bin screen-select` | Planifier les ecrans d'UI a coder | | |
| 834 | +| `cargo run -p rustwarden-core --bin screen-select -- --check` | Recocher les ecrans deja implementes | | |
| 793 | 835 | "#, |
| 794 | 836 | name = cfg.name, |
| 795 | 837 | description = cfg.description, |
@@ -1275,10 +1317,10 @@ RustwardenApp::builder()
| 1275 | 1317 | | Commande | Description | |
| 1276 | 1318 | |----------|-------------| |
| 1277 | 1319 | | `cargo run` | Lancer l'application | |
| 1278 | -| `cargo run --bin migrate` | Executer les migrations manuellement | | |
| 1279 | -| `cargo run --bin seed` | Creer/mettre a jour l'admin initial | | |
| 1280 | -| `cargo run --bin hash_password` | Hasher un mot de passe bcrypt | | |
| 1281 | -| `cargo run --bin change_password` | Changer le mot de passe d'un utilisateur | | |
| 1320 | +| `cargo run -p rustwarden-core --bin migrate` | Executer les migrations manuellement | | |
| 1321 | +| `cargo run -p rustwarden-core --bin seed` | Creer/mettre a jour l'admin initial | | |
| 1322 | +| `cargo run -p rustwarden-core --bin hash_password` | Hasher un mot de passe bcrypt | | |
| 1323 | +| `cargo run -p rustwarden-core --bin change_password` | Changer le mot de passe d'un utilisateur | | |
| 1282 | 1324 | | `cargo test` | Lancer les tests | |
| 1283 | 1325 | | `cargo build --release` | Build optimise pour la production | |
| 1284 | 1326 |
M
tools/rustwarden-cli/src/update_project.rs
+14
-2
@@ -490,10 +490,19 @@ fn print_summary(
| 490 | 490 | ); |
| 491 | 491 | println!(" {}", style("cargo clippy -- -D warnings").green()); |
| 492 | 492 | println!(" {} Verifier les migrations :", style("3.").cyan().bold()); |
| 493 | - println!(" {}", style("cargo run --bin migrate").green()); | |
| 493 | + println!( | |
| 494 | + " {}", | |
| 495 | + style("cargo run -p rustwarden-core --bin migrate").green() | |
| 496 | + ); | |
| 494 | 497 | println!(" {} Lancer les tests :", style("4.").cyan().bold()); |
| 495 | 498 | println!(" {}", style("cargo test").green()); |
| 496 | 499 | println!(); |
| 500 | + println!( | |
| 501 | + " • Nouveaux écrans éventuels : {} (puis {})", | |
| 502 | + style("cargo run -p rustwarden-core --bin screen-select").green(), | |
| 503 | + style("-- --check").green() | |
| 504 | + ); | |
| 505 | + println!(); | |
| 497 | 506 | |
| 498 | 507 | if !newly_added.is_empty() { |
| 499 | 508 | let divider = style("─".repeat(55)).dim(); |
@@ -567,7 +576,10 @@ fn print_summary(
| 567 | 576 | " {} Appliquer les migrations fediverse :", |
| 568 | 577 | style("d)").cyan() |
| 569 | 578 | ); |
| 570 | - println!(" {}", style("cargo run --bin migrate").green()); | |
| 579 | + println!( | |
| 580 | + " {}", | |
| 581 | + style("cargo run -p rustwarden-core --bin migrate").green() | |
| 582 | + ); | |
| 571 | 583 | println!(); |
| 572 | 584 | println!( |
| 573 | 585 | " {} Guide complet : {}", |
GitRust