happyDomain/internal/adapters/libdns-providers_test.go
Pierre-Olivier Mercier 25f37af35d Add libdns provider adapter for supporting libdns-based DNS providers
Introduce a new adapter layer that allows happyDomain to use providers
from the libdns ecosystem alongside the existing dnscontrol providers.
The adapter implements ProviderActuator by converting between miekg/dns
and libdns record formats, reusing the existing DNSControl diff engine
for computing corrections, and generating executable correction functions
that call libdns Append/Delete/Set methods.
2026-03-30 11:58:02 +07:00

331 lines
8.3 KiB
Go

// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package adapter
import (
"context"
"net/netip"
"testing"
"time"
"github.com/libdns/libdns"
"github.com/miekg/dns"
"git.happydns.org/happyDomain/model"
)
// mockLibdnsProvider implements RecordGetter, RecordAppender, RecordDeleter for testing.
type mockLibdnsProvider struct {
records []libdns.Record
appended []libdns.Record
deleted []libdns.Record
zones []libdns.Zone
appendErr error
deleteErr error
getErr error
listZoneErr error
}
func (m *mockLibdnsProvider) GetRecords(_ context.Context, _ string) ([]libdns.Record, error) {
if m.getErr != nil {
return nil, m.getErr
}
return m.records, nil
}
func (m *mockLibdnsProvider) AppendRecords(_ context.Context, _ string, recs []libdns.Record) ([]libdns.Record, error) {
if m.appendErr != nil {
return nil, m.appendErr
}
m.appended = append(m.appended, recs...)
return recs, nil
}
func (m *mockLibdnsProvider) DeleteRecords(_ context.Context, _ string, recs []libdns.Record) ([]libdns.Record, error) {
if m.deleteErr != nil {
return nil, m.deleteErr
}
m.deleted = append(m.deleted, recs...)
return recs, nil
}
func (m *mockLibdnsProvider) ListZones(_ context.Context) ([]libdns.Zone, error) {
if m.listZoneErr != nil {
return nil, m.listZoneErr
}
return m.zones, nil
}
// mockLibdnsConfig implements LibdnsConfigAdapter.
type mockLibdnsConfig struct {
provider any
}
func (m *mockLibdnsConfig) LibdnsProvider() any {
return m.provider
}
func (m *mockLibdnsConfig) InstantiateProvider() (happydns.ProviderActuator, error) {
return NewLibdnsProviderAdapter(m)
}
func TestNewLibdnsProviderAdapter(t *testing.T) {
mock := &mockLibdnsProvider{}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !adapter.CanListZones() {
t.Error("expected CanListZones to be true")
}
if adapter.CanCreateDomain() {
t.Error("expected CanCreateDomain to be false")
}
}
func TestLibdnsAdapter_GetZoneRecords(t *testing.T) {
mock := &mockLibdnsProvider{
records: []libdns.Record{
libdns.Address{
Name: "www",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.1"),
},
libdns.TXT{
Name: "@",
TTL: 300 * time.Second,
Text: "v=spf1 ~all",
},
},
}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
records, err := adapter.GetZoneRecords("example.com.")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(records) != 2 {
t.Fatalf("expected 2 records, got %d", len(records))
}
// Check A record
if records[0].Header().Rrtype != dns.TypeA {
t.Errorf("expected first record to be A, got %s", dns.TypeToString[records[0].Header().Rrtype])
}
// Check TXT record
txt, ok := records[1].(*happydns.TXT)
if !ok {
t.Fatalf("expected second record to be *happydns.TXT, got %T", records[1])
}
if txt.Txt != "v=spf1 ~all" {
t.Errorf("expected TXT 'v=spf1 ~all', got %q", txt.Txt)
}
}
func TestLibdnsAdapter_ListZones(t *testing.T) {
mock := &mockLibdnsProvider{
zones: []libdns.Zone{
{Name: "example.com."},
{Name: "example.org."},
},
}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
zones, err := adapter.ListZones()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(zones) != 2 {
t.Fatalf("expected 2 zones, got %d", len(zones))
}
if zones[0] != "example.com." {
t.Errorf("expected first zone 'example.com.', got %q", zones[0])
}
}
func TestLibdnsAdapter_GetZoneCorrections_NoChanges(t *testing.T) {
records := []libdns.Record{
libdns.Address{
Name: "www",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.1"),
},
}
mock := &mockLibdnsProvider{records: records}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Pass the same records as wanted
aRR, _ := dns.NewRR("www.example.com. 300 IN A 192.0.2.1")
corrections, _, err := adapter.GetZoneCorrections("example.com.", []happydns.Record{aRR})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(corrections) != 0 {
t.Errorf("expected 0 corrections, got %d", len(corrections))
}
}
func TestLibdnsAdapter_GetZoneCorrections_Addition(t *testing.T) {
// Provider has one A record, we want to add a CNAME.
mock := &mockLibdnsProvider{
records: []libdns.Record{
libdns.Address{
Name: "www",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.1"),
},
},
}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
aRR, _ := dns.NewRR("www.example.com. 300 IN A 192.0.2.1")
cnameRR, _ := dns.NewRR("blog.example.com. 300 IN CNAME www.example.com.")
corrections, _, err := adapter.GetZoneCorrections("example.com.", []happydns.Record{aRR, cnameRR})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(corrections) == 0 {
t.Fatal("expected at least 1 correction")
}
// Execute the correction
for _, c := range corrections {
if c.Kind == happydns.CorrectionKindAddition {
if err := c.F(); err != nil {
t.Fatalf("unexpected error executing correction: %v", err)
}
}
}
if len(mock.appended) == 0 {
t.Error("expected records to be appended")
}
}
func TestLibdnsAdapter_GetZoneCorrections_Deletion(t *testing.T) {
// Provider has two records, we want only one.
mock := &mockLibdnsProvider{
records: []libdns.Record{
libdns.Address{
Name: "www",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.1"),
},
libdns.Address{
Name: "old",
TTL: 300 * time.Second,
IP: netip.MustParseAddr("192.0.2.2"),
},
},
}
config := &mockLibdnsConfig{provider: mock}
adapter, err := NewLibdnsProviderAdapter(config)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
aRR, _ := dns.NewRR("www.example.com. 300 IN A 192.0.2.1")
corrections, _, err := adapter.GetZoneCorrections("example.com.", []happydns.Record{aRR})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(corrections) == 0 {
t.Fatal("expected at least 1 correction")
}
// Execute the deletion correction
for _, c := range corrections {
if c.Kind == happydns.CorrectionKindDeletion {
if err := c.F(); err != nil {
t.Fatalf("unexpected error executing correction: %v", err)
}
}
}
if len(mock.deleted) == 0 {
t.Error("expected records to be deleted")
}
}
func TestGetLibdnsProviderCapabilities(t *testing.T) {
mock := &mockLibdnsProvider{}
config := &mockLibdnsConfig{provider: mock}
caps := GetLibdnsProviderCapabilities(config)
// Should include ListDomains since mock implements ZoneLister
found := false
for _, c := range caps {
if c == "ListDomains" {
found = true
break
}
}
if !found {
t.Error("expected ListDomains capability")
}
// Should include common RR types
expectedTypes := []string{"rr-1-A", "rr-28-AAAA", "rr-5-CNAME", "rr-15-MX", "rr-16-TXT"}
for _, expected := range expectedTypes {
found = false
for _, c := range caps {
if c == expected {
found = true
break
}
}
if !found {
t.Errorf("expected capability %s", expected)
}
}
}