client_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. package api
  2. import (
  3. "context"
  4. "encoding/xml"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "time"
  11. )
  12. // testClient returns a Client pointed at srv, with the request-rate floor
  13. // removed so tests do not sleep.
  14. func testClient(srv *httptest.Server) *Client {
  15. return New(
  16. Credentials{User: "u", Password: "p", Context: "10"},
  17. WithEndpoint(srv.URL),
  18. WithInterval(time.Microsecond),
  19. )
  20. }
  21. // respond builds a server that returns body for every request and records the
  22. // last request it saw.
  23. func respond(t *testing.T, body string) (*httptest.Server, *Request) {
  24. t.Helper()
  25. var last Request
  26. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  27. raw, err := io.ReadAll(r.Body)
  28. if err != nil {
  29. t.Errorf("read body: %v", err)
  30. }
  31. if err := xml.Unmarshal(raw, &last); err != nil {
  32. t.Errorf("server could not parse request %q: %v", raw, err)
  33. }
  34. w.Header().Set("Content-Type", "application/xml")
  35. // Every gateway answer, error or not, is HTTP 200. The tests must not
  36. // rely on this being different for failures.
  37. _, _ = io.WriteString(w, body)
  38. }))
  39. t.Cleanup(srv.Close)
  40. return srv, &last
  41. }
  42. func TestZonesParsesListResponse(t *testing.T) {
  43. // Shape from the Zone list documentation, task 0205.
  44. body := `<?xml version="1.0" encoding="UTF-8"?>
  45. <response>
  46. <result>
  47. <data>
  48. <summary>2</summary>
  49. <zone>
  50. <name>example.com</name>
  51. <system_ns>nsa4.schlundtech.de</system_ns>
  52. <domainsafe>0</domainsafe>
  53. </zone>
  54. <zone>
  55. <name>example.org</name>
  56. <system_ns>nsb4.schlundtech.de</system_ns>
  57. <domainsafe>0</domainsafe>
  58. </zone>
  59. </data>
  60. <status>
  61. <code>S0205</code>
  62. <text>Zone information was inquired successfully.</text>
  63. <type>success</type>
  64. </status>
  65. </result>
  66. <stid>20160218-app2-dev-2603</stid>
  67. </response>`
  68. srv, req := respond(t, body)
  69. zones, err := testClient(srv).Zones(context.Background())
  70. if err != nil {
  71. t.Fatalf("Zones: %v", err)
  72. }
  73. if len(zones) != 2 {
  74. t.Fatalf("got %d zones, want 2", len(zones))
  75. }
  76. if zones[0].Name != "example.com" || zones[0].SystemNS != "nsa4.schlundtech.de" {
  77. t.Errorf("zone 0 = %+v", zones[0])
  78. }
  79. if req.Task[0].Code != TaskZoneInquire {
  80. t.Errorf("task code = %q, want %q", req.Task[0].Code, TaskZoneInquire)
  81. }
  82. if req.Auth.Context != "10" {
  83. t.Errorf("context = %q, want 10", req.Auth.Context)
  84. }
  85. }
  86. func TestZoneParsesRecords(t *testing.T) {
  87. // Shape from the HAR capture of the live portal, task 0205 with a name.
  88. body := `<?xml version="1.0" encoding="UTF-8"?>
  89. <response>
  90. <result>
  91. <data>
  92. <zone>
  93. <name>example.com</name>
  94. <origin>example.com</origin>
  95. <system_ns>nsa4.schlundtech.de</system_ns>
  96. <soa><ttl>3600</ttl><email>hostmaster@example.com</email></soa>
  97. <rr><name></name><type>TXT</type><value>v=spf1 -all</value><ttl>3600</ttl></rr>
  98. <rr><name>www</name><type>A</type><value>203.0.113.10</value><ttl>3600</ttl></rr>
  99. <rr><name>www</name><type>AAAA</type><value>2001:db8::1</value><ttl>3600</ttl></rr>
  100. </zone>
  101. </data>
  102. <status>
  103. <code>S0205</code>
  104. <text>Zonen-Informationen wurden erfolgreich ermittelt.</text>
  105. <type>success</type>
  106. </status>
  107. </result>
  108. <stid>20261001-18bbc3ffa605f6fd032405faebe80a7c</stid>
  109. </response>`
  110. srv, _ := respond(t, body)
  111. c := testClient(srv)
  112. zone, err := c.Zone(context.Background(), "example.com")
  113. if err != nil {
  114. t.Fatalf("Zone: %v", err)
  115. }
  116. if zone.SystemNS != "nsa4.schlundtech.de" {
  117. t.Errorf("system_ns = %q", zone.SystemNS)
  118. }
  119. if len(zone.RRs) != 3 {
  120. t.Fatalf("got %d records, want 3", len(zone.RRs))
  121. }
  122. // The apex is the empty name.
  123. if zone.RRs[0].Name != "" || zone.RRs[0].Type != "TXT" {
  124. t.Errorf("record 0 = %+v", zone.RRs[0])
  125. }
  126. if zone.RRs[0].TTL != 3600 {
  127. t.Errorf("ttl = %d, want 3600", zone.RRs[0].TTL)
  128. }
  129. txt, err := c.Records(context.Background(), "example.com", "txt")
  130. if err != nil {
  131. t.Fatalf("Records: %v", err)
  132. }
  133. if len(txt) != 1 || txt[0].Type != "TXT" {
  134. t.Errorf("txt filter returned %+v", txt)
  135. }
  136. }
  137. // The gateway answers HTTP 200 for a bad password. A client that trusted the
  138. // status code would report success here.
  139. func TestErrorIsNotTakenFromHTTPStatus(t *testing.T) {
  140. body := `<?xml version="1.0" encoding="UTF-8"?>
  141. <response><result>
  142. <msg>
  143. <text>User does not exist or password incorrect.</text>
  144. <code>EF00202</code>
  145. <type>error</type>
  146. <object><type>user</type><value>u</value></object>
  147. </msg>
  148. <status><code>E00000</code><text>Errors occurred during processing.</text><type>error</type></status>
  149. </result><stid>20261001-4f6ed18d77f91afa9c1ef8cbd4ac434d</stid></response>`
  150. srv, _ := respond(t, body)
  151. _, err := testClient(srv).Zones(context.Background())
  152. if err == nil {
  153. t.Fatal("expected an error, got nil: HTTP 200 was mistaken for success")
  154. }
  155. apiErr, ok := err.(*APIError)
  156. if !ok {
  157. t.Fatalf("error type = %T, want *APIError", err)
  158. }
  159. // The actionable code lives in msg, not in the generic status code.
  160. if apiErr.Code != "EF00202" {
  161. t.Errorf("code = %q, want EF00202", apiErr.Code)
  162. }
  163. if !strings.Contains(apiErr.Error(), "password incorrect") {
  164. t.Errorf("message = %q", apiErr.Error())
  165. }
  166. if apiErr.Stid == "" {
  167. t.Error("stid was dropped, it is what support asks for")
  168. }
  169. }
  170. // A non-2xx status is a routing problem, and must not be reported as a task
  171. // error with a gateway code.
  172. func TestNon2xxIsReportedAsRoutingError(t *testing.T) {
  173. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  174. w.WriteHeader(http.StatusNotFound)
  175. _, _ = io.WriteString(w, "<html>404 Not Found</html>")
  176. }))
  177. t.Cleanup(srv.Close)
  178. _, err := testClient(srv).Zones(context.Background())
  179. if err == nil {
  180. t.Fatal("expected an error for HTTP 404")
  181. }
  182. if _, ok := err.(*APIError); ok {
  183. t.Errorf("HTTP 404 was wrapped as an APIError: %v", err)
  184. }
  185. if !strings.Contains(err.Error(), "404") {
  186. t.Errorf("message = %q, want it to mention 404", err)
  187. }
  188. }
  189. func TestSetRecordsRemovesThenAdds(t *testing.T) {
  190. // First call answers the zone read, second the update.
  191. zoneBody := `<?xml version="1.0" encoding="UTF-8"?>
  192. <response><result><data><zone>
  193. <name>example.com</name>
  194. <system_ns>nsa4.schlundtech.de</system_ns>
  195. <rr><name></name><type>TXT</type><value>old-a</value><ttl>600</ttl></rr>
  196. <rr><name></name><type>TXT</type><value>old-b</value><ttl>600</ttl></rr>
  197. <rr><name>www</name><type>A</type><value>203.0.113.1</value><ttl>300</ttl></rr>
  198. </zone></data><status><code>S0205</code><type>success</type></status></result></response>`
  199. updateBody := `<?xml version="1.0" encoding="UTF-8"?>
  200. <response><result>
  201. <msg><text>Zone was updated successfully on the name server.</text><code>0202001</code>
  202. <type>success</type><object><type>zone</type><value>example.com</value></object></msg>
  203. <status><code>S0202001</code><text>Bulk zone update completed successfully.</text><type>success</type></status>
  204. </result><stid>20130906-app1-test-7072</stid></response>`
  205. var seen []Request
  206. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  207. raw, _ := io.ReadAll(r.Body)
  208. var req Request
  209. if err := xml.Unmarshal(raw, &req); err != nil {
  210. t.Errorf("parse request: %v", err)
  211. }
  212. seen = append(seen, req)
  213. w.Header().Set("Content-Type", "application/xml")
  214. body := zoneBody
  215. if req.Task[0].Code == TaskZoneUpdateBulk {
  216. body = updateBody
  217. }
  218. _, _ = io.WriteString(w, body)
  219. }))
  220. t.Cleanup(srv.Close)
  221. err := testClient(srv).SetRecords(context.Background(), "example.com", "@", "TXT", []string{"new"}, 0)
  222. if err != nil {
  223. t.Fatalf("SetRecords: %v", err)
  224. }
  225. if len(seen) != 2 {
  226. t.Fatalf("got %d requests, want 2 (read then update)", len(seen))
  227. }
  228. update := seen[1].Task[0]
  229. if update.Code != TaskZoneUpdateBulk {
  230. t.Errorf("task code = %q", update.Code)
  231. }
  232. if update.Zone == nil || update.Zone.SystemNS != "nsa4.schlundtech.de" {
  233. t.Fatalf("update zone = %+v, want the system_ns from the zone read", update.Zone)
  234. }
  235. if update.Default == nil {
  236. t.Fatal("update had no default block")
  237. }
  238. // Both old values go, in one task.
  239. if len(update.Default.RRRem) != 2 {
  240. t.Errorf("rr_rem count = %d, want 2", len(update.Default.RRRem))
  241. }
  242. if len(update.Default.RRAdd) != 1 {
  243. t.Fatalf("rr_add count = %d, want 1", len(update.Default.RRAdd))
  244. }
  245. add := update.Default.RRAdd[0]
  246. // @ must reach the gateway as the empty apex name.
  247. if add.Name != "" {
  248. t.Errorf("rr_add name = %q, want empty (the apex)", add.Name)
  249. }
  250. if add.Type != "TXT" || add.Value != "new" {
  251. t.Errorf("rr_add = %+v", add)
  252. }
  253. // TTL was not given, so the existing one is carried over.
  254. if add.TTL != 600 {
  255. t.Errorf("rr_add ttl = %d, want 600 inherited from the existing record", add.TTL)
  256. }
  257. }
  258. func TestSetRecordsPreservesGivenTTL(t *testing.T) {
  259. zoneBody := `<?xml version="1.0" encoding="UTF-8"?>
  260. <response><result><data><zone><name>example.com</name><system_ns>ns1.example.com</system_ns>
  261. <rr><name></name><type>A</type><value>1.1.1.1</value><ttl>300</ttl></rr>
  262. </zone></data><status><code>S0205</code><type>success</type></status></result></response>`
  263. okBody := `<response><result><status><code>S0202001</code><type>success</type></status></result></response>`
  264. var update *Task
  265. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  266. raw, _ := io.ReadAll(r.Body)
  267. var req Request
  268. _ = xml.Unmarshal(raw, &req)
  269. body := zoneBody
  270. if req.Task[0].Code == TaskZoneUpdateBulk {
  271. update = &req.Task[0]
  272. body = okBody
  273. }
  274. w.Header().Set("Content-Type", "application/xml")
  275. _, _ = io.WriteString(w, body)
  276. }))
  277. t.Cleanup(srv.Close)
  278. if err := testClient(srv).SetRecords(context.Background(), "example.com", "@", "A", []string{"2.2.2.2"}, 120); err != nil {
  279. t.Fatalf("SetRecords: %v", err)
  280. }
  281. if update == nil || update.Default == nil || len(update.Default.RRAdd) != 1 {
  282. t.Fatalf("no update task was sent: %+v", update)
  283. }
  284. if got := update.Default.RRAdd[0].TTL; got != 120 {
  285. t.Errorf("rr_add ttl = %d, want the explicit 120", got)
  286. }
  287. }
  288. func TestDeleteWithoutValueRemovesEveryValue(t *testing.T) {
  289. zoneBody := `<?xml version="1.0" encoding="UTF-8"?>
  290. <response><result><data><zone><name>example.com</name><system_ns>ns1.example.com</system_ns>
  291. <rr><name>www</name><type>A</type><value>1.1.1.1</value><ttl>300</ttl></rr>
  292. <rr><name>www</name><type>A</type><value>2.2.2.2</value><ttl>300</ttl></rr>
  293. <rr><name>mail</name><type>A</type><value>3.3.3.3</value><ttl>300</ttl></rr>
  294. </zone></data><status><code>S0205</code><type>success</type></status></result></response>`
  295. okBody := `<response><result><status><code>S0202001</code><type>success</type></status></result></response>`
  296. var update *Task
  297. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  298. raw, _ := io.ReadAll(r.Body)
  299. var req Request
  300. _ = xml.Unmarshal(raw, &req)
  301. body := zoneBody
  302. if req.Task[0].Code == TaskZoneUpdateBulk {
  303. update = &req.Task[0]
  304. body = okBody
  305. }
  306. w.Header().Set("Content-Type", "application/xml")
  307. _, _ = io.WriteString(w, body)
  308. }))
  309. t.Cleanup(srv.Close)
  310. if err := testClient(srv).DeleteRecords(context.Background(), "example.com", "www", "A", nil); err != nil {
  311. t.Fatalf("DeleteRecords: %v", err)
  312. }
  313. if update == nil || update.Default == nil {
  314. t.Fatal("no update task was sent")
  315. }
  316. // Only the two records at that name, not the unrelated one.
  317. if len(update.Default.RRRem) != 2 {
  318. t.Errorf("rr_rem count = %d, want 2", len(update.Default.RRRem))
  319. }
  320. if len(update.Default.RRAdd) != 0 {
  321. t.Errorf("a delete sent %d rr_add blocks", len(update.Default.RRAdd))
  322. }
  323. }
  324. func TestDeleteUnknownRecordIsRefusedLocally(t *testing.T) {
  325. zoneBody := `<?xml version="1.0" encoding="UTF-8"?>
  326. <response><result><data><zone><name>example.com</name><system_ns>ns1.example.com</system_ns>
  327. <rr><name>www</name><type>A</type><value>1.1.1.1</value><ttl>300</ttl></rr>
  328. </zone></data><status><code>S0205</code><type>success</type></status></result></response>`
  329. var updates int
  330. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  331. raw, _ := io.ReadAll(r.Body)
  332. var req Request
  333. _ = xml.Unmarshal(raw, &req)
  334. if req.Task[0].Code == TaskZoneUpdateBulk {
  335. updates++
  336. }
  337. w.Header().Set("Content-Type", "application/xml")
  338. _, _ = io.WriteString(w, zoneBody)
  339. }))
  340. t.Cleanup(srv.Close)
  341. err := testClient(srv).DeleteRecords(context.Background(), "example.com", "@", "TXT", nil)
  342. if err == nil {
  343. t.Fatal("expected an error when nothing matches")
  344. }
  345. if updates != 0 {
  346. t.Errorf("an update was sent anyway (%d)", updates)
  347. }
  348. }
  349. func TestSetRecordsRejectsEmptyValues(t *testing.T) {
  350. srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
  351. t.Error("no request should be made")
  352. }))
  353. t.Cleanup(srv.Close)
  354. if err := testClient(srv).SetRecords(context.Background(), "example.com", "@", "TXT", nil, 0); err == nil {
  355. t.Fatal("expected an error for an empty value list")
  356. }
  357. }
  358. // The gateway rejects a body with a byte order mark, so the client must not
  359. // send one.
  360. func TestRequestHasNoBOM(t *testing.T) {
  361. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  362. raw, _ := io.ReadAll(r.Body)
  363. if len(raw) >= 3 && raw[0] == 0xEF && raw[1] == 0xBB && raw[2] == 0xBF {
  364. t.Errorf("request starts with a byte order mark: % X", raw[:3])
  365. }
  366. if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/xml") {
  367. t.Errorf("Content-Type = %q", ct)
  368. }
  369. w.Header().Set("Content-Type", "application/xml")
  370. _, _ = io.WriteString(w, `<response><result><status><code>S0205</code><type>success</type></status></result></response>`)
  371. }))
  372. t.Cleanup(srv.Close)
  373. if _, err := testClient(srv).Zones(context.Background()); err != nil {
  374. t.Fatalf("Zones: %v", err)
  375. }
  376. }
  377. func TestUpdateFailureSurfacesTheGatewayCode(t *testing.T) {
  378. zoneBody := `<?xml version="1.0" encoding="UTF-8"?>
  379. <response><result><data><zone><name>example.com</name><system_ns>ns1.example.com</system_ns>
  380. </zone></data><status><code>S0205</code><type>success</type></status></result></response>`
  381. // EF02020 is the documented "no such zone exists".
  382. errBody := `<?xml version="1.0" encoding="UTF-8"?>
  383. <response><result>
  384. <msg><text>No such zone exists.</text><code>EF02020</code><type>error</type>
  385. <object><type>zone</type><value>example.com</value></object></msg>
  386. <status><code>E00000</code><text>Errors occurred during processing.</text><type>error</type></status>
  387. </result></response>`
  388. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  389. raw, _ := io.ReadAll(r.Body)
  390. var req Request
  391. _ = xml.Unmarshal(raw, &req)
  392. w.Header().Set("Content-Type", "application/xml")
  393. if req.Task[0].Code == TaskZoneUpdateBulk {
  394. _, _ = io.WriteString(w, errBody)
  395. return
  396. }
  397. _, _ = io.WriteString(w, zoneBody)
  398. }))
  399. t.Cleanup(srv.Close)
  400. err := testClient(srv).SetRecords(context.Background(), "example.com", "@", "A", []string{"1.2.3.4"}, 0)
  401. apiErr, ok := err.(*APIError)
  402. if !ok {
  403. t.Fatalf("error type = %T (%v), want *APIError", err, err)
  404. }
  405. if apiErr.Code != "EF02020" {
  406. t.Errorf("code = %q, want EF02020", apiErr.Code)
  407. }
  408. if !strings.Contains(apiErr.Object, "example.com") {
  409. t.Errorf("object = %q, want it to name the zone", apiErr.Object)
  410. }
  411. }
  412. // The Auth object documents token as an optional 2FA element. It must appear in
  413. // the auth block when set, and be absent otherwise.
  414. func TestTwoFactorTokenIsSerialised(t *testing.T) {
  415. for _, tc := range []struct {
  416. name string
  417. token string
  418. want bool
  419. }{
  420. {name: "without 2FA", token: "", want: false},
  421. {name: "with 2FA", token: "123456", want: true},
  422. } {
  423. t.Run(tc.name, func(t *testing.T) {
  424. srv, req := respond(t, `<response><result><status><code>S0205</code><type>success</type></status></result></response>`)
  425. c := New(
  426. Credentials{User: "u", Password: "p", Context: "10", Token: tc.token},
  427. WithEndpoint(srv.URL),
  428. WithInterval(time.Microsecond),
  429. )
  430. if _, err := c.Zones(context.Background()); err != nil {
  431. t.Fatalf("Zones: %v", err)
  432. }
  433. if got := req.Auth.Token != ""; got != tc.want {
  434. t.Errorf("auth token present = %v, want %v", got, tc.want)
  435. }
  436. })
  437. }
  438. }