mirror of
https://github.com/StackExchange/dnscontrol.git
synced 2025-01-11 09:59:59 +08:00
9812ecd9ff
* github.com/miekg/dns * Greatly simplify the logic for handling serial numbers. Related code was all over the place. Now it is abstracted into one testable method makeSoa. This simplifies code in many other places. * Update docs/_providers/bind.md: Edit old text. Add SOA description. * SOA records are now treated like any other record internally. You still can't specify them in dnsconfig.js, but that's by design. * The URL for issue 491 was wrong in many places * BIND: Clarify GENERATE_ZONEFILE message
531 lines
15 KiB
Go
531 lines
15 KiB
Go
package prettyzone
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"log"
|
|
"math/rand"
|
|
"testing"
|
|
|
|
"github.com/StackExchange/dnscontrol/v2/models"
|
|
"github.com/miekg/dns"
|
|
"github.com/miekg/dns/dnsutil"
|
|
)
|
|
|
|
func parseAndRegen(t *testing.T, buf *bytes.Buffer, expected string) {
|
|
// Take a zonefile, parse it, then generate a zone. We should
|
|
// get back the same string.
|
|
// This is used after any WriteZoneFile test as an extra verification step.
|
|
|
|
// Parse the output:
|
|
var parsed []dns.RR
|
|
for x := range dns.ParseZone(buf, "bosun.org", "bosun.org.zone") {
|
|
if x.Error != nil {
|
|
log.Fatalf("Error in zonefile: %v", x.Error)
|
|
} else {
|
|
parsed = append(parsed, x.RR)
|
|
}
|
|
}
|
|
// Generate it back:
|
|
buf2 := &bytes.Buffer{}
|
|
WriteZoneFileRR(buf2, parsed, "bosun.org")
|
|
|
|
// Compare:
|
|
if buf2.String() != expected {
|
|
t.Fatalf("Regenerated zonefile does not match: got=(\n%v\n)\nexpected=(\n%v\n)\n", buf2.String(), expected)
|
|
}
|
|
}
|
|
|
|
func TestMostCommonTtl(t *testing.T) {
|
|
var records []dns.RR
|
|
var g, e uint32
|
|
r1, _ := dns.NewRR("bosun.org. 100 IN A 1.1.1.1")
|
|
r2, _ := dns.NewRR("bosun.org. 200 IN A 1.1.1.1")
|
|
r3, _ := dns.NewRR("bosun.org. 300 IN A 1.1.1.1")
|
|
r4, _ := dns.NewRR("bosun.org. 400 IN NS foo.bosun.org.")
|
|
r5, _ := dns.NewRR("bosun.org. 400 IN NS bar.bosun.org.")
|
|
|
|
// All records are TTL=100
|
|
records = nil
|
|
records, e = append(records, r1, r1, r1), 100
|
|
x := models.RRstoRCs(records, "bosun.org")
|
|
g = mostCommonTTL(x)
|
|
if e != g {
|
|
t.Fatalf("expected %d; got %d\n", e, g)
|
|
}
|
|
|
|
// Mixture of TTLs with an obvious winner.
|
|
records = nil
|
|
records, e = append(records, r1, r2, r2), 200
|
|
g = mostCommonTTL(models.RRstoRCs(records, "bosun.org"))
|
|
if e != g {
|
|
t.Fatalf("expected %d; got %d\n", e, g)
|
|
}
|
|
|
|
// 3-way tie. Largest TTL should be used.
|
|
records = nil
|
|
records, e = append(records, r1, r2, r3), 300
|
|
g = mostCommonTTL(models.RRstoRCs(records, "bosun.org"))
|
|
if e != g {
|
|
t.Fatalf("expected %d; got %d\n", e, g)
|
|
}
|
|
|
|
// NS records are ignored.
|
|
records = nil
|
|
records, e = append(records, r1, r4, r5), 100
|
|
g = mostCommonTTL(models.RRstoRCs(records, "bosun.org"))
|
|
if e != g {
|
|
t.Fatalf("expected %d; got %d\n", e, g)
|
|
}
|
|
|
|
}
|
|
|
|
// func WriteZoneFile
|
|
|
|
func TestWriteZoneFileSimple(t *testing.T) {
|
|
r1, _ := dns.NewRR("bosun.org. 300 IN A 192.30.252.153")
|
|
r2, _ := dns.NewRR("bosun.org. 300 IN A 192.30.252.154")
|
|
r3, _ := dns.NewRR("www.bosun.org. 300 IN CNAME bosun.org.")
|
|
buf := &bytes.Buffer{}
|
|
WriteZoneFileRR(buf, []dns.RR{r1, r2, r3}, "bosun.org")
|
|
expected := `$TTL 300
|
|
@ IN A 192.30.252.153
|
|
IN A 192.30.252.154
|
|
www IN CNAME bosun.org.
|
|
`
|
|
if buf.String() != expected {
|
|
t.Log(buf.String())
|
|
t.Log(expected)
|
|
t.Fatalf("Zone file does not match.")
|
|
}
|
|
|
|
parseAndRegen(t, buf, expected)
|
|
}
|
|
|
|
func TestWriteZoneFileSimpleTtl(t *testing.T) {
|
|
r1, _ := dns.NewRR("bosun.org. 100 IN A 192.30.252.153")
|
|
r2, _ := dns.NewRR("bosun.org. 100 IN A 192.30.252.154")
|
|
r3, _ := dns.NewRR("bosun.org. 100 IN A 192.30.252.155")
|
|
r4, _ := dns.NewRR("www.bosun.org. 300 IN CNAME bosun.org.")
|
|
buf := &bytes.Buffer{}
|
|
WriteZoneFileRR(buf, []dns.RR{r1, r2, r3, r4}, "bosun.org")
|
|
expected := `$TTL 100
|
|
@ IN A 192.30.252.153
|
|
IN A 192.30.252.154
|
|
IN A 192.30.252.155
|
|
www 300 IN CNAME bosun.org.
|
|
`
|
|
if buf.String() != expected {
|
|
t.Log(buf.String())
|
|
t.Log(expected)
|
|
t.Fatalf("Zone file does not match")
|
|
}
|
|
|
|
parseAndRegen(t, buf, expected)
|
|
}
|
|
|
|
func TestWriteZoneFileMx(t *testing.T) {
|
|
// sort by priority
|
|
r1, _ := dns.NewRR("aaa.bosun.org. IN MX 1 aaa.example.com.")
|
|
r2, _ := dns.NewRR("aaa.bosun.org. IN MX 5 aaa.example.com.")
|
|
r3, _ := dns.NewRR("aaa.bosun.org. IN MX 10 aaa.example.com.")
|
|
// same priority? sort by name
|
|
r4, _ := dns.NewRR("bbb.bosun.org. IN MX 10 ccc.example.com.")
|
|
r5, _ := dns.NewRR("bbb.bosun.org. IN MX 10 bbb.example.com.")
|
|
r6, _ := dns.NewRR("bbb.bosun.org. IN MX 10 aaa.example.com.")
|
|
// a mix
|
|
r7, _ := dns.NewRR("ccc.bosun.org. IN MX 40 zzz.example.com.")
|
|
r8, _ := dns.NewRR("ccc.bosun.org. IN MX 40 aaa.example.com.")
|
|
r9, _ := dns.NewRR("ccc.bosun.org. IN MX 1 ttt.example.com.")
|
|
buf := &bytes.Buffer{}
|
|
WriteZoneFileRR(buf, []dns.RR{r1, r2, r3, r4, r5, r6, r7, r8, r9}, "bosun.org")
|
|
if buf.String() != testdataZFMX {
|
|
t.Log(buf.String())
|
|
t.Log(testdataZFMX)
|
|
t.Fatalf("Zone file does not match.")
|
|
}
|
|
parseAndRegen(t, buf, testdataZFMX)
|
|
}
|
|
|
|
var testdataZFMX = `$TTL 3600
|
|
aaa IN MX 1 aaa.example.com.
|
|
IN MX 5 aaa.example.com.
|
|
IN MX 10 aaa.example.com.
|
|
bbb IN MX 10 aaa.example.com.
|
|
IN MX 10 bbb.example.com.
|
|
IN MX 10 ccc.example.com.
|
|
ccc IN MX 1 ttt.example.com.
|
|
IN MX 40 aaa.example.com.
|
|
IN MX 40 zzz.example.com.
|
|
`
|
|
|
|
func TestWriteZoneFileSrv(t *testing.T) {
|
|
// exhibits explicit ttls and long name
|
|
r1, _ := dns.NewRR(`bosun.org. 300 IN SRV 10 10 9999 foo.com.`)
|
|
r2, _ := dns.NewRR(`bosun.org. 300 IN SRV 10 20 5050 foo.com.`)
|
|
r3, _ := dns.NewRR(`bosun.org. 300 IN SRV 10 10 5050 foo.com.`)
|
|
r4, _ := dns.NewRR(`bosun.org. 300 IN SRV 20 10 5050 foo.com.`)
|
|
r5, _ := dns.NewRR(`bosun.org. 300 IN SRV 10 10 5050 foo.com.`)
|
|
buf := &bytes.Buffer{}
|
|
WriteZoneFileRR(buf, []dns.RR{r1, r2, r3, r4, r5}, "bosun.org")
|
|
if buf.String() != testdataZFSRV {
|
|
t.Log(buf.String())
|
|
t.Log(testdataZFSRV)
|
|
t.Fatalf("Zone file does not match.")
|
|
}
|
|
parseAndRegen(t, buf, testdataZFSRV)
|
|
}
|
|
|
|
var testdataZFSRV = `$TTL 300
|
|
@ IN SRV 10 10 5050 foo.com.
|
|
IN SRV 10 10 5050 foo.com.
|
|
IN SRV 10 20 5050 foo.com.
|
|
IN SRV 20 10 5050 foo.com.
|
|
IN SRV 10 10 9999 foo.com.
|
|
`
|
|
|
|
func TestWriteZoneFilePtr(t *testing.T) {
|
|
// exhibits explicit ttls and long name
|
|
r1, _ := dns.NewRR(`bosun.org. 300 IN PTR chell.bosun.org`)
|
|
r2, _ := dns.NewRR(`bosun.org. 300 IN PTR barney.bosun.org.`)
|
|
r3, _ := dns.NewRR(`bosun.org. 300 IN PTR alex.bosun.org.`)
|
|
buf := &bytes.Buffer{}
|
|
WriteZoneFileRR(buf, []dns.RR{r1, r2, r3}, "bosun.org")
|
|
if buf.String() != testdataZFPTR {
|
|
t.Log(buf.String())
|
|
t.Log(testdataZFPTR)
|
|
t.Fatalf("Zone file does not match.")
|
|
}
|
|
parseAndRegen(t, buf, testdataZFPTR)
|
|
}
|
|
|
|
var testdataZFPTR = `$TTL 300
|
|
@ IN PTR alex.bosun.org.
|
|
IN PTR barney.bosun.org.
|
|
IN PTR chell.bosun.org.
|
|
`
|
|
|
|
func TestWriteZoneFileCaa(t *testing.T) {
|
|
// exhibits explicit ttls and long name
|
|
r1, _ := dns.NewRR(`bosun.org. 300 IN CAA 0 issuewild ";"`)
|
|
r2, _ := dns.NewRR(`bosun.org. 300 IN CAA 0 issue "letsencrypt.org"`)
|
|
r3, _ := dns.NewRR(`bosun.org. 300 IN CAA 1 iodef "http://example.com"`)
|
|
r4, _ := dns.NewRR(`bosun.org. 300 IN CAA 0 iodef "https://example.com"`)
|
|
r5, _ := dns.NewRR(`bosun.org. 300 IN CAA 0 iodef "https://example.net"`)
|
|
r6, _ := dns.NewRR(`bosun.org. 300 IN CAA 1 iodef "mailto:example.com"`)
|
|
buf := &bytes.Buffer{}
|
|
WriteZoneFileRR(buf, []dns.RR{r1, r2, r3, r4, r5, r6}, "bosun.org")
|
|
if buf.String() != testdataZFCAA {
|
|
t.Log(buf.String())
|
|
t.Log(testdataZFCAA)
|
|
t.Fatalf("Zone file does not match.")
|
|
}
|
|
parseAndRegen(t, buf, testdataZFCAA)
|
|
}
|
|
|
|
var testdataZFCAA = `$TTL 300
|
|
@ IN CAA 1 iodef "http://example.com"
|
|
IN CAA 1 iodef "mailto:example.com"
|
|
IN CAA 0 iodef "https://example.com"
|
|
IN CAA 0 iodef "https://example.net"
|
|
IN CAA 0 issue "letsencrypt.org"
|
|
IN CAA 0 issuewild ";"
|
|
`
|
|
|
|
// Test 1 of each record type
|
|
|
|
func mustNewRR(s string) dns.RR {
|
|
r, err := dns.NewRR(s)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func TestWriteZoneFileEach(t *testing.T) {
|
|
// Each rtype should be listed in this test exactly once.
|
|
// If an rtype has more than one variations, add a test like TestWriteZoneFileCaa to test each.
|
|
var d []dns.RR
|
|
// #rtype_variations
|
|
d = append(d, mustNewRR(`4.5 300 IN PTR y.bosun.org.`)) // Wouldn't actually be in this domain.
|
|
d = append(d, mustNewRR(`bosun.org. 300 IN A 1.2.3.4`))
|
|
d = append(d, mustNewRR(`bosun.org. 300 IN MX 1 bosun.org.`))
|
|
d = append(d, mustNewRR(`bosun.org. 300 IN TXT "my text"`))
|
|
d = append(d, mustNewRR(`bosun.org. 300 IN AAAA 4500:fe::1`))
|
|
d = append(d, mustNewRR(`bosun.org. 300 IN SRV 10 10 9999 foo.com.`))
|
|
d = append(d, mustNewRR(`bosun.org. 300 IN CAA 0 issue "letsencrypt.org"`))
|
|
d = append(d, mustNewRR(`_443._tcp.bosun.org. 300 IN TLSA 3 1 1 abcdef0`)) // Label must be _port._proto
|
|
d = append(d, mustNewRR(`sub.bosun.org. 300 IN NS bosun.org.`)) // Must be a label with no other records.
|
|
d = append(d, mustNewRR(`x.bosun.org. 300 IN CNAME bosun.org.`)) // Must be a label with no other records.
|
|
buf := &bytes.Buffer{}
|
|
WriteZoneFileRR(buf, d, "bosun.org")
|
|
if buf.String() != testdataZFEach {
|
|
t.Log(buf.String())
|
|
t.Log(testdataZFEach)
|
|
t.Fatalf("Zone file does not match.")
|
|
}
|
|
parseAndRegen(t, buf, testdataZFEach)
|
|
}
|
|
|
|
var testdataZFEach = `$TTL 300
|
|
@ IN A 1.2.3.4
|
|
IN AAAA 4500:fe::1
|
|
IN MX 1 bosun.org.
|
|
IN SRV 10 10 9999 foo.com.
|
|
IN TXT "my text"
|
|
IN CAA 0 issue "letsencrypt.org"
|
|
4.5 IN PTR y.bosun.org.
|
|
_443._tcp IN TLSA 3 1 1 abcdef0
|
|
sub IN NS bosun.org.
|
|
x IN CNAME bosun.org.
|
|
`
|
|
|
|
func TestWriteZoneFileSynth(t *testing.T) {
|
|
r1, _ := dns.NewRR("bosun.org. 300 IN A 192.30.252.153")
|
|
r2, _ := dns.NewRR("bosun.org. 300 IN A 192.30.252.154")
|
|
r3, _ := dns.NewRR("www.bosun.org. 300 IN CNAME bosun.org.")
|
|
rsynm := &models.RecordConfig{Type: "R53_ALIAS", TTL: 300}
|
|
rsynm.SetLabel("myalias", "bosun.org")
|
|
rsynz := &models.RecordConfig{Type: "R53_ALIAS", TTL: 300}
|
|
rsynz.SetLabel("zalias", "bosun.org")
|
|
|
|
recs := models.RRstoRCs([]dns.RR{r1, r2, r3}, "bosun.org")
|
|
recs = append(recs, rsynm)
|
|
recs = append(recs, rsynm)
|
|
recs = append(recs, rsynz)
|
|
|
|
buf := &bytes.Buffer{}
|
|
WriteZoneFileRC(buf, recs, "bosun.org", []string{"c1", "c2", "c3\nc4"})
|
|
expected := `$TTL 300
|
|
; c1
|
|
; c2
|
|
; c3
|
|
; c4
|
|
@ IN A 192.30.252.153
|
|
IN A 192.30.252.154
|
|
;myalias IN R53_ALIAS atype= zone_id=
|
|
;myalias IN R53_ALIAS atype= zone_id=
|
|
www IN CNAME bosun.org.
|
|
;zalias IN R53_ALIAS atype= zone_id=
|
|
`
|
|
if buf.String() != expected {
|
|
t.Log(buf.String())
|
|
t.Log(expected)
|
|
t.Fatalf("Zone file does not match.")
|
|
}
|
|
}
|
|
|
|
// Test sorting
|
|
|
|
func TestWriteZoneFileOrder(t *testing.T) {
|
|
var records []dns.RR
|
|
for i, td := range []string{
|
|
"@",
|
|
"@",
|
|
"@",
|
|
"stackoverflow.com.",
|
|
"*",
|
|
"foo",
|
|
"bar.foo",
|
|
"hip.foo",
|
|
"mup",
|
|
"a.mup",
|
|
"bzt.mup",
|
|
"aaa.bzt.mup",
|
|
"zzz.bzt.mup",
|
|
"nnn.mup",
|
|
"zt.mup",
|
|
"zap",
|
|
} {
|
|
name := dnsutil.AddOrigin(td, "stackoverflow.com.")
|
|
r, _ := dns.NewRR(fmt.Sprintf("%s 300 IN A 1.2.3.%d", name, i))
|
|
records = append(records, r)
|
|
}
|
|
|
|
buf := &bytes.Buffer{}
|
|
WriteZoneFileRR(buf, records, "stackoverflow.com")
|
|
// Compare
|
|
if buf.String() != testdataOrder {
|
|
t.Log("Found:")
|
|
t.Log(buf.String())
|
|
t.Log("Expected:")
|
|
t.Log(testdataOrder)
|
|
t.Fatalf("Zone file does not match.")
|
|
}
|
|
parseAndRegen(t, buf, testdataOrder)
|
|
|
|
// Now shuffle the list many times and make sure it still works:
|
|
for iteration := 5; iteration > 0; iteration-- {
|
|
// Randomize the list:
|
|
perm := rand.Perm(len(records))
|
|
for i, v := range perm {
|
|
records[i], records[v] = records[v], records[i]
|
|
}
|
|
// Generate
|
|
buf := &bytes.Buffer{}
|
|
WriteZoneFileRR(buf, records, "stackoverflow.com")
|
|
// Compare
|
|
if buf.String() != testdataOrder {
|
|
t.Log(buf.String())
|
|
t.Log(testdataOrder)
|
|
t.Fatalf("Zone file does not match.")
|
|
}
|
|
parseAndRegen(t, buf, testdataOrder)
|
|
}
|
|
}
|
|
|
|
var testdataOrder = `$TTL 300
|
|
@ IN A 1.2.3.0
|
|
IN A 1.2.3.1
|
|
IN A 1.2.3.2
|
|
IN A 1.2.3.3
|
|
* IN A 1.2.3.4
|
|
foo IN A 1.2.3.5
|
|
bar.foo IN A 1.2.3.6
|
|
hip.foo IN A 1.2.3.7
|
|
mup IN A 1.2.3.8
|
|
a.mup IN A 1.2.3.9
|
|
bzt.mup IN A 1.2.3.10
|
|
aaa.bzt.mup IN A 1.2.3.11
|
|
zzz.bzt.mup IN A 1.2.3.12
|
|
nnn.mup IN A 1.2.3.13
|
|
zt.mup IN A 1.2.3.14
|
|
zap IN A 1.2.3.15
|
|
`
|
|
|
|
// func formatLine
|
|
|
|
func TestFormatLine(t *testing.T) {
|
|
tests := []struct {
|
|
lengths []int
|
|
fields []string
|
|
expected string
|
|
}{
|
|
{[]int{2, 2, 0}, []string{"a", "b", "c"}, "a b c"},
|
|
{[]int{2, 2, 0}, []string{"aaaaa", "b", "c"}, "aaaaa b c"},
|
|
}
|
|
for _, ts := range tests {
|
|
actual := formatLine(ts.lengths, ts.fields)
|
|
if actual != ts.expected {
|
|
t.Errorf("\"%s\" != \"%s\"", actual, ts.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// func zoneLabelLess
|
|
|
|
func TestZoneLabelLess(t *testing.T) {
|
|
/*
|
|
The zone should sort in prefix traversal order:
|
|
|
|
@
|
|
*
|
|
foo
|
|
bar.foo
|
|
hip.foo
|
|
mup
|
|
a.mup
|
|
bzt.mup
|
|
*.bzt.mup
|
|
1.bzt.mup
|
|
2.bzt.mup
|
|
10.bzt.mup
|
|
aaa.bzt.mup
|
|
zzz.bzt.mup
|
|
nnn.mup
|
|
zt.mup
|
|
zap
|
|
*/
|
|
|
|
var tests = []struct {
|
|
e1, e2 string
|
|
expected bool
|
|
}{
|
|
{"@", "@", false},
|
|
{"@", "*", true},
|
|
{"@", "b", true},
|
|
{"*", "@", false},
|
|
{"*", "*", false},
|
|
{"*", "b", true},
|
|
{"foo", "foo", false},
|
|
{"foo", "bar", false},
|
|
{"bar", "foo", true},
|
|
{"a.mup", "mup", false},
|
|
{"mup", "a.mup", true},
|
|
{"a.mup", "a.mup", false},
|
|
{"a.mup", "bzt.mup", true},
|
|
{"a.mup", "aa.mup", true},
|
|
{"zt.mup", "aaa.bzt.mup", false},
|
|
{"aaa.bzt.mup", "mup", false},
|
|
{"*.bzt.mup", "aaa.bzt.mup", true},
|
|
{"1.bzt.mup", "aaa.bzt.mup", true},
|
|
{"1.bzt.mup", "2.bzt.mup", true},
|
|
{"10.bzt.mup", "2.bzt.mup", false},
|
|
{"nnn.mup", "aaa.bzt.mup", false},
|
|
{`www\.miek.nl`, `www.miek.nl`, false},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
actual := zoneLabelLess(test.e1, test.e2)
|
|
if test.expected != actual {
|
|
t.Errorf("%v: expected (%v) got (%v)\n", test.e1, test.e2, actual)
|
|
}
|
|
actual = zoneLabelLess(test.e2, test.e1)
|
|
// The reverse should work too:
|
|
var expected bool
|
|
if test.e1 == test.e2 {
|
|
expected = false
|
|
} else {
|
|
expected = !test.expected
|
|
}
|
|
if expected != actual {
|
|
t.Errorf("%v: expected (%v) got (%v)\n", test.e1, test.e2, actual)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestZoneRrtypeLess(t *testing.T) {
|
|
/*
|
|
In zonefiles we want to list SOAs, then NSs, then all others.
|
|
*/
|
|
|
|
var tests = []struct {
|
|
e1, e2 string
|
|
expected bool
|
|
}{
|
|
{"SOA", "SOA", false},
|
|
{"SOA", "A", true},
|
|
{"SOA", "TXT", true},
|
|
{"SOA", "NS", true},
|
|
{"NS", "SOA", false},
|
|
{"NS", "A", true},
|
|
{"NS", "TXT", true},
|
|
{"NS", "NS", false},
|
|
{"A", "SOA", false},
|
|
{"A", "A", false},
|
|
{"A", "TXT", true},
|
|
{"A", "NS", false},
|
|
{"MX", "SOA", false},
|
|
{"MX", "A", false},
|
|
{"MX", "TXT", true},
|
|
{"MX", "NS", false},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
actual := zoneRrtypeLess(test.e1, test.e2)
|
|
if test.expected != actual {
|
|
t.Errorf("%v: expected (%v) got (%v)\n", test.e1, test.e2, actual)
|
|
}
|
|
actual = zoneRrtypeLess(test.e2, test.e1)
|
|
// The reverse should work too:
|
|
var expected bool
|
|
if test.e1 == test.e2 {
|
|
expected = false
|
|
} else {
|
|
expected = !test.expected
|
|
}
|
|
if expected != actual {
|
|
t.Errorf("%v: expected (%v) got (%v)\n", test.e1, test.e2, actual)
|
|
}
|
|
}
|
|
}
|