mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 20:38:51 +00:00
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:
@@ -679,3 +679,216 @@ func TestIssuerAndTargetCRUD(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestM20EnhancedQueryAPI exercises M20 query API enhancements: sorting, time-range filters,
|
||||
// cursor pagination, sparse fields, profile/agent filters, and the deployments endpoint.
|
||||
func TestM20EnhancedQueryAPI(t *testing.T) {
|
||||
server, certRepo, _, _ := setupTestServer(t)
|
||||
|
||||
// Setup: Create a certificate for testing
|
||||
now := time.Now()
|
||||
cert := &domain.ManagedCertificate{
|
||||
ID: "mc-m20-test-1",
|
||||
Name: "M20 Test Cert",
|
||||
CommonName: "m20.example.com",
|
||||
Environment: "production",
|
||||
Status: domain.CertificateStatusActive,
|
||||
IssuerID: "iss-local",
|
||||
OwnerID: "owner-ops",
|
||||
TeamID: "team-platform",
|
||||
CertificateProfileID: "prof-standard",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
certRepo.certs["mc-m20-test-1"] = cert
|
||||
|
||||
t.Run("ListWithSortDescending", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?sort=-notAfter&page=1&per_page=10")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var respBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
if _, ok := respBody["data"]; !ok {
|
||||
t.Error("expected data field in response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListWithSortAscending", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?sort=createdAt&page=1&per_page=10")
|
||||
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)
|
||||
}
|
||||
var respBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
if _, ok := respBody["page"]; !ok {
|
||||
t.Error("expected page-based pagination response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TimeRangeFilter_ExpiresBefore", func(t *testing.T) {
|
||||
future := now.AddDate(0, 0, 365).Format(time.RFC3339)
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?expires_before=" + future)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TimeRangeFilter_ExpiresAfter", func(t *testing.T) {
|
||||
past := now.AddDate(0, 0, -90).Format(time.RFC3339)
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?expires_after=" + past)
|
||||
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("TimeRangeFilter_CreatedAfter", func(t *testing.T) {
|
||||
past := now.AddDate(-1, 0, 0).Format(time.RFC3339)
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?created_after=" + past)
|
||||
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("SparseFields", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?fields=id,common_name,status")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var respBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
if data, ok := respBody["data"].([]interface{}); ok && len(data) > 0 {
|
||||
firstCert, ok := data[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected cert object in data array")
|
||||
}
|
||||
// Should have requested fields
|
||||
if _, ok := firstCert["id"]; !ok {
|
||||
t.Error("expected 'id' field in sparse response")
|
||||
}
|
||||
// Should NOT have unrequested fields like 'environment'
|
||||
if _, ok := firstCert["environment"]; ok {
|
||||
t.Error("did not expect 'environment' field in sparse response")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ProfileFilter", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?profile_id=prof-standard")
|
||||
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("AgentIDFilter", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?agent_id=agent-prod-001")
|
||||
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("CursorPagination", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?cursor=abc123&page_size=10")
|
||||
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)
|
||||
}
|
||||
var respBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
if _, ok := respBody["next_cursor"]; !ok {
|
||||
t.Error("expected next_cursor field with cursor pagination")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CombinedFilters", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?status=Active&environment=production&profile_id=prof-standard&sort=-createdAt&per_page=10")
|
||||
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("GetCertificateDeployments_Success", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates/mc-m20-test-1/deployments")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var respBody map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respBody)
|
||||
if _, ok := respBody["data"]; !ok {
|
||||
t.Error("expected data field in response")
|
||||
}
|
||||
if _, ok := respBody["total"]; !ok {
|
||||
t.Error("expected total field in response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetCertificateDeployments_NotFound", func(t *testing.T) {
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates/mc-nonexistent-m20/deployments")
|
||||
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("InvalidTimeRange", func(t *testing.T) {
|
||||
// Invalid RFC3339 should be silently ignored (no filter applied)
|
||||
resp, err := http.Get(server.URL + "/api/v1/certificates?expires_before=not-a-date&page=1&per_page=10")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200 (invalid time ignored), got %d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ func TestCertificateLifecycle(t *testing.T) {
|
||||
certificateService.SetRevocationRepo(revocationRepo)
|
||||
certificateService.SetNotificationService(notificationService)
|
||||
certificateService.SetIssuerRegistry(issuerRegistry)
|
||||
certificateService.SetTargetRepo(targetRepo)
|
||||
renewalService := service.NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notificationService, issuerRegistry, "server")
|
||||
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
||||
jobService := service.NewJobService(jobRepo, renewalService, deploymentService, logger)
|
||||
|
||||
Reference in New Issue
Block a user