chore: update
ci-api / test (push) Successful in 22m1s
ci-web / test (push) Successful in 13m23s

This commit is contained in:
Nuxgrid
2026-08-02 13:48:21 +02:00
parent eeec8e239b
commit d4009d329c
10 changed files with 343 additions and 604 deletions
+29 -1
View File
@@ -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.
@@ -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 {
@@ -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{}
@@ -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")
}
}
@@ -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)
}
}
@@ -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
}
@@ -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": "<script>alert(1)</script>", "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("<script>")) {
t.Fatal("le HTML brut ne doit pas être stocké/renvoyé (XSS)")
}
}
func TestCreateLeadRejectsInvalidEmail(t *testing.T) {
e := newTestEnv(t)
w := e.do(http.MethodPost, "/api/v1/leads", "", gin.H{"company": "Corp", "email": "not-an-email"})
if w.Code != http.StatusBadRequest {
t.Fatalf("attendu 400, reçu %d", w.Code)
}
}
@@ -1,63 +0,0 @@
package apitest
import (
"os"
"testing"
"gorm.io/gorm"
"github.com/omnex/control-plane/api/internal/db"
"github.com/omnex/control-plane/api/internal/demos"
"github.com/omnex/control-plane/api/internal/leads"
)
// Tests d'intégration GORM/PostgreSQL. Skippés si OMNEX_TEST_DATABASE_URL absent.
// En CI : lancés contre un PostgreSQL éphémère (testcontainers-go / service kind).
func openTestDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("OMNEX_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("OMNEX_TEST_DATABASE_URL non défini — test d'intégration ignoré")
}
gdb, err := db.Open(dsn)
if err != nil {
t.Fatalf("open: %v", err)
}
if err := db.AutoMigrate(gdb); err != nil {
t.Fatalf("migrate: %v", err)
}
return gdb
}
func TestLeadsGormCRUD(t *testing.T) {
gdb := openTestDB(t)
store := leads.NewGormStore(gdb)
created, err := store.Create(leads.Lead{Telegram: "Corp", Message: "hi"})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.ID == "" || created.Status != leads.StatusNew {
t.Fatalf("lead créé invalide: %+v", created)
}
if _, ok := store.SetStatus(created.ID, leads.StatusContacted); !ok {
t.Fatal("setstatus a échoué")
}
}
func TestDemosGormPoolAndCapacity(t *testing.T) {
gdb := openTestDB(t)
if err := db.SeedExternalPool(gdb); err != nil {
t.Fatalf("seed pool: %v", err)
}
svc := demos.NewService(demos.NewGormStore(gdb), demos.NewGormPool(gdb), demos.NoopProvisioner{}, demos.Config{})
d, err := svc.Create("")
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := svc.Delete(d.ID); err != nil {
t.Fatalf("delete: %v", err)
}
}
+134 -3
View File
@@ -1,6 +1,5 @@
import { useState } from 'react'
import {
Box,
Button,
FormControl,
FormLabel,
@@ -36,13 +35,23 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
const toast = useToast()
const [username, setUsername] = useState(lockedUsername ?? '')
const [adminUsername, setAdminUsername] = useState('admin')
const [adminPassword, setAdminPassword] = useState('')
const [enableTelegram, setEnableTelegram] = useState(false)
const [telegramBotUsername, setTelegramBotUsername] = useState('')
const [telegramBotToken, setTelegramBotToken] = useState('')
const [enableNowPayments, setEnableNowPayments] = useState(false)
const [nowPaymentsApiKey, setNowPaymentsApiKey] = useState('')
const [nowPaymentsIpnSecret, setNowPaymentsIpnSecret] = useState('')
const [enableLBTelegram, setEnableLBTelegram] = useState(false)
const [lbBot1Username, setLbBot1Username] = useState('')
const [lbBot1Token, setLbBot1Token] = useState('')
const [lbBot2Username, setLbBot2Username] = useState('')
const [lbBot2Token, setLbBot2Token] = useState('')
const [storageDriver, setStorageDriver] = useState<StorageDriver>('local')
const [s3Bucket, setS3Bucket] = useState('')
const [s3Endpoint, setS3Endpoint] = useState('')
@@ -50,11 +59,19 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
const reset = () => {
setUsername(lockedUsername ?? '')
setAdminUsername('admin')
setAdminPassword('')
setEnableTelegram(false)
setTelegramBotUsername('')
setTelegramBotToken('')
setEnableNowPayments(false)
setNowPaymentsApiKey('')
setNowPaymentsIpnSecret('')
setEnableLBTelegram(false)
setLbBot1Username('')
setLbBot1Token('')
setLbBot2Username('')
setLbBot2Token('')
setStorageDriver('local')
setS3Bucket('')
setS3Endpoint('')
@@ -71,14 +88,25 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
toast({ status: 'warning', title: 'Username requis' })
return
}
if (!adminUsername.trim() || adminPassword.trim().length < 8) {
toast({ status: 'warning', title: 'Identifiants admin requis (mot de passe : 8 caractères min.)' })
return
}
if (storageDriver === 's3' && (!s3Bucket.trim() || !s3Endpoint.trim())) {
toast({ status: 'warning', title: 'Bucket et endpoint S3 requis' })
return
}
if (enableLBTelegram && !lbBot1Username.trim() && !lbBot2Username.trim()) {
toast({ status: 'warning', title: 'Au moins un bot (username) requis pour le load-balancer' })
return
}
const params: CreateDemoParams = {
username: username.trim(),
leadId,
adminUsername: adminUsername.trim(),
adminPassword: adminPassword.trim(),
telegramBotUsername: enableTelegram ? telegramBotUsername.trim() || undefined : undefined,
telegramBotToken: enableTelegram ? telegramBotToken.trim() || undefined : undefined,
nowPaymentsApiKey: enableNowPayments ? nowPaymentsApiKey.trim() || undefined : undefined,
nowPaymentsIpnSecret: enableNowPayments ? nowPaymentsIpnSecret.trim() || undefined : undefined,
@@ -86,6 +114,14 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
...(storageDriver === 's3'
? { s3Bucket: s3Bucket.trim(), s3Endpoint: s3Endpoint.trim() }
: {}),
...(enableLBTelegram
? {
lbBot1Username: lbBot1Username.trim() || undefined,
lbBot1Token: lbBot1Token.trim() || undefined,
lbBot2Username: lbBot2Username.trim() || undefined,
lbBot2Token: lbBot2Token.trim() || undefined,
}
: {}),
}
setSubmitting(true)
@@ -120,6 +156,35 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
/>
</FormControl>
{/* --- Compte admin de la démo --- */}
<Stack spacing={3} p={3} borderWidth="1px" borderRadius="md">
<Text fontSize="sm" fontWeight="semibold">
Compte admin de la démo
</Text>
<Text fontSize="xs" color="gray.500">
Créé une fois postgres/redis/backend/frontend démarrés c'est ce que le client utilisera pour se connecter au backoffice de sa démo.
</Text>
<HStack spacing={3} align="start">
<FormControl isRequired isDisabled={submitting}>
<FormLabel fontSize="sm">Username</FormLabel>
<Input
value={adminUsername}
onChange={(e) => setAdminUsername(e.target.value)}
/>
</FormControl>
<FormControl isRequired isDisabled={submitting}>
<FormLabel fontSize="sm">Mot de passe</FormLabel>
<Input
placeholder="8 caractères min."
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
type="password"
autoComplete="off"
/>
</FormControl>
</HStack>
</Stack>
{/* --- Telegram --- */}
<FormControl isDisabled={submitting}>
<HStack justify="space-between">
@@ -131,7 +196,15 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
</HStack>
</FormControl>
{enableTelegram && (
<Box pl={3} borderLeftWidth="2px" borderColor="primary.500">
<Stack spacing={3} pl={3} borderLeftWidth="2px" borderColor="primary.500">
<FormControl isDisabled={submitting}>
<FormLabel fontSize="sm">Nom du bot (username)</FormLabel>
<Input
placeholder="mon_bot"
value={telegramBotUsername}
onChange={(e) => setTelegramBotUsername(e.target.value)}
/>
</FormControl>
<FormControl isDisabled={submitting}>
<FormLabel fontSize="sm">Token bot Telegram</FormLabel>
<Input
@@ -142,7 +215,7 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
autoComplete="off"
/>
</FormControl>
</Box>
</Stack>
)}
{/* --- NowPayments --- */}
@@ -183,6 +256,64 @@ export function CreateDemoModal({ isOpen, onClose, onCreated, leadId, lockedUser
</Stack>
)}
{/* --- Load-balancer Telegram (multi-bots) --- */}
<FormControl isDisabled={submitting}>
<HStack justify="space-between">
<FormLabel mb={0}>Load-balancer Telegram</FormLabel>
<Switch
isChecked={enableLBTelegram}
onChange={(e) => setEnableLBTelegram(e.target.checked)}
/>
</HStack>
</FormControl>
{enableLBTelegram && (
<Stack spacing={4} pl={3} borderLeftWidth="2px" borderColor="primary.500">
<Text fontSize="xs" color="gray.500">
Répartit le trafic entre plusieurs bots. Renseignez au moins le bot 1 ; le bot 2 est optionnel.
</Text>
<HStack spacing={3} align="start">
<FormControl isDisabled={submitting}>
<FormLabel fontSize="sm">Bot 1 — username</FormLabel>
<Input
placeholder="mon_bot_1"
value={lbBot1Username}
onChange={(e) => setLbBot1Username(e.target.value)}
/>
</FormControl>
<FormControl isDisabled={submitting}>
<FormLabel fontSize="sm">Bot 1 — token</FormLabel>
<Input
placeholder="123456:ABC-DEF..."
value={lbBot1Token}
onChange={(e) => setLbBot1Token(e.target.value)}
type="password"
autoComplete="off"
/>
</FormControl>
</HStack>
<HStack spacing={3} align="start">
<FormControl isDisabled={submitting}>
<FormLabel fontSize="sm">Bot 2 — username (optionnel)</FormLabel>
<Input
placeholder="mon_bot_2"
value={lbBot2Username}
onChange={(e) => setLbBot2Username(e.target.value)}
/>
</FormControl>
<FormControl isDisabled={submitting}>
<FormLabel fontSize="sm">Bot 2 — token</FormLabel>
<Input
placeholder="123456:ABC-DEF..."
value={lbBot2Token}
onChange={(e) => setLbBot2Token(e.target.value)}
type="password"
autoComplete="off"
/>
</FormControl>
</HStack>
</Stack>
)}
{/* --- Stockage --- */}
<FormControl isDisabled={submitting}>
<FormLabel>Stockage des fichiers</FormLabel>
+12
View File
@@ -77,12 +77,19 @@ export type StorageDriver = 'local' | 's3'
export interface CreateDemoParams {
username: string
leadId?: string
telegramBotUsername?: string
telegramBotToken?: string
nowPaymentsApiKey?: string
nowPaymentsIpnSecret?: string
storageDriver: StorageDriver
s3Bucket?: string
s3Endpoint?: string
lbBot1Username?: string
lbBot1Token?: string
lbBot2Username?: string
lbBot2Token?: string
adminUsername: string
adminPassword: string
}
export interface CodeBuySub {
@@ -168,6 +175,7 @@ export const api = {
request<Demo>('POST', '/demos', {
username: params.username,
...(params.leadId ? { lead_id: params.leadId } : {}),
...(params.telegramBotUsername ? { telegram_bot_username: params.telegramBotUsername } : {}),
...(params.telegramBotToken ? { telegram_bot_token: params.telegramBotToken } : {}),
...(params.nowPaymentsApiKey ? { nowpayments_api_key: params.nowPaymentsApiKey } : {}),
...(params.nowPaymentsIpnSecret ? { nowpayments_ipn_secret: params.nowPaymentsIpnSecret } : {}),
@@ -175,6 +183,10 @@ export const api = {
...(params.storageDriver === 's3'
? { s3_bucket: params.s3Bucket, s3_endpoint: params.s3Endpoint }
: {}),
...(params.lbBot1Username ? { lb_bot1_username: params.lbBot1Username, lb_bot1_token: params.lbBot1Token } : {}),
...(params.lbBot2Username ? { lb_bot2_username: params.lbBot2Username, lb_bot2_token: params.lbBot2Token } : {}),
admin_username: params.adminUsername,
admin_password: params.adminPassword,
}),
extendDemo: (id: string) => request<Demo>('POST', `/demos/${id}/extend`),
deleteDemo: (id: string) => request<Demo>('DELETE', `/demos/${id}`),