chore: update
ci-api / test (push) Successful in 28m2s

This commit is contained in:
Xor290
2026-08-08 18:27:52 +02:00
parent 000ed75313
commit 2f6e5a953d
15 changed files with 939 additions and 42 deletions
+38 -8
View File
@@ -3,9 +3,14 @@ package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
@@ -22,6 +27,13 @@ import (
"github.com/omnex/control-plane/api/internal/sub"
)
// shutdownGracePeriod : délai laissé aux requêtes HTTP en cours pour se
// terminer après réception de SIGTERM/SIGINT (ex. redéploiement Docker
// Compose) avant l'arrêt forcé. Ne couvre pas les provisionnings de démo en
// arrière-plan (goroutines fire-and-forget, voir demos.Service.Create) —
// seules les requêtes HTTP en vol sont drainées.
const shutdownGracePeriod = 15 * time.Second
// memUsers : store utilisateurs en mémoire (fallback dev sans base).
type memUsers map[string]auth.User
@@ -147,12 +159,12 @@ func main() {
TTL: demos.TTL,
})
// Alerting : surveille les pods des démos actives, notifie Discord et/ou
// Telegram de chaque admin ayant configuré son propre canal (voir profil
// admin, /profile/alerts). Désactivé en mode mémoire (pas de réglages
// persistés à interroger).
// Alerting : surveille les pods des démos actives (état + seuils
// CPU/mémoire/PVC), notifie Discord et/ou Telegram de chaque admin ayant
// configuré son propre canal (voir profil admin, /profile/alerts).
// Désactivé en mode mémoire (pas de réglages persistés à interroger).
if alertRecipients != nil {
watcher := alerts.NewWatcher(k8sClient, alertRecipients, func() ([]string, error) {
watcher := alerts.NewWatcher(k8sClient, metricsClient, alertRecipients, func() ([]string, error) {
all, err := demoSvc.List()
if err != nil {
return nil, err
@@ -182,9 +194,27 @@ func main() {
}
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)
srv := &http.Server{Addr: cfg.Addr, Handler: r}
go func() {
log.Printf("Omnex API sur %s (env=%s)", cfg.Addr, cfg.Env)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("serveur HTTP: %v", err)
}
}()
// Arrêt propre : sur SIGTERM/SIGINT, cesse d'accepter de nouvelles
// connexions et laisse shutdownGracePeriod aux requêtes en cours pour se
// terminer (évite de couper une requête en vol lors d'un redéploiement).
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT)
<-stop
log.Printf("signal d'arrêt reçu, extinction en cours (jusqu'à %s)...", shutdownGracePeriod)
ctx, cancel := context.WithTimeout(context.Background(), shutdownGracePeriod)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("extinction forcée après délai: %v", err)
}
}
+153
View File
@@ -0,0 +1,153 @@
// cmd/loadtest : script de charge maison pour l'API control-plane (aucun
// outil externe type k6/locust — un seul binaire Go qui mesure latence et
// taux d'erreur sous charge concurrente sur un endpoint authentifié).
//
// Usage :
//
// go run ./cmd/loadtest --url https://control-plane.example \
// --username admin --password *** \
// --endpoint /api/v1/demos --concurrency 20 --duration 30s
package main
import (
"bytes"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"sort"
"sync"
"sync/atomic"
"time"
)
func main() {
baseURL := flag.String("url", "http://localhost:8080", "URL de base de l'API")
username := flag.String("username", "", "identifiant admin (pour /auth/login)")
password := flag.String("password", "", "mot de passe admin")
endpoint := flag.String("endpoint", "/api/v1/demos", "endpoint GET à charger (authentifié)")
concurrency := flag.Int("concurrency", 10, "nombre de workers concurrents")
duration := flag.Duration("duration", 30*time.Second, "durée du test")
flag.Parse()
if *username == "" || *password == "" {
log.Fatal("--username et --password requis (compte admin)")
}
token, err := login(*baseURL, *username, *password)
if err != nil {
log.Fatalf("login: %v", err)
}
log.Printf("connecté — %d workers pendant %s sur %s%s", *concurrency, *duration, *baseURL, *endpoint)
var (
mu sync.Mutex
latencies []time.Duration
okCount int64
errCount int64
)
ctx, cancel := context.WithTimeout(context.Background(), *duration)
defer cancel()
client := &http.Client{Timeout: 10 * time.Second}
var wg sync.WaitGroup
for i := 0; i < *concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
default:
}
start := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, *baseURL+*endpoint, nil)
if err != nil {
atomic.AddInt64(&errCount, 1)
continue
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
elapsed := time.Since(start)
if err != nil {
atomic.AddInt64(&errCount, 1)
continue
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode >= 400 {
atomic.AddInt64(&errCount, 1)
continue
}
atomic.AddInt64(&okCount, 1)
mu.Lock()
latencies = append(latencies, elapsed)
mu.Unlock()
}
}()
}
wg.Wait()
report(latencies, okCount, errCount, *duration)
}
func login(baseURL, username, password string) (string, error) {
body, err := json.Marshal(map[string]string{"username": username, "password": password})
if err != nil {
return "", err
}
resp, err := http.Post(baseURL+"/api/v1/auth/login", "application/json", bytes.NewReader(body))
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("statut %d: %s", resp.StatusCode, b)
}
var out struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
return out.Token, nil
}
func report(latencies []time.Duration, ok, errs int64, duration time.Duration) {
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
percentile := func(p float64) time.Duration {
if len(latencies) == 0 {
return 0
}
idx := int(float64(len(latencies)-1) * p)
return latencies[idx]
}
total := ok + errs
var errRate float64
if total > 0 {
errRate = 100 * float64(errs) / float64(total)
}
fmt.Println()
fmt.Println("=== Résultat ===")
fmt.Printf("Durée : %s\n", duration)
fmt.Printf("Requêtes : %d (%.1f req/s)\n", total, float64(total)/duration.Seconds())
fmt.Printf("Succès / Erreurs : %d / %d (%.2f%% d'erreurs)\n", ok, errs, errRate)
if len(latencies) > 0 {
fmt.Printf("Latence p50/p95/p99 : %s / %s / %s\n", percentile(0.50), percentile(0.95), percentile(0.99))
fmt.Printf("Latence min/max : %s / %s\n", latencies[0], latencies[len(latencies)-1])
} else {
fmt.Println("Aucune requête réussie — pas de statistiques de latence.")
}
}
@@ -0,0 +1,199 @@
package alerts
import (
"context"
"encoding/json"
"fmt"
"log"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const (
// Seuils d'alerte, en % de la limite (CPU/mémoire) ou de la capacité (PVC).
resourceHighThresholdPct = 90
pvcFullThresholdPct = 85
// Nombre de ticks consécutifs au-dessus du seuil avant notification
// (pollInterval = 30s, soit ~90s) : absorbe les pics transitoires (déploiement
// en cours, backup Velero, migration...) sans notifier à tort.
consecutiveTicksBeforeAlert = 3
)
// send envoie un message si un notifier est disponible, journalise en cas d'échec.
func (w *Watcher) send(ctx context.Context, notifier Notifier, msg string) {
if notifier == nil {
return
}
if err := notifier.Notify(ctx, msg); err != nil {
log.Printf("alerts: notification échouée: %v", err)
}
}
// podResourcePercents calcule le % d'usage CPU/mémoire d'un pod par rapport
// aux limites déclarées dans son spec. ok=false si un conteneur n'a pas de
// limite définie (pourcentage non significatif dans ce cas — on ne peut pas
// dire à quel point il est "plein").
func podResourcePercents(pod corev1.Pod, cpuMilli, memBytes int64) (cpuPct, memPct int, ok bool) {
var cpuLimit, memLimit int64
for _, c := range pod.Spec.Containers {
cl := c.Resources.Limits.Cpu()
ml := c.Resources.Limits.Memory()
if cl.IsZero() || ml.IsZero() {
return 0, 0, false
}
cpuLimit += cl.MilliValue()
memLimit += ml.Value()
}
if cpuLimit == 0 || memLimit == 0 {
return 0, 0, false
}
return int(cpuMilli * 100 / cpuLimit), int(memBytes * 100 / memLimit), true
}
// checkResourceUsage alerte si un pod dépasse resourceHighThresholdPct de CPU
// ou de mémoire pendant consecutiveTicksBeforeAlert ticks d'affilée. seenHigh
// est alimenté pour la purge de récupération en fin de tick (voir
// clearResourceRecovered).
func (w *Watcher) checkResourceUsage(ctx context.Context, loadNotifier func() Notifier, ns string, pods []corev1.Pod, seenHigh map[podKey]bool) {
if w.metricsClient == nil {
return
}
list, err := w.metricsClient.MetricsV1beta1().PodMetricses(ns).List(ctx, metav1.ListOptions{})
if err != nil {
return // metrics-server temporairement indisponible : pas une panne à notifier
}
usage := make(map[string][2]int64, len(list.Items)) // pod -> [cpuMilli, memBytes]
for _, m := range list.Items {
var cpu, mem int64
for _, c := range m.Containers {
cpu += c.Usage.Cpu().MilliValue()
mem += c.Usage.Memory().Value()
}
usage[m.Name] = [2]int64{cpu, mem}
}
for _, pod := range pods {
u, ok := usage[pod.Name]
if !ok {
continue
}
cpuPct, memPct, ok := podResourcePercents(pod, u[0], u[1])
if !ok || (cpuPct < resourceHighThresholdPct && memPct < resourceHighThresholdPct) {
continue
}
key := podKey{namespace: ns, pod: pod.Name}
seenHigh[key] = true
w.resourceHigh[key]++
if w.resourceHigh[key] == consecutiveTicksBeforeAlert {
w.send(ctx, loadNotifier(), fmt.Sprintf(
"🟠 [%s] pod %s : usage élevé — CPU %d%%, mémoire %d%% (limite)", ns, pod.Name, cpuPct, memPct))
}
}
}
// clearResourceRecovered notifie le retour sous le seuil CPU/mémoire et purge
// l'état des pods qui ne sont plus au-dessus du seuil ce tick-ci.
func (w *Watcher) clearResourceRecovered(ctx context.Context, loadNotifier func() Notifier, seenHigh map[podKey]bool) {
for key, count := range w.resourceHigh {
if seenHigh[key] {
continue
}
if count >= consecutiveTicksBeforeAlert {
w.send(ctx, loadNotifier(), fmt.Sprintf(
"🟢 [%s] pod %s : usage CPU/mémoire revenu à la normale", key.namespace, key.pod))
}
delete(w.resourceHigh, key)
}
}
type pvcKey struct{ namespace, pvc string }
// kubeletSummary : sous-ensemble de l'API kubelet /stats/summary (voir
// k8s.io/kubelet/pkg/apis/stats/v1alpha1.Summary sur
// github.com/kubernetes/kubelet) utilisé pour lire l'usage réel des volumes
// (PVC) montés sur un nœud — aucune métrique de ce type n'est exposée par
// metrics-server, qui ne couvre que CPU/mémoire. Nécessite la permission
// "get" sur le sous-type "nodes/proxy" pour le compte utilisé par l'API
// (aucun ClusterRole scoping n'existe dans ce repo à ce jour — vérifier si
// un jour le control-plane tourne avec un ServiceAccount restreint).
type kubeletSummary struct {
Pods []struct {
PodRef struct {
Namespace string `json:"namespace"`
} `json:"podRef"`
Volume []struct {
CapacityBytes *uint64 `json:"capacityBytes,omitempty"`
UsedBytes *uint64 `json:"usedBytes,omitempty"`
PVCRef *struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
} `json:"pvcRef,omitempty"`
} `json:"volume,omitempty"`
} `json:"pods"`
}
// fetchNodeSummary récupère les stats kubelet d'un nœud via le proxy API
// server (équivalent de `kubectl get --raw /api/v1/nodes/<node>/proxy/stats/summary`).
func (w *Watcher) fetchNodeSummary(ctx context.Context, nodeName string) (*kubeletSummary, error) {
raw, err := w.k8sClient.CoreV1().RESTClient().Get().
Resource("nodes").
Name(nodeName).
SubResource("proxy").
Suffix("stats/summary").
Do(ctx).Raw()
if err != nil {
return nil, err
}
var summary kubeletSummary
if err := json.Unmarshal(raw, &summary); err != nil {
return nil, err
}
return &summary, nil
}
// checkPVCUsage alerte si un volume (PVC) d'un namespace actif dépasse
// pvcFullThresholdPct de remplissage pendant consecutiveTicksBeforeAlert
// ticks. nodeNames : nœuds hébergeant au moins un pod Running d'un namespace
// actif (voir tick) — on n'interroge que ceux-là, pas tout le cluster.
func (w *Watcher) checkPVCUsage(ctx context.Context, loadNotifier func() Notifier, nsSet map[string]bool, nodeNames map[string]bool) {
seenHigh := make(map[pvcKey]bool)
for node := range nodeNames {
summary, err := w.fetchNodeSummary(ctx, node)
if err != nil {
log.Printf("alerts: stats kubelet indisponibles pour le nœud %s: %v", node, err)
continue
}
for _, pod := range summary.Pods {
if !nsSet[pod.PodRef.Namespace] {
continue
}
for _, vol := range pod.Volume {
if vol.PVCRef == nil || vol.CapacityBytes == nil || vol.UsedBytes == nil || *vol.CapacityBytes == 0 {
continue
}
pct := int(*vol.UsedBytes * 100 / *vol.CapacityBytes)
if pct < pvcFullThresholdPct {
continue
}
key := pvcKey{namespace: vol.PVCRef.Namespace, pvc: vol.PVCRef.Name}
seenHigh[key] = true
w.pvcHigh[key]++
if w.pvcHigh[key] == consecutiveTicksBeforeAlert {
w.send(ctx, loadNotifier(), fmt.Sprintf(
"🟠 [%s] volume %s rempli à %d%%", key.namespace, key.pvc, pct))
}
}
}
}
for key, count := range w.pvcHigh {
if seenHigh[key] {
continue
}
if count >= consecutiveTicksBeforeAlert {
w.send(ctx, loadNotifier(), fmt.Sprintf(
"🟢 [%s] volume %s repassé sous %d%% de remplissage", key.namespace, key.pvc, pvcFullThresholdPct))
}
delete(w.pvcHigh, key)
}
}
@@ -0,0 +1,90 @@
package alerts
import (
"testing"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
)
func podWithLimits(cpuLimit, memLimit string) corev1.Pod {
return corev1.Pod{
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse(cpuLimit),
corev1.ResourceMemory: resource.MustParse(memLimit),
},
},
},
},
},
}
}
func TestPodResourcePercents(t *testing.T) {
pod := podWithLimits("500m", "256Mi")
cpuPct, memPct, ok := podResourcePercents(pod, 450, 256*1024*1024)
if !ok {
t.Fatal("ok = false, want true")
}
if cpuPct != 90 {
t.Errorf("cpuPct = %d, want 90", cpuPct)
}
if memPct != 100 {
t.Errorf("memPct = %d, want 100", memPct)
}
}
func TestPodResourcePercents_NoLimitsDefined(t *testing.T) {
pod := corev1.Pod{
Spec: corev1.PodSpec{
Containers: []corev1.Container{{}},
},
}
_, _, ok := podResourcePercents(pod, 100, 100)
if ok {
t.Error("ok = true, want false when container has no resource limits")
}
}
func TestPodResourcePercents_MultiContainerSumsLimits(t *testing.T) {
pod := corev1.Pod{
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("250m"),
corev1.ResourceMemory: resource.MustParse("128Mi"),
},
},
},
{
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("250m"),
corev1.ResourceMemory: resource.MustParse("128Mi"),
},
},
},
},
},
}
// Limite totale : 500m CPU, 256Mi mémoire.
cpuPct, memPct, ok := podResourcePercents(pod, 250, 128*1024*1024)
if !ok {
t.Fatal("ok = false, want true")
}
if cpuPct != 50 {
t.Errorf("cpuPct = %d, want 50", cpuPct)
}
if memPct != 50 {
t.Errorf("memPct = %d, want 50", memPct)
}
}
+38 -19
View File
@@ -9,6 +9,7 @@ import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
metricsclient "k8s.io/metrics/pkg/client/clientset/versioned"
)
const pollInterval = 30 * time.Second
@@ -29,23 +30,31 @@ var badWaitingReasons = map[string]bool{
type podKey struct{ namespace, pod string }
// Watcher scrute périodiquement les pods des namespaces actifs et notifie
// tout changement d'état (sain -> panne, panne -> rétabli). Les destinataires
// (recipients) sont résolus à chaque tick où une notification est nécessaire
// — chaque admin configure son propre webhook Discord / bot Telegram dans
// son profil (voir internal/profile), rien n'est figé au démarrage.
// tout changement d'état (sain -> panne, panne -> rétabli), ainsi que les
// dépassements de seuil CPU/mémoire (voir resources.go) et de remplissage
// des volumes (PVC). Les destinataires (recipients) sont résolus à chaque
// tick où une notification est nécessaire — chaque admin configure son
// propre webhook Discord / bot Telegram dans son profil (voir
// internal/profile), rien n'est figé au démarrage.
type Watcher struct {
k8sClient *kubernetes.Clientset
recipients func(ctx context.Context) ([]Notifier, error)
namespaces func() ([]string, error)
state map[podKey]string // dernière raison de panne connue ("" = sain)
k8sClient *kubernetes.Clientset
metricsClient *metricsclient.Clientset // optionnel : nil désactive les alertes CPU/mémoire (le reste du watcher continue de fonctionner)
recipients func(ctx context.Context) ([]Notifier, error)
namespaces func() ([]string, error)
state map[podKey]string // dernière raison de panne connue ("" = sain)
resourceHigh map[podKey]int // ticks consécutifs au-dessus du seuil CPU/mémoire
pvcHigh map[pvcKey]int // ticks consécutifs au-dessus du seuil de remplissage PVC
}
func NewWatcher(k8sClient *kubernetes.Clientset, recipients func(ctx context.Context) ([]Notifier, error), namespaces func() ([]string, error)) *Watcher {
func NewWatcher(k8sClient *kubernetes.Clientset, metricsClient *metricsclient.Clientset, recipients func(ctx context.Context) ([]Notifier, error), namespaces func() ([]string, error)) *Watcher {
return &Watcher{
k8sClient: k8sClient,
recipients: recipients,
namespaces: namespaces,
state: make(map[podKey]string),
k8sClient: k8sClient,
metricsClient: metricsClient,
recipients: recipients,
namespaces: namespaces,
state: make(map[podKey]string),
resourceHigh: make(map[podKey]int),
pvcHigh: make(map[pvcKey]int),
}
}
@@ -69,6 +78,10 @@ func (w *Watcher) tick(ctx context.Context) {
log.Printf("alerts: liste des démos actives indisponible: %v", err)
return
}
nsSet := make(map[string]bool, len(nsList))
for _, ns := range nsList {
nsSet[ns] = true
}
// Les destinataires ne sont chargés (requête DB) que si une notification
// s'avère nécessaire ce tick-ci, et une seule fois par tick.
@@ -92,6 +105,9 @@ func (w *Watcher) tick(ctx context.Context) {
}
seen := make(map[podKey]bool, len(w.state))
seenHigh := make(map[podKey]bool, len(w.resourceHigh))
nodeNames := make(map[string]bool) // nœuds hébergeant un pod Running d'un namespace actif (voir checkPVCUsage)
for _, ns := range nsList {
pods, err := w.k8sClient.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{})
if err != nil {
@@ -114,7 +130,13 @@ func (w *Watcher) tick(ctx context.Context) {
}
w.state[key] = reason
}
if pod.Status.Phase == corev1.PodRunning && pod.Spec.NodeName != "" {
nodeNames[pod.Spec.NodeName] = true
}
}
w.checkResourceUsage(ctx, loadNotifier, ns, pods.Items, seenHigh)
}
// Pods disparus (démo supprimée, rollout) : on oublie leur état pour ne
@@ -124,12 +146,11 @@ func (w *Watcher) tick(ctx context.Context) {
delete(w.state, key)
}
}
w.clearResourceRecovered(ctx, loadNotifier, seenHigh)
w.checkPVCUsage(ctx, loadNotifier, nsSet, nodeNames)
}
func (w *Watcher) notify(ctx context.Context, notifier Notifier, key podKey, prev, reason string) {
if notifier == nil {
return
}
var msg string
switch {
case prev == "" && reason != "":
@@ -139,9 +160,7 @@ func (w *Watcher) notify(ctx context.Context, notifier Notifier, key podKey, pre
default:
msg = fmt.Sprintf("🔴 [%s] pod %s toujours en erreur : %s", key.namespace, key.pod, reason)
}
if err := notifier.Notify(ctx, msg); err != nil {
log.Printf("alerts: notification échouée: %v", err)
}
w.send(ctx, notifier, msg)
}
// problemReason retourne une raison de panne non vide si le pod est dans un
@@ -0,0 +1,89 @@
package auth
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestIssuer_IssueVerify_Roundtrip(t *testing.T) {
issuer := NewIssuer([]byte("test-secret-32-bytes-minimum!!!"), time.Hour)
token, err := issuer.Issue("user-1", "alice", RoleAdmin, "session-abc")
if err != nil {
t.Fatalf("Issue: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if claims.Subject != "user-1" {
t.Errorf("Subject = %q, want %q", claims.Subject, "user-1")
}
if claims.Username != "alice" {
t.Errorf("Username = %q, want %q", claims.Username, "alice")
}
if claims.Role != RoleAdmin {
t.Errorf("Role = %q, want %q", claims.Role, RoleAdmin)
}
if claims.SessionID != "session-abc" {
t.Errorf("SessionID = %q, want %q", claims.SessionID, "session-abc")
}
}
func TestIssuer_Verify_ExpiredToken(t *testing.T) {
issuer := NewIssuer([]byte("test-secret-32-bytes-minimum!!!"), -time.Hour)
token, err := issuer.Issue("user-1", "alice", RoleClient, "session-abc")
if err != nil {
t.Fatalf("Issue: %v", err)
}
if _, err := issuer.Verify(token); err != ErrInvalidToken {
t.Errorf("Verify(expired) error = %v, want ErrInvalidToken", err)
}
}
func TestIssuer_Verify_WrongSecret(t *testing.T) {
issuer := NewIssuer([]byte("test-secret-32-bytes-minimum!!!"), time.Hour)
token, err := issuer.Issue("user-1", "alice", RoleClient, "session-abc")
if err != nil {
t.Fatalf("Issue: %v", err)
}
other := NewIssuer([]byte("different-secret-32-bytes-min!!"), time.Hour)
if _, err := other.Verify(token); err != ErrInvalidToken {
t.Errorf("Verify(wrong secret) error = %v, want ErrInvalidToken", err)
}
}
func TestIssuer_Verify_RejectsNoneAlgorithm(t *testing.T) {
issuer := NewIssuer([]byte("test-secret-32-bytes-minimum!!!"), time.Hour)
claims := Claims{
Role: RoleAdmin,
Username: "attacker",
SessionID: "forged",
RegisteredClaims: jwt.RegisteredClaims{
Subject: "user-1",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
forged, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims).SignedString(jwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatalf("forge none-alg token: %v", err)
}
if _, err := issuer.Verify(forged); err != ErrInvalidToken {
t.Errorf("Verify(none-alg token) error = %v, want ErrInvalidToken (alg-confusion protection)", err)
}
}
func TestIssuer_Verify_MalformedToken(t *testing.T) {
issuer := NewIssuer([]byte("test-secret-32-bytes-minimum!!!"), time.Hour)
if _, err := issuer.Verify("not.a.token"); err != ErrInvalidToken {
t.Errorf("Verify(malformed) error = %v, want ErrInvalidToken", err)
}
}
@@ -0,0 +1,62 @@
package auth
import "testing"
func TestHashPassword_VerifyPassword_Roundtrip(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ok, err := VerifyPassword("correct horse battery staple", hash)
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if !ok {
t.Error("VerifyPassword = false, want true for correct password")
}
}
func TestVerifyPassword_WrongPassword(t *testing.T) {
hash, err := HashPassword("correct horse battery staple")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
ok, err := VerifyPassword("wrong password", hash)
if err != nil {
t.Fatalf("VerifyPassword: %v", err)
}
if ok {
t.Error("VerifyPassword = true, want false for wrong password")
}
}
func TestHashPassword_UniqueSaltPerCall(t *testing.T) {
hash1, err := HashPassword("same password")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
hash2, err := HashPassword("same password")
if err != nil {
t.Fatalf("HashPassword: %v", err)
}
if hash1 == hash2 {
t.Error("HashPassword produced identical hashes for two calls — salt is not random")
}
}
func TestVerifyPassword_InvalidHashFormat(t *testing.T) {
cases := []string{
"",
"not-a-hash",
"$argon2id$v=19$m=65536,t=1,p=4$onlyfiveparts",
"$bcrypt$v=19$m=65536,t=1,p=4$c2FsdA$aGFzaA",
}
for _, encoded := range cases {
_, err := VerifyPassword("anything", encoded)
if err != ErrInvalidHash {
t.Errorf("VerifyPassword(%q) error = %v, want ErrInvalidHash", encoded, err)
}
}
}
@@ -0,0 +1,102 @@
package config
import "testing"
func TestLoad_MissingJWTSecret(t *testing.T) {
t.Setenv("OMNEX_JWT_SECRET", "")
if _, err := Load(); err == nil {
t.Error("Load() error = nil, want error for missing OMNEX_JWT_SECRET")
}
}
func TestLoad_JWTSecretTooShort(t *testing.T) {
t.Setenv("OMNEX_JWT_SECRET", "short")
if _, err := Load(); err == nil {
t.Error("Load() error = nil, want error for JWT secret < 32 bytes")
}
}
func TestLoad_DevDefaults(t *testing.T) {
t.Setenv("OMNEX_JWT_SECRET", "12345678901234567890123456789012")
t.Setenv("OMNEX_ENV", "")
t.Setenv("OMNEX_DATABASE_URL", "")
t.Setenv("OMNEX_REDIS_URL", "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v, want nil", err)
}
if cfg.Env != "dev" {
t.Errorf("Env = %q, want %q", cfg.Env, "dev")
}
if cfg.Addr != ":8080" {
t.Errorf("Addr = %q, want %q", cfg.Addr, ":8080")
}
if cfg.Secure() {
t.Error("Secure() = true in dev, want false")
}
}
func TestLoad_ProdRequiresDatabaseAndRedis(t *testing.T) {
t.Setenv("OMNEX_JWT_SECRET", "12345678901234567890123456789012")
t.Setenv("OMNEX_ENV", "prod")
t.Setenv("OMNEX_DATABASE_URL", "")
t.Setenv("OMNEX_REDIS_URL", "")
if _, err := Load(); err == nil {
t.Error("Load() error = nil, want error for prod without DATABASE_URL/REDIS_URL")
}
}
func TestLoad_ProdRequiresRedisEvenWithDatabase(t *testing.T) {
t.Setenv("OMNEX_JWT_SECRET", "12345678901234567890123456789012")
t.Setenv("OMNEX_ENV", "prod")
t.Setenv("OMNEX_DATABASE_URL", "postgres://localhost/omnex")
t.Setenv("OMNEX_REDIS_URL", "")
if _, err := Load(); err == nil {
t.Error("Load() error = nil, want error for prod without REDIS_URL")
}
}
func TestLoad_ProdSucceedsWithAllRequired(t *testing.T) {
t.Setenv("OMNEX_JWT_SECRET", "12345678901234567890123456789012")
t.Setenv("OMNEX_ENV", "prod")
t.Setenv("OMNEX_DATABASE_URL", "postgres://localhost/omnex")
t.Setenv("OMNEX_REDIS_URL", "redis://localhost:6379/0")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v, want nil", err)
}
if !cfg.Secure() {
t.Error("Secure() = false in prod, want true")
}
}
func TestSplitCSV(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"", nil},
{"a", []string{"a"}},
{"a,b,c", []string{"a", "b", "c"}},
{"a,,c", []string{"a", "c"}},
{"a,b,", []string{"a", "b"}},
{",a,b", []string{"a", "b"}},
}
for _, tc := range cases {
got := splitCSV(tc.in)
if len(got) != len(tc.want) {
t.Errorf("splitCSV(%q) = %v, want %v", tc.in, got, tc.want)
continue
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("splitCSV(%q) = %v, want %v", tc.in, got, tc.want)
break
}
}
}
}
@@ -691,6 +691,7 @@ func (h *HelmProvisioner) installChart(namespace, chartName string, values map[s
"--namespace", namespace,
"--values", valuesFile,
"--wait",
"--atomic",
"--timeout", "5m",
}
if err := h.runHelm(args...); err != nil {
@@ -711,6 +712,7 @@ func (h *HelmProvisioner) upgradeInstallChart(namespace, release, chartName stri
"--install",
"--namespace", namespace,
"--wait",
"--atomic",
"--timeout", "5m",
}
if values != nil {
@@ -1096,10 +1098,12 @@ var componentResourceLimits = map[string]struct {
"frontend": {cpuMilli: 200, memMi: 128},
"postgresql": {cpuMilli: 1000, memMi: 1024},
"redis": {cpuMilli: 500, memMi: 512},
"lbtelegram": {cpuMilli: 500, memMi: 256},
}
// componentKey identifie le composant (backend/frontend/postgresql/redis) à
// partir du nom de pod généré par Helm (ex: "demo-xxx-backend-...-6d59f4-abcde").
// componentKey identifie le composant (backend/frontend/postgresql/redis/
// lbtelegram) à partir du nom de pod généré par Helm (ex:
// "demo-xxx-backend-...-6d59f4-abcde").
func componentKey(podName string) string {
switch {
case strings.Contains(podName, "backend"):
@@ -1110,6 +1114,8 @@ func componentKey(podName string) string {
return "postgresql"
case strings.Contains(podName, "redis"):
return "redis"
case strings.Contains(podName, "lbtelegram"):
return "lbtelegram"
default:
return ""
}
@@ -1172,6 +1178,8 @@ func (h *HelmProvisioner) GetResourceState(
state.DB = cs
case "redis":
state.DBM = cs
case "lbtelegram":
state.LB = cs
}
}
+4 -1
View File
@@ -40,10 +40,13 @@ type ComponentState struct {
MemoryLimitMi int64 `json:"memory_limit_mi"` // limite mémoire configurée, en Mi
}
// ResourceState : état live des 4 composants d'une démo.
// ResourceState : état live des composants d'une démo. LB (load-balancer
// Telegram) est optionnel : Phase reste "" si le chart lbtelegram n'est pas
// installé pour cette démo (voir ProvisionConfig.LBTelegramEnabled).
type ResourceState struct {
API ComponentState `json:"api"`
Web ComponentState `json:"web"`
DB ComponentState `json:"db"`
DBM ComponentState `json:"dbm"`
LB ComponentState `json:"lb"`
}
@@ -0,0 +1,142 @@
package httpx
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
func TestSecurityHeaders(t *testing.T) {
r := gin.New()
r.Use(SecurityHeaders())
r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
r.ServeHTTP(w, req)
want := map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
}
for header, expected := range want {
if got := w.Header().Get(header); got != expected {
t.Errorf("header %s = %q, want %q", header, got, expected)
}
}
}
func TestCORS_AllowedOrigin(t *testing.T) {
r := gin.New()
r.Use(CORS([]string{"https://omnex.example"}))
r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://omnex.example")
r.ServeHTTP(w, req)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://omnex.example" {
t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "https://omnex.example")
}
if got := w.Header().Get("Vary"); got != "Origin" {
t.Errorf("Vary = %q, want %q", got, "Origin")
}
}
func TestCORS_DisallowedOrigin(t *testing.T) {
r := gin.New()
r.Use(CORS([]string{"https://omnex.example"}))
r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Origin", "https://evil.example")
r.ServeHTTP(w, req)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Access-Control-Allow-Origin = %q, want empty for disallowed origin", got)
}
}
func TestCORS_PreflightOptions(t *testing.T) {
r := gin.New()
r.Use(CORS([]string{"https://omnex.example"}))
r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodOptions, "/", nil)
req.Header.Set("Origin", "https://omnex.example")
r.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("OPTIONS status = %d, want %d", w.Code, http.StatusNoContent)
}
}
func TestRateLimit_AllowsWithinBurst(t *testing.T) {
r := gin.New()
r.Use(RateLimit(1, 3))
r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) })
for i := 0; i < 3; i++ {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.1:1234"
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("request %d: status = %d, want %d", i, w.Code, http.StatusOK)
}
}
}
func TestRateLimit_BlocksOverBurst(t *testing.T) {
r := gin.New()
r.Use(RateLimit(1, 2))
r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) })
for i := 0; i < 2; i++ {
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.2:1234"
r.ServeHTTP(w, req)
}
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.2:1234"
r.ServeHTTP(w, req)
if w.Code != http.StatusTooManyRequests {
t.Errorf("status = %d, want %d", w.Code, http.StatusTooManyRequests)
}
}
func TestRateLimit_PerIPIsolation(t *testing.T) {
r := gin.New()
r.Use(RateLimit(1, 1))
r.GET("/", func(c *gin.Context) { c.Status(http.StatusOK) })
// Épuise le burst pour l'IP A.
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "10.0.0.3:1234"
r.ServeHTTP(w, req)
// L'IP B doit rester indépendante.
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2.RemoteAddr = "10.0.0.4:1234"
r.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Errorf("IP B status = %d, want %d (isolation from IP A)", w2.Code, http.StatusOK)
}
}