350 lines
11 KiB
Go
350 lines
11 KiB
Go
package checker
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
// JSON-round-tripping mirrors the production read path so tests catch tag
|
|
// drift between AliasData fields and rule expectations.
|
|
type fakeObs struct {
|
|
data *AliasData
|
|
}
|
|
|
|
func (f fakeObs) Get(_ context.Context, _ sdk.ObservationKey, dest any) error {
|
|
if f.data == nil {
|
|
return nil
|
|
}
|
|
raw, err := json.Marshal(f.data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal(raw, dest)
|
|
}
|
|
|
|
func (fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func run(r sdk.CheckRule, data *AliasData, opts sdk.CheckerOptions) []sdk.CheckState {
|
|
return r.Evaluate(context.Background(), fakeObs{data: data}, opts)
|
|
}
|
|
|
|
// apexKnownData returns a minimal AliasData whose apex lookup succeeded, used
|
|
// as a baseline so non-apex rules can run.
|
|
func apexKnownData() *AliasData {
|
|
return &AliasData{
|
|
Owner: "www.example.com.",
|
|
Apex: "example.com.",
|
|
}
|
|
}
|
|
|
|
func assertSkipped(t *testing.T, states []sdk.CheckState, wantSubstr string) {
|
|
t.Helper()
|
|
if len(states) != 1 {
|
|
t.Fatalf("want 1 state, got %d: %+v", len(states), states)
|
|
}
|
|
if states[0].Status != sdk.StatusUnknown {
|
|
t.Fatalf("want StatusUnknown (skipped), got %v", states[0].Status)
|
|
}
|
|
if !strings.Contains(states[0].Message, "skipped") || !strings.Contains(states[0].Message, wantSubstr) {
|
|
t.Fatalf("want skipped message containing %q, got %q", wantSubstr, states[0].Message)
|
|
}
|
|
}
|
|
|
|
func assertSingle(t *testing.T, states []sdk.CheckState, want sdk.Status) sdk.CheckState {
|
|
t.Helper()
|
|
if len(states) != 1 {
|
|
t.Fatalf("want 1 state, got %d: %+v", len(states), states)
|
|
}
|
|
if states[0].Status != want {
|
|
t.Fatalf("want status %v, got %v (msg=%q)", want, states[0].Status, states[0].Message)
|
|
}
|
|
return states[0]
|
|
}
|
|
|
|
func TestApexLookupRule(t *testing.T) {
|
|
t.Run("ok", func(t *testing.T) {
|
|
s := assertSingle(t, run(apexLookupRule{}, apexKnownData(), nil), sdk.StatusOK)
|
|
if s.Subject != "example.com." {
|
|
t.Fatalf("want subject=example.com., got %q", s.Subject)
|
|
}
|
|
})
|
|
t.Run("failure", func(t *testing.T) {
|
|
data := &AliasData{Owner: "www.nope.invalid.", ApexLookupError: "no SOA"}
|
|
s := assertSingle(t, run(apexLookupRule{}, data, nil), sdk.StatusCrit)
|
|
if s.Meta[hintKey] == nil {
|
|
t.Fatalf("want hint, got none")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestChainLoopRule(t *testing.T) {
|
|
t.Run("ok", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ChainTerminated.Reason = TermOK
|
|
assertSingle(t, run(chainLoopRule{}, d, nil), sdk.StatusOK)
|
|
})
|
|
t.Run("loop", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ChainTerminated = ChainTermination{Reason: TermLoop, Subject: "a.example.com."}
|
|
s := assertSingle(t, run(chainLoopRule{}, d, nil), sdk.StatusCrit)
|
|
if s.Subject != "a.example.com." {
|
|
t.Fatalf("want subject to be loop offender, got %q", s.Subject)
|
|
}
|
|
})
|
|
t.Run("skip when apex unknown", func(t *testing.T) {
|
|
d := &AliasData{Owner: "x.", ApexLookupError: "boom"}
|
|
assertSkipped(t, run(chainLoopRule{}, d, nil), "apex")
|
|
})
|
|
}
|
|
|
|
func TestChainLengthRule(t *testing.T) {
|
|
t.Run("ok", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ChainTerminated.Reason = TermOK
|
|
assertSingle(t, run(chainLengthRule{}, d, nil), sdk.StatusOK)
|
|
})
|
|
t.Run("too long", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ChainTerminated = ChainTermination{Reason: TermTooLong, Subject: "deep.example.com."}
|
|
assertSingle(t, run(chainLengthRule{}, d, sdk.CheckerOptions{"maxChainLength": float64(3)}), sdk.StatusCrit)
|
|
})
|
|
}
|
|
|
|
func TestChainQueryErrorRule(t *testing.T) {
|
|
t.Run("ok", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ChainTerminated.Reason = TermOK
|
|
assertSingle(t, run(chainQueryErrorRule{}, d, nil), sdk.StatusOK)
|
|
})
|
|
t.Run("query err", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ChainTerminated = ChainTermination{Reason: TermQueryErr, Subject: "broken.example.com.", Detail: "timeout"}
|
|
assertSingle(t, run(chainQueryErrorRule{}, d, nil), sdk.StatusWarn)
|
|
})
|
|
}
|
|
|
|
func TestChainRcodeRule(t *testing.T) {
|
|
t.Run("ok", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ChainTerminated.Reason = TermOK
|
|
assertSingle(t, run(chainRcodeRule{}, d, nil), sdk.StatusOK)
|
|
})
|
|
t.Run("mid-chain NXDOMAIN", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ChainTerminated = ChainTermination{Reason: TermRcode, Subject: "gone.example.com.", Rcode: "NXDOMAIN"}
|
|
assertSingle(t, run(chainRcodeRule{}, d, nil), sdk.StatusCrit)
|
|
})
|
|
t.Run("final rcode", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ChainTerminated.Reason = TermOK
|
|
d.FinalTarget = "target.example."
|
|
d.FinalRcode = "SERVFAIL"
|
|
states := run(chainRcodeRule{}, d, nil)
|
|
if len(states) != 1 || states[0].Status != sdk.StatusWarn {
|
|
t.Fatalf("want single WARN, got %+v", states)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHopTTLRule(t *testing.T) {
|
|
t.Run("ok", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.Chain = []ChainHop{{Owner: "a.", Kind: KindCNAME, Target: "b.", TTL: 300}}
|
|
d.ChainTerminated.Reason = TermOK
|
|
assertSingle(t, run(hopTTLRule{}, d, nil), sdk.StatusOK)
|
|
})
|
|
t.Run("multi-subject low TTL", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.Chain = []ChainHop{
|
|
{Owner: "a.", Kind: KindCNAME, Target: "b.", TTL: 10},
|
|
{Owner: "b.", Kind: KindCNAME, Target: "c.", TTL: 20},
|
|
{Owner: "c.", Kind: KindTarget},
|
|
}
|
|
states := run(hopTTLRule{}, d, sdk.CheckerOptions{"minTargetTTL": float64(60)})
|
|
if len(states) != 2 {
|
|
t.Fatalf("want 2 states (one per low-TTL hop), got %d: %+v", len(states), states)
|
|
}
|
|
for _, s := range states {
|
|
if s.Status != sdk.StatusWarn {
|
|
t.Fatalf("want WARN, got %v", s.Status)
|
|
}
|
|
}
|
|
})
|
|
t.Run("skip empty chain", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
assertSkipped(t, run(hopTTLRule{}, d, nil), "chain is empty")
|
|
})
|
|
}
|
|
|
|
func TestCnameAtApexRule(t *testing.T) {
|
|
t.Run("ok when no cname at apex", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.OwnerIsApex = true
|
|
d.Owner = "example.com."
|
|
assertSingle(t, run(cnameAtApexRule{}, d, nil), sdk.StatusOK)
|
|
})
|
|
t.Run("crit when apex has cname", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.OwnerIsApex = true
|
|
d.ApexHasCNAME = true
|
|
d.Owner = "example.com."
|
|
assertSingle(t, run(cnameAtApexRule{}, d, nil), sdk.StatusCrit)
|
|
})
|
|
t.Run("warn when allowApexCNAME", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.OwnerIsApex = true
|
|
d.ApexHasCNAME = true
|
|
d.Owner = "example.com."
|
|
assertSingle(t, run(cnameAtApexRule{}, d, sdk.CheckerOptions{"allowApexCNAME": true}), sdk.StatusWarn)
|
|
})
|
|
t.Run("skip when not apex", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
assertSkipped(t, run(cnameAtApexRule{}, d, nil), "apex")
|
|
})
|
|
}
|
|
|
|
func TestApexFlatteningRule(t *testing.T) {
|
|
t.Run("ok when no flattening", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.OwnerIsApex = true
|
|
assertSingle(t, run(apexFlatteningRule{}, d, nil), sdk.StatusOK)
|
|
})
|
|
t.Run("info when flattening recognized", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.OwnerIsApex = true
|
|
d.ApexFlattening = true
|
|
assertSingle(t, run(apexFlatteningRule{}, d, nil), sdk.StatusInfo)
|
|
})
|
|
t.Run("skip when recognizeApexFlattening=false", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.OwnerIsApex = true
|
|
d.ApexFlattening = true
|
|
assertSkipped(t, run(apexFlatteningRule{}, d, sdk.CheckerOptions{"recognizeApexFlattening": false}), "recognizeApexFlattening")
|
|
})
|
|
}
|
|
|
|
func TestCnameCoexistenceRule(t *testing.T) {
|
|
t.Run("ok", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.OwnerHasCNAME = true
|
|
assertSingle(t, run(cnameCoexistenceRule{}, d, nil), sdk.StatusOK)
|
|
})
|
|
t.Run("multi-subject crit", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.OwnerHasCNAME = true
|
|
d.Coexisting = []CoexistingRRset{{Type: "MX"}, {Type: "TXT"}}
|
|
states := run(cnameCoexistenceRule{}, d, nil)
|
|
if len(states) != 2 {
|
|
t.Fatalf("want 2 states, got %d", len(states))
|
|
}
|
|
})
|
|
t.Run("apex A/AAAA excused by flattening", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.Owner = "example.com."
|
|
d.OwnerIsApex = true
|
|
d.OwnerHasCNAME = true
|
|
d.ApexFlattening = true
|
|
d.Coexisting = []CoexistingRRset{{Type: "A"}, {Type: "AAAA"}, {Type: "MX"}}
|
|
states := run(cnameCoexistenceRule{}, d, nil)
|
|
// Only MX remains, A/AAAA excused.
|
|
if len(states) != 1 {
|
|
t.Fatalf("want 1 state (MX only), got %d: %+v", len(states), states)
|
|
}
|
|
if states[0].Code != "MX" {
|
|
t.Fatalf("want code=MX, got %q", states[0].Code)
|
|
}
|
|
})
|
|
t.Run("skip without cname", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
assertSkipped(t, run(cnameCoexistenceRule{}, d, nil), "owner has no CNAME")
|
|
})
|
|
}
|
|
|
|
func TestCnameDnssecRule(t *testing.T) {
|
|
t.Run("skip unsigned zone", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.OwnerHasCNAME = true
|
|
assertSkipped(t, run(cnameDnssecRule{}, d, nil), "zone not DNSSEC")
|
|
})
|
|
t.Run("ok when signed", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ZoneSigned = true
|
|
d.OwnerHasCNAME = true
|
|
d.CNAMESigCheckDone = true
|
|
d.CNAMESigned = true
|
|
assertSingle(t, run(cnameDnssecRule{}, d, nil), sdk.StatusOK)
|
|
})
|
|
t.Run("crit when unsigned cname", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ZoneSigned = true
|
|
d.OwnerHasCNAME = true
|
|
d.CNAMESigCheckDone = true
|
|
assertSingle(t, run(cnameDnssecRule{}, d, nil), sdk.StatusCrit)
|
|
})
|
|
}
|
|
|
|
func TestTargetResolvableRule(t *testing.T) {
|
|
t.Run("ok", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ChainTerminated.Reason = TermOK
|
|
d.FinalTarget = "target."
|
|
d.FinalA = []string{"1.2.3.4"}
|
|
assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusOK)
|
|
})
|
|
t.Run("crit by default", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ChainTerminated.Reason = TermOK
|
|
d.FinalTarget = "target."
|
|
assertSingle(t, run(targetResolvableRule{}, d, nil), sdk.StatusCrit)
|
|
})
|
|
t.Run("warn when requireResolvableTarget=false", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ChainTerminated.Reason = TermOK
|
|
d.FinalTarget = "target."
|
|
assertSingle(t, run(targetResolvableRule{}, d, sdk.CheckerOptions{"requireResolvableTarget": false}), sdk.StatusWarn)
|
|
})
|
|
t.Run("skip when chain did not terminate normally", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.ChainTerminated.Reason = TermLoop
|
|
assertSkipped(t, run(targetResolvableRule{}, d, nil), "chain did not terminate normally")
|
|
})
|
|
}
|
|
|
|
func TestMultipleRecordsRule(t *testing.T) {
|
|
t.Run("ok", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.Chain = []ChainHop{{Owner: "a.", Kind: KindCNAME, Target: "b."}}
|
|
assertSingle(t, run(multipleRecordsRule{}, d, nil), sdk.StatusOK)
|
|
})
|
|
t.Run("duplicate owner", func(t *testing.T) {
|
|
d := apexKnownData()
|
|
d.Chain = []ChainHop{
|
|
{Owner: "dup.", Kind: KindCNAME, Target: "b."},
|
|
{Owner: "dup.", Kind: KindCNAME, Target: "c."},
|
|
}
|
|
states := run(multipleRecordsRule{}, d, nil)
|
|
if len(states) != 1 || states[0].Status != sdk.StatusCrit {
|
|
t.Fatalf("want 1 CRIT, got %+v", states)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Sanity: every rule registered in the Definition returns at least one state
|
|
// even when asked to judge a blank AliasData (apex lookup failed). This guards
|
|
// against a rule slipping through with an empty-slice return path that would
|
|
// be replaced by the SDK with StatusUnknown.
|
|
func TestAllRulesAlwaysReturnAtLeastOneState(t *testing.T) {
|
|
blank := &AliasData{ApexLookupError: "no apex"}
|
|
for _, r := range Definition().Rules {
|
|
got := run(r, blank, nil)
|
|
if len(got) == 0 {
|
|
t.Fatalf("rule %s returned no states", r.Name())
|
|
}
|
|
}
|
|
}
|