chore: update
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
Reference in New Issue
Block a user