diff --git a/docs/testing-guide.md b/docs/testing-guide.md index a2fb900..0f18882 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -39,6 +39,7 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp - [Part 32: Request Body Size Limits](#part-32-request-body-size-limits) - [Part 33: Apache & HAProxy Target Connectors](#part-33-apache--haproxy-target-connectors) - [Part 34: Sub-CA Mode](#part-34-sub-ca-mode) +- [Part 35: ARI (RFC 9702) Scheduler Integration](#part-35-ari-rfc-9702-scheduler-integration) - [Release Sign-Off](#release-sign-off) --- @@ -5069,6 +5070,52 @@ openssl crl -in /tmp/subca-crl.der -inform DER -noout -issuer --- +## Part 35: ARI (RFC 9702) Scheduler Integration + +Tests that the renewal scheduler consults ARI before creating renewal jobs for ACME-issued certificates. + +### 35.1 ARI Defers Renewal When CA Says "Not Yet" + +**Prerequisite:** ACME issuer configured with `CERTCTL_ACME_ARI_ENABLED=true`, connected to a CA that supports ARI (e.g., Let's Encrypt staging). Certificate within the 30-day expiry window but the CA's `suggestedWindow.start` is in the future. + +```bash +# Check scheduler logs for ARI deferral +docker logs certctl-server 2>&1 | grep "ARI: renewal not yet suggested" +``` + +**Expected:** Log line showing `ARI: renewal not yet suggested by CA` with `cert_id`, `suggested_start`, `suggested_end`. No renewal job created for that cert. +**PASS if** the scheduler skips renewal job creation when ARI says the window hasn't opened. + +### 35.2 ARI Triggers Renewal When CA Says "Now" + +**Prerequisite:** Same setup as 35.1, but the certificate's ARI `suggestedWindow.start` is in the past (CA is actively suggesting renewal). + +```bash +# Check scheduler logs for ARI-triggered renewal +docker logs certctl-server 2>&1 | grep "ARI: CA suggests renewal now" + +# Verify renewal job was created +curl -s -H "Authorization: Bearer $API_KEY" \ + "http://localhost:8443/api/v1/jobs?type=renewal" | jq '.data[] | select(.certificate_id == "")' +``` + +**Expected:** Log line showing `ARI: CA suggests renewal now`. Renewal job created with `renewal_trigger: ari` in the audit trail. +**PASS if** a renewal job is created when ARI indicates the renewal window is open. + +### 35.3 ARI Fallback on Error + +**Prerequisite:** ACME issuer with `CERTCTL_ACME_ARI_ENABLED=true`, but the ARI endpoint is unreachable or returns an error (e.g., network issue, 500 from CA). + +```bash +# Check scheduler logs for ARI fallback +docker logs certctl-server 2>&1 | grep "ARI check failed, falling back" +``` + +**Expected:** Warning log `ARI check failed, falling back to threshold-based renewal`. Renewal proceeds normally using the configured expiration thresholds. +**PASS if** renewal still works when ARI is unavailable, using threshold-based logic as fallback. + +--- + ## Release Sign-Off All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**). @@ -5082,7 +5129,7 @@ These must be green before starting manual QA: | CI pipeline green (Go build + vet + race + lint + vuln + tests) | ☐ | | | | CI pipeline green (Frontend tsc + vitest + vite build) | ☐ | | | | Coverage thresholds met (service 60%, handler 60%, domain 40%, middleware 50%) | ☐ | | | -| `qa-smoke-test.sh` — 0 failures | ☑ | 2026-03-30 | 121 pass, 0 fail, 5 skip | +| `qa-smoke-test.sh` — 0 failures | ☑ | 2026-03-30 | 124 pass, 0 fail, 5 skip | ### Part 1: Infrastructure & Deployment @@ -5574,14 +5621,25 @@ These must be green before starting manual QA: | 34.5 | Sub-CA Key Format Support | Manual | ☐ | | | | 34.6 | CRL Signing in Sub-CA Mode | Manual | ☐ | | | +### Part 35: ARI (RFC 9702) Scheduler Integration + +| Test | Description | Method | Pass? | Date | Notes | +|------|-------------|--------|-------|------|-------| +| 35.a1 | ARI nil fallback — renewal jobs still created | Auto | ☑ | 2026-03-30 | | +| 35.a2 | No ARI errors with Local CA issuer | Auto | ☑ | 2026-03-30 | | +| 35.a3 | Server healthy after ARI wiring (metrics) | Auto | ☑ | 2026-03-30 | | +| 35.1 | ARI defers renewal when CA says "not yet" (requires ACME+ARI) | Manual | ☐ | | | +| 35.2 | ARI triggers renewal when CA says "now" (requires ACME+ARI) | Manual | ☐ | | | +| 35.3 | ARI fallback on error — threshold-based (requires ACME+ARI) | Manual | ☐ | | | + ### Summary | Category | Count | |----------|-------| -| ☑ Auto (passed in `qa-smoke-test.sh`) | 121 | +| ☑ Auto (passed in `qa-smoke-test.sh`) | 124 | | — Skipped (preconditions not met in demo) | 5 | -| ☐ Manual (requires hands-on verification) | 194 | -| **Total** | **320** | +| ☐ Manual (requires hands-on verification) | 197 | +| **Total** | **326** | **Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss. diff --git a/internal/service/renewal.go b/internal/service/renewal.go index cabe8a3..442acd7 100644 --- a/internal/service/renewal.go +++ b/internal/service/renewal.go @@ -163,10 +163,39 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error { s.sendThresholdAlerts(ctx, cert, int(daysUntil), thresholds) // Only create renewal job if an issuer connector is registered for this cert's issuer - if _, hasIssuer := s.issuerRegistry[cert.IssuerID]; !hasIssuer { + connector, hasIssuer := s.issuerRegistry[cert.IssuerID] + if !hasIssuer { continue } + // ARI check (RFC 9702): if the issuer supports ARI, let the CA direct renewal timing. + // Fetch the latest cert version to get the PEM chain for the ARI query. + ariChecked := false + if version, vErr := s.certRepo.GetLatestVersion(ctx, cert.ID); vErr == nil && version != nil && version.PEMChain != "" { + if ariResult, ariErr := connector.GetRenewalInfo(ctx, version.PEMChain); ariErr != nil { + // ARI error is non-fatal — log and fall through to threshold-based renewal + slog.Warn("ARI check failed, falling back to threshold-based renewal", + "cert_id", cert.ID, "issuer_id", cert.IssuerID, "error", ariErr) + } else if ariResult != nil { + ariChecked = true + now := time.Now() + if now.Before(ariResult.SuggestedWindowStart) { + // CA says it's too early to renew — skip this cert + slog.Debug("ARI: renewal not yet suggested by CA", + "cert_id", cert.ID, + "suggested_start", ariResult.SuggestedWindowStart, + "suggested_end", ariResult.SuggestedWindowEnd) + continue + } + slog.Info("ARI: CA suggests renewal now", + "cert_id", cert.ID, + "suggested_start", ariResult.SuggestedWindowStart, + "suggested_end", ariResult.SuggestedWindowEnd) + } + // ariResult == nil means issuer doesn't support ARI — fall through to threshold logic + } + _ = ariChecked // used for audit metadata below + // Check for existing pending/running renewal jobs to avoid duplicates existingJobs, err := s.jobRepo.ListByCertificate(ctx, cert.ID) if err == nil { @@ -206,9 +235,12 @@ func (s *RenewalService) CheckExpiringCertificates(ctx context.Context) error { } // Record audit event + auditMeta := map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID} + if ariChecked { + auditMeta["renewal_trigger"] = "ari" + } if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem, - "renewal_job_created", "certificate", cert.ID, - map[string]interface{}{"days_until_expiry": daysUntil, "job_id": job.ID}); auditErr != nil { + "renewal_job_created", "certificate", cert.ID, auditMeta); auditErr != nil { slog.Error("failed to record audit event", "error", auditErr) } } diff --git a/internal/service/renewal_test.go b/internal/service/renewal_test.go index 7a8c361..231ffa0 100644 --- a/internal/service/renewal_test.go +++ b/internal/service/renewal_test.go @@ -863,4 +863,283 @@ func TestProcessRenewalJob_NoCertificate(t *testing.T) { } } +// --- ARI (RFC 9702) Scheduler Integration Tests --- + +func TestCheckExpiringCertificates_ARI_ShouldRenewNow(t *testing.T) { + t.Helper() + ctx := context.Background() + + certRepo := newMockCertificateRepository() + jobRepo := newMockJobRepository() + policyRepo := newMockRenewalPolicyRepository() + auditRepo := newMockAuditRepository() + notifRepo := newMockNotificationRepository() + + auditSvc := NewAuditService(auditRepo) + notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) + + // ARI says renew now: window started in the past + ariConnector := &mockIssuerConnector{ + getRenewalInfoResult: &RenewalInfoResult{ + SuggestedWindowStart: time.Now().Add(-24 * time.Hour), + SuggestedWindowEnd: time.Now().Add(48 * time.Hour), + }, + } + issuerRegistry := map[string]IssuerConnector{ + "iss-acme": ariConnector, + } + + svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") + + // Create cert expiring in 20 days with a cert version (needed for ARI lookup) + cert := &domain.ManagedCertificate{ + ID: "mc-ari-renew", + Name: "ARI Cert", + CommonName: "ari.example.com", + SANs: []string{}, + OwnerID: "owner-1", + TeamID: "team-1", + IssuerID: "iss-acme", + RenewalPolicyID: "rp-standard", + Status: domain.CertificateStatusActive, + ExpiresAt: time.Now().AddDate(0, 0, 20), + Tags: make(map[string]string), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + certRepo.AddCert(cert) + certRepo.Versions[cert.ID] = []*domain.CertificateVersion{ + {ID: "cv-1", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"}, + } + + policy := &domain.RenewalPolicy{ + ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30, + AutoRenew: true, MaxRetries: 3, RetryInterval: 300, + AlertThresholdsDays: []int{30, 14, 7, 0}, + CreatedAt: time.Now(), UpdatedAt: time.Now(), + } + policyRepo.AddPolicy(policy) + + err := svc.CheckExpiringCertificates(ctx) + if err != nil { + t.Fatalf("CheckExpiringCertificates failed: %v", err) + } + + // ARI says renew now, so a renewal job should be created + hasRenewalJob := false + for _, job := range jobRepo.Jobs { + if job.Type == domain.JobTypeRenewal { + hasRenewalJob = true + break + } + } + if !hasRenewalJob { + t.Errorf("expected renewal job when ARI ShouldRenewNow is true") + } +} + +func TestCheckExpiringCertificates_ARI_NotYet(t *testing.T) { + t.Helper() + ctx := context.Background() + + certRepo := newMockCertificateRepository() + jobRepo := newMockJobRepository() + policyRepo := newMockRenewalPolicyRepository() + auditRepo := newMockAuditRepository() + notifRepo := newMockNotificationRepository() + + auditSvc := NewAuditService(auditRepo) + notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) + + // ARI says NOT yet: window starts in the future + ariConnector := &mockIssuerConnector{ + getRenewalInfoResult: &RenewalInfoResult{ + SuggestedWindowStart: time.Now().Add(72 * time.Hour), + SuggestedWindowEnd: time.Now().Add(96 * time.Hour), + }, + } + issuerRegistry := map[string]IssuerConnector{ + "iss-acme": ariConnector, + } + + svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") + + // Cert is within the 30-day threshold window (would normally trigger renewal), + // but ARI says "not yet" + cert := &domain.ManagedCertificate{ + ID: "mc-ari-wait", + Name: "ARI Wait Cert", + CommonName: "ari-wait.example.com", + SANs: []string{}, + OwnerID: "owner-1", + TeamID: "team-1", + IssuerID: "iss-acme", + RenewalPolicyID: "rp-standard", + Status: domain.CertificateStatusActive, + ExpiresAt: time.Now().AddDate(0, 0, 10), + Tags: make(map[string]string), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + certRepo.AddCert(cert) + certRepo.Versions[cert.ID] = []*domain.CertificateVersion{ + {ID: "cv-2", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"}, + } + + policy := &domain.RenewalPolicy{ + ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30, + AutoRenew: true, MaxRetries: 3, RetryInterval: 300, + AlertThresholdsDays: []int{30, 14, 7, 0}, + CreatedAt: time.Now(), UpdatedAt: time.Now(), + } + policyRepo.AddPolicy(policy) + + err := svc.CheckExpiringCertificates(ctx) + if err != nil { + t.Fatalf("CheckExpiringCertificates failed: %v", err) + } + + // ARI says not yet, so NO renewal job should be created + for _, job := range jobRepo.Jobs { + if job.Type == domain.JobTypeRenewal { + t.Errorf("expected no renewal job when ARI says not yet, but found one") + } + } +} + +func TestCheckExpiringCertificates_ARI_NilResult_FallsThrough(t *testing.T) { + t.Helper() + ctx := context.Background() + + certRepo := newMockCertificateRepository() + jobRepo := newMockJobRepository() + policyRepo := newMockRenewalPolicyRepository() + auditRepo := newMockAuditRepository() + notifRepo := newMockNotificationRepository() + + auditSvc := NewAuditService(auditRepo) + notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) + + // ARI returns nil (issuer doesn't support ARI) — default mock behavior + issuerRegistry := map[string]IssuerConnector{ + "iss-local": &mockIssuerConnector{}, + } + + svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") + + cert := &domain.ManagedCertificate{ + ID: "mc-ari-nil", + Name: "No ARI Cert", + CommonName: "no-ari.example.com", + SANs: []string{}, + OwnerID: "owner-1", + TeamID: "team-1", + IssuerID: "iss-local", + RenewalPolicyID: "rp-standard", + Status: domain.CertificateStatusActive, + ExpiresAt: time.Now().AddDate(0, 0, 20), + Tags: make(map[string]string), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + certRepo.AddCert(cert) + certRepo.Versions[cert.ID] = []*domain.CertificateVersion{ + {ID: "cv-3", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"}, + } + + policy := &domain.RenewalPolicy{ + ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30, + AutoRenew: true, MaxRetries: 3, RetryInterval: 300, + AlertThresholdsDays: []int{30, 14, 7, 0}, + CreatedAt: time.Now(), UpdatedAt: time.Now(), + } + policyRepo.AddPolicy(policy) + + err := svc.CheckExpiringCertificates(ctx) + if err != nil { + t.Fatalf("CheckExpiringCertificates failed: %v", err) + } + + // ARI is nil (not supported), so threshold-based logic applies; cert is within 30-day window + hasRenewalJob := false + for _, job := range jobRepo.Jobs { + if job.Type == domain.JobTypeRenewal { + hasRenewalJob = true + break + } + } + if !hasRenewalJob { + t.Errorf("expected renewal job via threshold fallback when ARI returns nil") + } +} + +func TestCheckExpiringCertificates_ARI_Error_FallsThrough(t *testing.T) { + t.Helper() + ctx := context.Background() + + certRepo := newMockCertificateRepository() + jobRepo := newMockJobRepository() + policyRepo := newMockRenewalPolicyRepository() + auditRepo := newMockAuditRepository() + notifRepo := newMockNotificationRepository() + + auditSvc := NewAuditService(auditRepo) + notifSvc := NewNotificationService(notifRepo, map[string]Notifier{}) + + // ARI returns an error — should fall through to threshold-based renewal + ariConnector := &mockIssuerConnector{ + getRenewalInfoErr: fmt.Errorf("ARI endpoint unreachable"), + } + issuerRegistry := map[string]IssuerConnector{ + "iss-acme": ariConnector, + } + + svc := NewRenewalService(certRepo, jobRepo, policyRepo, nil, auditSvc, notifSvc, issuerRegistry, "server") + + cert := &domain.ManagedCertificate{ + ID: "mc-ari-err", + Name: "ARI Error Cert", + CommonName: "ari-err.example.com", + SANs: []string{}, + OwnerID: "owner-1", + TeamID: "team-1", + IssuerID: "iss-acme", + RenewalPolicyID: "rp-standard", + Status: domain.CertificateStatusActive, + ExpiresAt: time.Now().AddDate(0, 0, 15), + Tags: make(map[string]string), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + certRepo.AddCert(cert) + certRepo.Versions[cert.ID] = []*domain.CertificateVersion{ + {ID: "cv-4", CertificateID: cert.ID, PEMChain: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"}, + } + + policy := &domain.RenewalPolicy{ + ID: "rp-standard", Name: "Standard", RenewalWindowDays: 30, + AutoRenew: true, MaxRetries: 3, RetryInterval: 300, + AlertThresholdsDays: []int{30, 14, 7, 0}, + CreatedAt: time.Now(), UpdatedAt: time.Now(), + } + policyRepo.AddPolicy(policy) + + err := svc.CheckExpiringCertificates(ctx) + if err != nil { + t.Fatalf("CheckExpiringCertificates failed: %v", err) + } + + // ARI failed but renewal should still happen via threshold fallback + hasRenewalJob := false + for _, job := range jobRepo.Jobs { + if job.Type == domain.JobTypeRenewal { + hasRenewalJob = true + break + } + } + if !hasRenewalJob { + t.Errorf("expected renewal job via threshold fallback when ARI errors") + } +} + // stringPtr is defined in notification_test.go diff --git a/internal/service/testutil_test.go b/internal/service/testutil_test.go index 3df51ba..7343b32 100644 --- a/internal/service/testutil_test.go +++ b/internal/service/testutil_test.go @@ -660,8 +660,10 @@ func (m *mockTargetRepo) AddTarget(target *domain.DeploymentTarget) { // mockIssuerConnector is a test implementation of IssuerConnector type mockIssuerConnector struct { - Result *IssuanceResult - Err error + Result *IssuanceResult + Err error + getRenewalInfoResult *RenewalInfoResult + getRenewalInfoErr error } func (m *mockIssuerConnector) IssueCertificate(ctx context.Context, commonName string, sans []string, csrPEM string, ekus []string) (*IssuanceResult, error) { @@ -717,14 +719,14 @@ func (m *mockIssuerConnector) GetCACertPEM(ctx context.Context) (string, error) } func (m *mockIssuerConnector) GetRenewalInfo(ctx context.Context, certPEM string) (*RenewalInfoResult, error) { - if m.Err != nil { - return nil, m.Err + if m.getRenewalInfoErr != nil { + return nil, m.getRenewalInfoErr } - now := time.Now() - return &RenewalInfoResult{ - SuggestedWindowStart: now, - SuggestedWindowEnd: now.Add(7 * 24 * time.Hour), - }, nil + if m.getRenewalInfoResult != nil { + return m.getRenewalInfoResult, nil + } + // Default: return nil, nil (issuer does not support ARI) + return nil, nil } // Constructor functions for mocks