mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:41:29 +00:00
b3cc7cbdb2
D-008 was a three-part drift in the policy engine that made the
D-005/D-006 remediation cosmetic below the DB layer:
(a) migrations/seed.sql INSERTed rules with pre-D-005 lowercase
types ('ownership', 'environment', 'lifetime', 'renewal_window')
that the handler validator rejects on Create/Update but that
raw SQL INSERTs bypassed entirely. At runtime evaluateRule's
switch fell through to the default "unknown policy rule type"
error branch on every demo rule × every cert × every cycle,
flooding logs while emitting zero violations.
(b) migrations/seed_demo.sql persisted lowercase severity values
('critical', 'error', 'warning') on policy_violations rows.
INSERT succeeded because that column had no CHECK, but any
frontend comparing against the canonical PolicySeverity enum
mis-categorized every seeded violation.
(c) evaluateRule hardcoded Severity: PolicySeverityWarning on
every emitted violation and ignored rule.Config entirely —
so the D-006 per-rule severity column (000013) and every
per-arm Config JSON ({allowed_issuer_ids, allowed_domains,
required_keys, allowed, lead_time_days, max_days}) was dead
data below the evaluation layer.
This commit lands (a)+(b)+(c) atomically. Shipping any subset
leaves the feature half-working.
## Changes
Domain (internal/domain/policy.go):
* Add PolicyTypeCertificateLifetime as the 6th TitleCase canonical.
Pre-D-008 the seeded "max-certificate-lifetime" rule had no engine
arm — routing it through RenewalLeadTime would conflate "how
close to expiry before we renew" with "how long can the cert
possibly be", two distinct semantics. The new type accepts
config {"max_days": int} and flags certs whose
NotAfter - NotBefore exceeds the cap.
Handler validator (internal/api/handler/validation.go):
* ValidatePolicyType allowlist grown to 6 canonicals
(AllowedIssuers, AllowedDomains, RequiredMetadata,
AllowedEnvironments, RenewalLeadTime, CertificateLifetime).
OpenAPI (api/openapi.yaml):
* PolicyType enum grown to match domain.
Frontend (web/src/api/types.ts, types.test.ts):
* POLICY_TYPES tuple gains CertificateLifetime; pin test asserts
all 6 canonicals and rejects casing drift.
Migration 000014 (policy_violations severity CHECK):
* Named CHECK constraint (policy_violations_severity_check)
mirroring 000013's allowlist, defense-in-depth at the DB layer
against future drift from bypassed writes (migrations, psql
sessions, future callers). Symmetric down migration drops by
name.
Seed data:
* migrations/seed.sql rewritten to emit TitleCase canonicals with
per-arm config JSON that actually exercises the config-consuming
paths (not the missing-field backstops):
- pr-require-owner → RequiredMetadata {"required_keys":["owner"]} Warning
- pr-allowed-environments → AllowedEnvironments {"allowed":["production","staging","development"]} Error
- pr-max-certificate-lifetime → CertificateLifetime {"max_days":90} Critical
- pr-min-renewal-window → RenewalLeadTime {"lead_time_days":14} Warning
Severities are now differentiated per rule (D-006 intent).
* migrations/seed_demo.sql violation rows flipped to TitleCase
severity ('Critical', 'Error', 'Warning') so migration 000014
applies cleanly on upgrade paths.
Engine rewrite (internal/service/policy.go):
* evaluateRule rewritten. All six arms now:
1. Parse rule.Config into the per-arm typed struct.
2. Bad JSON → log at ValidateCertificate boundary and skip
this rule (no co-located poisoning of other rules in the
same batch).
3. Empty/null Config → emit the pre-D-008 missing-field
violation (backwards compat invariant — operators who
haven't reconfigured still see the same output).
4. Violations emitted carry rule.Severity (no more hardcoded
Warning); D-006 column is now load-bearing.
* CertificateLifetime arm reads NotBefore/NotAfter from the
certificate's latest version via CertRepo. Injected via
PolicyService.SetCertRepo() setter — avoids churning ~36
NewPolicyService call sites while keeping the lifetime arm
optional (degrades to a log+skip if the setter is not wired).
Server wiring (cmd/server/main.go):
* policyService.SetCertRepo(certRepo) wired after construction.
Tests (internal/service/policy_test.go):
* 25 new subtests across 5 groups:
- TestEvaluateRule_SeverityPassThrough (6): every rule type
emits violations carrying rule.Severity, not hardcoded.
- TestEvaluateRule_ConfigConsumed (12): every per-arm Config
path exercised positive + negative.
- TestEvaluateRule_EmptyConfig_BackCompat (3): empty/null
Config still emits pre-D-008 missing-field violations.
- TestEvaluateRule_BadConfig_SkipsRule: malformed JSON logs
and skips cleanly without poisoning neighbors.
- TestEvaluateRule_CertificateLifetime_RepoScenarios (3):
ok when repo wired, log+skip when not, handles missing
NotBefore/NotAfter edges.
Provenance: D-008 surfaced during D-005/D-006 remediation review
in eef1db0. That commit added persistence and CI pins for the
severity field but did not re-verify the evaluation layer
consumed it; this finding and fix close the audit-process gap.
957 lines
30 KiB
Go
957 lines
30 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
func TestCreateRule(t *testing.T) {
|
|
ctx := context.Background()
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: make(map[string]*domain.PolicyRule),
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
|
auditService := NewAuditService(auditRepo)
|
|
|
|
policyService := NewPolicyService(policyRepo, auditService)
|
|
|
|
config := map[string]interface{}{"issuers": []string{"iss-acme"}}
|
|
configJSON, _ := json.Marshal(config)
|
|
|
|
rule := &domain.PolicyRule{
|
|
ID: "rule-001",
|
|
Name: "Allowed Issuers",
|
|
Type: domain.PolicyTypeAllowedIssuers,
|
|
Config: configJSON,
|
|
Enabled: true,
|
|
}
|
|
|
|
err := policyService.CreateRule(ctx, rule, "user-1")
|
|
if err != nil {
|
|
t.Fatalf("CreateRule failed: %v", err)
|
|
}
|
|
|
|
if len(policyRepo.Rules) != 1 {
|
|
t.Errorf("expected 1 rule, got %d", len(policyRepo.Rules))
|
|
}
|
|
|
|
if len(auditRepo.Events) != 1 {
|
|
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
|
|
}
|
|
}
|
|
|
|
func TestGetRule(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
rule := &domain.PolicyRule{
|
|
ID: "rule-001",
|
|
Name: "Allowed Issuers",
|
|
Type: domain.PolicyTypeAllowedIssuers,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: map[string]*domain.PolicyRule{"rule-001": rule},
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
auditService := NewAuditService(auditRepo)
|
|
|
|
policyService := NewPolicyService(policyRepo, auditService)
|
|
|
|
retrieved, err := policyService.GetRule(ctx, "rule-001")
|
|
if err != nil {
|
|
t.Fatalf("GetRule failed: %v", err)
|
|
}
|
|
|
|
if retrieved.Name != "Allowed Issuers" {
|
|
t.Errorf("expected name Allowed Issuers, got %s", retrieved.Name)
|
|
}
|
|
}
|
|
|
|
func TestGetRule_NotFound(t *testing.T) {
|
|
ctx := context.Background()
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: make(map[string]*domain.PolicyRule),
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
auditService := NewAuditService(auditRepo)
|
|
|
|
policyService := NewPolicyService(policyRepo, auditService)
|
|
|
|
_, err := policyService.GetRule(ctx, "nonexistent")
|
|
if err == nil {
|
|
t.Fatal("expected error for nonexistent rule")
|
|
}
|
|
}
|
|
|
|
func TestListRules(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
|
|
rule1 := &domain.PolicyRule{
|
|
ID: "rule-001",
|
|
Name: "Allowed Issuers",
|
|
Type: domain.PolicyTypeAllowedIssuers,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
rule2 := &domain.PolicyRule{
|
|
ID: "rule-002",
|
|
Name: "Required Metadata",
|
|
Type: domain.PolicyTypeRequiredMetadata,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: map[string]*domain.PolicyRule{"rule-001": rule1, "rule-002": rule2},
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
auditService := NewAuditService(auditRepo)
|
|
|
|
policyService := NewPolicyService(policyRepo, auditService)
|
|
|
|
rules, err := policyService.ListRules(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ListRules failed: %v", err)
|
|
}
|
|
|
|
if len(rules) != 2 {
|
|
t.Errorf("expected 2 rules, got %d", len(rules))
|
|
}
|
|
}
|
|
|
|
func TestUpdateRule(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
|
|
originalRule := &domain.PolicyRule{
|
|
ID: "rule-001",
|
|
Name: "Allowed Issuers",
|
|
Type: domain.PolicyTypeAllowedIssuers,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: map[string]*domain.PolicyRule{"rule-001": originalRule},
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
|
auditService := NewAuditService(auditRepo)
|
|
|
|
policyService := NewPolicyService(policyRepo, auditService)
|
|
|
|
updatedRule := *originalRule
|
|
updatedRule.Enabled = false
|
|
|
|
err := policyService.UpdateRule(ctx, &updatedRule, "user-1")
|
|
if err != nil {
|
|
t.Fatalf("UpdateRule failed: %v", err)
|
|
}
|
|
|
|
stored := policyRepo.Rules["rule-001"]
|
|
if stored.Enabled {
|
|
t.Error("expected rule to be disabled")
|
|
}
|
|
|
|
if len(auditRepo.Events) != 1 {
|
|
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
|
|
}
|
|
}
|
|
|
|
func TestDeleteRule(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
|
|
rule := &domain.PolicyRule{
|
|
ID: "rule-001",
|
|
Name: "Allowed Issuers",
|
|
Type: domain.PolicyTypeAllowedIssuers,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: map[string]*domain.PolicyRule{"rule-001": rule},
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
auditRepo := &mockAuditRepo{Events: []*domain.AuditEvent{}}
|
|
auditService := NewAuditService(auditRepo)
|
|
|
|
policyService := NewPolicyService(policyRepo, auditService)
|
|
|
|
err := policyService.DeleteRule(ctx, "rule-001", "user-1")
|
|
if err != nil {
|
|
t.Fatalf("DeleteRule failed: %v", err)
|
|
}
|
|
|
|
if len(policyRepo.Rules) != 0 {
|
|
t.Errorf("expected 0 rules, got %d", len(policyRepo.Rules))
|
|
}
|
|
|
|
if len(auditRepo.Events) != 1 {
|
|
t.Errorf("expected 1 audit event, got %d", len(auditRepo.Events))
|
|
}
|
|
}
|
|
|
|
func TestValidateCertificate(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
|
|
rule := &domain.PolicyRule{
|
|
ID: "rule-001",
|
|
Name: "Allowed Issuers",
|
|
Type: domain.PolicyTypeAllowedIssuers,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: map[string]*domain.PolicyRule{"rule-001": rule},
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
auditService := NewAuditService(auditRepo)
|
|
|
|
policyService := NewPolicyService(policyRepo, auditService)
|
|
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "cert-001",
|
|
CommonName: "example.com",
|
|
IssuerID: "iss-acme",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: now.AddDate(1, 0, 0),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
violations, err := policyService.ValidateCertificate(ctx, cert)
|
|
if err != nil {
|
|
t.Fatalf("ValidateCertificate failed: %v", err)
|
|
}
|
|
|
|
if len(violations) > 0 {
|
|
t.Errorf("expected no violations, got %d", len(violations))
|
|
}
|
|
}
|
|
|
|
func TestValidateCertificate_WithViolation(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
|
|
rule := &domain.PolicyRule{
|
|
ID: "rule-001",
|
|
Name: "Allowed Issuers",
|
|
Type: domain.PolicyTypeAllowedIssuers,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: map[string]*domain.PolicyRule{"rule-001": rule},
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
auditService := NewAuditService(auditRepo)
|
|
|
|
policyService := NewPolicyService(policyRepo, auditService)
|
|
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "cert-001",
|
|
CommonName: "example.com",
|
|
IssuerID: "", // Missing issuer
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: now.AddDate(1, 0, 0),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
violations, err := policyService.ValidateCertificate(ctx, cert)
|
|
if err != nil {
|
|
t.Fatalf("ValidateCertificate failed: %v", err)
|
|
}
|
|
|
|
if len(violations) != 1 {
|
|
t.Errorf("expected 1 violation, got %d", len(violations))
|
|
}
|
|
|
|
if violations[0].CertificateID != "cert-001" {
|
|
t.Errorf("expected violation for cert-001, got %s", violations[0].CertificateID)
|
|
}
|
|
}
|
|
|
|
func TestValidateCertificate_MultipleViolations(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
|
|
rule1 := &domain.PolicyRule{
|
|
ID: "rule-001",
|
|
Name: "Allowed Issuers",
|
|
Type: domain.PolicyTypeAllowedIssuers,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
rule2 := &domain.PolicyRule{
|
|
ID: "rule-002",
|
|
Name: "Required Metadata",
|
|
Type: domain.PolicyTypeRequiredMetadata,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: map[string]*domain.PolicyRule{"rule-001": rule1, "rule-002": rule2},
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
auditService := NewAuditService(auditRepo)
|
|
|
|
policyService := NewPolicyService(policyRepo, auditService)
|
|
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "cert-001",
|
|
CommonName: "example.com",
|
|
IssuerID: "", // Missing issuer
|
|
Tags: nil, // Missing metadata
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: now.AddDate(1, 0, 0),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
violations, err := policyService.ValidateCertificate(ctx, cert)
|
|
if err != nil {
|
|
t.Fatalf("ValidateCertificate failed: %v", err)
|
|
}
|
|
|
|
if len(violations) != 2 {
|
|
t.Errorf("expected 2 violations, got %d", len(violations))
|
|
}
|
|
}
|
|
|
|
func TestListPolicies(t *testing.T) {
|
|
now := time.Now()
|
|
rule1 := &domain.PolicyRule{
|
|
ID: "rule-001",
|
|
Name: "Rule 1",
|
|
Type: domain.PolicyTypeAllowedIssuers,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
rule2 := &domain.PolicyRule{
|
|
ID: "rule-002",
|
|
Name: "Rule 2",
|
|
Type: domain.PolicyTypeRequiredMetadata,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: map[string]*domain.PolicyRule{"rule-001": rule1, "rule-002": rule2},
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
auditService := NewAuditService(auditRepo)
|
|
|
|
policyService := NewPolicyService(policyRepo, auditService)
|
|
|
|
policies, total, err := policyService.ListPolicies(context.Background(), 1, 50)
|
|
if err != nil {
|
|
t.Fatalf("ListPolicies failed: %v", err)
|
|
}
|
|
|
|
if len(policies) != 2 {
|
|
t.Errorf("expected 2 policies, got %d", len(policies))
|
|
}
|
|
if total != 2 {
|
|
t.Errorf("expected total 2, got %d", total)
|
|
}
|
|
}
|
|
|
|
func TestCreatePolicy(t *testing.T) {
|
|
now := time.Now()
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: make(map[string]*domain.PolicyRule),
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
auditRepo := &mockAuditRepo{}
|
|
auditService := NewAuditService(auditRepo)
|
|
|
|
policyService := NewPolicyService(policyRepo, auditService)
|
|
|
|
policy := domain.PolicyRule{
|
|
Name: "Test Policy",
|
|
Type: domain.PolicyTypeAllowedIssuers,
|
|
Enabled: true,
|
|
CreatedAt: now,
|
|
}
|
|
|
|
created, err := policyService.CreatePolicy(context.Background(), policy)
|
|
if err != nil {
|
|
t.Fatalf("CreatePolicy failed: %v", err)
|
|
}
|
|
|
|
if created.ID == "" {
|
|
t.Fatal("expected non-empty policy ID")
|
|
}
|
|
|
|
if len(policyRepo.Rules) != 1 {
|
|
t.Errorf("expected 1 rule in repo, got %d", len(policyRepo.Rules))
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// D-008 regression tests
|
|
//
|
|
// These pin the behavior that closes the D-006 loop:
|
|
// 1. evaluateRule copies rule.Severity onto every violation (pre-D-008 the
|
|
// engine hardcoded Warning regardless of the rule's configured severity).
|
|
// 2. evaluateRule parses rule.Config per-arm so rules enforce real thresholds
|
|
// and allowlists (pre-D-008 the configs were ignored; rules fired only on
|
|
// the missing-field shape).
|
|
// 3. An empty/zero Config preserves the pre-D-008 missing-field violation
|
|
// (backward-compat invariant).
|
|
// 4. Malformed Config returns an error; the caller logs and skips the rule
|
|
// instead of producing a zero-value violation.
|
|
// 5. CertificateLifetime (new 6th arm) reads NotBefore/NotAfter from the
|
|
// latest CertificateVersion via the cert repo wired with SetCertRepo.
|
|
// ============================================================================
|
|
|
|
// mkRule is a tiny constructor used by the D-008 tests to keep the table rows
|
|
// readable. Every rule is enabled; test-specific fields layer on top.
|
|
func mkRule(id string, t domain.PolicyType, sev domain.PolicySeverity, cfg string) *domain.PolicyRule {
|
|
return &domain.PolicyRule{
|
|
ID: id,
|
|
Name: id,
|
|
Type: t,
|
|
Config: json.RawMessage(cfg),
|
|
Enabled: true,
|
|
Severity: sev,
|
|
}
|
|
}
|
|
|
|
// evalCert is a minimal cert used by the arms that don't look at much beyond
|
|
// the shape of the field they're testing. Tests shadow fields as needed.
|
|
func evalCert() *domain.ManagedCertificate {
|
|
return &domain.ManagedCertificate{
|
|
ID: "cert-001",
|
|
CommonName: "example.com",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: time.Now().AddDate(1, 0, 0),
|
|
}
|
|
}
|
|
|
|
// TestEvaluateRule_SeverityPassThrough pins invariant #1 — every arm stamps
|
|
// rule.Severity onto the violation. The pre-D-008 bug was that arms
|
|
// independently hardcoded PolicySeverityWarning. We test each arm with a
|
|
// severity that isn't the legacy default so a regression would be visible.
|
|
func TestEvaluateRule_SeverityPassThrough(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Cert shaped to fail every non-empty-config check via the backward-compat
|
|
// missing-field path. Each row picks a severity intentionally ≠ Warning to
|
|
// make a stray hardcoded default obvious.
|
|
cases := []struct {
|
|
name string
|
|
rule *domain.PolicyRule
|
|
cert *domain.ManagedCertificate
|
|
setupFn func(svc *PolicyService)
|
|
expected domain.PolicySeverity
|
|
}{
|
|
{
|
|
name: "AllowedIssuers Critical via missing IssuerID",
|
|
rule: mkRule("r-ai", domain.PolicyTypeAllowedIssuers, domain.PolicySeverityCritical, ""),
|
|
cert: func() *domain.ManagedCertificate {
|
|
c := evalCert()
|
|
c.IssuerID = ""
|
|
return c
|
|
}(),
|
|
expected: domain.PolicySeverityCritical,
|
|
},
|
|
{
|
|
name: "AllowedDomains Error via empty SANs",
|
|
rule: mkRule("r-ad", domain.PolicyTypeAllowedDomains, domain.PolicySeverityError, ""),
|
|
cert: func() *domain.ManagedCertificate {
|
|
c := evalCert()
|
|
c.SANs = nil
|
|
return c
|
|
}(),
|
|
expected: domain.PolicySeverityError,
|
|
},
|
|
{
|
|
name: "RequiredMetadata Critical via empty Tags",
|
|
rule: mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityCritical, ""),
|
|
cert: func() *domain.ManagedCertificate {
|
|
c := evalCert()
|
|
c.Tags = nil
|
|
return c
|
|
}(),
|
|
expected: domain.PolicySeverityCritical,
|
|
},
|
|
{
|
|
name: "AllowedEnvironments Warning via empty Environment",
|
|
rule: mkRule("r-ae", domain.PolicyTypeAllowedEnvironments, domain.PolicySeverityWarning, ""),
|
|
cert: func() *domain.ManagedCertificate {
|
|
c := evalCert()
|
|
c.Environment = ""
|
|
return c
|
|
}(),
|
|
expected: domain.PolicySeverityWarning,
|
|
},
|
|
{
|
|
name: "RenewalLeadTime Critical via short remaining validity",
|
|
rule: mkRule("r-rl", domain.PolicyTypeRenewalLeadTime, domain.PolicySeverityCritical, `{"lead_time_days": 60}`),
|
|
cert: func() *domain.ManagedCertificate {
|
|
c := evalCert()
|
|
c.ExpiresAt = time.Now().AddDate(0, 0, 30) // 30d remaining < 60d lead
|
|
return c
|
|
}(),
|
|
expected: domain.PolicySeverityCritical,
|
|
},
|
|
{
|
|
name: "CertificateLifetime Error via 365d span vs 90d max",
|
|
rule: mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityError, `{"max_days": 90}`),
|
|
cert: evalCert(),
|
|
setupFn: func(svc *PolicyService) {
|
|
// Seed a version with 365d lifetime on the same cert ID used
|
|
// by evalCert().
|
|
cr := &mockCertRepo{
|
|
Certs: map[string]*domain.ManagedCertificate{},
|
|
Versions: map[string][]*domain.CertificateVersion{},
|
|
}
|
|
now := time.Now()
|
|
cr.Versions["cert-001"] = []*domain.CertificateVersion{{
|
|
ID: "ver-001",
|
|
CertificateID: "cert-001",
|
|
NotBefore: now.AddDate(0, 0, -10),
|
|
NotAfter: now.AddDate(1, 0, -10), // ~365d lifetime
|
|
}}
|
|
svc.SetCertRepo(cr)
|
|
},
|
|
expected: domain.PolicySeverityError,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: map[string]*domain.PolicyRule{tc.rule.ID: tc.rule},
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
auditService := NewAuditService(&mockAuditRepo{})
|
|
svc := NewPolicyService(policyRepo, auditService)
|
|
if tc.setupFn != nil {
|
|
tc.setupFn(svc)
|
|
}
|
|
|
|
violations, err := svc.ValidateCertificate(ctx, tc.cert)
|
|
if err != nil {
|
|
t.Fatalf("ValidateCertificate failed: %v", err)
|
|
}
|
|
if len(violations) != 1 {
|
|
t.Fatalf("expected 1 violation, got %d", len(violations))
|
|
}
|
|
if violations[0].Severity != tc.expected {
|
|
t.Errorf("expected severity %q, got %q", tc.expected, violations[0].Severity)
|
|
}
|
|
if violations[0].RuleID != tc.rule.ID {
|
|
t.Errorf("expected rule ID %q, got %q", tc.rule.ID, violations[0].RuleID)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEvaluateRule_ConfigConsumed pins invariant #2 — non-empty Config drives
|
|
// arm behavior (allowlists, thresholds, keys). Each subtest supplies a config
|
|
// that the cert would satisfy under the backward-compat missing-field path
|
|
// but violates under the config-aware path. A regression to the pre-D-008
|
|
// "config silently dropped" behavior would make these pass with 0 violations.
|
|
func TestEvaluateRule_ConfigConsumed(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("AllowedIssuers rejects issuer not in allowlist", func(t *testing.T) {
|
|
rule := mkRule("r-ai", domain.PolicyTypeAllowedIssuers, domain.PolicySeverityWarning,
|
|
`{"allowed_issuer_ids": ["iss-acme"]}`)
|
|
cert := evalCert()
|
|
cert.IssuerID = "iss-wrong"
|
|
|
|
violations := runEval(ctx, t, rule, cert, nil)
|
|
if len(violations) != 1 {
|
|
t.Fatalf("expected 1 violation for disallowed issuer, got %d", len(violations))
|
|
}
|
|
if !strings.Contains(violations[0].Message, "iss-wrong") {
|
|
t.Errorf("expected message to mention issuer ID, got %q", violations[0].Message)
|
|
}
|
|
})
|
|
|
|
t.Run("AllowedIssuers accepts issuer in allowlist", func(t *testing.T) {
|
|
rule := mkRule("r-ai", domain.PolicyTypeAllowedIssuers, domain.PolicySeverityWarning,
|
|
`{"allowed_issuer_ids": ["iss-acme"]}`)
|
|
cert := evalCert()
|
|
cert.IssuerID = "iss-acme"
|
|
|
|
violations := runEval(ctx, t, rule, cert, nil)
|
|
if len(violations) != 0 {
|
|
t.Errorf("expected 0 violations for allowed issuer, got %d", len(violations))
|
|
}
|
|
})
|
|
|
|
t.Run("AllowedDomains rejects SAN outside allowlist", func(t *testing.T) {
|
|
rule := mkRule("r-ad", domain.PolicyTypeAllowedDomains, domain.PolicySeverityWarning,
|
|
`{"allowed_domains": ["*.foo.com"]}`)
|
|
cert := evalCert()
|
|
cert.SANs = []string{"bar.elsewhere.com"}
|
|
|
|
violations := runEval(ctx, t, rule, cert, nil)
|
|
if len(violations) != 1 {
|
|
t.Fatalf("expected 1 violation for disallowed SAN, got %d", len(violations))
|
|
}
|
|
})
|
|
|
|
t.Run("AllowedDomains wildcard matches single-label subdomain", func(t *testing.T) {
|
|
rule := mkRule("r-ad", domain.PolicyTypeAllowedDomains, domain.PolicySeverityWarning,
|
|
`{"allowed_domains": ["*.foo.com"]}`)
|
|
cert := evalCert()
|
|
cert.SANs = []string{"bar.foo.com"}
|
|
|
|
violations := runEval(ctx, t, rule, cert, nil)
|
|
if len(violations) != 0 {
|
|
t.Errorf("expected 0 violations for single-label wildcard match, got %d", len(violations))
|
|
}
|
|
})
|
|
|
|
t.Run("AllowedDomains wildcard rejects multi-label subdomain", func(t *testing.T) {
|
|
// X.509 wildcard semantics: *.foo consumes exactly one label.
|
|
rule := mkRule("r-ad", domain.PolicyTypeAllowedDomains, domain.PolicySeverityWarning,
|
|
`{"allowed_domains": ["*.foo.com"]}`)
|
|
cert := evalCert()
|
|
cert.SANs = []string{"baz.bar.foo.com"}
|
|
|
|
violations := runEval(ctx, t, rule, cert, nil)
|
|
if len(violations) != 1 {
|
|
t.Errorf("expected 1 violation for multi-label wildcard (X.509 semantics), got %d", len(violations))
|
|
}
|
|
})
|
|
|
|
t.Run("RequiredMetadata rejects missing key", func(t *testing.T) {
|
|
rule := mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityWarning,
|
|
`{"required_keys": ["owner"]}`)
|
|
cert := evalCert()
|
|
cert.Tags = map[string]string{"team": "platform"}
|
|
|
|
violations := runEval(ctx, t, rule, cert, nil)
|
|
if len(violations) != 1 {
|
|
t.Fatalf("expected 1 violation for missing owner key, got %d", len(violations))
|
|
}
|
|
if !strings.Contains(violations[0].Message, "owner") {
|
|
t.Errorf("expected message to mention the missing key, got %q", violations[0].Message)
|
|
}
|
|
})
|
|
|
|
t.Run("RequiredMetadata accepts all required keys present", func(t *testing.T) {
|
|
rule := mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityWarning,
|
|
`{"required_keys": ["owner"]}`)
|
|
cert := evalCert()
|
|
cert.Tags = map[string]string{"owner": "alice"}
|
|
|
|
violations := runEval(ctx, t, rule, cert, nil)
|
|
if len(violations) != 0 {
|
|
t.Errorf("expected 0 violations when all required keys present, got %d", len(violations))
|
|
}
|
|
})
|
|
|
|
t.Run("AllowedEnvironments rejects env outside allowlist", func(t *testing.T) {
|
|
rule := mkRule("r-ae", domain.PolicyTypeAllowedEnvironments, domain.PolicySeverityWarning,
|
|
`{"allowed": ["production", "staging"]}`)
|
|
cert := evalCert()
|
|
cert.Environment = "wild-west"
|
|
|
|
violations := runEval(ctx, t, rule, cert, nil)
|
|
if len(violations) != 1 {
|
|
t.Fatalf("expected 1 violation for disallowed env, got %d", len(violations))
|
|
}
|
|
})
|
|
|
|
t.Run("RenewalLeadTime fires when remaining < configured lead", func(t *testing.T) {
|
|
rule := mkRule("r-rl", domain.PolicyTypeRenewalLeadTime, domain.PolicySeverityWarning,
|
|
`{"lead_time_days": 60}`)
|
|
cert := evalCert()
|
|
cert.ExpiresAt = time.Now().AddDate(0, 0, 30) // 30d < 60d lead
|
|
|
|
violations := runEval(ctx, t, rule, cert, nil)
|
|
if len(violations) != 1 {
|
|
t.Fatalf("expected 1 violation for 30d remaining vs 60d lead, got %d", len(violations))
|
|
}
|
|
})
|
|
|
|
t.Run("RenewalLeadTime quiet when remaining > configured lead", func(t *testing.T) {
|
|
rule := mkRule("r-rl", domain.PolicyTypeRenewalLeadTime, domain.PolicySeverityWarning,
|
|
`{"lead_time_days": 14}`)
|
|
cert := evalCert()
|
|
cert.ExpiresAt = time.Now().AddDate(0, 0, 60) // 60d > 14d lead
|
|
|
|
violations := runEval(ctx, t, rule, cert, nil)
|
|
if len(violations) != 0 {
|
|
t.Errorf("expected 0 violations when plenty of runway remains, got %d", len(violations))
|
|
}
|
|
})
|
|
|
|
t.Run("CertificateLifetime fires when lifetime exceeds max", func(t *testing.T) {
|
|
rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityWarning,
|
|
`{"max_days": 90}`)
|
|
cert := evalCert()
|
|
now := time.Now()
|
|
|
|
certRepo := &mockCertRepo{
|
|
Certs: map[string]*domain.ManagedCertificate{},
|
|
Versions: map[string][]*domain.CertificateVersion{},
|
|
}
|
|
certRepo.Versions["cert-001"] = []*domain.CertificateVersion{{
|
|
ID: "ver-001",
|
|
CertificateID: "cert-001",
|
|
NotBefore: now.AddDate(0, 0, -1),
|
|
NotAfter: now.AddDate(1, 0, -1), // ~365d > 90d
|
|
}}
|
|
|
|
violations := runEval(ctx, t, rule, cert, certRepo)
|
|
if len(violations) != 1 {
|
|
t.Fatalf("expected 1 violation for 365d lifetime vs 90d max, got %d", len(violations))
|
|
}
|
|
if !strings.Contains(violations[0].Message, "90 days") {
|
|
t.Errorf("expected message to mention max_days threshold, got %q", violations[0].Message)
|
|
}
|
|
})
|
|
|
|
t.Run("CertificateLifetime quiet when lifetime within max", func(t *testing.T) {
|
|
rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityWarning,
|
|
`{"max_days": 90}`)
|
|
cert := evalCert()
|
|
now := time.Now()
|
|
|
|
certRepo := &mockCertRepo{
|
|
Certs: map[string]*domain.ManagedCertificate{},
|
|
Versions: map[string][]*domain.CertificateVersion{},
|
|
}
|
|
certRepo.Versions["cert-001"] = []*domain.CertificateVersion{{
|
|
ID: "ver-001",
|
|
CertificateID: "cert-001",
|
|
NotBefore: now.AddDate(0, 0, -10),
|
|
NotAfter: now.AddDate(0, 0, 60), // 70d lifetime < 90d
|
|
}}
|
|
|
|
violations := runEval(ctx, t, rule, cert, certRepo)
|
|
if len(violations) != 0 {
|
|
t.Errorf("expected 0 violations for 70d lifetime under 90d max, got %d", len(violations))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestEvaluateRule_EmptyConfig_BackCompat pins invariant #3 — a rule with no
|
|
// Config (e.g., a legacy row from a pre-D-008 migration) still fires on the
|
|
// pre-D-008 missing-field shape using its configured severity. This is how
|
|
// we let existing deployments migrate without a schema rewrite.
|
|
func TestEvaluateRule_EmptyConfig_BackCompat(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("RequiredMetadata fires on zero tags", func(t *testing.T) {
|
|
rule := mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityError, "")
|
|
cert := evalCert()
|
|
cert.Tags = nil
|
|
|
|
violations := runEval(ctx, t, rule, cert, nil)
|
|
if len(violations) != 1 {
|
|
t.Fatalf("expected 1 backcompat violation, got %d", len(violations))
|
|
}
|
|
if violations[0].Severity != domain.PolicySeverityError {
|
|
t.Errorf("expected severity Error (passed through from rule), got %q", violations[0].Severity)
|
|
}
|
|
})
|
|
|
|
t.Run("RequiredMetadata quiet when any tags present under empty config", func(t *testing.T) {
|
|
// Empty config means "only fire on missing-field shape" — so a cert
|
|
// with any tags (even not what a human would call meaningful) passes.
|
|
rule := mkRule("r-rm", domain.PolicyTypeRequiredMetadata, domain.PolicySeverityError, "")
|
|
cert := evalCert()
|
|
cert.Tags = map[string]string{"arbitrary": "value"}
|
|
|
|
violations := runEval(ctx, t, rule, cert, nil)
|
|
if len(violations) != 0 {
|
|
t.Errorf("expected 0 violations under backcompat shape w/ tags set, got %d", len(violations))
|
|
}
|
|
})
|
|
|
|
t.Run("RenewalLeadTime uses 30d default under empty/zero config", func(t *testing.T) {
|
|
rule := mkRule("r-rl", domain.PolicyTypeRenewalLeadTime, domain.PolicySeverityWarning, "")
|
|
cert := evalCert()
|
|
cert.ExpiresAt = time.Now().AddDate(0, 0, 15) // 15d < 30d default
|
|
|
|
violations := runEval(ctx, t, rule, cert, nil)
|
|
if len(violations) != 1 {
|
|
t.Errorf("expected 1 violation under 30d backcompat default, got %d", len(violations))
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestEvaluateRule_BadConfig_SkipsRule pins invariant #4 — malformed JSON in
|
|
// Config returns an error from evaluateRule, which ValidateCertificate logs
|
|
// and swallows. The pass continues; no zero-value violation is emitted.
|
|
// Co-located rules still fire normally.
|
|
func TestEvaluateRule_BadConfig_SkipsRule(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Rule 1 has malformed JSON — should log+skip.
|
|
// Rule 2 is a healthy AllowedIssuers rule that should still emit its
|
|
// violation on the missing-IssuerID cert. If the bad rule poisoned the
|
|
// loop, we'd see 0 or 2 violations instead of exactly 1.
|
|
badRule := mkRule("r-bad", domain.PolicyTypeAllowedIssuers, domain.PolicySeverityError,
|
|
`{"allowed_issuer_ids": [`) // unterminated JSON
|
|
goodRule := mkRule("r-good", domain.PolicyTypeAllowedEnvironments, domain.PolicySeverityWarning, "")
|
|
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: map[string]*domain.PolicyRule{
|
|
badRule.ID: badRule,
|
|
goodRule.ID: goodRule,
|
|
},
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
auditService := NewAuditService(&mockAuditRepo{})
|
|
svc := NewPolicyService(policyRepo, auditService)
|
|
|
|
cert := evalCert()
|
|
cert.IssuerID = "" // would trigger the bad rule if it wasn't skipped
|
|
cert.Environment = "" // triggers goodRule via missing-field backcompat
|
|
|
|
violations, err := svc.ValidateCertificate(ctx, cert)
|
|
if err != nil {
|
|
t.Fatalf("ValidateCertificate should swallow rule-eval errors, got %v", err)
|
|
}
|
|
if len(violations) != 1 {
|
|
t.Fatalf("expected exactly 1 violation (bad rule skipped, good rule fires), got %d", len(violations))
|
|
}
|
|
if violations[0].RuleID != goodRule.ID {
|
|
t.Errorf("expected violation from r-good, got %q", violations[0].RuleID)
|
|
}
|
|
}
|
|
|
|
// TestEvaluateRule_CertificateLifetime_RepoScenarios pins the setter-injection
|
|
// pattern for the 6th arm. SetCertRepo wires the dependency; without it the
|
|
// arm errors (logged+skipped by the caller). With it but no version present,
|
|
// the arm silently returns nil (matching the missing-field backcompat shape).
|
|
func TestEvaluateRule_CertificateLifetime_RepoScenarios(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
t.Run("repo not wired logs and skips", func(t *testing.T) {
|
|
rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityError,
|
|
`{"max_days": 90}`)
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: map[string]*domain.PolicyRule{rule.ID: rule},
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
svc := NewPolicyService(policyRepo, NewAuditService(&mockAuditRepo{}))
|
|
// deliberately do NOT call SetCertRepo
|
|
|
|
violations, err := svc.ValidateCertificate(ctx, evalCert())
|
|
if err != nil {
|
|
t.Fatalf("ValidateCertificate should swallow the nil-repo error, got %v", err)
|
|
}
|
|
if len(violations) != 0 {
|
|
t.Errorf("expected 0 violations when repo unwired (rule skipped), got %d", len(violations))
|
|
}
|
|
})
|
|
|
|
t.Run("version missing silently skips", func(t *testing.T) {
|
|
rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityError,
|
|
`{"max_days": 90}`)
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: map[string]*domain.PolicyRule{rule.ID: rule},
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
svc := NewPolicyService(policyRepo, NewAuditService(&mockAuditRepo{}))
|
|
// Empty Versions map — GetLatestVersion returns errNotFound, arm skips.
|
|
svc.SetCertRepo(&mockCertRepo{
|
|
Certs: map[string]*domain.ManagedCertificate{},
|
|
Versions: map[string][]*domain.CertificateVersion{},
|
|
})
|
|
|
|
violations, err := svc.ValidateCertificate(ctx, evalCert())
|
|
if err != nil {
|
|
t.Fatalf("ValidateCertificate failed: %v", err)
|
|
}
|
|
if len(violations) != 0 {
|
|
t.Errorf("expected 0 violations when no version exists (nothing to measure), got %d", len(violations))
|
|
}
|
|
})
|
|
|
|
t.Run("max_days zero/absent means no enforcement", func(t *testing.T) {
|
|
// Even with a version, max_days=0 is a no-op (matches the
|
|
// no-threshold-configured guard in the arm).
|
|
rule := mkRule("r-cl", domain.PolicyTypeCertificateLifetime, domain.PolicySeverityError, "")
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: map[string]*domain.PolicyRule{rule.ID: rule},
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
svc := NewPolicyService(policyRepo, NewAuditService(&mockAuditRepo{}))
|
|
now := time.Now()
|
|
svc.SetCertRepo(&mockCertRepo{
|
|
Certs: map[string]*domain.ManagedCertificate{},
|
|
Versions: map[string][]*domain.CertificateVersion{
|
|
"cert-001": {{
|
|
CertificateID: "cert-001",
|
|
NotBefore: now.AddDate(0, 0, -1),
|
|
NotAfter: now.AddDate(10, 0, 0), // 10 years — huge but unchecked
|
|
}},
|
|
},
|
|
})
|
|
|
|
violations, err := svc.ValidateCertificate(ctx, evalCert())
|
|
if err != nil {
|
|
t.Fatalf("ValidateCertificate failed: %v", err)
|
|
}
|
|
if len(violations) != 0 {
|
|
t.Errorf("expected 0 violations when max_days absent (no enforcement), got %d", len(violations))
|
|
}
|
|
})
|
|
}
|
|
|
|
// runEval is a test helper that exercises ValidateCertificate against a
|
|
// single-rule configuration and returns the violation slice. Optionally
|
|
// wires a cert repo for the CertificateLifetime arm.
|
|
func runEval(ctx context.Context, t *testing.T, rule *domain.PolicyRule, cert *domain.ManagedCertificate, certRepo *mockCertRepo) []*domain.PolicyViolation {
|
|
t.Helper()
|
|
policyRepo := &mockPolicyRepo{
|
|
Rules: map[string]*domain.PolicyRule{rule.ID: rule},
|
|
Violations: []*domain.PolicyViolation{},
|
|
}
|
|
svc := NewPolicyService(policyRepo, NewAuditService(&mockAuditRepo{}))
|
|
if certRepo != nil {
|
|
svc.SetCertRepo(certRepo)
|
|
}
|
|
violations, err := svc.ValidateCertificate(ctx, cert)
|
|
if err != nil {
|
|
t.Fatalf("ValidateCertificate failed: %v", err)
|
|
}
|
|
return violations
|
|
}
|