Files
certctl/internal/api/handler/target_handler_test.go
T
Shankar Reddy 5c01c7f21f fix(gui,api): close C-001 + C-002 — ownership + agent FK contract
C-001 — CreateCertificate was server-accepted with null owner_id,
team_id, renewal_policy_id because the GUI neither collected the fields
nor enforced them, even though the backend's ManagedCertificate schema
and handler contract treat them as required. Fix the contract at all
four layers:

  - web/src/pages/CertificatesPage.tsx: replace owner_id/team_id free-
    text inputs with <select> elements fed by getOwners/getTeams/
    getPolicies queries; mark all three required; gate the Create
    button on owner_id + team_id + renewal_policy_id being set.
  - internal/api/handler/certificates.go: ValidateRequired for
    owner_id, team_id, renewal_policy_id on CreateCertificate so the
    handler returns HTTP 400 with the offending field name before the
    service layer is reached.
  - internal/mcp/types.go: drop ',omitempty' from
    CreateCertificateInput.RenewalPolicyID so the MCP schema reflects
    the required contract; Update inputs keep partial-update semantics.
  - api/openapi.yaml: 'required: [name, common_name, renewal_policy_id,
    issuer_id, owner_id, team_id]' was already present on the Create
    schema; clarified DeploymentTarget.agent_id description to note the
    FK contract.

C-002 — CreateTargetWizard accepted an empty or bogus agent_id and the
service inserted directly, producing a Postgres 23503 FK-violation that
bubbled out as a generic HTTP 500. The FK itself (migration 000001 line
104: agent_id TEXT NOT NULL REFERENCES agents(id)) is correct; we keep
the schema strict and add validation at three layers:

  - internal/service/target.go: introduce
    ErrAgentNotFound sentinel and pre-validate agent_id in
    TargetService.CreateTarget — empty string returns
    'agent_id is required'; a nonexistent id returns the full
    'referenced agent does not exist: <id>' error. Both wrap
    ErrAgentNotFound via fmt.Errorf %w so callers can use errors.Is.
  - internal/api/handler/targets.go: ValidateRequired on agent_id; map
    errors.Is(err, service.ErrAgentNotFound) to HTTP 400 instead of
    letting it fall through to the generic 500 branch.
  - internal/mcp/types.go: drop ',omitempty' from
    CreateTargetInput.AgentID to match the required contract.
  - web/src/pages/TargetsPage.tsx: replace the free-text Agent ID input
    with a <select> populated from getAgents(); include agent in the
    canProceedToReview gate so Next is disabled until an agent is
    chosen.

Regression coverage (21 new subtests total):

  - TestCreateCertificate_MissingRequiredField_Returns400 — 6 subtests,
    one per required field, each proves the handler guard fires before
    the mock service is called.
  - TestCreateTarget_MissingAgentID_Returns400 — handler guard.
  - TestCreateTarget_NonexistentAgent_Returns400 — pins the
    ErrAgentNotFound -> 400 translation.
  - TestTargetService_CreateTarget_MissingAgentID — errors.Is sentinel.
  - TestTargetService_CreateTarget_NonexistentAgentID — errors.Is.
  - The existing TestTargetService_CreateTarget_Success, along with
    TestCreateTarget_{MissingName,MissingType,NameTooLong}_* handler
    tests, were updated to seed a real agent or include agent_id in
    the request body so the happy paths still run cleanly.

Gates (Phase 4):
  - go build/vet/test/race: green
  - go test -cover: internal/service 68.7% (gate 55%),
    internal/api/handler 78.9% (gate 60%)
  - golangci-lint on service+handler+mcp: 0 issues
  - govulncheck: no reachable vulns
  - tsc --noEmit: clean
  - vitest: 223/223 passing

See cowork/certctl-coverage-gap-audit.md entries C-001 and C-002.
2026-04-18 16:01:40 +00:00

