checker-email-keys/checker/rules_test.go

330 lines
12 KiB
Go

package checker
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func boolPtr(b bool) *bool { return &b }
// fakeObs implements sdk.ObservationGetter against an in-memory map.
type fakeObs struct {
store map[string]any
}
func (f *fakeObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
v, ok := f.store[key]
if !ok {
return errFake("missing observation: " + key)
}
raw, err := json.Marshal(v)
if err != nil {
return err
}
return json.Unmarshal(raw, dest)
}
func (f *fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return nil, nil
}
type errFake string
func (e errFake) Error() string { return string(e) }
// ── DNS rules ────────────────────────────────────────────────────────────────
func TestCheckDNSQueryFailed(t *testing.T) {
if got := checkDNSQueryFailed(&EmailKeyData{}, nil); got != nil {
t.Errorf("expected no issue, got %+v", got)
}
got := checkDNSQueryFailed(&EmailKeyData{DNSQueryError: "timeout"}, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
t.Errorf("expected one crit issue, got %+v", got)
}
}
func TestCheckDNSNoRecord(t *testing.T) {
// nil DNSAnswerPresent ⇒ no judgement.
if got := checkDNSNoRecord(&EmailKeyData{}, nil); got != nil {
t.Errorf("expected no issue when present is nil, got %+v", got)
}
// Present=true ⇒ no issue.
if got := checkDNSNoRecord(&EmailKeyData{DNSAnswerPresent: boolPtr(true)}, nil); got != nil {
t.Errorf("expected no issue when present, got %+v", got)
}
// Present=false ⇒ crit.
got := checkDNSNoRecord(&EmailKeyData{Kind: KindSMIMEA, QueriedOwner: "x", DNSAnswerPresent: boolPtr(false)}, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusCrit || !strings.Contains(got[0].Message, "SMIMEA") {
t.Errorf("unexpected: %+v", got)
}
}
func TestCheckDNSSECNotValidated_Severity(t *testing.T) {
d := &EmailKeyData{DNSSECSecure: boolPtr(false)}
// Default: requireDNSSEC=true ⇒ crit.
got := checkDNSSECNotValidated(d, sdk.CheckerOptions{})
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
t.Errorf("default should be crit, got %+v", got)
}
// Override to false ⇒ warn.
got = checkDNSSECNotValidated(d, sdk.CheckerOptions{OptionRequireDNSSEC: false})
if len(got) != 1 || got[0].Severity != sdk.StatusWarn {
t.Errorf("opt-off should be warn, got %+v", got)
}
// Secure ⇒ no issue.
got = checkDNSSECNotValidated(&EmailKeyData{DNSSECSecure: boolPtr(true)}, nil)
if got != nil {
t.Errorf("expected no issue, got %+v", got)
}
}
func TestCheckOwnerHashMismatch(t *testing.T) {
d := &EmailKeyData{Username: "alice", ExpectedOwnerPrefix: "abc", ObservedOwnerPrefix: "abc"}
if got := checkOwnerHashMismatch(d, nil); got != nil {
t.Errorf("matching prefixes should not issue, got %+v", got)
}
d.ObservedOwnerPrefix = "ABC" // case-insensitive
if got := checkOwnerHashMismatch(d, nil); got != nil {
t.Errorf("case-insensitive match should not issue, got %+v", got)
}
d.ObservedOwnerPrefix = "xyz"
got := checkOwnerHashMismatch(d, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
t.Errorf("mismatch should crit, got %+v", got)
}
// Either prefix empty ⇒ skip silently.
d.ObservedOwnerPrefix = ""
if got := checkOwnerHashMismatch(d, nil); got != nil {
t.Errorf("empty observed should skip, got %+v", got)
}
}
// ── PGP rules ────────────────────────────────────────────────────────────────
func TestCheckPGPParseError(t *testing.T) {
got := checkPGPParseError(&EmailKeyData{}, nil)
if len(got) != 1 || !strings.Contains(got[0].Message, "no OPENPGPKEY") {
t.Errorf("expected no-record issue, got %+v", got)
}
got = checkPGPParseError(&EmailKeyData{OpenPGP: &OpenPGPInfo{ParseError: "boom"}}, nil)
if len(got) != 1 || got[0].Message != "boom" {
t.Errorf("expected parse-error issue, got %+v", got)
}
if got := checkPGPParseError(&EmailKeyData{OpenPGP: &OpenPGPInfo{}}, nil); got != nil {
t.Errorf("expected no issue, got %+v", got)
}
}
func TestCheckPGPPrimaryExpired(t *testing.T) {
past := time.Now().Add(-1 * time.Hour)
d := &EmailKeyData{OpenPGP: &OpenPGPInfo{ExpiresAt: past}}
got := checkPGPPrimaryExpired(d, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
t.Errorf("expected crit, got %+v", got)
}
d.OpenPGP.ExpiresAt = time.Now().Add(time.Hour)
if got := checkPGPPrimaryExpired(d, nil); got != nil {
t.Errorf("future expiry should not issue, got %+v", got)
}
}
func TestCheckPGPPrimaryExpiring(t *testing.T) {
soon := time.Now().Add(10 * 24 * time.Hour)
d := &EmailKeyData{OpenPGP: &OpenPGPInfo{ExpiresAt: soon}}
// Default 30-day window ⇒ warn.
got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{})
if len(got) != 1 || got[0].Severity != sdk.StatusWarn {
t.Errorf("expected warn, got %+v", got)
}
// Already expired ⇒ this rule does not fire (the expired rule does).
d.OpenPGP.ExpiresAt = time.Now().Add(-time.Hour)
if got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{}); got != nil {
t.Errorf("expired key should not trigger expiring rule, got %+v", got)
}
// Disable via warnDays=0 ⇒ no issue.
d.OpenPGP.ExpiresAt = soon
if got := checkPGPPrimaryExpiring(d, sdk.CheckerOptions{OptionCertExpiryWarnDays: float64(0)}); got != nil {
t.Errorf("warnDays=0 should disable, got %+v", got)
}
}
func TestCheckPGPWeakKeySize(t *testing.T) {
d := &EmailKeyData{OpenPGP: &OpenPGPInfo{PrimaryAlgorithm: "RSA", PrimaryBits: 1024}}
got := checkPGPWeakKeySize(d, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
t.Errorf("1024-bit RSA should be crit, got %+v", got)
}
d.OpenPGP.PrimaryBits = 2048
got = checkPGPWeakKeySize(d, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusWarn {
t.Errorf("2048-bit RSA should be warn, got %+v", got)
}
d.OpenPGP.PrimaryBits = 4096
if got := checkPGPWeakKeySize(d, nil); got != nil {
t.Errorf("4096-bit RSA should pass, got %+v", got)
}
// Non-RSA ⇒ skip.
d.OpenPGP.PrimaryAlgorithm = "Ed25519"
d.OpenPGP.PrimaryBits = 256
if got := checkPGPWeakKeySize(d, nil); got != nil {
t.Errorf("Ed25519 should skip, got %+v", got)
}
}
func TestCheckPGPRecordTooLarge(t *testing.T) {
d := &EmailKeyData{OpenPGP: &OpenPGPInfo{RawSize: pgpMaxRecordBytes + 1}}
got := checkPGPRecordTooLarge(d, nil)
if len(got) != 1 {
t.Errorf("expected one issue, got %+v", got)
}
d.OpenPGP.RawSize = pgpMaxRecordBytes
if got := checkPGPRecordTooLarge(d, nil); got != nil {
t.Errorf("at-limit should pass, got %+v", got)
}
}
func TestCheckPGPUIDMismatch(t *testing.T) {
d := &EmailKeyData{Username: "alice", OpenPGP: &OpenPGPInfo{MatchesUsername: boolPtr(false)}}
got := checkPGPUIDMismatch(d, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusInfo {
t.Errorf("expected info issue, got %+v", got)
}
d.OpenPGP.MatchesUsername = boolPtr(true)
if got := checkPGPUIDMismatch(d, nil); got != nil {
t.Errorf("matching should pass, got %+v", got)
}
d.OpenPGP.MatchesUsername = nil
if got := checkPGPUIDMismatch(d, nil); got != nil {
t.Errorf("nil should skip, got %+v", got)
}
}
// ── SMIMEA rules ─────────────────────────────────────────────────────────────
func TestCheckSMIMEAFieldRanges(t *testing.T) {
if got := checkSMIMEABadUsage(&EmailKeyData{SMIMEA: &SMIMEAInfo{Usage: 4}}, nil); len(got) != 1 {
t.Errorf("usage=4 should issue, got %+v", got)
}
if got := checkSMIMEABadUsage(&EmailKeyData{SMIMEA: &SMIMEAInfo{Usage: 3}}, nil); got != nil {
t.Errorf("usage=3 should pass, got %+v", got)
}
if got := checkSMIMEABadSelector(&EmailKeyData{SMIMEA: &SMIMEAInfo{Selector: 2}}, nil); len(got) != 1 {
t.Errorf("selector=2 should issue")
}
if got := checkSMIMEABadMatchType(&EmailKeyData{SMIMEA: &SMIMEAInfo{MatchingType: 3}}, nil); len(got) != 1 {
t.Errorf("matching=3 should issue")
}
}
func TestCheckSMIMEACertExpired(t *testing.T) {
past := time.Now().Add(-time.Hour)
d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{NotAfter: past}}}
got := checkSMIMEACertExpired(d, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusCrit {
t.Errorf("expected crit, got %+v", got)
}
}
func TestCheckSMIMEANoEmailProtect_Severity(t *testing.T) {
d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{}}}
// Default true ⇒ crit.
if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{}); len(got) != 1 || got[0].Severity != sdk.StatusCrit {
t.Errorf("default crit, got %+v", got)
}
// Off ⇒ warn.
if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{OptionRequireEmailProtection: false}); got[0].Severity != sdk.StatusWarn {
t.Errorf("opt-off should warn, got %+v", got)
}
// Has EKU ⇒ no issue.
d.SMIMEA.Certificate.HasEmailProtectionEKU = true
if got := checkSMIMEANoEmailProtect(d, sdk.CheckerOptions{}); got != nil {
t.Errorf("EKU present should pass, got %+v", got)
}
}
func TestCheckSMIMEAWeakSigAlgorithm(t *testing.T) {
for _, algo := range []string{"MD5-RSA", "SHA1-RSA"} {
d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{SignatureAlgorithm: algo}}}
if got := checkSMIMEAWeakSigAlgorithm(d, nil); len(got) != 1 {
t.Errorf("%s should issue", algo)
}
}
d := &EmailKeyData{SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{SignatureAlgorithm: "SHA256-RSA"}}}
if got := checkSMIMEAWeakSigAlgorithm(d, nil); got != nil {
t.Errorf("SHA256-RSA should pass, got %+v", got)
}
}
func TestCheckSMIMEAEmailMismatch(t *testing.T) {
d := &EmailKeyData{Username: "alice", SMIMEA: &SMIMEAInfo{Certificate: &CertInfo{
EmailAddresses: []string{"bob@example.com"},
EmailMatchesUsername: boolPtr(false),
}}}
got := checkSMIMEAEmailMismatch(d, nil)
if len(got) != 1 || got[0].Severity != sdk.StatusInfo {
t.Errorf("expected info, got %+v", got)
}
}
// ── Rule.Evaluate plumbing ───────────────────────────────────────────────────
func TestRuleEvaluate_OKPath(t *testing.T) {
obs := &fakeObs{store: map[string]any{
ObservationKey: &EmailKeyData{Kind: KindOpenPGPKey, QueriedOwner: "x.example.com.", DNSSECSecure: boolPtr(true)},
}}
for _, r := range allRules {
if r.name != RuleDNSSECNotValidated {
continue
}
states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{})
if len(states) != 1 || states[0].Status != sdk.StatusOK || states[0].Code != RuleDNSSECNotValidated {
t.Fatalf("expected single OK state, got %+v", states)
}
}
}
func TestRuleEvaluate_KindFiltering(t *testing.T) {
obs := &fakeObs{store: map[string]any{
ObservationKey: &EmailKeyData{Kind: KindSMIMEA},
}}
for _, r := range allRules {
if r.name != RulePGPParseError {
continue
}
states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{})
if len(states) != 1 || states[0].Status != sdk.StatusUnknown {
t.Fatalf("PGP rule on SMIMEA kind should yield single Unknown state, got %+v", states)
}
}
}
func TestRuleEvaluate_MissingObservation(t *testing.T) {
obs := &fakeObs{store: map[string]any{}}
r := allRules[0]
states := r.Evaluate(context.Background(), obs, sdk.CheckerOptions{})
if len(states) != 1 || states[0].Status != sdk.StatusError {
t.Fatalf("expected single Error state, got %+v", states)
}
}
func TestRulesUniqueNames(t *testing.T) {
seen := map[string]bool{}
for _, r := range allRules {
if seen[r.name] {
t.Errorf("duplicate rule name: %s", r.name)
}
seen[r.name] = true
if r.check == nil {
t.Errorf("rule %s has nil check func", r.name)
}
if r.okMessage == "" {
t.Errorf("rule %s has empty okMessage", r.name)
}
}
}