@@ -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