package api
import (
"context"
"encoding/xml"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// testClient returns a Client pointed at srv, with the request-rate floor
// removed so tests do not sleep.
func testClient(srv *httptest.Server) *Client {
return New(
Credentials{User: "u", Password: "p", Context: "10"},
WithEndpoint(srv.URL),
WithInterval(time.Microsecond),
)
}
// respond builds a server that returns body for every request and records the
// last request it saw.
func respond(t *testing.T, body string) (*httptest.Server, *Request) {
t.Helper()
var last Request
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body: %v", err)
}
if err := xml.Unmarshal(raw, &last); err != nil {
t.Errorf("server could not parse request %q: %v", raw, err)
}
w.Header().Set("Content-Type", "application/xml")
// Every gateway answer, error or not, is HTTP 200. The tests must not
// rely on this being different for failures.
_, _ = io.WriteString(w, body)
}))
t.Cleanup(srv.Close)
return srv, &last
}
func TestZonesParsesListResponse(t *testing.T) {
// Shape from the Zone list documentation, task 0205.
body := `
2
example.com
nsa4.schlundtech.de
0
example.org
nsb4.schlundtech.de
0
S0205
Zone information was inquired successfully.
success
20160218-app2-dev-2603
`
srv, req := respond(t, body)
zones, err := testClient(srv).Zones(context.Background())
if err != nil {
t.Fatalf("Zones: %v", err)
}
if len(zones) != 2 {
t.Fatalf("got %d zones, want 2", len(zones))
}
if zones[0].Name != "example.com" || zones[0].SystemNS != "nsa4.schlundtech.de" {
t.Errorf("zone 0 = %+v", zones[0])
}
if req.Task[0].Code != TaskZoneInquire {
t.Errorf("task code = %q, want %q", req.Task[0].Code, TaskZoneInquire)
}
if req.Auth.Context != "10" {
t.Errorf("context = %q, want 10", req.Auth.Context)
}
}
func TestZoneParsesRecords(t *testing.T) {
// Shape from the HAR capture of the live portal, task 0205 with a name.
body := `
example.com
example.com
nsa4.schlundtech.de
3600hostmaster@example.com
TXTv=spf1 -all3600
wwwA203.0.113.103600
wwwAAAA2001:db8::13600
S0205
Zonen-Informationen wurden erfolgreich ermittelt.
success
20261001-18bbc3ffa605f6fd032405faebe80a7c
`
srv, _ := respond(t, body)
c := testClient(srv)
zone, err := c.Zone(context.Background(), "example.com")
if err != nil {
t.Fatalf("Zone: %v", err)
}
if zone.SystemNS != "nsa4.schlundtech.de" {
t.Errorf("system_ns = %q", zone.SystemNS)
}
if len(zone.RRs) != 3 {
t.Fatalf("got %d records, want 3", len(zone.RRs))
}
// The apex is the empty name.
if zone.RRs[0].Name != "" || zone.RRs[0].Type != "TXT" {
t.Errorf("record 0 = %+v", zone.RRs[0])
}
if zone.RRs[0].TTL != 3600 {
t.Errorf("ttl = %d, want 3600", zone.RRs[0].TTL)
}
txt, err := c.Records(context.Background(), "example.com", "txt")
if err != nil {
t.Fatalf("Records: %v", err)
}
if len(txt) != 1 || txt[0].Type != "TXT" {
t.Errorf("txt filter returned %+v", txt)
}
}
// The gateway answers HTTP 200 for a bad password. A client that trusted the
// status code would report success here.
func TestErrorIsNotTakenFromHTTPStatus(t *testing.T) {
body := `
User does not exist or password incorrect.
EF00202
error
E00000Errors occurred during processing.error
20261001-4f6ed18d77f91afa9c1ef8cbd4ac434d`
srv, _ := respond(t, body)
_, err := testClient(srv).Zones(context.Background())
if err == nil {
t.Fatal("expected an error, got nil: HTTP 200 was mistaken for success")
}
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("error type = %T, want *APIError", err)
}
// The actionable code lives in msg, not in the generic status code.
if apiErr.Code != "EF00202" {
t.Errorf("code = %q, want EF00202", apiErr.Code)
}
if !strings.Contains(apiErr.Error(), "password incorrect") {
t.Errorf("message = %q", apiErr.Error())
}
if apiErr.Stid == "" {
t.Error("stid was dropped, it is what support asks for")
}
}
// A non-2xx status is a routing problem, and must not be reported as a task
// error with a gateway code.
func TestNon2xxIsReportedAsRoutingError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = io.WriteString(w, "404 Not Found")
}))
t.Cleanup(srv.Close)
_, err := testClient(srv).Zones(context.Background())
if err == nil {
t.Fatal("expected an error for HTTP 404")
}
if _, ok := err.(*APIError); ok {
t.Errorf("HTTP 404 was wrapped as an APIError: %v", err)
}
if !strings.Contains(err.Error(), "404") {
t.Errorf("message = %q, want it to mention 404", err)
}
}
func TestSetRecordsRemovesThenAdds(t *testing.T) {
// First call answers the zone read, second the update.
zoneBody := `
example.com
nsa4.schlundtech.de
TXTold-a600
TXTold-b600
wwwA203.0.113.1300
S0205success`
updateBody := `
Zone was updated successfully on the name server.0202001
success
S0202001Bulk zone update completed successfully.success
20130906-app1-test-7072`
var seen []Request
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
var req Request
if err := xml.Unmarshal(raw, &req); err != nil {
t.Errorf("parse request: %v", err)
}
seen = append(seen, req)
w.Header().Set("Content-Type", "application/xml")
body := zoneBody
if req.Task[0].Code == TaskZoneUpdateBulk {
body = updateBody
}
_, _ = io.WriteString(w, body)
}))
t.Cleanup(srv.Close)
err := testClient(srv).SetRecords(context.Background(), "example.com", "@", "TXT", []string{"new"}, 0)
if err != nil {
t.Fatalf("SetRecords: %v", err)
}
if len(seen) != 2 {
t.Fatalf("got %d requests, want 2 (read then update)", len(seen))
}
update := seen[1].Task[0]
if update.Code != TaskZoneUpdateBulk {
t.Errorf("task code = %q", update.Code)
}
if update.Zone == nil || update.Zone.SystemNS != "nsa4.schlundtech.de" {
t.Fatalf("update zone = %+v, want the system_ns from the zone read", update.Zone)
}
if update.Default == nil {
t.Fatal("update had no default block")
}
// Both old values go, in one task.
if len(update.Default.RRRem) != 2 {
t.Errorf("rr_rem count = %d, want 2", len(update.Default.RRRem))
}
if len(update.Default.RRAdd) != 1 {
t.Fatalf("rr_add count = %d, want 1", len(update.Default.RRAdd))
}
add := update.Default.RRAdd[0]
// @ must reach the gateway as the empty apex name.
if add.Name != "" {
t.Errorf("rr_add name = %q, want empty (the apex)", add.Name)
}
if add.Type != "TXT" || add.Value != "new" {
t.Errorf("rr_add = %+v", add)
}
// TTL was not given, so the existing one is carried over.
if add.TTL != 600 {
t.Errorf("rr_add ttl = %d, want 600 inherited from the existing record", add.TTL)
}
}
func TestSetRecordsPreservesGivenTTL(t *testing.T) {
zoneBody := `
example.comns1.example.com
A1.1.1.1300
S0205success`
okBody := `S0202001success`
var update *Task
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
var req Request
_ = xml.Unmarshal(raw, &req)
body := zoneBody
if req.Task[0].Code == TaskZoneUpdateBulk {
update = &req.Task[0]
body = okBody
}
w.Header().Set("Content-Type", "application/xml")
_, _ = io.WriteString(w, body)
}))
t.Cleanup(srv.Close)
if err := testClient(srv).SetRecords(context.Background(), "example.com", "@", "A", []string{"2.2.2.2"}, 120); err != nil {
t.Fatalf("SetRecords: %v", err)
}
if update == nil || update.Default == nil || len(update.Default.RRAdd) != 1 {
t.Fatalf("no update task was sent: %+v", update)
}
if got := update.Default.RRAdd[0].TTL; got != 120 {
t.Errorf("rr_add ttl = %d, want the explicit 120", got)
}
}
func TestDeleteWithoutValueRemovesEveryValue(t *testing.T) {
zoneBody := `
example.comns1.example.com
wwwA1.1.1.1300
wwwA2.2.2.2300
mailA3.3.3.3300
S0205success`
okBody := `S0202001success`
var update *Task
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
var req Request
_ = xml.Unmarshal(raw, &req)
body := zoneBody
if req.Task[0].Code == TaskZoneUpdateBulk {
update = &req.Task[0]
body = okBody
}
w.Header().Set("Content-Type", "application/xml")
_, _ = io.WriteString(w, body)
}))
t.Cleanup(srv.Close)
if err := testClient(srv).DeleteRecords(context.Background(), "example.com", "www", "A", nil); err != nil {
t.Fatalf("DeleteRecords: %v", err)
}
if update == nil || update.Default == nil {
t.Fatal("no update task was sent")
}
// Only the two records at that name, not the unrelated one.
if len(update.Default.RRRem) != 2 {
t.Errorf("rr_rem count = %d, want 2", len(update.Default.RRRem))
}
if len(update.Default.RRAdd) != 0 {
t.Errorf("a delete sent %d rr_add blocks", len(update.Default.RRAdd))
}
}
func TestDeleteUnknownRecordIsRefusedLocally(t *testing.T) {
zoneBody := `
example.comns1.example.com
wwwA1.1.1.1300
S0205success`
var updates int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
var req Request
_ = xml.Unmarshal(raw, &req)
if req.Task[0].Code == TaskZoneUpdateBulk {
updates++
}
w.Header().Set("Content-Type", "application/xml")
_, _ = io.WriteString(w, zoneBody)
}))
t.Cleanup(srv.Close)
err := testClient(srv).DeleteRecords(context.Background(), "example.com", "@", "TXT", nil)
if err == nil {
t.Fatal("expected an error when nothing matches")
}
if updates != 0 {
t.Errorf("an update was sent anyway (%d)", updates)
}
}
func TestSetRecordsRejectsEmptyValues(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Error("no request should be made")
}))
t.Cleanup(srv.Close)
if err := testClient(srv).SetRecords(context.Background(), "example.com", "@", "TXT", nil, 0); err == nil {
t.Fatal("expected an error for an empty value list")
}
}
// The gateway rejects a body with a byte order mark, so the client must not
// send one.
func TestRequestHasNoBOM(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
if len(raw) >= 3 && raw[0] == 0xEF && raw[1] == 0xBB && raw[2] == 0xBF {
t.Errorf("request starts with a byte order mark: % X", raw[:3])
}
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/xml") {
t.Errorf("Content-Type = %q", ct)
}
w.Header().Set("Content-Type", "application/xml")
_, _ = io.WriteString(w, `S0205success`)
}))
t.Cleanup(srv.Close)
if _, err := testClient(srv).Zones(context.Background()); err != nil {
t.Fatalf("Zones: %v", err)
}
}
func TestUpdateFailureSurfacesTheGatewayCode(t *testing.T) {
zoneBody := `
example.comns1.example.com
S0205success`
// EF02020 is the documented "no such zone exists".
errBody := `
No such zone exists.EF02020error
E00000Errors occurred during processing.error
`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
var req Request
_ = xml.Unmarshal(raw, &req)
w.Header().Set("Content-Type", "application/xml")
if req.Task[0].Code == TaskZoneUpdateBulk {
_, _ = io.WriteString(w, errBody)
return
}
_, _ = io.WriteString(w, zoneBody)
}))
t.Cleanup(srv.Close)
err := testClient(srv).SetRecords(context.Background(), "example.com", "@", "A", []string{"1.2.3.4"}, 0)
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("error type = %T (%v), want *APIError", err, err)
}
if apiErr.Code != "EF02020" {
t.Errorf("code = %q, want EF02020", apiErr.Code)
}
if !strings.Contains(apiErr.Object, "example.com") {
t.Errorf("object = %q, want it to name the zone", apiErr.Object)
}
}
// The Auth object documents token as an optional 2FA element. It must appear in
// the auth block when set, and be absent otherwise.
func TestTwoFactorTokenIsSerialised(t *testing.T) {
for _, tc := range []struct {
name string
token string
want bool
}{
{name: "without 2FA", token: "", want: false},
{name: "with 2FA", token: "123456", want: true},
} {
t.Run(tc.name, func(t *testing.T) {
srv, req := respond(t, `S0205success`)
c := New(
Credentials{User: "u", Password: "p", Context: "10", Token: tc.token},
WithEndpoint(srv.URL),
WithInterval(time.Microsecond),
)
if _, err := c.Zones(context.Background()); err != nil {
t.Fatalf("Zones: %v", err)
}
if got := req.Auth.Token != ""; got != tc.want {
t.Errorf("auth token present = %v, want %v", got, tc.want)
}
})
}
}