author | Alberto Bertogli
<albertito@blitiri.com.ar> 2019-10-13 14:13:33 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2019-10-14 12:18:21 UTC |
parent | 53949317bf979e0a995cd0c0a048fe2b47f6ebae |
testdata/rfc4408-tests.yml | +8 | -8 |
testdata/rfc7208-tests.yml | +8 | -8 |
testdata/simple-tests.yml | +40 | -0 |
yml_test.go | +294 | -0 |
diff --git a/testdata/rfc4408-tests.yml b/testdata/rfc4408-tests.yml index cdbebb4..2e158be 100644 --- a/testdata/rfc4408-tests.yml +++ b/testdata/rfc4408-tests.yml @@ -139,7 +139,7 @@ tests: result: fail zonedata: example.com: - - TIMEOUT + - TIMEOUT: true example.net: - SPF: v=spf1 -all exp=exp.example.net a.example.net: @@ -233,17 +233,17 @@ zonedata: - TXT: NONE spftimeout.example.net: - TXT: v=spf1 -all - - TIMEOUT + - TIMEOUT: true txttimeout.example.net: - SPF: v=spf1 -all - TXT: NONE - - TIMEOUT + - TIMEOUT: true nospftxttimeout.example.net: - SPF: "v=spf3 !a:yahoo.com -all" - TXT: NONE - - TIMEOUT + - TIMEOUT: true alltimeout.example.net: - - TIMEOUT + - TIMEOUT: true --- description: Selecting records tests: @@ -1061,7 +1061,7 @@ zonedata: ip7.example.com: - SPF: v=spf1 ip4:1.2.3.7 ?all ip8.example.com: - - TIMEOUT + - TIMEOUT: true erehwon.example.com: - TXT: v=spfl am not an SPF record e1.example.com: @@ -1381,7 +1381,7 @@ zonedata: mail6.example.com: - AAAA: CAFE:BABE::4 err.example.com: - - TIMEOUT + - TIMEOUT: true e1.example.com: - SPF: "v=spf1 exists:" e2.example.com: @@ -1943,7 +1943,7 @@ zonedata: e21.example.com: - SPF: v=spf1 exp=e21msg.example.com -all e21msg.example.com: - - TIMEOUT + - TIMEOUT: true e22.example.com: - SPF: v=spf1 exp=mail.example.com -all nonascii.example.com: diff --git a/testdata/rfc7208-tests.yml b/testdata/rfc7208-tests.yml index c65a0f3..8427c47 100644 --- a/testdata/rfc7208-tests.yml +++ b/testdata/rfc7208-tests.yml @@ -158,7 +158,7 @@ tests: result: pass zonedata: example.com: - - TIMEOUT + - TIMEOUT: true example.net: - SPF: v=spf1 -all exp=exp.example.net a.example.net: @@ -262,17 +262,17 @@ zonedata: - TXT: NONE spftimeout.example.net: - TXT: v=spf1 -all - - TIMEOUT + - TIMEOUT: true txttimeout.example.net: - SPF: v=spf1 -all - TXT: NONE - - TIMEOUT + - TIMEOUT: true nospftxttimeout.example.net: - SPF: "v=spf3 !a:yahoo.com -all" - TXT: NONE - - TIMEOUT + - TIMEOUT: true alltimeout.example.net: - - TIMEOUT + - TIMEOUT: true --- description: Selecting records tests: @@ -1138,7 +1138,7 @@ zonedata: ip7.example.com: - SPF: v=spf1 ip4:1.2.3.7 ?all ip8.example.com: - - TIMEOUT + - TIMEOUT: true erehwon.example.com: - TXT: v=spfl am not an SPF record e1.example.com: @@ -1463,7 +1463,7 @@ zonedata: mail6.example.com: - AAAA: CAFE:BABE::4 err.example.com: - - TIMEOUT + - TIMEOUT: true e1.example.com: - SPF: "v=spf1 exists:" e2.example.com: @@ -2036,7 +2036,7 @@ zonedata: e21.example.com: - SPF: v=spf1 exp=e21msg.example.com -all e21msg.example.com: - - TIMEOUT + - TIMEOUT: true e22.example.com: - SPF: v=spf1 exp=mail.example.com -all nonascii.example.com: diff --git a/testdata/simple-tests.yml b/testdata/simple-tests.yml new file mode 100644 index 0000000..200db62 --- /dev/null +++ b/testdata/simple-tests.yml @@ -0,0 +1,40 @@ +# Simple tests, used for debugging the testing infrastructure. + +--- +description: Simple successes +tests: + test1: + description: Straightforward sucesss + helo: example.net + mailfrom: "foobar@example.net" + host: 1.2.3.4 + result: pass + test2: + description: HELO is set, but expected to be ignored + helo: blargh + mailfrom: "foobar@example.net" + host: 1.2.3.4 + result: pass +zonedata: + example.net: + - SPF: v=spf1 +all +--- +description: Simple failures +tests: + test1: + description: Straightforward failure + helo: example.net + mailfrom: "foobar@example.net" + host: 1.2.3.4 + result: fail + test2: + description: HELO is set, but expected to be ignored + helo: blargh + mailfrom: "foobar@example.net" + host: 1.2.3.4 + result: fail +zonedata: + example.net: + - SPF: v=spf1 -all + + diff --git a/yml_test.go b/yml_test.go new file mode 100644 index 0000000..48c20a8 --- /dev/null +++ b/yml_test.go @@ -0,0 +1,294 @@ +package spf + +import ( + "flag" + "fmt" + "io" + "net" + "os" + "strings" + "testing" + + "gopkg.in/yaml.v2" +) + +var ( + ymlSingle = flag.String("yml_single", "", + "run only the test with this name") +) + +////////////////////////////////////////////////////// +// YAML test suite parsing. +// + +type Suite struct { + Description string + Tests map[string]Test + ZoneData map[string][]Record `yaml:"zonedata"` +} + +type Test struct { + Description string + Comment string + Spec stringSlice + Helo string + Host string + MailFrom string `yaml:"mailfrom"` + Result stringSlice + Explanation string +} + +// Only one of these will be set. +type Record struct { + A stringSlice `yaml:"A"` + AAAA stringSlice `yaml:"AAAA"` + MX *MX `yaml:"MX"` + SPF stringSlice `yaml:"SPF"` + TXT stringSlice `yaml:"TXT"` + PTR stringSlice `yaml:"PTR"` + CNAME stringSlice `yaml:"CNAME"` + TIMEOUT bool `yaml:"TIMEOUT"` +} + +func (r Record) String() string { + if len(r.A) > 0 { + return fmt.Sprintf("A: %v", r.A) + } + if len(r.AAAA) > 0 { + return fmt.Sprintf("AAAA: %v", r.AAAA) + } + if r.MX != nil { + return fmt.Sprintf("MX: %v", *r.MX) + } + if len(r.SPF) > 0 { + return fmt.Sprintf("SPF: %v", r.SPF) + } + if len(r.TXT) > 0 { + return fmt.Sprintf("TXT: %v", r.TXT) + } + if len(r.PTR) > 0 { + return fmt.Sprintf("PTR: %v", r.PTR) + } + if len(r.CNAME) > 0 { + return fmt.Sprintf("CNAME: %v", r.CNAME) + } + if r.TIMEOUT { + return "TIMEOUT" + } + return fmt.Sprintf("<empty>") +} + +// String slice with a custom yaml unmarshaller, because the yaml parser can't +// handle single-element entries. +// https://github.com/go-yaml/yaml/issues/100 +type stringSlice []string + +func (sl *stringSlice) UnmarshalYAML(unmarshal func(interface{}) error) error { + // Try a slice first, and if it works, return it. + slice := []string{} + if err := unmarshal(&slice); err == nil { + *sl = slice + return nil + } + + // Get a single string, and append it. + single := "" + if err := unmarshal(&single); err != nil { + return err + } + *sl = []string{single} + return nil +} + +// MX is encoded as: +// MX: [0, mail.example.com] +// so we have a custom decoder to handle the multi-typed list. +type MX struct { + Prio uint16 + Host string +} + +func (mx *MX) UnmarshalYAML(unmarshal func(interface{}) error) error { + seq := []interface{}{} + if err := unmarshal(&seq); err != nil { + return err + } + + mx.Prio = uint16(seq[0].(int)) + mx.Host = seq[1].(string) + return nil +} + +////////////////////////////////////////////////////// +// Test runners. +// + +func testRFC(t *testing.T, fname string) { + + //data, err := ioutil.ReadFile(fname) + input, err := os.Open(fname) + if err != nil { + t.Fatal(err) + } + + suites := []Suite{} + dec := yaml.NewDecoder(input) + for { + s := Suite{} + err = dec.Decode(&s) + if err == io.EOF { + break + } + //err = yaml.Unmarshal(data, suites) + if err != nil { + t.Fatal(err) + } + suites = append(suites, s) + } + + for _, suite := range suites { + t.Logf("suite: %v", suite.Description) + + // Set up zone for the suite based on zonedata. + dns = NewDNS() + for domain, records := range suite.ZoneData { + t.Logf(" domain %v", domain) + for _, record := range records { + t.Logf(" %v", record) + if record.TIMEOUT { + err := &net.DNSError{ + Err: "test timeout error", + IsTimeout: true, + } + dns.errors[domain] = err + } + for _, s := range record.A { + dns.ip[domain] = append(dns.ip[domain], net.ParseIP(s)) + } + for _, s := range record.AAAA { + dns.ip[domain] = append(dns.ip[domain], net.ParseIP(s)) + } + for _, s := range record.TXT { + dns.txt[domain] = append(dns.txt[domain], s) + } + if record.MX != nil { + dns.mx[domain] = append(dns.mx[domain], + &net.MX{record.MX.Host, record.MX.Prio}) + } + for _, s := range record.PTR { + // domain in this case is of the form: + // 4.3.2.1.in-addr.arpa + // 1.0.0.0.0.[...].0.0.E.B.A.B.E.F.A.C.ip6.arpa + // We need to extract the normal string representation for + // them, and add the record to dns.addr[ip.String()]. + // Enforce that the record is fully qualified, that's what + // we expect to see in practice. + if !strings.HasSuffix(s, ".") { + s += "." + } + ip := reverseDNS(t, domain).String() + dns.addr[ip] = append(dns.addr[ip], s) + } + // TODO: CNAME + } + + // The test suite is not well done: some tests use SPF instead of + // TXT because they are old, and others expect the lookup to try + // TXT first and SPF later, even though that's forbidden by the + // standard. + // To try to minimize changes to the suite, we work around this by + // only adding records from SPF if there is no TXT already. + // We need to do this in a separate step because order of + // appeareance is not guaranteed. + if len(dns.txt[domain]) == 0 { + for _, record := range records { + if len(record.SPF) > 0 { + // The test suite expect a single-line SPF record to be + // concatenated without spaces. + dns.txt[domain] = append(dns.txt[domain], + strings.Join(record.SPF, "")) + } + } + } + } + + // Run each test. + for name, test := range suite.Tests { + if *ymlSingle != "" && *ymlSingle != name { + continue + } + t.Logf(" test %s", name) + ip := net.ParseIP(test.Host) + t.Logf(" checkhost %v %v", ip, test.MailFrom) + res, err := CheckHostWithSender( + net.ParseIP(test.Host), test.Helo, test.MailFrom) + if !resultIn(res, test.Result) { + t.Errorf(" failed: expected %v, got %v (%v) [%v]", + test.Result, res, err, name) + } else { + t.Logf(" success: %v, %v [%v]", res, err, name) + } + } + } +} + +func resultIn(got Result, exp []string) bool { + for _, e := range exp { + if e == string(got) { + return true + } + } + return false +} + +// Take a reverse-dns host name of the form: +// 4.3.2.1.in-addr.arpa +// 1.0.0.0.0.[...].0.0.E.B.A.B.E.F.A.C.ip6.arpa +// and returns the corresponding ip. +func reverseDNS(t *testing.T, r string) net.IP { + s := "" + if strings.HasSuffix(r, ".in-addr.arpa") { + // Strip suffix. + r := r[:len(r)-len(".in-addr.arpa")] + + // Break down in pieces, and construct the ipv4 string backwards. + pieces := strings.Split(r, ".") + for i := 0; i < len(pieces); i++ { + s += pieces[len(pieces)-1-i] + "." + } + s = s[:len(s)-1] + } else if strings.HasSuffix(r, ".ip6.arpa") { + // Strip suffix. + r := r[:len(r)-len(".ip6.arpa")] + + // Break down in pieces, and construct the ipv6 string backwards. + pieces := strings.Split(r, ".") + for i := 0; i < len(pieces); i++ { + s += pieces[len(pieces)-1-i] + if i%4 == 3 { + s += ":" + } + } + s = s[:len(s)-1] + } else { + t.Fatalf("invalid reverse dns %q: invalid suffix", r) + } + + ip := net.ParseIP(s) + if ip == nil { + t.Fatalf("invalid reverse dns %q: bad ip %q", r, s) + } + return ip +} + +func TestSimple(t *testing.T) { + testRFC(t, "testdata/simple-tests.yml") +} + +func TestRFC4408(t *testing.T) { + testRFC(t, "testdata/rfc4408-tests.yml") +} + +func TestRFC7208(t *testing.T) { + testRFC(t, "testdata/rfc7208-tests.yml") +}