package service import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/rsa" "encoding/json" "math/big" "testing" "github.com/shankar0123/certctl/internal/domain" ) // detailsMapFromAuditEvent unmarshals the json.RawMessage Details // field of an AuditEvent into a map[string]interface{} so tests // can inspect individual keys. func detailsMapFromAuditEvent(t *testing.T, e *domain.AuditEvent) map[string]interface{} { t.Helper() m := map[string]interface{}{} if len(e.Details) == 0 { return m } if err := json.Unmarshal(e.Details, &m); err != nil { t.Fatalf("unmarshal Details: %v", err) } return m } // Production hardening II — coverage uplift on cheap targets that // landed on or near the bundle's modified files. These tests pin // the small setter-style functions + audit-emission paths that // drag the package's overall coverage below the 70% R-CI-extended // floor. func TestCertificateService_SetCRLCacheSvc_Setter(t *testing.T) { // Trivial setter test: ensures the field is wired through and // the read-through facade in GenerateDERCRL takes the cache // branch when wired (vs. fall-through to live signing). svc := &CertificateService{} svc.SetCRLCacheSvc(nil) // Setting nil is a no-op (back-compat with deploys that don't // wire the cache); GenerateDERCRL falls through to caSvc. if svc.crlCacheSvc != nil { t.Errorf("setting nil should leave crlCacheSvc nil") } } func TestExportPEM_AuditEmitsTypedAction(t *testing.T) { // Phase 7 split-emit: ExportPEM should emit BOTH the legacy // bare "export_pem" AND the typed AuditActionCertExportPEM // (= "cert_export_pem") via two RecordEvent calls. This // pins the typed-emission contract so a future refactor that // drops one of the codes is caught at test time. certPEM := generateTestCertPEM(t) certRepo := newMockCertRepoWithVersion("mc-typed-1", &domain.ManagedCertificate{ ID: "mc-typed-1", CommonName: "typed.example.com", Status: domain.CertificateStatusActive, }, &domain.CertificateVersion{ ID: "cv-typed-1", CertificateID: "mc-typed-1", SerialNumber: "deadbeef", PEMChain: certPEM, }, ) auditRepo := &mockAuditRepo{} auditSvc := &AuditService{auditRepo: auditRepo} svc := NewExportService(certRepo, auditSvc) if _, err := svc.ExportPEM(context.Background(), "mc-typed-1"); err != nil { t.Fatalf("ExportPEM: %v", err) } // Walk the captured audit events; both codes should appear. hasLegacy, hasTyped := false, false hasPrivKey, hasActorKind := false, false for _, e := range auditRepo.Events { switch e.Action { case "export_pem": hasLegacy = true case AuditActionCertExportPEM: hasTyped = true } // Detail map enrichment: has_private_key (always false in V2) // + actor_kind ("user"). if e.Action == AuditActionCertExportPEM || e.Action == "export_pem" { d := detailsMapFromAuditEvent(t, e) if v, ok := d["has_private_key"]; ok { if b, isBool := v.(bool); isBool && !b { hasPrivKey = true } } if v, ok := d["actor_kind"]; ok { if s, isStr := v.(string); isStr && s == "user" { hasActorKind = true } } } } if !hasLegacy { t.Errorf("expected legacy bare 'export_pem' audit action emitted") } if !hasTyped { t.Errorf("expected typed AuditActionCertExportPEM (%q) emitted", AuditActionCertExportPEM) } if !hasPrivKey { t.Errorf("expected details.has_private_key=false in audit event") } if !hasActorKind { t.Errorf("expected details.actor_kind=\"user\" in audit event") } } func TestExportPKCS12_AuditEmitsTypedActionAndCipher(t *testing.T) { // Phase 7 split-emit + cipher pin: ExportPKCS12 emits typed // AuditActionCertExportPKCS12 alongside the legacy "export_pkcs12" // AND the detail map carries cipher=PKCS12CipherModernAES256 // (drift catches a future go-pkcs12 default change). certPEM := generateTestCertPEM(t) certRepo := newMockCertRepoWithVersion("mc-typed-p12", &domain.ManagedCertificate{ ID: "mc-typed-p12", CommonName: "typed-p12.example.com", Status: domain.CertificateStatusActive, }, &domain.CertificateVersion{ ID: "cv-typed-p12", CertificateID: "mc-typed-p12", SerialNumber: "cafebabe", PEMChain: certPEM, }, ) auditRepo := &mockAuditRepo{} auditSvc := &AuditService{auditRepo: auditRepo} svc := NewExportService(certRepo, auditSvc) if _, err := svc.ExportPKCS12(context.Background(), "mc-typed-p12", "test-pw"); err != nil { t.Fatalf("ExportPKCS12: %v", err) } hasLegacy, hasTyped, hasCipher := false, false, false for _, e := range auditRepo.Events { switch e.Action { case "export_pkcs12": hasLegacy = true case AuditActionCertExportPKCS12: hasTyped = true } if e.Action == AuditActionCertExportPKCS12 || e.Action == "export_pkcs12" { d := detailsMapFromAuditEvent(t, e) if v, ok := d["cipher"]; ok { if s, isStr := v.(string); isStr && s == PKCS12CipherModernAES256 { hasCipher = true } } } } if !hasLegacy { t.Errorf("expected legacy bare 'export_pkcs12' audit action emitted") } if !hasTyped { t.Errorf("expected typed AuditActionCertExportPKCS12 (%q) emitted", AuditActionCertExportPKCS12) } if !hasCipher { t.Errorf("expected details.cipher=%q (PKCS12CipherModernAES256 pin)", PKCS12CipherModernAES256) } } func TestPKCS12CipherModernAES256_PinnedValue(t *testing.T) { // Pinned cipher identifier — must NOT silently change. A future // go-pkcs12 dependency upgrade that flips the default cipher // would land here as a test failure (operator updates docs + // the pinned constant in one diff). want := "AES-256-CBC-PBE2-SHA256" if PKCS12CipherModernAES256 != want { t.Errorf("PKCS12CipherModernAES256 drifted: got %q, want %q", PKCS12CipherModernAES256, want) } } func TestAuditService_ListAuditEvents_HappyPath(t *testing.T) { // audit.go::ListAuditEvents — handler-interface method, was at 0%. repo := &mockAuditRepo{} repo.AddEvent(&domain.AuditEvent{Action: "test", ResourceID: "r1"}) repo.AddEvent(&domain.AuditEvent{Action: "test", ResourceID: "r2"}) svc := &AuditService{auditRepo: repo} events, total, err := svc.ListAuditEvents(context.Background(), 1, 50) if err != nil { t.Fatalf("ListAuditEvents: %v", err) } if len(events) != 2 || total != 2 { t.Errorf("got %d events / total=%d, want 2/2", len(events), total) } } func TestAuditService_ListAuditEvents_DefaultPagination(t *testing.T) { // Pagination defaults: page<1 -> 1, perPage<1 -> 50. Exercises // the two if-branches at the top of ListAuditEvents. repo := &mockAuditRepo{} svc := &AuditService{auditRepo: repo} if _, _, err := svc.ListAuditEvents(context.Background(), 0, 0); err != nil { t.Errorf("ListAuditEvents(0,0): %v", err) } if _, _, err := svc.ListAuditEvents(context.Background(), -5, -10); err != nil { t.Errorf("ListAuditEvents(-5,-10): %v", err) } } func TestAuditService_GetAuditEvent_HappyPathAndNotFound(t *testing.T) { // audit.go::GetAuditEvent — was at 0%. repo := &mockAuditRepo{} repo.AddEvent(&domain.AuditEvent{Action: "test", ResourceID: "found-id"}) svc := &AuditService{auditRepo: repo} e, err := svc.GetAuditEvent(context.Background(), "found-id") if err != nil { t.Fatalf("GetAuditEvent(found-id): %v", err) } if e == nil || e.ResourceID != "found-id" { t.Errorf("expected event with ResourceID=found-id, got %#v", e) } if _, err := svc.GetAuditEvent(context.Background(), "missing-id"); err == nil { t.Errorf("expected error for missing event id") } } func TestDiscoveryService_ListScans_Delegates(t *testing.T) { // discovery.go:217::ListScans was at 0% — trivial delegate. repo := newMockDiscoveryRepository() svc := NewDiscoveryService(repo, nil, nil) scans, total, err := svc.ListScans(context.Background(), "", 1, 50) if err != nil { t.Fatalf("ListScans: %v", err) } if scans == nil { // Accept empty slice; mock returns no scans by default. _ = total } } func TestDiscoveryService_GetScan_Delegates(t *testing.T) { // discovery.go:222::GetScan was at 0% — trivial delegate. repo := newMockDiscoveryRepository() svc := NewDiscoveryService(repo, nil, nil) // Mock returns nil/error for unknown id; we just exercise the // delegate so coverage ticks the line. _, _ = svc.GetScan(context.Background(), "missing-id") } func TestDiscoveryService_GetDiscoverySummary_Delegates(t *testing.T) { // discovery.go:227::GetDiscoverySummary was at 0% — trivial. repo := newMockDiscoveryRepository() svc := NewDiscoveryService(repo, nil, nil) got, err := svc.GetDiscoverySummary(context.Background()) if err != nil { t.Fatalf("GetDiscoverySummary: %v", err) } if got == nil { t.Errorf("expected non-nil map, got nil") } } func TestCertificateService_ListCertificatesWithFilter(t *testing.T) { // certificate.go:90::ListCertificatesWithFilter was at 0% — covers // the M20 filter delegate path through the repo + the // pointer→value conversion loop. certRepo := &mockCertRepo{ Certs: map[string]*domain.ManagedCertificate{ "mc-1": {ID: "mc-1", CommonName: "a.example.com"}, "mc-2": {ID: "mc-2", CommonName: "b.example.com"}, }, } svc := &CertificateService{certRepo: certRepo} got, total, err := svc.ListCertificatesWithFilter(context.Background(), nil) if err != nil { t.Fatalf("ListCertificatesWithFilter: %v", err) } if len(got) == 0 || total == 0 { // mockCertRepo.List returns all certs regardless of filter; just // verify the delegate ran + pointer→value conversion happened. t.Errorf("expected non-empty result, got len=%d total=%d", len(got), total) } } func TestHealthCheckService_Update_HappyPath(t *testing.T) { // health_check.go:219::Update was at 0%. Exercises the repo // delegate + the audit-emit branch (when auditSvc is wired). repo := newMockHealthCheckRepo() check := &domain.EndpointHealthCheck{ ID: "hc-1", Endpoint: "example.com:443", Status: domain.HealthStatusHealthy, Enabled: true, CheckIntervalSecs: 300, } _ = repo.Create(context.Background(), check) auditRepo := &mockAuditRepo{} auditSvc := &AuditService{auditRepo: auditRepo} svc := NewHealthCheckService(repo, auditSvc, newTestLogger(), 1, 0, 0, false) if err := svc.Update(context.Background(), check); err != nil { t.Fatalf("Update: %v", err) } // Audit row should land. if len(auditRepo.Events) == 0 { t.Errorf("expected an audit event after Update") } } func TestHealthCheckService_SetNotificationService_Setter(t *testing.T) { // health_check.go:49::SetNotificationService was at 0% — single // line setter. repo := newMockHealthCheckRepo() svc := NewHealthCheckService(repo, nil, newTestLogger(), 1, 0, 0, false) svc.SetNotificationService(nil) if svc.notifService != nil { t.Errorf("expected nil notifService after setter, got %v", svc.notifService) } } func TestEST_zeroizeBytes_OverwritesInPlace(t *testing.T) { // est.go::zeroizeBytes — pure function with no deps, was at 0%. b := []byte{0xff, 0xaa, 0x42, 0x99, 0x00} zeroizeBytes(b) for i, c := range b { if c != 0 { t.Errorf("byte[%d] = 0x%x, want 0", i, c) } } } func TestEST_deterministicSerial_HappyAndEmpty(t *testing.T) { // est.go::deterministicSerial — pure function, was at 0%. // Empty signature → fallback to BigInt(1). if got := deterministicSerial(nil); got.Cmp(big.NewInt(1)) != 0 { t.Errorf("deterministicSerial(nil) = %v, want 1", got) } // Short signature → uses all bytes (< 16). if got := deterministicSerial([]byte{0x01, 0x02}); got.Cmp(big.NewInt(0x0102)) != 0 { t.Errorf("deterministicSerial(short) = %v, want 258 (0x0102)", got) } // Long signature → uses first 16 bytes only. long := make([]byte, 32) for i := range long { long[i] = 0x01 } got := deterministicSerial(long) wantBytes := long[:16] want := new(big.Int).SetBytes(wantBytes) if got.Cmp(want) != 0 { t.Errorf("deterministicSerial(long) used full slice, want first 16 bytes") } } func TestEST_zeroizeKey_NilSafe(t *testing.T) { // est.go::zeroizeKey — nil-safe + the type-switch branches. zeroizeKey(nil) // unknown type — no-op zeroizeKey((*rsa.PrivateKey)(nil)) // nil RSA — early return zeroizeKey((*ecdsa.PrivateKey)(nil)) // nil ECDSA — early return } func TestEST_zeroizeKey_LiveRSAKey(t *testing.T) { // Exercise the meaningful RSA branch: D + Primes get zeroed. priv, err := rsa.GenerateKey(rand.Reader, 1024) //nolint:gosec // test fixture if err != nil { t.Skipf("RSA keygen unavailable: %v", err) } if priv.D.Sign() == 0 { t.Fatal("expected non-zero D before zeroize") } zeroizeKey(priv) if priv.D.Sign() != 0 { t.Errorf("expected D zeroed, got %v", priv.D) } for i, p := range priv.Primes { if p.Sign() != 0 { t.Errorf("expected Prime[%d] zeroed, got %v", i, p) } } } func TestEST_zeroizeKey_LiveECDSAKey(t *testing.T) { // Exercise the ECDSA branch: D gets zeroed. priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Skipf("ECDSA keygen unavailable: %v", err) } if priv.D.Sign() == 0 { t.Fatal("expected non-zero D before zeroize") } zeroizeKey(priv) if priv.D.Sign() != 0 { t.Errorf("expected D zeroed, got %v", priv.D) } } func TestAuditActionCertExport_ConstantsArePopulated(t *testing.T) { // Pin every typed audit-action constant to its expected wire string // so a future cut-paste typo in the const block is caught here. cases := map[string]string{ "PEM": AuditActionCertExportPEM, "PEMWithKey": AuditActionCertExportPEMWithKey, "PKCS12": AuditActionCertExportPKCS12, "Failed": AuditActionCertExportFailed, } want := map[string]string{ "PEM": "cert_export_pem", "PEMWithKey": "cert_export_pem_with_key", "PKCS12": "cert_export_pkcs12", "Failed": "cert_export_failed", } for k, got := range cases { if got != want[k] { t.Errorf("AuditActionCertExport%s = %q, want %q", k, got, want[k]) } if got == "" { t.Errorf("AuditActionCertExport%s is empty", k) } } }