248 lines
8.6 KiB
Go
248 lines
8.6 KiB
Go
package hetzner
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
|
|
)
|
|
|
|
func TestDNSWritesWaitForActionCompletion(t *testing.T) {
|
|
operations := map[string]func(*Client) error{
|
|
"create": func(c *Client) error {
|
|
return c.CreateRRSet(context.Background(), "token", "123", "home", "A", 60, nil)
|
|
},
|
|
"records": func(c *Client) error {
|
|
return c.SetRRSetRecords(context.Background(), "token", "123", "home", "A", nil)
|
|
},
|
|
"ttl": func(c *Client) error { return c.ChangeRRSetTTL(context.Background(), "token", "123", "home", "A", 60) },
|
|
"comments": func(c *Client) error {
|
|
return c.UpdateRecordComments(context.Background(), "token", "123", "home", "A", nil)
|
|
},
|
|
"delete": func(c *Client) error { return c.DeleteRRSet(context.Background(), "token", "123", "home", "A") },
|
|
}
|
|
for name, operation := range operations {
|
|
t.Run(name, func(t *testing.T) {
|
|
for _, status := range []string{"success", "error"} {
|
|
t.Run(status, func(t *testing.T) {
|
|
var writes, polls int
|
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writes++
|
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"running"}}`))
|
|
return
|
|
}
|
|
if r.URL.Path != "/zones/actions/42" || r.Header.Get("Authorization") != "Bearer token" {
|
|
t.Errorf("incorrect action polling request: %s", r.URL.Path)
|
|
}
|
|
polls++
|
|
if polls == 1 {
|
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"running"}}`))
|
|
} else {
|
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"` + status + `","error":{"code":"action_failed","message":"failed later"}}}`))
|
|
}
|
|
}))
|
|
defer api.Close()
|
|
client := New(api.URL)
|
|
client.actionPollInterval = time.Millisecond
|
|
err := operation(client)
|
|
if status == "success" && err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var actionErr *ActionError
|
|
if status == "error" && (!errors.As(err, &actionErr) || actionErr.Code != "action_failed") {
|
|
t.Fatalf("lost action failure: %v", err)
|
|
}
|
|
if status == "error" && (diagnostics.Failure(err) != "provider_action" || diagnostics.ActionID(err) != 42) {
|
|
t.Fatal("action failure missing from safe diagnostics")
|
|
}
|
|
if writes != 1 || polls != 2 {
|
|
t.Fatalf("returned too early or repeated write: writes=%d polls=%d", writes, polls)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestActionWaitIsBoundedAndRejectsMalformedResponses(t *testing.T) {
|
|
for _, body := range []string{"", `{}`, `{"action":{"id":42,"status":"unknown"}}`, `{"action":{"status":"running"}}`, `{"action":{"id":42,"status":"running"}}`} {
|
|
t.Run(body, func(t *testing.T) {
|
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) }))
|
|
defer api.Close()
|
|
client := New(api.URL)
|
|
client.actionPollInterval = time.Millisecond
|
|
client.actionTimeout = 30 * time.Millisecond
|
|
if err := client.DeleteRRSet(context.Background(), "token", "123", "home", "A"); err == nil {
|
|
t.Fatal("unconfirmed operation reported success")
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := client.DeleteRRSet(ctx, "token", "123", "home", "A"); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("cancellation lost: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPaginationRejectsRepeatedPage(t *testing.T) {
|
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"zones":[],"rrsets":[],"meta":{"pagination":{"next_page":1}}}`))
|
|
}))
|
|
defer api.Close()
|
|
client := New(api.URL)
|
|
if _, err := client.ListZones(context.Background(), "token"); err == nil {
|
|
t.Fatal("repeated zones page accepted")
|
|
}
|
|
if _, err := client.ListRRSets(context.Background(), "token", "123"); err == nil {
|
|
t.Fatal("repeated RRsets page accepted")
|
|
}
|
|
}
|
|
|
|
func TestSetRRSetRecordsUsesSetRecordsAction(t *testing.T) {
|
|
t.Parallel()
|
|
type capturedRequest struct {
|
|
Method string
|
|
Path string
|
|
Authorization string
|
|
Records []Record
|
|
}
|
|
captured := make(chan capturedRequest, 1)
|
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Records []Record `json:"records"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
captured <- capturedRequest{
|
|
Method: r.Method,
|
|
Path: r.URL.Path,
|
|
Authorization: r.Header.Get("Authorization"),
|
|
Records: body.Records,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
|
}))
|
|
defer api.Close()
|
|
|
|
client := New(api.URL)
|
|
err := client.SetRRSetRecords(
|
|
context.Background(),
|
|
"test-token",
|
|
"123",
|
|
"home",
|
|
"A",
|
|
[]Record{{Value: "198.51.100.42", Comment: "DynDNS"}},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("set RRSet records: %v", err)
|
|
}
|
|
request := <-captured
|
|
if request.Method != http.MethodPost {
|
|
t.Errorf("unexpected method: got %s, want POST", request.Method)
|
|
}
|
|
if request.Path != "/zones/123/rrsets/home/A/actions/set_records" {
|
|
t.Errorf("unexpected path: %s", request.Path)
|
|
}
|
|
if request.Authorization != "Bearer test-token" {
|
|
t.Errorf("unexpected authorization header: %q", request.Authorization)
|
|
}
|
|
if len(request.Records) != 1 || request.Records[0].Value != "198.51.100.42" || request.Records[0].Comment != "DynDNS" {
|
|
t.Errorf("unexpected records payload: %#v", request.Records)
|
|
}
|
|
}
|
|
|
|
func TestUpdateRecordCommentsAlwaysSendsComment(t *testing.T) {
|
|
t.Parallel()
|
|
captured := make(chan map[string]any, 1)
|
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
body["method"] = r.Method
|
|
body["path"] = r.URL.Path
|
|
captured <- body
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
|
}))
|
|
defer api.Close()
|
|
|
|
client := New(api.URL)
|
|
if err := client.UpdateRecordComments(context.Background(), "test-token", "123", "home", "A", []Record{{Value: "198.51.100.42"}}); err != nil {
|
|
t.Fatalf("update record comment: %v", err)
|
|
}
|
|
request := <-captured
|
|
if request["method"] != http.MethodPost || request["path"] != "/zones/123/rrsets/home/A/actions/update_records" {
|
|
t.Fatalf("unexpected request: %#v", request)
|
|
}
|
|
records, ok := request["records"].([]any)
|
|
if !ok || len(records) != 1 {
|
|
t.Fatalf("unexpected records payload: %#v", request["records"])
|
|
}
|
|
record, ok := records[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("unexpected record payload: %#v", records[0])
|
|
}
|
|
comment, exists := record["comment"]
|
|
if !exists || comment != "" {
|
|
t.Errorf("empty comment must be sent explicitly, got %#v", record)
|
|
}
|
|
}
|
|
|
|
func TestChangeRRSetTTLUsesDedicatedAction(t *testing.T) {
|
|
t.Parallel()
|
|
captured := make(chan map[string]any, 1)
|
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
body["method"] = r.Method
|
|
body["path"] = r.URL.Path
|
|
captured <- body
|
|
w.WriteHeader(http.StatusCreated)
|
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
|
}))
|
|
defer api.Close()
|
|
|
|
client := New(api.URL)
|
|
if err := client.ChangeRRSetTTL(context.Background(), "test-token", "123", "home", "A", 300); err != nil {
|
|
t.Fatalf("change RRSet TTL: %v", err)
|
|
}
|
|
request := <-captured
|
|
if request["method"] != http.MethodPost || request["path"] != "/zones/123/rrsets/home/A/actions/change_ttl" || request["ttl"] != float64(300) {
|
|
t.Fatalf("unexpected request: %#v", request)
|
|
}
|
|
}
|
|
|
|
func TestDeleteRRSetUsesRRSetEndpoint(t *testing.T) {
|
|
t.Parallel()
|
|
captured := make(chan *http.Request, 1)
|
|
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
captured <- r
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
|
|
}))
|
|
defer api.Close()
|
|
|
|
client := New(api.URL)
|
|
if err := client.DeleteRRSet(context.Background(), "test-token", "123", "www", "CNAME"); err != nil {
|
|
t.Fatalf("delete RRSet: %v", err)
|
|
}
|
|
request := <-captured
|
|
if request.Method != http.MethodDelete || request.URL.Path != "/zones/123/rrsets/www/CNAME" || request.Header.Get("Authorization") != "Bearer test-token" {
|
|
t.Fatalf("unexpected request: %s %s %q", request.Method, request.URL.Path, request.Header.Get("Authorization"))
|
|
}
|
|
}
|