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("