| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443 |
- 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 := `<?xml version="1.0" encoding="UTF-8"?>
- <response>
- <result>
- <data>
- <summary>2</summary>
- <zone>
- <name>example.com</name>
- <system_ns>nsa4.schlundtech.de</system_ns>
- <domainsafe>0</domainsafe>
- </zone>
- <zone>
- <name>example.org</name>
- <system_ns>nsb4.schlundtech.de</system_ns>
- <domainsafe>0</domainsafe>
- </zone>
- </data>
- <status>
- <code>S0205</code>
- <text>Zone information was inquired successfully.</text>
- <type>success</type>
- </status>
- </result>
- <stid>20160218-app2-dev-2603</stid>
- </response>`
- 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 := `<?xml version="1.0" encoding="UTF-8"?>
- <response>
- <result>
- <data>
- <zone>
- <name>example.com</name>
- <origin>example.com</origin>
- <system_ns>nsa4.schlundtech.de</system_ns>
- <soa><ttl>3600</ttl><email>hostmaster@example.com</email></soa>
- <rr><name></name><type>TXT</type><value>v=spf1 -all</value><ttl>3600</ttl></rr>
- <rr><name>www</name><type>A</type><value>203.0.113.10</value><ttl>3600</ttl></rr>
- <rr><name>www</name><type>AAAA</type><value>2001:db8::1</value><ttl>3600</ttl></rr>
- </zone>
- </data>
- <status>
- <code>S0205</code>
- <text>Zonen-Informationen wurden erfolgreich ermittelt.</text>
- <type>success</type>
- </status>
- </result>
- <stid>20261001-18bbc3ffa605f6fd032405faebe80a7c</stid>
- </response>`
- 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 := `<?xml version="1.0" encoding="UTF-8"?>
- <response><result>
- <msg>
- <text>User does not exist or password incorrect.</text>
- <code>EF00202</code>
- <type>error</type>
- <object><type>user</type><value>u</value></object>
- </msg>
- <status><code>E00000</code><text>Errors occurred during processing.</text><type>error</type></status>
- </result><stid>20261001-4f6ed18d77f91afa9c1ef8cbd4ac434d</stid></response>`
- 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, "<html>404 Not Found</html>")
- }))
- 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 := `<?xml version="1.0" encoding="UTF-8"?>
- <response><result><data><zone>
- <name>example.com</name>
- <system_ns>nsa4.schlundtech.de</system_ns>
- <rr><name></name><type>TXT</type><value>old-a</value><ttl>600</ttl></rr>
- <rr><name></name><type>TXT</type><value>old-b</value><ttl>600</ttl></rr>
- <rr><name>www</name><type>A</type><value>203.0.113.1</value><ttl>300</ttl></rr>
- </zone></data><status><code>S0205</code><type>success</type></status></result></response>`
- updateBody := `<?xml version="1.0" encoding="UTF-8"?>
- <response><result>
- <msg><text>Zone was updated successfully on the name server.</text><code>0202001</code>
- <type>success</type><object><type>zone</type><value>example.com</value></object></msg>
- <status><code>S0202001</code><text>Bulk zone update completed successfully.</text><type>success</type></status>
- </result><stid>20130906-app1-test-7072</stid></response>`
- 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 := `<?xml version="1.0" encoding="UTF-8"?>
- <response><result><data><zone><name>example.com</name><system_ns>ns1.example.com</system_ns>
- <rr><name></name><type>A</type><value>1.1.1.1</value><ttl>300</ttl></rr>
- </zone></data><status><code>S0205</code><type>success</type></status></result></response>`
- okBody := `<response><result><status><code>S0202001</code><type>success</type></status></result></response>`
- 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 := `<?xml version="1.0" encoding="UTF-8"?>
- <response><result><data><zone><name>example.com</name><system_ns>ns1.example.com</system_ns>
- <rr><name>www</name><type>A</type><value>1.1.1.1</value><ttl>300</ttl></rr>
- <rr><name>www</name><type>A</type><value>2.2.2.2</value><ttl>300</ttl></rr>
- <rr><name>mail</name><type>A</type><value>3.3.3.3</value><ttl>300</ttl></rr>
- </zone></data><status><code>S0205</code><type>success</type></status></result></response>`
- okBody := `<response><result><status><code>S0202001</code><type>success</type></status></result></response>`
- 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 := `<?xml version="1.0" encoding="UTF-8"?>
- <response><result><data><zone><name>example.com</name><system_ns>ns1.example.com</system_ns>
- <rr><name>www</name><type>A</type><value>1.1.1.1</value><ttl>300</ttl></rr>
- </zone></data><status><code>S0205</code><type>success</type></status></result></response>`
- 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, `<response><result><status><code>S0205</code><type>success</type></status></result></response>`)
- }))
- t.Cleanup(srv.Close)
- if _, err := testClient(srv).Zones(context.Background()); err != nil {
- t.Fatalf("Zones: %v", err)
- }
- }
- func TestUpdateFailureSurfacesTheGatewayCode(t *testing.T) {
- zoneBody := `<?xml version="1.0" encoding="UTF-8"?>
- <response><result><data><zone><name>example.com</name><system_ns>ns1.example.com</system_ns>
- </zone></data><status><code>S0205</code><type>success</type></status></result></response>`
- // EF02020 is the documented "no such zone exists".
- errBody := `<?xml version="1.0" encoding="UTF-8"?>
- <response><result>
- <msg><text>No such zone exists.</text><code>EF02020</code><type>error</type>
- <object><type>zone</type><value>example.com</value></object></msg>
- <status><code>E00000</code><text>Errors occurred during processing.</text><type>error</type></status>
- </result></response>`
- 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)
- }
- }
|