diff --git a/control-plane/api/internal/demos/demos.go b/control-plane/api/internal/demos/demos.go
index ca8a51a..f92ce16 100644
--- a/control-plane/api/internal/demos/demos.go
+++ b/control-plane/api/internal/demos/demos.go
@@ -40,7 +40,12 @@ var (
// transitent que par le fichier de valeurs Helm temporaire puis le Secret
// k8s du chart, comme les autres secrets.
type ProvisionConfig struct {
- TelegramBotToken string
+ // TelegramBotUsername alimente TELEGRAM_BOT_USERNAME (lu par le backend
+ // au démarrage). TELEGRAM_WEBHOOK_URL est calculée automatiquement
+ // (URL publique de la démo) ; TELEGRAM_WEBHOOK_SECRET reste généré par
+ // le pool de ressources (ServiceTelegram), jamais saisi par l'admin.
+ TelegramBotUsername string
+ TelegramBotToken string
// NowPaymentsAPIKey écrase NOWPAYMENTS_API_KEY. NowPaymentsIPNSecret,
// s'il est fourni, écrase le secret pré-provisionné par le pool
// (ServiceNowPayments) — sinon celui du pool reste utilisé.
@@ -49,6 +54,29 @@ type ProvisionConfig struct {
StorageDriver string // "local" | "s3", "" => "local"
S3Bucket string
S3Endpoint string
+
+ // Load-balancer Telegram (chart lbtelegram) : installé seulement si au
+ // moins un bot est renseigné. Le reste de la config du chart (DSN
+ // postgres/redis, secrets JWT/webhook/backend-link) est généré par le
+ // provisioner, pas saisi par l'admin.
+ LBBot1Username string
+ LBBot1Token string
+ LBBot2Username string
+ LBBot2Token string
+
+ // Compte admin de l'application déployée (pas le compte omnex de
+ // l'admin qui lance la démo) — requis : sans lui, personne ne peut se
+ // connecter au backoffice de la démo. Créé directement en base une fois
+ // postgres/redis/backend/frontend Running (voir HelmProvisioner.Provision),
+ // car la table users n'existe qu'après la migration du backend au
+ // premier démarrage.
+ AdminUsername string
+ AdminPassword string
+}
+
+// LBTelegramEnabled indique si au moins un bot du load-balancer est configuré.
+func (c ProvisionConfig) LBTelegramEnabled() bool {
+ return c.LBBot1Username != "" || c.LBBot2Username != ""
}
// Validate vérifie la cohérence du choix de stockage.
diff --git a/control-plane/api/internal/demos/handler.go b/control-plane/api/internal/demos/handler.go
index fefa014..b6fbb99 100644
--- a/control-plane/api/internal/demos/handler.go
+++ b/control-plane/api/internal/demos/handler.go
@@ -23,12 +23,23 @@ type createRequest struct {
Username string `json:"username" binding:"omitempty,min=3,max=64,alphanum"`
// Réglages saisis dans le popup de déploiement (voir ProvisionConfig).
+ TelegramBotUsername string `json:"telegram_bot_username" binding:"omitempty,max=64"`
TelegramBotToken string `json:"telegram_bot_token" binding:"omitempty"`
NowPaymentsAPIKey string `json:"nowpayments_api_key" binding:"omitempty"`
NowPaymentsIPNSecret string `json:"nowpayments_ipn_secret" binding:"omitempty"`
StorageDriver string `json:"storage_driver" binding:"omitempty,oneof=local s3"`
S3Bucket string `json:"s3_bucket" binding:"omitempty,max=63"`
S3Endpoint string `json:"s3_endpoint" binding:"omitempty,max=255"`
+
+ LBBot1Username string `json:"lb_bot1_username" binding:"omitempty,max=64"`
+ LBBot1Token string `json:"lb_bot1_token" binding:"omitempty"`
+ LBBot2Username string `json:"lb_bot2_username" binding:"omitempty,max=64"`
+ LBBot2Token string `json:"lb_bot2_token" binding:"omitempty"`
+
+ // Compte admin de l'application déployée — requis, sans lui personne ne
+ // peut se connecter au backoffice de la démo.
+ AdminUsername string `json:"admin_username" binding:"required,min=3,max=64,alphanum"`
+ AdminPassword string `json:"admin_password" binding:"required,min=8"`
}
type DetailDemoUserRequest struct {
@@ -52,12 +63,19 @@ func (h *Handler) Create(c *gin.Context) {
return
}
cfg := ProvisionConfig{
+ TelegramBotUsername: req.TelegramBotUsername,
TelegramBotToken: req.TelegramBotToken,
NowPaymentsAPIKey: req.NowPaymentsAPIKey,
NowPaymentsIPNSecret: req.NowPaymentsIPNSecret,
StorageDriver: req.StorageDriver,
S3Bucket: req.S3Bucket,
S3Endpoint: req.S3Endpoint,
+ LBBot1Username: req.LBBot1Username,
+ LBBot1Token: req.LBBot1Token,
+ LBBot2Username: req.LBBot2Username,
+ LBBot2Token: req.LBBot2Token,
+ AdminUsername: req.AdminUsername,
+ AdminPassword: req.AdminPassword,
}
d, err := h.svc.Create(req.LeadID, req.Username, cfg)
if err != nil {
diff --git a/control-plane/api/internal/demos/helm_provisioner.go b/control-plane/api/internal/demos/helm_provisioner.go
index fac7fc5..97c98e4 100644
--- a/control-plane/api/internal/demos/helm_provisioner.go
+++ b/control-plane/api/internal/demos/helm_provisioner.go
@@ -3,6 +3,8 @@ package demos
import (
"bytes"
"context"
+ "crypto/rand"
+ "encoding/hex"
"fmt"
"io"
"log"
@@ -14,6 +16,7 @@ import (
"github.com/omnex/control-plane/api/internal/config"
"go.yaml.in/yaml/v2"
+ "golang.org/x/crypto/bcrypt"
k8sCoreV1 "k8s.io/api/core/v1"
k8sErrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -102,7 +105,16 @@ func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource, cfg Pr
// en stockage persistant lorsqu'un client devient payant.
postgresValues := h.buildPostgresValues(d, false)
redisValues := h.buildRedisValues(d, false)
- backendValues := h.buildBackendValues(d, resources, cfg)
+
+ // Secret partagé backend <-> lbtelegram (authentifie les appels de
+ // lbtelegram vers backend) — généré une seule fois si le load-balancer
+ // Telegram est activé, vide sinon (le chart lbtelegram n'est pas installé).
+ var backendLinkSecret string
+ if cfg.LBTelegramEnabled() {
+ backendLinkSecret = randomSecret()
+ }
+
+ backendValues := h.buildBackendValues(d, resources, cfg, backendLinkSecret)
frontendValues := h.buildFrontendValues(d)
ingressValues := h.buildIngressRouteValues(d)
@@ -137,9 +149,28 @@ func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource, cfg Pr
return fmt.Errorf("déploiement ingressroute: %w", err)
}
- // Attendre que les pods soient prêts
+ // Load-balancer Telegram : optionnel, seulement si l'admin a renseigné
+ // au moins un bot.
+ if cfg.LBTelegramEnabled() {
+ lbValues := h.buildLBTelegramValues(d, cfg, backendLinkSecret)
+ if err := h.installChart(d.Namespace, "lbtelegram", lbValues); err != nil {
+ h.deleteNamespace(d.Namespace)
+ return fmt.Errorf("déploiement lbtelegram: %w", err)
+ }
+ }
+
+ // Attendre que postgres/redis/backend/frontend soient tous Running avant
+ // de créer le compte admin : la table "users" n'existe qu'une fois que
+ // le backend a fait tourner sa migration au démarrage, donc tenter
+ // l'insertion avant que le rollout ne soit confirmé échouerait
+ // ("relation users does not exist").
if err := h.waitForRollout(d.Namespace); err != nil {
- log.Printf("Warning: rollout check échoué pour %s: %v", d.Namespace, err)
+ log.Printf("Warning: rollout check échoué pour %s: %v — compte admin non créé", d.Namespace, err)
+ return nil
+ }
+
+ if err := h.createGestionAdmin(context.Background(), d.Namespace, cfg.AdminUsername, cfg.AdminPassword); err != nil {
+ log.Printf("Warning: création du compte admin échouée pour %s: %v", d.Namespace, err)
}
return nil
@@ -171,6 +202,53 @@ func (h *HelmProvisioner) Teardown(d Demo) error {
return h.deleteNamespace(d.Namespace)
}
+// createGestionAdmin crée le compte admin de l'application "gestion"
+// déployée dans cette démo, en insérant directement dans la table "users"
+// (aucune route API ne le permet : CreateUser refuse explicitement de créer
+// un compte admin, par design de l'app). Mot de passe haché en bcrypt
+// (golang.org/x/crypto/bcrypt), comme le fait l'app elle-même
+// (bcrypt.CompareHashAndPassword côté LoginAdmin).
+func (h *HelmProvisioner) createGestionAdmin(ctx context.Context, namespace, username, password string) error {
+ if username == "" || password == "" {
+ return fmt.Errorf("username/password admin manquants")
+ }
+
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return fmt.Errorf("hash bcrypt: %w", err)
+ }
+
+ pgPod, err := h.findPod(ctx, namespace, "postgresql")
+ if err != nil {
+ return fmt.Errorf("pod postgresql introuvable: %w", err)
+ }
+
+ // Échappement basique des quotes simples : le username est déjà validé
+ // alphanumérique côté handler HTTP, le hash bcrypt ne contient jamais de
+ // guillemet (alphabet base64 restreint ./A-Za-z0-9).
+ escapedUsername := strings.ReplaceAll(username, "'", "''")
+ sql := fmt.Sprintf(
+ `INSERT INTO users (username, password, role) VALUES ('%s', '%s', 'admin') ON CONFLICT (username) DO NOTHING;`,
+ escapedUsername, string(hash),
+ )
+ shellCmd := fmt.Sprintf("PGPASSWORD=%s psql -h localhost -U %s -d %s -v ON_ERROR_STOP=1 -c %s",
+ demoDBPass, demoDBUser, demoDBName, shellQuote(sql))
+
+ _, stderr, err := h.execInPod(ctx, namespace, pgPod, "postgresql", []string{"sh", "-c", shellCmd}, nil)
+ if err != nil {
+ return fmt.Errorf("insertion compte admin: %w (%s)", err, stderr)
+ }
+
+ log.Printf("Compte admin %q créé pour la démo %s", username, namespace)
+ return nil
+}
+
+// shellQuote entoure une chaîne de guillemets simples pour un usage sûr dans
+// une commande sh -c (échappe les guillemets simples qu'elle contient).
+func shellQuote(s string) string {
+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
+}
+
// Identifiants postgres des démos — mêmes valeurs codées en dur que
// buildPostgresValues/buildBackendValues (voir ces fonctions).
const (
@@ -397,7 +475,9 @@ func parseImage(image string) (repo string, tag string) {
}
// buildBackendValues construit les valeurs pour le chart backend.
-func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource, cfg ProvisionConfig) map[string]interface{} {
+// backendLinkSecret : partagé avec le chart lbtelegram (voir
+// buildLBTelegramValues) — vide si le load-balancer Telegram n'est pas activé.
+func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource, cfg ProvisionConfig, backendLinkSecret string) map[string]interface{} {
// Parser l'image backend pour séparer repository et tag
backendRepo, backendTag := parseImage(h.backendImage)
@@ -429,9 +509,17 @@ func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResourc
env["S3_ENDPOINT"] = cfg.S3Endpoint
}
+ if cfg.TelegramBotUsername != "" {
+ env["TELEGRAM_BOT_USERNAME"] = cfg.TelegramBotUsername
+ }
+
secrets := h.buildSecrets(resources)
if cfg.TelegramBotToken != "" {
secrets["TELEGRAM_BOT_TOKEN"] = cfg.TelegramBotToken
+ // URL publique du webhook Telegram de cette démo (route backend :
+ // POST /webhook/telegram, sans préfixe /api/v1). TELEGRAM_WEBHOOK_SECRET
+ // reste celui généré par le pool (h.buildSecrets ci-dessus).
+ secrets["TELEGRAM_WEBHOOK_URL"] = d.URL + "/webhook/telegram"
}
if cfg.NowPaymentsAPIKey != "" {
secrets["NOWPAYMENTS_API_KEY"] = cfg.NowPaymentsAPIKey
@@ -443,6 +531,12 @@ func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResourc
// marchand NowPayments, pas à une valeur générée par le pool.
secrets["NOWPAYMENTS_IPN_SECRET"] = cfg.NowPaymentsIPNSecret
}
+ if cfg.LBTelegramEnabled() {
+ env["LBTELEGRAM_URL"] = fmt.Sprintf("http://%s-lbtelegram-lbtelegram.%s.svc.cluster.local:8081", d.Namespace, d.Namespace)
+ env["LBTELEGRAM_BOT1_USERNAME"] = cfg.LBBot1Username
+ env["LBTELEGRAM_BOT2_USERNAME"] = cfg.LBBot2Username
+ secrets["BACKEND_LINK_SECRET"] = backendLinkSecret
+ }
values := map[string]interface{}{
"replicaCount": 1,
@@ -505,6 +599,58 @@ func (h *HelmProvisioner) buildIngressRouteValues(d Demo) map[string]interface{}
}
}
+// buildLBTelegramValues construit les valeurs pour le chart lbtelegram
+// (load-balancer multi-bots Telegram). N'est appelé que si
+// cfg.LBTelegramEnabled() — au moins un bot renseigné par l'admin. Les
+// identifiants des bots (username/token) viennent de l'admin ; le reste
+// (DSN postgres/redis, secrets JWT/webhook/backend-link) est généré ici,
+// comme indiqué par le commentaire du chart (deploy/chart-gestion/lbtelegram/values.yaml).
+func (h *HelmProvisioner) buildLBTelegramValues(d Demo, cfg ProvisionConfig, backendLinkSecret string) map[string]interface{} {
+ botCount := 0
+ if cfg.LBBot1Username != "" {
+ botCount++
+ }
+ if cfg.LBBot2Username != "" {
+ botCount++
+ }
+
+ backendURL := fmt.Sprintf("http://%s-backend-gestion-backend.%s.svc.cluster.local:8080", d.Namespace, d.Namespace)
+ dbURL := fmt.Sprintf("postgres://postgres:demo-postgres-pass@%s-postgresql-postgresql:5432/demo_db?sslmode=disable", d.Namespace)
+ redisURL := fmt.Sprintf("redis://:demo-redis-pass@%s-redis-redis:6379/0", d.Namespace)
+
+ return map[string]interface{}{
+ "env": map[string]string{
+ "PORT": "8081",
+ "ENV": "production",
+ "BOT_COUNT": fmt.Sprintf("%d", botCount),
+ "BOT1_USERNAME": cfg.LBBot1Username,
+ "BOT2_USERNAME": cfg.LBBot2Username,
+ "BACKEND_LINK_URL": backendURL,
+ },
+ "secrets": map[string]string{
+ "BOT1_TOKEN": cfg.LBBot1Token,
+ "BOT2_TOKEN": cfg.LBBot2Token,
+ "BOT1_WEBHOOK_SECRET": randomSecret(),
+ "BOT2_WEBHOOK_SECRET": randomSecret(),
+ "BACKEND_LINK_SECRET": backendLinkSecret,
+ "JWT_SECRET": randomSecret(),
+ "DATABASE_URL": dbURL,
+ "REDIS_URL": redisURL,
+ },
+ }
+}
+
+// randomSecret génère un secret hexadécimal aléatoire de 32 octets (256 bits).
+func randomSecret() string {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ // crypto/rand.Read ne devrait jamais échouer sur un système normal ;
+ // fail-secure plutôt que de retourner un secret prévisible.
+ panic(fmt.Sprintf("randomSecret: %v", err))
+ }
+ return hex.EncodeToString(b)
+}
+
// buildSecrets extrait les références des secrets des ressources externes.
func (h *HelmProvisioner) buildSecrets(resources []ExternalResource) map[string]string {
secrets := map[string]string{}
diff --git a/control-plane/api/internal/test/auth_test.go b/control-plane/api/internal/test/auth_test.go
deleted file mode 100644
index 5ebc713..0000000
--- a/control-plane/api/internal/test/auth_test.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package apitest
-
-import (
- "context"
- "testing"
- "time"
-
- "github.com/omnex/control-plane/api/internal/auth"
- "github.com/omnex/control-plane/api/internal/session"
-)
-
-func TestHashAndVerifyPassword(t *testing.T) {
- hash, err := auth.HashPassword("s3cret-password")
- if err != nil {
- t.Fatalf("hash: %v", err)
- }
- if hash == "s3cret-password" {
- t.Fatal("le mot de passe ne doit pas être stocké en clair")
- }
- ok, err := auth.VerifyPassword("s3cret-password", hash)
- if err != nil || !ok {
- t.Fatalf("verify bon mdp: ok=%v err=%v", ok, err)
- }
- if ok, _ := auth.VerifyPassword("mauvais", hash); ok {
- t.Fatal("un mauvais mot de passe ne doit pas passer")
- }
-}
-
-func TestHashUniqueSalt(t *testing.T) {
- h1, _ := auth.HashPassword("same")
- h2, _ := auth.HashPassword("same")
- if h1 == h2 {
- t.Fatal("deux hash du même mdp doivent différer (salt aléatoire)")
- }
-}
-
-func TestVerifyRejectsMalformed(t *testing.T) {
- if _, err := auth.VerifyPassword("x", "pas-un-hash"); err == nil {
- t.Fatal("un hash malformé doit être rejeté")
- }
-}
-
-func TestJWTIssueVerify(t *testing.T) {
- iss := auth.NewIssuer([]byte(testSecret), time.Minute)
- tok, err := iss.Issue("user-1", auth.RoleClient, "sid-123")
- if err != nil {
- t.Fatalf("issue: %v", err)
- }
- claims, err := iss.Verify(tok)
- if err != nil {
- t.Fatalf("verify: %v", err)
- }
- if claims.Subject != "user-1" || claims.Role != auth.RoleClient || claims.SessionID != "sid-123" {
- t.Fatalf("claims inattendus: %+v", claims)
- }
-}
-
-// Sécurité : token signé avec une autre clé rejeté.
-func TestJWTRejectsWrongKey(t *testing.T) {
- a := auth.NewIssuer([]byte(testSecret), time.Minute)
- b := auth.NewIssuer([]byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), time.Minute)
- tok, _ := a.Issue("u", auth.RoleClient, "sid")
- if _, err := b.Verify(tok); err == nil {
- t.Fatal("un token d'une autre clé ne doit pas être accepté")
- }
-}
-
-// Sécurité : token expiré rejeté.
-func TestJWTRejectsExpired(t *testing.T) {
- iss := auth.NewIssuer([]byte(testSecret), -time.Minute)
- tok, _ := iss.Issue("u", auth.RoleClient, "sid")
- if _, err := iss.Verify(tok); err == nil {
- t.Fatal("un token expiré ne doit pas être accepté")
- }
-}
-
-// Session Redis(mem) : create → get → delete.
-func TestSessionManagerLifecycle(t *testing.T) {
- mgr := session.NewMemManager(time.Hour)
- sid, err := mgr.Create(context.Background(), session.Session{UserID: "u1", Role: "sales"})
- if err != nil || sid == "" {
- t.Fatalf("create: sid=%q err=%v", sid, err)
- }
- if _, found, _ := mgr.Get(context.Background(), sid); !found {
- t.Fatal("session attendue présente")
- }
- if err := mgr.Delete(context.Background(), sid); err != nil {
- t.Fatalf("delete: %v", err)
- }
- if _, found, _ := mgr.Get(context.Background(), sid); found {
- t.Fatal("session ne doit plus exister après delete")
- }
-}
diff --git a/control-plane/api/internal/test/demos_test.go b/control-plane/api/internal/test/demos_test.go
deleted file mode 100644
index 1c82123..0000000
--- a/control-plane/api/internal/test/demos_test.go
+++ /dev/null
@@ -1,187 +0,0 @@
-package apitest
-
-import (
- "errors"
- "net/http"
- "testing"
- "time"
-
- "github.com/gin-gonic/gin"
-
- "github.com/omnex/control-plane/api/internal/demos"
-)
-
-func newSvc() *demos.Service {
- return demos.NewService(demos.NewMemStore(), demos.NewMemPool(), demos.NoopProvisioner{}, demos.Config{})
-}
-
-// Feature : une démo créée est en provisioning, TTL ~30 j, URL et namespace posés.
-func TestDemoCreate(t *testing.T) {
- svc := newSvc()
- d, err := svc.Create("")
- if err != nil {
- t.Fatalf("create: %v", err)
- }
- if d.Status != demos.StatusProvisioning {
- t.Fatalf("statut attendu provisioning, reçu %s", d.Status)
- }
- if d.Namespace == "" || d.URL == "" {
- t.Fatalf("namespace/url manquants: %+v", d)
- }
- ttl := d.ExpiresAt.Sub(d.CreatedAt)
- if ttl < demos.TTL-time.Minute || ttl > demos.TTL+time.Minute {
- t.Fatalf("TTL attendu ~30j, reçu %s", ttl)
- }
-}
-
-// Feature : borrow/return du pool cohérents.
-func TestDemoPoolBorrowReturn(t *testing.T) {
- pool := demos.NewMemPool()
- svc := demos.NewService(demos.NewMemStore(), pool, demos.NoopProvisioner{}, demos.Config{})
-
- before, _ := pool.FreeCount()
- if before[demos.ServiceTelegram] != demos.PoolSizePerService {
- t.Fatalf("pool initial telegram attendu %d, reçu %d", demos.PoolSizePerService, before[demos.ServiceTelegram])
- }
-
- d, err := svc.Create("")
- if err != nil {
- t.Fatalf("create: %v", err)
- }
- after, _ := pool.FreeCount()
- if after[demos.ServiceTelegram] != demos.PoolSizePerService-1 {
- t.Fatalf("après emprunt telegram attendu %d, reçu %d", demos.PoolSizePerService-1, after[demos.ServiceTelegram])
- }
-
- if _, err := svc.Delete(d.ID); err != nil {
- t.Fatalf("delete: %v", err)
- }
- back, _ := pool.FreeCount()
- if back[demos.ServiceTelegram] != demos.PoolSizePerService {
- t.Fatalf("après restitution telegram attendu %d, reçu %d", demos.PoolSizePerService, back[demos.ServiceTelegram])
- }
-}
-
-// Feature : capacité — la 6ᵉ démo est refusée (5 slots de pool).
-func TestDemoCapacityReached(t *testing.T) {
- svc := newSvc()
- for i := 0; i < demos.MaxConcurrentDemos; i++ {
- if _, err := svc.Create(""); err != nil {
- t.Fatalf("create %d: %v", i, err)
- }
- }
- if _, err := svc.Create(""); !errors.Is(err, demos.ErrCapacityReached) {
- t.Fatalf("6ᵉ démo : attendu ErrCapacityReached, reçu %v", err)
- }
-}
-
-// Feature : la capacité se libère après destruction.
-func TestDemoCapacityFreesAfterDelete(t *testing.T) {
- svc := newSvc()
- var first demos.Demo
- for i := 0; i < demos.MaxConcurrentDemos; i++ {
- d, err := svc.Create("")
- if err != nil {
- t.Fatalf("create %d: %v", i, err)
- }
- if i == 0 {
- first = d
- }
- }
- if _, err := svc.Delete(first.ID); err != nil {
- t.Fatalf("delete: %v", err)
- }
- if _, err := svc.Create(""); err != nil {
- t.Fatalf("après libération : create doit réussir, reçu %v", err)
- }
-}
-
-// Feature : extend prolonge de 30 j.
-func TestDemoExtend(t *testing.T) {
- svc := newSvc()
- d, _ := svc.Create("")
- ext, err := svc.Extend(d.ID)
- if err != nil {
- t.Fatalf("extend: %v", err)
- }
- if !ext.ExpiresAt.After(d.ExpiresAt) {
- t.Fatalf("extend doit repousser l'échéance: avant=%s après=%s", d.ExpiresAt, ext.ExpiresAt)
- }
-}
-
-// Feature : delete idempotent + not found.
-func TestDemoDeleteNotFound(t *testing.T) {
- svc := newSvc()
- if _, err := svc.Delete("inconnu"); !errors.Is(err, demos.ErrNotFound) {
- t.Fatalf("attendu ErrNotFound, reçu %v", err)
- }
-}
-
-// --- Tests HTTP (routes protégées) ---
-
-func TestDemosEndpointRequiresAuth(t *testing.T) {
- e := newTestEnv(t)
- w := e.do(http.MethodPost, "/api/v1/demos", "", gin.H{})
- if w.Code != http.StatusUnauthorized {
- t.Fatalf("attendu 401 sans token, reçu %d", w.Code)
- }
-}
-
-// Sécurité : un commercial (sales/« client ») ne peut PAS provisionner de démo.
-func TestDemosForbiddenForSales(t *testing.T) {
- e := newTestEnv(t)
- sales := e.token(t) // rôle sales
- for _, tc := range []struct{ method, path string }{
- {http.MethodPost, "/api/v1/demos"},
- {http.MethodGet, "/api/v1/demos"},
- {http.MethodDelete, "/api/v1/demos/x"},
- {http.MethodPost, "/api/v1/demos/x/extend"},
- } {
- w := e.do(tc.method, tc.path, sales, gin.H{})
- if w.Code != http.StatusForbidden {
- t.Fatalf("%s %s : attendu 403 pour un commercial, reçu %d", tc.method, tc.path, w.Code)
- }
- }
-}
-
-func TestDemosCreateAndGet(t *testing.T) {
- e := newTestEnv(t)
- tok := e.adminToken(t)
-
- w := e.do(http.MethodPost, "/api/v1/demos", tok, gin.H{})
- if w.Code != http.StatusAccepted {
- t.Fatalf("attendu 202, reçu %d: %s", w.Code, w.Body)
- }
- created := decode[demos.Demo](t, w)
-
- g := e.do(http.MethodGet, "/api/v1/demos/"+created.ID, tok, nil)
- if g.Code != http.StatusOK {
- t.Fatalf("get attendu 200, reçu %d", g.Code)
- }
- if u := decode[demos.Demo](t, g); u.ID != created.ID {
- t.Fatalf("id incohérent: %s != %s", u.ID, created.ID)
- }
-}
-
-func TestDemosGetUnknown404(t *testing.T) {
- e := newTestEnv(t)
- w := e.do(http.MethodGet, "/api/v1/demos/inconnu", e.adminToken(t), nil)
- if w.Code != http.StatusNotFound {
- t.Fatalf("attendu 404, reçu %d", w.Code)
- }
-}
-
-// Capacité via HTTP : la 6ᵉ création renvoie 409.
-func TestDemosCapacityHTTP(t *testing.T) {
- e := newTestEnv(t)
- tok := e.adminToken(t)
- for i := 0; i < demos.MaxConcurrentDemos; i++ {
- if w := e.do(http.MethodPost, "/api/v1/demos", tok, gin.H{}); w.Code != http.StatusAccepted {
- t.Fatalf("create %d attendu 202, reçu %d", i, w.Code)
- }
- }
- w := e.do(http.MethodPost, "/api/v1/demos", tok, gin.H{})
- if w.Code != http.StatusConflict {
- t.Fatalf("6ᵉ démo attendu 409, reçu %d", w.Code)
- }
-}
diff --git a/control-plane/api/internal/test/helpers_test.go b/control-plane/api/internal/test/helpers_test.go
deleted file mode 100644
index 00a4265..0000000
--- a/control-plane/api/internal/test/helpers_test.go
+++ /dev/null
@@ -1,111 +0,0 @@
-// Package apitest regroupe les tests unitaires et de sécurité de l'API Omnex
-// (tests en boîte noire, isolés du code de production dans un dossier dédié).
-package apitest
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "net/http/httptest"
- "testing"
- "time"
-
- "github.com/gin-gonic/gin"
-
- "github.com/omnex/control-plane/api/internal/auth"
- "github.com/omnex/control-plane/api/internal/config"
- "github.com/omnex/control-plane/api/internal/demos"
- "github.com/omnex/control-plane/api/internal/leads"
- "github.com/omnex/control-plane/api/internal/router"
- "github.com/omnex/control-plane/api/internal/session"
-)
-
-const testSecret = "01234567890123456789012345678901"
-
-// seedUsers : UserStore mémoire pour les tests.
-type seedUsers map[string]auth.User
-
-func (m seedUsers) ByUsername(username string) (auth.User, bool) { u, ok := m[username]; return u, ok }
-
-func (m seedUsers) Create(username, passwordHash string, role auth.Role) (auth.User, error) {
- u := auth.User{ID: "u-" + username, Username: username, PasswordHash: passwordHash, Role: role}
- m[username] = u
- return u, nil
-}
-
-// testEnv : dépendances assemblées pour un test HTTP.
-type testEnv struct {
- engine *gin.Engine
- issuer *auth.Issuer
- sessions session.Manager
-}
-
-func newTestEnv(t *testing.T) testEnv {
- return newTestEnvOpts(t)
-}
-
-// newTestEnvOpts permet de configurer un code d'inscription.
-func newTestEnvOpts(t *testing.T) testEnv {
- t.Helper()
- gin.SetMode(gin.TestMode)
- iss := auth.NewIssuer([]byte(testSecret), time.Minute)
- sessions := session.NewMemManager(time.Hour)
-
- hash, _ := auth.HashPassword("correct-horse")
- users := seedUsers{"sales": {ID: "u1", Username: "sales", PasswordHash: hash, Role: auth.RoleClient}}
-
- demoSvc := demos.NewService(demos.NewMemStore(), demos.NewMemPool(), demos.NoopProvisioner{}, demos.Config{})
-
- deps := router.Deps{
- Cfg: config.Config{Env: "test", AllowedOrigins: []string{"http://localhost:5173"}},
- Issuer: iss,
- Sessions: sessions,
- AuthH: auth.NewHandler(users, sessions, iss, false),
- LeadsH: leads.NewHandler(leads.NewMemStore()),
- DemosH: demos.NewHandler(demoSvc),
- }
- return testEnv{engine: router.New(deps), issuer: iss, sessions: sessions}
-}
-
-// token : JWT commercial (rôle sales) adossé à une vraie session Redis(mem).
-func (e testEnv) token(t *testing.T) string { return e.tokenAs(t, auth.RoleClient) }
-
-// adminToken : JWT administrateur.
-func (e testEnv) adminToken(t *testing.T) string { return e.tokenAs(t, auth.RoleAdmin) }
-
-func (e testEnv) tokenAs(t *testing.T, role auth.Role) string {
- t.Helper()
- sid, err := e.sessions.Create(context.Background(), session.Session{UserID: "u1", Role: string(role)})
- if err != nil {
- t.Fatalf("create session: %v", err)
- }
- tok, err := e.issuer.Issue("u1", role, sid)
- if err != nil {
- t.Fatalf("issue token: %v", err)
- }
- return tok
-}
-
-func (e testEnv) do(method, path, token string, body any) *httptest.ResponseRecorder {
- var buf bytes.Buffer
- if body != nil {
- _ = json.NewEncoder(&buf).Encode(body)
- }
- req := httptest.NewRequest(method, path, &buf)
- req.Header.Set("Content-Type", "application/json")
- if token != "" {
- req.Header.Set("Authorization", "Bearer "+token)
- }
- w := httptest.NewRecorder()
- e.engine.ServeHTTP(w, req)
- return w
-}
-
-func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T {
- t.Helper()
- var v T
- if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
- t.Fatalf("decode: %v — body=%s", err, w.Body)
- }
- return v
-}
diff --git a/control-plane/api/internal/test/http_test.go b/control-plane/api/internal/test/http_test.go
deleted file mode 100644
index ba08061..0000000
--- a/control-plane/api/internal/test/http_test.go
+++ /dev/null
@@ -1,142 +0,0 @@
-package apitest
-
-import (
- "bytes"
- "net/http"
- "testing"
-
- "github.com/gin-gonic/gin"
-)
-
-func TestLoginSuccess(t *testing.T) {
- e := newTestEnv(t)
- w := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "sales", "password": "correct-horse"})
- if w.Code != http.StatusOK {
- t.Fatalf("login attendu 200, reçu %d: %s", w.Code, w.Body)
- }
- if tok := decode[map[string]any](t, w)["token"]; tok == nil || tok == "" {
- t.Fatal("login doit renvoyer un token")
- }
- // Le cookie de session httpOnly doit être posé.
- if len(w.Result().Cookies()) == 0 {
- t.Fatal("login doit poser un cookie de session")
- }
-}
-
-// Inscription : crée un compte et connecte directement (201 + token).
-func TestRegisterCreatesAccountAndSession(t *testing.T) {
- e := newTestEnv(t)
- w := e.do(http.MethodPost, "/api/v1/auth/register", "", gin.H{"username": "newsales", "password": "s3cure-pass-1"})
- if w.Code != http.StatusCreated {
- t.Fatalf("register attendu 201, reçu %d: %s", w.Code, w.Body)
- }
- tok, _ := decode[map[string]any](t, w)["token"].(string)
- if tok == "" {
- t.Fatal("register doit renvoyer un token")
- }
- // Le token doit donner accès aux routes protégées.
- if g := e.do(http.MethodGet, "/api/v1/leads", tok, nil); g.Code != http.StatusOK {
- t.Fatalf("accès après inscription attendu 200, reçu %d", g.Code)
- }
-}
-
-// Inscription : username déjà pris => 409.
-func TestRegisterDuplicateUsername(t *testing.T) {
- e := newTestEnv(t)
- // "sales" existe déjà (seedé).
- w := e.do(http.MethodPost, "/api/v1/auth/register", "", gin.H{"username": "sales", "password": "s3cure-pass-1"})
- if w.Code != http.StatusConflict {
- t.Fatalf("attendu 409, reçu %d", w.Code)
- }
-}
-
-// Sécurité : mot de passe trop court rejeté à la validation.
-func TestRegisterRejectsWeakPassword(t *testing.T) {
- e := newTestEnv(t)
- w := e.do(http.MethodPost, "/api/v1/auth/register", "", gin.H{"username": "weakling", "password": "short"})
- if w.Code != http.StatusBadRequest {
- t.Fatalf("attendu 400, reçu %d", w.Code)
- }
-}
-
-// Sécurité : après logout, le JWT (même valide) est refusé — session Redis révoquée.
-func TestLogoutRevokesSession(t *testing.T) {
- e := newTestEnv(t)
- login := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "sales", "password": "correct-horse"})
- tok, _ := decode[map[string]any](t, login)["token"].(string)
-
- if w := e.do(http.MethodGet, "/api/v1/leads", tok, nil); w.Code != http.StatusOK {
- t.Fatalf("accès avant logout attendu 200, reçu %d", w.Code)
- }
- if w := e.do(http.MethodPost, "/api/v1/auth/logout", tok, nil); w.Code != http.StatusOK {
- t.Fatalf("logout attendu 200, reçu %d", w.Code)
- }
- // Même token, mais session supprimée => 401.
- if w := e.do(http.MethodGet, "/api/v1/leads", tok, nil); w.Code != http.StatusUnauthorized {
- t.Fatalf("après logout attendu 401, reçu %d", w.Code)
- }
-}
-
-func TestLoginWrongPassword(t *testing.T) {
- e := newTestEnv(t)
- w := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "sales", "password": "wrong-password"})
- if w.Code != http.StatusUnauthorized {
- t.Fatalf("attendu 401, reçu %d", w.Code)
- }
-}
-
-// Sécurité : pas d'énumération — même réponse pour un utilisateur inconnu.
-func TestLoginUnknownUserSameResponse(t *testing.T) {
- e := newTestEnv(t)
- w := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "ghost", "password": "whatever8"})
- if w.Code != http.StatusUnauthorized {
- t.Fatalf("attendu 401 (pas d'énumération), reçu %d", w.Code)
- }
-}
-
-// Sécurité : un username avec caractères spéciaux (tentative d'injection) rejeté à la validation.
-func TestLoginRejectsNonAlnumUsername(t *testing.T) {
- e := newTestEnv(t)
- w := e.do(http.MethodPost, "/api/v1/auth/login", "", gin.H{"username": "sales' OR '1'='1", "password": "whatever8"})
- if w.Code != http.StatusBadRequest {
- t.Fatalf("attendu 400 (validation), reçu %d", w.Code)
- }
-}
-
-func TestLeadsListRequiresAuth(t *testing.T) {
- e := newTestEnv(t)
- w := e.do(http.MethodGet, "/api/v1/leads", "", nil)
- if w.Code != http.StatusUnauthorized {
- t.Fatalf("attendu 401 sans token, reçu %d", w.Code)
- }
-}
-
-func TestLeadsListRejectsForgedToken(t *testing.T) {
- e := newTestEnv(t)
- w := e.do(http.MethodGet, "/api/v1/leads", "eyJ.forged.token", nil)
- if w.Code != http.StatusUnauthorized {
- t.Fatalf("attendu 401 token forgé, reçu %d", w.Code)
- }
-}
-
-// Feature lead : création publique + XSS stocké neutralisé.
-func TestCreateLeadSanitizesXSS(t *testing.T) {
- e := newTestEnv(t)
- payload := gin.H{"company": "", "message": "hi"}
- w := e.do(http.MethodPost, "/api/v1/leads", "", payload)
- if w.Code != http.StatusCreated {
- t.Fatalf("attendu 201, reçu %d: %s", w.Code, w.Body)
- }
- list := e.do(http.MethodGet, "/api/v1/leads", e.token(t), nil)
- if bytes.Contains(list.Body.Bytes(), []byte("