checker-caa/checker/rule_test.go

230 lines
7.1 KiB
Go

package checker
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// stubObsGetter is a minimal ObservationGetter for tests: it serves a
// canned CAAData under ObservationKeyCAA and a canned list of related
// observations under TLSRelatedKey.
type stubObsGetter struct {
data CAAData
related []sdk.RelatedObservation
}
func (s *stubObsGetter) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
if key != ObservationKeyCAA {
return nil
}
b, _ := json.Marshal(s.data)
return json.Unmarshal(b, dest)
}
func (s *stubObsGetter) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return s.related, nil
}
// mkTLSObs wraps a single probe into the {"probes": {<ref>: …}} shape
// checker-tls actually emits.
func mkTLSObs(t *testing.T, ref string, probe map[string]any) sdk.RelatedObservation {
t.Helper()
payload := map[string]any{
"probes": map[string]any{ref: probe},
}
b, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal tls payload: %v", err)
}
return sdk.RelatedObservation{
CheckerID: "tls",
Key: TLSRelatedKey,
Data: b,
CollectedAt: time.Now(),
Ref: ref,
}
}
// TestRule_OK: CAA allows letsencrypt.org and the probe is from a
// Let's Encrypt intermediate. Expect StatusOK.
func TestRule_OK(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: "letsencrypt.org"}},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer": "R10",
"issuer_dn": "CN=R10,O=Let's Encrypt,C=US",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
if state.Status != sdk.StatusOK {
t.Fatalf("expected StatusOK, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeOK {
t.Errorf("expected code %q, got %q", CodeOK, state.Code)
}
}
// TestRule_NotAuthorized: CAA only allows digicert.com but the probe
// shows a Let's Encrypt cert. Expect StatusCrit / caa_not_authorized.
func TestRule_NotAuthorized(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: "digicert.com"}},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer": "R10",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
if state.Status != sdk.StatusCrit {
t.Fatalf("expected StatusCrit, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeNotAuthorized {
t.Errorf("expected code %q, got %q", CodeNotAuthorized, state.Code)
}
if !strings.Contains(state.Message, "letsencrypt.org") {
t.Errorf("expected message to mention letsencrypt.org, got %q", state.Message)
}
}
// TestRule_IssuanceDisallowed: CAA says `issue ";"` but a cert was
// observed. Expect StatusCrit / caa_issuance_disallowed regardless of
// the issuer.
func TestRule_IssuanceDisallowed(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: ";"}},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
if state.Status != sdk.StatusCrit {
t.Fatalf("expected StatusCrit, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeIssuanceDisallowed {
t.Errorf("expected code %q, got %q", CodeIssuanceDisallowed, state.Code)
}
}
// TestRule_IssuerUnknown: the observed AKI is not in CCADB. Expect
// StatusInfo / caa_issuer_unknown.
func TestRule_IssuerUnknown(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: "letsencrypt.org"}},
},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer_aki": "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF",
"issuer_dn": "CN=Totally Made Up CA,O=Nope,C=XX",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
if state.Status != sdk.StatusInfo {
t.Fatalf("expected StatusInfo, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeIssuerUnknown {
t.Errorf("expected code %q, got %q", CodeIssuerUnknown, state.Code)
}
}
// TestRule_NoTLS: no related TLS observations yet. Steady state during
// the eventual-consistency window before checker-tls has produced data.
func TestRule_NoTLS(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{
Domain: "example.com",
Records: []CAARecord{{Flag: 0, Tag: "issue", Value: "letsencrypt.org"}},
},
related: nil,
}
state := Rule().Evaluate(context.Background(), obs, nil)
if state.Status != sdk.StatusUnknown {
t.Fatalf("expected StatusUnknown, got %s: %s", state.Status, state.Message)
}
if state.Code != CodeNoTLS {
t.Errorf("expected code %q, got %q", CodeNoTLS, state.Code)
}
}
// TestRule_NoCAAPublished: valid TLS cert, but the zone has no CAA
// records. Rule should nudge the user (StatusInfo) with a suggestion
// to publish CAA.
func TestRule_NoCAAPublished(t *testing.T) {
obs := &stubObsGetter{
data: CAAData{Domain: "example.com", Records: nil},
related: []sdk.RelatedObservation{
mkTLSObs(t, "ep-1", map[string]any{
"host": "www.example.com",
"port": 443,
"endpoint": "www.example.com:443",
"issuer": "R10",
"issuer_aki": "BBBCC347A5E4BCA9C6C3A4720C108DA235E1C8E8",
}),
},
}
state := Rule().Evaluate(context.Background(), obs, nil)
if state.Status != sdk.StatusInfo {
t.Fatalf("expected StatusInfo (no policy), got %s: %s", state.Status, state.Message)
}
if !strings.Contains(state.Message, "letsencrypt.org") {
t.Errorf("expected suggestion to mention letsencrypt.org, got %q", state.Message)
}
}
// TestBuildAllowList is a unit test for the policy parser. The ';'
// sentinel and parameter stripping are the two subtle bits worth
// covering directly.
func TestBuildAllowList(t *testing.T) {
al := buildAllowList([]CAARecord{
{Flag: 0, Tag: "issue", Value: "letsencrypt.org"},
{Flag: 0, Tag: "issue", Value: "sectigo.com; account=12345"},
{Flag: 0, Tag: "issuewild", Value: ";"},
})
if !al.issueAll["letsencrypt.org"] {
t.Error("expected letsencrypt.org in issueAll")
}
if !al.issueAll["sectigo.com"] {
t.Errorf("expected sectigo.com (stripped) in issueAll, got %v", al.issueAll)
}
if al.disallowIssue {
t.Error("disallowIssue should be false; only issuewild was ';'")
}
if !al.disallowWildcardIssue {
t.Error("expected disallowWildcardIssue=true")
}
}