package selectel import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync" "sync/atomic" "testing" "time" ) type staticTokens struct { tok string invalidated int32 } func (s *staticTokens) Token(context.Context) (string, error) { return s.tok, nil } func (s *staticTokens) Invalidate() { atomic.AddInt32(&s.invalidated, 1) } func testCreds() Credentials { return Credentials{Username: "u", Password: "secret-pass", AccountID: "123456", ProjectID: "proj-1"} } func TestCredentialsStringHidesPassword(t *testing.T) { s := testCreds().String() if strings.Contains(s, "secret-pass") { t.Fatalf("пароль утёк в String(): %s", s) } if fmt := (Credentials{}).Validate(); fmt == nil { t.Fatal("ожидалась ошибка валидации") } } func TestIAMTokenSourceRequestAndCache(t *testing.T) { var calls int32 var gotBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/identity/v3/auth/tokens" || r.Method != http.MethodPost { t.Errorf("%s %s", r.Method, r.URL.Path) } atomic.AddInt32(&calls, 1) b, _ := io.ReadAll(r.Body) _ = json.Unmarshal(b, &gotBody) w.Header().Set("X-Subject-Token", "tok-"+fmt.Sprint(atomic.LoadInt32(&calls))) w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{"token":{"expires_at":"2030-01-01T00:00:00.000000Z"}}`)) })) defer srv.Close() ts, err := NewIAMTokenSource(testCreds(), srv.URL+"/identity/v3/", time.Second) if err != nil { t.Fatal(err) } now := time.Date(2029, 1, 1, 0, 0, 0, 0, time.UTC) ts.now = func() time.Time { return now } tok, err := ts.Token(context.Background()) if err != nil || tok != "tok-1" { t.Fatalf("tok=%q err=%v", tok, err) } if tok2, _ := ts.Token(context.Background()); tok2 != "tok-1" || calls != 1 { t.Fatalf("токен не закэширован: %q calls=%d", tok2, calls) } // структура запроса auth := gotBody["auth"].(map[string]any) user := auth["identity"].(map[string]any)["password"].(map[string]any)["user"].(map[string]any) if user["name"] != "u" || user["password"] != "secret-pass" || user["domain"].(map[string]any)["name"] != "123456" { t.Errorf("user = %v", user) } proj := auth["scope"].(map[string]any)["project"].(map[string]any) if proj["id"] != "proj-1" || proj["domain"].(map[string]any)["name"] != "123456" { t.Errorf("project = %v", proj) } // обновление за margin до истечения now = time.Date(2029, 12, 31, 23, 55, 0, 0, time.UTC) if tok3, _ := ts.Token(context.Background()); tok3 != "tok-2" || calls != 2 { t.Fatalf("токен не обновлён: %q calls=%d", tok3, calls) } ts.Invalidate() if tok4, _ := ts.Token(context.Background()); tok4 != "tok-3" { t.Fatalf("после Invalidate: %q", tok4) } } func TestIAMTokenSourceErrorDoesNotLeakBody(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = w.Write([]byte("secret-pass echoed")) })) defer srv.Close() ts, _ := NewIAMTokenSource(testCreds(), srv.URL, time.Second) _, err := ts.Token(context.Background()) if err == nil || strings.Contains(err.Error(), "secret-pass") { t.Fatalf("err = %v", err) } } // fakeDNS — минимальный эмулятор DNS API. type fakeDNS struct { mu sync.Mutex t *testing.T requests []string bodies map[string]string rrsets []map[string]any fail401 int32 } func (f *fakeDNS) handler(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() if r.Header.Get("X-Auth-Token") != "tok" { w.WriteHeader(http.StatusUnauthorized) return } if atomic.LoadInt32(&f.fail401) > 0 { atomic.AddInt32(&f.fail401, -1) w.WriteHeader(http.StatusUnauthorized) return } f.requests = append(f.requests, r.Method+" "+r.URL.Path) b, _ := io.ReadAll(r.Body) if len(b) > 0 { if f.bodies == nil { f.bodies = map[string]string{} } f.bodies[r.Method+" "+r.URL.Path] = string(b) } switch { case r.Method == http.MethodGet && r.URL.Path == "/zones": _ = json.NewEncoder(w).Encode(map[string]any{"count": 2, "next_offset": 0, "result": []map[string]any{ {"id": "z-other", "name": "notexample.com."}, {"id": "z1", "name": "example.com."}, }}) case r.Method == http.MethodGet && r.URL.Path == "/zones/z1/rrset": _ = json.NewEncoder(w).Encode(map[string]any{"count": len(f.rrsets), "next_offset": 0, "result": f.rrsets}) case r.Method == http.MethodPost && r.URL.Path == "/zones/z1/rrset": _, _ = w.Write([]byte(`{"id":"new"}`)) case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, "/zones/z1/rrset/"): w.WriteHeader(http.StatusNoContent) case r.Method == http.MethodDelete && r.URL.Path == "/zones/z1/rrset/gone": w.WriteHeader(http.StatusNotFound) case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/zones/z1/rrset/"): w.WriteHeader(http.StatusNoContent) default: w.WriteHeader(http.StatusNotFound) } } func newTestClient(t *testing.T, f *fakeDNS) (*Client, *staticTokens) { srv := httptest.NewServer(http.HandlerFunc(f.handler)) t.Cleanup(srv.Close) tk := &staticTokens{tok: "tok"} c := NewClient(srv.URL, tk, time.Second) c.retry.Sleep = func(context.Context, time.Duration) error { return nil } return c, tk } func TestListRRSetsNormalizesAndResolvesZone(t *testing.T) { f := &fakeDNS{t: t, rrsets: []map[string]any{ {"id": "r1", "name": "App.Example.com.", "type": "A", "ttl": 300, "comment": "managed-by=x", "records": []map[string]any{{"content": "1.2.3.4", "disabled": false}}}, }} c, _ := newTestClient(t, f) got, err := c.ListRRSets(context.Background(), "Example.com.") if err != nil { t.Fatal(err) } if len(got) != 1 || got[0].Name != "app.example.com" || got[0].Type != "A" || got[0].Records[0].Content != "1.2.3.4" || got[0].Comment != "managed-by=x" { t.Fatalf("got = %+v", got) } // зона закэширована: второй вызов не ходит в /zones if _, err := c.ListRRSets(context.Background(), "example.com"); err != nil { t.Fatal(err) } zoneCalls := 0 for _, r := range f.requests { if r == "GET /zones" { zoneCalls++ } } if zoneCalls != 1 { t.Fatalf("GET /zones вызван %d раз", zoneCalls) } } func TestZoneNotFound(t *testing.T) { f := &fakeDNS{t: t} c, _ := newTestClient(t, f) _, err := c.ListRRSets(context.Background(), "missing.org") if !errors.Is(err, ErrZoneNotFound) { t.Fatalf("err = %v", err) } } func TestCreateUpdateDelete(t *testing.T) { f := &fakeDNS{t: t} c, _ := newTestClient(t, f) ctx := context.Background() err := c.CreateRRSet(ctx, "example.com", RRSet{Name: "app.example.com", Type: "a", TTL: 300, Records: []Record{{Content: "1.2.3.4"}}, Comment: "managed-by=traefik-selectel-dns"}) if err != nil { t.Fatal(err) } var body map[string]any if err := json.Unmarshal([]byte(f.bodies["POST /zones/z1/rrset"]), &body); err != nil { t.Fatal(err) } if body["name"] != "app.example.com." || body["type"] != "A" || body["ttl"].(float64) != 300 || body["comment"] != "managed-by=traefik-selectel-dns" { t.Errorf("create body = %v", body) } recs := body["records"].([]any) if len(recs) != 1 || recs[0].(map[string]any)["content"] != "1.2.3.4" { t.Errorf("records = %v", recs) } if err := c.UpdateRRSet(ctx, "example.com", "r1", 600, []Record{{Content: "5.6.7.8"}}, "managed-by=traefik-selectel-dns"); err != nil { t.Fatal(err) } var upd map[string]any _ = json.Unmarshal([]byte(f.bodies["PATCH /zones/z1/rrset/r1"]), &upd) if _, has := upd["name"]; has || upd["ttl"].(float64) != 600 { t.Errorf("patch body = %v", upd) } if err := c.DeleteRRSet(ctx, "example.com", "r1"); err != nil { t.Fatal(err) } if err := c.DeleteRRSet(ctx, "example.com", "gone"); err != nil { t.Fatalf("404 на delete должен быть успехом: %v", err) } } func TestUnauthorizedInvalidatesTokenAndRetriesOnce(t *testing.T) { f := &fakeDNS{t: t, fail401: 1} c, tk := newTestClient(t, f) if _, err := c.ListRRSets(context.Background(), "example.com"); err != nil { t.Fatal(err) } if tk.invalidated != 1 { t.Fatalf("invalidated = %d", tk.invalidated) } } func TestAPIErrorOn422(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/zones" { _, _ = w.Write([]byte(`{"result":[{"id":"z1","name":"example.com."}]}`)) return } w.WriteHeader(http.StatusUnprocessableEntity) _, _ = w.Write([]byte(`{"error":"bad"}`)) })) defer srv.Close() c := NewClient(srv.URL, &staticTokens{tok: "tok"}, time.Second) err := c.CreateRRSet(context.Background(), "example.com", RRSet{Name: "a.example.com", Type: "A", TTL: 300, Records: []Record{{Content: "1.1.1.1"}}}) var apiErr *APIError if !errors.As(err, &apiErr) || apiErr.Status != 422 { t.Fatalf("err = %v", err) } } func TestRetryOn503(t *testing.T) { var n int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if atomic.AddInt32(&n, 1) == 1 { w.WriteHeader(http.StatusServiceUnavailable) return } _, _ = w.Write([]byte(`{"result":[{"id":"z1","name":"example.com."}]}`)) })) defer srv.Close() c := NewClient(srv.URL, &staticTokens{tok: "tok"}, time.Second) c.retry.Sleep = func(context.Context, time.Duration) error { return nil } id, err := c.zoneID(context.Background(), "example.com", true) if err != nil || id != "z1" || n != 2 { t.Fatalf("id=%q err=%v n=%d", id, err, n) } }