dnscontrol/pkg/js/js_test.go
Tom Limoncelli 1b2f5d4d34
BUGFIX: IDN support is broken for domain names (#3845)
# Issue

Fixes https://github.com/StackExchange/dnscontrol/issues/3842

CC @das7pad

# Resolution

Convert domain.Name to IDN earlier in the pipeline. Hack the --domains
processing to convert everything to IDN.

* Domain names are now stored 3 ways: The original input from
dnsconfig.js, canonical IDN format (`xn--...`), and Unicode format. All
are downcased. Providers that haven't been updated will receive the IDN
format instead of the original input format. This might break some
providers but only for users with unicode in their D("domain.tld").
PLEASE TEST YOUR PROVIDER.
* BIND filename formatting options have been added to access the new
formats.

# Breaking changes

* BIND zonefiles may change. The default used the name input in the D()
statement. It now defaults to the IDN name + "!tag" if there is a tag.
* Providers that are not IDN-aware may break (hopefully only if they
weren't processing IDN already)

---------

Co-authored-by: Jakob Ackermann <das7pad@outlook.com>
2025-11-29 12:17:44 -05:00

173 lines
5.1 KiB
Go

package js
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"unicode"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/normalize"
"github.com/StackExchange/dnscontrol/v4/pkg/prettyzone"
"github.com/StackExchange/dnscontrol/v4/providers"
_ "github.com/StackExchange/dnscontrol/v4/providers/_all"
testifyrequire "github.com/stretchr/testify/require"
)
const (
testDir = "pkg/js/parse_tests"
)
func init() {
// go up a directory so we helpers.js is in a consistent place.
if err := os.Chdir("../.."); err != nil {
panic(err)
}
}
func TestParsedFiles(t *testing.T) {
files, err := os.ReadDir(testDir)
if err != nil {
t.Fatal(err)
}
for _, f := range files {
name := f.Name()
// run all js files that start with a number. Skip others.
if filepath.Ext(name) != ".js" || !unicode.IsNumber(rune(name[0])) {
continue
}
t.Run(name, func(t *testing.T) {
var err error
// Compile the .js file:
conf, err := ExecuteJavaScript(string(filepath.Join(testDir, name)), true, nil)
if err != nil {
t.Fatal(err)
}
errs := normalize.ValidateAndNormalizeConfig(conf)
if len(errs) != 0 {
t.Fatal(errs[0])
}
for _, dc := range conf.Domains {
// fmt.Printf("DEBUG: PrettySort: domain=%q #rec=%d\n", dc.Name, len(dc.Records))
// fmt.Printf("DEBUG: records = %d %v\n", len(dc.Records), dc.Records)
ps := prettyzone.PrettySort(dc.Records, dc.Name, 0, nil)
dc.Records = ps.Records
if len(dc.Records) == 0 {
dc.Records = models.Records{}
}
}
// Initialize any DNS providers mentioned.
for _, dProv := range conf.DNSProviders {
pcfg := map[string]string{}
if dProv.Type == "-" {
// Pretend any "look up provider type in creds.json" results
// in a provider type that actually exists.
dProv.Type = "CLOUDFLAREAPI"
}
// Fake out any provider's validation tests.
switch dProv.Type {
case "CLOUDFLAREAPI":
pcfg["apitoken"] = "fake"
default:
}
_, err := providers.CreateDNSProvider(dProv.Type, pcfg, nil)
if err != nil {
t.Fatal(err)
}
}
// Test the JS compiled as expected (compare to the .json file)
actualJSON, err := json.MarshalIndent(conf, "", " ")
if err != nil {
t.Fatal(err)
}
testName := name[:len(name)-3]
expectedFile := filepath.Join(testDir, testName+".json")
expectedJSON, err := os.ReadFile(expectedFile)
if err != nil {
t.Fatal(err)
}
es := string(expectedJSON)
as := string(actualJSON)
_, _ = es, as
// Leave behind the actual result:
if err := os.WriteFile(expectedFile+".ACTUAL", []byte(as), 0o644); err != nil {
t.Fatal(err)
}
testifyrequire.JSONEqf(t, es, as, "EXPECTING %q = \n```\n%s\n```", expectedFile, as)
// For each domain, if there is a zone file, test against it:
var dCount int
for _, dc := range conf.Domains {
var zoneFile string
if dc.Tag != "" {
zoneFile = filepath.Join(testDir, testName, dc.GetUniqueName()+".zone")
} else {
zoneFile = filepath.Join(testDir, testName, dc.Name+".zone")
}
// fmt.Printf("DEBUG: zonefile = %q\n", zoneFile)
expectedZone, err := os.ReadFile(zoneFile)
if err != nil {
continue
}
dCount++
// Generate the zonefile
var buf bytes.Buffer
err = prettyzone.WriteZoneFileRC(&buf, dc.Records, dc.Name, 300, nil)
if err != nil {
t.Fatal(err)
}
actualZone := buf.String()
es := string(expectedZone)
as := actualZone
if es != as {
// On failure, leave behind the .ACTUAL file.
if err := os.WriteFile(zoneFile+".ACTUAL", []byte(actualZone), 0o644); err != nil {
t.Fatal(err)
}
}
testifyrequire.Equal(t, es, as, "EXPECTING %q =\n```\n%s```", zoneFile, as)
}
if dCount > 0 && (len(conf.Domains) != dCount) {
t.Fatal(fmt.Errorf("only %d of %d domains in %q have zonefiles", dCount, len(conf.Domains), name))
}
})
}
}
func TestErrors(t *testing.T) {
tests := []struct{ desc, text string }{
{"old dsp style", `D("foo.com","reg","dsp")`},
{"MX no priority", `D("foo.com","reg",MX("@","test."))`},
{"MX reversed", `D("foo.com","reg",MX("@","test.", 5))`},
{"CF_REDIRECT With comma", `D("foo.com","reg",CF_REDIRECT("foo.com,","baaa"))`},
{"CF_TEMP_REDIRECT With comma", `D("foo.com","reg",CF_TEMP_REDIRECT("foo.com","baa,a"))`},
{"CF_WORKER_ROUTE With comma", `D("foo.com","reg",CF_WORKER_ROUTE("foo.com","baa,a"))`},
{"ADGUARDHOME_A_PASSTHROUGH With non-empty value", `D("foo.com","reg",ADGUARDHOME_A_PASSTHROUGH("foo","baaa"))`},
{"ADGUARDHOME_AAAA_PASSTHROUGH With non-empty value", `D("foo.com","reg",ADGUARDHOME_AAAA_PASSTHROUGH("foo,","baaa"))`},
{"Bad cidr", `D(reverse("foo.com"), "reg")`},
{"Dup domains", `D("example.org", "reg"); D("example.org", "reg")`},
{"Bad NAMESERVER", `D("example.com","reg", NAMESERVER("@","ns1.foo.com."))`},
{"Bad Hash function", `D(HASH("123", "abc"),"reg")`},
}
for _, tst := range tests {
t.Run(tst.desc, func(t *testing.T) {
if _, err := ExecuteJavaScript(tst.text, true, nil); err == nil {
t.Fatal("Expected error but found none")
}
})
}
}