mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:21: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>
245 lines
6.3 KiB
Go
245 lines
6.3 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// helper to build job service with proper constructor signatures
|
|
func newTestJobService(jobRepo *mockJobRepo) *JobService {
|
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
|
|
|
certRepo := &mockCertRepo{
|
|
Certs: make(map[string]*domain.ManagedCertificate),
|
|
Versions: make(map[string][]*domain.CertificateVersion),
|
|
}
|
|
renewalPolicyRepo := &mockRenewalPolicyRepo{
|
|
Policies: make(map[string]*domain.RenewalPolicy),
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
auditService := NewAuditService(auditRepo)
|
|
notifRepo := newMockNotificationRepository()
|
|
notifService := NewNotificationService(notifRepo, make(map[string]Notifier))
|
|
targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)}
|
|
agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent)}
|
|
|
|
renewalService := NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notifService, make(map[string]IssuerConnector), "server")
|
|
deploymentService := NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notifService)
|
|
|
|
return NewJobService(jobRepo, renewalService, deploymentService, logger)
|
|
}
|
|
|
|
func TestProcessPendingJobs_Renewal(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
now := time.Now()
|
|
job := &domain.Job{
|
|
ID: "job-001",
|
|
Type: domain.JobTypeRenewal,
|
|
CertificateID: "cert-001",
|
|
Status: domain.JobStatusPending,
|
|
Attempts: 0,
|
|
MaxAttempts: 3,
|
|
CreatedAt: now,
|
|
ScheduledAt: now,
|
|
}
|
|
|
|
jobRepo := &mockJobRepo{
|
|
Jobs: map[string]*domain.Job{"job-001": job},
|
|
StatusUpdates: make(map[string]domain.JobStatus),
|
|
}
|
|
|
|
jobService := newTestJobService(jobRepo)
|
|
|
|
err := jobService.ProcessPendingJobs(ctx)
|
|
if err != nil {
|
|
t.Logf("ProcessPendingJobs returned error (expected for renewal without cert): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessPendingJobs_NoJobs(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
jobRepo := &mockJobRepo{
|
|
Jobs: make(map[string]*domain.Job),
|
|
StatusUpdates: make(map[string]domain.JobStatus),
|
|
}
|
|
|
|
jobService := newTestJobService(jobRepo)
|
|
|
|
err := jobService.ProcessPendingJobs(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ProcessPendingJobs failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCancelJob(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
now := time.Now()
|
|
job := &domain.Job{
|
|
ID: "job-001",
|
|
Type: domain.JobTypeDeployment,
|
|
CertificateID: "cert-001",
|
|
Status: domain.JobStatusPending,
|
|
CreatedAt: now,
|
|
ScheduledAt: now,
|
|
}
|
|
|
|
jobRepo := &mockJobRepo{
|
|
Jobs: map[string]*domain.Job{"job-001": job},
|
|
StatusUpdates: make(map[string]domain.JobStatus),
|
|
}
|
|
|
|
jobService := newTestJobService(jobRepo)
|
|
|
|
err := jobService.CancelJobWithContext(ctx, "job-001")
|
|
if err != nil {
|
|
t.Fatalf("CancelJob failed: %v", err)
|
|
}
|
|
|
|
if jobRepo.StatusUpdates["job-001"] != domain.JobStatusCancelled {
|
|
t.Errorf("expected status Cancelled, got %s", jobRepo.StatusUpdates["job-001"])
|
|
}
|
|
}
|
|
|
|
func TestCancelJob_AlreadyCompleted(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
now := time.Now()
|
|
job := &domain.Job{
|
|
ID: "job-001",
|
|
Type: domain.JobTypeDeployment,
|
|
CertificateID: "cert-001",
|
|
Status: domain.JobStatusCompleted,
|
|
CreatedAt: now,
|
|
ScheduledAt: now,
|
|
}
|
|
|
|
jobRepo := &mockJobRepo{
|
|
Jobs: map[string]*domain.Job{"job-001": job},
|
|
StatusUpdates: make(map[string]domain.JobStatus),
|
|
}
|
|
|
|
jobService := newTestJobService(jobRepo)
|
|
|
|
err := jobService.CancelJobWithContext(ctx, "job-001")
|
|
if err == nil {
|
|
t.Fatal("expected error for completed job")
|
|
}
|
|
}
|
|
|
|
func TestGetJob(t *testing.T) {
|
|
now := time.Now()
|
|
job := &domain.Job{
|
|
ID: "job-001",
|
|
Type: domain.JobTypeDeployment,
|
|
CertificateID: "cert-001",
|
|
Status: domain.JobStatusPending,
|
|
CreatedAt: now,
|
|
ScheduledAt: now,
|
|
}
|
|
|
|
jobRepo := &mockJobRepo{
|
|
Jobs: map[string]*domain.Job{"job-001": job},
|
|
StatusUpdates: make(map[string]domain.JobStatus),
|
|
}
|
|
|
|
jobService := newTestJobService(jobRepo)
|
|
|
|
retrieved, err := jobService.GetJob("job-001")
|
|
if err != nil {
|
|
t.Fatalf("GetJob failed: %v", err)
|
|
}
|
|
|
|
if retrieved.ID != "job-001" {
|
|
t.Errorf("expected job ID job-001, got %s", retrieved.ID)
|
|
}
|
|
if retrieved.Type != domain.JobTypeDeployment {
|
|
t.Errorf("expected job type Deployment, got %s", retrieved.Type)
|
|
}
|
|
}
|
|
|
|
func TestListJobs(t *testing.T) {
|
|
now := time.Now()
|
|
job1 := &domain.Job{
|
|
ID: "job-001",
|
|
Type: domain.JobTypeDeployment,
|
|
CertificateID: "cert-001",
|
|
Status: domain.JobStatusCompleted,
|
|
CreatedAt: now,
|
|
ScheduledAt: now,
|
|
}
|
|
job2 := &domain.Job{
|
|
ID: "job-002",
|
|
Type: domain.JobTypeRenewal,
|
|
CertificateID: "cert-002",
|
|
Status: domain.JobStatusPending,
|
|
CreatedAt: now,
|
|
ScheduledAt: now,
|
|
}
|
|
|
|
jobRepo := &mockJobRepo{
|
|
Jobs: map[string]*domain.Job{"job-001": job1, "job-002": job2},
|
|
StatusUpdates: make(map[string]domain.JobStatus),
|
|
}
|
|
|
|
jobService := newTestJobService(jobRepo)
|
|
|
|
jobs, total, err := jobService.ListJobs("", "", 1, 50)
|
|
if err != nil {
|
|
t.Fatalf("ListJobs failed: %v", err)
|
|
}
|
|
|
|
if len(jobs) != 2 {
|
|
t.Errorf("expected 2 jobs, got %d", len(jobs))
|
|
}
|
|
if total != 2 {
|
|
t.Errorf("expected total 2, got %d", total)
|
|
}
|
|
}
|
|
|
|
func TestListJobs_FilterByStatus(t *testing.T) {
|
|
now := time.Now()
|
|
job1 := &domain.Job{
|
|
ID: "job-001",
|
|
Type: domain.JobTypeDeployment,
|
|
CertificateID: "cert-001",
|
|
Status: domain.JobStatusCompleted,
|
|
CreatedAt: now,
|
|
ScheduledAt: now,
|
|
}
|
|
job2 := &domain.Job{
|
|
ID: "job-002",
|
|
Type: domain.JobTypeRenewal,
|
|
CertificateID: "cert-002",
|
|
Status: domain.JobStatusPending,
|
|
CreatedAt: now,
|
|
ScheduledAt: now,
|
|
}
|
|
|
|
jobRepo := &mockJobRepo{
|
|
Jobs: map[string]*domain.Job{"job-001": job1, "job-002": job2},
|
|
StatusUpdates: make(map[string]domain.JobStatus),
|
|
}
|
|
|
|
jobService := newTestJobService(jobRepo)
|
|
|
|
jobs, total, err := jobService.ListJobs(string(domain.JobStatusPending), "", 1, 50)
|
|
if err != nil {
|
|
t.Fatalf("ListJobs failed: %v", err)
|
|
}
|
|
|
|
if len(jobs) != 1 {
|
|
t.Errorf("expected 1 pending job, got %d", len(jobs))
|
|
}
|
|
if total != 1 {
|
|
t.Errorf("expected total 1, got %d", total)
|
|
}
|
|
}
|