345 lines
10 KiB
Go
345 lines
10 KiB
Go
package projects
|
|
|
|
import (
|
|
"errors"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/omnex/control-plane/api/internal/demos"
|
|
)
|
|
|
|
// fakeProv enregistre les appels au cluster ; provisioned est fermé une fois
|
|
// le déploiement (asynchrone) terminé.
|
|
type fakeProv struct {
|
|
mu sync.Mutex
|
|
specs []demos.ProjectSpec
|
|
suspended []string
|
|
resumed []string
|
|
tornDown []string
|
|
provErr error
|
|
resumeErr error
|
|
provisioned chan struct{}
|
|
}
|
|
|
|
func newFakeProv() *fakeProv { return &fakeProv{provisioned: make(chan struct{}, 8)} }
|
|
|
|
func (f *fakeProv) ProvisionProject(s demos.ProjectSpec) error {
|
|
f.mu.Lock()
|
|
f.specs = append(f.specs, s)
|
|
f.mu.Unlock()
|
|
defer func() { f.provisioned <- struct{}{} }()
|
|
return f.provErr
|
|
}
|
|
func (f *fakeProv) TeardownProject(ns string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.tornDown = append(f.tornDown, ns)
|
|
return nil
|
|
}
|
|
func (f *fakeProv) SuspendProject(ns string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.suspended = append(f.suspended, ns)
|
|
return nil
|
|
}
|
|
func (f *fakeProv) ResumeProject(ns string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.resumed = append(f.resumed, ns)
|
|
return f.resumeErr
|
|
}
|
|
|
|
var t0 = time.Date(2026, time.September, 20, 12, 0, 0, 0, time.UTC)
|
|
|
|
func newTestService(t *testing.T) (*Service, *fakeProv, *time.Time) {
|
|
t.Helper()
|
|
prov := newFakeProv()
|
|
svc := NewService(NewMemStore(), prov, Config{Domain: "vitrine-omnex.club", Grace: 7 * 24 * time.Hour})
|
|
now := t0
|
|
svc.now = func() time.Time { return now }
|
|
return svc, prov, &now
|
|
}
|
|
|
|
func validInput() CreateInput {
|
|
return CreateInput{ClientName: "Boutique Dupont", Months: 3, AdminUsername: "admin", AdminPassword: "un-mot-de-passe-solide"}
|
|
}
|
|
|
|
// createReady crée un projet et attend la fin de son déploiement simulé.
|
|
func createReady(t *testing.T, svc *Service, prov *fakeProv, in CreateInput) Project {
|
|
t.Helper()
|
|
p, err := svc.Create(in)
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
select {
|
|
case <-prov.provisioned:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("déploiement simulé non terminé")
|
|
}
|
|
// La goroutine de Create met le statut à jour après ProvisionProject :
|
|
// prendre le verrou attend qu'elle ait fini.
|
|
svc.mu.Lock()
|
|
svc.mu.Unlock() //nolint:staticcheck // barrière de synchronisation
|
|
got, err := svc.Get(p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
func TestCreate_MonthsBounds(t *testing.T) {
|
|
svc, _, _ := newTestService(t)
|
|
for _, months := range []int{-1, 0, 13, 100} {
|
|
in := validInput()
|
|
in.Months = months
|
|
if _, err := svc.Create(in); !errors.Is(err, ErrInvalidMonths) {
|
|
t.Errorf("months=%d : erreur %v, attendu ErrInvalidMonths", months, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreate_ExpiryIsCalendarMonths(t *testing.T) {
|
|
for _, months := range []int{1, 6, 12} {
|
|
svc, prov, _ := newTestService(t)
|
|
in := validInput()
|
|
in.Months = months
|
|
p := createReady(t, svc, prov, in)
|
|
want := t0.AddDate(0, months, 0)
|
|
if !p.ExpiresAt.Equal(want) {
|
|
t.Errorf("months=%d : ExpiresAt=%v, attendu %v", months, p.ExpiresAt, want)
|
|
}
|
|
if !p.DeleteAt.Equal(want.Add(7 * 24 * time.Hour)) {
|
|
t.Errorf("months=%d : DeleteAt=%v (grâce de 7 jours attendue)", months, p.DeleteAt)
|
|
}
|
|
if p.MonthsPurchased != months {
|
|
t.Errorf("MonthsPurchased=%d, attendu %d", p.MonthsPurchased, months)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreate_ValidatesInput(t *testing.T) {
|
|
svc, _, _ := newTestService(t)
|
|
cases := map[string]struct {
|
|
mutate func(*CreateInput)
|
|
want error
|
|
}{
|
|
"client vide": {func(i *CreateInput) { i.ClientName = " " }, ErrInvalidClient},
|
|
"admin invalide": {func(i *CreateInput) { i.AdminUsername = "a b" }, ErrInvalidAdmin},
|
|
"admin trop court": {func(i *CreateInput) { i.AdminUsername = "ab" }, ErrInvalidAdmin},
|
|
"mot de passe": {func(i *CreateInput) { i.AdminPassword = "court" }, ErrWeakAdminPass},
|
|
"nb admin trop": {func(i *CreateInput) { i.AdminNumber = 21 }, ErrInvalidAdminSize},
|
|
"nb admin négatif": {func(i *CreateInput) { i.AdminNumber = -1 }, ErrInvalidAdminSize},
|
|
}
|
|
for name, c := range cases {
|
|
in := validInput()
|
|
c.mutate(&in)
|
|
if _, err := svc.Create(in); !errors.Is(err, c.want) {
|
|
t.Errorf("%s : erreur %v, attendu %v", name, err, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreate_PassesAdminSettingsToProvisioner(t *testing.T) {
|
|
svc, prov, _ := newTestService(t)
|
|
in := validInput()
|
|
in.AdminNumber = 3
|
|
p := createReady(t, svc, prov, in)
|
|
|
|
if len(prov.specs) != 1 {
|
|
t.Fatalf("%d déploiements, attendu 1", len(prov.specs))
|
|
}
|
|
spec := prov.specs[0]
|
|
if spec.AdminNumber != 3 || spec.AdminUsername != "admin" || spec.AdminPassword != in.AdminPassword {
|
|
t.Errorf("spec inattendue : %+v", spec)
|
|
}
|
|
if spec.Host != p.Namespace+".vitrine-omnex.club" || spec.URL != "https://"+spec.Host {
|
|
t.Errorf("host/URL inattendus : %q %q", spec.Host, spec.URL)
|
|
}
|
|
if p.Status != StatusReady {
|
|
t.Errorf("statut %s, attendu ready", p.Status)
|
|
}
|
|
}
|
|
|
|
func TestCreate_DefaultAdminNumberIsOne(t *testing.T) {
|
|
svc, prov, _ := newTestService(t)
|
|
p := createReady(t, svc, prov, validInput())
|
|
if p.AdminNumber != 1 || prov.specs[0].AdminNumber != 1 {
|
|
t.Errorf("AdminNumber=%d / spec=%d, attendu 1", p.AdminNumber, prov.specs[0].AdminNumber)
|
|
}
|
|
}
|
|
|
|
func TestCreate_ProvisionFailureMarksFailed(t *testing.T) {
|
|
svc, prov, _ := newTestService(t)
|
|
prov.provErr = errors.New("helm en échec")
|
|
p := createReady(t, svc, prov, validInput())
|
|
if p.Status != StatusFailed {
|
|
t.Errorf("statut %s, attendu failed", p.Status)
|
|
}
|
|
if _, err := svc.Extend(p.ID, 1); !errors.Is(err, ErrNotExtendable) {
|
|
t.Errorf("Extend sur un projet failed : %v, attendu ErrNotExtendable", err)
|
|
}
|
|
}
|
|
|
|
func TestExtend_AddsMonthsFromCurrentExpiry(t *testing.T) {
|
|
svc, prov, _ := newTestService(t)
|
|
p := createReady(t, svc, prov, validInput()) // 3 mois
|
|
|
|
got, err := svc.Extend(p.ID, 12)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := t0.AddDate(0, 3, 0).AddDate(0, 12, 0)
|
|
if !got.ExpiresAt.Equal(want) {
|
|
t.Errorf("ExpiresAt=%v, attendu %v (échéance courante + 12 mois)", got.ExpiresAt, want)
|
|
}
|
|
if got.MonthsPurchased != 15 {
|
|
t.Errorf("MonthsPurchased=%d, attendu 15", got.MonthsPurchased)
|
|
}
|
|
if !got.DeleteAt.Equal(want.Add(7 * 24 * time.Hour)) {
|
|
t.Errorf("DeleteAt non recalculé : %v", got.DeleteAt)
|
|
}
|
|
}
|
|
|
|
func TestExtend_MonthsBounds(t *testing.T) {
|
|
svc, prov, _ := newTestService(t)
|
|
p := createReady(t, svc, prov, validInput())
|
|
for _, months := range []int{0, 13, -2} {
|
|
if _, err := svc.Extend(p.ID, months); !errors.Is(err, ErrInvalidMonths) {
|
|
t.Errorf("months=%d : %v, attendu ErrInvalidMonths", months, err)
|
|
}
|
|
}
|
|
got, _ := svc.Get(p.ID)
|
|
if !got.ExpiresAt.Equal(p.ExpiresAt) {
|
|
t.Error("une prolongation refusée a modifié l'échéance")
|
|
}
|
|
}
|
|
|
|
func TestExtend_UnknownProject(t *testing.T) {
|
|
svc, _, _ := newTestService(t)
|
|
if _, err := svc.Extend("inconnu", 1); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("erreur %v, attendu ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestExpiration_SuspendsAtExpiryThenDeletesAfterGrace(t *testing.T) {
|
|
svc, prov, now := newTestService(t)
|
|
p := createReady(t, svc, prov, validInput()) // 3 mois
|
|
|
|
// Avant l'échéance : rien ne bouge.
|
|
*now = p.ExpiresAt.Add(-time.Hour)
|
|
svc.expireOverdue()
|
|
if len(prov.suspended) != 0 {
|
|
t.Fatal("suspension avant l'échéance")
|
|
}
|
|
|
|
// À l'échéance : suspendu, jamais supprimé.
|
|
*now = p.ExpiresAt.Add(time.Minute)
|
|
svc.expireOverdue()
|
|
got, _ := svc.Get(p.ID)
|
|
if got.Status != StatusSuspended || got.SuspendedAt == nil {
|
|
t.Fatalf("statut %s, attendu suspended", got.Status)
|
|
}
|
|
if len(prov.suspended) != 1 || len(prov.tornDown) != 0 {
|
|
t.Fatalf("suspendus=%v supprimés=%v", prov.suspended, prov.tornDown)
|
|
}
|
|
|
|
// Pendant la grâce : toujours suspendu, données conservées.
|
|
*now = p.ExpiresAt.Add(6 * 24 * time.Hour)
|
|
svc.expireOverdue()
|
|
if len(prov.tornDown) != 0 {
|
|
t.Fatal("suppression pendant le délai de grâce")
|
|
}
|
|
if len(prov.suspended) != 1 {
|
|
t.Errorf("suspension répétée : %v", prov.suspended)
|
|
}
|
|
|
|
// Grâce dépassée : suppression définitive.
|
|
*now = p.DeleteAt.Add(time.Minute)
|
|
svc.expireOverdue()
|
|
got, _ = svc.Get(p.ID)
|
|
if got.Status != StatusDeleted || len(prov.tornDown) != 1 || prov.tornDown[0] != p.Namespace {
|
|
t.Errorf("statut %s, supprimés=%v", got.Status, prov.tornDown)
|
|
}
|
|
}
|
|
|
|
func TestExtend_ResumesSuspendedProject(t *testing.T) {
|
|
svc, prov, now := newTestService(t)
|
|
p := createReady(t, svc, prov, validInput())
|
|
|
|
*now = p.ExpiresAt.Add(2 * 24 * time.Hour)
|
|
svc.expireOverdue()
|
|
|
|
got, err := svc.Extend(p.ID, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Status != StatusReady || got.SuspendedAt != nil {
|
|
t.Errorf("statut %s suspended_at=%v, attendu ready sans suspension", got.Status, got.SuspendedAt)
|
|
}
|
|
if len(prov.resumed) != 1 || prov.resumed[0] != p.Namespace {
|
|
t.Errorf("relances=%v", prov.resumed)
|
|
}
|
|
// Échéance en retard : le nouveau mois part de maintenant, pas de l'ancienne échéance.
|
|
if want := now.AddDate(0, 1, 0); !got.ExpiresAt.Equal(want) {
|
|
t.Errorf("ExpiresAt=%v, attendu %v", got.ExpiresAt, want)
|
|
}
|
|
|
|
// Renouvelé : la boucle ne doit plus le suspendre.
|
|
svc.expireOverdue()
|
|
if len(prov.suspended) != 1 {
|
|
t.Errorf("re-suspension d'un projet renouvelé : %v", prov.suspended)
|
|
}
|
|
}
|
|
|
|
func TestExtend_ResumeFailureKeepsProjectSuspended(t *testing.T) {
|
|
svc, prov, now := newTestService(t)
|
|
p := createReady(t, svc, prov, validInput())
|
|
*now = p.ExpiresAt.Add(time.Hour)
|
|
svc.expireOverdue()
|
|
|
|
prov.resumeErr = errors.New("api k8s indisponible")
|
|
if _, err := svc.Extend(p.ID, 1); err == nil {
|
|
t.Fatal("erreur attendue")
|
|
}
|
|
got, _ := svc.Get(p.ID)
|
|
if got.Status != StatusSuspended || !got.ExpiresAt.Equal(p.ExpiresAt) {
|
|
t.Errorf("l'échec de relance a modifié le projet : %s %v", got.Status, got.ExpiresAt)
|
|
}
|
|
}
|
|
|
|
func TestDelete_IsIdempotentAndImmediate(t *testing.T) {
|
|
svc, prov, _ := newTestService(t)
|
|
p := createReady(t, svc, prov, validInput())
|
|
|
|
got, err := svc.Delete(p.ID)
|
|
if err != nil || got.Status != StatusDeleted {
|
|
t.Fatalf("Delete: %v, statut %s", err, got.Status)
|
|
}
|
|
if _, err := svc.Delete(p.ID); err != nil {
|
|
t.Fatalf("second Delete: %v", err)
|
|
}
|
|
if len(prov.tornDown) != 1 {
|
|
t.Errorf("teardown appelé %d fois, attendu 1", len(prov.tornDown))
|
|
}
|
|
if _, err := svc.Delete("inconnu"); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("erreur %v, attendu ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestZeroGraceSuspendsThenDeletesNextTick(t *testing.T) {
|
|
prov := newFakeProv()
|
|
svc := NewService(NewMemStore(), prov, Config{})
|
|
now := t0
|
|
svc.now = func() time.Time { return now }
|
|
p := createReady(t, svc, prov, validInput())
|
|
|
|
now = p.ExpiresAt.Add(time.Minute)
|
|
svc.expireOverdue() // suspension
|
|
svc.expireOverdue() // DeleteAt == ExpiresAt : déjà dépassé
|
|
got, _ := svc.Get(p.ID)
|
|
if got.Status != StatusDeleted {
|
|
t.Errorf("statut %s, attendu deleted (grâce nulle)", got.Status)
|
|
}
|
|
}
|