@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*_test.go
|
||||||
|
test/
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# === Omnex control-plane API — variables d'environnement ===
|
||||||
|
# Copier en .env (jamais versionné) et compléter.
|
||||||
|
|
||||||
|
# Obligatoire : secret de signature JWT (min 32 octets)
|
||||||
|
OMNEX_JWT_SECRET=change-me-32-bytes-minimum-secret!!
|
||||||
|
|
||||||
|
# Adresse d'écoute
|
||||||
|
OMNEX_ADDR=:8080
|
||||||
|
|
||||||
|
# "dev" | "prod" (en prod, OMNEX_DATABASE_URL et OMNEX_REDIS_URL deviennent obligatoires)
|
||||||
|
OMNEX_ENV=dev
|
||||||
|
|
||||||
|
# CORS : origines autorisées (vitrine + back-office), séparées par des virgules
|
||||||
|
OMNEX_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
# PostgreSQL (GORM). Vide en dev => store mémoire volatil.
|
||||||
|
# OMNEX_DATABASE_URL=host=localhost user=omnex password=omnex dbname=omnex port=5432 sslmode=disable
|
||||||
|
|
||||||
|
# Redis (sessions). Vide en dev => sessions en mémoire volatile.
|
||||||
|
# OMNEX_REDIS_URL=redis://:omnexredis@localhost:6379/0
|
||||||
|
|
||||||
|
# Seed de l'utilisateur admin de départ (optionnel) — nom d'utilisateur, pas email
|
||||||
|
OMNEX_SEED_USERNAME=admin
|
||||||
|
OMNEX_SEED_PASSWORD=change-me-strong-password
|
||||||
|
|
||||||
|
# Domaine des démos déployées dans le cluster
|
||||||
|
OMNEX_DEMO_DOMAIN=demo.omnex.app
|
||||||
|
|
||||||
|
# Tests d'intégration GORM (optionnel) — base jetable
|
||||||
|
# OMNEX_TEST_DATABASE_URL=host=localhost user=omnex password=omnex dbname=omnex_test port=5432 sslmode=disable
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
FROM golang:1.26-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache ca-certificates wget curl \
|
||||||
|
&& wget -q https://get.helm.sh/helm-v3.16.1-linux-amd64.tar.gz -O /tmp/helm.tar.gz \
|
||||||
|
&& tar -xzf /tmp/helm.tar.gz -C /tmp \
|
||||||
|
&& mv /tmp/linux-amd64/helm /usr/local/bin/helm \
|
||||||
|
&& rm -rf /tmp/helm.tar.gz /tmp/linux-amd64 \
|
||||||
|
&& adduser -D -u 10001 omnex
|
||||||
|
COPY --from=build /out/api /api
|
||||||
|
USER omnex
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
|
||||||
|
ENTRYPOINT ["/api"]
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Commande api : point d'entrée du control-plane Omnex.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
"github.com/omnex/control-plane/api/internal/auth"
|
||||||
|
"github.com/omnex/control-plane/api/internal/config"
|
||||||
|
"github.com/omnex/control-plane/api/internal/db"
|
||||||
|
"github.com/omnex/control-plane/api/internal/demos"
|
||||||
|
"github.com/omnex/control-plane/api/internal/k8s"
|
||||||
|
"github.com/omnex/control-plane/api/internal/leads"
|
||||||
|
"github.com/omnex/control-plane/api/internal/router"
|
||||||
|
"github.com/omnex/control-plane/api/internal/sav"
|
||||||
|
"github.com/omnex/control-plane/api/internal/session"
|
||||||
|
"github.com/omnex/control-plane/api/internal/sub"
|
||||||
|
)
|
||||||
|
|
||||||
|
// memUsers : store utilisateurs en mémoire (fallback dev sans base).
|
||||||
|
type memUsers map[string]auth.User
|
||||||
|
|
||||||
|
func (m memUsers) ByUsername(username string) (auth.User, bool) {
|
||||||
|
u, ok := m[strings.ToLower(strings.TrimSpace(username))]
|
||||||
|
return u, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m memUsers) Create(username, passwordHash string, role auth.Role) (auth.User, error) {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(username))
|
||||||
|
u := auth.User{ID: uuid.NewString(), Username: key, PasswordHash: passwordHash, Role: role}
|
||||||
|
m[key] = u
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
iss := auth.NewIssuer(cfg.JWTSecret, cfg.SessionTTL)
|
||||||
|
|
||||||
|
// Sessions : Redis en prod/dev configuré, mémoire en fallback dev.
|
||||||
|
var sessions session.Manager
|
||||||
|
if cfg.RedisURL != "" {
|
||||||
|
opt, err := redis.ParseURL(cfg.RedisURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("redis url: %v", err)
|
||||||
|
}
|
||||||
|
rdb := redis.NewClient(opt)
|
||||||
|
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
||||||
|
log.Fatalf("redis ping: %v", err)
|
||||||
|
}
|
||||||
|
sessions = session.NewRedisManager(rdb, cfg.SessionTTL)
|
||||||
|
log.Printf("sessions: Redis")
|
||||||
|
} else {
|
||||||
|
sessions = session.NewMemManager(cfg.SessionTTL)
|
||||||
|
log.Printf("sessions: mémoire (dev — définir OMNEX_REDIS_URL pour Redis)")
|
||||||
|
}
|
||||||
|
|
||||||
|
var userStore auth.UserStore
|
||||||
|
var leadStore leads.Store
|
||||||
|
var demoStore demos.Store
|
||||||
|
var demoPool demos.Pool
|
||||||
|
var codeStore sub.Store
|
||||||
|
var contactStore sav.Store
|
||||||
|
if cfg.DatabaseURL != "" {
|
||||||
|
gdb, err := db.Open(cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("db: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(gdb); err != nil {
|
||||||
|
log.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.SeedExternalPool(gdb); err != nil {
|
||||||
|
log.Fatalf("seed pool: %v", err)
|
||||||
|
}
|
||||||
|
users := auth.NewGormStore(gdb)
|
||||||
|
seedAdmin(users)
|
||||||
|
userStore = users
|
||||||
|
codeStore = sub.NewGormStore(gdb)
|
||||||
|
leadStore = leads.NewGormStore(gdb)
|
||||||
|
demoStore = demos.NewGormStore(gdb)
|
||||||
|
demoPool = demos.NewGormPool(gdb)
|
||||||
|
contactStore = sav.NewGormStore(gdb)
|
||||||
|
log.Printf("persistance: PostgreSQL (GORM)")
|
||||||
|
} else {
|
||||||
|
userStore = seedMemUsers()
|
||||||
|
codeStore = sub.NewMemStore()
|
||||||
|
leadStore = leads.NewMemStore()
|
||||||
|
demoStore = demos.NewMemStore()
|
||||||
|
demoPool = demos.NewMemPool()
|
||||||
|
contactStore = sav.NewMemContactStore()
|
||||||
|
log.Printf("persistance: mémoire (dev — définir OMNEX_DATABASE_URL pour PostgreSQL)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer le client Kubernetes
|
||||||
|
k8sClient, err := k8s.NewClient(&cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("k8s client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
helmProv, err := demos.NewHelmProvisioner(&cfg, k8sClient, "/charts", "helm")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("helm provisioner: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service de démos avec le provisioner Helm
|
||||||
|
demoSvc := demos.NewService(demoStore, demoPool, helmProv, demos.Config{
|
||||||
|
BaseDomain: cfg.DemoDomain,
|
||||||
|
TTL: demos.TTL,
|
||||||
|
MaxDemos: demos.MaxConcurrentDemos,
|
||||||
|
})
|
||||||
|
|
||||||
|
deps := router.Deps{
|
||||||
|
Cfg: cfg,
|
||||||
|
Issuer: iss,
|
||||||
|
Sessions: sessions,
|
||||||
|
AuthH: auth.NewHandler(userStore, sessions, iss, cfg.Secure()),
|
||||||
|
LeadsH: leads.NewHandler(leadStore),
|
||||||
|
DemosH: demos.NewHandler(demoSvc),
|
||||||
|
SubH: sub.NewHandler(codeStore),
|
||||||
|
ContactH: sav.NewHandler(contactStore),
|
||||||
|
}
|
||||||
|
|
||||||
|
r := router.New(deps)
|
||||||
|
log.Printf("Omnex API sur %s (env=%s)", cfg.Addr, cfg.Env)
|
||||||
|
if err := r.Run(cfg.Addr); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedAdmin crée l'utilisateur admin de départ depuis l'env (idempotent).
|
||||||
|
func seedAdmin(users *auth.GormStore) {
|
||||||
|
username, pass := os.Getenv("OMNEX_SEED_USERNAME"), os.Getenv("OMNEX_SEED_PASSWORD")
|
||||||
|
if username == "" || pass == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(pass)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
if err := users.EnsureUser(username, hash, auth.RoleAdmin); err != nil {
|
||||||
|
log.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("utilisateur admin seedé: %s", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedMemUsers construit un store mémoire depuis l'env (dev uniquement).
|
||||||
|
func seedMemUsers() memUsers {
|
||||||
|
users := memUsers{}
|
||||||
|
if username, pass := os.Getenv("OMNEX_SEED_USERNAME"), os.Getenv("OMNEX_SEED_PASSWORD"); username != "" && pass != "" {
|
||||||
|
hash, err := auth.HashPassword(pass)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(username))
|
||||||
|
users[key] = auth.User{ID: "seed-admin", Username: key, PasswordHash: hash, Role: auth.RoleAdmin}
|
||||||
|
log.Printf("utilisateur admin seedé (mémoire): %s", key)
|
||||||
|
}
|
||||||
|
return users
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
module github.com/omnex/control-plane/api
|
||||||
|
|
||||||
|
go 1.26.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/redis/go-redis/v9 v9.7.0
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3
|
||||||
|
golang.org/x/crypto v0.47.0
|
||||||
|
golang.org/x/time v0.14.0
|
||||||
|
gorm.io/driver/postgres v1.5.9
|
||||||
|
gorm.io/gorm v1.25.12
|
||||||
|
k8s.io/api v0.36.3
|
||||||
|
k8s.io/apimachinery v0.36.3
|
||||||
|
k8s.io/client-go v0.36.3
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
|
golang.org/x/net v0.49.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.34.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
golang.org/x/term v0.39.0 // indirect
|
||||||
|
golang.org/x/text v0.33.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||||
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
k8s.io/klog/v2 v2.140.0 // indirect
|
||||||
|
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
|
||||||
|
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
|
||||||
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||||
|
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect
|
||||||
|
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
|
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||||
|
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
|
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||||
|
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||||
|
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||||
|
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||||
|
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||||
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||||
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||||
|
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
|
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
|
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||||
|
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||||
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
|
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
||||||
|
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
|
||||||
|
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||||
|
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||||
|
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
|
||||||
|
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||||
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
|
k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w=
|
||||||
|
k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg=
|
||||||
|
k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM=
|
||||||
|
k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE=
|
||||||
|
k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg=
|
||||||
|
k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30=
|
||||||
|
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
|
||||||
|
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
|
||||||
|
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
|
||||||
|
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
|
||||||
|
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||||
|
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||||
|
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.3 h1:u08YRbVUi59ri4YD6cg0UqNM4Dimn0sIl+wldcx5PYw=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||||
|
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||||
|
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GormStore : UserStore adossé à PostgreSQL via GORM.
|
||||||
|
type GormStore struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGormStore(db *gorm.DB) *GormStore {
|
||||||
|
return &GormStore{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByUsername cherche un utilisateur (requête paramétrée : anti-injection).
|
||||||
|
func (s *GormStore) ByUsername(username string) (User, bool) {
|
||||||
|
var u User
|
||||||
|
err := s.db.Where("username = ?", NormalizeUsername(username)).First(&u).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return User{}, false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return User{}, false
|
||||||
|
}
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create insère un utilisateur (usage seed/admin), mot de passe déjà haché.
|
||||||
|
func (s *GormStore) Create(username, passwordHash string, role Role) (User, error) {
|
||||||
|
u := User{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Username: NormalizeUsername(username),
|
||||||
|
PasswordHash: passwordHash,
|
||||||
|
Role: role,
|
||||||
|
}
|
||||||
|
if role == RoleAdmin {
|
||||||
|
u.TypeAbo = "admin"
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&u).Error; err != nil {
|
||||||
|
return User{}, err
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureUser crée l'utilisateur s'il n'existe pas encore (idempotent, pour le seed).
|
||||||
|
func (s *GormStore) EnsureUser(username, passwordHash string, role Role) error {
|
||||||
|
if _, found := s.ByUsername(username); found {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.Create(username, passwordHash, role)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/omnex/control-plane/api/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (User) TableName() string { return "users" }
|
||||||
|
|
||||||
|
type UserStore interface {
|
||||||
|
ByUsername(username string) (User, bool)
|
||||||
|
Create(username, passwordHash string, role Role) (User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
users UserStore
|
||||||
|
sessions session.Manager
|
||||||
|
iss *Issuer
|
||||||
|
secure bool // cookie Secure (activé en prod/HTTPS)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(users UserStore, sessions session.Manager, iss *Issuer, secure bool) *Handler {
|
||||||
|
return &Handler{users: users, sessions: sessions, iss: iss, secure: secure}
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authentifie, ouvre une session Redis et pose le cookie httpOnly.
|
||||||
|
// Réponse volontairement uniforme (pas d'énumération d'utilisateurs).
|
||||||
|
func (h *Handler) Login(c *gin.Context) {
|
||||||
|
var req loginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, found := h.users.ByUsername(req.Username)
|
||||||
|
if !found {
|
||||||
|
// On hache quand même une valeur bidon pour égaliser le temps de réponse.
|
||||||
|
_, _ = VerifyPassword(req.Password, dummyHash)
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "identifiants invalides"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := VerifyPassword(req.Password, user.PasswordHash)
|
||||||
|
if err != nil || !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "identifiants invalides"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.startSession(c, user)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"token": token, "token_type": "Bearer", "role": user.Role})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me renvoie l'identité de la session courante (dont le rôle, pour le front).
|
||||||
|
func (h *Handler) Me(c *gin.Context) {
|
||||||
|
p := PrincipalFrom(c)
|
||||||
|
if p == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, found := h.users.ByUsername(p.Username)
|
||||||
|
if !found {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
typeAbo := user.TypeAbo
|
||||||
|
if typeAbo == "premium" && time.Now().UTC().After(user.ExpiredAt) {
|
||||||
|
typeAbo = "demo" // expiré : on ne le renvoie plus comme premium
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"user_id": p.UserID,
|
||||||
|
"username": p.Username,
|
||||||
|
"role": p.Role,
|
||||||
|
"type_abonnement": typeAbo,
|
||||||
|
"expired_at": user.ExpiredAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerRequest struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register crée un compte commercial puis ouvre directement la session.
|
||||||
|
func (h *Handler) Register(c *gin.Context) {
|
||||||
|
var req registerRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := h.users.ByUsername(req.Username); exists {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "nom d'utilisateur déjà pris"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := HashPassword(req.Password)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.users.Create(req.Username, hash, RoleClient)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "nom d'utilisateur déjà pris"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.startSession(c, user)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"token": token, "token_type": "Bearer", "role": user.Role})
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSession ouvre une session Redis, signe le JWT et pose le cookie.
|
||||||
|
func (h *Handler) startSession(c *gin.Context, user User) (string, error) {
|
||||||
|
sid, err := h.sessions.Create(c.Request.Context(), session.Session{
|
||||||
|
UserID: user.ID,
|
||||||
|
Role: string(user.Role),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
token, err := h.iss.Issue(user.ID, user.Username, user.Role, sid)
|
||||||
|
if err != nil {
|
||||||
|
_ = h.sessions.Delete(c.Request.Context(), sid) // fail-secure
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
h.setSessionCookie(c, token, int(h.sessions.TTL().Seconds()))
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout révoque la session Redis courante et efface le cookie.
|
||||||
|
func (h *Handler) Logout(c *gin.Context) {
|
||||||
|
if p := PrincipalFrom(c); p != nil {
|
||||||
|
if err := h.sessions.Delete(c.Request.Context(), p.SessionID); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.setSessionCookie(c, "", -1)
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "déconnecté"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) setSessionCookie(c *gin.Context, value string, maxAge int) {
|
||||||
|
http.SetCookie(c.Writer, &http.Cookie{
|
||||||
|
Name: session.CookieName,
|
||||||
|
Value: value,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: maxAge,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: h.secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// dummyHash : argon2id d'une valeur arbitraire, pour la mitigation de timing.
|
||||||
|
const dummyHash = "=19=65536,t=1,p=4$" +
|
||||||
|
"3Vk0m5m8c1m6l0k9j8h7g6f5d4s3a2z1x0c9v8b7n6m"
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidToken = errors.New("token invalide")
|
||||||
|
|
||||||
|
// Claims du token Omnex : porte le rôle et l'identifiant de session Redis (sid).
|
||||||
|
// La signature (secret) authentifie le token ; le sid permet la révocation.
|
||||||
|
type Claims struct {
|
||||||
|
Role Role `json:"role"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
SessionID string `json:"sid"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issuer signe et vérifie des JWT HS256.
|
||||||
|
type Issuer struct {
|
||||||
|
secret []byte
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssuer(secret []byte, ttl time.Duration) *Issuer {
|
||||||
|
return &Issuer{secret: secret, ttl: ttl}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue crée un token pour un utilisateur, lié à une session Redis (sid).
|
||||||
|
func (i *Issuer) Issue(userID string, username string, role Role, sid string) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
claims := Claims{
|
||||||
|
Role: role,
|
||||||
|
SessionID: sid,
|
||||||
|
Username: username,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: userID,
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
Issuer: "omnex",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(i.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify valide la signature + l'expiration et renvoie les claims.
|
||||||
|
func (i *Issuer) Verify(tokenStr string) (*Claims, error) {
|
||||||
|
claims := &Claims{}
|
||||||
|
_, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) {
|
||||||
|
// Refuse tout algo autre que HS256 (protection alg-confusion).
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return i.secret, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/omnex/control-plane/api/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ctxPrincipalKey = "omnex.principal"
|
||||||
|
|
||||||
|
// Principal : identité authentifiée injectée dans le contexte de requête.
|
||||||
|
type Principal struct {
|
||||||
|
UserID string
|
||||||
|
Username string
|
||||||
|
Role Role
|
||||||
|
SessionID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenFromRequest lit le JWT : cookie httpOnly en priorité, sinon Bearer.
|
||||||
|
func tokenFromRequest(c *gin.Context) string {
|
||||||
|
if ck, err := c.Request.Cookie(session.CookieName); err == nil && ck.Value != "" {
|
||||||
|
return ck.Value
|
||||||
|
}
|
||||||
|
if t, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer "); ok {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAuth valide le JWT (signature) PUIS l'existence de la session Redis.
|
||||||
|
// Un logout supprime la session : le JWT devient alors invalide avant expiration.
|
||||||
|
func RequireAuth(iss *Issuer, mgr session.Manager) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
raw := tokenFromRequest(c)
|
||||||
|
if raw == "" {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentification requise"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := iss.Verify(raw)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "token invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, found, err := mgr.Get(c.Request.Context(), claims.SessionID)
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session expirée ou révoquée"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set(ctxPrincipalKey, Principal{UserID: claims.Subject, Username: claims.Username, Role: claims.Role, SessionID: claims.SessionID})
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireRole impose un rôle minimal (admin > rôle demandé).
|
||||||
|
func RequireRole(role Role) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
p := PrincipalFrom(c)
|
||||||
|
if p == nil || (p.Role != role && p.Role != RoleAdmin) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "accès refusé"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrincipalFrom récupère l'identité injectée par RequireAuth.
|
||||||
|
func PrincipalFrom(c *gin.Context) *Principal {
|
||||||
|
v, ok := c.Get(ctxPrincipalKey)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p, _ := v.(Principal)
|
||||||
|
return &p
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||||
|
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
|
||||||
|
PasswordHash string `gorm:"not null" json:"-"`
|
||||||
|
Role Role `gorm:"size:20;not null" json:"role"`
|
||||||
|
TypeAbo string `gorm:"type:varchar(35);default:demo" json:"type_abonnement"`
|
||||||
|
ExpiredAt time.Time `json:"expired_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Package auth : hachage de mots de passe (argon2id) et JWT.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Paramètres argon2id (OWASP Password Storage Cheat Sheet).
|
||||||
|
const (
|
||||||
|
argonTime = 1
|
||||||
|
argonMemory = 64 * 1024 // 64 MiB
|
||||||
|
argonThreads = 4
|
||||||
|
argonKeyLen = 32
|
||||||
|
argonSaltLen = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidHash = errors.New("format de hash invalide")
|
||||||
|
|
||||||
|
// HashPassword produit un hash argon2id encodé (format PHC).
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
salt := make([]byte, argonSaltLen)
|
||||||
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||||
|
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||||
|
argon2.Version, argonMemory, argonTime, argonThreads,
|
||||||
|
base64.RawStdEncoding.EncodeToString(salt),
|
||||||
|
base64.RawStdEncoding.EncodeToString(key),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword compare un mot de passe à un hash encodé, en temps constant.
|
||||||
|
func VerifyPassword(password, encoded string) (bool, error) {
|
||||||
|
parts := strings.Split(encoded, "$")
|
||||||
|
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||||
|
return false, ErrInvalidHash
|
||||||
|
}
|
||||||
|
var memory, time uint32
|
||||||
|
var threads uint8
|
||||||
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
|
||||||
|
return false, ErrInvalidHash
|
||||||
|
}
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return false, ErrInvalidHash
|
||||||
|
}
|
||||||
|
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||||
|
if err != nil {
|
||||||
|
return false, ErrInvalidHash
|
||||||
|
}
|
||||||
|
got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
|
||||||
|
return subtle.ConstantTimeCompare(got, want) == 1, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
// Role : rôle applicatif d'un utilisateur du control-plane.
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleClient Role = "client"
|
||||||
|
RoleAdmin Role = "admin"
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const codeCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
// GenerateCode génère un code de 16 caractères alphanumériques,
|
||||||
|
// regroupés par 4 et séparés par des tirets (ex: "A3F9-K2M7-XQ4R-8ZT1").
|
||||||
|
func GenerateCode() (string, error) {
|
||||||
|
const length = 16
|
||||||
|
const groupSize = 4
|
||||||
|
|
||||||
|
b := make([]byte, length)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for i := 0; i < length; i++ {
|
||||||
|
if i > 0 && i%groupSize == 0 {
|
||||||
|
sb.WriteByte('-')
|
||||||
|
}
|
||||||
|
sb.WriteByte(codeCharset[int(b[i])%len(codeCharset)])
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeUsername(u string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(u))
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Package config charge la configuration depuis l'environnement.
|
||||||
|
// Secure by design : aucun secret par défaut en dur, tout vient de l'env.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Addr string
|
||||||
|
JWTSecret []byte // OMNEX_JWT_SECRET (min 32 octets) — signe les tokens
|
||||||
|
SessionTTL time.Duration // durée de vie JWT + session Redis
|
||||||
|
AllowedOrigins []string // CORS (vitrine + back-office)
|
||||||
|
Env string // "dev" | "prod"
|
||||||
|
DatabaseURL string // OMNEX_DATABASE_URL (DSN PostgreSQL GORM)
|
||||||
|
RedisURL string // OMNEX_REDIS_URL (sessions), ex. redis://:pass@host:6379/0
|
||||||
|
DemoDomain string // domaine des démos
|
||||||
|
RegistrationCode string // OMNEX_REGISTRATION_CODE (vide = inscription ouverte)
|
||||||
|
Kubeconfig string
|
||||||
|
FrontendImage string
|
||||||
|
BackendImage string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load lit la config. Fail-secure : secret JWT obligatoire ; en prod base + Redis aussi.
|
||||||
|
func Load() (Config, error) {
|
||||||
|
secret := os.Getenv("OMNEX_JWT_SECRET")
|
||||||
|
if len(secret) < 32 {
|
||||||
|
return Config{}, errors.New("OMNEX_JWT_SECRET manquant ou < 32 octets")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
Addr: getenv("OMNEX_ADDR", ":8080"),
|
||||||
|
JWTSecret: []byte(secret),
|
||||||
|
SessionTTL: 24 * time.Hour,
|
||||||
|
AllowedOrigins: splitCSV(getenv("OMNEX_ALLOWED_ORIGINS", "http://localhost:5173")),
|
||||||
|
Env: getenv("OMNEX_ENV", "dev"),
|
||||||
|
DatabaseURL: os.Getenv("OMNEX_DATABASE_URL"),
|
||||||
|
RedisURL: os.Getenv("OMNEX_REDIS_URL"),
|
||||||
|
DemoDomain: getenv("OMNEX_DEMO_DOMAIN", "demo.omnex.app"),
|
||||||
|
RegistrationCode: os.Getenv("OMNEX_REGISTRATION_CODE"),
|
||||||
|
Kubeconfig: os.Getenv("KUBECONFIG"),
|
||||||
|
FrontendImage: os.Getenv("FRONTEND_IMAGE_APP"),
|
||||||
|
BackendImage: os.Getenv("BACKEND_IMAGE_APP"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Env == "prod" {
|
||||||
|
if cfg.DatabaseURL == "" {
|
||||||
|
return Config{}, errors.New("OMNEX_DATABASE_URL requis en prod")
|
||||||
|
}
|
||||||
|
if cfg.RedisURL == "" {
|
||||||
|
return Config{}, errors.New("OMNEX_REDIS_URL requis en prod (sessions)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secure indique si les cookies doivent porter l'attribut Secure (HTTPS).
|
||||||
|
func (c Config) Secure() bool { return c.Env == "prod" }
|
||||||
|
|
||||||
|
func getenv(k, def string) string {
|
||||||
|
if v := os.Getenv(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(s string) []string {
|
||||||
|
var out []string
|
||||||
|
start := 0
|
||||||
|
for i := 0; i <= len(s); i++ {
|
||||||
|
if i == len(s) || s[i] == ',' {
|
||||||
|
if seg := s[start:i]; seg != "" {
|
||||||
|
out = append(out, seg)
|
||||||
|
}
|
||||||
|
start = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Package db : connexion GORM (PostgreSQL) et migrations.
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
"github.com/omnex/control-plane/api/internal/auth"
|
||||||
|
"github.com/omnex/control-plane/api/internal/demos"
|
||||||
|
"github.com/omnex/control-plane/api/internal/leads"
|
||||||
|
"github.com/omnex/control-plane/api/internal/sav"
|
||||||
|
"github.com/omnex/control-plane/api/internal/sub"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Open ouvre une connexion GORM vers PostgreSQL et configure le pool.
|
||||||
|
func Open(dsn string) (*gorm.DB, error) {
|
||||||
|
gdb, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Warn),
|
||||||
|
SkipDefaultTransaction: true,
|
||||||
|
PrepareStmt: true, // requêtes préparées : perf + anti-injection
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ouverture db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := gdb.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sqlDB.SetMaxOpenConns(20)
|
||||||
|
sqlDB.SetMaxIdleConns(5)
|
||||||
|
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||||
|
return gdb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoMigrate crée/actualise le schéma des entités du control-plane.
|
||||||
|
func AutoMigrate(gdb *gorm.DB) error {
|
||||||
|
return gdb.AutoMigrate(
|
||||||
|
&auth.User{},
|
||||||
|
&sub.CodeBuySub{},
|
||||||
|
&leads.Lead{},
|
||||||
|
&demos.Demo{},
|
||||||
|
&demos.ExternalResource{},
|
||||||
|
&sav.Contact{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedExternalPool remplit le pool de ressources externes (idempotent).
|
||||||
|
func SeedExternalPool(gdb *gorm.DB) error {
|
||||||
|
return demos.SeedPool(gdb)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Package demos : cœur métier — cycle de vie des démos client déployées
|
||||||
|
// dans le cluster (une démo = un namespace isolé, TTL 30 jours).
|
||||||
|
package demos
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Paramètres capacité / durée de vie.
|
||||||
|
const (
|
||||||
|
TTL = 30 * 24 * time.Hour // durée de vie d'une démo
|
||||||
|
MaxConcurrentDemos = 5 // capacité cible
|
||||||
|
PoolSizePerService = 5 // slots par service externe poolé
|
||||||
|
)
|
||||||
|
|
||||||
|
// Services externes gérés par pool pré-provisionné (non auto-créables par API).
|
||||||
|
// TomTom n'y figure pas : ses clés sont créées à la volée par le worker.
|
||||||
|
const (
|
||||||
|
ServiceTelegram = "telegram"
|
||||||
|
ServiceNowPayments = "nowpayments"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PooledServices : services nécessitant un slot de pool par démo.
|
||||||
|
var PooledServices = []string{ServiceTelegram, ServiceNowPayments}
|
||||||
|
|
||||||
|
// Status : état d'une démo.
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusPending Status = "pending" // créée, pas encore prise par le worker
|
||||||
|
StatusProvisioning Status = "provisioning" // worker en train de déployer
|
||||||
|
StatusReady Status = "ready" // démo accessible
|
||||||
|
StatusExpiring Status = "expiring" // teardown en cours
|
||||||
|
StatusExpired Status = "expired" // détruite / TTL atteint
|
||||||
|
StatusFailed Status = "failed" // échec de provisioning
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Status) Valid() bool {
|
||||||
|
switch s {
|
||||||
|
case StatusPending, StatusProvisioning, StatusReady, StatusExpiring, StatusExpired, StatusFailed:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active indique si la démo occupe des ressources (compte dans la capacité).
|
||||||
|
func (s Status) Active() bool {
|
||||||
|
switch s {
|
||||||
|
case StatusPending, StatusProvisioning, StatusReady, StatusExpiring:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extendable : on ne prolonge que des démos encore vivantes.
|
||||||
|
func (s Status) Extendable() bool {
|
||||||
|
switch s {
|
||||||
|
case StatusProvisioning, StatusReady:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo : modèle GORM d'une démo.
|
||||||
|
type Demo struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||||
|
Username string `gorm:"type:varchar(64);index" json:"username,omitempty"`
|
||||||
|
LeadID string `gorm:"type:varchar(36);index" json:"lead_id,omitempty"`
|
||||||
|
Status Status `gorm:"size:20;not null;index" json:"status"`
|
||||||
|
Namespace string `gorm:"size:63;uniqueIndex" json:"namespace"`
|
||||||
|
URL string `gorm:"size:255" json:"url"`
|
||||||
|
TypeAbo string `gorm:"type:varchar(35)" json:"type_abonnement"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Demo) TableName() string { return "demos" }
|
||||||
|
|
||||||
|
// ResourceStatus : état d'un slot de pool.
|
||||||
|
type ResourceStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResourceFree ResourceStatus = "free"
|
||||||
|
ResourceBorrowed ResourceStatus = "borrowed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExternalResource : slot pré-provisionné (table external_pool).
|
||||||
|
// On stocke une *référence* au secret (SecretRef), jamais le secret en clair.
|
||||||
|
type ExternalResource struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Service string `gorm:"size:20;not null;index:idx_service_status" json:"service"`
|
||||||
|
Label string `gorm:"size:120" json:"label"` // ex. "@omnex_demo_bot_1"
|
||||||
|
SecretRef string `gorm:"size:200;not null" json:"-"` // clé dans le secret manager
|
||||||
|
Status ResourceStatus `gorm:"size:20;not null;index:idx_service_status" json:"status"`
|
||||||
|
DemoID string `gorm:"type:varchar(36);index" json:"demo_id,omitempty"` // FK optionnelle (vide si libre)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ExternalResource) TableName() string { return "external_pool" }
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package demos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/omnex/control-plane/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct{ svc *Service }
|
||||||
|
|
||||||
|
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
|
||||||
|
|
||||||
|
type createRequest struct {
|
||||||
|
LeadID string `json:"lead_id" binding:"omitempty,uuid4"`
|
||||||
|
Username string `json:"username" binding:"omitempty,min=3,max=64,alphanum"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create : POST /demos — provisionne une démo (202 Accepted, worker asynchrone).
|
||||||
|
func (h *Handler) Create(c *gin.Context) {
|
||||||
|
var req createRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, err := h.svc.Create(req.LeadID, req.Username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrCapacityReached) {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "capacité maximale de démos atteinte"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusAccepted, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List : GET /demos.
|
||||||
|
func (h *Handler) List(c *gin.Context) {
|
||||||
|
items, err := h.svc.List()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMine : GET /demos/mine — démos rattachées au client authentifié.
|
||||||
|
func (h *Handler) ListMine(c *gin.Context) {
|
||||||
|
p := auth.PrincipalFrom(c)
|
||||||
|
if p == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := h.svc.ListForUser(p.Username)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get : GET /demos/:id.
|
||||||
|
func (h *Handler) Get(c *gin.Context) {
|
||||||
|
d, err := h.svc.Get(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete : DELETE /demos/:id — teardown + libération du pool.
|
||||||
|
func (h *Handler) Delete(c *gin.Context) {
|
||||||
|
d, err := h.svc.Delete(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend : POST /demos/:id/extend — prolonge de 30 jours.
|
||||||
|
func (h *Handler) Extend(c *gin.Context) {
|
||||||
|
d, err := h.svc.Extend(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"})
|
||||||
|
case errors.Is(err, ErrNotExtendable):
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "démo non prolongeable"})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, d)
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
package demos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/omnex/control-plane/api/internal/config"
|
||||||
|
"go.yaml.in/yaml/v2"
|
||||||
|
k8sCoreV1 "k8s.io/api/core/v1"
|
||||||
|
k8sErrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HelmProvisioner : implémente Provisioner en appelant l'exécutable Helm.
|
||||||
|
// Nécessite que helm soit installé dans le container (ex: dans /usr/local/bin/helm).
|
||||||
|
type HelmProvisioner struct {
|
||||||
|
cfg *config.Config
|
||||||
|
k8sClient *kubernetes.Clientset
|
||||||
|
chartsDir string // chemin vers le dossier des charts (ex: /charts)
|
||||||
|
frontendImage string
|
||||||
|
backendImage string
|
||||||
|
baseDomain string
|
||||||
|
helmPath string // chemin vers l'exécutable helm (default: "helm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHelmProvisioner crée un nouveau provisioner Helm.
|
||||||
|
// chartsDir : chemin absolu vers le dossier contenant les charts (backend/, frontend/)
|
||||||
|
// helmPath : chemin vers l'exécutable helm (optionnel, default: "helm")
|
||||||
|
func NewHelmProvisioner(
|
||||||
|
cfg *config.Config,
|
||||||
|
k8sClient *kubernetes.Clientset,
|
||||||
|
chartsDir string,
|
||||||
|
helmPath string,
|
||||||
|
) (*HelmProvisioner, error) {
|
||||||
|
if chartsDir == "" {
|
||||||
|
chartsDir = "/charts"
|
||||||
|
}
|
||||||
|
if helmPath == "" {
|
||||||
|
helmPath = "helm"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que helm est disponible
|
||||||
|
if _, err := exec.LookPath(helmPath); err != nil {
|
||||||
|
return nil, fmt.Errorf("helm exécutable non trouvé à %s: %w", helmPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &HelmProvisioner{
|
||||||
|
cfg: cfg,
|
||||||
|
k8sClient: k8sClient,
|
||||||
|
chartsDir: chartsDir,
|
||||||
|
frontendImage: cfg.FrontendImage,
|
||||||
|
backendImage: cfg.BackendImage,
|
||||||
|
baseDomain: cfg.DemoDomain,
|
||||||
|
helmPath: helmPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision déploie une démo avec Helm :
|
||||||
|
// 1. Crée le namespace
|
||||||
|
// 2. Installe PostgreSQL pour la démo
|
||||||
|
// 3. Installe Redis pour la démo
|
||||||
|
// 4. Installe le chart backend
|
||||||
|
// 5. Installe le chart frontend
|
||||||
|
func (h *HelmProvisioner) Provision(d Demo, resources []ExternalResource) error {
|
||||||
|
// Créer le namespace
|
||||||
|
if err := h.createNamespace(d.Namespace); err != nil {
|
||||||
|
return fmt.Errorf("création namespace %s: %w", d.Namespace, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Préparer les valeurs
|
||||||
|
postgresValues := h.buildPostgresValues(d)
|
||||||
|
redisValues := h.buildRedisValues(d)
|
||||||
|
backendValues := h.buildBackendValues(d, resources)
|
||||||
|
frontendValues := h.buildFrontendValues(d)
|
||||||
|
|
||||||
|
// Installer PostgreSQL (nécessaire pour le backend)
|
||||||
|
if err := h.installChart(d.Namespace, "postgresql", postgresValues); err != nil {
|
||||||
|
h.deleteNamespace(d.Namespace)
|
||||||
|
return fmt.Errorf("déploiement postgresql: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installer Redis (nécessaire pour le backend)
|
||||||
|
if err := h.installChart(d.Namespace, "redis", redisValues); err != nil {
|
||||||
|
h.deleteNamespace(d.Namespace)
|
||||||
|
return fmt.Errorf("déploiement redis: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installer le backend
|
||||||
|
if err := h.installChart(d.Namespace, "backend", backendValues); err != nil {
|
||||||
|
h.deleteNamespace(d.Namespace)
|
||||||
|
return fmt.Errorf("déploiement backend: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installer le frontend
|
||||||
|
if err := h.installChart(d.Namespace, "frontend", frontendValues); err != nil {
|
||||||
|
h.deleteNamespace(d.Namespace)
|
||||||
|
return fmt.Errorf("déploiement frontend: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendre que les pods soient prêts
|
||||||
|
if err := h.waitForRollout(d.Namespace); err != nil {
|
||||||
|
log.Printf("Warning: rollout check échoué pour %s: %v", d.Namespace, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teardown détruit une démo en supprimant son namespace (cascading).
|
||||||
|
func (h *HelmProvisioner) Teardown(d Demo) error {
|
||||||
|
return h.deleteNamespace(d.Namespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createNamespace crée un namespace Kubernetes.
|
||||||
|
func (h *HelmProvisioner) createNamespace(name string) error {
|
||||||
|
ns := &k8sCoreV1.Namespace{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: name,
|
||||||
|
Labels: map[string]string{
|
||||||
|
"omnex.app/demo": "true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := h.k8sClient.CoreV1().Namespaces().Create(context.Background(), ns, metav1.CreateOptions{})
|
||||||
|
if err != nil && !k8sErrors.IsAlreadyExists(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteNamespace supprime un namespace (avec cascading).
|
||||||
|
func (h *HelmProvisioner) deleteNamespace(name string) error {
|
||||||
|
propagationPolicy := metav1.DeletePropagationForeground
|
||||||
|
return h.k8sClient.CoreV1().Namespaces().Delete(context.Background(), name, metav1.DeleteOptions{
|
||||||
|
PropagationPolicy: &propagationPolicy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// installChart installe un chart Helm avec des valeurs personnalisées.
|
||||||
|
func (h *HelmProvisioner) installChart(namespace, chartName string, values map[string]interface{}) error {
|
||||||
|
chartPath := filepath.Join(h.chartsDir, chartName)
|
||||||
|
|
||||||
|
// Créer un fichier values.yaml temporaire
|
||||||
|
valuesFile := filepath.Join("/tmp", fmt.Sprintf("values-%s-%s.yaml", namespace, chartName))
|
||||||
|
valuesYAML, err := yaml.Marshal(values)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal YAML: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(valuesFile, valuesYAML, 0644); err != nil {
|
||||||
|
return fmt.Errorf("write values file: %w", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(valuesFile)
|
||||||
|
|
||||||
|
// Construire la commande helm install
|
||||||
|
args := []string{
|
||||||
|
"install",
|
||||||
|
fmt.Sprintf("%s-%s", namespace, chartName),
|
||||||
|
chartPath,
|
||||||
|
"--namespace", namespace,
|
||||||
|
"--values", valuesFile,
|
||||||
|
"--wait",
|
||||||
|
"--timeout", "5m",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command(h.helmPath, args...)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("helm install %s: %v (stdout: %s, stderr: %s)", chartName, err, stdout.String(), stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Chart %s installé dans %s", chartName, namespace)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseImage(image string) (repo string, tag string) {
|
||||||
|
if image == "" {
|
||||||
|
return "", "helm"
|
||||||
|
}
|
||||||
|
// Chercher le dernier ":" (pour gérer les images avec port dans le repo)
|
||||||
|
idx := strings.LastIndex(image, ":")
|
||||||
|
if idx == -1 {
|
||||||
|
return image, "helm"
|
||||||
|
}
|
||||||
|
return image[:idx], image[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildBackendValues construit les valeurs pour le chart backend.
|
||||||
|
func (h *HelmProvisioner) buildBackendValues(d Demo, resources []ExternalResource) map[string]interface{} {
|
||||||
|
// Parser l'image backend pour séparer repository et tag
|
||||||
|
backendRepo, backendTag := parseImage(h.backendImage)
|
||||||
|
|
||||||
|
values := map[string]interface{}{
|
||||||
|
"replicaCount": 1,
|
||||||
|
"image": map[string]interface{}{
|
||||||
|
"repository": backendRepo,
|
||||||
|
"tag": backendTag,
|
||||||
|
"pullPolicy": "Always",
|
||||||
|
},
|
||||||
|
"service": map[string]interface{}{
|
||||||
|
"type": "ClusterIP",
|
||||||
|
"port": 8080,
|
||||||
|
},
|
||||||
|
"autoscaling": map[string]interface{}{
|
||||||
|
"enabled": false,
|
||||||
|
},
|
||||||
|
"persistence": map[string]interface{}{
|
||||||
|
"uploads": map[string]interface{}{
|
||||||
|
"enabled": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"env": map[string]string{
|
||||||
|
"DB_HOST": fmt.Sprintf("%s-postgresql-postgresql", d.Namespace),
|
||||||
|
"DB_PORT": "5432",
|
||||||
|
"DB_PASSWORD": "demo-postgres-pass",
|
||||||
|
"DB_USER": "postgres",
|
||||||
|
"DB_NAME": "demo_db",
|
||||||
|
"DB_SSLMODE": "disable",
|
||||||
|
"REDIS_HOST": fmt.Sprintf("%s-redis-redis", d.Namespace),
|
||||||
|
"REDIS_PORT": "6379",
|
||||||
|
"REDIS_PASSWORD": "demo-redis-pass", // Mot de passe Redis
|
||||||
|
"API_PORT": "8080",
|
||||||
|
"NODE_ENV": "production",
|
||||||
|
},
|
||||||
|
"secrets": h.buildSecrets(resources),
|
||||||
|
}
|
||||||
|
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFrontendValues construit les valeurs pour le chart frontend.
|
||||||
|
func (h *HelmProvisioner) buildFrontendValues(d Demo) map[string]interface{} {
|
||||||
|
backendURL := fmt.Sprintf("http://%s-backend.%s.svc.cluster.local:8080", d.Namespace, d.Namespace)
|
||||||
|
// Parser l'image frontend pour séparer repository et tag
|
||||||
|
frontendRepo, frontendTag := parseImage(h.frontendImage)
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"replicaCount": 1,
|
||||||
|
"image": map[string]interface{}{
|
||||||
|
"repository": frontendRepo,
|
||||||
|
"tag": frontendTag,
|
||||||
|
"pullPolicy": "Always",
|
||||||
|
},
|
||||||
|
"apiUrl": backendURL,
|
||||||
|
"service": map[string]interface{}{
|
||||||
|
"type": "ClusterIP",
|
||||||
|
"port": 80,
|
||||||
|
},
|
||||||
|
"autoscaling": map[string]interface{}{
|
||||||
|
"enabled": false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSecrets extrait les références des secrets des ressources externes.
|
||||||
|
func (h *HelmProvisioner) buildSecrets(resources []ExternalResource) map[string]string {
|
||||||
|
secrets := map[string]string{}
|
||||||
|
for _, r := range resources {
|
||||||
|
switch r.Service {
|
||||||
|
case ServiceTelegram:
|
||||||
|
secrets["TELEGRAM_WEBHOOK_SECRET"] = r.SecretRef
|
||||||
|
case ServiceNowPayments:
|
||||||
|
secrets["NOWPAYMENTS_IPN_SECRET"] = r.SecretRef
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return secrets
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPostgresValues construit les valeurs pour le chart postgresql.
|
||||||
|
func (h *HelmProvisioner) buildPostgresValues(d Demo) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"auth": map[string]interface{}{
|
||||||
|
"password": "demo-postgres-pass", // Mot de passe OBLIGATOIRE (champ correct pour le chart)
|
||||||
|
"username": "postgres",
|
||||||
|
"database": "demo_db",
|
||||||
|
},
|
||||||
|
"service": map[string]interface{}{
|
||||||
|
"type": "ClusterIP",
|
||||||
|
"port": 5432,
|
||||||
|
},
|
||||||
|
"persistence": map[string]interface{}{
|
||||||
|
"enabled": false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRedisValues construit les valeurs pour le chart redis.
|
||||||
|
func (h *HelmProvisioner) buildRedisValues(d Demo) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"service": map[string]interface{}{
|
||||||
|
"type": "ClusterIP",
|
||||||
|
"port": 6379,
|
||||||
|
},
|
||||||
|
"auth": map[string]interface{}{
|
||||||
|
"password": "demo-redis-pass", // Mot de passe simple pour les démos
|
||||||
|
},
|
||||||
|
"persistence": map[string]interface{}{
|
||||||
|
"enabled": false, // Pas de persistence pour les démos
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForRollout attend que tous les déploiements dans le namespace soient prêts.
|
||||||
|
func (h *HelmProvisioner) waitForRollout(namespace string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
// Utiliser kubectl pour vérifier le rollout
|
||||||
|
cmd := exec.Command("kubectl", "rollout", "status", "deployment", "-n", namespace, "--timeout=30s")
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
// Si c'est juste un timeout, on continue d'attendre
|
||||||
|
if strings.Contains(stderr.String(), "timed out") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("rollout check: %v: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
// Si la commande réussit, tous les déploiements sont prêts
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package demos
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// MemStore : Store en mémoire (dev/tests).
|
||||||
|
type MemStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
items map[string]Demo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemStore() *MemStore { return &MemStore{items: make(map[string]Demo)} }
|
||||||
|
|
||||||
|
func (m *MemStore) Create(d Demo) (Demo, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.items[d.ID] = d
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) Get(id string) (Demo, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
d, ok := m.items[id]
|
||||||
|
return d, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) List() ([]Demo, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
out := make([]Demo, 0, len(m.items))
|
||||||
|
for _, d := range m.items {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) ListByUsername(username string) ([]Demo, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
out := make([]Demo, 0)
|
||||||
|
for _, d := range m.items {
|
||||||
|
if d.Username == username {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) Update(d Demo) (Demo, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.items[d.ID] = d
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) CountActive() (int, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
n := 0
|
||||||
|
for _, d := range m.items {
|
||||||
|
if d.Status.Active() {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemPool : Pool en mémoire (dev/tests), PoolSizePerService slots par service.
|
||||||
|
type MemPool struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
res []*ExternalResource
|
||||||
|
next uint
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemPool construit un pool rempli (PoolSizePerService slots libres par service).
|
||||||
|
func NewMemPool() *MemPool {
|
||||||
|
p := &MemPool{}
|
||||||
|
for _, svc := range PooledServices {
|
||||||
|
for i := 1; i <= PoolSizePerService; i++ {
|
||||||
|
p.next++
|
||||||
|
p.res = append(p.res, &ExternalResource{
|
||||||
|
ID: p.next,
|
||||||
|
Service: svc,
|
||||||
|
Label: placeholderLabel(svc, i),
|
||||||
|
SecretRef: "secretref://" + svc + "/slot-" + itoa(i),
|
||||||
|
Status: ResourceFree,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MemPool) Borrow(demoID string) ([]ExternalResource, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
// Vérifie d'abord la dispo de chaque service (atomicité).
|
||||||
|
pick := make(map[string]*ExternalResource, len(PooledServices))
|
||||||
|
for _, svc := range PooledServices {
|
||||||
|
var found *ExternalResource
|
||||||
|
for _, r := range p.res {
|
||||||
|
if r.Service == svc && r.Status == ResourceFree {
|
||||||
|
found = r
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
return nil, ErrPoolExhausted
|
||||||
|
}
|
||||||
|
pick[svc] = found
|
||||||
|
}
|
||||||
|
var out []ExternalResource
|
||||||
|
for _, r := range pick {
|
||||||
|
r.Status = ResourceBorrowed
|
||||||
|
r.DemoID = demoID
|
||||||
|
out = append(out, *r)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MemPool) Return(demoID string) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for _, r := range p.res {
|
||||||
|
if r.DemoID == demoID {
|
||||||
|
r.Status = ResourceFree
|
||||||
|
r.DemoID = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MemPool) FreeCount() (map[string]int, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
out := map[string]int{}
|
||||||
|
for _, svc := range PooledServices {
|
||||||
|
out[svc] = 0
|
||||||
|
}
|
||||||
|
for _, r := range p.res {
|
||||||
|
if r.Status == ResourceFree {
|
||||||
|
out[r.Service]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package demos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrPoolExhausted : plus de slot libre pour au moins un service requis.
|
||||||
|
var ErrPoolExhausted = errors.New("pool de ressources externes épuisé")
|
||||||
|
|
||||||
|
// Pool : allocation des ressources externes pré-provisionnées.
|
||||||
|
type Pool interface {
|
||||||
|
// Borrow réserve un slot par service requis, de façon atomique.
|
||||||
|
// Rend ErrPoolExhausted si un service n'a plus de slot libre.
|
||||||
|
Borrow(demoID string) ([]ExternalResource, error)
|
||||||
|
// Return libère tous les slots d'une démo.
|
||||||
|
Return(demoID string) error
|
||||||
|
// FreeCount renvoie le nombre de slots libres par service.
|
||||||
|
FreeCount() (map[string]int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GormPool : Pool adossé à PostgreSQL via GORM (verrou transactionnel).
|
||||||
|
type GormPool struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
func NewGormPool(db *gorm.DB) *GormPool { return &GormPool{db: db} }
|
||||||
|
|
||||||
|
func (p *GormPool) Borrow(demoID string) ([]ExternalResource, error) {
|
||||||
|
var borrowed []ExternalResource
|
||||||
|
err := p.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, svc := range PooledServices {
|
||||||
|
var r ExternalResource
|
||||||
|
// SELECT ... FOR UPDATE SKIP LOCKED : évite la course entre deux démos.
|
||||||
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
||||||
|
Where("service = ? AND status = ?", svc, ResourceFree).
|
||||||
|
First(&r).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrPoolExhausted
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Status = ResourceBorrowed
|
||||||
|
r.DemoID = demoID
|
||||||
|
if err := tx.Save(&r).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
borrowed = append(borrowed, r)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return borrowed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GormPool) Return(demoID string) error {
|
||||||
|
return p.db.Model(&ExternalResource{}).
|
||||||
|
Where("demo_id = ?", demoID).
|
||||||
|
Updates(map[string]any{"status": ResourceFree, "demo_id": ""}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GormPool) FreeCount() (map[string]int, error) {
|
||||||
|
type row struct {
|
||||||
|
Service string
|
||||||
|
N int
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
err := p.db.Model(&ExternalResource{}).
|
||||||
|
Select("service, count(*) as n").
|
||||||
|
Where("status = ?", ResourceFree).
|
||||||
|
Group("service").Scan(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]int{}
|
||||||
|
for _, svc := range PooledServices {
|
||||||
|
out[svc] = 0
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
out[r.Service] = r.N
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedPool crée les slots manquants pour atteindre PoolSizePerService par service.
|
||||||
|
// Idempotent : n'ajoute que ce qui manque. SecretRef pointe vers le secret manager.
|
||||||
|
func SeedPool(db *gorm.DB) error {
|
||||||
|
for _, svc := range PooledServices {
|
||||||
|
var n int64
|
||||||
|
if err := db.Model(&ExternalResource{}).Where("service = ?", svc).Count(&n).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := int(n) + 1; i <= PoolSizePerService; i++ {
|
||||||
|
res := ExternalResource{
|
||||||
|
Service: svc,
|
||||||
|
Label: placeholderLabel(svc, i),
|
||||||
|
SecretRef: "secretref://" + svc + "/slot-" + itoa(i),
|
||||||
|
Status: ResourceFree,
|
||||||
|
}
|
||||||
|
if err := db.Create(&res).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeholderLabel(svc string, i int) string {
|
||||||
|
return svc + "-slot-" + itoa(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(i int) string {
|
||||||
|
if i == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b [20]byte
|
||||||
|
pos := len(b)
|
||||||
|
for i > 0 {
|
||||||
|
pos--
|
||||||
|
b[pos] = byte('0' + i%10)
|
||||||
|
i /= 10
|
||||||
|
}
|
||||||
|
return string(b[pos:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package demos
|
||||||
|
|
||||||
|
// Provisioner : abstraction du worker qui déploie/détruit la stack dans le cluster.
|
||||||
|
// L'implémentation réelle (Helm + client-go) sera branchée plus tard ; l'API se
|
||||||
|
// contente d'enfiler les intentions.
|
||||||
|
type Provisioner interface {
|
||||||
|
// Provision demande le déploiement d'une démo (asynchrone).
|
||||||
|
Provision(d Demo, resources []ExternalResource) error
|
||||||
|
// Teardown demande la destruction d'une démo.
|
||||||
|
Teardown(d Demo) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoopProvisioner : implémentation neutre (aucun déploiement réel).
|
||||||
|
// Sert au scaffolding et aux tests tant que le worker n'existe pas.
|
||||||
|
type NoopProvisioner struct{}
|
||||||
|
|
||||||
|
func (NoopProvisioner) Provision(Demo, []ExternalResource) error { return nil }
|
||||||
|
func (NoopProvisioner) Teardown(Demo) error { return nil }
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package demos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/omnex/control-plane/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrCapacityReached = errors.New("capacité maximale de démos atteinte")
|
||||||
|
ErrNotFound = errors.New("démo introuvable")
|
||||||
|
ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config du service (injectable pour les tests).
|
||||||
|
type Config struct {
|
||||||
|
BaseDomain string // ex. "demo.omnex.app"
|
||||||
|
TTL time.Duration // durée de vie
|
||||||
|
MaxDemos int // capacité
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service : logique métier des démos (indépendante du transport HTTP).
|
||||||
|
type Service struct {
|
||||||
|
store Store
|
||||||
|
pool Pool
|
||||||
|
prov Provisioner
|
||||||
|
cfg Config
|
||||||
|
now func() time.Time // horloge injectable
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(store Store, pool Pool, prov Provisioner, cfg Config) *Service {
|
||||||
|
if cfg.TTL == 0 {
|
||||||
|
cfg.TTL = TTL
|
||||||
|
}
|
||||||
|
if cfg.MaxDemos == 0 {
|
||||||
|
cfg.MaxDemos = MaxConcurrentDemos
|
||||||
|
}
|
||||||
|
if cfg.BaseDomain == "" {
|
||||||
|
cfg.BaseDomain = "demo.omnex.app"
|
||||||
|
}
|
||||||
|
return &Service{store: store, pool: pool, prov: prov, cfg: cfg, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create réserve la capacité + le pool, persiste la démo et déclenche le worker.
|
||||||
|
// username (optionnel) rattache la démo à un client existant.
|
||||||
|
func (s *Service) Create(leadID, username string) (Demo, error) {
|
||||||
|
active, err := s.store.CountActive()
|
||||||
|
if err != nil {
|
||||||
|
return Demo{}, err
|
||||||
|
}
|
||||||
|
if active >= s.cfg.MaxDemos {
|
||||||
|
return Demo{}, ErrCapacityReached
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
demo := Demo{
|
||||||
|
ID: id,
|
||||||
|
Username: auth.NormalizeUsername(username),
|
||||||
|
LeadID: leadID,
|
||||||
|
Status: StatusPending,
|
||||||
|
Namespace: "demo-" + shortID(id),
|
||||||
|
CreatedAt: s.now().UTC(),
|
||||||
|
ExpiresAt: s.now().UTC().Add(s.cfg.TTL),
|
||||||
|
}
|
||||||
|
demo.URL = "https://" + demo.Namespace + "." + s.cfg.BaseDomain
|
||||||
|
|
||||||
|
// Réserve les ressources externes ; ErrPoolExhausted => capacité atteinte.
|
||||||
|
resources, err := s.pool.Borrow(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrPoolExhausted) {
|
||||||
|
return Demo{}, ErrCapacityReached
|
||||||
|
}
|
||||||
|
return Demo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
demo.Status = StatusProvisioning
|
||||||
|
created, err := s.store.Create(demo)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.pool.Return(id) // fail-secure : on rend ce qu'on a emprunté
|
||||||
|
return Demo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lance le déploiement de manière asynchrone (worker inline via goroutine).
|
||||||
|
go func() {
|
||||||
|
if err := s.prov.Provision(created, resources); err != nil {
|
||||||
|
log.Printf("Provisioning échoué pour %s: %v", created.ID, err)
|
||||||
|
created.Status = StatusFailed
|
||||||
|
_, _ = s.store.Update(created)
|
||||||
|
_ = s.pool.Return(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created.Status = StatusReady
|
||||||
|
_, _ = s.store.Update(created)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Get(id string) (Demo, error) {
|
||||||
|
d, ok := s.store.Get(id)
|
||||||
|
if !ok {
|
||||||
|
return Demo{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List() ([]Demo, error) {
|
||||||
|
return s.store.List()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListForUser retourne uniquement les démos rattachées à ce client.
|
||||||
|
func (s *Service) ListForUser(username string) ([]Demo, error) {
|
||||||
|
return s.store.ListByUsername(auth.NormalizeUsername(username))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete déclenche le teardown et libère le pool.
|
||||||
|
func (s *Service) Delete(id string) (Demo, error) {
|
||||||
|
d, ok := s.store.Get(id)
|
||||||
|
if !ok {
|
||||||
|
return Demo{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if d.Status == StatusExpired {
|
||||||
|
return d, nil // déjà détruite (idempotent)
|
||||||
|
}
|
||||||
|
d.Status = StatusExpiring
|
||||||
|
if _, err := s.store.Update(d); err != nil {
|
||||||
|
return Demo{}, err
|
||||||
|
}
|
||||||
|
if err := s.prov.Teardown(d); err != nil {
|
||||||
|
return Demo{}, err
|
||||||
|
}
|
||||||
|
if err := s.pool.Return(id); err != nil {
|
||||||
|
return Demo{}, err
|
||||||
|
}
|
||||||
|
d.Status = StatusExpired
|
||||||
|
return s.store.Update(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend prolonge la démo de TTL à partir de son échéance courante.
|
||||||
|
func (s *Service) Extend(id string) (Demo, error) {
|
||||||
|
d, ok := s.store.Get(id)
|
||||||
|
if !ok {
|
||||||
|
return Demo{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if !d.Status.Extendable() {
|
||||||
|
return Demo{}, ErrNotExtendable
|
||||||
|
}
|
||||||
|
base := d.ExpiresAt
|
||||||
|
if now := s.now().UTC(); base.Before(now) {
|
||||||
|
base = now
|
||||||
|
}
|
||||||
|
d.ExpiresAt = base.Add(s.cfg.TTL)
|
||||||
|
return s.store.Update(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shortID : 8 premiers caractères hex d'un uuid pour un nom de namespace court.
|
||||||
|
func shortID(id string) string {
|
||||||
|
clean := ""
|
||||||
|
for _, c := range id {
|
||||||
|
if c != '-' {
|
||||||
|
clean += string(c)
|
||||||
|
}
|
||||||
|
if len(clean) == 8 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clean
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package demos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store : persistance des démos.
|
||||||
|
type Store interface {
|
||||||
|
Create(d Demo) (Demo, error)
|
||||||
|
Get(id string) (Demo, bool)
|
||||||
|
List() ([]Demo, error)
|
||||||
|
ListByUsername(username string) ([]Demo, error)
|
||||||
|
Update(d Demo) (Demo, error)
|
||||||
|
CountActive() (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GormStore : Store adossé à PostgreSQL via GORM.
|
||||||
|
type GormStore struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
func NewGormStore(db *gorm.DB) *GormStore { return &GormStore{db: db} }
|
||||||
|
|
||||||
|
func (s *GormStore) Create(d Demo) (Demo, error) {
|
||||||
|
if err := s.db.Create(&d).Error; err != nil {
|
||||||
|
return Demo{}, err
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GormStore) Get(id string) (Demo, bool) {
|
||||||
|
var d Demo
|
||||||
|
err := s.db.Where("id = ?", id).First(&d).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) || err != nil {
|
||||||
|
return Demo{}, false
|
||||||
|
}
|
||||||
|
return d, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GormStore) List() ([]Demo, error) {
|
||||||
|
var out []Demo
|
||||||
|
if err := s.db.Order("created_at DESC").Find(&out).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GormStore) ListByUsername(username string) ([]Demo, error) {
|
||||||
|
var out []Demo
|
||||||
|
if err := s.db.Where("username = ?", username).Order("created_at DESC").Find(&out).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GormStore) Update(d Demo) (Demo, error) {
|
||||||
|
if err := s.db.Save(&d).Error; err != nil {
|
||||||
|
return Demo{}, err
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountActive compte les démos occupant de la capacité.
|
||||||
|
func (s *GormStore) CountActive() (int, error) {
|
||||||
|
var n int64
|
||||||
|
err := s.db.Model(&Demo{}).
|
||||||
|
Where("status IN ?", []Status{StatusPending, StatusProvisioning, StatusReady, StatusExpiring}).
|
||||||
|
Count(&n).Error
|
||||||
|
return int(n), err
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Package httpx : middlewares transverses (sécurité, CORS, rate-limit).
|
||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecurityHeaders ajoute les en-têtes de sécurité recommandés (OWASP).
|
||||||
|
func SecurityHeaders() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
h := c.Writer.Header()
|
||||||
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
|
h.Set("X-Frame-Options", "DENY")
|
||||||
|
h.Set("Referrer-Policy", "no-referrer")
|
||||||
|
h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
|
||||||
|
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS restreint les origines à une allowlist explicite.
|
||||||
|
func CORS(allowed []string) gin.HandlerFunc {
|
||||||
|
set := make(map[string]struct{}, len(allowed))
|
||||||
|
for _, o := range allowed {
|
||||||
|
set[o] = struct{}{}
|
||||||
|
}
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
origin := c.GetHeader("Origin")
|
||||||
|
if _, ok := set[origin]; ok {
|
||||||
|
h := c.Writer.Header()
|
||||||
|
h.Set("Access-Control-Allow-Origin", origin)
|
||||||
|
h.Set("Vary", "Origin")
|
||||||
|
h.Set("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS")
|
||||||
|
h.Set("Access-Control-Allow-Headers", "Authorization,Content-Type")
|
||||||
|
}
|
||||||
|
if c.Request.Method == http.MethodOptions {
|
||||||
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipLimiter : rate-limiter par IP (token bucket).
|
||||||
|
type ipLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buck map[string]*rate.Limiter
|
||||||
|
r rate.Limit
|
||||||
|
burst int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newIPLimiter(r rate.Limit, burst int) *ipLimiter {
|
||||||
|
return &ipLimiter{buck: make(map[string]*rate.Limiter), r: r, burst: burst}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ipLimiter) get(ip string) *rate.Limiter {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
lim, ok := l.buck[ip]
|
||||||
|
if !ok {
|
||||||
|
lim = rate.NewLimiter(l.r, l.burst)
|
||||||
|
l.buck[ip] = lim
|
||||||
|
}
|
||||||
|
return lim
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimit limite chaque IP à r req/s avec un burst donné.
|
||||||
|
func RateLimit(perSecond float64, burst int) gin.HandlerFunc {
|
||||||
|
limiter := newIPLimiter(rate.Limit(perSecond), burst)
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if !limiter.get(c.ClientIP()).Allow() {
|
||||||
|
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "trop de requêtes"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package k8s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/omnex/control-plane/api/internal/config"
|
||||||
|
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewClient(cfg *config.Config) (*kubernetes.Clientset, error) {
|
||||||
|
var (
|
||||||
|
k8sCfg *rest.Config
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if cfg.Kubeconfig != "" {
|
||||||
|
k8sCfg, err = clientcmd.BuildConfigFromFlags("", cfg.Kubeconfig)
|
||||||
|
} else {
|
||||||
|
k8sCfg, err = rest.InClusterConfig()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("k8s config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := kubernetes.NewForConfig(k8sCfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("k8s clientset: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package leads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GormStore : Store adossé à PostgreSQL via GORM.
|
||||||
|
type GormStore struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGormStore(db *gorm.DB) *GormStore {
|
||||||
|
return &GormStore{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GormStore) Create(l Lead) (Lead, error) {
|
||||||
|
l.ID = uuid.NewString()
|
||||||
|
l.Status = StatusNew
|
||||||
|
l.CreatedAt = time.Now().UTC()
|
||||||
|
if err := s.db.Create(&l).Error; err != nil {
|
||||||
|
return Lead{}, err
|
||||||
|
}
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GormStore) List() ([]Lead, error) {
|
||||||
|
var out []Lead
|
||||||
|
if err := s.db.Order("created_at DESC").Find(&out).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GormStore) Get(id string) (Lead, bool) {
|
||||||
|
var l Lead
|
||||||
|
err := s.db.Where("id = ?", id).First(&l).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) || err != nil {
|
||||||
|
return Lead{}, false
|
||||||
|
}
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GormStore) SetStatus(id string, status Status) (Lead, bool) {
|
||||||
|
var l Lead
|
||||||
|
if err := s.db.Where("id = ?", id).First(&l).Error; err != nil {
|
||||||
|
return Lead{}, false
|
||||||
|
}
|
||||||
|
l.Status = status
|
||||||
|
if err := s.db.Save(&l).Error; err != nil {
|
||||||
|
return Lead{}, false
|
||||||
|
}
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package leads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
store Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(store Store) *Handler {
|
||||||
|
return &Handler{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createRequest : entrée publique (formulaire vitrine). Validation stricte.
|
||||||
|
type createRequest struct {
|
||||||
|
Telegram string `json:"company" binding:"required,min=2,max=120"`
|
||||||
|
Message string `json:"message" binding:"max=2000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create enregistre un lead depuis le formulaire public.
|
||||||
|
// Les entrées sont nettoyées (trim + échappement HTML) avant stockage.
|
||||||
|
func (h *Handler) Create(c *gin.Context) {
|
||||||
|
var req createRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l, err := h.store.Create(Lead{
|
||||||
|
Telegram: sanitize(req.Telegram),
|
||||||
|
Message: sanitize(req.Message),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List renvoie tous les leads (back-office, protégé).
|
||||||
|
func (h *Handler) List(c *gin.Context) {
|
||||||
|
items, err := h.store.List()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusRequest struct {
|
||||||
|
Status Status `json:"status" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStatus met à jour le statut d'un lead (back-office, protégé).
|
||||||
|
func (h *Handler) SetStatus(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var req statusRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || !req.Status.Valid() {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "statut invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
l, ok := h.store.SetStatus(id, req.Status)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "lead introuvable"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitize : trim + échappement HTML pour neutraliser le XSS stocké.
|
||||||
|
func sanitize(s string) string {
|
||||||
|
return html.EscapeString(strings.TrimSpace(s))
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Package leads : gestion des prospects (feature "leads" du back-office).
|
||||||
|
package leads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusNew Status = "new"
|
||||||
|
StatusContacted Status = "contacted"
|
||||||
|
StatusDemo Status = "demo"
|
||||||
|
StatusWon Status = "won"
|
||||||
|
StatusLost Status = "lost"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Status) Valid() bool {
|
||||||
|
switch s {
|
||||||
|
case StatusNew, StatusContacted, StatusDemo, StatusWon, StatusLost:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lead : prospect. Modèle GORM.
|
||||||
|
type Lead struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||||
|
Telegram string `gorm:"size:120;not null" json:"telegram"`
|
||||||
|
Message string `gorm:"size:2000" json:"message"`
|
||||||
|
Status Status `gorm:"size:20;not null;index" json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName force le nom de table.
|
||||||
|
func (Lead) TableName() string { return "leads" }
|
||||||
|
|
||||||
|
// Store : persistance des leads. Impl mémoire ici, Postgres plus tard.
|
||||||
|
type Store interface {
|
||||||
|
Create(l Lead) (Lead, error)
|
||||||
|
List() ([]Lead, error)
|
||||||
|
Get(id string) (Lead, bool)
|
||||||
|
SetStatus(id string, s Status) (Lead, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemStore : implémentation en mémoire (dev/tests).
|
||||||
|
type MemStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
items map[string]Lead
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemStore() *MemStore {
|
||||||
|
return &MemStore{items: make(map[string]Lead)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) Create(l Lead) (Lead, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
l.ID = uuid.NewString()
|
||||||
|
l.Status = StatusNew
|
||||||
|
l.CreatedAt = time.Now().UTC()
|
||||||
|
m.items[l.ID] = l
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) List() ([]Lead, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
out := make([]Lead, 0, len(m.items))
|
||||||
|
for _, l := range m.items {
|
||||||
|
out = append(out, l)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) Get(id string) (Lead, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
l, ok := m.items[id]
|
||||||
|
return l, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) SetStatus(id string, s Status) (Lead, bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
l, ok := m.items[id]
|
||||||
|
if !ok {
|
||||||
|
return Lead{}, false
|
||||||
|
}
|
||||||
|
l.Status = s
|
||||||
|
m.items[id] = l
|
||||||
|
return l, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Package router assemble les routes de l'API Omnex.
|
||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"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/httpx"
|
||||||
|
"github.com/omnex/control-plane/api/internal/leads"
|
||||||
|
"github.com/omnex/control-plane/api/internal/sav"
|
||||||
|
"github.com/omnex/control-plane/api/internal/session"
|
||||||
|
"github.com/omnex/control-plane/api/internal/sub"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Deps : dépendances injectées (facilite les tests).
|
||||||
|
type Deps struct {
|
||||||
|
Cfg config.Config
|
||||||
|
Issuer *auth.Issuer
|
||||||
|
Sessions session.Manager
|
||||||
|
AuthH *auth.Handler
|
||||||
|
LeadsH *leads.Handler
|
||||||
|
DemosH *demos.Handler
|
||||||
|
SubH *sub.Handler
|
||||||
|
ContactH *sav.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// New construit l'engine Gin avec toute la chaîne de sécurité.
|
||||||
|
func New(d Deps) *gin.Engine {
|
||||||
|
if d.Cfg.Env == "prod" {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
r.Use(httpx.SecurityHeaders())
|
||||||
|
r.Use(httpx.CORS(d.Cfg.AllowedOrigins))
|
||||||
|
|
||||||
|
r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
|
||||||
|
|
||||||
|
api := r.Group("/api/v1")
|
||||||
|
|
||||||
|
// Public : login, inscription + formulaire de contact, rate-limités.
|
||||||
|
api.POST("/auth/login", httpx.RateLimit(1, 5), d.AuthH.Login)
|
||||||
|
api.POST("/auth/register", httpx.RateLimit(0.2, 3), d.AuthH.Register)
|
||||||
|
api.POST("/leads", httpx.RateLimit(1, 3), d.LeadsH.Create)
|
||||||
|
api.POST("/send/message", httpx.RateLimit(1, 3), d.ContactH.CallSupport)
|
||||||
|
// Toute route authentifiée (session valide, n'importe quel rôle).
|
||||||
|
authed := api.Group("")
|
||||||
|
authed.Use(auth.RequireAuth(d.Issuer, d.Sessions))
|
||||||
|
{
|
||||||
|
authed.POST("/auth/logout", d.AuthH.Logout)
|
||||||
|
authed.GET("/auth/me", d.AuthH.Me)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := authed.Group("")
|
||||||
|
client.Use(auth.RequireRole(auth.RoleClient))
|
||||||
|
{
|
||||||
|
client.GET("/leads", d.LeadsH.List)
|
||||||
|
client.PATCH("/leads/:id/status", d.LeadsH.SetStatus)
|
||||||
|
client.POST("/subscription", d.SubH.AddCode)
|
||||||
|
if d.DemosH != nil {
|
||||||
|
client.GET("/demos/mine", d.DemosH.ListMine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Espace admin : provisioning des démos (admin uniquement).
|
||||||
|
admin := authed.Group("")
|
||||||
|
admin.Use(auth.RequireRole(auth.RoleAdmin))
|
||||||
|
{
|
||||||
|
admin.GET("/codes", d.SubH.ListCodes)
|
||||||
|
admin.POST("/codes", d.SubH.CreateCodeForBuy)
|
||||||
|
admin.GET("/messages", d.ContactH.GetMessage)
|
||||||
|
if d.DemosH != nil {
|
||||||
|
admin.POST("/demos", d.DemosH.Create)
|
||||||
|
admin.GET("/demos", d.DemosH.List)
|
||||||
|
admin.GET("/demos/:id", d.DemosH.Get)
|
||||||
|
admin.DELETE("/demos/:id", d.DemosH.Delete)
|
||||||
|
admin.POST("/demos/:id/extend", d.DemosH.Extend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package sav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/omnex/control-plane/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
store Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler crée un nouveau Handler.
|
||||||
|
func NewHandler(store Store) *Handler {
|
||||||
|
return &Handler{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
type contactSupport struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Telegram string `json:"telegram"`
|
||||||
|
Sujet string `json:"sujet"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CallSupport(c *gin.Context) {
|
||||||
|
var req contactSupport
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contact, err := h.store.ContactSupportByUser(req.Username, req.Telegram, req.Sujet, req.Message)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"success": "message envoyé", "contact": contact})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetMessage(c *gin.Context) {
|
||||||
|
p := auth.PrincipalFrom(c)
|
||||||
|
if p == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Role != "admin" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "role non correcte"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessage, err := h.store.GetMessage()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"messages": getMessage})
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package sav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/omnex/control-plane/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemContactStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
items map[string]Contact
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemContactStore() *MemContactStore {
|
||||||
|
return &MemContactStore{
|
||||||
|
items: make(map[string]Contact),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemContactStore) ContactSupportByUser(username, telegram, sujet, message string) (Contact, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
contact := Contact{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Username: auth.NormalizeUsername(username),
|
||||||
|
Telegram: auth.NormalizeUsername(telegram),
|
||||||
|
Sujet: sujet,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
|
||||||
|
m.items[contact.ID] = contact
|
||||||
|
return contact, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemContactStore) GetMessage() ([]Contact, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
contacts := make([]Contact, 0, len(m.items))
|
||||||
|
for _, c := range m.items {
|
||||||
|
contacts = append(contacts, c)
|
||||||
|
}
|
||||||
|
sort.Slice(contacts, func(i, j int) bool {
|
||||||
|
return contacts[i].CreatedAt.After(contacts[j].CreatedAt)
|
||||||
|
})
|
||||||
|
return contacts, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package sav
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Contact struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||||
|
Username string `gorm:"size:64;not null" json:"username"`
|
||||||
|
Telegram string `gorm:"size:64;not null" json:"telegram"`
|
||||||
|
Sujet string `gorm:"size:64;not null" json:"sujet"`
|
||||||
|
Message string `gorm:"size:64;not null" json:"message"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package sav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/omnex/control-plane/api/internal/auth"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
ContactSupportByUser(username, telegram, sujet, message string) (Contact, error)
|
||||||
|
GetMessage() ([]Contact, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GormStore struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGormStore(db *gorm.DB) *GormStore {
|
||||||
|
return &GormStore{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GormStore) ContactSupportByUser(username, telegram, sujet, message string) (Contact, error) {
|
||||||
|
contact := Contact{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Username: auth.NormalizeUsername(username),
|
||||||
|
Telegram: auth.NormalizeUsername(telegram),
|
||||||
|
Sujet: sujet,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(&contact).Error; err != nil {
|
||||||
|
return Contact{}, err
|
||||||
|
}
|
||||||
|
return contact, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GormStore) GetMessage() ([]Contact, error) {
|
||||||
|
var contacts []Contact
|
||||||
|
if err := s.db.Find(&contacts).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return contacts, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MemManager : Manager en mémoire (dev/tests), avec expiration paresseuse.
|
||||||
|
type MemManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
items map[string]entry
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
s Session
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemManager(ttl time.Duration) *MemManager {
|
||||||
|
return &MemManager{items: make(map[string]entry), ttl: ttl}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemManager) TTL() time.Duration { return m.ttl }
|
||||||
|
|
||||||
|
func (m *MemManager) Create(_ context.Context, s Session) (string, error) {
|
||||||
|
token, err := newToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.CreatedAt = time.Now().UTC()
|
||||||
|
m.mu.Lock()
|
||||||
|
m.items[token] = entry{s: s, expires: time.Now().Add(m.ttl)}
|
||||||
|
m.mu.Unlock()
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemManager) Get(_ context.Context, token string) (Session, bool, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
e, ok := m.items[token]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if !ok || time.Now().After(e.expires) {
|
||||||
|
return Session{}, false, nil
|
||||||
|
}
|
||||||
|
return e.s, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemManager) Delete(_ context.Context, token string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.items, token)
|
||||||
|
m.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyPrefix = "omnex:session:"
|
||||||
|
|
||||||
|
// RedisManager : Manager adossé à Redis.
|
||||||
|
type RedisManager struct {
|
||||||
|
rdb *redis.Client
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRedisManager(rdb *redis.Client, ttl time.Duration) *RedisManager {
|
||||||
|
return &RedisManager{rdb: rdb, ttl: ttl}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *RedisManager) TTL() time.Duration { return m.ttl }
|
||||||
|
|
||||||
|
func (m *RedisManager) Create(ctx context.Context, s Session) (string, error) {
|
||||||
|
token, err := newToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.CreatedAt = time.Now().UTC()
|
||||||
|
data, err := json.Marshal(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := m.rdb.Set(ctx, keyPrefix+token, data, m.ttl).Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *RedisManager) Get(ctx context.Context, token string) (Session, bool, error) {
|
||||||
|
if token == "" {
|
||||||
|
return Session{}, false, nil
|
||||||
|
}
|
||||||
|
data, err := m.rdb.Get(ctx, keyPrefix+token).Bytes()
|
||||||
|
if errors.Is(err, redis.Nil) {
|
||||||
|
return Session{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Session{}, false, err
|
||||||
|
}
|
||||||
|
var s Session
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return Session{}, false, err
|
||||||
|
}
|
||||||
|
return s, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *RedisManager) Delete(ctx context.Context, token string) error {
|
||||||
|
if token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.rdb.Del(ctx, keyPrefix+token).Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Package session : sessions utilisateurs stockées côté serveur dans Redis.
|
||||||
|
// Tokens opaques aléatoires (révocables), pas de JWT.
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CookieName : nom du cookie de session (httpOnly).
|
||||||
|
const CookieName = "omnex_session"
|
||||||
|
|
||||||
|
// Session : données associées à un token de session.
|
||||||
|
type Session struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager : cycle de vie des sessions.
|
||||||
|
type Manager interface {
|
||||||
|
// Create génère un token opaque et persiste la session (TTL appliqué).
|
||||||
|
Create(ctx context.Context, s Session) (token string, err error)
|
||||||
|
// Get récupère une session par token (found=false si absente/expirée).
|
||||||
|
Get(ctx context.Context, token string) (s Session, found bool, err error)
|
||||||
|
// Delete révoque une session (logout).
|
||||||
|
Delete(ctx context.Context, token string) error
|
||||||
|
// TTL renvoie la durée de vie configurée.
|
||||||
|
TTL() time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// newToken génère un token de session cryptographiquement aléatoire (256 bits).
|
||||||
|
func newToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/omnex/control-plane/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler gère les requêtes HTTP pour les codes de souscription.
|
||||||
|
type Handler struct {
|
||||||
|
store Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler crée un nouveau Handler.
|
||||||
|
func NewHandler(store Store) *Handler {
|
||||||
|
return &Handler{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createCodeRequest représente la requête pour créer un code.
|
||||||
|
type createCodeRequest struct {
|
||||||
|
Username string `json:"username" binding:"required,min=3,max=64,alphanum"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type addCodeRequest struct {
|
||||||
|
Code string `json:"code_verif" binding:"required,len=19"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateCode() (string, error) {
|
||||||
|
const length = 16
|
||||||
|
const groupSize = 4
|
||||||
|
|
||||||
|
b := make([]byte, length)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for i := 0; i < length; i++ {
|
||||||
|
if i > 0 && i%groupSize == 0 {
|
||||||
|
sb.WriteByte('-')
|
||||||
|
}
|
||||||
|
sb.WriteByte(codeCharset[int(b[i])%len(codeCharset)])
|
||||||
|
}
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
|
// ListCodes retourne la liste de tous les codes de souscription.
|
||||||
|
// Réservé aux administrateurs.
|
||||||
|
func (h *Handler) ListCodes(c *gin.Context) {
|
||||||
|
p := auth.PrincipalFrom(c)
|
||||||
|
if p == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Role != "admin" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "role non correcte"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
codes, err := h.store.ListCodes()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": codes})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCodeForBuy crée un nouveau code de souscription pour un utilisateur.
|
||||||
|
// Réservé aux administrateurs.
|
||||||
|
func (h *Handler) CreateCodeForBuy(c *gin.Context) {
|
||||||
|
// Vérification de l'authentification et du rôle sera faite par le middleware
|
||||||
|
p := auth.PrincipalFrom(c)
|
||||||
|
if p == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.Role != "admin" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "role non correcte"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req createCodeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := GenerateCode()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
codeSub, err := h.store.CreateCodeForSub(req.Username, code)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"code": code, "code_id": codeSub.ID, "username": codeSub.Username})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) AddCode(c *gin.Context) {
|
||||||
|
p := auth.PrincipalFrom(c)
|
||||||
|
if p == nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req addCodeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.store.AddCodeForSub(p.Username, req.Code)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "code invalide"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"success": "vous passez en abonnement premium"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/omnex/control-plane/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MemCodeStore : implémentation en mémoire de CodeStore (dev/tests).
|
||||||
|
type MemCodeStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
items map[string]CodeBuySub
|
||||||
|
byCode map[string]CodeBuySub
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemCodeStore() *MemCodeStore {
|
||||||
|
return &MemCodeStore{
|
||||||
|
items: make(map[string]CodeBuySub),
|
||||||
|
byCode: make(map[string]CodeBuySub),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemCodeStore) CreateCodeForSub(username, code string) (CodeBuySub, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
c := CodeBuySub{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Username: username,
|
||||||
|
CodeBuy: code,
|
||||||
|
}
|
||||||
|
m.items[c.ID] = c
|
||||||
|
m.byCode[code] = c
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemCodeStore) GetCodeForSub(username, code string) (auth.User, CodeBuySub, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
c, ok := m.byCode[code]
|
||||||
|
if !ok {
|
||||||
|
return auth.User{}, CodeBuySub{}, nil
|
||||||
|
}
|
||||||
|
// In a real implementation, we'd fetch the user too
|
||||||
|
return auth.User{}, c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemCodeStore) ListCodes() ([]CodeBuySub, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
out := make([]CodeBuySub, 0, len(m.items))
|
||||||
|
for _, c := range m.items {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CodeBuySub représente un code de souscription associé à un utilisateur.
|
||||||
|
type CodeBuySub struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||||
|
Username string `gorm:"size:64;not null" json:"username"`
|
||||||
|
CodeBuy string `gorm:"uniqueIndex;type:varchar(50);not null" json:"code_verif"`
|
||||||
|
CreatedAt time.Time `gorm:"index;autoCreateTime" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName définit explicitement le nom de la table.
|
||||||
|
func (CodeBuySub) TableName() string { return "code_buy_subs" }
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/omnex/control-plane/api/internal/auth"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store définit les opérations de persistance pour les codes de souscription.
|
||||||
|
type Store interface {
|
||||||
|
// CreateCodeForSub crée un nouveau code pour un utilisateur.
|
||||||
|
CreateCodeForSub(username, code string) (CodeBuySub, error)
|
||||||
|
// GetCodeForSub récupère un code pour un utilisateur.
|
||||||
|
GetCodeForSub(username, code string) (CodeBuySub, error)
|
||||||
|
// ListCodes liste tous les codes.
|
||||||
|
ListCodes() ([]CodeBuySub, error)
|
||||||
|
AddCodeForSub(username, code string) (auth.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GormStore implémente Store avec PostgreSQL via GORM.
|
||||||
|
type GormStore struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGormStore crée un nouveau GormStore.
|
||||||
|
func NewGormStore(db *gorm.DB) *GormStore {
|
||||||
|
return &GormStore{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCodeForSub crée un nouveau code pour un utilisateur.
|
||||||
|
func (s *GormStore) CreateCodeForSub(username, code string) (CodeBuySub, error) {
|
||||||
|
c := CodeBuySub{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Username: auth.NormalizeUsername(username),
|
||||||
|
CodeBuy: code,
|
||||||
|
}
|
||||||
|
if err := s.db.Create(&c).Error; err != nil {
|
||||||
|
return CodeBuySub{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// S'assurer que CreatedAt est bien rempli
|
||||||
|
if c.CreatedAt.IsZero() {
|
||||||
|
c.CreatedAt = time.Now().UTC()
|
||||||
|
if err := s.db.Save(&c).Error; err != nil {
|
||||||
|
return CodeBuySub{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCodeForSub récupère un code pour un utilisateur.
|
||||||
|
func (s *GormStore) GetCodeForSub(username, code string) (CodeBuySub, error) {
|
||||||
|
var c CodeBuySub
|
||||||
|
if err := s.db.Where("username = ? AND code_buy = ?", username, code).First(&c).Error; err != nil {
|
||||||
|
return CodeBuySub{}, err
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GormStore) AddCodeForSub(username, code string) (auth.User, error) {
|
||||||
|
var c CodeBuySub
|
||||||
|
err := s.db.Where("username = ? AND code_buy = ?", username, code).First(&c).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return auth.User{}, errors.New("code invalide")
|
||||||
|
}
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var u auth.User
|
||||||
|
if err := s.db.Where("username = ?", auth.NormalizeUsername(username)).First(&u).Error; err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
u.TypeAbo = "premium"
|
||||||
|
u.ExpiredAt = time.Now().UTC().AddDate(0, 0, 30)
|
||||||
|
if err := s.db.Model(&u).Updates(map[string]interface{}{
|
||||||
|
"type_abo": u.TypeAbo,
|
||||||
|
"expired_at": u.ExpiredAt,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Delete(&c).Error; err != nil {
|
||||||
|
return auth.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCodes liste tous les codes, triés par date de création.
|
||||||
|
func (s *GormStore) ListCodes() ([]CodeBuySub, error) {
|
||||||
|
var codes []CodeBuySub
|
||||||
|
// Essayer avec tri par date, sinon sans tri pour compatibilité
|
||||||
|
if err := s.db.Order("created_at desc").Find(&codes).Error; err != nil {
|
||||||
|
// Si colonne manquante, on liste sans tri
|
||||||
|
if orderErr := s.db.Find(&codes).Error; orderErr != nil {
|
||||||
|
return nil, orderErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemStore implémente Store en mémoire (pour dev/tests).
|
||||||
|
type MemStore struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
items map[string]CodeBuySub
|
||||||
|
byCode map[string]CodeBuySub
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMemStore crée un nouveau MemStore.
|
||||||
|
func NewMemStore() *MemStore {
|
||||||
|
return &MemStore{
|
||||||
|
items: make(map[string]CodeBuySub),
|
||||||
|
byCode: make(map[string]CodeBuySub),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCodeForSub crée un nouveau code pour un utilisateur.
|
||||||
|
func (m *MemStore) CreateCodeForSub(username, code string) (CodeBuySub, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
c := CodeBuySub{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Username: username,
|
||||||
|
CodeBuy: code,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
m.items[c.ID] = c
|
||||||
|
m.byCode[code] = c
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCodeForSub récupère un code pour un utilisateur.
|
||||||
|
func (m *MemStore) GetCodeForSub(username, code string) (CodeBuySub, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
c, ok := m.byCode[code]
|
||||||
|
if !ok {
|
||||||
|
return CodeBuySub{}, nil
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCodes liste tous les codes.
|
||||||
|
func (m *MemStore) ListCodes() ([]CodeBuySub, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
out := make([]CodeBuySub, 0, len(m.items))
|
||||||
|
for _, c := range m.items {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemStore) AddCodeForSub(username, code string) (auth.User, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
c, ok := m.byCode[code]
|
||||||
|
if !ok || c.Username != username {
|
||||||
|
return auth.User{}, errors.New("code invalide")
|
||||||
|
}
|
||||||
|
delete(m.byCode, code)
|
||||||
|
delete(m.items, c.ID)
|
||||||
|
|
||||||
|
// Le MemStore ne connaît pas les utilisateurs (pas de référence à memUsers ici) :
|
||||||
|
// il ne peut donc pas mettre à jour TypeAbo lui-même. Cf. point ci-dessous.
|
||||||
|
return auth.User{Username: username, TypeAbo: "premium"}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user