feat: M20 Enhanced Query API — sort, time-range filters, cursor pagination, sparse fields, deployments endpoint

V2 (free) query enhancements for certificates:
- `sort` param with direction (`?sort=-notAfter` for descending)
- Time-range filters: `expires_before`, `expires_after`, `created_after`, `updated_after`
- Cursor-based pagination (`?cursor=token&page_size=100`) alongside page-based
- Sparse field selection (`?fields=id,commonName,status`)
- Additional filters: `agent_id`, `profile_id`
- New endpoint: `GET /api/v1/certificates/{id}/deployments`

25 new tests (12 handler + 13 e2e) covering all M20 features.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shankar
2026-03-23 18:56:02 -04:00
parent 53f2f239af
commit 2f0a721b86
10 changed files with 1041 additions and 42 deletions
+423 -21
View File
@@ -12,22 +12,25 @@ import (
"github.com/shankar0123/certctl/internal/api/middleware"
"github.com/shankar0123/certctl/internal/domain"
"github.com/shankar0123/certctl/internal/repository"
)
// MockCertificateService is a mock implementation of CertificateService interface.
type MockCertificateService struct {
ListCertificatesFn func(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error)
GetCertificateFn func(id string) (*domain.ManagedCertificate, error)
CreateCertificateFn func(cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
UpdateCertificateFn func(id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
ArchiveCertificateFn func(id string) error
GetCertificateVersionsFn func(certID string, page, perPage int) ([]domain.CertificateVersion, int64, error)
TriggerRenewalFn func(certID string) error
TriggerDeploymentFn func(certID string, targetID string) error
RevokeCertificateFn func(certID string, reason string) error
GetRevokedCertificatesFn func() ([]*domain.CertificateRevocation, error)
GenerateDERCRLFn func(issuerID string) ([]byte, error)
GetOCSPResponseFn func(issuerID string, serialHex string) ([]byte, error)
ListCertificatesFn func(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error)
ListCertificatesWithFilterFn func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error)
GetCertificateFn func(id string) (*domain.ManagedCertificate, error)
CreateCertificateFn func(cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
UpdateCertificateFn func(id string, cert domain.ManagedCertificate) (*domain.ManagedCertificate, error)
ArchiveCertificateFn func(id string) error
GetCertificateVersionsFn func(certID string, page, perPage int) ([]domain.CertificateVersion, int64, error)
TriggerRenewalFn func(certID string) error
TriggerDeploymentFn func(certID string, targetID string) error
RevokeCertificateFn func(certID string, reason string) error
GetRevokedCertificatesFn func() ([]*domain.CertificateRevocation, error)
GenerateDERCRLFn func(issuerID string) ([]byte, error)
GetOCSPResponseFn func(issuerID string, serialHex string) ([]byte, error)
GetCertificateDeploymentsFn func(certID string) ([]domain.DeploymentTarget, error)
}
func (m *MockCertificateService) ListCertificates(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error) {
@@ -114,6 +117,20 @@ func (m *MockCertificateService) GetOCSPResponse(issuerID string, serialHex stri
return nil, nil
}
func (m *MockCertificateService) ListCertificatesWithFilter(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
if m.ListCertificatesWithFilterFn != nil {
return m.ListCertificatesWithFilterFn(filter)
}
return nil, 0, nil
}
func (m *MockCertificateService) GetCertificateDeployments(certID string) ([]domain.DeploymentTarget, error) {
if m.GetCertificateDeploymentsFn != nil {
return m.GetCertificateDeploymentsFn(certID)
}
return nil, nil
}
// Helper function to create context with request ID.
func contextWithRequestID() context.Context {
return context.WithValue(context.Background(), middleware.RequestIDKey{}, "test-request-id-123")
@@ -141,8 +158,8 @@ func TestListCertificates_Success(t *testing.T) {
}
mock := &MockCertificateService{
ListCertificatesFn: func(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error) {
if page == 1 && perPage == 50 {
ListCertificatesWithFilterFn: func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
if filter.Page == 1 && filter.PerPage == 50 {
return []domain.ManagedCertificate{cert1, cert2}, 2, nil
}
return nil, 0, nil
@@ -180,8 +197,8 @@ func TestListCertificates_Success(t *testing.T) {
// Test ListCertificates - with filters
func TestListCertificates_WithFilters(t *testing.T) {
mock := &MockCertificateService{
ListCertificatesFn: func(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error) {
if status == "Active" && environment == "prod" {
ListCertificatesWithFilterFn: func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
if filter.Status == "Active" && filter.Environment == "prod" {
return []domain.ManagedCertificate{}, 0, nil
}
return nil, 0, nil
@@ -219,7 +236,7 @@ func TestListCertificates_MethodNotAllowed(t *testing.T) {
// Test ListCertificates - service error
func TestListCertificates_ServiceError(t *testing.T) {
mock := &MockCertificateService{
ListCertificatesFn: func(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error) {
ListCertificatesWithFilterFn: func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
return nil, 0, ErrMockServiceFailed
},
}
@@ -697,9 +714,9 @@ func TestTriggerDeployment_NoTargetID(t *testing.T) {
// Test ListCertificates - invalid page parameter
func TestListCertificates_InvalidPageParam(t *testing.T) {
mock := &MockCertificateService{
ListCertificatesFn: func(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error) {
ListCertificatesWithFilterFn: func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
// Should default to page 1
if page == 1 {
if filter.Page == 1 {
return []domain.ManagedCertificate{}, 0, nil
}
return nil, 0, nil
@@ -721,9 +738,9 @@ func TestListCertificates_InvalidPageParam(t *testing.T) {
// Test ListCertificates - per_page exceeds max
func TestListCertificates_PerPageExceedsMax(t *testing.T) {
mock := &MockCertificateService{
ListCertificatesFn: func(status, environment, ownerID, teamID, issuerID string, page, perPage int) ([]domain.ManagedCertificate, int64, error) {
ListCertificatesWithFilterFn: func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
// Should cap perPage at 500
if perPage == 50 { // defaults to 50 if > 500
if filter.PerPage == 50 { // defaults to 50 if > 500
return []domain.ManagedCertificate{}, 0, nil
}
return nil, 0, nil
@@ -1236,3 +1253,388 @@ func TestHandleOCSP_MethodNotAllowed(t *testing.T) {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
// === M20 Enhanced Query API Tests ===
// TestListCertificates_SortParam tests sort parameter parsing and passing to service.
func TestListCertificates_SortParam(t *testing.T) {
mock := &MockCertificateService{
ListCertificatesWithFilterFn: func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
// Handler strips the '-' prefix and sets SortDesc = true
if filter.Sort != "notAfter" || !filter.SortDesc {
t.Errorf("expected sort=notAfter desc=true, got sort=%s desc=%v", filter.Sort, filter.SortDesc)
}
return []domain.ManagedCertificate{}, 0, nil
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?sort=-notAfter", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListCertificates(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
// TestListCertificates_SortParam_Ascending tests sort parameter without '-' prefix (ascending).
func TestListCertificates_SortParam_Ascending(t *testing.T) {
mock := &MockCertificateService{
ListCertificatesWithFilterFn: func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
if filter.Sort != "createdAt" || filter.SortDesc {
t.Errorf("expected sort=createdAt desc=false, got sort=%s desc=%v", filter.Sort, filter.SortDesc)
}
return []domain.ManagedCertificate{}, 0, nil
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?sort=createdAt", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListCertificates(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
// TestListCertificates_TimeRangeFilters tests time-range filter parsing.
func TestListCertificates_TimeRangeFilters(t *testing.T) {
before := time.Now().AddDate(0, 0, 90)
after := time.Now().AddDate(0, 0, -90)
mock := &MockCertificateService{
ListCertificatesWithFilterFn: func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
if filter.ExpiresBefore == nil {
t.Error("expected ExpiresBefore to be set")
}
if filter.ExpiresAfter == nil {
t.Error("expected ExpiresAfter to be set")
}
return []domain.ManagedCertificate{}, 0, nil
},
}
handler := NewCertificateHandler(mock)
url := fmt.Sprintf("/api/v1/certificates?expires_before=%s&expires_after=%s",
before.Format(time.RFC3339), after.Format(time.RFC3339))
req := httptest.NewRequest(http.MethodGet, url, nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListCertificates(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
// TestListCertificates_CreatedAfterFilter tests created_after filter parsing.
func TestListCertificates_CreatedAfterFilter(t *testing.T) {
past := time.Now().AddDate(-1, 0, 0)
mock := &MockCertificateService{
ListCertificatesWithFilterFn: func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
if filter.CreatedAfter == nil {
t.Error("expected CreatedAfter to be set")
}
return []domain.ManagedCertificate{}, 0, nil
},
}
handler := NewCertificateHandler(mock)
url := fmt.Sprintf("/api/v1/certificates?created_after=%s", past.Format(time.RFC3339))
req := httptest.NewRequest(http.MethodGet, url, nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListCertificates(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
// TestListCertificates_CursorPagination tests cursor-based pagination response.
func TestListCertificates_CursorPagination(t *testing.T) {
cert := domain.ManagedCertificate{
ID: "mc-cursor-test-1",
CommonName: "cursor.example.com",
CreatedAt: time.Now(),
}
mock := &MockCertificateService{
ListCertificatesWithFilterFn: func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
return []domain.ManagedCertificate{cert}, 1, nil
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?cursor=abc123&page_size=10", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListCertificates(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp CursorPagedResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.NextCursor == "" {
t.Error("expected NextCursor to be populated with cursor pagination")
}
if resp.PageSize != 10 {
t.Errorf("expected PageSize=10, got %d", resp.PageSize)
}
}
// TestListCertificates_SparseFields tests field filtering in response.
func TestListCertificates_SparseFields(t *testing.T) {
cert := domain.ManagedCertificate{
ID: "mc-sparse-test-1",
Name: "Sparse Test Cert",
CommonName: "sparse.example.com",
Environment: "staging",
Status: domain.CertificateStatusActive,
}
mock := &MockCertificateService{
ListCertificatesWithFilterFn: func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
if len(filter.Fields) != 2 {
t.Errorf("expected 2 fields, got %d", len(filter.Fields))
}
return []domain.ManagedCertificate{cert}, 1, nil
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?fields=id,common_name", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListCertificates(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 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)
}
// Response data should have sparse fields applied
data, ok := resp.Data.([]interface{})
if !ok || len(data) == 0 {
t.Fatal("expected data array in response")
}
certMap, ok := data[0].(map[string]interface{})
if !ok {
t.Fatal("expected cert object in response")
}
// Check that requested fields are present
if _, ok := certMap["id"]; !ok {
t.Error("expected 'id' field in filtered response")
}
if _, ok := certMap["common_name"]; !ok {
t.Error("expected 'common_name' field in filtered response")
}
}
// TestListCertificates_ProfileFilter tests profile_id filter.
func TestListCertificates_ProfileFilter(t *testing.T) {
mock := &MockCertificateService{
ListCertificatesWithFilterFn: func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
if filter.ProfileID != "prof-standard" {
t.Errorf("expected ProfileID=prof-standard, got %s", filter.ProfileID)
}
return []domain.ManagedCertificate{}, 0, nil
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?profile_id=prof-standard", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListCertificates(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
// TestListCertificates_AgentIDFilter tests agent_id filter.
func TestListCertificates_AgentIDFilter(t *testing.T) {
mock := &MockCertificateService{
ListCertificatesWithFilterFn: func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
if filter.AgentID != "agent-prod-001" {
t.Errorf("expected AgentID=agent-prod-001, got %s", filter.AgentID)
}
return []domain.ManagedCertificate{}, 0, nil
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?agent_id=agent-prod-001", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListCertificates(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
// TestListCertificates_CombinedFilters tests multiple filters together.
func TestListCertificates_CombinedFilters(t *testing.T) {
mock := &MockCertificateService{
ListCertificatesWithFilterFn: func(filter *repository.CertificateFilter) ([]domain.ManagedCertificate, int, error) {
if filter.Status != "Active" || filter.Environment != "production" || filter.ProfileID != "prof-standard" {
t.Error("expected all filters to be set")
}
return []domain.ManagedCertificate{}, 0, nil
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates?status=Active&environment=production&profile_id=prof-standard", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.ListCertificates(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
// TestGetCertificateDeployments_Success tests retrieving deployments for a certificate.
func TestGetCertificateDeployments_Success(t *testing.T) {
deployments := []domain.DeploymentTarget{
{
ID: "t-nginx-prod-1",
Name: "NGINX Production",
Type: "NGINX",
Config: json.RawMessage(`{"cert_path": "/etc/nginx/ssl/cert.pem"}`),
},
{
ID: "t-haproxy-prod-1",
Name: "HAProxy Production",
Type: "HAProxy",
Config: json.RawMessage(`{"pem_path": "/etc/haproxy/ssl/cert.pem"}`),
},
}
mock := &MockCertificateService{
GetCertificateDeploymentsFn: func(certID string) ([]domain.DeploymentTarget, error) {
if certID != "mc-prod-001" {
return nil, ErrMockNotFound
}
return deployments, nil
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-prod-001/deployments", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetCertificateDeployments(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 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 data, ok := resp["data"].([]interface{}); !ok || len(data) != 2 {
t.Errorf("expected 2 deployments in response")
}
if total, ok := resp["total"].(float64); !ok || int(total) != 2 {
t.Errorf("expected total=2, got %v", resp["total"])
}
}
// TestGetCertificateDeployments_NotFound tests 404 for nonexistent certificate.
func TestGetCertificateDeployments_NotFound(t *testing.T) {
mock := &MockCertificateService{
GetCertificateDeploymentsFn: func(certID string) ([]domain.DeploymentTarget, error) {
return nil, fmt.Errorf("certificate not found")
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-nonexistent/deployments", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetCertificateDeployments(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
}
// TestGetCertificateDeployments_Empty tests successful response with no deployments.
func TestGetCertificateDeployments_Empty(t *testing.T) {
mock := &MockCertificateService{
GetCertificateDeploymentsFn: func(certID string) ([]domain.DeploymentTarget, error) {
if certID == "mc-no-deployments" {
return []domain.DeploymentTarget{}, nil
}
return nil, ErrMockNotFound
},
}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodGet, "/api/v1/certificates/mc-no-deployments/deployments", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetCertificateDeployments(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 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 total, ok := resp["total"].(float64); !ok || int(total) != 0 {
t.Errorf("expected total=0, got %v", resp["total"])
}
}
// TestGetCertificateDeployments_MethodNotAllowed tests 405 for non-GET requests.
func TestGetCertificateDeployments_MethodNotAllowed(t *testing.T) {
mock := &MockCertificateService{}
handler := NewCertificateHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/api/v1/certificates/mc-prod-001/deployments", nil)
req = req.WithContext(contextWithRequestID())
w := httptest.NewRecorder()
handler.GetCertificateDeployments(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}