mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:51:30 +00:00
a579a84c7f
Add certificate profiles as named enrollment templates that control allowed key algorithms, max TTL, permitted EKUs, required SAN patterns, and optional SPIFFE URI SANs. CSR submissions are validated against profile rules at signing time (key type + minimum size). Short-lived certs (TTL < 1 hour) auto-expire via a new scheduler loop — expiry acts as revocation, no CRL/OCSP needed. New files: - Migration 000003: certificate_profiles table, FK columns on managed_certificates/renewal_policies, key metadata on certificate_versions - domain/profile.go: CertificateProfile + KeyAlgorithmRule structs - repository/postgres/profile.go: full CRUD with JSONB marshaling - service/profile.go: ProfileService with validation + audit logging - service/crypto_validation.go: CSR-against-profile validation (RSA/ECDSA/Ed25519) - handler/profiles.go: 5 HTTP endpoints under /api/v1/profiles - web/src/pages/ProfilesPage.tsx: profiles management page Modified: - renewal.go: CSR validation in CompleteAgentCSRRenewal, ExpireShortLivedCertificates - scheduler.go: 30s short-lived expiry check loop - certificate.go (repo): nullable profile FK, key metadata on versions - main.go: profile repo/service/handler wiring, 8-param NewRenewalService - router.go: 12-param RegisterHandlers with profile routes - seed_demo.sql: 4 demo profiles (standard, mtls, short-lived, high-security) - Frontend: types, API client, routing, sidebar nav Tests: 40 new tests across handler (15), service (13), crypto validation (12) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
416 lines
10 KiB
Go
416 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// mockProfileRepo is a test implementation of CertificateProfileRepository
|
|
type mockProfileRepo struct {
|
|
profiles map[string]*domain.CertificateProfile
|
|
ListErr error
|
|
GetErr error
|
|
CreateErr error
|
|
UpdateErr error
|
|
DeleteErr error
|
|
}
|
|
|
|
func newMockProfileRepository() *mockProfileRepo {
|
|
return &mockProfileRepo{
|
|
profiles: make(map[string]*domain.CertificateProfile),
|
|
}
|
|
}
|
|
|
|
func (m *mockProfileRepo) List(ctx context.Context) ([]*domain.CertificateProfile, error) {
|
|
if m.ListErr != nil {
|
|
return nil, m.ListErr
|
|
}
|
|
var profiles []*domain.CertificateProfile
|
|
for _, p := range m.profiles {
|
|
profiles = append(profiles, p)
|
|
}
|
|
return profiles, nil
|
|
}
|
|
|
|
func (m *mockProfileRepo) Get(ctx context.Context, id string) (*domain.CertificateProfile, error) {
|
|
if m.GetErr != nil {
|
|
return nil, m.GetErr
|
|
}
|
|
p, ok := m.profiles[id]
|
|
if !ok {
|
|
return nil, errNotFound
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (m *mockProfileRepo) Create(ctx context.Context, profile *domain.CertificateProfile) error {
|
|
if m.CreateErr != nil {
|
|
return m.CreateErr
|
|
}
|
|
m.profiles[profile.ID] = profile
|
|
return nil
|
|
}
|
|
|
|
func (m *mockProfileRepo) Update(ctx context.Context, profile *domain.CertificateProfile) error {
|
|
if m.UpdateErr != nil {
|
|
return m.UpdateErr
|
|
}
|
|
m.profiles[profile.ID] = profile
|
|
return nil
|
|
}
|
|
|
|
func (m *mockProfileRepo) Delete(ctx context.Context, id string) error {
|
|
if m.DeleteErr != nil {
|
|
return m.DeleteErr
|
|
}
|
|
delete(m.profiles, id)
|
|
return nil
|
|
}
|
|
|
|
func (m *mockProfileRepo) AddProfile(p *domain.CertificateProfile) {
|
|
m.profiles[p.ID] = p
|
|
}
|
|
|
|
// --- ProfileService Tests ---
|
|
|
|
func TestProfileService_ListProfiles(t *testing.T) {
|
|
repo := newMockProfileRepository()
|
|
repo.AddProfile(&domain.CertificateProfile{ID: "prof-1", Name: "Standard TLS", Enabled: true})
|
|
repo.AddProfile(&domain.CertificateProfile{ID: "prof-2", Name: "Internal mTLS", Enabled: true})
|
|
|
|
svc := NewProfileService(repo, nil)
|
|
profiles, total, err := svc.ListProfiles(1, 50)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if total != 2 {
|
|
t.Errorf("expected total 2, got %d", total)
|
|
}
|
|
if len(profiles) != 2 {
|
|
t.Errorf("expected 2 profiles, got %d", len(profiles))
|
|
}
|
|
}
|
|
|
|
func TestProfileService_ListProfiles_Empty(t *testing.T) {
|
|
repo := newMockProfileRepository()
|
|
svc := NewProfileService(repo, nil)
|
|
|
|
profiles, total, err := svc.ListProfiles(1, 50)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if total != 0 {
|
|
t.Errorf("expected total 0, got %d", total)
|
|
}
|
|
if len(profiles) != 0 {
|
|
t.Errorf("expected 0 profiles, got %d", len(profiles))
|
|
}
|
|
}
|
|
|
|
func TestProfileService_ListProfiles_RepoError(t *testing.T) {
|
|
repo := newMockProfileRepository()
|
|
repo.ListErr = errors.New("db error")
|
|
svc := NewProfileService(repo, nil)
|
|
|
|
_, _, err := svc.ListProfiles(1, 50)
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestProfileService_GetProfile(t *testing.T) {
|
|
repo := newMockProfileRepository()
|
|
repo.AddProfile(&domain.CertificateProfile{ID: "prof-1", Name: "Standard TLS"})
|
|
svc := NewProfileService(repo, nil)
|
|
|
|
profile, err := svc.GetProfile("prof-1")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if profile.Name != "Standard TLS" {
|
|
t.Errorf("expected 'Standard TLS', got '%s'", profile.Name)
|
|
}
|
|
}
|
|
|
|
func TestProfileService_GetProfile_NotFound(t *testing.T) {
|
|
repo := newMockProfileRepository()
|
|
svc := NewProfileService(repo, nil)
|
|
|
|
_, err := svc.GetProfile("nonexistent")
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestProfileService_CreateProfile_Defaults(t *testing.T) {
|
|
repo := newMockProfileRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
auditSvc := NewAuditService(auditRepo)
|
|
svc := NewProfileService(repo, auditSvc)
|
|
|
|
profile := domain.CertificateProfile{
|
|
Name: "New Profile",
|
|
MaxTTLSeconds: 86400,
|
|
}
|
|
|
|
created, err := svc.CreateProfile(profile)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if created.ID == "" {
|
|
t.Error("expected generated ID, got empty")
|
|
}
|
|
if len(created.AllowedKeyAlgorithms) == 0 {
|
|
t.Error("expected default key algorithms, got empty")
|
|
}
|
|
if len(created.AllowedEKUs) == 0 {
|
|
t.Error("expected default EKUs, got empty")
|
|
}
|
|
if created.CreatedAt.IsZero() {
|
|
t.Error("expected CreatedAt to be set")
|
|
}
|
|
// Verify audit event recorded
|
|
if len(auditRepo.Events) != 1 {
|
|
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
|
|
}
|
|
}
|
|
|
|
func TestProfileService_CreateProfile_ValidationErrors(t *testing.T) {
|
|
repo := newMockProfileRepository()
|
|
svc := NewProfileService(repo, nil)
|
|
|
|
tests := []struct {
|
|
name string
|
|
profile domain.CertificateProfile
|
|
errMsg string
|
|
}{
|
|
{
|
|
name: "empty name",
|
|
profile: domain.CertificateProfile{},
|
|
errMsg: "profile name is required",
|
|
},
|
|
{
|
|
name: "name too long",
|
|
profile: domain.CertificateProfile{
|
|
Name: string(make([]byte, 256)),
|
|
},
|
|
errMsg: "exceeds 255 characters",
|
|
},
|
|
{
|
|
name: "invalid key algorithm",
|
|
profile: domain.CertificateProfile{
|
|
Name: "Bad Algo",
|
|
AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{
|
|
{Algorithm: "DES", MinSize: 56},
|
|
},
|
|
},
|
|
errMsg: "invalid key algorithm",
|
|
},
|
|
{
|
|
name: "RSA key too small",
|
|
profile: domain.CertificateProfile{
|
|
Name: "Weak RSA",
|
|
AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{
|
|
{Algorithm: "RSA", MinSize: 1024},
|
|
},
|
|
},
|
|
errMsg: "RSA minimum key size must be at least 2048",
|
|
},
|
|
{
|
|
name: "ECDSA key too small",
|
|
profile: domain.CertificateProfile{
|
|
Name: "Weak ECDSA",
|
|
AllowedKeyAlgorithms: []domain.KeyAlgorithmRule{
|
|
{Algorithm: "ECDSA", MinSize: 128},
|
|
},
|
|
},
|
|
errMsg: "ECDSA minimum key size must be at least 256",
|
|
},
|
|
{
|
|
name: "invalid EKU",
|
|
profile: domain.CertificateProfile{
|
|
Name: "Bad EKU",
|
|
AllowedEKUs: []string{"invalidEKU"},
|
|
},
|
|
errMsg: "invalid EKU",
|
|
},
|
|
{
|
|
name: "negative TTL",
|
|
profile: domain.CertificateProfile{
|
|
Name: "Negative TTL",
|
|
MaxTTLSeconds: -1,
|
|
},
|
|
errMsg: "cannot be negative",
|
|
},
|
|
{
|
|
name: "short-lived with long TTL",
|
|
profile: domain.CertificateProfile{
|
|
Name: "Inconsistent Short-Lived",
|
|
AllowShortLived: true,
|
|
MaxTTLSeconds: 7200,
|
|
},
|
|
errMsg: "short-lived certs must have TTL under 1 hour",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := svc.CreateProfile(tt.profile)
|
|
if err == nil {
|
|
t.Fatalf("expected error containing %q, got nil", tt.errMsg)
|
|
}
|
|
if !contains(err.Error(), tt.errMsg) {
|
|
t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProfileService_CreateProfile_RepoError(t *testing.T) {
|
|
repo := newMockProfileRepository()
|
|
repo.CreateErr = errors.New("db create failed")
|
|
svc := NewProfileService(repo, nil)
|
|
|
|
_, err := svc.CreateProfile(domain.CertificateProfile{Name: "Valid"})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestProfileService_UpdateProfile(t *testing.T) {
|
|
repo := newMockProfileRepository()
|
|
repo.AddProfile(&domain.CertificateProfile{ID: "prof-1", Name: "Original"})
|
|
auditRepo := newMockAuditRepository()
|
|
auditSvc := NewAuditService(auditRepo)
|
|
svc := NewProfileService(repo, auditSvc)
|
|
|
|
updated, err := svc.UpdateProfile("prof-1", domain.CertificateProfile{
|
|
Name: "Updated",
|
|
MaxTTLSeconds: 43200,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if updated.ID != "prof-1" {
|
|
t.Errorf("expected ID 'prof-1', got '%s'", updated.ID)
|
|
}
|
|
if len(auditRepo.Events) != 1 {
|
|
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
|
|
}
|
|
}
|
|
|
|
func TestProfileService_UpdateProfile_ValidationError(t *testing.T) {
|
|
repo := newMockProfileRepository()
|
|
svc := NewProfileService(repo, nil)
|
|
|
|
_, err := svc.UpdateProfile("prof-1", domain.CertificateProfile{Name: ""})
|
|
if err == nil {
|
|
t.Fatal("expected validation error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestProfileService_DeleteProfile(t *testing.T) {
|
|
repo := newMockProfileRepository()
|
|
repo.AddProfile(&domain.CertificateProfile{ID: "prof-1", Name: "To Delete"})
|
|
auditRepo := newMockAuditRepository()
|
|
auditSvc := NewAuditService(auditRepo)
|
|
svc := NewProfileService(repo, auditSvc)
|
|
|
|
err := svc.DeleteProfile("prof-1")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(auditRepo.Events) != 1 {
|
|
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
|
|
}
|
|
}
|
|
|
|
func TestProfileService_DeleteProfile_RepoError(t *testing.T) {
|
|
repo := newMockProfileRepository()
|
|
repo.DeleteErr = errors.New("db delete failed")
|
|
svc := NewProfileService(repo, nil)
|
|
|
|
err := svc.DeleteProfile("prof-1")
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestProfileService_CreateProfile_ValidShortLived(t *testing.T) {
|
|
repo := newMockProfileRepository()
|
|
svc := NewProfileService(repo, nil)
|
|
|
|
// Short-lived with TTL under 1 hour should succeed
|
|
created, err := svc.CreateProfile(domain.CertificateProfile{
|
|
Name: "CI Ephemeral",
|
|
AllowShortLived: true,
|
|
MaxTTLSeconds: 300, // 5 minutes
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !created.AllowShortLived {
|
|
t.Error("expected AllowShortLived to be true")
|
|
}
|
|
}
|
|
|
|
func TestIsShortLived(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
profile domain.CertificateProfile
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "short-lived with 5 min TTL",
|
|
profile: domain.CertificateProfile{AllowShortLived: true, MaxTTLSeconds: 300},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "short-lived flag false",
|
|
profile: domain.CertificateProfile{AllowShortLived: false, MaxTTLSeconds: 300},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "zero TTL with flag",
|
|
profile: domain.CertificateProfile{AllowShortLived: true, MaxTTLSeconds: 0},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "TTL at 1 hour boundary",
|
|
profile: domain.CertificateProfile{AllowShortLived: true, MaxTTLSeconds: 3600},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "standard long-lived",
|
|
profile: domain.CertificateProfile{AllowShortLived: false, MaxTTLSeconds: 7776000},
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := tt.profile.IsShortLived()
|
|
if result != tt.expected {
|
|
t.Errorf("expected %v, got %v", tt.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// contains checks if a string contains a substring (helper for test assertions).
|
|
func contains(s, substr string) bool {
|
|
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr))
|
|
}
|
|
|
|
func containsSubstring(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|