Initial commit
This commit is contained in:
commit
debf799975
25 changed files with 3732 additions and 0 deletions
330
checker/rules_test.go
Normal file
330
checker/rules_test.go
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
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.StatusInfo {
|
||||
t.Fatalf("PGP rule on SMIMEA kind should yield single Info 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue