diff --git a/internal/api/agent/helpers_test.go b/internal/api/agent/helpers_test.go new file mode 100644 index 0000000..206874c --- /dev/null +++ b/internal/api/agent/helpers_test.go @@ -0,0 +1,27 @@ +package agentapi + +import ( + "io" + "log/slog" + "testing" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" + dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent" + "git.g3e.fr/syonad/two/pkg/db/kv" + "git.g3e.fr/syonad/two/pkg/worker" + "github.com/dgraph-io/badger/v4" +) + +// newTestServer builds a Server backed by an in-memory Badger DB. +// The worker queue is buffered but has no running goroutines: Dispatch enqueues +// without blocking and Execute never runs, so DB state reflects only Prepare writes. +func newTestServer(t *testing.T) (*Server, *badger.DB) { + t.Helper() + db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) + q := worker.New(100) + cfg := &configuration.Config{DefaultInterface: "br-test"} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + d := dispatcher.New(q, db, cfg, logger) + return New(d, db, logger), db +} diff --git a/internal/api/agent/subnet.go b/internal/api/agent/subnet.go index 556b7c3..31eddc8 100644 --- a/internal/api/agent/subnet.go +++ b/internal/api/agent/subnet.go @@ -69,7 +69,12 @@ func (s *Server) deleteSubnet(w http.ResponseWriter, _ *http.Request, name strin return } s.dispatcher.Dispatch(cmd) - state, _ := kv.GetFromDB(s.db, "subnet/"+name+"/state") + state, err := kv.GetFromDB(s.db, "subnet/"+name+"/state") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read subnet state"}) + return + } w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(Subnet{Name: name, State: state}) } diff --git a/internal/api/agent/subnet_test.go b/internal/api/agent/subnet_test.go new file mode 100644 index 0000000..0fdd18b --- /dev/null +++ b/internal/api/agent/subnet_test.go @@ -0,0 +1,234 @@ +package agentapi + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +// --- SubnetsHandler --- + +func TestListSubnets_Empty(t *testing.T) { + s, _ := newTestServer(t) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodGet, "/subnets", nil)) + if w.Code != http.StatusOK { + t.Fatalf("attendu 200, obtenu %d", w.Code) + } + var result []Subnet + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 0 { + t.Errorf("attendu liste vide, obtenu %v", result) + } +} + +func TestListSubnets_WithData(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + kv.AddInDB(db, "subnet/sn-2/state", "creating") + kv.AddInDB(db, "subnet/sn-2/vpc", "vpc-1") + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodGet, "/subnets", nil)) + if w.Code != http.StatusOK { + t.Fatalf("attendu 200, obtenu %d", w.Code) + } + var result []Subnet + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 2 { + t.Errorf("attendu 2 subnets, obtenu %d", len(result)) + } +} + +func TestListSubnets_InvalidMethod(t *testing.T) { + s, _ := newTestServer(t) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPut, "/subnets", nil)) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("attendu 405, obtenu %d", w.Code) + } +} + +func TestPostSubnet_Created(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + req := SubnetCreateRequest{ + Name: "sn-new", + VPC: "vpc-1", + IfaceType: "vms", + GatewayIP: "10.0.0.1", + CIDR: "10.0.0.0/24", + } + body, _ := json.Marshal(req) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) + } + var result Subnet + json.NewDecoder(w.Body).Decode(&result) + if result.Name != "sn-new" { + t.Errorf("name attendu sn-new, obtenu %q", result.Name) + } + if result.State != "creating" { + t.Errorf("state attendu creating, obtenu %q", result.State) + } +} + +func TestPostSubnet_MissingFields(t *testing.T) { + s, _ := newTestServer(t) + body, _ := json.Marshal(SubnetCreateRequest{Name: "sn-1"}) // vpc, iface_type, gateway_ip, cidr manquants + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) + if w.Code != http.StatusBadRequest { + t.Errorf("attendu 400, obtenu %d", w.Code) + } +} + +func TestPostSubnet_VPCNotFound(t *testing.T) { + s, _ := newTestServer(t) + req := SubnetCreateRequest{ + Name: "sn-1", + VPC: "vpc-inexistant", + IfaceType: "vms", + GatewayIP: "10.0.0.1", + CIDR: "10.0.0.0/24", + } + body, _ := json.Marshal(req) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) + if w.Code != http.StatusConflict { + t.Errorf("attendu 409, obtenu %d", w.Code) + } +} + +func TestPostSubnet_Duplicate(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + kv.AddInDB(db, "subnet/sn-exist/state", "created") + req := SubnetCreateRequest{ + Name: "sn-exist", + VPC: "vpc-1", + IfaceType: "vms", + GatewayIP: "10.0.0.1", + CIDR: "10.0.0.0/24", + } + body, _ := json.Marshal(req) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) + if w.Code != http.StatusConflict { + t.Errorf("attendu 409, obtenu %d", w.Code) + } +} + +func TestPostSubnet_VPCDeleting(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-dying/state", "deleting") + req := SubnetCreateRequest{ + Name: "sn-1", + VPC: "vpc-dying", + IfaceType: "vms", + GatewayIP: "10.0.0.1", + CIDR: "10.0.0.0/24", + } + body, _ := json.Marshal(req) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) + if w.Code != http.StatusConflict { + t.Errorf("attendu 409, obtenu %d", w.Code) + } +} + +func TestPostSubnet_InvalidBody(t *testing.T) { + s, _ := newTestServer(t) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader([]byte("not json")))) + if w.Code != http.StatusBadRequest { + t.Errorf("attendu 400, obtenu %d", w.Code) + } +} + +// --- SubnetByNameHandler --- + +func TestGetSubnet_Found(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + kv.AddInDB(db, "subnet/sn-1/cidr", "10.0.0.0/24") + kv.AddInDB(db, "subnet/sn-1/gateway_ip", "10.0.0.1") + req := httptest.NewRequest(http.MethodGet, "/subnets/sn-1", nil) + w := httptest.NewRecorder() + s.SubnetByNameHandler(w, req) + if w.Code != http.StatusOK { + t.Fatalf("attendu 200, obtenu %d", w.Code) + } + var result Subnet + json.NewDecoder(w.Body).Decode(&result) + if result.Name != "sn-1" || result.State != "created" { + t.Errorf("résultat inattendu : %+v", result) + } + if result.VPC != "vpc-1" { + t.Errorf("vpc attendu vpc-1, obtenu %q", result.VPC) + } +} + +func TestGetSubnet_NotFound(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/subnets/inexistant", nil) + w := httptest.NewRecorder() + s.SubnetByNameHandler(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404, obtenu %d", w.Code) + } +} + +func TestGetSubnet_EmptyName(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/subnets/", nil) + w := httptest.NewRecorder() + s.SubnetByNameHandler(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404, obtenu %d", w.Code) + } +} + +func TestDeleteSubnet_Success(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "subnet/sn-del/state", "created") + req := httptest.NewRequest(http.MethodDelete, "/subnets/sn-del", nil) + w := httptest.NewRecorder() + s.SubnetByNameHandler(w, req) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) + } + var result Subnet + json.NewDecoder(w.Body).Decode(&result) + if result.State != "deleting" { + t.Errorf("state attendu deleting, obtenu %q", result.State) + } +} + +func TestDeleteSubnet_NotFound(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodDelete, "/subnets/inexistant", nil) + w := httptest.NewRecorder() + s.SubnetByNameHandler(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404, obtenu %d", w.Code) + } +} + +func TestSubnetByName_InvalidMethod(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + req := httptest.NewRequest(http.MethodPut, "/subnets/sn-1", nil) + w := httptest.NewRecorder() + s.SubnetByNameHandler(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("attendu 405, obtenu %d", w.Code) + } +} diff --git a/internal/api/agent/subnets.go b/internal/api/agent/subnets.go index 9e99a45..b0fb2fb 100644 --- a/internal/api/agent/subnets.go +++ b/internal/api/agent/subnets.go @@ -88,7 +88,12 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { return } s.dispatcher.Dispatch(cmd) - entries, _ := kv.ListByPrefix(s.db, "subnet/"+req.Name+"/") + entries, err := kv.ListByPrefix(s.db, "subnet/"+req.Name+"/") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read subnet state"}) + return + } sub := Subnet{Name: req.Name} for key, value := range entries { parts := strings.Split(key, "/") diff --git a/internal/api/agent/vpc.go b/internal/api/agent/vpc.go index be0724e..df2d47c 100644 --- a/internal/api/agent/vpc.go +++ b/internal/api/agent/vpc.go @@ -47,7 +47,12 @@ func (s *Server) deleteVpc(w http.ResponseWriter, _ *http.Request, name string) return } s.dispatcher.Dispatch(cmd) - state, _ := kv.GetFromDB(s.db, "vpc/"+name+"/state") + state, err := kv.GetFromDB(s.db, "vpc/"+name+"/state") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read vpc state"}) + return + } w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(VPC{Name: name, State: state}) } diff --git a/internal/api/agent/vpc_test.go b/internal/api/agent/vpc_test.go new file mode 100644 index 0000000..0edcd5b --- /dev/null +++ b/internal/api/agent/vpc_test.go @@ -0,0 +1,188 @@ +package agentapi + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +// --- VpcsHandler --- + +func TestListVpcs_Empty(t *testing.T) { + s, _ := newTestServer(t) + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodGet, "/vpcs", nil)) + if w.Code != http.StatusOK { + t.Fatalf("attendu 200, obtenu %d", w.Code) + } + var result []VPC + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 0 { + t.Errorf("attendu liste vide, obtenu %v", result) + } +} + +func TestListVpcs_WithData(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/v1/state", "created") + kv.AddInDB(db, "vpc/v2/state", "creating") + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodGet, "/vpcs", nil)) + if w.Code != http.StatusOK { + t.Fatalf("attendu 200, obtenu %d", w.Code) + } + var result []VPC + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 2 { + t.Errorf("attendu 2 VPCs, obtenu %d", len(result)) + } +} + +func TestListVpcs_InvalidMethod(t *testing.T) { + s, _ := newTestServer(t) + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodPut, "/vpcs", nil)) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("attendu 405, obtenu %d", w.Code) + } +} + +func TestPostVpc_Created(t *testing.T) { + s, _ := newTestServer(t) + body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-new"}) + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) + } + var result VPC + json.NewDecoder(w.Body).Decode(&result) + if result.Name != "vpc-new" { + t.Errorf("name attendu vpc-new, obtenu %q", result.Name) + } + if result.State != "creating" { + t.Errorf("state attendu creating, obtenu %q", result.State) + } +} + +func TestPostVpc_MissingName(t *testing.T) { + s, _ := newTestServer(t) + body, _ := json.Marshal(VPCCreateRequest{}) + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) + if w.Code != http.StatusBadRequest { + t.Errorf("attendu 400, obtenu %d", w.Code) + } +} + +func TestPostVpc_Duplicate(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-exist/state", "created") + body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-exist"}) + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) + if w.Code != http.StatusConflict { + t.Errorf("attendu 409, obtenu %d", w.Code) + } +} + +func TestPostVpc_InvalidBody(t *testing.T) { + s, _ := newTestServer(t) + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader([]byte("not json")))) + if w.Code != http.StatusBadRequest { + t.Errorf("attendu 400, obtenu %d", w.Code) + } +} + +// --- VpcByNameHandler --- + +func TestGetVpc_Found(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + req := httptest.NewRequest(http.MethodGet, "/vpcs/vpc-1", nil) + w := httptest.NewRecorder() + s.VpcByNameHandler(w, req) + if w.Code != http.StatusOK { + t.Fatalf("attendu 200, obtenu %d", w.Code) + } + var result VPC + json.NewDecoder(w.Body).Decode(&result) + if result.Name != "vpc-1" || result.State != "created" { + t.Errorf("résultat inattendu : %+v", result) + } +} + +func TestGetVpc_NotFound(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/vpcs/inexistant", nil) + w := httptest.NewRecorder() + s.VpcByNameHandler(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404, obtenu %d", w.Code) + } +} + +func TestGetVpc_EmptyName(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/vpcs/", nil) + w := httptest.NewRecorder() + s.VpcByNameHandler(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404, obtenu %d", w.Code) + } +} + +func TestDeleteVpc_Success(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-del/state", "created") + req := httptest.NewRequest(http.MethodDelete, "/vpcs/vpc-del", nil) + w := httptest.NewRecorder() + s.VpcByNameHandler(w, req) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) + } + var result VPC + json.NewDecoder(w.Body).Decode(&result) + if result.State != "deleting" { + t.Errorf("state attendu deleting, obtenu %q", result.State) + } +} + +func TestDeleteVpc_NotFound(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodDelete, "/vpcs/inexistant", nil) + w := httptest.NewRecorder() + s.VpcByNameHandler(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404, obtenu %d", w.Code) + } +} + +func TestDeleteVpc_BlockedByActiveSubnet(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-busy/state", "created") + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-busy") + req := httptest.NewRequest(http.MethodDelete, "/vpcs/vpc-busy", nil) + w := httptest.NewRecorder() + s.VpcByNameHandler(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404 (Prepare échoue), obtenu %d: %s", w.Code, w.Body.String()) + } +} + +func TestVpcByName_InvalidMethod(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + req := httptest.NewRequest(http.MethodPut, "/vpcs/vpc-1", nil) + w := httptest.NewRecorder() + s.VpcByNameHandler(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("attendu 405, obtenu %d", w.Code) + } +} diff --git a/internal/api/agent/vpcs.go b/internal/api/agent/vpcs.go index 3087456..b062d1c 100644 --- a/internal/api/agent/vpcs.go +++ b/internal/api/agent/vpcs.go @@ -69,7 +69,12 @@ func (s *Server) postVpc(w http.ResponseWriter, r *http.Request) { return } s.dispatcher.Dispatch(cmd) - state, _ := kv.GetFromDB(s.db, "vpc/"+req.Name+"/state") + state, err := kv.GetFromDB(s.db, "vpc/"+req.Name+"/state") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read vpc state"}) + return + } w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(VPC{Name: req.Name, State: state}) } diff --git a/internal/dispatcher/agent/dispatcher_test.go b/internal/dispatcher/agent/dispatcher_test.go new file mode 100644 index 0000000..b4881d9 --- /dev/null +++ b/internal/dispatcher/agent/dispatcher_test.go @@ -0,0 +1,63 @@ +package dispatcher + +import ( + "errors" + "sync" + "testing" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "github.com/dgraph-io/badger/v4" +) + +func TestDispatcher_Prepare_Success(t *testing.T) { + d, _ := newTestDispatcher(t) + cmd := mockCmd{ + prepareFn: func(*badger.DB, *configuration.Config) error { return nil }, + executeFn: func(*badger.DB, *configuration.Config) error { return nil }, + } + if err := d.Prepare(cmd); err != nil { + t.Errorf("Prepare devrait retourner nil, obtenu : %v", err) + } +} + +func TestDispatcher_Prepare_PropagatesError(t *testing.T) { + d, _ := newTestDispatcher(t) + want := errors.New("prepare failed") + cmd := mockCmd{ + prepareFn: func(*badger.DB, *configuration.Config) error { return want }, + executeFn: func(*badger.DB, *configuration.Config) error { return nil }, + } + if err := d.Prepare(cmd); !errors.Is(err, want) { + t.Errorf("attendu %v, obtenu %v", want, err) + } +} + +func TestDispatcher_Dispatch_ExecutesCommand(t *testing.T) { + d, _ := newTestDispatcher(t) + var wg sync.WaitGroup + wg.Add(1) + cmd := mockCmd{ + prepareFn: func(*badger.DB, *configuration.Config) error { return nil }, + executeFn: func(*badger.DB, *configuration.Config) error { + wg.Done() + return nil + }, + } + d.Dispatch(cmd) + wg.Wait() +} + +func TestDispatcher_Dispatch_ExecuteErrorLogged(t *testing.T) { + d, _ := newTestDispatcher(t) + var wg sync.WaitGroup + wg.Add(1) + cmd := mockCmd{ + prepareFn: func(*badger.DB, *configuration.Config) error { return nil }, + executeFn: func(*badger.DB, *configuration.Config) error { + defer wg.Done() + return errors.New("execute failed") + }, + } + d.Dispatch(cmd) + wg.Wait() // Execute s'est terminé — l'erreur est loggée, pas propagée +} diff --git a/internal/dispatcher/agent/helpers_test.go b/internal/dispatcher/agent/helpers_test.go new file mode 100644 index 0000000..2cee6fc --- /dev/null +++ b/internal/dispatcher/agent/helpers_test.go @@ -0,0 +1,38 @@ +package dispatcher + +import ( + "io" + "log/slog" + "testing" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/pkg/db/kv" + "git.g3e.fr/syonad/two/pkg/worker" + "github.com/dgraph-io/badger/v4" +) + +func newTestDispatcher(t *testing.T) (*Dispatcher, *badger.DB) { + t.Helper() + db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) + q := worker.New(100) + q.Start(2) + cfg := &configuration.Config{DefaultInterface: "br-default"} + cfg.Interfaces = map[string]string{"vms": "br-vms"} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return New(q, db, cfg, logger), db +} + +// mockCmd implémente Command sans aucune dépendance système. +type mockCmd struct { + prepareFn func(*badger.DB, *configuration.Config) error + executeFn func(*badger.DB, *configuration.Config) error +} + +func (m mockCmd) Prepare(db *badger.DB, cfg *configuration.Config) error { + return m.prepareFn(db, cfg) +} + +func (m mockCmd) Execute(db *badger.DB, cfg *configuration.Config) error { + return m.executeFn(db, cfg) +} diff --git a/internal/dispatcher/agent/subnet_commands_test.go b/internal/dispatcher/agent/subnet_commands_test.go new file mode 100644 index 0000000..3b118bc --- /dev/null +++ b/internal/dispatcher/agent/subnet_commands_test.go @@ -0,0 +1,135 @@ +package dispatcher + +import ( + "testing" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +func testCfg() *configuration.Config { + cfg := &configuration.Config{DefaultInterface: "br-default"} + cfg.Interfaces = map[string]string{"vms": "br-vms"} + return cfg +} + +// --- CreateSubnetCommand.Prepare --- + +func TestCreateSubnetCommand_Prepare_Success(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-1", VxlanID: 100, + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + if err := cmd.Prepare(db, testCfg()); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + state, _ := kv.GetFromDB(db, "subnet/sn-1/state") + if state != "creating" { + t.Errorf("state attendu creating, obtenu %q", state) + } + vpc, _ := kv.GetFromDB(db, "subnet/sn-1/vpc") + if vpc != "vpc-1" { + t.Errorf("vpc attendu vpc-1, obtenu %q", vpc) + } +} + +func TestCreateSubnetCommand_Prepare_UsesIfaceTypeMapping(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-1", VxlanID: 100, + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + cmd.Prepare(db, testCfg()) + iface, _ := kv.GetFromDB(db, "subnet/sn-1/local_iface") + if iface != "br-vms" { + t.Errorf("local_iface attendu br-vms, obtenu %q", iface) + } +} + +func TestCreateSubnetCommand_Prepare_UsesDefaultIfaceWhenTypeUnknown(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-1", VxlanID: 100, + IfaceType: "inconnu", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + cmd.Prepare(db, testCfg()) + iface, _ := kv.GetFromDB(db, "subnet/sn-1/local_iface") + if iface != "br-default" { + t.Errorf("local_iface attendu br-default, obtenu %q", iface) + } +} + +func TestCreateSubnetCommand_Prepare_Duplicate(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + kv.AddInDB(db, "subnet/sn-exist/state", "created") + cmd := CreateSubnetCommand{ + Name: "sn-exist", VPC: "vpc-1", VxlanID: 100, + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + if err := cmd.Prepare(db, testCfg()); err == nil { + t.Error("Prepare devrait échouer sur un subnet déjà existant") + } +} + +func TestCreateSubnetCommand_Prepare_VPCNotFound(t *testing.T) { + _, db := newTestDispatcher(t) + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-inexistant", VxlanID: 100, + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + if err := cmd.Prepare(db, testCfg()); err == nil { + t.Error("Prepare devrait échouer si le VPC n'existe pas") + } +} + +func TestCreateSubnetCommand_Prepare_VPCDeleting(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-dying/state", "deleting") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-dying", VxlanID: 100, + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + if err := cmd.Prepare(db, testCfg()); err == nil { + t.Error("Prepare devrait échouer si le VPC est en cours de suppression") + } +} + +func TestCreateSubnetCommand_Prepare_VPCDeleted(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-gone/state", "deleted") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-gone", VxlanID: 100, + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + if err := cmd.Prepare(db, testCfg()); err == nil { + t.Error("Prepare devrait échouer si le VPC est supprimé") + } +} + +// --- DeleteSubnetCommand.Prepare --- + +func TestDeleteSubnetCommand_Prepare_Success(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "subnet/sn-del/state", "created") + cmd := DeleteSubnetCommand{Name: "sn-del"} + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + state, _ := kv.GetFromDB(db, "subnet/sn-del/state") + if state != "deleting" { + t.Errorf("state attendu deleting, obtenu %q", state) + } +} + +func TestDeleteSubnetCommand_Prepare_NotFound(t *testing.T) { + _, db := newTestDispatcher(t) + cmd := DeleteSubnetCommand{Name: "sn-inexistant"} + if err := cmd.Prepare(db, nil); err == nil { + t.Error("Prepare devrait échouer si le subnet n'existe pas") + } +} diff --git a/internal/dispatcher/agent/vpc_commands_test.go b/internal/dispatcher/agent/vpc_commands_test.go new file mode 100644 index 0000000..e2f2162 --- /dev/null +++ b/internal/dispatcher/agent/vpc_commands_test.go @@ -0,0 +1,89 @@ +package dispatcher + +import ( + "testing" + + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +// --- CreateVPCCommand.Prepare --- + +func TestCreateVPCCommand_Prepare_NewVPC(t *testing.T) { + _, db := newTestDispatcher(t) + cmd := CreateVPCCommand{Name: "vpc-1"} + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + state, err := kv.GetFromDB(db, "vpc/vpc-1/state") + if err != nil { + t.Fatalf("état non écrit en DB : %v", err) + } + if state != "creating" { + t.Errorf("state attendu creating, obtenu %q", state) + } +} + +func TestCreateVPCCommand_Prepare_Duplicate(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-exist/state", "created") + cmd := CreateVPCCommand{Name: "vpc-exist"} + if err := cmd.Prepare(db, nil); err == nil { + t.Error("Prepare devrait échouer sur un VPC déjà existant") + } +} + +// --- DeleteVPCCommand.Prepare --- + +func TestDeleteVPCCommand_Prepare_Success(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-del/state", "created") + cmd := DeleteVPCCommand{Name: "vpc-del"} + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + state, _ := kv.GetFromDB(db, "vpc/vpc-del/state") + if state != "deleting" { + t.Errorf("state attendu deleting, obtenu %q", state) + } +} + +func TestDeleteVPCCommand_Prepare_NotFound(t *testing.T) { + _, db := newTestDispatcher(t) + cmd := DeleteVPCCommand{Name: "vpc-inexistant"} + if err := cmd.Prepare(db, nil); err == nil { + t.Error("Prepare devrait échouer si le VPC n'existe pas") + } +} + +func TestDeleteVPCCommand_Prepare_BlockedByActiveSubnet(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-busy/state", "created") + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-busy") + cmd := DeleteVPCCommand{Name: "vpc-busy"} + if err := cmd.Prepare(db, nil); err == nil { + t.Error("Prepare devrait échouer si un subnet actif existe") + } +} + +func TestDeleteVPCCommand_Prepare_AllowedWhenSubnetDeleted(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-ok/state", "created") + kv.AddInDB(db, "subnet/sn-1/state", "deleted") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-ok") + cmd := DeleteVPCCommand{Name: "vpc-ok"} + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare devrait réussir si le subnet est deleted : %v", err) + } +} + +func TestDeleteVPCCommand_Prepare_AllowedWhenSubnetDeleting(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-ok/state", "created") + kv.AddInDB(db, "subnet/sn-1/state", "deleting") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-ok") + cmd := DeleteVPCCommand{Name: "vpc-ok"} + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare devrait réussir si le subnet est deleting : %v", err) + } +} diff --git a/internal/ebtables/ebtables.go b/internal/ebtables/ebtables.go new file mode 100644 index 0000000..31e276b --- /dev/null +++ b/internal/ebtables/ebtables.go @@ -0,0 +1,58 @@ +package ebtables + +import ( + "fmt" + "os/exec" +) + +func addRule(args ...string) error { + return exec.Command("ebtables", append([]string{"-A"}, args...)...).Run() +} + +func deleteRule(args ...string) error { + return exec.Command("ebtables", append([]string{"-D"}, args...)...).Run() +} + +func DropARPToGateway(bridge, gatewayIP string) error { + if err := addRule("FORWARD", + "--out-interface", bridge, + "-p", "arp", + "--arp-op", "Request", + "--arp-ip-dst", gatewayIP, + "-j", "DROP"); err != nil { + return fmt.Errorf("ebtables arp rule: %w", err) + } + return nil +} + +func DropDHCP(bridge string) error { + if err := addRule("FORWARD", + "--out-interface", bridge, + "-p", "IPv4", + "--ip-protocol", "udp", + "--ip-source-port", "67:68", + "--ip-destination-port", "67:68", + "-j", "DROP"); err != nil { + return fmt.Errorf("ebtables dhcp rule: %w", err) + } + return nil +} + +func DeleteARPToGateway(bridge, gatewayIP string) error { + return deleteRule("FORWARD", + "--out-interface", bridge, + "-p", "arp", + "--arp-op", "Request", + "--arp-ip-dst", gatewayIP, + "-j", "DROP") +} + +func DeleteDHCP(bridge string) error { + return deleteRule("FORWARD", + "--out-interface", bridge, + "-p", "IPv4", + "--ip-protocol", "udp", + "--ip-source-port", "67:68", + "--ip-destination-port", "67:68", + "-j", "DROP") +} diff --git a/internal/metadata/metadata_test.go b/internal/metadata/metadata_test.go index 62a2830..5884151 100644 --- a/internal/metadata/metadata_test.go +++ b/internal/metadata/metadata_test.go @@ -1,6 +1,8 @@ package metadata import ( + "net/http" + "net/http/httptest" "strings" "testing" @@ -181,6 +183,103 @@ func TestUnLoadNoCloudInDB_RemovesAllKeys(t *testing.T) { } } +// --- getIP --- + +func TestGetIP_ValidHostPort(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1:4567" + if ip := getIP(req); ip != "10.0.0.1" { + t.Errorf("attendu 10.0.0.1, obtenu %q", ip) + } +} + +func TestGetIP_IPv6(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "[::1]:8080" + if ip := getIP(req); ip != "::1" { + t.Errorf("attendu ::1, obtenu %q", ip) + } +} + +func TestGetIP_NoPort(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1" + if ip := getIP(req); ip != "10.0.0.1" { + t.Errorf("attendu RemoteAddr brut, obtenu %q", ip) + } +} + +// --- rootHandler --- + +func TestRootHandler_UserData(t *testing.T) { + data = NoCloudData{UserData: "userdata-content"} + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/user-data", nil) + rootHandler(w, req) + if w.Code != http.StatusOK { + t.Errorf("attendu 200, obtenu %d", w.Code) + } + if body := w.Body.String(); body != "userdata-content" { + t.Errorf("body inattendu : %q", body) + } +} + +func TestRootHandler_MetaData(t *testing.T) { + data = NoCloudData{MetaData: "metadata-content"} + w := httptest.NewRecorder() + rootHandler(w, httptest.NewRequest(http.MethodGet, "/meta-data", nil)) + if w.Code != http.StatusOK { + t.Errorf("attendu 200, obtenu %d", w.Code) + } + if body := w.Body.String(); body != "metadata-content" { + t.Errorf("body inattendu : %q", body) + } +} + +func TestRootHandler_NetworkConfig(t *testing.T) { + data = NoCloudData{NetworkConfig: "network-content"} + w := httptest.NewRecorder() + rootHandler(w, httptest.NewRequest(http.MethodGet, "/network-config", nil)) + if w.Code != http.StatusOK { + t.Errorf("attendu 200, obtenu %d", w.Code) + } + if body := w.Body.String(); body != "network-content" { + t.Errorf("body inattendu : %q", body) + } +} + +func TestRootHandler_VendorData(t *testing.T) { + data = NoCloudData{VendorData: "vendor-content"} + w := httptest.NewRecorder() + rootHandler(w, httptest.NewRequest(http.MethodGet, "/vendor-data", nil)) + if w.Code != http.StatusOK { + t.Errorf("attendu 200, obtenu %d", w.Code) + } + if body := w.Body.String(); body != "vendor-content" { + t.Errorf("body inattendu : %q", body) + } +} + +func TestRootHandler_UnknownPath(t *testing.T) { + data = NoCloudData{} + w := httptest.NewRecorder() + rootHandler(w, httptest.NewRequest(http.MethodGet, "/unknown", nil)) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404, obtenu %d", w.Code) + } +} + +func TestRootHandler_ContentType(t *testing.T) { + data = NoCloudData{MetaData: "x"} + w := httptest.NewRecorder() + rootHandler(w, httptest.NewRequest(http.MethodGet, "/meta-data", nil)) + if ct := w.Header().Get("Content-Type"); ct != "text/yaml" { + t.Errorf("Content-Type attendu text/yaml, obtenu %q", ct) + } +} + +// --- UnLoadNoCloudInDB_DoesNotAffectOtherVMs --- + func TestUnLoadNoCloudInDB_DoesNotAffectOtherVMs(t *testing.T) { db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) t.Cleanup(func() { db.Close() }) diff --git a/internal/netif/vxlan.go b/internal/netif/vxlan.go index 6523f37..70740bc 100644 --- a/internal/netif/vxlan.go +++ b/internal/netif/vxlan.go @@ -4,7 +4,7 @@ import ( "github.com/vishvananda/netlink" ) -func CreateVxlan(name string, vxlanID int, localIface string) error { +func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error { link, err := netlink.LinkByName(localIface) if err != nil { return err @@ -12,6 +12,7 @@ func CreateVxlan(name string, vxlanID int, localIface string) error { vxlan := &netlink.Vxlan{ LinkAttrs: netlink.LinkAttrs{ Name: name, + MTU: mtu, }, VxlanId: vxlanID, Port: 4789, diff --git a/internal/subnet/create.go b/internal/subnet/create.go index 1ed514e..496c257 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -3,11 +3,11 @@ package subnet import ( "fmt" "net" - "os/exec" "strconv" "strings" "git.g3e.fr/syonad/two/internal/dhcp" + "git.g3e.fr/syonad/two/internal/ebtables" "git.g3e.fr/syonad/two/internal/netif" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/pkg/db/kv" @@ -86,7 +86,7 @@ func CreateSubnet(db *badger.DB, subnetName string) error { } // vxlan - if err := netif.CreateVxlan(vxlanIface, vxlanID, localIface); err != nil { + if err := netif.CreateVxlan(vxlanIface, vxlanID, localIface, 1500); err != nil { return fmt.Errorf("create vxlan: %w", err) } @@ -136,25 +136,11 @@ func CreateSubnet(db *badger.DB, subnetName string) error { return fmt.Errorf("add route in netns: %w", err) } - // ebtables : drop ARP Request vers la gateway sur ce bridge - if err := exec.Command("ebtables", "-A", "FORWARD", - "--out-interface", bridge, - "-p", "arp", - "--arp-op", "Request", - "--arp-ip-dst", gatewayIP.String(), - "-j", "DROP").Run(); err != nil { - return fmt.Errorf("ebtables arp rule: %w", err) + if err := ebtables.DropARPToGateway(bridge, gatewayIP.String()); err != nil { + return err } - - // ebtables : drop trafic DHCP sur ce bridge - if err := exec.Command("ebtables", "-A", "FORWARD", - "--out-interface", bridge, - "-p", "IPv4", - "--ip-protocol", "udp", - "--ip-source-port", "67:68", - "--ip-destination-port", "67:68", - "-j", "DROP").Run(); err != nil { - return fmt.Errorf("ebtables dhcp rule: %w", err) + if err := ebtables.DropDHCP(bridge); err != nil { + return err } // génération de la config dnsmasq et démarrage du service diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go index 650aa6f..8ce91ff 100644 --- a/internal/subnet/delete.go +++ b/internal/subnet/delete.go @@ -3,9 +3,9 @@ package subnet import ( "fmt" "os" - "os/exec" "strings" + "git.g3e.fr/syonad/two/internal/ebtables" "git.g3e.fr/syonad/two/internal/netif" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/pkg/db/kv" @@ -33,6 +33,11 @@ func DeleteSubnet(db *badger.DB, subnetName string) error { return fmt.Errorf("get vxlan_id: %w", err) } + gatewayIP, err := kv.GetFromDB(db, "subnet/"+subnetName+"/gateway_ip") + if err != nil { + return fmt.Errorf("get gateway_ip: %w", err) + } + subnetID := strings.SplitN(subnetName, "-", 2)[1] bridge := "br-" + subnetID vxlanIface := "vxlan-" + vxlanIDStr @@ -55,19 +60,12 @@ func DeleteSubnet(db *badger.DB, subnetName string) error { } // suppression des règles ebtables - exec.Command("ebtables", "-D", "FORWARD", - "--out-interface", bridge, - "-p", "arp", - "--arp-op", "Request", - "-j", "DROP").Run() - - exec.Command("ebtables", "-D", "FORWARD", - "--out-interface", bridge, - "-p", "IPv4", - "--ip-protocol", "udp", - "--ip-source-port", "67:68", - "--ip-destination-port", "67:68", - "-j", "DROP").Run() + if err := ebtables.DeleteARPToGateway(bridge, gatewayIP); err != nil { + return fmt.Errorf("delete ebtables arp rule: %w", err) + } + if err := ebtables.DeleteDHCP(bridge); err != nil { + return fmt.Errorf("delete ebtables dhcp rule: %w", err) + } // suppression du bridge dans le netns VPC if err := netns.Call(vpcName, func() error { diff --git a/pkg/db/kv/deleteInDB.go b/pkg/db/kv/deleteInDB.go index 1943c81..e1335de 100644 --- a/pkg/db/kv/deleteInDB.go +++ b/pkg/db/kv/deleteInDB.go @@ -1,8 +1,6 @@ package kv import ( - "log" - "github.com/dgraph-io/badger/v4" ) @@ -35,7 +33,7 @@ func DeleteInDB(db *badger.DB, key string) error { return nil }) if err != nil { - log.Fatal(err) + return err } return deleteKey(db, key) diff --git a/pkg/db/kv/kv_test.go b/pkg/db/kv/kv_test.go index de4fd68..b45422b 100644 --- a/pkg/db/kv/kv_test.go +++ b/pkg/db/kv/kv_test.go @@ -151,3 +151,69 @@ func TestDeleteInDB_MissingKey(t *testing.T) { t.Logf("DeleteInDB clé inexistante retourne : %v (non bloquant)", err) } } + +// --- ListByPrefix --- + +func TestListByPrefix_MatchingKeys(t *testing.T) { + db := newTestDB(t) + AddInDB(db, "subnet/sn1/state", "created") + AddInDB(db, "subnet/sn1/vpc", "vpc-1") + AddInDB(db, "subnet/sn1/cidr", "10.0.0.0/24") + + entries, err := ListByPrefix(db, "subnet/sn1/") + if err != nil { + t.Fatalf("ListByPrefix a échoué : %v", err) + } + if len(entries) != 3 { + t.Fatalf("attendu 3 entrées, obtenu %d", len(entries)) + } + if entries["subnet/sn1/state"] != "created" { + t.Errorf("valeur inattendue pour state : %q", entries["subnet/sn1/state"]) + } + if entries["subnet/sn1/vpc"] != "vpc-1" { + t.Errorf("valeur inattendue pour vpc : %q", entries["subnet/sn1/vpc"]) + } +} + +func TestListByPrefix_NoMatch(t *testing.T) { + db := newTestDB(t) + AddInDB(db, "vpc/v1/state", "created") + + entries, err := ListByPrefix(db, "subnet/") + if err != nil { + t.Fatalf("ListByPrefix a échoué : %v", err) + } + if len(entries) != 0 { + t.Errorf("attendu 0 entrées, obtenu %d", len(entries)) + } +} + +func TestListByPrefix_IsolatesPrefix(t *testing.T) { + db := newTestDB(t) + AddInDB(db, "subnet/sn1/state", "created") + AddInDB(db, "subnet/sn2/state", "creating") + AddInDB(db, "vpc/v1/state", "created") + + entries, err := ListByPrefix(db, "subnet/sn1/") + if err != nil { + t.Fatalf("ListByPrefix a échoué : %v", err) + } + if len(entries) != 1 { + t.Errorf("attendu 1 entrée, obtenu %d : %v", len(entries), entries) + } + if _, ok := entries["subnet/sn1/state"]; !ok { + t.Error("subnet/sn1/state devrait être présent") + } +} + +func TestListByPrefix_EmptyDB(t *testing.T) { + db := newTestDB(t) + + entries, err := ListByPrefix(db, "subnet/") + if err != nil { + t.Fatalf("ListByPrefix a échoué : %v", err) + } + if len(entries) != 0 { + t.Errorf("attendu 0 entrées sur DB vide, obtenu %d", len(entries)) + } +} diff --git a/pkg/worker/queue_test.go b/pkg/worker/queue_test.go new file mode 100644 index 0000000..5353b06 --- /dev/null +++ b/pkg/worker/queue_test.go @@ -0,0 +1,103 @@ +package worker + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestNew_ReturnsQueue(t *testing.T) { + q := New(10) + if q == nil { + t.Fatal("New devrait retourner une queue non-nil") + } +} + +func TestQueue_SingleTaskExecuted(t *testing.T) { + q := New(1) + q.Start(1) + + var done atomic.Bool + var wg sync.WaitGroup + wg.Add(1) + q.Submit(func() { + done.Store(true) + wg.Done() + }) + + wg.Wait() + if !done.Load() { + t.Error("la tâche n'a pas été exécutée") + } +} + +func TestQueue_AllTasksExecuted(t *testing.T) { + const n = 50 + q := New(n) + q.Start(1) + + var count atomic.Int32 + var wg sync.WaitGroup + wg.Add(n) + for range n { + q.Submit(func() { + count.Add(1) + wg.Done() + }) + } + + wg.Wait() + if count.Load() != n { + t.Errorf("attendu %d exécutions, obtenu %d", n, count.Load()) + } +} + +func TestQueue_MultipleWorkers(t *testing.T) { + const n = 100 + q := New(n) + q.Start(4) + + var count atomic.Int32 + var wg sync.WaitGroup + wg.Add(n) + for range n { + q.Submit(func() { + count.Add(1) + wg.Done() + }) + } + + wg.Wait() + if count.Load() != n { + t.Errorf("attendu %d exécutions, obtenu %d", n, count.Load()) + } +} + +func TestQueue_SubmitBlocksWhenFull(t *testing.T) { + q := New(1) + // Remplit le buffer sans worker + q.Submit(func() {}) + + submitted := make(chan struct{}) + go func() { + q.Submit(func() {}) // doit bloquer jusqu'à ce qu'un worker consomme + close(submitted) + }() + + select { + case <-submitted: + t.Error("Submit aurait dû bloquer sur une queue pleine") + case <-time.After(50 * time.Millisecond): + // comportement attendu : goroutine bloquée + } + + // Démarre un worker pour débloquer + q.Start(1) + select { + case <-submitted: + // Submit a pu avancer + case <-time.After(time.Second): + t.Error("Submit aurait dû se débloquer après démarrage d'un worker") + } +}