coredns-rfc2136/setup_test.go
Ryan Malloy eba6313ec0 Phase 1.2: wire parser → typed config + 13 unit tests
The Corefile parser now fully populates typed fields on RFC2136 instead
of just recognising directives. Validation happens at parse-time so
configuration errors fail loud at CoreDNS startup rather than silent at
request time.

Added:
- config.go: tsigKey type, TSIG algorithm allowlist (rejects HMAC-MD5
  deliberately), base64 secret decoder with 8-byte minimum length check,
  canonical-key-name normalisation (lowercase + trailing dot).
- plugin.go: RFC2136 struct now carries TSIGKeys map, TTL uint32,
  PersistPath string. DefaultTTL=60.
- setup.go: parse() validates and stores tsig-key/ttl/persist directives.
  Duplicate key names rejected. Multiple TSIG keys allowed (for rotation).
  At-least-one-zone is enforced.
- setup_test.go: 13 table-driven cases (5 happy + 8 error paths) using
  caddy.NewTestController. All pass.

ServeDNS still passes through — UPDATE handling lands in Phase 1.4.

Module path: git.supported.systems/rsp2k/coredns-rfc2136
2026-05-21 10:31:22 -06:00

210 lines
5.9 KiB
Go

package rfc2136
import (
"strings"
"testing"
"github.com/coredns/caddy"
"github.com/miekg/dns"
)
// A 32-byte HMAC-SHA256 secret, base64-encoded. Generated once and
// re-used across tests so failures are reproducible. NEVER use this
// value in production — it's literally in this file in plaintext.
const testSecret = "xTgset4zj7kHqniSslYFn+OcdCf419olek9MNmOvlUM="
// TestParse exercises the Corefile parser across the matrix of valid
// and invalid configurations. Each row is fully self-contained.
func TestParse(t *testing.T) {
tests := []struct {
name string
input string
shouldErr bool
errMatch string // substring to look for in the error (when shouldErr)
check func(t *testing.T, p *RFC2136)
}{
{
name: "single zone, no block",
input: `rfc2136 auth.example.com.`,
check: func(t *testing.T, p *RFC2136) {
wantZone(t, p, "auth.example.com.")
if p.TTL != DefaultTTL {
t.Errorf("TTL = %d, want default %d", p.TTL, DefaultTTL)
}
if len(p.TSIGKeys) != 0 {
t.Errorf("expected 0 TSIG keys, got %d", len(p.TSIGKeys))
}
},
},
{
name: "multiple zones in one directive",
input: `rfc2136 auth.example.com. acme.example.org.`,
check: func(t *testing.T, p *RFC2136) {
if len(p.Zones) != 2 {
t.Fatalf("Zones = %v, want 2", p.Zones)
}
wantZone(t, p, "auth.example.com.")
wantZone(t, p, "acme.example.org.")
},
},
{
name: "full block with all directives",
input: `rfc2136 auth.example.com. {
tsig-key acme-key. hmac-sha256 ` + testSecret + `
ttl 120
persist /var/lib/coredns/rfc2136/auth.db
}`,
check: func(t *testing.T, p *RFC2136) {
wantZone(t, p, "auth.example.com.")
if p.TTL != 120 {
t.Errorf("TTL = %d, want 120", p.TTL)
}
if p.PersistPath != "/var/lib/coredns/rfc2136/auth.db" {
t.Errorf("PersistPath = %q", p.PersistPath)
}
k, ok := p.TSIGKeys["acme-key."]
if !ok {
t.Fatalf("acme-key. not in TSIGKeys; have keys=%v", keysOf(p.TSIGKeys))
}
if k.Algorithm != dns.HmacSHA256 {
t.Errorf("Algorithm = %q, want %q", k.Algorithm, dns.HmacSHA256)
}
if len(k.Secret) != 32 {
t.Errorf("Secret length = %d, want 32 (decoded SHA-256-sized)", len(k.Secret))
}
},
},
{
name: "tsig-key name normalised to trailing-dot lowercase",
input: `rfc2136 auth.example.com. {
tsig-key Acme-Key hmac-sha256 ` + testSecret + `
}`,
check: func(t *testing.T, p *RFC2136) {
if _, ok := p.TSIGKeys["acme-key."]; !ok {
t.Errorf("expected canonicalised key 'acme-key.', got keys=%v", keysOf(p.TSIGKeys))
}
},
},
{
name: "multiple tsig-keys allowed",
input: `rfc2136 auth.example.com. {
tsig-key key-a. hmac-sha256 ` + testSecret + `
tsig-key key-b. hmac-sha512 ` + testSecret + `
}`,
check: func(t *testing.T, p *RFC2136) {
if len(p.TSIGKeys) != 2 {
t.Errorf("expected 2 keys, got %d (%v)", len(p.TSIGKeys), keysOf(p.TSIGKeys))
}
if p.TSIGKeys["key-a."].Algorithm != dns.HmacSHA256 {
t.Errorf("key-a algorithm wrong")
}
if p.TSIGKeys["key-b."].Algorithm != dns.HmacSHA512 {
t.Errorf("key-b algorithm wrong")
}
},
},
// ─── error cases ──────────────────────────────────────────
{
name: "no zone",
input: `rfc2136`,
shouldErr: true,
errMatch: "Wrong argument count",
},
{
name: "unknown directive",
input: `rfc2136 auth.example.com. { bogus value }`,
shouldErr: true,
errMatch: "unknown directive",
},
{
name: "tsig-key with too few args",
input: `rfc2136 auth.example.com. {
tsig-key only-name
}`,
shouldErr: true,
errMatch: "tsig-key requires 3 args",
},
{
name: "unsupported TSIG algorithm",
input: `rfc2136 auth.example.com. {
tsig-key key. hmac-md5 ` + testSecret + `
}`,
shouldErr: true,
errMatch: "unsupported TSIG algorithm",
},
{
name: "malformed base64 secret",
input: `rfc2136 auth.example.com. {
tsig-key key. hmac-sha256 not_base64_at_all!!!
}`,
shouldErr: true,
errMatch: "invalid base64",
},
{
name: "secret too short after decode",
input: `rfc2136 auth.example.com. {
tsig-key key. hmac-sha256 c2hvcnQ=
}`, // "short" → 5 bytes < 8 min
shouldErr: true,
errMatch: "too short",
},
{
name: "duplicate tsig-key name",
input: `rfc2136 auth.example.com. {
tsig-key dup. hmac-sha256 ` + testSecret + `
tsig-key dup. hmac-sha512 ` + testSecret + `
}`,
shouldErr: true,
errMatch: "duplicate tsig-key",
},
{
name: "ttl non-numeric",
input: `rfc2136 auth.example.com. {
ttl not-a-number
}`,
shouldErr: true,
errMatch: "ttl must be a non-negative integer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := caddy.NewTestController("dns", tt.input)
p, err := parse(c)
if (err != nil) != tt.shouldErr {
t.Fatalf("parse() err = %v, shouldErr = %v", err, tt.shouldErr)
}
if err != nil {
if tt.errMatch != "" && !strings.Contains(err.Error(), tt.errMatch) {
t.Errorf("err = %q, expected substring %q", err.Error(), tt.errMatch)
}
return
}
if tt.check != nil {
tt.check(t, p)
}
})
}
}
// wantZone asserts that p.Zones contains the expected canonical zone.
func wantZone(t *testing.T, p *RFC2136, want string) {
t.Helper()
for _, z := range p.Zones {
if z == want {
return
}
}
t.Errorf("zone %q not in p.Zones=%v", want, p.Zones)
}
// keysOf is a debug helper for failure messages.
func keysOf(m map[string]tsigKey) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}