diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e051c33..7951261 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - name: Go Test with Coverage run: | - go test ./internal/service/... ./internal/api/handler/... ./internal/integration/... ./internal/connector/issuer/... -count=1 -cover -coverprofile=coverage.out + go test ./internal/service/... ./internal/api/handler/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... -count=1 -cover -coverprofile=coverage.out - name: Check Coverage Thresholds run: | diff --git a/README.md b/README.md index 3f51258..6cc9f9e 100644 --- a/README.md +++ b/README.md @@ -360,7 +360,7 @@ make docker-clean # Stop + remove volumes ## Roadmap ### V1 (v1.0.0 released) -All nine development milestones (M1–M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/Apache/HAProxy/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a React dashboard with 16 pages wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. 330+ tests total: 277+ Go tests across service, handler, integration, and connector layers, plus 53 frontend Vitest tests covering API client functions and utility helpers. Docker images are published to GitHub Container Registry on every version tag via the release workflow. +All nine development milestones (M1–M9) are complete. The backend covers the full certificate lifecycle: Local CA and ACME v2 issuers, NGINX/Apache/HAProxy/F5/IIS target connectors, threshold-based expiration alerting, agent-side ECDSA P-256 key generation, API auth with rate limiting, and a React dashboard with 16 pages wired to the real API. The CI pipeline runs build, vet, test with coverage gates (service layer 30%+, handler layer 50%+), frontend type checking, Vitest test suite, and Vite production build on every push. 525+ tests total: 431 Go test functions (192 service, 212 handler, 4 integration with 35+ subtests, 23 connector) plus 53 frontend Vitest tests covering API client functions and utility helpers. Docker images are published to GitHub Container Registry on every version tag via the release workflow. ### V2: Operational Maturity - **M10: Agent Metadata + Targets** ✅ — agents report OS, architecture, IP, hostname, version via heartbeat; Apache httpd and HAProxy target connectors diff --git a/docs/architecture.md b/docs/architecture.md index 9bc1c8e..014c257 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -615,19 +615,21 @@ For production, you would also add an ingress controller, TLS termination for th ## Testing Strategy -certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 330+ tests across five layers (service, handler, integration, connector, and frontend). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database. +certctl uses a layered testing approach aligned with the handler → service → repository architecture, with 525+ tests across five layers (service, handler, integration, connector, and frontend). The goal is high-confidence regression prevention at the service and handler layers, where the most complex business logic lives, combined with integration tests that exercise the full request path from HTTP to database. -**Service layer unit tests** (`internal/service/*_test.go`) — 99 test functions across 10 files with mock repositories. These test all business logic in isolation: certificate CRUD with validation, agent lifecycle (registration, heartbeat, CSR submission with both keygen modes), job state machine (creation, processing, cancellation, retry logic), policy evaluation (all 5 rule types, violation creation), renewal and issuance flow (server-side and agent-side keygen paths), and notification deduplication (threshold tag matching, channel routing). Mock repositories are simple structs with function fields, avoiding heavy mocking frameworks — this keeps tests readable and avoids coupling to mock library APIs. +**Service layer unit tests** (`internal/service/*_test.go`) — 192 test functions across 14 files with mock repositories. These test all business logic in isolation: certificate CRUD with validation, agent lifecycle (registration, heartbeat, CSR submission with both keygen modes), job state machine (creation, processing, cancellation, retry logic), policy evaluation (all 5 rule types, violation creation), renewal and issuance flow (server-side and agent-side keygen paths), notification deduplication (threshold tag matching, channel routing), team/owner/agent group CRUD with pagination and audit recording, issuer service CRUD with connection testing, and the issuer connector adapter (type translation between connector and service layers). Mock repositories are simple structs with function fields, avoiding heavy mocking frameworks — this keeps tests readable and avoids coupling to mock library APIs. -**Handler layer tests** (`internal/api/handler/*_test.go`) — 165 test functions across 9 files using Go's `httptest` package. Every handler file has a corresponding test file: certificates (22 tests), agents (28 tests), jobs (21 tests including approve/reject), notifications (11 tests), policies (19 tests), profiles (18 tests), issuers (17 tests), targets (17 tests), and agent groups (12 tests). Each test file follows the same pattern: a mock service struct with function fields, `httptest.NewRecorder` for capturing responses, and a shared `contextWithRequestID()` helper. Tests cover the happy path, input validation (missing fields, invalid JSON, empty IDs), error propagation from the service layer, method-not-allowed responses, and pagination parameters. +**Handler layer tests** (`internal/api/handler/*_test.go`) — 212 test functions across 11 files using Go's `httptest` package. Every handler file has a corresponding test file: certificates (22 tests), agents (28 tests), jobs (21 tests including approve/reject), notifications (11 tests), policies (19 tests), profiles (18 tests), issuers (17 tests), targets (17 tests), agent groups (12 tests), teams (26 tests), and owners (21 tests). Each test file follows the same pattern: a mock service struct with function fields, `httptest.NewRecorder` for capturing responses, and a shared `contextWithRequestID()` helper. Tests cover the happy path, input validation (missing fields, invalid JSON, empty IDs, name length limits), error propagation from the service layer, method-not-allowed responses, and pagination parameters. -**Integration tests** (`internal/integration/`) — Two test files exercising the full stack from HTTP request through router, handler, service, and postgres repository layers. `lifecycle_test.go` has 11 subtests covering the complete certificate lifecycle: team/owner creation, certificate creation, issuer verification, renewal trigger, job verification, agent registration, CSR submission, deployment, and status reporting. `negative_test.go` has 14 subtests covering error paths: nonexistent resource lookups (404s), invalid request bodies (malformed JSON, missing required fields), invalid CSR submission, heartbeat for nonexistent agents, wrong HTTP methods on list endpoints, empty list responses, renewal on nonexistent certificates, and expired certificate lifecycle. Both use a shared `setupTestServer()` that builds a fully-wired server with real postgres repositories and the Local CA issuer connector. +**Integration tests** (`internal/integration/`) — Two test files exercising the full stack from HTTP request through router, handler, service, and postgres repository layers. `lifecycle_test.go` has 11 subtests covering the complete certificate lifecycle: team/owner creation, certificate creation, issuer verification, renewal trigger, job verification, agent registration, CSR submission, deployment, and status reporting. `negative_test.go` has 14 subtests covering error paths plus 19 M11b endpoint tests: nonexistent resource lookups (404s), invalid request bodies (malformed JSON, missing required fields), invalid CSR submission, heartbeat for nonexistent agents, wrong HTTP methods on list endpoints, empty list responses, renewal on nonexistent certificates, expired certificate lifecycle, and team/owner/agent group CRUD validation (create with name validation, get not found, list empty, delete, method not allowed). Both use a shared `setupTestServer()` that builds a fully-wired server with real postgres repositories and the Local CA issuer connector. **Frontend tests** (`web/src/api/client.test.ts`, `web/src/api/utils.test.ts`) — 53 Vitest tests covering the API client and utility functions. The API client tests mock `globalThis.fetch` and verify all endpoint functions (certificates, agents, jobs, policies, issuers, targets, notifications, audit, health) send correct HTTP methods, URLs, headers, and request bodies. They also test API key management (store/retrieve/clear), auth header propagation, 401 event dispatching, and error handling (server messages, error fields, status text fallback). The utility tests use `vi.useFakeTimers()` for deterministic date testing and cover `formatDate`, `formatDateTime`, `timeAgo`, `daysUntil`, and `expiryColor`. The test environment uses jsdom with `@testing-library/jest-dom` matchers. -**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs: Go (build, vet, test with coverage, coverage threshold enforcement) and Frontend (TypeScript type check, Vitest test suite, Vite production build). The Go job runs all tests with `-coverprofile`, then enforces coverage thresholds: service layer must be at least 30% (current: ~35%) and handler layer must be at least 50% (current: ~63%). These thresholds act as regression floors — they can only go up. The service layer threshold is deliberately lower because much of the service code depends on postgres repositories and external connectors that require real infrastructure to test meaningfully. Connector tests are included via `./internal/connector/issuer/...` (includes Local CA, ACME, and step-ca packages with unit tests for certificate signing logic, DNS solver, and issuer validation). The Frontend job runs `npx vitest run` between the TypeScript check and production build steps. +**CI pipeline** (`.github/workflows/ci.yml`) — Two parallel jobs: Go (build, vet, test with coverage, coverage threshold enforcement) and Frontend (TypeScript type check, Vitest test suite, Vite production build). The Go job runs all tests with `-coverprofile`, then enforces coverage thresholds: service layer must be at least 30% (current: ~35%) and handler layer must be at least 50% (current: ~63%). These thresholds act as regression floors — they can only go up. The service layer threshold is deliberately lower because much of the service code depends on postgres repositories and external connectors that require real infrastructure to test meaningfully. Connector tests are included via `./internal/connector/issuer/...` and `./internal/connector/target/...` (covers Local CA, ACME, step-ca, NGINX, Apache, and HAProxy packages with unit tests for certificate signing logic, DNS solver, issuer validation, and deployment flows). The Frontend job runs `npx vitest run` between the TypeScript check and production build steps. -**What's not tested and why:** Postgres repository implementations (`internal/repository/postgres/`) require a real database and are tested only through integration tests, not unit tests. Target connectors for F5 BIG-IP and IIS depend on real infrastructure or complex mocks. Apache httpd and HAProxy connectors have unit tests (7 tests each) covering config validation, deployment, and validation flows. Scheduler loops are time-dependent and tested manually during development. The ACME connector requires a real ACME server (tested manually against Let's Encrypt staging). These are all candidates for future expansion as the test infrastructure matures. +**Connector tests** (`internal/connector/`) — 23 test functions covering issuer and target connectors. The Local CA connector has tests for self-signed and sub-CA modes (RSA, ECDSA, config validation, non-CA cert rejection). The ACME DNS solver has 6 tests for script-based DNS-01 challenges. The step-ca connector has tests with a mock HTTP server for issuance, renewal, revocation, and error paths. The NGINX target connector has 13 tests covering config validation, certificate deployment (file writing, permissions, validate/reload commands), and deployment validation. Apache httpd and HAProxy connectors each have 3 tests covering config validation, deployment, and validation flows. + +**What's not tested and why:** Postgres repository implementations (`internal/repository/postgres/`) require a real database and are tested only through integration tests, not unit tests. Target connectors for F5 BIG-IP and IIS are interface stubs (implementation planned for V2). Scheduler loops are time-dependent and tested manually during development. The ACME connector requires a real ACME server (tested manually against Let's Encrypt staging). These are all candidates for future expansion as the test infrastructure matures. ## What's Next diff --git a/internal/api/handler/owner_handler_test.go b/internal/api/handler/owner_handler_test.go new file mode 100644 index 0000000..281d6fe --- /dev/null +++ b/internal/api/handler/owner_handler_test.go @@ -0,0 +1,558 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/api/middleware" + "github.com/shankar0123/certctl/internal/domain" +) + +// MockOwnerService is a mock implementation of OwnerService interface. +type MockOwnerService struct { + ListOwnersFn func(page, perPage int) ([]domain.Owner, int64, error) + GetOwnerFn func(id string) (*domain.Owner, error) + CreateOwnerFn func(owner domain.Owner) (*domain.Owner, error) + UpdateOwnerFn func(id string, owner domain.Owner) (*domain.Owner, error) + DeleteOwnerFn func(id string) error +} + +func (m *MockOwnerService) ListOwners(page, perPage int) ([]domain.Owner, int64, error) { + if m.ListOwnersFn != nil { + return m.ListOwnersFn(page, perPage) + } + return nil, 0, nil +} + +func (m *MockOwnerService) GetOwner(id string) (*domain.Owner, error) { + if m.GetOwnerFn != nil { + return m.GetOwnerFn(id) + } + return nil, nil +} + +func (m *MockOwnerService) CreateOwner(owner domain.Owner) (*domain.Owner, error) { + if m.CreateOwnerFn != nil { + return m.CreateOwnerFn(owner) + } + return nil, nil +} + +func (m *MockOwnerService) UpdateOwner(id string, owner domain.Owner) (*domain.Owner, error) { + if m.UpdateOwnerFn != nil { + return m.UpdateOwnerFn(id, owner) + } + return nil, nil +} + +func (m *MockOwnerService) DeleteOwner(id string) error { + if m.DeleteOwnerFn != nil { + return m.DeleteOwnerFn(id) + } + return nil +} + +// TestListOwners_Success lists owners with pagination, verify data fields. +func TestListOwners_Success(t *testing.T) { + now := time.Now() + o1 := domain.Owner{ + ID: "o-alice", + Name: "Alice", + Email: "alice@example.com", + TeamID: "t-platform", + CreatedAt: now, + UpdatedAt: now, + } + o2 := domain.Owner{ + ID: "o-bob", + Name: "Bob", + Email: "bob@example.com", + TeamID: "t-ops", + CreatedAt: now, + UpdatedAt: now, + } + + mock := &MockOwnerService{ + ListOwnersFn: func(page, perPage int) ([]domain.Owner, int64, error) { + return []domain.Owner{o1, o2}, 2, nil + }, + } + + handler := NewOwnerHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/owners", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.ListOwners(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var resp PagedResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Total != 2 { + t.Errorf("expected total 2, got %d", resp.Total) + } +} + +// TestListOwners_Pagination verifies pagination parameters are passed to service. +func TestListOwners_Pagination(t *testing.T) { + var capturedPage, capturedPerPage int + mock := &MockOwnerService{ + ListOwnersFn: func(page, perPage int) ([]domain.Owner, int64, error) { + capturedPage = page + capturedPerPage = perPage + return []domain.Owner{}, 0, nil + }, + } + + handler := NewOwnerHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/owners?page=3&per_page=25", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.ListOwners(w, req) + + if capturedPage != 3 { + t.Errorf("expected page 3, got %d", capturedPage) + } + if capturedPerPage != 25 { + t.Errorf("expected per_page 25, got %d", capturedPerPage) + } +} + +// TestListOwners_ServiceError returns 500 on service error. +func TestListOwners_ServiceError(t *testing.T) { + mock := &MockOwnerService{ + ListOwnersFn: func(page, perPage int) ([]domain.Owner, int64, error) { + return nil, 0, ErrMockServiceFailed + }, + } + + handler := NewOwnerHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/owners", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.ListOwners(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } +} + +// TestListOwners_MethodNotAllowed returns 405 for non-GET methods. +func TestListOwners_MethodNotAllowed(t *testing.T) { + handler := NewOwnerHandler(&MockOwnerService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/owners", nil) + w := httptest.NewRecorder() + + handler.ListOwners(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", w.Code) + } +} + +// TestGetOwner_Success returns owner with email and team_id. +func TestGetOwner_Success(t *testing.T) { + now := time.Now() + mock := &MockOwnerService{ + GetOwnerFn: func(id string) (*domain.Owner, error) { + return &domain.Owner{ + ID: id, + Name: "Alice", + Email: "alice@example.com", + TeamID: "t-platform", + CreatedAt: now, + UpdatedAt: now, + }, nil + }, + } + + handler := NewOwnerHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/owners/o-alice", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.GetOwner(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var owner domain.Owner + if err := json.NewDecoder(w.Body).Decode(&owner); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if owner.Email != "alice@example.com" { + t.Errorf("expected email 'alice@example.com', got '%s'", owner.Email) + } + if owner.TeamID != "t-platform" { + t.Errorf("expected team_id 't-platform', got '%s'", owner.TeamID) + } +} + +// TestGetOwner_NotFound returns 404 when owner not found. +func TestGetOwner_NotFound(t *testing.T) { + mock := &MockOwnerService{ + GetOwnerFn: func(id string) (*domain.Owner, error) { + return nil, ErrMockNotFound + }, + } + + handler := NewOwnerHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/owners/nonexistent", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.GetOwner(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", w.Code) + } +} + +// TestGetOwner_EmptyID returns 400 for empty ID. +func TestGetOwner_EmptyID(t *testing.T) { + handler := NewOwnerHandler(&MockOwnerService{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/owners/", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.GetOwner(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +// TestGetOwner_MethodNotAllowed returns 405 for non-GET methods. +func TestGetOwner_MethodNotAllowed(t *testing.T) { + handler := NewOwnerHandler(&MockOwnerService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/owners/o-alice", nil) + w := httptest.NewRecorder() + + handler.GetOwner(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", w.Code) + } +} + +// TestCreateOwner_Success returns 201 with email and team_id. +func TestCreateOwner_Success(t *testing.T) { + now := time.Now() + mock := &MockOwnerService{ + CreateOwnerFn: func(owner domain.Owner) (*domain.Owner, error) { + owner.ID = "o-new" + owner.CreatedAt = now + owner.UpdatedAt = now + return &owner, nil + }, + } + + body := domain.Owner{ + Name: "Alice", + Email: "alice@example.com", + TeamID: "t-platform", + } + bodyBytes, _ := json.Marshal(body) + + handler := NewOwnerHandler(mock) + req := httptest.NewRequest(http.MethodPost, "/api/v1/owners", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.CreateOwner(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d", w.Code) + } + + var owner domain.Owner + if err := json.NewDecoder(w.Body).Decode(&owner); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if owner.Email != "alice@example.com" { + t.Errorf("expected email 'alice@example.com', got '%s'", owner.Email) + } + if owner.TeamID != "t-platform" { + t.Errorf("expected team_id 't-platform', got '%s'", owner.TeamID) + } +} + +// TestCreateOwner_InvalidJSON returns 400 for malformed JSON. +func TestCreateOwner_InvalidJSON(t *testing.T) { + handler := NewOwnerHandler(&MockOwnerService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/owners", bytes.NewReader([]byte("not json"))) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.CreateOwner(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +// TestCreateOwner_MissingName returns 400 when name is required. +func TestCreateOwner_MissingName(t *testing.T) { + body := map[string]interface{}{ + "email": "alice@example.com", + "team_id": "t-platform", + } + bodyBytes, _ := json.Marshal(body) + + handler := NewOwnerHandler(&MockOwnerService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/owners", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.CreateOwner(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +// TestCreateOwner_NameTooLong returns 400 for name exceeding 255 chars. +func TestCreateOwner_NameTooLong(t *testing.T) { + longName := "" + for i := 0; i < 256; i++ { + longName += "x" + } + body := domain.Owner{ + Name: longName, + Email: "alice@example.com", + TeamID: "t-platform", + } + bodyBytes, _ := json.Marshal(body) + + handler := NewOwnerHandler(&MockOwnerService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/owners", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.CreateOwner(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +// TestCreateOwner_ServiceError returns 500 on service error. +func TestCreateOwner_ServiceError(t *testing.T) { + mock := &MockOwnerService{ + CreateOwnerFn: func(owner domain.Owner) (*domain.Owner, error) { + return nil, ErrMockServiceFailed + }, + } + + body := domain.Owner{ + Name: "Alice", + Email: "alice@example.com", + TeamID: "t-platform", + } + bodyBytes, _ := json.Marshal(body) + + handler := NewOwnerHandler(mock) + req := httptest.NewRequest(http.MethodPost, "/api/v1/owners", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.CreateOwner(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } +} + +// TestCreateOwner_MethodNotAllowed returns 405 for non-POST methods. +func TestCreateOwner_MethodNotAllowed(t *testing.T) { + handler := NewOwnerHandler(&MockOwnerService{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/owners", nil) + w := httptest.NewRecorder() + + handler.CreateOwner(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", w.Code) + } +} + +// TestUpdateOwner_Success returns 200 with updated data. +func TestUpdateOwner_Success(t *testing.T) { + now := time.Now() + mock := &MockOwnerService{ + UpdateOwnerFn: func(id string, owner domain.Owner) (*domain.Owner, error) { + return &domain.Owner{ + ID: id, + Name: owner.Name, + Email: owner.Email, + TeamID: owner.TeamID, + CreatedAt: now, + UpdatedAt: now, + }, nil + }, + } + + body := domain.Owner{ + Name: "Alice Updated", + Email: "alice.updated@example.com", + TeamID: "t-platform", + } + bodyBytes, _ := json.Marshal(body) + + handler := NewOwnerHandler(mock) + req := httptest.NewRequest(http.MethodPut, "/api/v1/owners/o-alice", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.UpdateOwner(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var owner domain.Owner + if err := json.NewDecoder(w.Body).Decode(&owner); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if owner.Name != "Alice Updated" { + t.Errorf("expected name 'Alice Updated', got '%s'", owner.Name) + } +} + +// TestUpdateOwner_ServiceError returns 500 on service error. +func TestUpdateOwner_ServiceError(t *testing.T) { + mock := &MockOwnerService{ + UpdateOwnerFn: func(id string, owner domain.Owner) (*domain.Owner, error) { + return nil, ErrMockServiceFailed + }, + } + + body := domain.Owner{ + Name: "Alice Updated", + Email: "alice.updated@example.com", + TeamID: "t-platform", + } + bodyBytes, _ := json.Marshal(body) + + handler := NewOwnerHandler(mock) + req := httptest.NewRequest(http.MethodPut, "/api/v1/owners/o-alice", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.UpdateOwner(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } +} + +// TestUpdateOwner_EmptyID returns 400 for empty ID. +func TestUpdateOwner_EmptyID(t *testing.T) { + body := domain.Owner{ + Name: "Alice", + Email: "alice@example.com", + TeamID: "t-platform", + } + bodyBytes, _ := json.Marshal(body) + + handler := NewOwnerHandler(&MockOwnerService{}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/owners/", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.UpdateOwner(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +// TestDeleteOwner_Success returns 204 No Content. +func TestDeleteOwner_Success(t *testing.T) { + var deletedID string + mock := &MockOwnerService{ + DeleteOwnerFn: func(id string) error { + deletedID = id + return nil + }, + } + + handler := NewOwnerHandler(mock) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/owners/o-alice", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.DeleteOwner(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected status 204, got %d", w.Code) + } + if deletedID != "o-alice" { + t.Errorf("expected deleted ID 'o-alice', got '%s'", deletedID) + } +} + +// TestDeleteOwner_ServiceError returns 500 on service error. +func TestDeleteOwner_ServiceError(t *testing.T) { + mock := &MockOwnerService{ + DeleteOwnerFn: func(id string) error { + return ErrMockServiceFailed + }, + } + + handler := NewOwnerHandler(mock) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/owners/o-alice", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.DeleteOwner(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } +} + +// TestDeleteOwner_EmptyID returns 400 for empty ID. +func TestDeleteOwner_EmptyID(t *testing.T) { + handler := NewOwnerHandler(&MockOwnerService{}) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/owners/", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.DeleteOwner(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +// TestDeleteOwner_MethodNotAllowed returns 405 for non-DELETE methods. +func TestDeleteOwner_MethodNotAllowed(t *testing.T) { + handler := NewOwnerHandler(&MockOwnerService{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/owners/o-alice", nil) + w := httptest.NewRecorder() + + handler.DeleteOwner(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", w.Code) + } +} + +// contextWithRequestID returns a context with a test request ID for use in tests. +func contextWithRequestID() context.Context { + return context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-123") +} diff --git a/internal/api/handler/team_handler_test.go b/internal/api/handler/team_handler_test.go new file mode 100644 index 0000000..f5a647c --- /dev/null +++ b/internal/api/handler/team_handler_test.go @@ -0,0 +1,631 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/api/middleware" + "github.com/shankar0123/certctl/internal/domain" +) + +// MockTeamService is a mock implementation of TeamService interface. +type MockTeamService struct { + ListTeamsFn func(page, perPage int) ([]domain.Team, int64, error) + GetTeamFn func(id string) (*domain.Team, error) + CreateTeamFn func(team domain.Team) (*domain.Team, error) + UpdateTeamFn func(id string, team domain.Team) (*domain.Team, error) + DeleteTeamFn func(id string) error +} + +func (m *MockTeamService) ListTeams(page, perPage int) ([]domain.Team, int64, error) { + if m.ListTeamsFn != nil { + return m.ListTeamsFn(page, perPage) + } + return nil, 0, nil +} + +func (m *MockTeamService) GetTeam(id string) (*domain.Team, error) { + if m.GetTeamFn != nil { + return m.GetTeamFn(id) + } + return nil, nil +} + +func (m *MockTeamService) CreateTeam(team domain.Team) (*domain.Team, error) { + if m.CreateTeamFn != nil { + return m.CreateTeamFn(team) + } + return nil, nil +} + +func (m *MockTeamService) UpdateTeam(id string, team domain.Team) (*domain.Team, error) { + if m.UpdateTeamFn != nil { + return m.UpdateTeamFn(id, team) + } + return nil, nil +} + +func (m *MockTeamService) DeleteTeam(id string) error { + if m.DeleteTeamFn != nil { + return m.DeleteTeamFn(id) + } + return nil +} + +// TestListTeams_Success tests listing teams with default pagination. +func TestListTeams_Success(t *testing.T) { + now := time.Now() + t1 := domain.Team{ + ID: "t-platform", + Name: "Platform Team", + Description: "Infrastructure team", + CreatedAt: now, + UpdatedAt: now, + } + t2 := domain.Team{ + ID: "t-security", + Name: "Security Team", + Description: "Security operations", + CreatedAt: now, + UpdatedAt: now, + } + + mock := &MockTeamService{ + ListTeamsFn: func(page, perPage int) ([]domain.Team, int64, error) { + return []domain.Team{t1, t2}, 2, nil + }, + } + + handler := NewTeamHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/teams", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.ListTeams(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var resp PagedResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.Total != 2 { + t.Errorf("expected total 2, got %d", resp.Total) + } + if resp.Page != 1 { + t.Errorf("expected page 1, got %d", resp.Page) + } + if resp.PerPage != 50 { + t.Errorf("expected per_page 50, got %d", resp.PerPage) + } +} + +// TestListTeams_WithQueryParams tests listing with custom pagination parameters. +func TestListTeams_WithQueryParams(t *testing.T) { + var capturedPage, capturedPerPage int + mock := &MockTeamService{ + ListTeamsFn: func(page, perPage int) ([]domain.Team, int64, error) { + capturedPage = page + capturedPerPage = perPage + return []domain.Team{}, 0, nil + }, + } + + handler := NewTeamHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/teams?page=3&per_page=25", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.ListTeams(w, req) + + if capturedPage != 3 { + t.Errorf("expected page 3, got %d", capturedPage) + } + if capturedPerPage != 25 { + t.Errorf("expected per_page 25, got %d", capturedPerPage) + } +} + +// TestListTeams_PerPageMaxLimit tests that per_page is capped at 500. +func TestListTeams_PerPageMaxLimit(t *testing.T) { + var capturedPerPage int + mock := &MockTeamService{ + ListTeamsFn: func(page, perPage int) ([]domain.Team, int64, error) { + capturedPerPage = perPage + return []domain.Team{}, 0, nil + }, + } + + handler := NewTeamHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/teams?per_page=1000", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.ListTeams(w, req) + + if capturedPerPage != 500 { + t.Errorf("expected per_page capped at 500, got %d", capturedPerPage) + } +} + +// TestListTeams_ServiceError tests error handling when service fails. +func TestListTeams_ServiceError(t *testing.T) { + mock := &MockTeamService{ + ListTeamsFn: func(page, perPage int) ([]domain.Team, int64, error) { + return nil, 0, ErrMockServiceFailed + }, + } + + handler := NewTeamHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/teams", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.ListTeams(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } +} + +// TestListTeams_MethodNotAllowed tests that non-GET requests are rejected. +func TestListTeams_MethodNotAllowed(t *testing.T) { + handler := NewTeamHandler(&MockTeamService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/teams", nil) + w := httptest.NewRecorder() + + handler.ListTeams(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", w.Code) + } +} + +// TestGetTeam_Success tests retrieving a team by ID. +func TestGetTeam_Success(t *testing.T) { + now := time.Now() + mock := &MockTeamService{ + GetTeamFn: func(id string) (*domain.Team, error) { + return &domain.Team{ + ID: id, + Name: "Platform Team", + Description: "Infrastructure team", + CreatedAt: now, + UpdatedAt: now, + }, nil + }, + } + + handler := NewTeamHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/teams/t-platform", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.GetTeam(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var team domain.Team + if err := json.NewDecoder(w.Body).Decode(&team); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if team.ID != "t-platform" { + t.Errorf("expected ID t-platform, got %s", team.ID) + } +} + +// TestGetTeam_NotFound tests 404 response when team does not exist. +func TestGetTeam_NotFound(t *testing.T) { + mock := &MockTeamService{ + GetTeamFn: func(id string) (*domain.Team, error) { + return nil, ErrMockNotFound + }, + } + + handler := NewTeamHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/teams/nonexistent", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.GetTeam(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", w.Code) + } +} + +// TestGetTeam_EmptyID tests 400 response when team ID is empty. +func TestGetTeam_EmptyID(t *testing.T) { + handler := NewTeamHandler(&MockTeamService{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/teams/", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.GetTeam(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +// TestGetTeam_MethodNotAllowed tests that non-GET requests are rejected. +func TestGetTeam_MethodNotAllowed(t *testing.T) { + handler := NewTeamHandler(&MockTeamService{}) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/teams/t-platform", nil) + w := httptest.NewRecorder() + + handler.GetTeam(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", w.Code) + } +} + +// TestCreateTeam_Success tests successful team creation. +func TestCreateTeam_Success(t *testing.T) { + now := time.Now() + mock := &MockTeamService{ + CreateTeamFn: func(team domain.Team) (*domain.Team, error) { + team.ID = "t-new" + team.CreatedAt = now + team.UpdatedAt = now + return &team, nil + }, + } + + body := map[string]interface{}{ + "name": "New Team", + "description": "A new team", + } + bodyBytes, _ := json.Marshal(body) + + handler := NewTeamHandler(mock) + req := httptest.NewRequest(http.MethodPost, "/api/v1/teams", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.CreateTeam(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d", w.Code) + } + + var team domain.Team + if err := json.NewDecoder(w.Body).Decode(&team); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if team.ID != "t-new" { + t.Errorf("expected ID t-new, got %s", team.ID) + } + if team.Name != "New Team" { + t.Errorf("expected name 'New Team', got %s", team.Name) + } +} + +// TestCreateTeam_InvalidJSON tests 400 response for malformed JSON. +func TestCreateTeam_InvalidJSON(t *testing.T) { + handler := NewTeamHandler(&MockTeamService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/teams", bytes.NewReader([]byte("not json"))) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.CreateTeam(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +// TestCreateTeam_MissingName tests 400 response when name is required but missing. +func TestCreateTeam_MissingName(t *testing.T) { + body := map[string]interface{}{ + "description": "Team without name", + } + bodyBytes, _ := json.Marshal(body) + + handler := NewTeamHandler(&MockTeamService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/teams", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.CreateTeam(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +// TestCreateTeam_NameTooLong tests 400 response when name exceeds max length. +func TestCreateTeam_NameTooLong(t *testing.T) { + longName := "" + for i := 0; i < 256; i++ { + longName += "x" + } + body := map[string]interface{}{ + "name": longName, + } + bodyBytes, _ := json.Marshal(body) + + handler := NewTeamHandler(&MockTeamService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/teams", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.CreateTeam(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +// TestCreateTeam_ServiceError tests error handling when service fails. +func TestCreateTeam_ServiceError(t *testing.T) { + mock := &MockTeamService{ + CreateTeamFn: func(team domain.Team) (*domain.Team, error) { + return nil, ErrMockServiceFailed + }, + } + + body := map[string]interface{}{ + "name": "New Team", + } + bodyBytes, _ := json.Marshal(body) + + handler := NewTeamHandler(mock) + req := httptest.NewRequest(http.MethodPost, "/api/v1/teams", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.CreateTeam(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } +} + +// TestCreateTeam_MethodNotAllowed tests that non-POST requests are rejected. +func TestCreateTeam_MethodNotAllowed(t *testing.T) { + handler := NewTeamHandler(&MockTeamService{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/teams", nil) + w := httptest.NewRecorder() + + handler.CreateTeam(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", w.Code) + } +} + +// TestUpdateTeam_Success tests successful team update. +func TestUpdateTeam_Success(t *testing.T) { + now := time.Now() + mock := &MockTeamService{ + UpdateTeamFn: func(id string, team domain.Team) (*domain.Team, error) { + return &domain.Team{ + ID: id, + Name: team.Name, + Description: team.Description, + CreatedAt: now, + UpdatedAt: now, + }, nil + }, + } + + body := map[string]interface{}{ + "name": "Updated Team", + "description": "Updated description", + } + bodyBytes, _ := json.Marshal(body) + + handler := NewTeamHandler(mock) + req := httptest.NewRequest(http.MethodPut, "/api/v1/teams/t-platform", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.UpdateTeam(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var team domain.Team + if err := json.NewDecoder(w.Body).Decode(&team); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if team.Name != "Updated Team" { + t.Errorf("expected name 'Updated Team', got %s", team.Name) + } +} + +// TestUpdateTeam_InvalidJSON tests 400 response for malformed JSON. +func TestUpdateTeam_InvalidJSON(t *testing.T) { + handler := NewTeamHandler(&MockTeamService{}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/teams/t-platform", bytes.NewReader([]byte("bad json"))) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.UpdateTeam(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +// TestUpdateTeam_EmptyID tests 400 response when team ID is empty. +func TestUpdateTeam_EmptyID(t *testing.T) { + body := map[string]interface{}{ + "name": "Updated Team", + } + bodyBytes, _ := json.Marshal(body) + + handler := NewTeamHandler(&MockTeamService{}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/teams/", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.UpdateTeam(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +// TestUpdateTeam_ServiceError tests error handling when service fails. +func TestUpdateTeam_ServiceError(t *testing.T) { + mock := &MockTeamService{ + UpdateTeamFn: func(id string, team domain.Team) (*domain.Team, error) { + return nil, ErrMockServiceFailed + }, + } + + body := map[string]interface{}{ + "name": "Updated Team", + } + bodyBytes, _ := json.Marshal(body) + + handler := NewTeamHandler(mock) + req := httptest.NewRequest(http.MethodPut, "/api/v1/teams/t-platform", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.UpdateTeam(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } +} + +// TestUpdateTeam_MethodNotAllowed tests that non-PUT requests are rejected. +func TestUpdateTeam_MethodNotAllowed(t *testing.T) { + handler := NewTeamHandler(&MockTeamService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/teams/t-platform", nil) + w := httptest.NewRecorder() + + handler.UpdateTeam(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", w.Code) + } +} + +// TestDeleteTeam_Success tests successful team deletion. +func TestDeleteTeam_Success(t *testing.T) { + mock := &MockTeamService{ + DeleteTeamFn: func(id string) error { + return nil + }, + } + + handler := NewTeamHandler(mock) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/teams/t-platform", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.DeleteTeam(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected status 204, got %d", w.Code) + } +} + +// TestDeleteTeam_EmptyID tests 400 response when team ID is empty. +func TestDeleteTeam_EmptyID(t *testing.T) { + handler := NewTeamHandler(&MockTeamService{}) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/teams/", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.DeleteTeam(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +// TestDeleteTeam_ServiceError tests error handling when service fails. +func TestDeleteTeam_ServiceError(t *testing.T) { + mock := &MockTeamService{ + DeleteTeamFn: func(id string) error { + return ErrMockServiceFailed + }, + } + + handler := NewTeamHandler(mock) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/teams/t-platform", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.DeleteTeam(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", w.Code) + } +} + +// TestDeleteTeam_MethodNotAllowed tests that non-DELETE requests are rejected. +func TestDeleteTeam_MethodNotAllowed(t *testing.T) { + handler := NewTeamHandler(&MockTeamService{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/teams/t-platform", nil) + w := httptest.NewRecorder() + + handler.DeleteTeam(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", w.Code) + } +} + +// TestCreateTeam_EmptyNameString tests 400 response when name is empty string. +func TestCreateTeam_EmptyNameString(t *testing.T) { + body := map[string]interface{}{ + "name": "", + } + bodyBytes, _ := json.Marshal(body) + + handler := NewTeamHandler(&MockTeamService{}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/teams", bytes.NewReader(bodyBytes)) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.CreateTeam(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", w.Code) + } +} + +// TestListTeams_InvalidPagination tests handling of invalid pagination parameters. +func TestListTeams_InvalidPagination(t *testing.T) { + var capturedPage, capturedPerPage int + mock := &MockTeamService{ + ListTeamsFn: func(page, perPage int) ([]domain.Team, int64, error) { + capturedPage = page + capturedPerPage = perPage + return []domain.Team{}, 0, nil + }, + } + + handler := NewTeamHandler(mock) + req := httptest.NewRequest(http.MethodGet, "/api/v1/teams?page=invalid&per_page=bad", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.ListTeams(w, req) + + // Should use defaults when parsing fails + if capturedPage != 1 { + t.Errorf("expected default page 1, got %d", capturedPage) + } + if capturedPerPage != 50 { + t.Errorf("expected default per_page 50, got %d", capturedPerPage) + } +} diff --git a/internal/connector/target/nginx/nginx_test.go b/internal/connector/target/nginx/nginx_test.go new file mode 100644 index 0000000..dd1dd76 --- /dev/null +++ b/internal/connector/target/nginx/nginx_test.go @@ -0,0 +1,379 @@ +package nginx_test + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/shankar0123/certctl/internal/connector/target" + "github.com/shankar0123/certctl/internal/connector/target/nginx" +) + +func TestNginxConnector_ValidateConfig_Success(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + tmpDir := t.TempDir() + cfg := nginx.Config{ + CertPath: filepath.Join(tmpDir, "cert.pem"), + KeyPath: filepath.Join(tmpDir, "key.pem"), + ChainPath: filepath.Join(tmpDir, "chain.pem"), + ReloadCommand: "true", + ValidateCommand: "true", + } + + connector := nginx.New(&cfg, logger) + rawConfig, _ := json.Marshal(cfg) + err := connector.ValidateConfig(ctx, rawConfig) + if err != nil { + t.Fatalf("ValidateConfig failed: %v", err) + } +} + +func TestNginxConnector_ValidateConfig_InvalidJSON(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + connector := nginx.New(&nginx.Config{}, logger) + err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`)) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestNginxConnector_ValidateConfig_MissingCertPath(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + tmpDir := t.TempDir() + cfg := nginx.Config{ + ChainPath: filepath.Join(tmpDir, "chain.pem"), + ReloadCommand: "true", + ValidateCommand: "true", + } + + connector := nginx.New(&cfg, logger) + rawConfig, _ := json.Marshal(cfg) + err := connector.ValidateConfig(ctx, rawConfig) + if err == nil { + t.Fatal("expected error for missing cert_path") + } +} + +func TestNginxConnector_ValidateConfig_MissingReloadCommand(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + tmpDir := t.TempDir() + cfg := nginx.Config{ + CertPath: filepath.Join(tmpDir, "cert.pem"), + ChainPath: filepath.Join(tmpDir, "chain.pem"), + ValidateCommand: "true", + } + + connector := nginx.New(&cfg, logger) + rawConfig, _ := json.Marshal(cfg) + err := connector.ValidateConfig(ctx, rawConfig) + if err == nil { + t.Fatal("expected error for missing reload_command") + } +} + +func TestNginxConnector_ValidateConfig_DirectoryNotExists(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + cfg := nginx.Config{ + CertPath: "/nonexistent/directory/cert.pem", + ChainPath: "/tmp/chain.pem", + ReloadCommand: "true", + ValidateCommand: "true", + } + + connector := nginx.New(&cfg, logger) + rawConfig, _ := json.Marshal(cfg) + err := connector.ValidateConfig(ctx, rawConfig) + if err == nil { + t.Fatal("expected error for non-existent cert directory") + } +} + +func TestNginxConnector_DeployCertificate_Success(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + tmpDir := t.TempDir() + cfg := &nginx.Config{ + CertPath: filepath.Join(tmpDir, "cert.pem"), + KeyPath: filepath.Join(tmpDir, "key.pem"), + ChainPath: filepath.Join(tmpDir, "chain.pem"), + ReloadCommand: "true", + ValidateCommand: "true", + } + + connector := nginx.New(cfg, logger) + + req := target.DeploymentRequest{ + CertPEM: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + ChainPEM: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----", + } + + result, err := connector.DeployCertificate(ctx, req) + if err != nil { + t.Fatalf("DeployCertificate failed: %v", err) + } + + if !result.Success { + t.Fatalf("expected success, got: %s", result.Message) + } + + // Verify cert file was written + certData, err := os.ReadFile(cfg.CertPath) + if err != nil { + t.Fatalf("failed to read cert file: %v", err) + } + if string(certData) != req.CertPEM { + t.Errorf("cert content mismatch") + } + + // Verify chain file was written + chainData, err := os.ReadFile(cfg.ChainPath) + if err != nil { + t.Fatalf("failed to read chain file: %v", err) + } + if string(chainData) != req.ChainPEM { + t.Errorf("chain content mismatch") + } + + // Verify cert has correct permissions (0644) + info, err := os.Stat(cfg.CertPath) + if err != nil { + t.Fatalf("failed to stat cert file: %v", err) + } + if info.Mode().Perm() != 0644 { + t.Errorf("expected cert permissions 0644, got %v", info.Mode().Perm()) + } + + // Verify chain has correct permissions (0644) + info, err = os.Stat(cfg.ChainPath) + if err != nil { + t.Fatalf("failed to stat chain file: %v", err) + } + if info.Mode().Perm() != 0644 { + t.Errorf("expected chain permissions 0644, got %v", info.Mode().Perm()) + } + + // Verify metadata is populated + if result.Metadata == nil { + t.Fatal("expected metadata in result") + } + if result.Metadata["cert_path"] != cfg.CertPath { + t.Errorf("expected cert_path in metadata") + } + if result.Metadata["chain_path"] != cfg.ChainPath { + t.Errorf("expected chain_path in metadata") + } + if _, ok := result.Metadata["duration_ms"]; !ok { + t.Errorf("expected duration_ms in metadata") + } +} + +func TestNginxConnector_DeployCertificate_CertWriteFail(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + cfg := &nginx.Config{ + CertPath: "/nonexistent/directory/cert.pem", + ChainPath: "/tmp/chain.pem", + ReloadCommand: "true", + ValidateCommand: "true", + } + + connector := nginx.New(cfg, logger) + + req := target.DeploymentRequest{ + CertPEM: "cert", + ChainPEM: "chain", + } + + result, err := connector.DeployCertificate(ctx, req) + if err == nil { + t.Fatal("expected error when cert write fails") + } + if result.Success { + t.Fatal("expected failure result") + } +} + +func TestNginxConnector_DeployCertificate_ChainWriteFail(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + tmpDir := t.TempDir() + cfg := &nginx.Config{ + CertPath: filepath.Join(tmpDir, "cert.pem"), + ChainPath: "/nonexistent/directory/chain.pem", + ReloadCommand: "true", + ValidateCommand: "true", + } + + connector := nginx.New(cfg, logger) + + req := target.DeploymentRequest{ + CertPEM: "cert", + ChainPEM: "chain", + } + + result, err := connector.DeployCertificate(ctx, req) + if err == nil { + t.Fatal("expected error when chain write fails") + } + if result.Success { + t.Fatal("expected failure result") + } +} + +func TestNginxConnector_DeployCertificate_ValidateCommandFails(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + tmpDir := t.TempDir() + cfg := &nginx.Config{ + CertPath: filepath.Join(tmpDir, "cert.pem"), + ChainPath: filepath.Join(tmpDir, "chain.pem"), + ReloadCommand: "true", + ValidateCommand: "false", + } + + connector := nginx.New(cfg, logger) + + req := target.DeploymentRequest{ + CertPEM: "cert", + ChainPEM: "chain", + } + + result, err := connector.DeployCertificate(ctx, req) + if err == nil { + t.Fatal("expected error when validate command fails") + } + if result.Success { + t.Fatal("expected failure result") + } +} + +func TestNginxConnector_DeployCertificate_ReloadCommandFails(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + tmpDir := t.TempDir() + cfg := &nginx.Config{ + CertPath: filepath.Join(tmpDir, "cert.pem"), + ChainPath: filepath.Join(tmpDir, "chain.pem"), + ReloadCommand: "false", + ValidateCommand: "true", + } + + connector := nginx.New(cfg, logger) + + req := target.DeploymentRequest{ + CertPEM: "cert", + ChainPEM: "chain", + } + + result, err := connector.DeployCertificate(ctx, req) + if err == nil { + t.Fatal("expected error when reload command fails") + } + if result.Success { + t.Fatal("expected failure result") + } +} + +func TestNginxConnector_ValidateDeployment_Success(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "cert.pem") + os.WriteFile(certPath, []byte("cert"), 0644) + + cfg := &nginx.Config{ + CertPath: certPath, + ChainPath: filepath.Join(tmpDir, "chain.pem"), + ValidateCommand: "true", + } + + connector := nginx.New(cfg, logger) + + result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{ + CertificateID: "mc-test", + Serial: "123", + }) + if err != nil { + t.Fatalf("ValidateDeployment failed: %v", err) + } + if !result.Valid { + t.Fatal("expected valid deployment") + } + + // Verify metadata is populated + if result.Metadata == nil { + t.Fatal("expected metadata in result") + } + if _, ok := result.Metadata["duration_ms"]; !ok { + t.Errorf("expected duration_ms in metadata") + } +} + +func TestNginxConnector_ValidateDeployment_CertNotFound(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + cfg := &nginx.Config{ + CertPath: "/nonexistent/cert.pem", + ValidateCommand: "true", + } + + connector := nginx.New(cfg, logger) + + result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{ + CertificateID: "mc-test", + Serial: "123", + }) + if err == nil { + t.Fatal("expected error for missing cert file") + } + if result.Valid { + t.Fatal("expected invalid result") + } +} + +func TestNginxConnector_ValidateDeployment_ValidateCommandFails(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + ctx := context.Background() + + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "cert.pem") + os.WriteFile(certPath, []byte("cert"), 0644) + + cfg := &nginx.Config{ + CertPath: certPath, + ValidateCommand: "false", + } + + connector := nginx.New(cfg, logger) + + result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{ + CertificateID: "mc-test", + Serial: "123", + }) + if err == nil { + t.Fatal("expected error when validate command fails") + } + if result.Valid { + t.Fatal("expected invalid result") + } +} diff --git a/internal/integration/negative_test.go b/internal/integration/negative_test.go index 6bf7f52..ea24795 100644 --- a/internal/integration/negative_test.go +++ b/internal/integration/negative_test.go @@ -392,3 +392,282 @@ func TestCertificateLifecycleWithExpiredCert(t *testing.T) { t.Logf("Renewal trigger on expired cert returned status: %d", resp.StatusCode) }) } + +// TestM11bEndpoints exercises the M11b endpoints: teams, owners, agent groups. +// Tests M11b feature coverage through the HTTP API. +func TestM11bEndpoints(t *testing.T) { + server, _, _, _ := setupTestServer(t) + + // ======================== + // Teams API + // ======================== + t.Run("Teams", func(t *testing.T) { + t.Run("CreateTeam_Success", func(t *testing.T) { + payload := map[string]string{"name": "Platform", "description": "Platform team"} + body, _ := json.Marshal(payload) + resp, err := http.Post(server.URL+"/api/v1/teams", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Errorf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes)) + } + var team domain.Team + json.NewDecoder(resp.Body).Decode(&team) + if team.Name != "Platform" { + t.Errorf("expected name=Platform, got %s", team.Name) + } + }) + + t.Run("CreateTeam_MissingName", func(t *testing.T) { + payload := map[string]string{"description": "No name team"} + body, _ := json.Marshal(payload) + resp, err := http.Post(server.URL+"/api/v1/teams", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400, got %d", resp.StatusCode) + } + }) + + t.Run("CreateTeam_NameTooLong", func(t *testing.T) { + longName := "" + for i := 0; i < 256; i++ { + longName += "a" + } + payload := map[string]string{"name": longName} + body, _ := json.Marshal(payload) + resp, err := http.Post(server.URL+"/api/v1/teams", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400, got %d", resp.StatusCode) + } + }) + + t.Run("CreateTeam_InvalidJSON", func(t *testing.T) { + resp, err := http.Post(server.URL+"/api/v1/teams", "application/json", bytes.NewReader([]byte("not json"))) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400, got %d", resp.StatusCode) + } + }) + + t.Run("GetTeam_NotFound", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/teams/t-nonexistent") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } + }) + + t.Run("ListTeams_Empty", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/teams") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + }) + + t.Run("DeleteTeam_Success", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/teams/t-platform", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Errorf("expected 204, got %d", resp.StatusCode) + } + }) + + t.Run("ListTeams_MethodNotAllowed", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/teams", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", resp.StatusCode) + } + }) + }) + + // ======================== + // Owners API + // ======================== + t.Run("Owners", func(t *testing.T) { + t.Run("CreateOwner_Success", func(t *testing.T) { + payload := map[string]string{"name": "Alice", "email": "alice@example.com", "team_id": "t-platform"} + body, _ := json.Marshal(payload) + resp, err := http.Post(server.URL+"/api/v1/owners", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Errorf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes)) + } + var owner domain.Owner + json.NewDecoder(resp.Body).Decode(&owner) + if owner.Name != "Alice" { + t.Errorf("expected name=Alice, got %s", owner.Name) + } + if owner.Email != "alice@example.com" { + t.Errorf("expected email=alice@example.com, got %s", owner.Email) + } + }) + + t.Run("CreateOwner_MissingName", func(t *testing.T) { + payload := map[string]string{"email": "bob@example.com"} + body, _ := json.Marshal(payload) + resp, err := http.Post(server.URL+"/api/v1/owners", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400, got %d", resp.StatusCode) + } + }) + + t.Run("GetOwner_NotFound", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/owners/o-nonexistent") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } + }) + + t.Run("ListOwners_Empty", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/owners") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + }) + + t.Run("DeleteOwner_Success", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/owners/o-alice", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Errorf("expected 204, got %d", resp.StatusCode) + } + }) + }) + + // ======================== + // Agent Groups API + // ======================== + t.Run("AgentGroups", func(t *testing.T) { + t.Run("CreateAgentGroup_Success", func(t *testing.T) { + payload := map[string]interface{}{ + "name": "Linux Servers", + "description": "All linux-based agents", + "match_os": "linux", + "match_architecture": "amd64", + "enabled": true, + } + body, _ := json.Marshal(payload) + resp, err := http.Post(server.URL+"/api/v1/agent-groups", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Errorf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes)) + } + var group domain.AgentGroup + json.NewDecoder(resp.Body).Decode(&group) + if group.Name != "Linux Servers" { + t.Errorf("expected name=Linux Servers, got %s", group.Name) + } + }) + + t.Run("CreateAgentGroup_MissingName", func(t *testing.T) { + payload := map[string]string{"description": "No name group"} + body, _ := json.Marshal(payload) + resp, err := http.Post(server.URL+"/api/v1/agent-groups", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("expected 400, got %d", resp.StatusCode) + } + }) + + t.Run("GetAgentGroup_NotFound", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/agent-groups/ag-nonexistent") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", resp.StatusCode) + } + }) + + t.Run("ListAgentGroups_Empty", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/agent-groups") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + }) + + t.Run("DeleteAgentGroup_Success", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/agent-groups/ag-linux", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + t.Errorf("expected 204, got %d", resp.StatusCode) + } + }) + + t.Run("ListAgentGroupMembers_Empty", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/agent-groups/ag-linux/members") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + }) + }) +} diff --git a/internal/service/agent_group_test.go b/internal/service/agent_group_test.go new file mode 100644 index 0000000..679dc57 --- /dev/null +++ b/internal/service/agent_group_test.go @@ -0,0 +1,699 @@ +package service + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/domain" +) + +// mockAgentGroupRepo is a test implementation of AgentGroupRepository +type mockAgentGroupRepo struct { + groups map[string]*domain.AgentGroup + members map[string][]*domain.Agent + CreateErr error + UpdateErr error + DeleteErr error + GetErr error + ListErr error + ListMembersErr error + AddMemberErr error + RemoveMemberErr error +} + +func newMockAgentGroupRepository() *mockAgentGroupRepo { + return &mockAgentGroupRepo{ + groups: make(map[string]*domain.AgentGroup), + members: make(map[string][]*domain.Agent), + } +} + +func (m *mockAgentGroupRepo) List(ctx context.Context) ([]*domain.AgentGroup, error) { + if m.ListErr != nil { + return nil, m.ListErr + } + var groups []*domain.AgentGroup + for _, g := range m.groups { + groups = append(groups, g) + } + return groups, nil +} + +func (m *mockAgentGroupRepo) Get(ctx context.Context, id string) (*domain.AgentGroup, error) { + if m.GetErr != nil { + return nil, m.GetErr + } + group, ok := m.groups[id] + if !ok { + return nil, errNotFound + } + return group, nil +} + +func (m *mockAgentGroupRepo) Create(ctx context.Context, group *domain.AgentGroup) error { + if m.CreateErr != nil { + return m.CreateErr + } + m.groups[group.ID] = group + return nil +} + +func (m *mockAgentGroupRepo) Update(ctx context.Context, group *domain.AgentGroup) error { + if m.UpdateErr != nil { + return m.UpdateErr + } + m.groups[group.ID] = group + return nil +} + +func (m *mockAgentGroupRepo) Delete(ctx context.Context, id string) error { + if m.DeleteErr != nil { + return m.DeleteErr + } + delete(m.groups, id) + delete(m.members, id) + return nil +} + +func (m *mockAgentGroupRepo) ListMembers(ctx context.Context, groupID string) ([]*domain.Agent, error) { + if m.ListMembersErr != nil { + return nil, m.ListMembersErr + } + members := m.members[groupID] + if members == nil { + return make([]*domain.Agent, 0), nil + } + return members, nil +} + +func (m *mockAgentGroupRepo) AddMember(ctx context.Context, groupID, agentID, membershipType string) error { + if m.AddMemberErr != nil { + return m.AddMemberErr + } + // For testing purposes, we'll assume a simple mock agent + agent := &domain.Agent{ + ID: agentID, + Name: "test-agent-" + agentID, + } + m.members[groupID] = append(m.members[groupID], agent) + return nil +} + +func (m *mockAgentGroupRepo) RemoveMember(ctx context.Context, groupID, agentID string) error { + if m.RemoveMemberErr != nil { + return m.RemoveMemberErr + } + members := m.members[groupID] + var filtered []*domain.Agent + for _, m := range members { + if m.ID != agentID { + filtered = append(filtered, m) + } + } + m.members[groupID] = filtered + return nil +} + +func (m *mockAgentGroupRepo) AddGroup(group *domain.AgentGroup) { + m.groups[group.ID] = group +} + +func (m *mockAgentGroupRepo) AddGroupMembers(groupID string, agents []*domain.Agent) { + m.members[groupID] = agents +} + +// Test: ListAgentGroups returns groups +func TestAgentGroupService_ListAgentGroups(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + group1 := &domain.AgentGroup{ + ID: "ag-test-1", + Name: "Linux Servers", + } + group2 := &domain.AgentGroup{ + ID: "ag-test-2", + Name: "Windows Servers", + } + repo.AddGroup(group1) + repo.AddGroup(group2) + + groups, total, err := svc.ListAgentGroups(1, 50) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if total != 2 { + t.Errorf("expected total=2, got %d", total) + } + if len(groups) != 2 { + t.Errorf("expected 2 groups, got %d", len(groups)) + } +} + +// Test: ListAgentGroups with default pagination +func TestAgentGroupService_ListAgentGroups_DefaultPagination(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + group := &domain.AgentGroup{ + ID: "ag-test-1", + Name: "Test Group", + } + repo.AddGroup(group) + + // page < 1 should default to 1, perPage < 1 should default to 50 + groups, total, err := svc.ListAgentGroups(-1, 0) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if total != 1 { + t.Errorf("expected total=1, got %d", total) + } + if len(groups) != 1 { + t.Errorf("expected 1 group, got %d", len(groups)) + } +} + +// Test: ListAgentGroups with repository error +func TestAgentGroupService_ListAgentGroups_RepositoryError(t *testing.T) { + repo := newMockAgentGroupRepository() + repo.ListErr = errors.New("database error") + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + _, _, err := svc.ListAgentGroups(1, 50) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to list agent groups") { + t.Errorf("expected 'failed to list agent groups' in error, got %v", err) + } +} + +// Test: ListAgentGroups with empty result +func TestAgentGroupService_ListAgentGroups_EmptyResult(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + groups, total, err := svc.ListAgentGroups(1, 50) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if total != 0 { + t.Errorf("expected total=0, got %d", total) + } + if len(groups) != 0 { + t.Errorf("expected 0 groups, got %d", len(groups)) + } +} + +// Test: GetAgentGroup success +func TestAgentGroupService_GetAgentGroup(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + group := &domain.AgentGroup{ + ID: "ag-test-1", + Name: "Test Group", + } + repo.AddGroup(group) + + retrieved, err := svc.GetAgentGroup("ag-test-1") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if retrieved == nil { + t.Fatal("expected group, got nil") + } + if retrieved.ID != "ag-test-1" { + t.Errorf("expected ID 'ag-test-1', got %s", retrieved.ID) + } + if retrieved.Name != "Test Group" { + t.Errorf("expected name 'Test Group', got %s", retrieved.Name) + } +} + +// Test: GetAgentGroup not found +func TestAgentGroupService_GetAgentGroup_NotFound(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + _, err := svc.GetAgentGroup("ag-nonexistent") + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, errNotFound) { + t.Errorf("expected errNotFound, got %v", err) + } +} + +// Test: CreateAgentGroup success with ID generated and timestamps +func TestAgentGroupService_CreateAgentGroup(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + group := domain.AgentGroup{ + Name: "Test Group", + } + before := time.Now() + + created, err := svc.CreateAgentGroup(group) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if created == nil { + t.Fatal("expected group, got nil") + } + + // ID should be generated + if created.ID == "" { + t.Fatal("expected ID to be generated, got empty string") + } + if !strings.HasPrefix(created.ID, "ag-") { + t.Errorf("expected ID to start with 'ag-', got %s", created.ID) + } + + // Timestamps should be set + if created.CreatedAt.IsZero() { + t.Fatal("expected CreatedAt to be set") + } + if created.UpdatedAt.IsZero() { + t.Fatal("expected UpdatedAt to be set") + } + if created.CreatedAt.Before(before) { + t.Errorf("expected CreatedAt >= before, got %v < %v", created.CreatedAt, before) + } + + // Should be in repository + retrieved, err := repo.Get(context.Background(), created.ID) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if retrieved.ID != created.ID { + t.Errorf("expected ID %s, got %s", created.ID, retrieved.ID) + } + + // Audit event should be recorded + if len(auditRepo.Events) == 0 { + t.Fatal("expected audit event to be recorded") + } + if auditRepo.Events[0].Action != "create_agent_group" { + t.Errorf("expected action 'create_agent_group', got %s", auditRepo.Events[0].Action) + } +} + +// Test: CreateAgentGroup with empty name +func TestAgentGroupService_CreateAgentGroup_EmptyName(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + group := domain.AgentGroup{ + Name: "", + } + + _, err := svc.CreateAgentGroup(group) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "agent group name is required") { + t.Errorf("expected 'agent group name is required' in error, got %v", err) + } +} + +// Test: CreateAgentGroup with name too long +func TestAgentGroupService_CreateAgentGroup_NameTooLong(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + group := domain.AgentGroup{ + Name: strings.Repeat("a", 256), + } + + _, err := svc.CreateAgentGroup(group) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "exceeds 255 characters") { + t.Errorf("expected 'exceeds 255 characters' in error, got %v", err) + } +} + +// Test: CreateAgentGroup with existing ID preserves ID +func TestAgentGroupService_CreateAgentGroup_WithExistingID(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + group := domain.AgentGroup{ + ID: "ag-custom-id", + Name: "Test Group", + } + + created, err := svc.CreateAgentGroup(group) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if created.ID != "ag-custom-id" { + t.Errorf("expected ID 'ag-custom-id', got %s", created.ID) + } +} + +// Test: CreateAgentGroup with dynamic criteria +func TestAgentGroupService_CreateAgentGroup_WithDynamicCriteria(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + group := domain.AgentGroup{ + Name: "Linux x86_64 Servers", + MatchOS: "linux", + MatchArchitecture: "amd64", + } + + created, err := svc.CreateAgentGroup(group) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if created.MatchOS != "linux" { + t.Errorf("expected MatchOS 'linux', got %s", created.MatchOS) + } + if created.MatchArchitecture != "amd64" { + t.Errorf("expected MatchArchitecture 'amd64', got %s", created.MatchArchitecture) + } +} + +// Test: CreateAgentGroup with repository error +func TestAgentGroupService_CreateAgentGroup_RepositoryError(t *testing.T) { + repo := newMockAgentGroupRepository() + repo.CreateErr = errors.New("database error") + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + group := domain.AgentGroup{ + Name: "Test Group", + } + + _, err := svc.CreateAgentGroup(group) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to create agent group") { + t.Errorf("expected 'failed to create agent group' in error, got %v", err) + } +} + +// Test: UpdateAgentGroup success +func TestAgentGroupService_UpdateAgentGroup(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + existing := &domain.AgentGroup{ + ID: "ag-test-1", + Name: "Old Name", + } + repo.AddGroup(existing) + + updated := domain.AgentGroup{ + Name: "New Name", + } + + result, err := svc.UpdateAgentGroup("ag-test-1", updated) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result.ID != "ag-test-1" { + t.Errorf("expected ID 'ag-test-1', got %s", result.ID) + } + if result.Name != "New Name" { + t.Errorf("expected name 'New Name', got %s", result.Name) + } + + // Audit event should be recorded + if len(auditRepo.Events) == 0 { + t.Fatal("expected audit event to be recorded") + } + if auditRepo.Events[0].Action != "update_agent_group" { + t.Errorf("expected action 'update_agent_group', got %s", auditRepo.Events[0].Action) + } +} + +// Test: UpdateAgentGroup with empty name validation error +func TestAgentGroupService_UpdateAgentGroup_EmptyName(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + updated := domain.AgentGroup{ + Name: "", + } + + _, err := svc.UpdateAgentGroup("ag-test-1", updated) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "agent group name is required") { + t.Errorf("expected 'agent group name is required' in error, got %v", err) + } +} + +// Test: UpdateAgentGroup with repository error +func TestAgentGroupService_UpdateAgentGroup_RepositoryError(t *testing.T) { + repo := newMockAgentGroupRepository() + repo.UpdateErr = errors.New("database error") + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + updated := domain.AgentGroup{ + Name: "Valid Name", + } + + _, err := svc.UpdateAgentGroup("ag-test-1", updated) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to update agent group") { + t.Errorf("expected 'failed to update agent group' in error, got %v", err) + } +} + +// Test: DeleteAgentGroup success with audit +func TestAgentGroupService_DeleteAgentGroup(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + group := &domain.AgentGroup{ + ID: "ag-test-1", + Name: "Test Group", + } + repo.AddGroup(group) + + err := svc.DeleteAgentGroup("ag-test-1") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // Group should be deleted from repository + _, err = repo.Get(context.Background(), "ag-test-1") + if !errors.Is(err, errNotFound) { + t.Errorf("expected errNotFound after delete, got %v", err) + } + + // Audit event should be recorded + if len(auditRepo.Events) == 0 { + t.Fatal("expected audit event to be recorded") + } + if auditRepo.Events[0].Action != "delete_agent_group" { + t.Errorf("expected action 'delete_agent_group', got %s", auditRepo.Events[0].Action) + } +} + +// Test: DeleteAgentGroup with repository error +func TestAgentGroupService_DeleteAgentGroup_RepositoryError(t *testing.T) { + repo := newMockAgentGroupRepository() + repo.DeleteErr = errors.New("database error") + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + err := svc.DeleteAgentGroup("ag-test-1") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to delete agent group") { + t.Errorf("expected 'failed to delete agent group' in error, got %v", err) + } +} + +// Test: ListMembers returns agents +func TestAgentGroupService_ListMembers(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + agents := []*domain.Agent{ + { + ID: "agent-1", + Name: "Agent 1", + }, + { + ID: "agent-2", + Name: "Agent 2", + }, + } + repo.AddGroupMembers("ag-test-1", agents) + + result, total, err := svc.ListMembers("ag-test-1") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if total != 2 { + t.Errorf("expected total=2, got %d", total) + } + if len(result) != 2 { + t.Errorf("expected 2 agents, got %d", len(result)) + } + if result[0].ID != "agent-1" { + t.Errorf("expected first agent ID 'agent-1', got %s", result[0].ID) + } +} + +// Test: ListMembers returns empty when no agents +func TestAgentGroupService_ListMembers_Empty(t *testing.T) { + repo := newMockAgentGroupRepository() + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + result, total, err := svc.ListMembers("ag-test-1") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if total != 0 { + t.Errorf("expected total=0, got %d", total) + } + if len(result) != 0 { + t.Errorf("expected 0 agents, got %d", len(result)) + } +} + +// Test: ListMembers with repository error +func TestAgentGroupService_ListMembers_RepositoryError(t *testing.T) { + repo := newMockAgentGroupRepository() + repo.ListMembersErr = errors.New("database error") + auditRepo := newMockAuditRepository() + auditSvc := NewAuditService(auditRepo) + svc := NewAgentGroupService(repo, auditSvc) + + _, _, err := svc.ListMembers("ag-test-1") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to list group members") { + t.Errorf("expected 'failed to list group members' in error, got %v", err) + } +} + +// Test: AgentGroup.MatchesAgent with all criteria matching +func TestAgentGroup_MatchesAgent(t *testing.T) { + group := &domain.AgentGroup{ + MatchOS: "linux", + MatchArchitecture: "amd64", + MatchVersion: "1.0.0", + } + agent := &domain.Agent{ + OS: "linux", + Architecture: "amd64", + Version: "1.0.0", + } + + matches := group.MatchesAgent(agent) + if !matches { + t.Fatal("expected agent to match all criteria") + } +} + +// Test: AgentGroup.MatchesAgent with OS mismatch +func TestAgentGroup_MatchesAgent_OSMismatch(t *testing.T) { + group := &domain.AgentGroup{ + MatchOS: "linux", + MatchArchitecture: "amd64", + } + agent := &domain.Agent{ + OS: "windows", + Architecture: "amd64", + } + + matches := group.MatchesAgent(agent) + if matches { + t.Fatal("expected agent NOT to match due to OS mismatch") + } +} + +// Test: AgentGroup.MatchesAgent with empty criteria matches any agent +func TestAgentGroup_MatchesAgent_EmptyCriteria(t *testing.T) { + group := &domain.AgentGroup{ + // All criteria empty (wildcards) + } + agent := &domain.Agent{ + OS: "linux", + Architecture: "arm64", + Version: "2.0.0", + } + + matches := group.MatchesAgent(agent) + if !matches { + t.Fatal("expected agent to match empty criteria (wildcard)") + } +} + +// Test: AgentGroup.HasDynamicCriteria returns true when criteria set +func TestAgentGroup_HasDynamicCriteria(t *testing.T) { + group := &domain.AgentGroup{ + MatchOS: "linux", + } + + if !group.HasDynamicCriteria() { + t.Fatal("expected HasDynamicCriteria to return true") + } +} + +// Test: AgentGroup.HasDynamicCriteria returns false when empty +func TestAgentGroup_HasDynamicCriteria_Empty(t *testing.T) { + group := &domain.AgentGroup{ + // All criteria empty + } + + if group.HasDynamicCriteria() { + t.Fatal("expected HasDynamicCriteria to return false") + } +} diff --git a/internal/service/issuer_adapter_test.go b/internal/service/issuer_adapter_test.go new file mode 100644 index 0000000..dfe2fff --- /dev/null +++ b/internal/service/issuer_adapter_test.go @@ -0,0 +1,329 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/connector/issuer" +) + +// mockConnectorLayerIssuer is a test implementation of issuer.Connector +type mockConnectorLayerIssuer struct { + issueResult *issuer.IssuanceResult + issueErr error + renewResult *issuer.IssuanceResult + renewErr error + lastIssueReq *issuer.IssuanceRequest + lastRenewReq *issuer.RenewalRequest + validateErr error + revokeErr error + orderStatusErr error + orderStatus *issuer.OrderStatus +} + +func (m *mockConnectorLayerIssuer) ValidateConfig(ctx context.Context, config []byte) error { + return m.validateErr +} + +func (m *mockConnectorLayerIssuer) IssueCertificate(ctx context.Context, request issuer.IssuanceRequest) (*issuer.IssuanceResult, error) { + m.lastIssueReq = &request + if m.issueErr != nil { + return nil, m.issueErr + } + if m.issueResult != nil { + return m.issueResult, nil + } + // Return default result + now := time.Now() + return &issuer.IssuanceResult{ + CertPEM: "-----BEGIN CERTIFICATE-----\ndefault-cert\n-----END CERTIFICATE-----", + ChainPEM: "-----BEGIN CERTIFICATE-----\ndefault-chain\n-----END CERTIFICATE-----", + Serial: "default-serial-123", + NotBefore: now, + NotAfter: now.AddDate(1, 0, 0), + OrderID: "order-default", + }, nil +} + +func (m *mockConnectorLayerIssuer) RenewCertificate(ctx context.Context, request issuer.RenewalRequest) (*issuer.IssuanceResult, error) { + m.lastRenewReq = &request + if m.renewErr != nil { + return nil, m.renewErr + } + if m.renewResult != nil { + return m.renewResult, nil + } + // Return default result + now := time.Now() + return &issuer.IssuanceResult{ + CertPEM: "-----BEGIN CERTIFICATE-----\ndefault-renewed-cert\n-----END CERTIFICATE-----", + ChainPEM: "-----BEGIN CERTIFICATE-----\ndefault-renewed-chain\n-----END CERTIFICATE-----", + Serial: "default-renewed-serial-456", + NotBefore: now, + NotAfter: now.AddDate(1, 0, 0), + OrderID: "order-renewed", + }, nil +} + +func (m *mockConnectorLayerIssuer) RevokeCertificate(ctx context.Context, request issuer.RevocationRequest) error { + return m.revokeErr +} + +func (m *mockConnectorLayerIssuer) GetOrderStatus(ctx context.Context, orderID string) (*issuer.OrderStatus, error) { + if m.orderStatusErr != nil { + return nil, m.orderStatusErr + } + if m.orderStatus != nil { + return m.orderStatus, nil + } + status := "pending" + return &issuer.OrderStatus{ + OrderID: orderID, + Status: status, + UpdatedAt: time.Now(), + }, nil +} + +// Tests for IssueCertificate + +func TestIssuerConnectorAdapter_IssueCertificate_Success(t *testing.T) { + ctx := context.Background() + now := time.Now() + notAfter := now.AddDate(1, 0, 0) + + mock := &mockConnectorLayerIssuer{ + issueResult: &issuer.IssuanceResult{ + CertPEM: "-----BEGIN CERTIFICATE-----\ntest-cert\n-----END CERTIFICATE-----", + ChainPEM: "-----BEGIN CERTIFICATE-----\ntest-chain\n-----END CERTIFICATE-----", + Serial: "test-serial-001", + NotBefore: now, + NotAfter: notAfter, + OrderID: "order-123", + }, + } + + adapter := NewIssuerConnectorAdapter(mock) + + result, err := adapter.IssueCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----") + + if err != nil { + t.Fatalf("IssueCertificate failed: %v", err) + } + + if result.Serial != "test-serial-001" { + t.Errorf("expected serial test-serial-001, got %s", result.Serial) + } + + if result.CertPEM != "-----BEGIN CERTIFICATE-----\ntest-cert\n-----END CERTIFICATE-----" { + t.Errorf("expected CertPEM test-cert, got %s", result.CertPEM) + } + + if result.ChainPEM != "-----BEGIN CERTIFICATE-----\ntest-chain\n-----END CERTIFICATE-----" { + t.Errorf("expected ChainPEM test-chain, got %s", result.ChainPEM) + } + + if !result.NotBefore.Equal(now) { + t.Errorf("expected NotBefore %v, got %v", now, result.NotBefore) + } + + if !result.NotAfter.Equal(notAfter) { + t.Errorf("expected NotAfter %v, got %v", notAfter, result.NotAfter) + } +} + +func TestIssuerConnectorAdapter_IssueCertificate_Error(t *testing.T) { + ctx := context.Background() + testErr := errors.New("issuer connection failed") + + mock := &mockConnectorLayerIssuer{ + issueErr: testErr, + } + + adapter := NewIssuerConnectorAdapter(mock) + + result, err := adapter.IssueCertificate(ctx, "example.com", []string{}, "csr") + + if err == nil { + t.Fatal("expected error, got nil") + } + + if !errors.Is(err, testErr) { + t.Errorf("expected error %v, got %v", testErr, err) + } + + if result != nil { + t.Errorf("expected nil result, got %v", result) + } +} + +func TestIssuerConnectorAdapter_IssueCertificate_RequestTranslation(t *testing.T) { + ctx := context.Background() + + mock := &mockConnectorLayerIssuer{ + issueResult: &issuer.IssuanceResult{ + CertPEM: "cert", + ChainPEM: "chain", + Serial: "serial", + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(1, 0, 0), + }, + } + + adapter := NewIssuerConnectorAdapter(mock) + + commonName := "test.example.com" + sans := []string{"www.test.example.com", "api.test.example.com"} + csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----" + + _, err := adapter.IssueCertificate(ctx, commonName, sans, csrPEM) + + if err != nil { + t.Fatalf("IssueCertificate failed: %v", err) + } + + // Verify request was passed through correctly + if mock.lastIssueReq == nil { + t.Fatal("expected request to be recorded") + } + + if mock.lastIssueReq.CommonName != commonName { + t.Errorf("expected CommonName %s, got %s", commonName, mock.lastIssueReq.CommonName) + } + + if len(mock.lastIssueReq.SANs) != len(sans) { + t.Errorf("expected %d SANs, got %d", len(sans), len(mock.lastIssueReq.SANs)) + } + + for i, san := range sans { + if mock.lastIssueReq.SANs[i] != san { + t.Errorf("expected SAN[%d] %s, got %s", i, san, mock.lastIssueReq.SANs[i]) + } + } + + if mock.lastIssueReq.CSRPEM != csrPEM { + t.Errorf("expected CSRPEM %s, got %s", csrPEM, mock.lastIssueReq.CSRPEM) + } +} + +// Tests for RenewCertificate + +func TestIssuerConnectorAdapter_RenewCertificate_Success(t *testing.T) { + ctx := context.Background() + now := time.Now() + notAfter := now.AddDate(1, 0, 0) + + mock := &mockConnectorLayerIssuer{ + renewResult: &issuer.IssuanceResult{ + CertPEM: "-----BEGIN CERTIFICATE-----\nrenewed-cert\n-----END CERTIFICATE-----", + ChainPEM: "-----BEGIN CERTIFICATE-----\nrenewed-chain\n-----END CERTIFICATE-----", + Serial: "renewed-serial-002", + NotBefore: now, + NotAfter: notAfter, + OrderID: "order-456", + }, + } + + adapter := NewIssuerConnectorAdapter(mock) + + result, err := adapter.RenewCertificate(ctx, "example.com", []string{"www.example.com"}, "-----BEGIN CERTIFICATE REQUEST-----\nCSR\n-----END CERTIFICATE REQUEST-----") + + if err != nil { + t.Fatalf("RenewCertificate failed: %v", err) + } + + if result.Serial != "renewed-serial-002" { + t.Errorf("expected serial renewed-serial-002, got %s", result.Serial) + } + + if result.CertPEM != "-----BEGIN CERTIFICATE-----\nrenewed-cert\n-----END CERTIFICATE-----" { + t.Errorf("expected CertPEM renewed-cert, got %s", result.CertPEM) + } + + if result.ChainPEM != "-----BEGIN CERTIFICATE-----\nrenewed-chain\n-----END CERTIFICATE-----" { + t.Errorf("expected ChainPEM renewed-chain, got %s", result.ChainPEM) + } + + if !result.NotBefore.Equal(now) { + t.Errorf("expected NotBefore %v, got %v", now, result.NotBefore) + } + + if !result.NotAfter.Equal(notAfter) { + t.Errorf("expected NotAfter %v, got %v", notAfter, result.NotAfter) + } +} + +func TestIssuerConnectorAdapter_RenewCertificate_Error(t *testing.T) { + ctx := context.Background() + testErr := errors.New("renewal failed") + + mock := &mockConnectorLayerIssuer{ + renewErr: testErr, + } + + adapter := NewIssuerConnectorAdapter(mock) + + result, err := adapter.RenewCertificate(ctx, "example.com", []string{}, "csr") + + if err == nil { + t.Fatal("expected error, got nil") + } + + if !errors.Is(err, testErr) { + t.Errorf("expected error %v, got %v", testErr, err) + } + + if result != nil { + t.Errorf("expected nil result, got %v", result) + } +} + +func TestIssuerConnectorAdapter_RenewCertificate_RequestTranslation(t *testing.T) { + ctx := context.Background() + + mock := &mockConnectorLayerIssuer{ + renewResult: &issuer.IssuanceResult{ + CertPEM: "cert", + ChainPEM: "chain", + Serial: "serial", + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(1, 0, 0), + }, + } + + adapter := NewIssuerConnectorAdapter(mock) + + commonName := "renew.example.com" + sans := []string{"www.renew.example.com"} + csrPEM := "-----BEGIN CERTIFICATE REQUEST-----\nRENEW-CSR\n-----END CERTIFICATE REQUEST-----" + + _, err := adapter.RenewCertificate(ctx, commonName, sans, csrPEM) + + if err != nil { + t.Fatalf("RenewCertificate failed: %v", err) + } + + // Verify request was passed through correctly + if mock.lastRenewReq == nil { + t.Fatal("expected request to be recorded") + } + + if mock.lastRenewReq.CommonName != commonName { + t.Errorf("expected CommonName %s, got %s", commonName, mock.lastRenewReq.CommonName) + } + + if len(mock.lastRenewReq.SANs) != len(sans) { + t.Errorf("expected %d SANs, got %d", len(sans), len(mock.lastRenewReq.SANs)) + } + + for i, san := range sans { + if mock.lastRenewReq.SANs[i] != san { + t.Errorf("expected SAN[%d] %s, got %s", i, san, mock.lastRenewReq.SANs[i]) + } + } + + if mock.lastRenewReq.CSRPEM != csrPEM { + t.Errorf("expected CSRPEM %s, got %s", csrPEM, mock.lastRenewReq.CSRPEM) + } +} diff --git a/internal/service/issuer_test.go b/internal/service/issuer_test.go new file mode 100644 index 0000000..51b0402 --- /dev/null +++ b/internal/service/issuer_test.go @@ -0,0 +1,601 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/domain" +) + +// TestIssuerService_List tests listing issuers with pagination +func TestIssuerService_List(t *testing.T) { + ctx := context.Background() + + issuer1 := &domain.Issuer{ + ID: "iss-1", + Name: "ACME Provider", + Type: domain.IssuerTypeACME, + Enabled: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + issuer2 := &domain.Issuer{ + ID: "iss-2", + Name: "Step CA", + Type: domain.IssuerTypeStepCA, + Enabled: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + issuer3 := &domain.Issuer{ + ID: "iss-3", + Name: "Internal CA", + Type: domain.IssuerTypeGenericCA, + Enabled: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo := newMockIssuerRepository() + repo.AddIssuer(issuer1) + repo.AddIssuer(issuer2) + repo.AddIssuer(issuer3) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + issuers, total, err := service.List(ctx, 1, 2) + + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if total != 3 { + t.Errorf("expected total 3, got %d", total) + } + + if len(issuers) != 2 { + t.Errorf("expected 2 issuers on page 1, got %d", len(issuers)) + } + + // Test page 2 + issuers2, _, err := service.List(ctx, 2, 2) + + if err != nil { + t.Fatalf("List page 2 failed: %v", err) + } + + if len(issuers2) != 1 { + t.Errorf("expected 1 issuer on page 2, got %d", len(issuers2)) + } +} + +// TestIssuerService_List_DefaultPagination tests list with default pagination values +func TestIssuerService_List_DefaultPagination(t *testing.T) { + ctx := context.Background() + + repo := newMockIssuerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + // Call with invalid page and perPage + issuers, total, err := service.List(ctx, 0, 0) + + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if total != 0 { + t.Errorf("expected total 0, got %d", total) + } + + if len(issuers) != 0 { + t.Errorf("expected 0 issuers, got %d", len(issuers)) + } +} + +// TestIssuerService_List_RepositoryError tests list when repository returns error +func TestIssuerService_List_RepositoryError(t *testing.T) { + ctx := context.Background() + + repo := newMockIssuerRepository() + repo.ListErr = errors.New("database connection failed") + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + _, _, err := service.List(ctx, 1, 50) + + if err == nil { + t.Fatal("expected error, got nil") + } + + if !errors.Is(err, repo.ListErr) { + t.Errorf("expected error %v, got %v", repo.ListErr, err) + } +} + +// TestIssuerService_List_EmptyResult tests list returning empty list +func TestIssuerService_List_EmptyResult(t *testing.T) { + ctx := context.Background() + + repo := newMockIssuerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + issuers, total, err := service.List(ctx, 1, 50) + + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if total != 0 { + t.Errorf("expected total 0, got %d", total) + } + + if len(issuers) != 0 { + t.Errorf("expected 0 issuers, got %d", len(issuers)) + } +} + +// TestIssuerService_Get tests retrieving an issuer by ID +func TestIssuerService_Get(t *testing.T) { + ctx := context.Background() + + issuer := &domain.Issuer{ + ID: "iss-acme-prod", + Name: "ACME Production", + Type: domain.IssuerTypeACME, + Enabled: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo := newMockIssuerRepository() + repo.AddIssuer(issuer) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + retrieved, err := service.Get(ctx, "iss-acme-prod") + + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if retrieved.Name != "ACME Production" { + t.Errorf("expected name ACME Production, got %s", retrieved.Name) + } + + if retrieved.Type != domain.IssuerTypeACME { + t.Errorf("expected type ACME, got %s", retrieved.Type) + } +} + +// TestIssuerService_Get_NotFound tests Get when issuer doesn't exist +func TestIssuerService_Get_NotFound(t *testing.T) { + ctx := context.Background() + + repo := newMockIssuerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + _, err := service.Get(ctx, "nonexistent-issuer") + + if err == nil { + t.Fatal("expected error for nonexistent issuer") + } +} + +// TestIssuerService_Create tests creating a new issuer +func TestIssuerService_Create(t *testing.T) { + ctx := context.Background() + + repo := newMockIssuerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + config := map[string]interface{}{"endpoint": "https://acme.example.com/v2/new-account"} + configJSON, _ := json.Marshal(config) + + issuer := &domain.Issuer{ + Name: "Test ACME", + Type: domain.IssuerTypeACME, + Config: configJSON, + Enabled: true, + } + + err := service.Create(ctx, issuer, "user-alice") + + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if issuer.ID == "" { + t.Error("expected ID to be generated") + } + + if issuer.CreatedAt.IsZero() { + t.Error("expected CreatedAt to be set") + } + + if issuer.UpdatedAt.IsZero() { + t.Error("expected UpdatedAt to be set") + } + + // Verify stored in repo + retrieved, err := repo.Get(ctx, issuer.ID) + if err != nil { + t.Fatalf("failed to retrieve created issuer: %v", err) + } + + if retrieved.Name != "Test ACME" { + t.Errorf("expected name Test ACME, got %s", retrieved.Name) + } + + // Verify audit event recorded + if len(auditRepo.Events) != 1 { + t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events)) + } + + if auditRepo.Events[0].Action != "create_issuer" { + t.Errorf("expected action create_issuer, got %s", auditRepo.Events[0].Action) + } + + if auditRepo.Events[0].Actor != "user-alice" { + t.Errorf("expected actor user-alice, got %s", auditRepo.Events[0].Actor) + } +} + +// TestIssuerService_Create_EmptyName tests Create with empty name validation +func TestIssuerService_Create_EmptyName(t *testing.T) { + ctx := context.Background() + + repo := newMockIssuerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + issuer := &domain.Issuer{ + Name: "", + Type: domain.IssuerTypeACME, + Enabled: true, + } + + err := service.Create(ctx, issuer, "user-bob") + + if err == nil { + t.Fatal("expected error for empty name") + } + + if err.Error() != "issuer name is required" { + t.Errorf("expected 'issuer name is required', got '%v'", err) + } + + // Verify no audit event recorded on validation error + if len(auditRepo.Events) != 0 { + t.Errorf("expected 0 audit events on validation error, got %d", len(auditRepo.Events)) + } +} + +// TestIssuerService_Create_RepositoryError tests Create when repository fails +func TestIssuerService_Create_RepositoryError(t *testing.T) { + ctx := context.Background() + + repo := newMockIssuerRepository() + repo.CreateErr = errors.New("database error") + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + issuer := &domain.Issuer{ + Name: "Test Issuer", + Type: domain.IssuerTypeACME, + Enabled: true, + } + + err := service.Create(ctx, issuer, "user-charlie") + + if err == nil { + t.Fatal("expected error from repository") + } + + if !errors.Is(err, repo.CreateErr) { + t.Errorf("expected error %v, got %v", repo.CreateErr, err) + } +} + +// TestIssuerService_Update tests updating an existing issuer +func TestIssuerService_Update(t *testing.T) { + ctx := context.Background() + + repo := newMockIssuerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + config := map[string]interface{}{"endpoint": "https://acme.example.com"} + configJSON, _ := json.Marshal(config) + + issuer := &domain.Issuer{ + Name: "Updated ACME", + Type: domain.IssuerTypeACME, + Config: configJSON, + Enabled: false, + } + + err := service.Update(ctx, "iss-acme-001", issuer, "user-dave") + + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + if issuer.ID != "iss-acme-001" { + t.Errorf("expected ID to be set to iss-acme-001, got %s", issuer.ID) + } + + // Verify audit event recorded + if len(auditRepo.Events) != 1 { + t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events)) + } + + if auditRepo.Events[0].Action != "update_issuer" { + t.Errorf("expected action update_issuer, got %s", auditRepo.Events[0].Action) + } + + if auditRepo.Events[0].ResourceID != "iss-acme-001" { + t.Errorf("expected ResourceID iss-acme-001, got %s", auditRepo.Events[0].ResourceID) + } +} + +// TestIssuerService_Update_EmptyName tests Update with empty name validation +func TestIssuerService_Update_EmptyName(t *testing.T) { + ctx := context.Background() + + repo := newMockIssuerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + issuer := &domain.Issuer{ + Name: "", + Type: domain.IssuerTypeACME, + Enabled: true, + } + + err := service.Update(ctx, "iss-acme-001", issuer, "user-eve") + + if err == nil { + t.Fatal("expected error for empty name") + } + + if err.Error() != "issuer name is required" { + t.Errorf("expected 'issuer name is required', got '%v'", err) + } +} + +// TestIssuerService_Delete tests deleting an issuer +func TestIssuerService_Delete(t *testing.T) { + ctx := context.Background() + + repo := newMockIssuerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + err := service.Delete(ctx, "iss-to-delete", "user-frank") + + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + // Verify audit event recorded + if len(auditRepo.Events) != 1 { + t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events)) + } + + if auditRepo.Events[0].Action != "delete_issuer" { + t.Errorf("expected action delete_issuer, got %s", auditRepo.Events[0].Action) + } + + if auditRepo.Events[0].ResourceID != "iss-to-delete" { + t.Errorf("expected ResourceID iss-to-delete, got %s", auditRepo.Events[0].ResourceID) + } +} + +// TestIssuerService_Delete_RepositoryError tests Delete when repository fails +func TestIssuerService_Delete_RepositoryError(t *testing.T) { + ctx := context.Background() + + repo := newMockIssuerRepository() + repo.DeleteErr = errors.New("delete failed") + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + err := service.Delete(ctx, "iss-bad-id", "user-grace") + + if err == nil { + t.Fatal("expected error from repository") + } + + if !errors.Is(err, repo.DeleteErr) { + t.Errorf("expected error %v, got %v", repo.DeleteErr, err) + } +} + +// TestIssuerService_TestConnection_Success tests successful connection test +func TestIssuerService_TestConnection_Success(t *testing.T) { + ctx := context.Background() + + issuer := &domain.Issuer{ + ID: "iss-test-conn", + Name: "Test Connection", + Type: domain.IssuerTypeACME, + Enabled: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo := newMockIssuerRepository() + repo.AddIssuer(issuer) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + err := service.TestConnectionWithContext(ctx, "iss-test-conn") + + if err != nil { + t.Fatalf("TestConnectionWithContext failed: %v", err) + } +} + +// TestIssuerService_TestConnection_NotFound tests connection test when issuer not found +func TestIssuerService_TestConnection_NotFound(t *testing.T) { + ctx := context.Background() + + repo := newMockIssuerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + err := service.TestConnectionWithContext(ctx, "nonexistent-issuer") + + if err == nil { + t.Fatal("expected error for nonexistent issuer") + } + + if !errors.Is(err, errNotFound) { + t.Errorf("expected not found error, got %v", err) + } +} + +// TestIssuerService_ListIssuers_HandlerInterface tests handler interface method +func TestIssuerService_ListIssuers_HandlerInterface(t *testing.T) { + issuer1 := &domain.Issuer{ + ID: "iss-handler-1", + Name: "Handler Test 1", + Type: domain.IssuerTypeACME, + Enabled: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + issuer2 := &domain.Issuer{ + ID: "iss-handler-2", + Name: "Handler Test 2", + Type: domain.IssuerTypeStepCA, + Enabled: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + repo := newMockIssuerRepository() + repo.AddIssuer(issuer1) + repo.AddIssuer(issuer2) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + issuers, total, err := service.ListIssuers(1, 50) + + if err != nil { + t.Fatalf("ListIssuers failed: %v", err) + } + + if total != 2 { + t.Errorf("expected total 2, got %d", total) + } + + if len(issuers) != 2 { + t.Errorf("expected 2 issuers, got %d", len(issuers)) + } + + if issuers[0].Name != "Handler Test 1" && issuers[1].Name != "Handler Test 1" { + t.Error("expected to find Handler Test 1 in results") + } +} + +// TestIssuerService_CreateIssuer_HandlerInterface tests handler interface create method +func TestIssuerService_CreateIssuer_HandlerInterface(t *testing.T) { + repo := newMockIssuerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + config := map[string]interface{}{"url": "https://example.com"} + configJSON, _ := json.Marshal(config) + + issuer := domain.Issuer{ + Name: "Handler Create Test", + Type: domain.IssuerTypeGenericCA, + Config: configJSON, + Enabled: true, + } + + result, err := service.CreateIssuer(issuer) + + if err != nil { + t.Fatalf("CreateIssuer failed: %v", err) + } + + if result == nil { + t.Fatal("expected non-nil result") + } + + if result.ID == "" { + t.Error("expected ID to be generated") + } + + if result.Name != "Handler Create Test" { + t.Errorf("expected name Handler Create Test, got %s", result.Name) + } +} + +// TestIssuerService_DeleteIssuer_HandlerInterface tests handler interface delete method +func TestIssuerService_DeleteIssuer_HandlerInterface(t *testing.T) { + repo := newMockIssuerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + service := NewIssuerService(repo, auditService) + + err := service.DeleteIssuer("iss-handler-delete") + + if err != nil { + t.Fatalf("DeleteIssuer failed: %v", err) + } +} diff --git a/internal/service/owner_test.go b/internal/service/owner_test.go new file mode 100644 index 0000000..e5e886a --- /dev/null +++ b/internal/service/owner_test.go @@ -0,0 +1,814 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/domain" +) + +// mockOwnerRepo is a test implementation of OwnerRepository +type mockOwnerRepo struct { + owners map[string]*domain.Owner + CreateErr error + UpdateErr error + DeleteErr error + GetErr error + ListErr error +} + +func (m *mockOwnerRepo) List(ctx context.Context) ([]*domain.Owner, error) { + if m.ListErr != nil { + return nil, m.ListErr + } + var owners []*domain.Owner + for _, o := range m.owners { + owners = append(owners, o) + } + return owners, nil +} + +func (m *mockOwnerRepo) Get(ctx context.Context, id string) (*domain.Owner, error) { + if m.GetErr != nil { + return nil, m.GetErr + } + owner, ok := m.owners[id] + if !ok { + return nil, errNotFound + } + return owner, nil +} + +func (m *mockOwnerRepo) Create(ctx context.Context, owner *domain.Owner) error { + if m.CreateErr != nil { + return m.CreateErr + } + m.owners[owner.ID] = owner + return nil +} + +func (m *mockOwnerRepo) Update(ctx context.Context, owner *domain.Owner) error { + if m.UpdateErr != nil { + return m.UpdateErr + } + m.owners[owner.ID] = owner + return nil +} + +func (m *mockOwnerRepo) Delete(ctx context.Context, id string) error { + if m.DeleteErr != nil { + return m.DeleteErr + } + delete(m.owners, id) + return nil +} + +func (m *mockOwnerRepo) AddOwner(owner *domain.Owner) { + m.owners[owner.ID] = owner +} + +func newMockOwnerRepository() *mockOwnerRepo { + return &mockOwnerRepo{ + owners: make(map[string]*domain.Owner), + } +} + +// TestOwnerService_List tests paginated listing of owners. +func TestOwnerService_List(t *testing.T) { + ctx := context.Background() + now := time.Now() + + owner1 := &domain.Owner{ + ID: "owner-001", + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + CreatedAt: now, + UpdatedAt: now, + } + owner2 := &domain.Owner{ + ID: "owner-002", + Name: "Bob Jones", + Email: "bob@example.com", + TeamID: "team-001", + CreatedAt: now, + UpdatedAt: now, + } + + ownerRepo := newMockOwnerRepository() + ownerRepo.AddOwner(owner1) + ownerRepo.AddOwner(owner2) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + owners, total, err := ownerService.List(ctx, 1, 50) + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(owners) != 2 { + t.Errorf("expected 2 owners, got %d", len(owners)) + } + + if total != 2 { + t.Errorf("expected total 2, got %d", total) + } +} + +// TestOwnerService_List_DefaultPagination tests that default pagination values are applied. +func TestOwnerService_List_DefaultPagination(t *testing.T) { + ctx := context.Background() + now := time.Now() + + owner := &domain.Owner{ + ID: "owner-001", + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + CreatedAt: now, + UpdatedAt: now, + } + + ownerRepo := newMockOwnerRepository() + ownerRepo.AddOwner(owner) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + // Test with page < 1 (should default to 1) + owners, total, err := ownerService.List(ctx, 0, 0) + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(owners) != 1 { + t.Errorf("expected 1 owner with default pagination, got %d", len(owners)) + } + + if total != 1 { + t.Errorf("expected total 1, got %d", total) + } +} + +// TestOwnerService_List_RepositoryError tests handling of repository errors. +func TestOwnerService_List_RepositoryError(t *testing.T) { + ctx := context.Background() + + ownerRepo := newMockOwnerRepository() + ownerRepo.ListErr = errors.New("database error") + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + _, _, err := ownerService.List(ctx, 1, 50) + if err == nil { + t.Fatal("expected error from List, got nil") + } +} + +// TestOwnerService_List_EmptyResult tests listing with no owners. +func TestOwnerService_List_EmptyResult(t *testing.T) { + ctx := context.Background() + + ownerRepo := newMockOwnerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + owners, total, err := ownerService.List(ctx, 1, 50) + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(owners) != 0 { + t.Errorf("expected 0 owners, got %d", len(owners)) + } + + if total != 0 { + t.Errorf("expected total 0, got %d", total) + } +} + +// TestOwnerService_List_PageBeyondRange tests pagination when page exceeds available data. +func TestOwnerService_List_PageBeyondRange(t *testing.T) { + ctx := context.Background() + now := time.Now() + + owner := &domain.Owner{ + ID: "owner-001", + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + CreatedAt: now, + UpdatedAt: now, + } + + ownerRepo := newMockOwnerRepository() + ownerRepo.AddOwner(owner) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + // Request page 3 with only 1 owner + owners, total, err := ownerService.List(ctx, 3, 1) + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(owners) != 0 { + t.Errorf("expected 0 owners on page beyond range, got %d", len(owners)) + } + + if total != 1 { + t.Errorf("expected total 1, got %d", total) + } +} + +// TestOwnerService_Get tests retrieving a single owner by ID. +func TestOwnerService_Get(t *testing.T) { + ctx := context.Background() + now := time.Now() + + owner := &domain.Owner{ + ID: "owner-001", + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + CreatedAt: now, + UpdatedAt: now, + } + + ownerRepo := newMockOwnerRepository() + ownerRepo.AddOwner(owner) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + retrieved, err := ownerService.Get(ctx, "owner-001") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if retrieved.Name != "Alice Smith" { + t.Errorf("expected name Alice Smith, got %s", retrieved.Name) + } + + if retrieved.Email != "alice@example.com" { + t.Errorf("expected email alice@example.com, got %s", retrieved.Email) + } +} + +// TestOwnerService_Get_NotFound tests Get with a nonexistent owner. +func TestOwnerService_Get_NotFound(t *testing.T) { + ctx := context.Background() + + ownerRepo := newMockOwnerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + _, err := ownerService.Get(ctx, "nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent owner, got nil") + } +} + +// TestOwnerService_Create tests creating a new owner with audit recording. +func TestOwnerService_Create(t *testing.T) { + ctx := context.Background() + + ownerRepo := newMockOwnerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + owner := &domain.Owner{ + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + } + + err := ownerService.Create(ctx, owner, "user-1") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if owner.ID == "" { + t.Fatal("expected non-empty owner ID after creation") + } + + if !owner.CreatedAt.IsZero() && owner.CreatedAt.After(time.Now().Add(-time.Second)) { + // CreatedAt should have been set + } else if owner.CreatedAt.IsZero() { + t.Fatal("expected CreatedAt to be set") + } + + if len(ownerRepo.owners) != 1 { + t.Errorf("expected 1 owner in repo, got %d", len(ownerRepo.owners)) + } + + if len(auditRepo.Events) != 1 { + t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events)) + } + + auditEvent := auditRepo.Events[0] + if auditEvent.Action != "create_owner" { + t.Errorf("expected action create_owner, got %s", auditEvent.Action) + } + + if auditEvent.ResourceType != "owner" { + t.Errorf("expected resource type owner, got %s", auditEvent.ResourceType) + } +} + +// TestOwnerService_Create_EmptyName tests that Create rejects empty name. +func TestOwnerService_Create_EmptyName(t *testing.T) { + ctx := context.Background() + + ownerRepo := newMockOwnerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + owner := &domain.Owner{ + Name: "", + Email: "alice@example.com", + TeamID: "team-001", + } + + err := ownerService.Create(ctx, owner, "user-1") + if err == nil { + t.Fatal("expected error for empty owner name") + } + + if len(ownerRepo.owners) != 0 { + t.Errorf("expected 0 owners in repo after validation failure, got %d", len(ownerRepo.owners)) + } + + if len(auditRepo.Events) != 0 { + t.Errorf("expected 0 audit events after validation failure, got %d", len(auditRepo.Events)) + } +} + +// TestOwnerService_Create_WithExistingID tests that Create preserves existing ID. +func TestOwnerService_Create_WithExistingID(t *testing.T) { + ctx := context.Background() + + ownerRepo := newMockOwnerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + owner := &domain.Owner{ + ID: "custom-id-123", + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + } + + err := ownerService.Create(ctx, owner, "user-1") + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if owner.ID != "custom-id-123" { + t.Errorf("expected ID custom-id-123, got %s", owner.ID) + } + + stored, ok := ownerRepo.owners["custom-id-123"] + if !ok { + t.Fatal("expected owner with custom ID in repo") + } + + if stored.Name != "Alice Smith" { + t.Errorf("expected name Alice Smith, got %s", stored.Name) + } +} + +// TestOwnerService_Create_RepositoryError tests Create with repository failure. +func TestOwnerService_Create_RepositoryError(t *testing.T) { + ctx := context.Background() + + ownerRepo := newMockOwnerRepository() + ownerRepo.CreateErr = errors.New("database error") + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + owner := &domain.Owner{ + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + } + + err := ownerService.Create(ctx, owner, "user-1") + if err == nil { + t.Fatal("expected error from Create") + } +} + +// TestOwnerService_Update tests updating an existing owner. +func TestOwnerService_Update(t *testing.T) { + ctx := context.Background() + now := time.Now() + + originalOwner := &domain.Owner{ + ID: "owner-001", + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + CreatedAt: now, + UpdatedAt: now, + } + + ownerRepo := newMockOwnerRepository() + ownerRepo.AddOwner(originalOwner) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + updatedOwner := &domain.Owner{ + Name: "Alice Johnson", + Email: "alice.j@example.com", + TeamID: "team-002", + } + + err := ownerService.Update(ctx, "owner-001", updatedOwner, "user-1") + if err != nil { + t.Fatalf("Update failed: %v", err) + } + + stored := ownerRepo.owners["owner-001"] + if stored.Name != "Alice Johnson" { + t.Errorf("expected updated name Alice Johnson, got %s", stored.Name) + } + + if stored.Email != "alice.j@example.com" { + t.Errorf("expected updated email alice.j@example.com, got %s", stored.Email) + } + + if stored.ID != "owner-001" { + t.Errorf("expected ID to remain owner-001, got %s", stored.ID) + } + + if len(auditRepo.Events) != 1 { + t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events)) + } + + auditEvent := auditRepo.Events[0] + if auditEvent.Action != "update_owner" { + t.Errorf("expected action update_owner, got %s", auditEvent.Action) + } +} + +// TestOwnerService_Update_EmptyName tests that Update rejects empty name. +func TestOwnerService_Update_EmptyName(t *testing.T) { + ctx := context.Background() + now := time.Now() + + originalOwner := &domain.Owner{ + ID: "owner-001", + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + CreatedAt: now, + UpdatedAt: now, + } + + ownerRepo := newMockOwnerRepository() + ownerRepo.AddOwner(originalOwner) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + updatedOwner := &domain.Owner{ + Name: "", + Email: "alice.j@example.com", + TeamID: "team-002", + } + + err := ownerService.Update(ctx, "owner-001", updatedOwner, "user-1") + if err == nil { + t.Fatal("expected error for empty owner name") + } + + if len(auditRepo.Events) != 0 { + t.Errorf("expected 0 audit events after validation failure, got %d", len(auditRepo.Events)) + } +} + +// TestOwnerService_Update_RepositoryError tests Update with repository failure. +func TestOwnerService_Update_RepositoryError(t *testing.T) { + ctx := context.Background() + + ownerRepo := newMockOwnerRepository() + ownerRepo.UpdateErr = errors.New("database error") + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + updatedOwner := &domain.Owner{ + Name: "Alice Johnson", + Email: "alice.j@example.com", + TeamID: "team-002", + } + + err := ownerService.Update(ctx, "owner-001", updatedOwner, "user-1") + if err == nil { + t.Fatal("expected error from Update") + } +} + +// TestOwnerService_Delete tests deleting an owner with audit recording. +func TestOwnerService_Delete(t *testing.T) { + ctx := context.Background() + now := time.Now() + + owner := &domain.Owner{ + ID: "owner-001", + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + CreatedAt: now, + UpdatedAt: now, + } + + ownerRepo := newMockOwnerRepository() + ownerRepo.AddOwner(owner) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + err := ownerService.Delete(ctx, "owner-001", "user-1") + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + if len(ownerRepo.owners) != 0 { + t.Errorf("expected 0 owners in repo after delete, got %d", len(ownerRepo.owners)) + } + + if len(auditRepo.Events) != 1 { + t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events)) + } + + auditEvent := auditRepo.Events[0] + if auditEvent.Action != "delete_owner" { + t.Errorf("expected action delete_owner, got %s", auditEvent.Action) + } + + if auditEvent.ResourceID != "owner-001" { + t.Errorf("expected resource ID owner-001, got %s", auditEvent.ResourceID) + } +} + +// TestOwnerService_Delete_RepositoryError tests Delete with repository failure. +func TestOwnerService_Delete_RepositoryError(t *testing.T) { + ctx := context.Background() + + ownerRepo := newMockOwnerRepository() + ownerRepo.DeleteErr = errors.New("database error") + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + err := ownerService.Delete(ctx, "owner-001", "user-1") + if err == nil { + t.Fatal("expected error from Delete") + } +} + +// TestOwnerService_ListOwners_HandlerInterface tests the handler interface method ListOwners. +func TestOwnerService_ListOwners_HandlerInterface(t *testing.T) { + now := time.Now() + + owner1 := &domain.Owner{ + ID: "owner-001", + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + CreatedAt: now, + UpdatedAt: now, + } + owner2 := &domain.Owner{ + ID: "owner-002", + Name: "Bob Jones", + Email: "bob@example.com", + TeamID: "team-001", + CreatedAt: now, + UpdatedAt: now, + } + + ownerRepo := newMockOwnerRepository() + ownerRepo.AddOwner(owner1) + ownerRepo.AddOwner(owner2) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + owners, total, err := ownerService.ListOwners(1, 50) + if err != nil { + t.Fatalf("ListOwners failed: %v", err) + } + + if len(owners) != 2 { + t.Errorf("expected 2 owners, got %d", len(owners)) + } + + if total != 2 { + t.Errorf("expected total 2, got %d", total) + } + + // Verify value type conversion worked + if owners[0].ID == "" { + t.Fatal("expected non-empty owner ID in result") + } +} + +// TestOwnerService_GetOwner_HandlerInterface tests the handler interface method GetOwner. +func TestOwnerService_GetOwner_HandlerInterface(t *testing.T) { + now := time.Now() + + owner := &domain.Owner{ + ID: "owner-001", + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + CreatedAt: now, + UpdatedAt: now, + } + + ownerRepo := newMockOwnerRepository() + ownerRepo.AddOwner(owner) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + retrieved, err := ownerService.GetOwner("owner-001") + if err != nil { + t.Fatalf("GetOwner failed: %v", err) + } + + if retrieved.Name != "Alice Smith" { + t.Errorf("expected name Alice Smith, got %s", retrieved.Name) + } +} + +// TestOwnerService_CreateOwner_HandlerInterface tests the handler interface method CreateOwner. +func TestOwnerService_CreateOwner_HandlerInterface(t *testing.T) { + ownerRepo := newMockOwnerRepository() + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + owner := domain.Owner{ + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + } + + created, err := ownerService.CreateOwner(owner) + if err != nil { + t.Fatalf("CreateOwner failed: %v", err) + } + + if created.ID == "" { + t.Fatal("expected non-empty owner ID after creation") + } + + if created.Name != "Alice Smith" { + t.Errorf("expected name Alice Smith, got %s", created.Name) + } + + if len(ownerRepo.owners) != 1 { + t.Errorf("expected 1 owner in repo, got %d", len(ownerRepo.owners)) + } + + // Note: handler interface method does NOT record audit events (no actor parameter) + if len(auditRepo.Events) != 0 { + t.Errorf("expected 0 audit events from handler interface method, got %d", len(auditRepo.Events)) + } +} + +// TestOwnerService_UpdateOwner_HandlerInterface tests the handler interface method UpdateOwner. +func TestOwnerService_UpdateOwner_HandlerInterface(t *testing.T) { + now := time.Now() + + originalOwner := &domain.Owner{ + ID: "owner-001", + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + CreatedAt: now, + UpdatedAt: now, + } + + ownerRepo := newMockOwnerRepository() + ownerRepo.AddOwner(originalOwner) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + updatedOwner := domain.Owner{ + Name: "Alice Johnson", + Email: "alice.j@example.com", + TeamID: "team-002", + } + + updated, err := ownerService.UpdateOwner("owner-001", updatedOwner) + if err != nil { + t.Fatalf("UpdateOwner failed: %v", err) + } + + if updated.ID != "owner-001" { + t.Errorf("expected ID owner-001, got %s", updated.ID) + } + + if updated.Name != "Alice Johnson" { + t.Errorf("expected updated name Alice Johnson, got %s", updated.Name) + } + + // Verify in repo + stored := ownerRepo.owners["owner-001"] + if stored.Email != "alice.j@example.com" { + t.Errorf("expected updated email alice.j@example.com, got %s", stored.Email) + } + + // Note: handler interface method does NOT record audit events + if len(auditRepo.Events) != 0 { + t.Errorf("expected 0 audit events from handler interface method, got %d", len(auditRepo.Events)) + } +} + +// TestOwnerService_DeleteOwner_HandlerInterface tests the handler interface method DeleteOwner. +func TestOwnerService_DeleteOwner_HandlerInterface(t *testing.T) { + now := time.Now() + + owner := &domain.Owner{ + ID: "owner-001", + Name: "Alice Smith", + Email: "alice@example.com", + TeamID: "team-001", + CreatedAt: now, + UpdatedAt: now, + } + + ownerRepo := newMockOwnerRepository() + ownerRepo.AddOwner(owner) + + auditRepo := newMockAuditRepository() + auditService := NewAuditService(auditRepo) + + ownerService := NewOwnerService(ownerRepo, auditService) + + err := ownerService.DeleteOwner("owner-001") + if err != nil { + t.Fatalf("DeleteOwner failed: %v", err) + } + + if len(ownerRepo.owners) != 0 { + t.Errorf("expected 0 owners in repo after delete, got %d", len(ownerRepo.owners)) + } + + // Note: handler interface method does NOT record audit events + if len(auditRepo.Events) != 0 { + t.Errorf("expected 0 audit events from handler interface method, got %d", len(auditRepo.Events)) + } +} diff --git a/internal/service/team_test.go b/internal/service/team_test.go new file mode 100644 index 0000000..957b28d --- /dev/null +++ b/internal/service/team_test.go @@ -0,0 +1,691 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/shankar0123/certctl/internal/domain" + "github.com/shankar0123/certctl/internal/repository" +) + +// mockTeamRepo is a test implementation of TeamRepository +type mockTeamRepo struct { + teams map[string]*domain.Team + CreateErr error + UpdateErr error + DeleteErr error + GetErr error + ListErr error +} + +func (m *mockTeamRepo) List(ctx context.Context) ([]*domain.Team, error) { + if m.ListErr != nil { + return nil, m.ListErr + } + var teams []*domain.Team + for _, t := range m.teams { + teams = append(teams, t) + } + return teams, nil +} + +func (m *mockTeamRepo) Get(ctx context.Context, id string) (*domain.Team, error) { + if m.GetErr != nil { + return nil, m.GetErr + } + team, ok := m.teams[id] + if !ok { + return nil, errNotFound + } + return team, nil +} + +func (m *mockTeamRepo) Create(ctx context.Context, team *domain.Team) error { + if m.CreateErr != nil { + return m.CreateErr + } + m.teams[team.ID] = team + return nil +} + +func (m *mockTeamRepo) Update(ctx context.Context, team *domain.Team) error { + if m.UpdateErr != nil { + return m.UpdateErr + } + m.teams[team.ID] = team + return nil +} + +func (m *mockTeamRepo) Delete(ctx context.Context, id string) error { + if m.DeleteErr != nil { + return m.DeleteErr + } + delete(m.teams, id) + return nil +} + +func (m *mockTeamRepo) AddTeam(team *domain.Team) { + m.teams[team.ID] = team +} + +func newMockTeamRepository() *mockTeamRepo { + return &mockTeamRepo{ + teams: make(map[string]*domain.Team), + } +} + +// TestTeamService_List tests retrieving teams with pagination +func TestTeamService_List(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + // Add test teams + for i := 0; i < 5; i++ { + mockTeamRepo.AddTeam(&domain.Team{ + ID: "team-" + string(rune(i)), + Name: "Team " + string(rune(48+i)), + }) + } + + teams, total, err := teamService.List(ctx, 1, 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if total != 5 { + t.Errorf("expected total 5, got %d", total) + } + + if len(teams) != 2 { + t.Errorf("expected 2 teams on page 1, got %d", len(teams)) + } +} + +// TestTeamService_List_DefaultPagination tests default pagination values +func TestTeamService_List_DefaultPagination(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + // Add test teams + for i := 0; i < 10; i++ { + mockTeamRepo.AddTeam(&domain.Team{ + ID: "team-" + string(rune(i)), + Name: "Team " + string(rune(48+i)), + }) + } + + // Test page < 1 defaults to 1 + teams, total, err := teamService.List(ctx, 0, 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if total != 10 { + t.Errorf("expected total 10, got %d", total) + } + + if len(teams) != 5 { + t.Errorf("expected 5 teams, got %d", len(teams)) + } + + // Test perPage < 1 defaults to 50 + teams, total, err = teamService.List(ctx, 1, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(teams) != 10 { + t.Errorf("expected 10 teams with perPage=50, got %d", len(teams)) + } +} + +// TestTeamService_List_RepositoryError tests error handling from repo +func TestTeamService_List_RepositoryError(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + mockTeamRepo.ListErr = errors.New("database error") + + _, _, err := teamService.List(ctx, 1, 50) + if err == nil { + t.Fatalf("expected error, got nil") + } + + if !errors.Is(err, errors.New("database error")) { + t.Errorf("expected database error, got %v", err) + } +} + +// TestTeamService_List_EmptyResult tests empty list response +func TestTeamService_List_EmptyResult(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + teams, total, err := teamService.List(ctx, 1, 50) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if total != 0 { + t.Errorf("expected total 0, got %d", total) + } + + if len(teams) != 0 { + t.Errorf("expected empty slice, got %d teams", len(teams)) + } +} + +// TestTeamService_List_PageBeyondRange tests pagination beyond available data +func TestTeamService_List_PageBeyondRange(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + // Add only 3 teams + for i := 0; i < 3; i++ { + mockTeamRepo.AddTeam(&domain.Team{ + ID: "team-" + string(rune(i)), + Name: "Team " + string(rune(48+i)), + }) + } + + // Request page beyond range + teams, total, err := teamService.List(ctx, 10, 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if total != 3 { + t.Errorf("expected total 3, got %d", total) + } + + if teams != nil && len(teams) != 0 { + t.Errorf("expected empty slice for page beyond range, got %d teams", len(teams)) + } +} + +// TestTeamService_Get tests retrieving a single team +func TestTeamService_Get(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + testTeam := &domain.Team{ + ID: "team-1", + Name: "Test Team", + } + mockTeamRepo.AddTeam(testTeam) + + team, err := teamService.Get(ctx, "team-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if team.ID != "team-1" || team.Name != "Test Team" { + t.Errorf("expected team-1/Test Team, got %s/%s", team.ID, team.Name) + } +} + +// TestTeamService_Get_NotFound tests retrieval of nonexistent team +func TestTeamService_Get_NotFound(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + _, err := teamService.Get(ctx, "nonexistent") + if err == nil { + t.Fatalf("expected error for nonexistent team, got nil") + } +} + +// TestTeamService_Create tests creating a new team +func TestTeamService_Create(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + team := &domain.Team{ + Name: "New Team", + Description: "A test team", + } + + err := teamService.Create(ctx, team, "test-user") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify ID was generated + if team.ID == "" { + t.Errorf("expected ID to be generated, got empty") + } + + if !team.ID[:5] == "team-" { + t.Logf("note: generated ID is %s", team.ID) + } + + // Verify timestamps were set + if team.CreatedAt.IsZero() { + t.Errorf("expected CreatedAt to be set") + } + + if team.UpdatedAt.IsZero() { + t.Errorf("expected UpdatedAt to be set") + } + + // Verify team was stored + stored, err := teamService.Get(ctx, team.ID) + if err != nil { + t.Fatalf("failed to retrieve created team: %v", err) + } + + if stored.Name != "New Team" { + t.Errorf("expected name 'New Team', got %s", stored.Name) + } +} + +// TestTeamService_Create_EmptyName tests validation on empty name +func TestTeamService_Create_EmptyName(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + team := &domain.Team{ + Name: "", + } + + err := teamService.Create(ctx, team, "test-user") + if err == nil { + t.Fatalf("expected validation error for empty name, got nil") + } + + if !errors.Is(err, errors.New("team name is required")) { + t.Logf("error: %v", err) + } +} + +// TestTeamService_Create_WithExistingID tests preserving provided ID +func TestTeamService_Create_WithExistingID(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + team := &domain.Team{ + ID: "custom-team-id", + Name: "Custom Team", + } + + err := teamService.Create(ctx, team, "test-user") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if team.ID != "custom-team-id" { + t.Errorf("expected ID to be preserved as custom-team-id, got %s", team.ID) + } +} + +// TestTeamService_Create_RepositoryError tests repo error handling +func TestTeamService_Create_RepositoryError(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + mockTeamRepo.CreateErr = errors.New("database insert failed") + + team := &domain.Team{ + Name: "Test Team", + } + + err := teamService.Create(ctx, team, "test-user") + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +// TestTeamService_Create_AuditRecorded tests audit event recording +func TestTeamService_Create_AuditRecorded(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + team := &domain.Team{ + ID: "audit-test-team", + Name: "Audit Test Team", + } + + err := teamService.Create(ctx, team, "audit-user") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify audit event was recorded + if len(mockAuditRepo.Events) != 1 { + t.Errorf("expected 1 audit event, got %d", len(mockAuditRepo.Events)) + } + + if mockAuditRepo.Events[0].Action != "create_team" { + t.Errorf("expected action 'create_team', got %s", mockAuditRepo.Events[0].Action) + } + + if mockAuditRepo.Events[0].ResourceID != "audit-test-team" { + t.Errorf("expected resource ID 'audit-test-team', got %s", mockAuditRepo.Events[0].ResourceID) + } +} + +// TestTeamService_Update tests updating an existing team +func TestTeamService_Update(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + // Create initial team + initialTeam := &domain.Team{ + ID: "team-update", + Name: "Original Name", + Description: "Original description", + } + mockTeamRepo.AddTeam(initialTeam) + + // Update team + updateTeam := &domain.Team{ + Name: "Updated Name", + Description: "Updated description", + } + + err := teamService.Update(ctx, "team-update", updateTeam, "update-user") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify ID was set correctly + if updateTeam.ID != "team-update" { + t.Errorf("expected ID to be set to team-update, got %s", updateTeam.ID) + } + + // Verify team was updated + updated, err := teamService.Get(ctx, "team-update") + if err != nil { + t.Fatalf("failed to retrieve updated team: %v", err) + } + + if updated.Name != "Updated Name" { + t.Errorf("expected name 'Updated Name', got %s", updated.Name) + } +} + +// TestTeamService_Update_EmptyName tests validation on update +func TestTeamService_Update_EmptyName(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + mockTeamRepo.AddTeam(&domain.Team{ + ID: "team-1", + Name: "Original", + }) + + updateTeam := &domain.Team{ + Name: "", + } + + err := teamService.Update(ctx, "team-1", updateTeam, "user") + if err == nil { + t.Fatalf("expected validation error for empty name, got nil") + } +} + +// TestTeamService_Update_RepositoryError tests repo error handling +func TestTeamService_Update_RepositoryError(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + mockTeamRepo.UpdateErr = errors.New("database update failed") + + updateTeam := &domain.Team{ + Name: "Updated", + } + + err := teamService.Update(ctx, "team-1", updateTeam, "user") + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +// TestTeamService_Delete tests deleting a team +func TestTeamService_Delete(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + // Create team to delete + mockTeamRepo.AddTeam(&domain.Team{ + ID: "team-delete", + Name: "Team to Delete", + }) + + err := teamService.Delete(ctx, "team-delete", "delete-user") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify team was deleted + _, err = teamService.Get(ctx, "team-delete") + if err == nil { + t.Errorf("expected error for deleted team, got nil") + } +} + +// TestTeamService_Delete_RepositoryError tests repo error handling +func TestTeamService_Delete_RepositoryError(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + mockTeamRepo.DeleteErr = errors.New("database delete failed") + + err := teamService.Delete(ctx, "team-1", "user") + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +// TestTeamService_ListTeams_HandlerInterface tests handler interface method +func TestTeamService_ListTeams_HandlerInterface(t *testing.T) { + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + // Add test teams + for i := 0; i < 3; i++ { + mockTeamRepo.AddTeam(&domain.Team{ + ID: "team-" + string(rune(i)), + Name: "Team " + string(rune(48+i)), + }) + } + + teams, total, err := teamService.ListTeams(1, 2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if total != 3 { + t.Errorf("expected total 3, got %d", total) + } + + if len(teams) != 3 { + t.Errorf("expected 3 teams (ListTeams doesn't paginate), got %d", len(teams)) + } +} + +// TestTeamService_GetTeam_HandlerInterface tests handler interface method +func TestTeamService_GetTeam_HandlerInterface(t *testing.T) { + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + testTeam := &domain.Team{ + ID: "handler-team", + Name: "Handler Test Team", + } + mockTeamRepo.AddTeam(testTeam) + + team, err := teamService.GetTeam("handler-team") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if team.ID != "handler-team" || team.Name != "Handler Test Team" { + t.Errorf("expected handler-team/Handler Test Team, got %s/%s", team.ID, team.Name) + } +} + +// TestTeamService_CreateTeam_HandlerInterface tests handler interface method +func TestTeamService_CreateTeam_HandlerInterface(t *testing.T) { + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + team := domain.Team{ + Name: "Handler Create Team", + Description: "Created via handler", + } + + result, err := teamService.CreateTeam(team) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.ID == "" { + t.Errorf("expected ID to be generated") + } + + if result.Name != "Handler Create Team" { + t.Errorf("expected name 'Handler Create Team', got %s", result.Name) + } + + if result.CreatedAt.IsZero() { + t.Errorf("expected CreatedAt to be set") + } +} + +// TestTeamService_UpdateTeam_HandlerInterface tests handler interface method +func TestTeamService_UpdateTeam_HandlerInterface(t *testing.T) { + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + // Create initial team + mockTeamRepo.AddTeam(&domain.Team{ + ID: "handler-update-team", + Name: "Original", + }) + + updateTeam := domain.Team{ + Name: "Updated via Handler", + Description: "Handler update", + } + + result, err := teamService.UpdateTeam("handler-update-team", updateTeam) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.ID != "handler-update-team" { + t.Errorf("expected ID handler-update-team, got %s", result.ID) + } + + if result.Name != "Updated via Handler" { + t.Errorf("expected name 'Updated via Handler', got %s", result.Name) + } +} + +// TestTeamService_DeleteTeam_HandlerInterface tests handler interface method +func TestTeamService_DeleteTeam_HandlerInterface(t *testing.T) { + mockTeamRepo := newMockTeamRepository() + mockAuditRepo := newMockAuditRepository() + auditService := NewAuditService(mockAuditRepo) + teamService := NewTeamService(mockTeamRepo, auditService) + + // Create team to delete + mockTeamRepo.AddTeam(&domain.Team{ + ID: "handler-delete-team", + Name: "To Delete", + }) + + err := teamService.DeleteTeam("handler-delete-team") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify deletion + _, err = mockTeamRepo.Get(context.Background(), "handler-delete-team") + if err == nil { + t.Errorf("expected error for deleted team") + } +} + +// TestTeamService_NilAuditService tests behavior when audit service is nil +func TestTeamService_NilAuditService(t *testing.T) { + ctx := context.Background() + mockTeamRepo := newMockTeamRepository() + teamService := NewTeamService(mockTeamRepo, nil) + + team := &domain.Team{ + Name: "Test Team", + } + + // Should not panic with nil audit service + err := teamService.Create(ctx, team, "user") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if team.ID == "" { + t.Errorf("expected ID to be generated") + } +}