Files
traefik-selectel/internal/selectel/selectel_test.go
T
vasyanskandClaude Sonnet 5 273cd54415
Build / Tests (push) Successful in 13s
Build / Build image (push) Failing after 15s
Build / Notify on failure (push) Failing after 0s
feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:50:07 +07:00

289 lines
9.4 KiB
Go

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)
}
}