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 useru 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 successzoneexample.com 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 zoneexample.com 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) } }) } }