561 lines
16 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/service"
)
// MockTargetService is a mock implementation of TargetService interface.
type MockTargetService struct {
ListTargetsFn func(ctx context.Context, page, perPage int) ([]domain.DeploymentTarget, int64, error)
GetTargetFn func(ctx context.Context, id string) (*domain.DeploymentTarget, error)
CreateTargetFn func(ctx context.Context, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
UpdateTargetFn func(ctx context.Context, id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error)
DeleteTargetFn func(ctx context.Context, id string) error
TestConnectionFn func(ctx context.Context, id string) error
}
func (m *MockTargetService) ListTargets(ctx context.Context, page, perPage int) ([]domain.DeploymentTarget, int64, error) {
if m.ListTargetsFn != nil {
return m.ListTargetsFn(ctx, page, perPage)
}
return nil, 0, nil
}
func (m *MockTargetService) GetTarget(ctx context.Context, id string) (*domain.DeploymentTarget, error) {
if m.GetTargetFn != nil {
return m.GetTargetFn(ctx, id)
}
return nil, nil
}
func (m *MockTargetService) CreateTarget(ctx context.Context, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
if m.CreateTargetFn != nil {
return m.CreateTargetFn(ctx, target)
}
return nil, nil
}
func (m *MockTargetService) UpdateTarget(ctx context.Context, id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
if m.UpdateTargetFn != nil {
return m.UpdateTargetFn(ctx, id, target)
}
return nil, nil
}
func (m *MockTargetService) DeleteTarget(ctx context.Context, id string) error {
if m.DeleteTargetFn != nil {
return m.DeleteTargetFn(ctx, id)
}
return nil
}
func (m *MockTargetService) TestConnection(ctx context.Context, id string) error {
if m.TestConnectionFn != nil {
return m.TestConnectionFn(ctx, id)
}
return nil
}
func TestListTargets_Success(t *testing.T) {
now := time.Now()
t1 := domain.DeploymentTarget{
ID: "t-nginx-01",
Name: "NGINX Proxy",
Type: "nginx",
AgentID: "agent-001",
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}
t2 := domain.DeploymentTarget{
ID: "t-f5-01",
Name: "F5 LTM",
Type: "f5",
AgentID: "agent-002",
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}
mock := &MockTargetService{
ListTargetsFn: func(_ context.Context, page, perPage int) ([]domain.DeploymentTarget, int64, error) {
return []domain.DeploymentTarget{t1, t2}, 2, nil
},
}
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/targets", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListTargets(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)
}
}
func TestListTargets_Pagination(t *testing.T) {
var capturedPage, capturedPerPage int
mock := &MockTargetService{
ListTargetsFn: func(_ context.Context, page, perPage int) ([]domain.DeploymentTarget, int64, error) {
capturedPage = page
capturedPerPage = perPage
return []domain.DeploymentTarget{}, 0, nil
},
}
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/targets?page=4&per_page=5", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListTargets(w, req)
if capturedPage != 4 {
t.Errorf("expected page 4, got %d", capturedPage)
}
if capturedPerPage != 5 {
t.Errorf("expected per_page 5, got %d", capturedPerPage)
}
}
func TestListTargets_ServiceError(t *testing.T) {
mock := &MockTargetService{
ListTargetsFn: func(_ context.Context, page, perPage int) ([]domain.DeploymentTarget, int64, error) {
return nil, 0, ErrMockServiceFailed
},
}
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/targets", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListTargets(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", w.Code)
}
}
func TestListTargets_MethodNotAllowed(t *testing.T) {
handler := NewTargetHandler(&MockTargetService{})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/targets", nil)
w := httptest.NewRecorder()
handler.ListTargets(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status 405, got %d", w.Code)
}
}
func TestGetTarget_Success(t *testing.T) {
now := time.Now()
mock := &MockTargetService{
GetTargetFn: func(_ context.Context, id string) (*domain.DeploymentTarget, error) {
return &domain.DeploymentTarget{
ID: id,
Name: "NGINX Proxy",
Type: "nginx",
AgentID: "agent-001",
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}, nil
},
}
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/targets/t-nginx-01", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetTarget(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
}
func TestGetTarget_NotFound(t *testing.T) {
mock := &MockTargetService{
GetTargetFn: func(_ context.Context, id string) (*domain.DeploymentTarget, error) {
return nil, ErrMockNotFound
},
}
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/targets/nonexistent", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetTarget(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", w.Code)
}
}
func TestGetTarget_EmptyID(t *testing.T) {
handler := NewTargetHandler(&MockTargetService{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/targets/", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetTarget(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestCreateTarget_Success(t *testing.T) {
now := time.Now()
mock := &MockTargetService{
CreateTargetFn: func(_ context.Context, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
target.ID = "t-new"
target.CreatedAt = now
target.UpdatedAt = now
return &target, nil
},
}
body := map[string]interface{}{
"name": "New Target",
"type": "nginx",
"agent_id": "agent-001",
}
bodyBytes, _ := json.Marshal(body)
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateTarget(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected status 201, got %d", w.Code)
}
}
func TestCreateTarget_MissingName(t *testing.T) {
body := map[string]interface{}{
"type": "nginx",
"agent_id": "agent-001",
}
bodyBytes, _ := json.Marshal(body)
handler := NewTargetHandler(&MockTargetService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateTarget(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestCreateTarget_MissingType(t *testing.T) {
body := map[string]interface{}{
"name": "New Target",
"agent_id": "agent-001",
}
bodyBytes, _ := json.Marshal(body)
handler := NewTargetHandler(&MockTargetService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateTarget(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestCreateTarget_InvalidJSON(t *testing.T) {
handler := NewTargetHandler(&MockTargetService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets", bytes.NewReader([]byte("not json")))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateTarget(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestCreateTarget_NameTooLong(t *testing.T) {
longName := ""
for i := 0; i < 256; i++ {
longName += "x"
}
body := map[string]interface{}{
"name": longName,
"type": "nginx",
"agent_id": "agent-001",
}
bodyBytes, _ := json.Marshal(body)
handler := NewTargetHandler(&MockTargetService{})
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateTarget(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestCreateTarget_MethodNotAllowed(t *testing.T) {
handler := NewTargetHandler(&MockTargetService{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/targets", nil)
w := httptest.NewRecorder()
handler.CreateTarget(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status 405, got %d", w.Code)
}
}
// TestCreateTarget_MissingAgentID_Returns400 pins the C-002 handler contract:
// handler MUST reject a create payload that omits agent_id with HTTP 400
// before the service is invoked. Using a mock that would return 201-worthy
// success proves the guard fires.
func TestCreateTarget_MissingAgentID_Returns400(t *testing.T) {
body := map[string]interface{}{
"name": "New Target",
"type": "nginx",
// agent_id intentionally omitted
}
bodyBytes, _ := json.Marshal(body)
mock := &MockTargetService{
CreateTargetFn: func(_ context.Context, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
// Would succeed if handler guard did not fire.
target.ID = "t-would-be-created"
return &target, nil
},
}
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateTarget(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d — body=%s", w.Code, w.Body.String())
}
}
// TestCreateTarget_NonexistentAgent_Returns400 pins the C-002 handler↔service
// translation: when the service returns service.ErrAgentNotFound, the handler
// MUST map it to HTTP 400, not the generic 500 used for other service errors.
func TestCreateTarget_NonexistentAgent_Returns400(t *testing.T) {
mock := &MockTargetService{
CreateTargetFn: func(_ context.Context, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
return nil, service.ErrAgentNotFound
},
}
body := map[string]interface{}{
"name": "New Target",
"type": "nginx",
"agent_id": "agent-does-not-exist",
}
bodyBytes, _ := json.Marshal(body)
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.CreateTarget(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for nonexistent agent, got %d — body=%s", w.Code, w.Body.String())
}
}
func TestUpdateTarget_Success(t *testing.T) {
now := time.Now()
mock := &MockTargetService{
UpdateTargetFn: func(_ context.Context, id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) {
return &domain.DeploymentTarget{
ID: id,
Name: target.Name,
Type: "nginx",
AgentID: "agent-001",
Enabled: true,
CreatedAt: now,
UpdatedAt: now,
}, nil
},
}
body := map[string]interface{}{
"name": "Updated Target",
}
bodyBytes, _ := json.Marshal(body)
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodPut, "/api/v1/targets/t-nginx-01", bytes.NewReader(bodyBytes))
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.UpdateTarget(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
}
func TestDeleteTarget_Success(t *testing.T) {
var deletedID string
mock := &MockTargetService{
DeleteTargetFn: func(_ context.Context, id string) error {
deletedID = id
return nil
},
}
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/targets/t-nginx-01", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.DeleteTarget(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("expected status 204, got %d", w.Code)
}
if deletedID != "t-nginx-01" {
t.Errorf("expected deleted ID 't-nginx-01', got '%s'", deletedID)
}
}
func TestDeleteTarget_ServiceError(t *testing.T) {
mock := &MockTargetService{
DeleteTargetFn: func(_ context.Context, id string) error {
return ErrMockServiceFailed
},
}
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/targets/t-nginx-01", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.DeleteTarget(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", w.Code)
}
}
func TestDeleteTarget_EmptyID(t *testing.T) {
handler := NewTargetHandler(&MockTargetService{})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/targets/", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.DeleteTarget(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
}
func TestTestTargetConnection_Success(t *testing.T) {
mock := &MockTargetService{
TestConnectionFn: func(_ context.Context, id string) error {
return nil
},
}
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets/t-nginx-01/test", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.TestTargetConnection(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["status"] != "success" {
t.Errorf("expected status 'success', got %v", resp["status"])
}
}
func TestTestTargetConnection_Failed(t *testing.T) {
mock := &MockTargetService{
TestConnectionFn: func(_ context.Context, id string) error {
return ErrMockServiceFailed
},
}
handler := NewTargetHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/targets/t-nginx-01/test", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.TestTargetConnection(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp["status"] != "failed" {
t.Errorf("expected status 'failed', got %v", resp["status"])
}
}
func TestTestTargetConnection_MethodNotAllowed(t *testing.T) {
handler := NewTargetHandler(&MockTargetService{})
req := httptest.NewRequest(http.MethodGet, "/api/v1/targets/t-nginx-01/test", nil)
w := httptest.NewRecorder()
handler.TestTargetConnection(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status 405, got %d", w.Code)
}
}