039bcc9af9
Normalize Only/Prefer values (ip4→ipv4, ip6→ipv6) so downstream comparisons match correctly. Fix asymmetric fallback where caller network=ip6 fell through to IPv4 but ip4 didn't fall through to IPv6 — both branches now check hard constraints (server.Only || callerPref) only, allowing soft Prefer to fall back. Fix data race on cache item.ts by reading it inside RLock. Fix Store mutating caller's dns.Msg by copying before TTL override. Add singleflight dedup, semaphore-bounded async refresh, DNS rcode error checking, and bump EDNS0 UDPSize to 4096. Add 25 tests.
576 lines
16 KiB
Go
576 lines
16 KiB
Go
package resolver
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
xlogger "github.com/go-gost/x/logger"
|
|
resolver_util "github.com/go-gost/x/internal/util/resolver"
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
// mockExchanger implements exchanger.Exchanger for testing.
|
|
type mockExchanger struct {
|
|
response *dns.Msg
|
|
err error
|
|
calls atomic.Int64
|
|
}
|
|
|
|
func (m *mockExchanger) Exchange(_ context.Context, msg []byte) ([]byte, error) {
|
|
m.calls.Add(1)
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
if m.response != nil {
|
|
return m.response.Pack()
|
|
}
|
|
// Echo back a basic response
|
|
q := new(dns.Msg)
|
|
if err := q.Unpack(msg); err != nil {
|
|
return nil, err
|
|
}
|
|
r := new(dns.Msg)
|
|
r.SetReply(q)
|
|
return r.Pack()
|
|
}
|
|
|
|
func (m *mockExchanger) String() string {
|
|
return "mock://test"
|
|
}
|
|
|
|
// newDNSMsgWithA creates a dns.Msg with A records for the given IPs.
|
|
func newDNSMsgWithA(ips ...string) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetQuestion("example.com.", dns.TypeA)
|
|
for _, ip := range ips {
|
|
m.Answer = append(m.Answer, &dns.A{
|
|
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
|
A: net.ParseIP(ip),
|
|
})
|
|
}
|
|
return m
|
|
}
|
|
|
|
// newDNSMsgWithAAAA creates a dns.Msg with AAAA records for the given IPs.
|
|
func newDNSMsgWithAAAA(ips ...string) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetQuestion("example.com.", dns.TypeAAAA)
|
|
for _, ip := range ips {
|
|
m.Answer = append(m.Answer, &dns.AAAA{
|
|
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 300},
|
|
AAAA: net.ParseIP(ip),
|
|
})
|
|
}
|
|
return m
|
|
}
|
|
|
|
func nopLog() options {
|
|
return options{logger: xlogger.Nop()}
|
|
}
|
|
|
|
// newTestResolver creates a localResolver with cache and nop logger.
|
|
func newTestResolver(servers []NameServer, opts ...Option) *localResolver {
|
|
o := options{logger: xlogger.Nop()}
|
|
for _, opt := range opts {
|
|
opt(&o)
|
|
}
|
|
cache := resolver_util.NewCache().WithLogger(xlogger.Nop())
|
|
return &localResolver{
|
|
servers: servers,
|
|
cache: cache,
|
|
options: o,
|
|
refreshSem: make(chan struct{}, maxAsyncRefresh),
|
|
}
|
|
}
|
|
|
|
func TestResolve_IPLiteral(t *testing.T) {
|
|
r := &localResolver{options: nopLog()}
|
|
|
|
tests := []struct {
|
|
host string
|
|
want string
|
|
}{
|
|
{"1.2.3.4", "1.2.3.4"},
|
|
{"::1", "::1"},
|
|
{"2001:db8::1", "2001:db8::1"},
|
|
}
|
|
for _, tt := range tests {
|
|
ips, err := r.Resolve(context.Background(), "ip", tt.host)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for %s: %v", tt.host, err)
|
|
}
|
|
if len(ips) != 1 || !ips[0].Equal(net.ParseIP(tt.want)) {
|
|
t.Errorf("Resolve(%q) = %v, want [%s]", tt.host, ips, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolve_DomainSuffix(t *testing.T) {
|
|
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
|
|
r := newTestResolver(
|
|
[]NameServer{{exchanger: ex, TTL: time.Minute}},
|
|
DomainOption("example.com"),
|
|
)
|
|
|
|
_, err := r.Resolve(context.Background(), "ip", "host")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if ex.calls.Load() == 0 {
|
|
t.Fatal("expected exchanger to be called")
|
|
}
|
|
}
|
|
|
|
func TestResolve_DomainSuffixNotAppliedToFQDN(t *testing.T) {
|
|
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
|
|
r := newTestResolver(
|
|
[]NameServer{{exchanger: ex, TTL: time.Minute}},
|
|
DomainOption("example.com"),
|
|
)
|
|
|
|
_, err := r.Resolve(context.Background(), "ip", "host.other.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if ex.calls.Load() == 0 {
|
|
t.Fatal("expected exchanger to be called")
|
|
}
|
|
}
|
|
|
|
func TestResolve_MultipleServers_Fallback(t *testing.T) {
|
|
failEx := &mockExchanger{err: net.UnknownNetworkError("fail")}
|
|
okEx := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
|
|
|
|
r := newTestResolver([]NameServer{
|
|
{exchanger: failEx, TTL: time.Minute},
|
|
{exchanger: okEx, TTL: time.Minute},
|
|
})
|
|
|
|
ips, err := r.Resolve(context.Background(), "ip", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ips) == 0 {
|
|
t.Fatal("expected fallback to second server")
|
|
}
|
|
if failEx.calls.Load() == 0 {
|
|
t.Error("expected first server to be called")
|
|
}
|
|
if okEx.calls.Load() == 0 {
|
|
t.Error("expected second server to be called")
|
|
}
|
|
}
|
|
|
|
func TestResolve_AllServersFail(t *testing.T) {
|
|
ex1 := &mockExchanger{err: net.UnknownNetworkError("fail1")}
|
|
ex2 := &mockExchanger{err: net.UnknownNetworkError("fail2")}
|
|
|
|
r := newTestResolver([]NameServer{
|
|
{exchanger: ex1, TTL: time.Minute},
|
|
{exchanger: ex2, TTL: time.Minute},
|
|
})
|
|
|
|
ips, _ := r.Resolve(context.Background(), "ip", "example.com")
|
|
if len(ips) != 0 {
|
|
t.Fatalf("expected no IPs when all servers fail, got %v", ips)
|
|
}
|
|
}
|
|
|
|
func TestResolve_EmptyServers(t *testing.T) {
|
|
r := &localResolver{servers: []NameServer{}, options: nopLog()}
|
|
|
|
ips, err := r.Resolve(context.Background(), "ip", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ips) != 0 {
|
|
t.Fatalf("expected no IPs with no servers, got %v", ips)
|
|
}
|
|
}
|
|
|
|
func TestResolve_IPv4Only(t *testing.T) {
|
|
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
|
|
r := newTestResolver([]NameServer{{exchanger: ex, Only: "ipv4", Prefer: "ipv4", TTL: time.Minute}})
|
|
|
|
ips, err := r.Resolve(context.Background(), "ip", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ips) == 0 {
|
|
t.Fatal("expected IPv4 result")
|
|
}
|
|
}
|
|
|
|
func TestResolve_IPv6Only(t *testing.T) {
|
|
ex := &mockExchanger{response: newDNSMsgWithAAAA("2001:db8::1")}
|
|
r := newTestResolver([]NameServer{{exchanger: ex, Only: "ipv6", Prefer: "ipv6", TTL: time.Minute}})
|
|
|
|
ips, err := r.Resolve(context.Background(), "ip", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ips) == 0 {
|
|
t.Fatal("expected IPv6 result")
|
|
}
|
|
}
|
|
|
|
func TestResolve_IPv6Prefer_FallbackToIPv4(t *testing.T) {
|
|
aEx := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
|
|
r := newTestResolver([]NameServer{{exchanger: aEx, Prefer: "ipv6", TTL: time.Minute}})
|
|
|
|
ips, err := r.Resolve(context.Background(), "ip", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ips) == 0 {
|
|
t.Fatal("expected IPv4 fallback result")
|
|
}
|
|
}
|
|
|
|
func TestResolve_CacheHit(t *testing.T) {
|
|
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
|
|
r := newTestResolver([]NameServer{{exchanger: ex, TTL: time.Minute}})
|
|
|
|
// First call: cache miss → exchanges with server
|
|
ips, err := r.Resolve(context.Background(), "ip", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ips) == 0 {
|
|
t.Fatal("expected result on first call")
|
|
}
|
|
firstCalls := ex.calls.Load()
|
|
|
|
// Second call: should hit cache (TTL = 1 minute)
|
|
ips, err = r.Resolve(context.Background(), "ip", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ips) == 0 {
|
|
t.Fatal("expected cached result")
|
|
}
|
|
if ex.calls.Load() != firstCalls {
|
|
t.Errorf("expected no additional exchange calls (got %d total)", ex.calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestResolve_NilServer(t *testing.T) {
|
|
r := &localResolver{options: nopLog()}
|
|
|
|
ips, err := r.resolve(context.Background(), nil, "example.com", "")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ips) != 0 {
|
|
t.Fatalf("expected no IPs for nil server, got %v", ips)
|
|
}
|
|
}
|
|
|
|
func TestResolve_AsyncCacheMiss(t *testing.T) {
|
|
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
|
|
r := newTestResolver([]NameServer{{exchanger: ex, Async: true, TTL: time.Minute}})
|
|
|
|
// Cache miss → should do synchronous resolve
|
|
ips, err := r.Resolve(context.Background(), "ip", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ips) == 0 {
|
|
t.Fatal("expected result on first async call (cache miss)")
|
|
}
|
|
if ex.calls.Load() != 1 {
|
|
t.Errorf("expected 1 exchange call, got %d", ex.calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestResolve_AsyncCacheHitWithRefresh(t *testing.T) {
|
|
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
|
|
r := newTestResolver([]NameServer{{exchanger: ex, Async: true, TTL: time.Minute}})
|
|
|
|
// Populate cache first
|
|
ips, err := r.Resolve(context.Background(), "ip", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
if len(ips) == 0 {
|
|
t.Fatal("setup: expected result")
|
|
}
|
|
|
|
// Manually expire the cache entry
|
|
mq := new(dns.Msg)
|
|
mq.SetQuestion(dns.Fqdn("example.com"), dns.TypeA)
|
|
key := resolver_util.NewCacheKey(&mq.Question[0])
|
|
mr := newDNSMsgWithA("10.0.0.2")
|
|
r.cache.Store(context.Background(), key, mr, -1*time.Second)
|
|
|
|
beforeCalls := ex.calls.Load()
|
|
|
|
// Cache hit with expired TTL → should return stale data and trigger background refresh
|
|
ips, err = r.Resolve(context.Background(), "ip", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Should return data (from stale cache or fresh resolve)
|
|
_ = ips
|
|
|
|
// Give background goroutine time to complete
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Background refresh should have fired
|
|
if ex.calls.Load() <= beforeCalls {
|
|
t.Logf("note: background refresh may not have fired yet (calls: before=%d, now=%d)", beforeCalls, ex.calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestResolve_CancelledContext(t *testing.T) {
|
|
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
|
|
r := newTestResolver([]NameServer{{exchanger: ex, TTL: time.Minute}})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
_, err := r.Resolve(ctx, "ip", "example.com")
|
|
// Should handle cancelled context gracefully (error or empty result)
|
|
_ = err
|
|
}
|
|
|
|
func TestResolveIPs_ExtractsAAndAAAA(t *testing.T) {
|
|
// Build a response with both A and AAAA records
|
|
mr := new(dns.Msg)
|
|
mr.SetQuestion("example.com.", dns.TypeA)
|
|
mr.Answer = append(mr.Answer,
|
|
&dns.A{
|
|
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
|
A: net.ParseIP("10.0.0.1"),
|
|
},
|
|
&dns.AAAA{
|
|
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 300},
|
|
AAAA: net.ParseIP("2001:db8::1"),
|
|
},
|
|
)
|
|
|
|
ex := &mockExchanger{response: mr}
|
|
r := newTestResolver([]NameServer{{exchanger: ex, TTL: time.Minute}})
|
|
|
|
ips, err := r.Resolve(context.Background(), "ip", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ips) < 2 {
|
|
t.Fatalf("expected at least 2 IPs (A+AAAA), got %d: %v", len(ips), ips)
|
|
}
|
|
|
|
has4, has6 := false, false
|
|
for _, ip := range ips {
|
|
if ip.Equal(net.ParseIP("10.0.0.1")) {
|
|
has4 = true
|
|
}
|
|
if ip.Equal(net.ParseIP("2001:db8::1")) {
|
|
has6 = true
|
|
}
|
|
}
|
|
if !has4 || !has6 {
|
|
t.Errorf("expected both A and AAAA records in result, got %v", ips)
|
|
}
|
|
}
|
|
|
|
func TestExchange_RoundTrip(t *testing.T) {
|
|
mq := new(dns.Msg)
|
|
mq.SetQuestion(dns.Fqdn("example.com"), dns.TypeA)
|
|
mq.RecursionDesired = true
|
|
|
|
packed, err := mq.Pack()
|
|
if err != nil {
|
|
t.Fatalf("pack: %v", err)
|
|
}
|
|
|
|
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
|
|
reply, err := ex.Exchange(context.Background(), packed)
|
|
if err != nil {
|
|
t.Fatalf("exchange: %v", err)
|
|
}
|
|
|
|
mr := new(dns.Msg)
|
|
if err := mr.Unpack(reply); err != nil {
|
|
t.Fatalf("unpack: %v", err)
|
|
}
|
|
if len(mr.Answer) == 0 {
|
|
t.Fatal("expected answers in response")
|
|
}
|
|
}
|
|
|
|
func TestDomainOption(t *testing.T) {
|
|
opts := &options{}
|
|
DomainOption("test.local")(opts)
|
|
if opts.domain != "test.local" {
|
|
t.Errorf("expected domain 'test.local', got %q", opts.domain)
|
|
}
|
|
}
|
|
|
|
func TestLoggerOption(t *testing.T) {
|
|
opts := &options{}
|
|
l := xlogger.Nop()
|
|
LoggerOption(l)(opts)
|
|
if opts.logger != l {
|
|
t.Error("logger option not applied")
|
|
}
|
|
}
|
|
|
|
func TestExchange_DNSRcodeError(t *testing.T) {
|
|
// Return a message with NXDOMAIN rcode
|
|
mr := new(dns.Msg)
|
|
mr.SetRcode(&dns.Msg{MsgHdr: dns.MsgHdr{Id: 1}}, dns.RcodeNameError)
|
|
packed, err := mr.Pack()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ex := &rcodeMockExchanger{response: packed}
|
|
r := newTestResolver([]NameServer{{exchanger: ex, TTL: time.Minute}})
|
|
|
|
ips, err := r.Resolve(context.Background(), "ip", "nonexistent.example.com")
|
|
if err == nil {
|
|
t.Fatal("expected error for NXDOMAIN")
|
|
}
|
|
if len(ips) != 0 {
|
|
t.Fatalf("expected no IPs for NXDOMAIN, got %v", ips)
|
|
}
|
|
}
|
|
|
|
func TestExchange_DNSRcodeServfail_Fallback(t *testing.T) {
|
|
// First server returns SERVFAIL, second returns success
|
|
servfailMr := new(dns.Msg)
|
|
servfailMr.SetRcode(&dns.Msg{MsgHdr: dns.MsgHdr{Id: 1}}, dns.RcodeServerFailure)
|
|
packed, err := servfailMr.Pack()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
okEx := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
|
|
servfailEx := &rcodeMockExchanger{response: packed}
|
|
|
|
r := newTestResolver([]NameServer{
|
|
{exchanger: servfailEx, TTL: time.Minute},
|
|
{exchanger: okEx, TTL: time.Minute},
|
|
})
|
|
|
|
ips, err := r.Resolve(context.Background(), "ip", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ips) == 0 {
|
|
t.Fatal("expected fallback to second server")
|
|
}
|
|
}
|
|
|
|
func TestResolve_NetworkIP4(t *testing.T) {
|
|
// When caller asks for ip4, only A queries should be made (no AAAA fallback).
|
|
// Use a mock that returns empty response for A queries.
|
|
ex := &mockExchanger{response: new(dns.Msg)} // empty response, no records
|
|
r := newTestResolver([]NameServer{{exchanger: ex, TTL: time.Minute}})
|
|
|
|
ips, err := r.Resolve(context.Background(), "ip4", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Should get empty result (no A records), and NOT fall back to IPv6
|
|
if len(ips) != 0 {
|
|
t.Errorf("expected no IPs when only ip4 requested and no A records, got %v", ips)
|
|
}
|
|
}
|
|
|
|
func TestResolve_NetworkIP6(t *testing.T) {
|
|
ex := &mockExchanger{response: newDNSMsgWithAAAA("2001:db8::1")}
|
|
r := newTestResolver([]NameServer{{exchanger: ex, TTL: time.Minute}})
|
|
|
|
ips, err := r.Resolve(context.Background(), "ip6", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ips) == 0 {
|
|
t.Fatal("expected IPv6 result for ip6 network")
|
|
}
|
|
}
|
|
|
|
func TestResolve_NetworkIP4_ServerOnlyOverrides(t *testing.T) {
|
|
// Server configured with Only="ipv6", caller asks for "ip4" → server wins
|
|
ex := &mockExchanger{response: newDNSMsgWithAAAA("2001:db8::1")}
|
|
r := newTestResolver([]NameServer{{exchanger: ex, Only: "ipv6", Prefer: "ipv6", TTL: time.Minute}})
|
|
|
|
ips, err := r.Resolve(context.Background(), "ip4", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Server Only=ipv6 should override caller's ip4 request
|
|
if len(ips) == 0 {
|
|
t.Fatal("expected IPv6 result (server Only overrides caller)")
|
|
}
|
|
for _, ip := range ips {
|
|
if ip.To4() != nil {
|
|
t.Errorf("expected only IPv6 from server Only constraint, got %v", ip)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolve_ConcurrentDedup(t *testing.T) {
|
|
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
|
|
r := newTestResolver([]NameServer{{exchanger: ex, TTL: time.Minute}})
|
|
|
|
const n = 10
|
|
var wg sync.WaitGroup
|
|
results := make([][]net.IP, n)
|
|
errs := make([]error, n)
|
|
|
|
for i := 0; i < n; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
results[idx], errs[idx] = r.Resolve(context.Background(), "ip", "example.com")
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
for i, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("goroutine %d: unexpected error: %v", i, err)
|
|
}
|
|
if len(results[i]) == 0 {
|
|
t.Fatalf("goroutine %d: expected IPs", i)
|
|
}
|
|
}
|
|
|
|
// singleflight should have deduplicated the exchange calls
|
|
calls := ex.calls.Load()
|
|
if calls > 2 { // allow small margin for race, but not 10
|
|
t.Errorf("expected ~1 exchange call from singleflight, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestResolve_IP_UnchangedBehavior(t *testing.T) {
|
|
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
|
|
r := newTestResolver([]NameServer{{exchanger: ex, TTL: time.Minute}})
|
|
|
|
ips, err := r.Resolve(context.Background(), "ip", "example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(ips) == 0 {
|
|
t.Fatal("expected result for network=ip (default behavior)")
|
|
}
|
|
}
|
|
|
|
// rcodeMockExchanger returns a pre-packed response, used for DNS rcode testing.
|
|
type rcodeMockExchanger struct {
|
|
response []byte
|
|
}
|
|
|
|
func (m *rcodeMockExchanger) Exchange(_ context.Context, _ []byte) ([]byte, error) {
|
|
return m.response, nil
|
|
}
|
|
|
|
func (m *rcodeMockExchanger) String() string { return "mock://rcode" }
|