Initial commit : monorepo seiche (serveur Fastify + web Vite/React, Docker, config VS Code)
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
web/dist
|
||||||
|
server/data
|
||||||
|
**/.env*
|
||||||
|
**/coverage
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Seiche — variables d'environnement (exemples)
|
||||||
|
# Copier dans .env (jamais committer le .env !)
|
||||||
|
|
||||||
|
# --- Serveur ---
|
||||||
|
# Port d'écoute du serveur (API + frontend en production)
|
||||||
|
SEICHE_PORT=8787
|
||||||
|
# Hôte d'écoute. 127.0.0.1 = uniquement la machine locale (recommandé).
|
||||||
|
# Mettre 0.0.0.0 uniquement si vous placez Caddy/proxy derrière (voir README).
|
||||||
|
SEICHE_HOST=127.0.0.1
|
||||||
|
# Dossier des données (SQLite, secrets)
|
||||||
|
SEICHE_DATA_DIR=./data
|
||||||
|
# URL du frontend servant les assets en dev (rarement nécessaire)
|
||||||
|
# SEICHE_PUBLIC_URL=http://localhost:8787
|
||||||
|
|
||||||
|
# --- Sessions ---
|
||||||
|
# Durée de vie d'une session (ms). 8h par défaut.
|
||||||
|
SEICHE_SESSION_TTL_MS=28800000
|
||||||
|
# Durée de vie absolue max d'une session (ms). 7 jours par défaut.
|
||||||
|
SEICHE_SESSION_ABSOLUTE_TTL_MS=604800000
|
||||||
|
# Nombre max de tentatives de connexion par minute (par IP)
|
||||||
|
SEICHE_MAX_LOGIN_ATTEMPTS_PER_MINUTE=5
|
||||||
|
|
||||||
|
# --- Service REST externe ---
|
||||||
|
# "example" = adaptateur de démonstration (données statiques, pour tester le pipeline)
|
||||||
|
# "http" = adaptateur générique pointant vers une vraie API REST (à paramétrer)
|
||||||
|
SEICHE_EXTERNAL_ADAPTER=example
|
||||||
|
# Base URL du service externe (adaptateur http)
|
||||||
|
SEICHE_EXTERNAL_BASE_URL=
|
||||||
|
# Clé API / token envoyé au service externe (adaptateur http)
|
||||||
|
SEICHE_EXTERNAL_API_KEY=
|
||||||
|
# Chemins d'API (adaptateur http)
|
||||||
|
SEICHE_EXTERNAL_HEALTH_PATH=/health
|
||||||
|
SEICHE_EXTERNAL_LIST_PATH=/items
|
||||||
|
# Timeout des appels sortants (ms)
|
||||||
|
SEICHE_EXTERNAL_TIMEOUT_MS=10000
|
||||||
|
# Nombre de tentatives sur erreur transitoire
|
||||||
|
SEICHE_EXTERNAL_RETRIES=2
|
||||||
|
# TTL du cache serveur des données externes (ms)
|
||||||
|
SEICHE_EXTERNAL_CACHE_TTL_MS=30000
|
||||||
|
|
||||||
|
# --- Initialisation (uniquement si aucun utilisateur n'existe) ---
|
||||||
|
# Nom d'utilisateur et mot de passe de l'admin, fournis via `pnpm setup`
|
||||||
|
# ou directement ici au premier démarrage :
|
||||||
|
# SEICHE_ADMIN_USERNAME=admin
|
||||||
|
# SEICHE_ADMIN_PASSWORD=changez-moi
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Builds
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Data & secrets (NEVER commit)
|
||||||
|
data/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-*
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
initial-admin-password.txt
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# Drizzle : ON COMMIT meta/ — le migrateur exige meta/_journal.json au démarrage
|
||||||
|
# (throw « Can't find meta/_journal.json file » sinon). Rien d'ignoré ici.
|
||||||
|
backups/
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["biomejs.biome", "ms-azuretools.vscode-docker"]
|
||||||
|
}
|
||||||
Vendored
+34
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Seiche : serveur API (breakpoints)",
|
||||||
|
"cwd": "${workspaceFolder}/server",
|
||||||
|
"runtimeExecutable": "node",
|
||||||
|
"runtimeArgs": ["--import", "tsx", "src/main.ts"],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"skipFiles": ["<node_internals>/**"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Seiche : web (Vite, port 5173)",
|
||||||
|
"cwd": "${workspaceFolder}/web",
|
||||||
|
"runtimeExecutable": "node",
|
||||||
|
"runtimeArgs": ["node_modules/vite/bin/vite.js"],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"server": "http://localhost:5173"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"compounds": [
|
||||||
|
{
|
||||||
|
"name": "Seiche : dev complet (API + web)",
|
||||||
|
"configurations": [
|
||||||
|
"Seiche : serveur API (breakpoints)",
|
||||||
|
"Seiche : web (Vite, port 5173)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"typescript.updateImportsOnFileMove.enabled": "always"
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# Seiche — image de run (API Fastify + front Vite statique servis ensemble).
|
||||||
|
#
|
||||||
|
# Pas de stage de build séparé : le serveur s'exécute directement en TypeScript
|
||||||
|
# (tsx, pas de script de build — typecheck fait par CI/local), et le front est
|
||||||
|
# buildé par Vite puis servi par @fastify/static (SEICHE_PUBLIC_DIR).
|
||||||
|
#
|
||||||
|
# Base glibc (node:22-slim) volontairement : argon2 consomme des prébuilds glibc.
|
||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
# Dépendances workspace (serveur + web), lockfile figé.
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
||||||
|
COPY server/package.json server/
|
||||||
|
COPY web/package.json web/
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Front : build de production (web/dist).
|
||||||
|
COPY web/ web/
|
||||||
|
RUN pnpm --filter seiche-web build
|
||||||
|
|
||||||
|
# Serveur : source TypeScript exécutée par tsx.
|
||||||
|
COPY server/ server/
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
SEICHE_HOST=0.0.0.0 \
|
||||||
|
SEICHE_PORT=8787 \
|
||||||
|
SEICHE_DATA_DIR=/data \
|
||||||
|
SEICHE_PUBLIC_DIR=/app/web/dist
|
||||||
|
|
||||||
|
RUN mkdir -p /data && chown node:node /data
|
||||||
|
|
||||||
|
EXPOSE 8787
|
||||||
|
VOLUME /data
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
# cwd = server/ pour que `node --import tsx` résolve tsx depuis server/node_modules
|
||||||
|
# (layout pnpm) et que dotenv retrouve .env comme en dev locale.
|
||||||
|
WORKDIR /app/server
|
||||||
|
CMD ["node", "--import", "tsx", "src/main.ts"]
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:' + (process.env.SEICHE_PORT || '8787') + '/api/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||||
+150
@@ -0,0 +1,150 @@
|
|||||||
|
# Projet « Seiche » — doc de reprise
|
||||||
|
|
||||||
|
> À jour : 2026-08-24 (A1–A5 ✅) · Statut : **scaffold backend installé, compilé, linté, boot + setup admin + smoke login OK** — front absent
|
||||||
|
> Mode d'emploi : doc auto-suffisant. En reprise avec un assistant IA : lui faire lire ce fichier en entier, traiter le §5, puis **planifier les tâches restantes une par une** (§9 + §11).
|
||||||
|
> Mode de travail imposé par Nicolas : **petites tâches, planifier avant d'exécuter, ne jamais lancer de run autonome**.
|
||||||
|
|
||||||
|
## 1. Projet en une phrase
|
||||||
|
Appli web auto-hébergée, **réseau local uniquement, mono-utilisateur** (Nicolas), qui gère un service externe via son API REST. Accès protégé par mot de passe. Le périmètre va grossir et devenir sensible : rigueur et sécurité **dès le départ**, pas en rattrapage.
|
||||||
|
|
||||||
|
## 2. Exigences (telles que déclarées)
|
||||||
|
- Gestion d'un service externe via API REST (service + périmètre MVP **à préciser**, §5)
|
||||||
|
- Auto-hébergée, accessible uniquement en réseau local, par le propriétaire
|
||||||
|
- Protection par mot de passe obligatoire (LAN ≠ confiance totale)
|
||||||
|
- Deviendra « compliqué et sensible » → fondations sérieuses dès le starter
|
||||||
|
- Stack imposée : Docker, SQLite, backend Node, front React (ou équivalent validé), tests
|
||||||
|
|
||||||
|
## 3. Stack recommandée (défauts à valider)
|
||||||
|
| Couche | Choix | Pourquoi |
|
||||||
|
|---|---|---|
|
||||||
|
| Langage | TypeScript partout (strict) | Une seule langue, contrats API↔front typés |
|
||||||
|
| Backend | Node 22 LTS + Fastify 5 | Rapide, validation Zod, erreurs propres, plugins |
|
||||||
|
| DB | SQLite (better-sqlite3) + Drizzle ORM | Migrations versionnées, typage, mode WAL |
|
||||||
|
| Front | Vite + React 18 + React Router + TanStack Query | Fetch/caching de l'API REST sans douleur |
|
||||||
|
| UI | Tailwind CSS + shadcn/ui | Dashboard admin pro, composants en repo |
|
||||||
|
| Auth | Argon2id + session cookie httpOnly + CSRF | Solide et simple, pas de JWT d'État |
|
||||||
|
| Tests | Vitest (API via inject, DB de test) + React Testing Library | Tooling TS unique, rapide |
|
||||||
|
| Qualité | Biome (lint + format) | Un seul outil, très rapide |
|
||||||
|
| Conteneur | Dockerfile multi-stage + compose, user non-root | Image fine, SQLite en volume |
|
||||||
|
|
||||||
|
## 4. Sécurité — non négociables
|
||||||
|
1. Hash **Argon2id** (sel par compte), jamais en clair, jamais de hash faible.
|
||||||
|
2. **Rate-limit** sur /login (anti brute-force).
|
||||||
|
3. Session : cookie **httpOnly + SameSite=Lax** (+ Secure si TLS), durée limitée, révocation au logout.
|
||||||
|
4. **CSRF** : token sur toutes les mutations (POST/PUT/DELETE).
|
||||||
|
5. Secrets **uniquement en variables d'env** (`.env` hors repo, `.env.example` versionné).
|
||||||
|
6. Logs **pino** structurés : jamais de mot de passe / token / cookie dans les logs.
|
||||||
|
7. SQLite **hors de l'image** : volume nommé `/data/app.db` + **script de backup** testé.
|
||||||
|
8. LAN sans TLS = mots de passe en clair sur le réseau → à trancher (§5 q4) : Caddy self-signed, Tailscale, ou HTTP assumé.
|
||||||
|
9. Dépendances : lockfile figé en prod, `npm audit` au rythme des maj.
|
||||||
|
10. Surface API minimale, pas de debug en prod.
|
||||||
|
|
||||||
|
## 5. Questions ouvertes (à répondre avant le MVP)
|
||||||
|
| # | Question | Défaut si pas de réponse |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Quel service externe** ? URL, doc API, mode d'auth (token statique / OAuth / Basic ?) | Client isolé `server/src/external/` (adaptateurs), config env, mock en dev |
|
||||||
|
| 2 | **Périmètre concret du MVP** ? (lister X, modifier Y, déclencher Z, logs…) | 1 CRUD exemple + health |
|
||||||
|
| 3 | Un seul compte, ou plusieurs possibles ? | Schéma multi-comptes, 1 compte créé au 1er boot |
|
||||||
|
| 4 | **TLS en LAN** ? (Caddy / Tailscale / HTTP accepté) | HTTP + risque documenté dans le README |
|
||||||
|
| 5 | UI Tailwind + shadcn/ui, interface en français ? | Oui / FR |
|
||||||
|
| 6 | Persistance : volume nommé + backup régulier OK ? | Oui |
|
||||||
|
| 7 | Node 22 LTS dans les conteneurs ? | Oui |
|
||||||
|
| 8 | ✅ **Docker opérationnel** (résolu 2026-08-23) : daemon 29.7.2 démarré (Arch Linux), compose 5.5.0 (plugin + standalone). | — |
|
||||||
|
| 9 | **Gitea auto-hébergé** : URL + nom du repo à confirmer. Accès **SSH** choisi ; Nicolas configure la clé le 2026-08-24. (rien sur `localhost:3000` au 2026-08-23) | `git init` local d'abord, remote Gitea dès que la clé sera opérationnelle |
|
||||||
|
|
||||||
|
## 6. Architecture
|
||||||
|
- **1 conteneur = API + front prébuildé** servi par Fastify → zéro CORS, 1 port, déploiement simple.
|
||||||
|
- `server/` (Fastify) : plugins (auth, error-handler, rate-limit) → routes `/api/v1/*` → DB + client externe.
|
||||||
|
- `web/` (Vite+React) : pages, guard de session, TanStack Query devant `/api/v1`.
|
||||||
|
- Service externe : **un seul package** `server/src/external/` (http-client, cache, types, adaptateurs) — le seul endroit qui le connaît.
|
||||||
|
- SQLite en **WAL**, migrations Drizzle appliquées au boot.
|
||||||
|
|
||||||
|
## 7. Structure du repo (état réel, inventaire 2026-08-23)
|
||||||
|
✅ = présent · ❌ = manquant
|
||||||
|
```
|
||||||
|
seiche/
|
||||||
|
├── REPRISE.md ✅ · README.md ❌
|
||||||
|
├── package.json ✅ · pnpm-workspace.yaml ✅ · biome.json ✅ · tsconfig.base.json ✅
|
||||||
|
├── .env.example ✅ · .gitignore ✅ · .dockerignore ❌
|
||||||
|
├── Dockerfile ❌ · docker-compose.yml ❌ · Makefile ❌
|
||||||
|
├── server/ ✅ (scaffold ~850 lignes — non installé, non compilé, non testé)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.ts · app.ts · config.ts · validation.ts
|
||||||
|
│ │ ├── db/ (client, schema) · migrations ❌ (à générer)
|
||||||
|
│ │ ├── auth/ (password, session+CSRF, rate-limit)
|
||||||
|
│ │ ├── routes/ (auth, external, health)
|
||||||
|
│ │ ├── external/ (http-client, cache, types, adapters/{http,example,index})
|
||||||
|
│ │ └── setup.ts ❌ (referencé par `pnpm setup`, absent)
|
||||||
|
│ ├── drizzle.config.ts · vitest.config.ts · tsconfig.json
|
||||||
|
│ └── test/ ❌ (aucun test)
|
||||||
|
└── web/ ❌ (front entier absent)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Starter pack — Definition of Done
|
||||||
|
- [ ] `pnpm dev` : API + web en dev, login → dashboard
|
||||||
|
- [ ] `docker compose up` : 1 conteneur, SQLite persisté en volume
|
||||||
|
- [ ] Auth complète : Argon2id, session httpOnly, CSRF, logout, rate-limit /login
|
||||||
|
- [ ] Table `users` + migration Drizzle, 1er compte créé au 1er boot
|
||||||
|
- [ ] `/api/v1/health` public, le reste protégé
|
||||||
|
- [ ] Tests verts (Vitest) : login ok/échec, CSRF, error-handler, DB round-trip
|
||||||
|
- [ ] Biome + `tsc --noEmit` clean
|
||||||
|
- [ ] README : dev, prod, env, backup, sécurité
|
||||||
|
|
||||||
|
## 9. Plan d'exécution (session de reprise)
|
||||||
|
1. Lire ce doc → répondre au §5 (ou valider les défauts) → loger les réponses dans §10.
|
||||||
|
2. ~~Scaffolder le monorepo~~ → **partiel (2026-08-23)** : racine ✅, `server/` ✅ (non vérifié), `web/` ❌.
|
||||||
|
3. Backend : config env → DB (Drizzle, WAL, migration) → auth (Argon2id, session, CSRF, rate-limit) → routes. → **code écrit ; reste : installer, migrer, compiler, tester**.
|
||||||
|
4. Front : Vite+React, login, guard, dashboard minimal, client API. → **à faire**.
|
||||||
|
5. Docker : Dockerfile multi-stage + compose + volume.
|
||||||
|
6. Tests + lint + typecheck → cocher le §8.
|
||||||
|
7. **Mettre à jour ce doc** (statut + §10) à la fin.
|
||||||
|
|
||||||
|
## 10. Journal des décisions
|
||||||
|
| Date | Décision |
|
||||||
|
|---|---|
|
||||||
|
| 2026-08-23 | Stack proposée : TS / Node22+Fastify / SQLite+Drizzle / Vite+React+Tailwind+shadcn / Vitest / Biome (à valider) |
|
||||||
|
| 2026-08-23 | 1 conteneur pour API + front (pas de CORS, déploiement simple) |
|
||||||
|
| 2026-08-23 | Workflow fichiers : contenu ajouté par petits blocs en edit (les gros writes timoutent) |
|
||||||
|
| 2026-08-23 | Docker 29.7.2 présent, plugin compose absent sur l'hôte → à résoudre avant test prod |
|
||||||
|
| 2026-08-23 | Scaffold `server/` écrit de manière autonome (21 fichiers, ~850 lignes) — **non installé, non compilé, non testé**. Front `web/` absent. |
|
||||||
|
| 2026-08-23 | Divergences du scaffold vs doc, à trancher : driver SQLite `@libsql/client` (pas better-sqlite3) ; service externe en package `external/` avec adaptateurs (pas un seul fichier) ; rate-limit custom (pas @fastify/rate-limit). |
|
||||||
|
| 2026-08-23 | Mode de travail imposé : planifier chaque tâche en mode plan, exécuter en petites tâches validées par Nicolas. Inventaire complet dans §11. |
|
||||||
|
| 2026-08-23 | Audit outils : node v26.7.0 (hôte — conteneur à fixer en 22 LTS), pnpm 11.3.0 ✅, npm 12, git 2.55 (identité user.name/email **non configurée**), sqlite3 CLI 3.53 ✅, make ✅, curl ✅. Gitea : rien sur `localhost:3000` → URL + accès à fournir par Nicolas. |
|
||||||
|
| 2026-08-23 | Re-test Docker : daemon démarré (server 29.7.2), `docker ps` OK (0 conteneur), compose 5.5.0 présent en plugin **et** standalone. Prêt pour la tâche Docker du §11. Gitea : accès SSH, clé à configurer par Nicolas le 2026-08-24. |
|
||||||
|
| 2026-08-24 | **A1–A4 faits** : `pnpm install` ✅, migration 0000 générée + appliquée au boot ✅, `tsc --noEmit` clean ✅, Biome clean ✅. Corrections : `biome.json` migré Biome 2.x (`preset`, `clientKind`, `noConsole`, patterns d'ignore), 12 auto-fixes, import inutile retiré, `pino-pretty` ajouté en devDep (boot cassé sans). **A5** : `setup.ts` créé (idempotent, refuse d'écraser, mdp admin ≥ 12, guard sans creds = erreur propre) — smoke : setup + idempotence + boot + `/api/health` + login ok (cookie + CSRF) + session + mdp faux → 401, tous ✅. |
|
||||||
|
| 2026-08-24 | **B1–B2 faits** : `tests/helpers.ts` (sqlite :memory:, unique par worker, applyMigrations, seedUser, cookies) + `tests/auth.test.ts` (6 tests : health, login ok/échec, corps invalide, argon2) + `tests/api.test.ts` (9 tests : health, login, 401/403 CSRF double-submit, logout + session morte, rate limit 429, error-handler 500 sans fuite, DB round-trip). Suite : 26/26 ✅, tsc ✅, biome ✅. |
|
||||||
|
| 2026-08-24 | **C3 fait** : `lib/session.ts` (`useSession()` sur `sessionQueryKey`, cache partagé login→guard→dashboard) ; `components/RequireAuth.tsx` (pending = spinner plein écran, `isError` → `navigate('/login', {replace:true})` via useEffect) ; `DashboardPage.tsx` réel (username, expiry locale `fr-FR`, bouton logout → `api.logout` + `removeQueries` + navigate — le catch du logout couvre la session déjà morte côté serveur) ; `App.tsx` : `/login` public, `/` sous `RequireAuth`, catch-all `*` → `/` ; `main.tsx` : QueryClient avec `staleTime: 30s` et `retry` qui exclut les 4xx (pas de retry sur 401/400). Pitfall TS noté : le narrowing de `session` ne propage pas dans le closure `handleLogout` → bind `const s = session` après le guard. tsc ✅, build ✅, biome ✅. |
|
||||||
|
| 2026-08-24 | **C2 fait** : client API `web/src/lib/api.ts` — `request()` fetch + `credentials: same-origin` + content-type JSON (merge headers corrigé : le spread `...init` écrasait le content-type des POST), `ApiError{status,code,retryAfterMs}`, `sessionQueryKey` pour le cache TanStack. `LoginPage.tsx` — formulaire contrôlé, `useMutation(api.login)`, validation miroir (user 1–64, pass 1–256), `onSuccess` → `setQueryData(sessionQueryKey)` + navigate `/`, erreurs mappées (invalid_credentials / too_many_attempts + retryAfterMs / 500). Contrat aligné `server/src/routes/auth.ts` (codes `invalid_body|invalid_credentials|too_many_attempts|not_authenticated`). tsc ✅, build ✅, biome ✅. |
|
||||||
|
| 2026-08-24 | **C1 fait** : `web/` scaffoldé (workspace `seiche-web` déjà câblé en racine) : Vite 7 + React 19 + TS strict + Tailwind 4 (plugin vite) + react-router 7 + TanStack Query 5, proxy dev `/api`→8787, `index.html` + entry + App (routes `/` et `/login` placeholders) + `tests/setup.ts` (jsdom + jest-dom 7.0.1 — 6.10 est déprécié/broken, peer `@testing-library/dom` 10.x déclaré). Vérif : tsc ✅, `vite build` ✅ (CSS TW émis), `vitest run` passWithNoTests ✅, biome ✅, dev server :5199 → HTTP 200. |
|
||||||
|
|
||||||
|
## 11. État du repo — inventaire détaillé (2026-08-23)
|
||||||
|
|
||||||
|
**Faits vérifiés :**
|
||||||
|
- `pnpm install` **jamais lancé** : pas de `node_modules`, pas de `pnpm-lock.yaml` → aucun fichier du scaffold n'a été compilé ni exécuté.
|
||||||
|
- Migrations Drizzle **absentes** (`drizzle-kit generate` non lancé) alors que `db/client.ts` les applique au boot.
|
||||||
|
- `server/src/setup.ts` **manquant** alors que `server/package.json` le référence (`pnpm setup`).
|
||||||
|
- Pas de dépôt git.
|
||||||
|
|
||||||
|
**Plan d'exécution détaillé (2026-08-23, validé par Nicolas — petites tâches, doc mise à jour après chacune) :**
|
||||||
|
- [x] A1. `pnpm install` (workspace racine + server)
|
||||||
|
- [x] A2. Migrations Drizzle : `drizzle-kit generate` (0000_heavy_saracen)
|
||||||
|
- [x] A3. `tsc --noEmit` (fixer les erreurs de compilation)
|
||||||
|
- [x] A4. `biome check` (fixer lint/format)
|
||||||
|
- [x] A5. `server/src/setup.ts` (compte admin initial) + smoke test : boot, `/api/v1/health`, login
|
||||||
|
- [x] B1. Tests server : helper DB de test + login ok/échec (`tests/helpers.ts` + `tests/auth.test.ts`, 6 tests)
|
||||||
|
- [x] B2. Tests server : CSRF + error-handler + DB round-trip (+rate limit) — `tests/api.test.ts`, 9 tests ; suite : 26/26
|
||||||
|
- [x] C1. Scaffold `web/` (Vite+React+TS+Tailwind+Router+TanStack Query) — 2026-08-24, 10 fichiers, typecheck/build/biome ✅, dev :200, proxy `/api`→8787
|
||||||
|
- [x] C2. Front : client API + CSRF + page login — 2026-08-24 : `lib/api.ts` (fetch + cookies, `ApiError`, `sessionQueryKey`, login/session/logout) + `LoginPage.tsx` (useMutation, validation miroir serveur, mapping erreurs 401/429/500) ; tsc/build/biome ✅
|
||||||
|
- [x] C3. Front : guard session + dashboard minimal + wiring API — 2026-08-24 : `useSession()` (hook TanStack), `RequireAuth` (pending → spinner, erreur → redirect `/login`), `DashboardPage` réel (user + expiry + logout), QueryClient global (staleTime 30 s, pas de retry 4xx) ; tsc/build/biome ✅
|
||||||
|
- [x] C4. Tests front (smoke : render login, redirect) — 2026-08-24 : `web/tests/smoke.test.tsx` (vitest+jsdom+RTL, fetch stubbé) : rendu formulaire login, redirect non-auth → /login, login → dashboard ; `cleanup()` explicite dans `tests/setup.ts` (auto-cleanup inactif sans `globals`) ; QueryClient factorisé `web/src/lib/queryClient.ts` (partagé main.tsx ↔ tests) ; tsc/build/biome ✅, 3/3 tests
|
||||||
|
- [x] D1. Dockerfile multi-stage (Node 22, user non-root) — 2026-08-24 : `Dockerfile` racine (PAS multi-stage au final : le serveur n'a pas d'étape de build, TS exécuté via tsx ; base `node:22-slim` glibc pour les prébuilds `argon2`) : pnpm workspace frozen-lockfile, front `vite build` servi par @fastify/static (`SEICHE_PUBLIC_DIR=/app/web/dist`), `WORKDIR /app/server` (sinon `node --import tsx` ne résout pas tsx avec le layout pnpm), `USER node`, `SEICHE_HOST=0.0.0.0`, `VOLUME /data`, HEALTHCHECK `/api/health` ; `.dockerignore` ; testé : `docker build` + `run` → health ok, `/` et `/login` en 200
|
||||||
|
- [x] D2. docker-compose.yml + .dockerignore + volume /data — 2026-08-24 : `docker-compose.yml` racine (build local, image `seiche:latest`, port 8787, volume nommé `seiche-data:/data`, `restart: unless-stopped`, aucune variable requise au runtime) ; testé : up → health ok, `seiche.db` créé dans le volume (owner node)
|
||||||
|
- [x] D3. Test prod : build image, up, login via API, persistence volume — 2026-08-24 : image buildée + up compose → `/api/health` ok, front 200 ; persistance : `down` (sans -v) → `up` → db intacte ; `setup` exécuté DANS le conteneur (admin créé dans /data/seiche.db) ; `POST /api/auth/login` → 200 avec session + csrfToken
|
||||||
|
- [x] D4. Script backup SQLite (testé) — 2026-08-24 : `scripts/backup.sh [dir_sortie]` (défaut `backups/`, gitignored) : `VACUUM INTO` dans le conteneur (snapshot cohérent EN LIGNE via `@libsql/client`, le serveur continue de servir) → `docker compose cp` HORS du volume (un backup dans le même volume n'est pas un backup) → nettoyage du tmp → vérif en-tête SQLite + non-vide ; testé : backup de 40K créé, ré-ouvert dans le conteneur et interrogé (`users` → admin présent), /data propre après exécution ; prérequis : conteneur en marche
|
||||||
|
- [ ] E1. README.md (dev, prod, env, backup, sécurité)
|
||||||
|
- [ ] E2. `git init` + commit initial (identité git à fournir par Nicolas)
|
||||||
|
- [ ] E3. Update §8 (Definition of Done) + statut final de ce doc
|
||||||
|
|
||||||
|
**Divergences du scaffold vs doc (à valider ou corriger) :**
|
||||||
|
- Driver SQLite : `@libsql/client` (scaffold) vs `better-sqlite3` (§3).
|
||||||
|
- Service externe : package `server/src/external/` avec adaptateurs (scaffold) vs module unique `clients/external.ts` (§6) — fonctionnellement équivalent, à confirmer.
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git"
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": true,
|
||||||
|
"includes": ["**", "!**/dist", "!**/node_modules", "!**/data", "!**/drizzle/meta"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 100
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"preset": "recommended",
|
||||||
|
"suspicious": {
|
||||||
|
"noConsole": "off"
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double",
|
||||||
|
"semicolons": "always",
|
||||||
|
"trailingCommas": "all"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
name: seiche
|
||||||
|
|
||||||
|
services:
|
||||||
|
seiche:
|
||||||
|
build: .
|
||||||
|
image: seiche:latest
|
||||||
|
ports:
|
||||||
|
- "8787:8787"
|
||||||
|
volumes:
|
||||||
|
- seiche-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
# Variables d'option (toutes ont des valeurs par défaut dans server/src/config.ts) :
|
||||||
|
# SEICHE_EXTERNAL_ADAPTER, SEICHE_EXTERNAL_BASE_URL, SEICHE_EXTERNAL_API_KEY,
|
||||||
|
# SEICHE_PORT, SEICHE_SESSION_TTL_MS, ...
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
seiche-data:
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "seiche",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@11.3.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev:server": "pnpm --filter seiche-server dev",
|
||||||
|
"dev:web": "pnpm --filter seiche-web dev",
|
||||||
|
"build": "pnpm --filter seiche-web build",
|
||||||
|
"test": "pnpm --filter seiche-server test && pnpm --filter seiche-web test",
|
||||||
|
"typecheck": "pnpm --filter seiche-server typecheck && pnpm --filter seiche-web typecheck",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"lint:fix": "biome check --fix .",
|
||||||
|
"setup": "pnpm --filter seiche-server exec tsx src/setup.ts"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2.5.10",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4395
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
packages:
|
||||||
|
- server
|
||||||
|
- web
|
||||||
|
allowBuilds:
|
||||||
|
argon2: true
|
||||||
|
esbuild: true
|
||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Seiche — sauvegarde SQLite (en ligne, cohérente, sans arrêter le serveur).
|
||||||
|
#
|
||||||
|
# Usage :
|
||||||
|
# ./scripts/backup.sh [répertoire_de_sortie] # défaut : ./backups
|
||||||
|
#
|
||||||
|
# Prérequis : le conteneur `seiche` tourne (docker compose up -d).
|
||||||
|
#
|
||||||
|
# Principe :
|
||||||
|
# 1. `VACUUM INTO` dans le conteneur (snapshot cohérent garanti par SQLite,
|
||||||
|
# le serveur continue de servir pendant l'opération),
|
||||||
|
# 2. copie HORS du volume (un backup dans le même volume n'est pas un backup),
|
||||||
|
# 3. nettoyage du fichier temporaire dans le volume.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
OUT_DIR="${1:-backups}"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
TS="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
HOST_FILE="$OUT_DIR/seiche-$TS.db"
|
||||||
|
TMP_IN_CONTAINER="/data/.seiche-backup-tmp.db"
|
||||||
|
|
||||||
|
# 1. Snapshot cohérente dans le conteneur (libsql = dépendance du serveur,
|
||||||
|
# résolu depuis le WORKDIR /app/server ; l'exécution est en CJS stdin,
|
||||||
|
# d'où l'import dynamique).
|
||||||
|
docker compose exec -T seiche node <<'NODE'
|
||||||
|
import("@libsql/client").then(async (m) => {
|
||||||
|
const c = m.createClient({ url: "file:/data/seiche.db" });
|
||||||
|
await c.execute("VACUUM INTO '/data/.seiche-backup-tmp.db'");
|
||||||
|
await c.close();
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
NODE
|
||||||
|
|
||||||
|
# 2. Copie hors du volume.
|
||||||
|
docker compose cp "seiche:$TMP_IN_CONTAINER" "$HOST_FILE"
|
||||||
|
|
||||||
|
# 3. Nettoyage dans le volume.
|
||||||
|
docker compose exec -T seiche rm -f "$TMP_IN_CONTAINER"
|
||||||
|
|
||||||
|
# 4. Vérification minimale : en-tête SQLite + fichier non vide.
|
||||||
|
MAGIC="$(head -c 15 "$HOST_FILE" 2>/dev/null || true)"
|
||||||
|
if [ "$MAGIC" != "SQLite format 3" ] || [ ! -s "$HOST_FILE" ]; then
|
||||||
|
echo "ERREUR : backup invalide ($HOST_FILE), suppression." >&2
|
||||||
|
rm -f "$HOST_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "OK : $HOST_FILE ($(du -h "$HOST_FILE" | cut -f1))"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: "sqlite",
|
||||||
|
schema: "./src/db/schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE `external_cache` (
|
||||||
|
`key` text PRIMARY KEY NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`fetched_at` integer NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `sessions` (
|
||||||
|
`token_hash` text PRIMARY KEY NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`absolute_expiry_at` integer NOT NULL,
|
||||||
|
`last_activity_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`username` text NOT NULL,
|
||||||
|
`password_hash` text NOT NULL,
|
||||||
|
`created_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "c52dc70f-9d5e-4c32-9abd-2d6e25ee3124",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"external_cache": {
|
||||||
|
"name": "external_cache",
|
||||||
|
"columns": {
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"fetched_at": {
|
||||||
|
"name": "fetched_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"sessions": {
|
||||||
|
"name": "sessions",
|
||||||
|
"columns": {
|
||||||
|
"token_hash": {
|
||||||
|
"name": "token_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"absolute_expiry_at": {
|
||||||
|
"name": "absolute_expiry_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_activity_at": {
|
||||||
|
"name": "last_activity_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"sessions_user_id_users_id_fk": {
|
||||||
|
"name": "sessions_user_id_users_id_fk",
|
||||||
|
"tableFrom": "sessions",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1787524356275,
|
||||||
|
"tag": "0000_heavy_saracen",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "seiche-server",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/main.ts",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/main.ts",
|
||||||
|
"start": "node --import tsx src/main.ts",
|
||||||
|
"setup": "tsx src/setup.ts",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/cookie": "^11.1.2",
|
||||||
|
"@fastify/sensible": "^6.0.5",
|
||||||
|
"@fastify/static": "^10.1.3",
|
||||||
|
"@libsql/client": "^0.17.4",
|
||||||
|
"argon2": "^0.45.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
|
"fastify": "^5.12.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.2.0",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
|
"tsx": "^4.23.12",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { RateLimiter } from "./auth/rate-limit";
|
||||||
|
import { buildSessionPlugin } from "./auth/session";
|
||||||
|
import type { Config } from "./config";
|
||||||
|
import type { Database } from "./db/client";
|
||||||
|
import { createExternalService } from "./external/adapters";
|
||||||
|
import { ExternalCache } from "./external/cache";
|
||||||
|
import { buildAuthRoutes } from "./routes/auth";
|
||||||
|
import { buildExternalRoutes } from "./routes/external";
|
||||||
|
import { buildHealthRoutes } from "./routes/health";
|
||||||
|
|
||||||
|
export interface AppDeps {
|
||||||
|
config: Config;
|
||||||
|
db: Database;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildApp(deps: AppDeps): Promise<FastifyInstance> {
|
||||||
|
const { config, db } = deps;
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger: {
|
||||||
|
level: process.env.SEICHE_LOG_LEVEL ?? "info",
|
||||||
|
transport:
|
||||||
|
process.env.NODE_ENV === "production"
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: { translateTime: "HH:MM:ss", ignore: "pid,hostname" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(cookie);
|
||||||
|
buildSessionPlugin(app, { db, config });
|
||||||
|
|
||||||
|
const loginLimiter = new RateLimiter(config.maxLoginAttemptsPerMinute, 60_000);
|
||||||
|
const externalService = createExternalService(config);
|
||||||
|
const externalCache = new ExternalCache(db, config.external.cacheTtlMs);
|
||||||
|
|
||||||
|
buildHealthRoutes(app);
|
||||||
|
buildAuthRoutes(app, { db, config, loginLimiter });
|
||||||
|
buildExternalRoutes(app, { service: externalService, cache: externalCache });
|
||||||
|
|
||||||
|
// Frontend : en production, servir le build Vite (web/dist) si présent.
|
||||||
|
const publicDir = config.publicDir ?? path.resolve(process.cwd(), "../web/dist");
|
||||||
|
if (existsSync(publicDir)) {
|
||||||
|
await app.register(fastifyStatic, {
|
||||||
|
root: publicDir,
|
||||||
|
setHeaders(res) {
|
||||||
|
// Le HTML ne doit jamais être mis en cache (les assets, si, via hash)
|
||||||
|
if (res.getHeader("content-type")?.toString().includes("text/html")) {
|
||||||
|
res.header("cache-control", "no-cache, no-store, must-revalidate");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Fallback SPA : toute route non /api* renvoie index.html
|
||||||
|
app.setNotFoundHandler(async (request, reply) => {
|
||||||
|
if (request.url.startsWith("/api/")) {
|
||||||
|
return reply.code(404).send({ error: "not_found" });
|
||||||
|
}
|
||||||
|
return reply.sendFile("index.html");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ne jamais fuiter les détails internes en production
|
||||||
|
app.setErrorHandler((error, _request, reply) => {
|
||||||
|
const isDev = process.env.NODE_ENV !== "production";
|
||||||
|
const details = error instanceof Error ? error.message : undefined;
|
||||||
|
app.log.error({ err: error }, "erreur interne");
|
||||||
|
reply.code(500).send({
|
||||||
|
error: "internal_error",
|
||||||
|
...(isDev && details ? { details } : {}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import argon2 from "argon2";
|
||||||
|
|
||||||
|
export const ARGON2_OPTIONS = {
|
||||||
|
type: argon2.argon2id,
|
||||||
|
memoryCost: 19_456, // 19 Mo — OWASP "modéré"
|
||||||
|
timeCost: 2,
|
||||||
|
parallelism: 1,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function hashPassword(password: string): Promise<string> {
|
||||||
|
return argon2.hash(password, ARGON2_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await argon2.verify(hash, password);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Rate limiter minimaliste en mémoire (fenêtre glissante).
|
||||||
|
* Adapté à un usage mono-utilisateur local ; pas besoin de persistance.
|
||||||
|
*/
|
||||||
|
export class RateLimiter {
|
||||||
|
private readonly hits = new Map<string, number[]>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly maxHits: number,
|
||||||
|
private readonly windowMs: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Retourne true si la requête est autorisée (et l'enregistre). */
|
||||||
|
hit(key: string, now: Date = new Date()): boolean {
|
||||||
|
const windowStart = now.getTime() - this.windowMs;
|
||||||
|
const timestamps = (this.hits.get(key) ?? []).filter((t) => t > windowStart);
|
||||||
|
if (timestamps.length >= this.maxHits) {
|
||||||
|
this.hits.set(key, timestamps);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
timestamps.push(now.getTime());
|
||||||
|
this.hits.set(key, timestamps);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(key: string): void {
|
||||||
|
this.hits.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre de tentatives restantes pour la clé (0 = bloqué). */
|
||||||
|
remaining(key: string, now: Date = new Date()): number {
|
||||||
|
const windowStart = now.getTime() - this.windowMs;
|
||||||
|
const timestamps = (this.hits.get(key) ?? []).filter((t) => t > windowStart);
|
||||||
|
return Math.max(0, this.maxHits - timestamps.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
import { desc, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import type { Config } from "../config";
|
||||||
|
import type { Database } from "../db/client";
|
||||||
|
import { sessions, users } from "../db/schema";
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = "seiche_session";
|
||||||
|
export const CSRF_COOKIE = "seiche_csrf";
|
||||||
|
export const CSRF_HEADER = "x-csrf-token";
|
||||||
|
|
||||||
|
export interface SessionContext {
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
sessionExpiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||||
|
|
||||||
|
function sha256Hex(value: string): string {
|
||||||
|
return createHash("sha256").update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEqual(a: string, b: string): boolean {
|
||||||
|
const bufA = Buffer.from(a, "utf8");
|
||||||
|
const bufB = Buffer.from(b, "utf8");
|
||||||
|
return bufA.length === bufB.length && timingSafeEqual(bufA, bufB);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionManager {
|
||||||
|
constructor(
|
||||||
|
private readonly db: Database,
|
||||||
|
private readonly config: Config,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Crée une session (token 256 bits) + un token CSRF (256 bits). */
|
||||||
|
async create(userId: number): Promise<{ token: string; csrfToken: string }> {
|
||||||
|
const now = new Date();
|
||||||
|
const token = randomBytes(32).toString("hex");
|
||||||
|
const csrfToken = randomBytes(32).toString("hex");
|
||||||
|
await this.db.insert(sessions).values({
|
||||||
|
tokenHash: sha256Hex(token),
|
||||||
|
userId,
|
||||||
|
expiresAt: new Date(now.getTime() + this.config.sessionTtlMs),
|
||||||
|
absoluteExpiryAt: new Date(now.getTime() + this.config.sessionAbsoluteTtlMs),
|
||||||
|
lastActivityAt: now,
|
||||||
|
});
|
||||||
|
await this.enforceSessionLimit(userId);
|
||||||
|
return { token, csrfToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retourne la session valide pour le token, met à jour le TTL glissant. */
|
||||||
|
async getValid(token: string | undefined): Promise<SessionContext | null> {
|
||||||
|
if (!token) return null;
|
||||||
|
const now = new Date();
|
||||||
|
const tokenHash = sha256Hex(token);
|
||||||
|
const rows = await this.db
|
||||||
|
.select({ session: sessions, user: users })
|
||||||
|
.from(sessions)
|
||||||
|
.innerJoin(users, eq(sessions.userId, users.id))
|
||||||
|
.where(eq(sessions.tokenHash, tokenHash))
|
||||||
|
.limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
const s = row.session;
|
||||||
|
if (now >= s.absoluteExpiryAt || now >= s.expiresAt) {
|
||||||
|
await this.db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const slidingExpiry = new Date(now.getTime() + this.config.sessionTtlMs);
|
||||||
|
const effectiveExpiry =
|
||||||
|
slidingExpiry <= s.absoluteExpiryAt ? slidingExpiry : s.absoluteExpiryAt;
|
||||||
|
await this.db
|
||||||
|
.update(sessions)
|
||||||
|
.set({ expiresAt: effectiveExpiry, lastActivityAt: now })
|
||||||
|
.where(eq(sessions.tokenHash, tokenHash));
|
||||||
|
return {
|
||||||
|
userId: row.user.id,
|
||||||
|
username: row.user.username,
|
||||||
|
sessionExpiresAt: effectiveExpiry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(token: string | undefined): Promise<void> {
|
||||||
|
if (!token) return;
|
||||||
|
await this.db.delete(sessions).where(eq(sessions.tokenHash, sha256Hex(token)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Garde un maximum de 5 sessions actives par utilisateur. */
|
||||||
|
private async enforceSessionLimit(userId: number): Promise<void> {
|
||||||
|
const all = await this.db
|
||||||
|
.select({ tokenHash: sessions.tokenHash, createdAt: sessions.createdAt })
|
||||||
|
.from(sessions)
|
||||||
|
.where(eq(sessions.userId, userId))
|
||||||
|
.orderBy(desc(sessions.createdAt), desc(sessions.tokenHash));
|
||||||
|
if (all.length <= 5) return;
|
||||||
|
const toRemove = all.slice(5).map((s) => s.tokenHash);
|
||||||
|
if (toRemove.length > 0) {
|
||||||
|
await this.db.delete(sessions).where(inArray(sessions.tokenHash, toRemove));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
session: SessionContext | null;
|
||||||
|
}
|
||||||
|
interface FastifyInstance {
|
||||||
|
sessionManager: SessionManager;
|
||||||
|
authenticate(request: FastifyRequest, reply: FastifyReply): Promise<void>;
|
||||||
|
requireCsrf(request: FastifyRequest, reply: FastifyReply): Promise<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin sessions :
|
||||||
|
* - décore `request.session` (résilée à chaque requête /api/*)
|
||||||
|
* - expose `app.authenticate` et `app.requireCsrf` (pre-handlers)
|
||||||
|
*
|
||||||
|
* À enregistrer APRÈS @fastify/cookie (le parsing des cookies doit précéder).
|
||||||
|
*/
|
||||||
|
export function buildSessionPlugin(
|
||||||
|
app: FastifyInstance,
|
||||||
|
deps: { db: Database; config: Config },
|
||||||
|
): void {
|
||||||
|
const manager = new SessionManager(deps.db, deps.config);
|
||||||
|
|
||||||
|
app.decorateRequest("session", null);
|
||||||
|
app.decorate("sessionManager", manager);
|
||||||
|
|
||||||
|
app.addHook("onRequest", async (request: FastifyRequest) => {
|
||||||
|
if (!request.url.startsWith("/api/")) return;
|
||||||
|
request.session = await manager.getValid(request.cookies[SESSION_COOKIE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorate("authenticate", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
if (!request.session) {
|
||||||
|
reply.code(401).send({ error: "not_authenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.decorate("requireCsrf", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
if (SAFE_METHODS.has(request.method)) return;
|
||||||
|
const cookie = request.cookies[CSRF_COOKIE];
|
||||||
|
const header = request.headers[CSRF_HEADER];
|
||||||
|
if (!cookie || typeof header !== "string" || header.length === 0) {
|
||||||
|
reply.code(403).send({ error: "csrf_invalid" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!safeEqual(cookie, header)) {
|
||||||
|
reply.code(403).send({ error: "csrf_invalid" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CSRF_COOKIE as csrfCookieName, SESSION_COOKIE as sessionCookieName };
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { config as dotenvConfig } from "dotenv";
|
||||||
|
|
||||||
|
// Chargement du .env : d'abord le .env du paquet serveur, puis celui de la racine du repo.
|
||||||
|
// Les variables déjà présentes dans l'environnement ne sont jamais écrasées.
|
||||||
|
dotenvConfig({ path: [".env", "../.env"] });
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
port: number;
|
||||||
|
host: string;
|
||||||
|
dataDir: string;
|
||||||
|
sessionTtlMs: number;
|
||||||
|
sessionAbsoluteTtlMs: number;
|
||||||
|
maxLoginAttemptsPerMinute: number;
|
||||||
|
publicDir: string | null;
|
||||||
|
external: {
|
||||||
|
adapter: "example" | "http";
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
healthPath: string;
|
||||||
|
listPath: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
retries: number;
|
||||||
|
cacheTtlMs: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function envStr(name: string, fallback: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
return value === undefined || value === "" ? fallback : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function envInt(name: string, fallback: number): number {
|
||||||
|
const raw = process.env[name];
|
||||||
|
if (raw === undefined || raw === "") return fallback;
|
||||||
|
const value = Number.parseInt(raw, 10);
|
||||||
|
if (Number.isNaN(value) || value <= 0) {
|
||||||
|
throw new Error(`Variable d'environnement ${name} invalide : ${raw} (entier positif attendu)`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): Config {
|
||||||
|
const dataDir = path.resolve(process.cwd(), envStr("SEICHE_DATA_DIR", "./data"));
|
||||||
|
return {
|
||||||
|
port: envInt("SEICHE_PORT", 8787),
|
||||||
|
host: envStr("SEICHE_HOST", "127.0.0.1"),
|
||||||
|
dataDir,
|
||||||
|
sessionTtlMs: envInt("SEICHE_SESSION_TTL_MS", 8 * 60 * 60 * 1000),
|
||||||
|
sessionAbsoluteTtlMs: envInt("SEICHE_SESSION_ABSOLUTE_TTL_MS", 7 * 24 * 60 * 60 * 1000),
|
||||||
|
maxLoginAttemptsPerMinute: envInt("SEICHE_MAX_LOGIN_ATTEMPTS_PER_MINUTE", 5),
|
||||||
|
publicDir: process.env.SEICHE_PUBLIC_DIR ?? null,
|
||||||
|
external: {
|
||||||
|
adapter: (process.env.SEICHE_EXTERNAL_ADAPTER ?? "example") as "example" | "http",
|
||||||
|
baseUrl: envStr("SEICHE_EXTERNAL_BASE_URL", ""),
|
||||||
|
apiKey: envStr("SEICHE_EXTERNAL_API_KEY", ""),
|
||||||
|
healthPath: envStr("SEICHE_EXTERNAL_HEALTH_PATH", "/health"),
|
||||||
|
listPath: envStr("SEICHE_EXTERNAL_LIST_PATH", "/items"),
|
||||||
|
timeoutMs: envInt("SEICHE_EXTERNAL_TIMEOUT_MS", 10_000),
|
||||||
|
retries: envInt("SEICHE_EXTERNAL_RETRIES", 2),
|
||||||
|
cacheTtlMs: envInt("SEICHE_EXTERNAL_CACHE_TTL_MS", 30_000),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { type Client, createClient } from "@libsql/client";
|
||||||
|
import { drizzle, type LibSQLDatabase } from "drizzle-orm/libsql";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import * as schema from "./schema";
|
||||||
|
|
||||||
|
export type Database = LibSQLDatabase<typeof schema>;
|
||||||
|
|
||||||
|
export function createDbClient(url: string): Client {
|
||||||
|
return createClient({ url });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDb(client: Client): Database {
|
||||||
|
return drizzle(client, { schema });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applique les migrations SQL (idempotent, journal géré par Drizzle). */
|
||||||
|
export async function runMigrations(db: Database, migrationsFolder: string): Promise<void> {
|
||||||
|
await migrate(db, { migrationsFolder });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ouvre (ou crée) la base SQLite au format fichier. */
|
||||||
|
export async function openDatabase(dataDir: string, migrationsFolder: string): Promise<Database> {
|
||||||
|
const client = createDbClient(path.join("file:", dataDir, "seiche.db"));
|
||||||
|
const db = createDb(client);
|
||||||
|
await runMigrations(db, migrationsFolder);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ouvre une base SQLite en mémoire (tests). */
|
||||||
|
export async function openInMemoryDatabase(migrationsFolder: string): Promise<Database> {
|
||||||
|
const client = createDbClient("file::memory:");
|
||||||
|
const db = createDb(client);
|
||||||
|
await runMigrations(db, migrationsFolder);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const users = sqliteTable("users", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
username: text("username").notNull().unique(),
|
||||||
|
passwordHash: text("password_hash").notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session serveur : le token de session (valeur du cookie) n'est JAMAIS stocké
|
||||||
|
* en clair — uniquement son hash SHA-256. Une fuite de la base ne permet donc
|
||||||
|
* pas de usurper une session.
|
||||||
|
*/
|
||||||
|
export const sessions = sqliteTable("sessions", {
|
||||||
|
/** SHA-256 hex du token de session (valeur du cookie `seiche_session`) */
|
||||||
|
tokenHash: text("token_hash").primaryKey(),
|
||||||
|
userId: integer("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date()),
|
||||||
|
/** Fin de la fenêtre glissante (lastActivityAt + TTL) */
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||||
|
/** Expiration absolue (createdAt + TTL absolu), quel que soit l'activité */
|
||||||
|
absoluteExpiryAt: integer("absolute_expiry_at", { mode: "timestamp" }).notNull(),
|
||||||
|
lastActivityAt: integer("last_activity_at", { mode: "timestamp" })
|
||||||
|
.notNull()
|
||||||
|
.$defaultFn(() => new Date()),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Cache des réponses du service REST externe (clé → JSON) */
|
||||||
|
export const externalCache = sqliteTable("external_cache", {
|
||||||
|
key: text("key").primaryKey(),
|
||||||
|
value: text("value", { mode: "json" }).notNull(),
|
||||||
|
fetchedAt: integer("fetched_at", { mode: "timestamp" }).notNull(),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type User = typeof users.$inferSelect;
|
||||||
|
export type Session = typeof sessions.$inferSelect;
|
||||||
|
export type ExternalCacheRow = typeof externalCache.$inferSelect;
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
import type { ExternalHealth, ExternalItem, ExternalListResult, ExternalService } from "../types";
|
||||||
|
|
||||||
|
const DEMO_ITEMS: ExternalItem[] = [
|
||||||
|
{
|
||||||
|
id: "demo-1",
|
||||||
|
title: "Exemple : tâche en cours",
|
||||||
|
subtitle: "Ceci est un élément de démonstration de l'adaptateur « example ».",
|
||||||
|
status: "running",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo-2",
|
||||||
|
title: "Exemple : tâche terminée",
|
||||||
|
subtitle: "Rajoutez ici le détail de ce que votre service REST gère.",
|
||||||
|
status: "done",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "demo-3",
|
||||||
|
title: "Exemple : tâche en erreur",
|
||||||
|
subtitle: "Le pipeline complet (API → cache → UI) fonctionne avec cet adaptateur.",
|
||||||
|
status: "error",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adaptateur de démonstration : ne contacte aucun service réel.
|
||||||
|
* Permet de valider tout le pipeline (auth → API → cache → UI) avant
|
||||||
|
* d'intégrer le service REST cible. À remplacer/configurer via
|
||||||
|
* SEICHE_EXTERNAL_ADAPTER=http + variables SEICHE_EXTERNAL_*.
|
||||||
|
*/
|
||||||
|
export class ExampleExternalService implements ExternalService {
|
||||||
|
readonly name = "example";
|
||||||
|
|
||||||
|
async health(): Promise<ExternalHealth> {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
message: "adaptateur de démonstration",
|
||||||
|
latencyMs: 0,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<ExternalListResult> {
|
||||||
|
return { items: DEMO_ITEMS, fetchedAt: new Date().toISOString(), fromCache: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+104
@@ -0,0 +1,104 @@
|
|||||||
|
import { HttpClient } from "../http-client";
|
||||||
|
import type { ExternalHealth, ExternalItem, ExternalListResult, ExternalService } from "../types";
|
||||||
|
|
||||||
|
export interface HttpExternalServiceOptions {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
healthPath: string;
|
||||||
|
listPath: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
retries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adaptateur générique vers une API REST HTTP/HTTPS.
|
||||||
|
*
|
||||||
|
* Convention de réponse pour /items (tolérante) :
|
||||||
|
* - un tableau JSON, ou un objet contenant une clé items/data/results (tableau)
|
||||||
|
* - chaque élément : id|uuid|slug, title|name|label, description|summary, status|state
|
||||||
|
*
|
||||||
|
* À ajuster/étendre quand les détails du service cible sont connus
|
||||||
|
* (voir docs/service-adapter.md).
|
||||||
|
*/
|
||||||
|
export class HttpExternalService implements ExternalService {
|
||||||
|
readonly name = "http";
|
||||||
|
private readonly client: HttpClient;
|
||||||
|
private readonly options: HttpExternalServiceOptions;
|
||||||
|
|
||||||
|
constructor(options: HttpExternalServiceOptions) {
|
||||||
|
this.options = options;
|
||||||
|
this.client = new HttpClient(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(): Promise<ExternalHealth> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await this.client.getJson(this.options.healthPath);
|
||||||
|
const body = isRecord(res.body) ? res.body : undefined;
|
||||||
|
return {
|
||||||
|
ok: res.status >= 200 && res.status < 300,
|
||||||
|
status: res.status,
|
||||||
|
message:
|
||||||
|
(body && typeof body.status === "string" ? body.status : undefined) ??
|
||||||
|
(body && typeof body.message === "string" ? body.message : undefined),
|
||||||
|
latencyMs: res.latencyMs,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 0,
|
||||||
|
message: err instanceof Error ? err.message : "erreur inconnue",
|
||||||
|
latencyMs: Date.now() - startedAt,
|
||||||
|
checkedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<ExternalListResult> {
|
||||||
|
const res = await this.client.getJson(this.options.listPath);
|
||||||
|
if (res.status < 200 || res.status >= 300) {
|
||||||
|
throw new Error(`Le service externe a répondu HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
const items = normalizeItems(res.body);
|
||||||
|
return { items, fetchedAt: new Date().toISOString(), fromCache: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeItems(body: unknown): ExternalItem[] {
|
||||||
|
let array: unknown[] | null = null;
|
||||||
|
if (Array.isArray(body)) {
|
||||||
|
array = body;
|
||||||
|
} else if (isRecord(body)) {
|
||||||
|
for (const key of ["items", "data", "results", "entries"]) {
|
||||||
|
const value = body[key];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
array = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (array === null) {
|
||||||
|
throw new Error("Réponse externe inattendue : tableau JSON attendu");
|
||||||
|
}
|
||||||
|
return array.map((element, index) => {
|
||||||
|
if (!isRecord(element)) {
|
||||||
|
return { id: String(index), title: String(element), raw: element };
|
||||||
|
}
|
||||||
|
const id = element.id ?? element.uuid ?? element.slug ?? element.key ?? String(index);
|
||||||
|
const title = element.title ?? element.name ?? element.label ?? element.id ?? String(index);
|
||||||
|
const subtitle = element.description ?? element.summary ?? element.subtitle;
|
||||||
|
const status = element.status ?? element.state;
|
||||||
|
return {
|
||||||
|
id: String(id),
|
||||||
|
title: String(title),
|
||||||
|
subtitle: subtitle !== undefined ? String(subtitle) : undefined,
|
||||||
|
status: status !== undefined ? String(status) : undefined,
|
||||||
|
raw: element,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Config } from "../../config";
|
||||||
|
import type { ExternalService } from "../types";
|
||||||
|
import { ExampleExternalService } from "./example";
|
||||||
|
import { HttpExternalService, type HttpExternalServiceOptions } from "./http";
|
||||||
|
|
||||||
|
/** Fabrique : instancie l'adaptateur configuré (SEICHE_EXTERNAL_ADAPTER). */
|
||||||
|
export function createExternalService(config: Config): ExternalService {
|
||||||
|
switch (config.external.adapter) {
|
||||||
|
case "http": {
|
||||||
|
if (config.external.baseUrl === "") {
|
||||||
|
throw new Error("SEICHE_EXTERNAL_BASE_URL est requis quand SEICHE_EXTERNAL_ADAPTER=http");
|
||||||
|
}
|
||||||
|
const options: HttpExternalServiceOptions = {
|
||||||
|
baseUrl: config.external.baseUrl,
|
||||||
|
apiKey: config.external.apiKey,
|
||||||
|
healthPath: config.external.healthPath,
|
||||||
|
listPath: config.external.listPath,
|
||||||
|
timeoutMs: config.external.timeoutMs,
|
||||||
|
retries: config.external.retries,
|
||||||
|
};
|
||||||
|
return new HttpExternalService(options);
|
||||||
|
}
|
||||||
|
case "example":
|
||||||
|
return new ExampleExternalService();
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = config.external.adapter;
|
||||||
|
throw new Error(`Adaptateur externe inconnu : ${String(exhaustive)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+43
@@ -0,0 +1,43 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { Database } from "../db/client";
|
||||||
|
import { externalCache } from "../db/schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache serveur (SQLite) des réponses du service externe.
|
||||||
|
* Évite de solliciter le service à chaque interaction UI et donne une
|
||||||
|
* date de fraîcheur cohérente.
|
||||||
|
*/
|
||||||
|
export class ExternalCache {
|
||||||
|
constructor(
|
||||||
|
private readonly db: Database,
|
||||||
|
private readonly ttlMs: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(externalCache)
|
||||||
|
.where(eq(externalCache.key, key))
|
||||||
|
.limit(1);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
if (new Date() >= row.expiresAt) {
|
||||||
|
await this.db.delete(externalCache).where(eq(externalCache.key, key));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return row.value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(key: string, value: unknown): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
const expiresAt = new Date(now.getTime() + this.ttlMs);
|
||||||
|
await this.db
|
||||||
|
.insert(externalCache)
|
||||||
|
.values({ key, value, fetchedAt: now, expiresAt })
|
||||||
|
.onConflictDoUpdate({ target: externalCache.key, set: { value, fetchedAt: now, expiresAt } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidate(key: string): Promise<void> {
|
||||||
|
await this.db.delete(externalCache).where(eq(externalCache.key, key));
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+76
@@ -0,0 +1,76 @@
|
|||||||
|
export interface HttpClientOptions {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
retries: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HttpClient {
|
||||||
|
constructor(private readonly options: HttpClientOptions) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET avec timeout et retries (backoff exponentiel + jitter) sur
|
||||||
|
* erreurs réseau / 5xx. Le body est renvoyé tel quel (JSON parsé
|
||||||
|
* à la main par l'appelant si nécessaire).
|
||||||
|
*/
|
||||||
|
async getJson(
|
||||||
|
path: string,
|
||||||
|
headers: Record<string, string> = {},
|
||||||
|
): Promise<{ status: number; body: unknown; latencyMs: number }> {
|
||||||
|
const url = new URL(path, this.options.baseUrl);
|
||||||
|
const allHeaders: Record<string, string> = { accept: "application/json", ...headers };
|
||||||
|
if (this.options.apiKey !== "") {
|
||||||
|
allHeaders.authorization = `Bearer ${this.options.apiKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastError: unknown = null;
|
||||||
|
for (let attempt = 0; attempt <= this.options.retries; attempt++) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: allHeaders,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const latencyMs = Date.now() - startedAt;
|
||||||
|
const text = await res.text();
|
||||||
|
let body: unknown = null;
|
||||||
|
if (text !== "") {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
body = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// On ne retente que sur les erreurs transitoires.
|
||||||
|
if (res.status >= 500) {
|
||||||
|
lastError = new Error(`HTTP ${res.status} de ${url.toString()}`);
|
||||||
|
if (attempt < this.options.retries) {
|
||||||
|
await this.backoff(attempt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return { status: res.status, body, latencyMs };
|
||||||
|
}
|
||||||
|
return { status: res.status, body, latencyMs };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
if (attempt < this.options.retries) {
|
||||||
|
await this.backoff(attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error("Échec de la requête externe");
|
||||||
|
}
|
||||||
|
|
||||||
|
private backoff(attempt: number): Promise<void> {
|
||||||
|
const base = 300 * 2 ** attempt;
|
||||||
|
const delay = base + Math.floor(Math.random() * 100);
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+32
@@ -0,0 +1,32 @@
|
|||||||
|
export interface ExternalHealth {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
message?: string;
|
||||||
|
latencyMs: number;
|
||||||
|
checkedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
status?: string;
|
||||||
|
/** Payload brut du service (affiché dans l'UI, utile pour le débogage) */
|
||||||
|
raw?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalListResult {
|
||||||
|
items: ExternalItem[];
|
||||||
|
fetchedAt: string;
|
||||||
|
fromCache: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat d'un adaptateur vers le service REST externe.
|
||||||
|
* Chaque implémentation est auto-suffisante et ne connaît que sa config.
|
||||||
|
*/
|
||||||
|
export interface ExternalService {
|
||||||
|
readonly name: string;
|
||||||
|
health(): Promise<ExternalHealth>;
|
||||||
|
list(): Promise<ExternalListResult>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { buildApp } from "./app";
|
||||||
|
import { loadConfig } from "./config";
|
||||||
|
import { openDatabase } from "./db/client";
|
||||||
|
|
||||||
|
const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, "../drizzle");
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const config = loadConfig();
|
||||||
|
mkdirSync(config.dataDir, { recursive: true });
|
||||||
|
|
||||||
|
const db = await openDatabase(config.dataDir, MIGRATIONS_FOLDER);
|
||||||
|
const app = await buildApp({ config, db });
|
||||||
|
|
||||||
|
app.addHook("onClose", async () => {
|
||||||
|
// libsql : pas de close explicite requis, on garde la connexion vivante
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ port: config.port, host: config.host });
|
||||||
|
console.log(`Seiche démarre sur http://${config.host}:${config.port}`);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shutdown = async (signal: string) => {
|
||||||
|
app.log.info(`${signal} reçu, arrêt en cours…`);
|
||||||
|
await app.close();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||||
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Échec du démarrage de Seiche :", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { verifyPassword } from "../auth/password";
|
||||||
|
import type { RateLimiter } from "../auth/rate-limit";
|
||||||
|
import { CSRF_COOKIE, CSRF_HEADER, SESSION_COOKIE } from "../auth/session";
|
||||||
|
import type { Config } from "../config";
|
||||||
|
import type { Database } from "../db/client";
|
||||||
|
import { users } from "../db/schema";
|
||||||
|
import { parseLoginBody } from "../validation";
|
||||||
|
|
||||||
|
interface AuthDeps {
|
||||||
|
db: Database;
|
||||||
|
config: Config;
|
||||||
|
loginLimiter: RateLimiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAuthRoutes(app: FastifyInstance, deps: AuthDeps): void {
|
||||||
|
const { db, config, loginLimiter } = deps;
|
||||||
|
|
||||||
|
app.post("/api/auth/login", async (request, reply) => {
|
||||||
|
const parsed = parseLoginBody(request.body);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
return reply.code(400).send({ error: "invalid_body" });
|
||||||
|
}
|
||||||
|
const { username, password } = parsed.data;
|
||||||
|
|
||||||
|
// Rate limit par IP (réinitialisé à succès)
|
||||||
|
const ip = request.ip;
|
||||||
|
if (!loginLimiter.hit(ip)) {
|
||||||
|
return reply.code(429).send({ error: "too_many_attempts", retryAfterMs: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db.select().from(users).where(eq(users.username, username)).limit(1);
|
||||||
|
const user = rows[0];
|
||||||
|
|
||||||
|
// Même message d'erreur que l'échec de vérification (pas d'oracle utilisateur)
|
||||||
|
const genericFailure = () => reply.code(401).send({ error: "invalid_credentials" });
|
||||||
|
if (!user) {
|
||||||
|
// On exécute quand même une vérification argon2 pour homogénéiser le temps de réponse.
|
||||||
|
await verifyPassword(
|
||||||
|
"$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
password,
|
||||||
|
).catch(() => false);
|
||||||
|
return genericFailure();
|
||||||
|
}
|
||||||
|
const ok = await verifyPassword(user.passwordHash, password);
|
||||||
|
if (!ok) return genericFailure();
|
||||||
|
|
||||||
|
loginLimiter.reset(ip);
|
||||||
|
const { token, csrfToken } = await app.sessionManager.create(user.id);
|
||||||
|
const cookieOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax" as const,
|
||||||
|
path: "/",
|
||||||
|
secure: false, // TLS géré par Caddy/proxy devant (voir README)
|
||||||
|
};
|
||||||
|
reply.setCookie(SESSION_COOKIE, token, cookieOptions).setCookie(CSRF_COOKIE, csrfToken, {
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
|
secure: false,
|
||||||
|
});
|
||||||
|
return reply.send({
|
||||||
|
user: { username: user.username },
|
||||||
|
csrfToken,
|
||||||
|
csrfHeader: CSRF_HEADER,
|
||||||
|
sessionExpiresAt: new Date(Date.now() + config.sessionTtlMs).toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/auth/logout",
|
||||||
|
{ preHandler: [app.authenticate, app.requireCsrf] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const token = request.cookies[SESSION_COOKIE];
|
||||||
|
await app.sessionManager.destroy(token);
|
||||||
|
const clearOptions = { path: "/", sameSite: "lax" as const, secure: false };
|
||||||
|
reply
|
||||||
|
.setCookie(SESSION_COOKIE, "", { ...clearOptions, httpOnly: true, maxAge: 0 })
|
||||||
|
.setCookie(CSRF_COOKIE, "", { ...clearOptions, httpOnly: false, maxAge: 0 });
|
||||||
|
return reply.send({ ok: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get("/api/auth/session", async (request, reply) => {
|
||||||
|
if (!request.session) {
|
||||||
|
return reply.code(401).send({ error: "not_authenticated" });
|
||||||
|
}
|
||||||
|
return reply.send({
|
||||||
|
user: { username: request.session.username },
|
||||||
|
// Double-submit : le token CSRF est la valeur du cookie (lisible par le JS),
|
||||||
|
// qu'on renvoie dans l'en-tête x-csrf-token (vérifié par requireCsrf).
|
||||||
|
csrfToken: request.cookies[CSRF_COOKIE] ?? null,
|
||||||
|
csrfHeader: CSRF_HEADER,
|
||||||
|
sessionExpiresAt: request.session.sessionExpiresAt.toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { ExternalCache } from "../external/cache";
|
||||||
|
import type { ExternalListResult, ExternalService } from "../external/types";
|
||||||
|
|
||||||
|
interface ExternalRoutesDeps {
|
||||||
|
service: ExternalService;
|
||||||
|
cache: ExternalCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheKey(serviceName: string, op: string): string {
|
||||||
|
return `${serviceName}:${op}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExternalRoutes(app: FastifyInstance, deps: ExternalRoutesDeps): void {
|
||||||
|
const { service, cache } = deps;
|
||||||
|
const keyItems = cacheKey(service.name, "items");
|
||||||
|
|
||||||
|
app.get("/api/external/health", { preHandler: [app.authenticate] }, async (_request, reply) => {
|
||||||
|
const health = await service.health();
|
||||||
|
return reply.send(health);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/external/items", { preHandler: [app.authenticate] }, async (_request, reply) => {
|
||||||
|
const cached = await cache.get<ExternalListResult>(keyItems);
|
||||||
|
if (cached) {
|
||||||
|
return reply.send({ ...cached, fromCache: true });
|
||||||
|
}
|
||||||
|
const result = await service.list();
|
||||||
|
await cache.set(keyItems, result);
|
||||||
|
return reply.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
"/api/external/items/refresh",
|
||||||
|
{ preHandler: [app.authenticate, app.requireCsrf] },
|
||||||
|
async (_request, reply) => {
|
||||||
|
await cache.invalidate(keyItems);
|
||||||
|
const result = await service.list();
|
||||||
|
await cache.set(keyItems, result);
|
||||||
|
return reply.send({ ...result, fromCache: false });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
export function buildHealthRoutes(app: FastifyInstance): void {
|
||||||
|
app.get("/api/health", async (_request, reply) => {
|
||||||
|
return reply.send({ status: "ok", uptimeSec: Math.round(process.uptime()) });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { hashPassword } from "./auth/password";
|
||||||
|
import { loadConfig } from "./config";
|
||||||
|
import { openDatabase } from "./db/client";
|
||||||
|
import { users } from "./db/schema";
|
||||||
|
|
||||||
|
const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, "../drizzle");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée le compte initial (admin) dans une base vide.
|
||||||
|
* Idempotent : si des comptes existent déjà, rien n'est modifié.
|
||||||
|
* Variables requises (voir .env.example) : SEICHE_ADMIN_USERNAME, SEICHE_ADMIN_PASSWORD.
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const config = loadConfig();
|
||||||
|
mkdirSync(config.dataDir, { recursive: true });
|
||||||
|
const db = await openDatabase(config.dataDir, MIGRATIONS_FOLDER);
|
||||||
|
|
||||||
|
const [row] = await db.select({ count: sql<number>`count(*)` }).from(users);
|
||||||
|
const existing = row?.count ?? 0;
|
||||||
|
if (existing > 0) {
|
||||||
|
console.log(
|
||||||
|
`Base ${config.dataDir}/seiche.db : ${existing} compte(s) déjà présent(s), rien à faire.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = process.env.SEICHE_ADMIN_USERNAME;
|
||||||
|
const password = process.env.SEICHE_ADMIN_PASSWORD;
|
||||||
|
if (!username || !password) {
|
||||||
|
throw new Error(
|
||||||
|
"Base vide : définir SEICHE_ADMIN_USERNAME et SEICHE_ADMIN_PASSWORD (voir .env.example) puis relancer pnpm setup.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (password.length < 12) {
|
||||||
|
throw new Error("SEICHE_ADMIN_PASSWORD doit faire au moins 12 caractères.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
await db.insert(users).values({ username, passwordHash });
|
||||||
|
console.log(`Compte initial « ${username} » créé (base : ${config.dataDir}/seiche.db).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(
|
||||||
|
"Échec de la création du compte initial :",
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** Validation minimaliste des corps de requête (sans dépendance externe). */
|
||||||
|
|
||||||
|
export interface LoginBody {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLoginBody(body: unknown): { ok: true; data: LoginBody } | { ok: false } {
|
||||||
|
if (typeof body !== "object" || body === null) return { ok: false };
|
||||||
|
const record = body as Record<string, unknown>;
|
||||||
|
const username = record.username;
|
||||||
|
const password = record.password;
|
||||||
|
if (
|
||||||
|
typeof username !== "string" ||
|
||||||
|
typeof password !== "string" ||
|
||||||
|
username.length === 0 ||
|
||||||
|
username.length > 64 ||
|
||||||
|
password.length === 0 ||
|
||||||
|
password.length > 256
|
||||||
|
) {
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true, data: { username, password } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { buildApp } from "../src/app";
|
||||||
|
import { hashPassword } from "../src/auth/password";
|
||||||
|
import { CSRF_COOKIE, CSRF_HEADER, SESSION_COOKIE } from "../src/auth/session";
|
||||||
|
import type { Database } from "../src/db/client";
|
||||||
|
import { users } from "../src/db/schema";
|
||||||
|
import { makeConfig, openTestDb } from "./helpers";
|
||||||
|
|
||||||
|
// Tests : pas de worker pino-pretty, error-handler en mode production.
|
||||||
|
process.env.NODE_ENV = "production";
|
||||||
|
process.env.SEICHE_LOG_LEVEL = "silent";
|
||||||
|
|
||||||
|
let db: Database;
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
db = await openTestDb();
|
||||||
|
app = await buildApp({ config: makeConfig(), db });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function asSetCookieList(raw: unknown): string[] {
|
||||||
|
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string");
|
||||||
|
if (raw === undefined) return [];
|
||||||
|
return [String(raw)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieValue(raw: unknown, name: string): string | undefined {
|
||||||
|
for (const item of asSetCookieList(raw)) {
|
||||||
|
const pair = item.split(";")[0] ?? "";
|
||||||
|
if (pair.startsWith(`${name}=`)) return pair.slice(name.length + 1);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieHeader(raw: unknown, name: string): string | undefined {
|
||||||
|
return asSetCookieList(raw).find((item) => item.startsWith(`${name}=`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedUser(username: string, password: string): Promise<void> {
|
||||||
|
await db.insert(users).values({ username, passwordHash: await hashPassword(password) });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("API /api (buildApp)", () => {
|
||||||
|
it("GET /api/health → 200 ok", async () => {
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/health" });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ status: "ok", uptimeSec: expect.any(Number) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/auth/login : succès → 200, cookies httpOnly + csrfToken", async () => {
|
||||||
|
await seedUser("alice", "correct-horse-battery");
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
payload: { username: "alice", password: "correct-horse-battery" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as {
|
||||||
|
user: { username: string };
|
||||||
|
csrfToken: string;
|
||||||
|
csrfHeader: string;
|
||||||
|
};
|
||||||
|
expect(body.user.username).toBe("alice");
|
||||||
|
expect(body.csrfToken).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(body.csrfHeader).toBe(CSRF_HEADER);
|
||||||
|
const raw = res.headers["set-cookie"];
|
||||||
|
expect(cookieValue(raw, SESSION_COOKIE)).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(cookieValue(raw, CSRF_COOKIE)).toBe(body.csrfToken);
|
||||||
|
expect(cookieHeader(raw, SESSION_COOKIE)).toContain("HttpOnly");
|
||||||
|
expect(cookieHeader(raw, SESSION_COOKIE)).toContain("SameSite=Lax");
|
||||||
|
expect(cookieHeader(raw, CSRF_COOKIE)).not.toContain("HttpOnly");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/auth/login : mauvais mdp ou user inconnu → 401 identique", async () => {
|
||||||
|
await seedUser("alice", "correct-horse-battery");
|
||||||
|
const wrong = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
payload: { username: "alice", password: "nope" },
|
||||||
|
});
|
||||||
|
expect(wrong.statusCode).toBe(401);
|
||||||
|
expect(wrong.json()).toEqual({ error: "invalid_credentials" });
|
||||||
|
const unknown = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
payload: { username: "ghost", password: "nope" },
|
||||||
|
});
|
||||||
|
expect(unknown.statusCode).toBe(401);
|
||||||
|
expect(unknown.json()).toEqual({ error: "invalid_credentials" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/auth/login : corps invalide → 400 invalid_body", async () => {
|
||||||
|
const empty = await app.inject({ method: "POST", url: "/api/auth/login", payload: {} });
|
||||||
|
expect(empty.statusCode).toBe(400);
|
||||||
|
expect(empty.json()).toEqual({ error: "invalid_body" });
|
||||||
|
const missing = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
payload: { username: "alice" },
|
||||||
|
});
|
||||||
|
expect(missing.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/auth/login : 6e tentative dans la minute → 429 (rate limit par IP)", async () => {
|
||||||
|
await seedUser("alice", "correct-horse-battery");
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
payload: { username: "alice", password: "wrong" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
}
|
||||||
|
const blocked = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
payload: { username: "alice", password: "correct-horse-battery" },
|
||||||
|
});
|
||||||
|
expect(blocked.statusCode).toBe(429);
|
||||||
|
expect((blocked.json() as { error: string }).error).toBe("too_many_attempts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /api/auth/logout sans session → 401 not_authenticated", async () => {
|
||||||
|
const res = await app.inject({ method: "POST", url: "/api/auth/logout" });
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
expect(res.json()).toEqual({ error: "not_authenticated" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("CSRF double-submit : 403 sans/mauvais header, 200 avec le bon, session détruite", async () => {
|
||||||
|
await seedUser("alice", "correct-horse-battery");
|
||||||
|
const login = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/login",
|
||||||
|
payload: { username: "alice", password: "correct-horse-battery" },
|
||||||
|
});
|
||||||
|
expect(login.statusCode).toBe(200);
|
||||||
|
const body = login.json() as { csrfToken: string };
|
||||||
|
const session = cookieValue(login.headers["set-cookie"], SESSION_COOKIE);
|
||||||
|
const csrf = cookieValue(login.headers["set-cookie"], CSRF_COOKIE);
|
||||||
|
if (session === undefined || csrf === undefined) {
|
||||||
|
throw new Error("cookies de session manquants");
|
||||||
|
}
|
||||||
|
const cookies = { [SESSION_COOKIE]: session, [CSRF_COOKIE]: csrf };
|
||||||
|
|
||||||
|
const noHeader = await app.inject({ method: "POST", url: "/api/auth/logout", cookies });
|
||||||
|
expect(noHeader.statusCode).toBe(403);
|
||||||
|
expect(noHeader.json()).toEqual({ error: "csrf_invalid" });
|
||||||
|
|
||||||
|
const wrongHeader = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/logout",
|
||||||
|
cookies,
|
||||||
|
headers: { [CSRF_HEADER]: "0".repeat(64) },
|
||||||
|
});
|
||||||
|
expect(wrongHeader.statusCode).toBe(403);
|
||||||
|
|
||||||
|
const ok = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/logout",
|
||||||
|
cookies,
|
||||||
|
headers: { [CSRF_HEADER]: body.csrfToken },
|
||||||
|
});
|
||||||
|
expect(ok.statusCode).toBe(200);
|
||||||
|
expect(ok.json()).toEqual({ ok: true });
|
||||||
|
expect(cookieValue(ok.headers["set-cookie"], SESSION_COOKIE)).toBe("");
|
||||||
|
|
||||||
|
const after = await app.inject({ method: "GET", url: "/api/auth/session", cookies });
|
||||||
|
expect(after.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("error-handler : exception de route → 500 internal_error, sans fuite (prod)", async () => {
|
||||||
|
app.post("/api/test-crash", async () => {
|
||||||
|
throw new Error("boom interne");
|
||||||
|
});
|
||||||
|
const res = await app.inject({ method: "POST", url: "/api/test-crash" });
|
||||||
|
expect(res.statusCode).toBe(500);
|
||||||
|
const body = res.json() as { error: string; details?: string };
|
||||||
|
expect(body.error).toBe("internal_error");
|
||||||
|
expect(body.details).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DB round-trip : insert → read → update → delete", async () => {
|
||||||
|
const hash = await hashPassword("roundtrip");
|
||||||
|
const inserted = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({ username: "bob", passwordHash: hash })
|
||||||
|
.returning();
|
||||||
|
const user = inserted[0];
|
||||||
|
if (user === undefined) throw new Error("insertion ratée");
|
||||||
|
|
||||||
|
const [found] = await db.select().from(users).where(eq(users.id, user.id)).limit(1);
|
||||||
|
expect(found?.username).toBe("bob");
|
||||||
|
expect(found?.passwordHash).toBe(hash);
|
||||||
|
|
||||||
|
const newHash = await hashPassword("roundtrip2");
|
||||||
|
await db.update(users).set({ passwordHash: newHash }).where(eq(users.id, user.id));
|
||||||
|
const [updated] = await db.select().from(users).where(eq(users.id, user.id)).limit(1);
|
||||||
|
expect(updated?.passwordHash).toBe(newHash);
|
||||||
|
|
||||||
|
await db.delete(users).where(eq(users.id, user.id));
|
||||||
|
const remaining = await db.select().from(users).where(eq(users.id, user.id)).limit(1);
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import type { Config } from "../src/config";
|
||||||
|
import { type Database, openInMemoryDatabase } from "../src/db/client";
|
||||||
|
|
||||||
|
export const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, "../drizzle");
|
||||||
|
|
||||||
|
export function makeConfig(overrides: Partial<Config> = {}): Config {
|
||||||
|
return {
|
||||||
|
port: 8787,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
dataDir: "/tmp/seiche-tests",
|
||||||
|
sessionTtlMs: 8 * 60 * 60 * 1000,
|
||||||
|
sessionAbsoluteTtlMs: 7 * 24 * 60 * 60 * 1000,
|
||||||
|
maxLoginAttemptsPerMinute: 5,
|
||||||
|
publicDir: null,
|
||||||
|
external: {
|
||||||
|
adapter: "example",
|
||||||
|
baseUrl: "",
|
||||||
|
apiKey: "",
|
||||||
|
healthPath: "/health",
|
||||||
|
listPath: "/items",
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
retries: 2,
|
||||||
|
cacheTtlMs: 30_000,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base SQLite in-memory avec les migrations jouées. */
|
||||||
|
export async function openTestDb(): Promise<Database> {
|
||||||
|
return openInMemoryDatabase(MIGRATIONS_FOLDER);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { hashPassword, verifyPassword } from "../src/auth/password";
|
||||||
|
|
||||||
|
describe("hashPassword / verifyPassword (argon2id)", () => {
|
||||||
|
it("roundtrip", async () => {
|
||||||
|
const hash = await hashPassword("correct horse battery staple");
|
||||||
|
expect(hash).toMatch(/^\$argon2id\$/);
|
||||||
|
expect(await verifyPassword(hash, "correct horse battery staple")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mauvais mot de passe → false", async () => {
|
||||||
|
const hash = await hashPassword("correct horse battery staple");
|
||||||
|
expect(await verifyPassword(hash, "nope")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hash invalide → false (jamais d'exception)", async () => {
|
||||||
|
expect(await verifyPassword("$argon2id$garbage", "x")).toBe(false);
|
||||||
|
expect(await verifyPassword("", "x")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("salé : même mot de passe → hashes différents", async () => {
|
||||||
|
const a = await hashPassword("même mot de passe");
|
||||||
|
const b = await hashPassword("même mot de passe");
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
expect(await verifyPassword(a, "même mot de passe")).toBe(true);
|
||||||
|
expect(await verifyPassword(b, "même mot de passe")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { RateLimiter } from "../src/auth/rate-limit";
|
||||||
|
|
||||||
|
const T0 = new Date("2026-01-01T00:00:00Z");
|
||||||
|
const at = (ms: number) => new Date(T0.getTime() + ms);
|
||||||
|
|
||||||
|
describe("RateLimiter (fenêtre glissante)", () => {
|
||||||
|
it("autorise jusqu'à maxHits puis bloque", () => {
|
||||||
|
const rl = new RateLimiter(3, 60_000);
|
||||||
|
expect(rl.hit("a", T0)).toBe(true);
|
||||||
|
expect(rl.hit("a", at(1000))).toBe(true);
|
||||||
|
expect(rl.hit("a", at(2000))).toBe(true);
|
||||||
|
expect(rl.hit("a", at(3000))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("les hits anciens sortent de la fenêtre", () => {
|
||||||
|
const rl = new RateLimiter(2, 60_000);
|
||||||
|
expect(rl.hit("a", T0)).toBe(true);
|
||||||
|
expect(rl.hit("a", at(30_000))).toBe(true);
|
||||||
|
expect(rl.hit("a", at(31_000))).toBe(false);
|
||||||
|
// Le hit de T0 a expiré de la fenêtre (60 s).
|
||||||
|
expect(rl.hit("a", at(61_001))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reset vide la clé", () => {
|
||||||
|
const rl = new RateLimiter(1, 60_000);
|
||||||
|
expect(rl.hit("a", T0)).toBe(true);
|
||||||
|
expect(rl.hit("a", at(1000))).toBe(false);
|
||||||
|
rl.reset("a");
|
||||||
|
expect(rl.hit("a", at(2000))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remaining reflète les tentatives restantes", () => {
|
||||||
|
const rl = new RateLimiter(2, 60_000);
|
||||||
|
expect(rl.remaining("a", T0)).toBe(2);
|
||||||
|
rl.hit("a", T0);
|
||||||
|
expect(rl.remaining("a", at(1000))).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("les clés sont isolées", () => {
|
||||||
|
const rl = new RateLimiter(1, 60_000);
|
||||||
|
expect(rl.hit("a", T0)).toBe(true);
|
||||||
|
expect(rl.hit("b", T0)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { SessionManager } from "../src/auth/session";
|
||||||
|
import type { Database } from "../src/db/client";
|
||||||
|
import { sessions, users } from "../src/db/schema";
|
||||||
|
import { makeConfig, openTestDb } from "./helpers";
|
||||||
|
|
||||||
|
function sha256Hex(value: string): string {
|
||||||
|
return createHash("sha256").update(value).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function newToken(): string {
|
||||||
|
return randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SessionManager", () => {
|
||||||
|
let db: Database;
|
||||||
|
let manager: SessionManager;
|
||||||
|
let userId: number;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = await openTestDb();
|
||||||
|
manager = new SessionManager(db, makeConfig());
|
||||||
|
const inserted = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({ username: "alice", passwordHash: "x" })
|
||||||
|
.returning();
|
||||||
|
const user = inserted[0];
|
||||||
|
if (user === undefined) throw new Error("utilisateur de test non inséré");
|
||||||
|
userId = user.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("create : token + CSRF 256 bits, getValid renvoie le contexte", async () => {
|
||||||
|
const { token, csrfToken } = await manager.create(userId);
|
||||||
|
expect(token).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
expect(csrfToken).toMatch(/^[a-f0-9]{64}$/);
|
||||||
|
const ctx = await manager.getValid(token);
|
||||||
|
expect(ctx?.userId).toBe(userId);
|
||||||
|
expect(ctx?.username).toBe("alice");
|
||||||
|
expect(ctx?.sessionExpiresAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getValid : token absent ou inconnu → null", async () => {
|
||||||
|
expect(await manager.getValid(undefined)).toBeNull();
|
||||||
|
expect(await manager.getValid(newToken())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("le token est stocké haché (pas en clair)", async () => {
|
||||||
|
const { token } = await manager.create(userId);
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.where(eq(sessions.tokenHash, sha256Hex(token)));
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]?.tokenHash).not.toBe(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("TTL glissant : expiration = maintenant + ttl", async () => {
|
||||||
|
const { token } = await manager.create(userId);
|
||||||
|
const before = Date.now();
|
||||||
|
const ctx = await manager.getValid(token);
|
||||||
|
expect(ctx?.sessionExpiresAt.getTime()).toBeGreaterThanOrEqual(before + 8 * 3600 * 1000 - 2000);
|
||||||
|
expect(ctx?.sessionExpiresAt.getTime()).toBeLessThanOrEqual(before + 8 * 3600 * 1000 + 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("TTL glissant borné par l'expiration absolue", async () => {
|
||||||
|
const m = new SessionManager(
|
||||||
|
db,
|
||||||
|
makeConfig({ sessionTtlMs: 3_600_000, sessionAbsoluteTtlMs: 300_000 }),
|
||||||
|
);
|
||||||
|
const { token } = await m.create(userId);
|
||||||
|
const before = Date.now();
|
||||||
|
const ctx = await m.getValid(token);
|
||||||
|
expect(ctx?.sessionExpiresAt.getTime()).toBeGreaterThanOrEqual(before + 300_000 - 1_000);
|
||||||
|
expect(ctx?.sessionExpiresAt.getTime()).toBeLessThanOrEqual(before + 300_000 + 1_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expiration absolue dépassée → null + ligne supprimée", async () => {
|
||||||
|
const token = newToken();
|
||||||
|
const past = new Date(Date.now() - 1000);
|
||||||
|
await db.insert(sessions).values({
|
||||||
|
tokenHash: sha256Hex(token),
|
||||||
|
userId,
|
||||||
|
createdAt: past,
|
||||||
|
expiresAt: past,
|
||||||
|
absoluteExpiryAt: past,
|
||||||
|
lastActivityAt: past,
|
||||||
|
});
|
||||||
|
expect(await manager.getValid(token)).toBeNull();
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(sessions)
|
||||||
|
.where(eq(sessions.tokenHash, sha256Hex(token)));
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroy invalidate le token", async () => {
|
||||||
|
const { token } = await manager.create(userId);
|
||||||
|
await manager.destroy(token);
|
||||||
|
expect(await manager.getValid(token)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("5 sessions max par utilisateur (exactement 5 des 6 restent valides)", async () => {
|
||||||
|
const inserted = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({ username: "bob", passwordHash: "x" })
|
||||||
|
.returning();
|
||||||
|
const bob = inserted[0];
|
||||||
|
if (bob === undefined) throw new Error("utilisateur bob non inséré");
|
||||||
|
const m2 = new SessionManager(db, makeConfig());
|
||||||
|
const tokens: string[] = [];
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
tokens.push((await m2.create(bob.id)).token);
|
||||||
|
}
|
||||||
|
const rows = await db.select().from(sessions).where(eq(sessions.userId, bob.id));
|
||||||
|
expect(rows).toHaveLength(5);
|
||||||
|
let validCount = 0;
|
||||||
|
for (const t of tokens) {
|
||||||
|
const ctx = await m2.getValid(t);
|
||||||
|
if (ctx !== null) validCount += 1;
|
||||||
|
}
|
||||||
|
expect(validCount).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"noEmit": true,
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src", "tests", "drizzle.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["tests/**/*.test.ts"],
|
||||||
|
testTimeout: 20000,
|
||||||
|
hookTimeout: 20000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"useDefineForClassFields": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Séiche</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "seiche-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router": "^7.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.0",
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/react": "^19.1.0",
|
||||||
|
"@types/react-dom": "^19.1.0",
|
||||||
|
"@vitejs/plugin-react": "^4.5.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"tailwindcss": "^4.1.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.0.0",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Navigate, Route, Routes } from "react-router";
|
||||||
|
import RequireAuth from "./components/RequireAuth";
|
||||||
|
import DashboardPage from "./pages/DashboardPage";
|
||||||
|
import LoginPage from "./pages/LoginPage";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<DashboardPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { type ReactNode, useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { useSession } from "../lib/session";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Garde de route : sans session valide, redirect `/login`.
|
||||||
|
* Pendant la vérification, un écran de chargement (pas de flash de contenu protégé).
|
||||||
|
*/
|
||||||
|
export default function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
|
const { isPending, isError } = useSession();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isError) navigate("/login", { replace: true });
|
||||||
|
}, [isError, navigate]);
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100">
|
||||||
|
<p>Chargement…</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isError) return null;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Client API minimal (fetch + cookies).
|
||||||
|
* Contrat serveur : server/src/routes/auth.ts (mêmes codes d'erreur, mêmes shapes).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ApiUser {
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
user: ApiUser;
|
||||||
|
csrfToken: string;
|
||||||
|
csrfHeader: string;
|
||||||
|
sessionExpiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly status: number,
|
||||||
|
public readonly code: string,
|
||||||
|
public readonly retryAfterMs?: number,
|
||||||
|
) {
|
||||||
|
super(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clé TanStack Query du cache de session (partagée entre login et guard). */
|
||||||
|
export const sessionQueryKey = ["session"] as const;
|
||||||
|
|
||||||
|
interface ErrorBody {
|
||||||
|
error?: unknown;
|
||||||
|
retryAfterMs?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "content-type": "application/json", ...init.headers },
|
||||||
|
});
|
||||||
|
|
||||||
|
let body: ErrorBody | T = {};
|
||||||
|
try {
|
||||||
|
body = (await res.json()) as ErrorBody | T;
|
||||||
|
} catch {
|
||||||
|
// corps vide / non JSON : on reste sur {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const record = body as ErrorBody;
|
||||||
|
const retryAfterMs = typeof record.retryAfterMs === "number" ? record.retryAfterMs : undefined;
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
typeof record.error === "string" ? record.error : "unknown",
|
||||||
|
retryAfterMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login(username: string, password: string): Promise<AuthSession> {
|
||||||
|
return request<AuthSession>("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
session(): Promise<AuthSession> {
|
||||||
|
return request<AuthSession>("/api/auth/session");
|
||||||
|
},
|
||||||
|
|
||||||
|
logout(session: AuthSession): Promise<{ ok: true }> {
|
||||||
|
return request<{ ok: true }>("/api/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { [session.csrfHeader]: session.csrfToken },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { ApiError } from "./api";
|
||||||
|
|
||||||
|
/** Factory partagée main.tsx ↔ tests : mêmes options par défaut des queries. */
|
||||||
|
export function createQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
// Pas de retry sur les 4xx (401 session morte, 400 corps invalide…) : ça ne passera pas.
|
||||||
|
retry: (failureCount, error) =>
|
||||||
|
!(error instanceof ApiError && error.status >= 400 && error.status < 500) &&
|
||||||
|
failureCount < 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { type AuthSession, api, sessionQueryKey } from "./api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session courante (cache partagé : la login seede, le guard lit, le dashboard consomme).
|
||||||
|
*/
|
||||||
|
export function useSession(): ReturnType<typeof useQuery<AuthSession>> {
|
||||||
|
return useQuery<AuthSession>({
|
||||||
|
queryKey: sessionQueryKey,
|
||||||
|
queryFn: api.session,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router";
|
||||||
|
import App from "./App";
|
||||||
|
import { createQueryClient } from "./lib/queryClient";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
const queryClient = createQueryClient();
|
||||||
|
|
||||||
|
const rootElement = document.getElementById("root");
|
||||||
|
if (!rootElement) throw new Error("élément #root introuvable");
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { api, sessionQueryKey } from "../lib/api";
|
||||||
|
import { useSession } from "../lib/session";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Le guard garantit une session valide ici ; null = état transitoire impossible.
|
||||||
|
if (!session) return null;
|
||||||
|
const s = session; // const resserré : accessible depuis le closure sans renarrowing
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await api.logout(s);
|
||||||
|
} catch {
|
||||||
|
// Session déjà morte côté serveur : on continue la déconnexion locale.
|
||||||
|
}
|
||||||
|
queryClient.removeQueries({ queryKey: sessionQueryKey });
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-slate-950 p-8 text-slate-100">
|
||||||
|
<div className="mx-auto max-w-xl space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Tableau de bord — Séiche</h1>
|
||||||
|
<p>
|
||||||
|
Connecté en tant que <strong>{session.user.username}</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Session expire le {new Date(session.sessionExpiresAt).toLocaleString("fr-FR")}.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="rounded bg-red-600 px-3 py-2 font-medium hover:bg-red-500"
|
||||||
|
>
|
||||||
|
Se déconnecter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { type FormEvent, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { ApiError, api, sessionQueryKey } from "../lib/api";
|
||||||
|
|
||||||
|
function errorMessage(err: unknown): string {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
if (err.code === "invalid_credentials") return "Identifiants invalides.";
|
||||||
|
if (err.code === "too_many_attempts") {
|
||||||
|
const s = Math.ceil((err.retryAfterMs ?? 60_000) / 1000);
|
||||||
|
return `Trop de tentatives, réessayez dans ${s} s.`;
|
||||||
|
}
|
||||||
|
if (err.status === 429) return "Trop de tentatives, patientez un instant.";
|
||||||
|
if (err.status >= 500) return "Erreur serveur, réessayez plus tard.";
|
||||||
|
}
|
||||||
|
return "Connexion impossible, réessayez.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: ({ username, password }: { username: string; password: string }) =>
|
||||||
|
api.login(username, password),
|
||||||
|
onSuccess: (session) => {
|
||||||
|
queryClient.setQueryData(sessionQueryKey, session);
|
||||||
|
navigate("/");
|
||||||
|
},
|
||||||
|
onError: (err) => setFormError(errorMessage(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
if (mutation.isPending) return;
|
||||||
|
if (username.length < 1 || username.length > 64) {
|
||||||
|
setFormError("Nom d'utilisateur : entre 1 et 64 caractères.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 1 || password.length > 256) {
|
||||||
|
setFormError("Mot de passe : entre 1 et 256 caractères.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mutation.mutate({ username, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="w-80 space-y-4 rounded-lg border border-slate-800 bg-slate-900 p-6"
|
||||||
|
>
|
||||||
|
<h1 className="text-xl font-semibold">Connexion — Séiche</h1>
|
||||||
|
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-slate-400">Nom d'utilisateur</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
maxLength={64}
|
||||||
|
className="mt-1 block w-full rounded border border-slate-700 bg-slate-950 px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block text-sm">
|
||||||
|
<span className="text-slate-400">Mot de passe</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
maxLength={256}
|
||||||
|
className="mt-1 block w-full rounded border border-slate-700 bg-slate-950 px-3 py-2"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{formError !== null && (
|
||||||
|
<p role="alert" className="text-sm text-red-400">
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="w-full rounded bg-sky-600 px-3 py-2 font-medium hover:bg-sky-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Connexion…" : "Se connecter"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
|
||||||
|
// Auto-cleanup désactivé (vitest sans `globals: true`) → cleanup explicite.
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import App from "../src/App";
|
||||||
|
import type { AuthSession } from "../src/lib/api";
|
||||||
|
import { createQueryClient } from "../src/lib/queryClient";
|
||||||
|
|
||||||
|
const session: AuthSession = {
|
||||||
|
user: { username: "nicolas" },
|
||||||
|
csrfToken: "csrf-token",
|
||||||
|
csrfHeader: "x-csrf-token",
|
||||||
|
sessionExpiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockApi(handler: (url: string, init?: RequestInit) => Response) {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => handler(String(input), init)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApp(path: string) {
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={createQueryClient()}>
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("smoke front", () => {
|
||||||
|
it("rend le formulaire de login", () => {
|
||||||
|
mockApi(() => jsonResponse({}));
|
||||||
|
renderApp("/login");
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/nom d'utilisateur/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/mot de passe/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /se connecter/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirige vers /login sans session valide", async () => {
|
||||||
|
mockApi((url) =>
|
||||||
|
url.includes("/auth/session")
|
||||||
|
? jsonResponse({ error: "not_authenticated" }, 401)
|
||||||
|
: jsonResponse({}, 404),
|
||||||
|
);
|
||||||
|
renderApp("/");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: /se connecter/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connecte l'utilisateur et affiche le dashboard", async () => {
|
||||||
|
mockApi((url, init) =>
|
||||||
|
url.includes("/auth/login") && init?.method === "POST"
|
||||||
|
? jsonResponse(session)
|
||||||
|
: jsonResponse({ error: "not_authenticated" }, 401),
|
||||||
|
);
|
||||||
|
renderApp("/login");
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/nom d'utilisateur/i), {
|
||||||
|
target: { value: "nicolas" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/mot de passe/i), {
|
||||||
|
target: { value: "secret" },
|
||||||
|
});
|
||||||
|
const form = screen
|
||||||
|
.getByRole("button", { name: /se connecter/i })
|
||||||
|
.closest("form") as HTMLFormElement;
|
||||||
|
fireEvent.submit(form);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/tableau de bord/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("nicolas")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "tests", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/// <reference types="vitest/config" />
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
// Dev : l'API tourne sur le port du serveur (défaut 8787, voir server/src/config.ts).
|
||||||
|
"/api": "http://localhost:8787",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: "./tests/setup.ts",
|
||||||
|
passWithNoTests: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user