Merge feature/selectel-iam-auth: фикс 401 — project IAM-токен для Cloud DNS v2

Приложение получает 24ч IAM-токен из учётки сервисного пользователя (Identity API),
кэширует и обновляет; учётка = зашифрованный JSON {username,password,account_id,project_name};
валидация кредов пробным логином при добавлении; форма из 4 полей.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
2026-07-04 21:25:39 +07:00
14 changed files with 1059 additions and 107 deletions
@@ -0,0 +1,398 @@
# Selectel IAM-авторизация (Cloud DNS v2) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Заменить неработающую авторизацию Selectel (статический API-ключ → 401) на project-scoped IAM-токен: приложение само получает 24ч-токен из учётки сервисного пользователя, кэширует и обновляет.
**Architecture:** Cloud DNS v2 (`/domains/v2`) требует `X-Auth-Token` = project IAM-токен, выдаваемый Identity API (Keystone v3) сервисному пользователю. Учётка теперь хранит зашифрованный JSON `{username, password, account_id, project_name}` в том же `provider_accounts.secret_enc` (схема БД не меняется). Selectel-клиент получает токен через `POST https://cloud.api.selcloud.ru/identity/v3/auth/tokens`, кэширует его в памяти по ключу учётки до `expires_at` минус запас. Креды проверяются пробным логином при добавлении учётки.
**Tech Stack:** Go (net/http, crypto/aes-gcm cipher уже есть), React 19 + Vite + zod.
## Global Constraints
- Identity endpoint: `https://cloud.api.selcloud.ru/identity/v3/auth/tokens` (домен **selcloud.ru**). DNS v2 base: `https://api.selectel.ru/domains/v2` (не менять).
- Тело запроса токена (project-scoped) — строго по доке Selectel (см. Task 1 Step 3).
- Токен — в заголовке ответа `X-Subject-Token`; срок — в теле `token.expires_at` (ISO 8601 / RFC3339). Запас перед истечением: 5 минут.
- SECURITY: пароль сервисного пользователя — секрет. НИКОГДА не в логах, не в HTTP-ответах, не в текстах ошибок. `accountResponse` уже исключает secret — не регрессировать. При ошибке Identity/DNS не включать тело с кредами в возвращаемую ошибку.
- Секрет учётки шифруется целиком (весь JSON) существующим `crypto.Cipher`. Кэш токенов — только в памяти, не в БД.
- Комментарии в Go — на английском (как в файлах), в web — как в окружающих файлах.
- TDD: тест падает → минимальная реализация → тест проходит → commit. НЕ коммитить реальную сборку `internal/web/dist/*` (перед коммитом `git checkout internal/web/dist/index.html`).
---
### Task 1: Selectel IAM-аутентификация + кэш токена
**Files:**
- Modify: `internal/provider/provider.go`, `internal/provider/selectel/selectel.go`, `internal/provider/registry/registry_test.go` (fakeProvider += Validate), `cmd/server/main.go` (если New() сигнатура)
- Test: `internal/provider/selectel/selectel_test.go` (создать, если нет — проверь)
**Interfaces:**
- Produces: `provider.Provider` получает метод `Validate(ctx, Credentials) error`; `selectel.Creds{Username,Password,AccountID,ProjectName}` (JSON-теги `username/password/account_id/project_name`); `selectel.Client` кэширует IAM-токен и шлёт его как `X-Auth-Token` в v2.
- Consumes: `provider.Credentials.Secret` теперь = расшифрованный provider-specific JSON (для Selectel — Creds).
- [ ] **Step 1: Тест — Credentials парсится, Validate делает пробный логин**
В `internal/provider/selectel/selectel_test.go`: подними `httptest.Server`, отвечающий на `POST /identity/v3/auth/tokens` заголовком `X-Subject-Token: tok-1` и телом `{"token":{"expires_at":"<now+24h RFC3339>"}}`, статус 201. Настрой `Client{IdentityURL: srv.URL+"/identity/v3/auth/tokens", ...}`. Проверь: `Validate(ctx, Credentials{Secret: `{"username":"u","password":"p","account_id":"123","project_name":"proj"}`})` возвращает nil; при 401 от Identity — ошибку. Run: `go test ./internal/provider/selectel/... -run Validate -v`. Ожидание: FAIL (метод/поля не существуют).
- [ ] **Step 2: Тип Creds + парсинг**
В `selectel.go` добавь:
```go
// Creds is the Selectel service-user credential set, stored as the encrypted
// JSON blob in provider_accounts.secret_enc and parsed from Credentials.Secret.
type Creds struct {
Username string `json:"username"`
Password string `json:"password"`
AccountID string `json:"account_id"`
ProjectName string `json:"project_name"`
}
func parseCreds(secret string) (Creds, error) {
var c Creds
if err := json.Unmarshal([]byte(secret), &c); err != nil {
return Creds{}, fmt.Errorf("selectel: invalid credentials format")
}
if c.Username == "" || c.Password == "" || c.AccountID == "" || c.ProjectName == "" {
return Creds{}, fmt.Errorf("selectel: username, password, account_id and project_name are required")
}
return c, nil
}
```
- [ ] **Step 3: Token-manager (authenticate + cache)**
Расширь `Client`:
```go
const (
DefaultBaseURL = "https://api.selectel.ru/domains/v2"
DefaultIdentityURL = "https://cloud.api.selcloud.ru/identity/v3/auth/tokens"
tokenLeeway = 5 * time.Minute
)
type Client struct {
BaseURL string
IdentityURL string
HTTP *http.Client
mu sync.Mutex
tokens map[string]cachedToken
}
type cachedToken struct {
token string
expires time.Time
}
func New() *Client {
return &Client{
BaseURL: DefaultBaseURL,
IdentityURL: DefaultIdentityURL,
HTTP: &http.Client{Timeout: 30 * time.Second},
tokens: map[string]cachedToken{},
}
}
```
`cacheKey` — по неизменяемой части учётки (без пароля в открытом виде в ключе; пароль включаем как часть идентичности через хеш, чтобы смена пароля инвалидировала запись):
```go
func cacheKey(c Creds) string {
sum := sha256.Sum256([]byte(c.AccountID + "\x00" + c.ProjectName + "\x00" + c.Username + "\x00" + c.Password))
return hex.EncodeToString(sum[:])
}
```
`authenticate` — POST в Identity, вернуть токен и срок:
```go
func (c *Client) authenticate(ctx context.Context, cr Creds) (string, time.Time, error) {
body := map[string]any{
"auth": map[string]any{
"identity": map[string]any{
"methods": []string{"password"},
"password": map[string]any{
"user": map[string]any{
"name": cr.Username,
"domain": map[string]any{"name": cr.AccountID},
"password": cr.Password,
},
},
},
"scope": map[string]any{
"project": map[string]any{
"name": cr.ProjectName,
"domain": map[string]any{"name": cr.AccountID},
},
},
},
}
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.IdentityURL, bytes.NewReader(b))
if err != nil {
return "", time.Time{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
// Never wrap the error with request/body (contains password).
return "", time.Time{}, fmt.Errorf("selectel: identity request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return "", time.Time{}, fmt.Errorf("selectel: identity auth failed: %d", resp.StatusCode)
}
tok := resp.Header.Get("X-Subject-Token")
if tok == "" {
return "", time.Time{}, fmt.Errorf("selectel: identity returned no token")
}
var out struct {
Token struct {
ExpiresAt string `json:"expires_at"`
} `json:"token"`
}
// Body may be absent/short — a decode error is non-fatal; fall back to a
// conservative 1h TTL so the token is still usable and refreshed sooner.
exp := time.Time{}
if err := json.NewDecoder(resp.Body).Decode(&out); err == nil && out.Token.ExpiresAt != "" {
if t, perr := time.Parse(time.RFC3339, out.Token.ExpiresAt); perr == nil {
exp = t
}
}
return tok, exp, nil
}
```
`token` — кэш с запасом; `now` инъектируется для тестируемости через поле или передаётся. Используй `time.Now()` напрямую (в тестах играй сроком expires):
```go
func (c *Client) token(ctx context.Context, cr Creds) (string, error) {
key := cacheKey(cr)
c.mu.Lock()
if ct, ok := c.tokens[key]; ok && (ct.expires.IsZero() || time.Now().Add(tokenLeeway).Before(ct.expires)) {
c.mu.Unlock()
return ct.token, nil
}
c.mu.Unlock()
tok, exp, err := c.authenticate(ctx, cr)
if err != nil {
return "", err
}
// Zero expiry (unparseable) → treat as short-lived: cache for 1h from now.
if exp.IsZero() {
exp = time.Now().Add(time.Hour)
}
c.mu.Lock()
c.tokens[key] = cachedToken{token: tok, expires: exp}
c.mu.Unlock()
return tok, nil
}
```
(Добавь импорты: `sync`, `crypto/sha256`, `encoding/hex`, `time` — часть уже есть.)
- [ ] **Step 4: Validate + подключение токена в методы**
```go
func (c *Client) Validate(ctx context.Context, creds provider.Credentials) error {
cr, err := parseCreds(creds.Secret)
if err != nil {
return err
}
if _, err := c.token(ctx, cr); err != nil {
return err
}
return nil
}
```
В `ListZones`, `GetRecords``listRRSets`, `ApplyChanges`: в начале `cr, err := parseCreds(creds.Secret)`; `tok, err := c.token(ctx, cr)`; далее использовать `tok` там, где сейчас передаётся `creds.Secret` в `c.do(...)`. `listRRSets`/`do` уже принимают token-строку — передавать `tok` вместо расшифрованного secret. Сигнатуры `listRRSets(ctx, token, zoneID)` и `do(...token...)` НЕ менять — они и так работают с токеном.
Проверь на 401-refresh: если хочешь (опционально, вне минимума) — при 401 от v2 сбросить кэш и повторить один раз. НЕ добавляй, если усложняет; базовый кэш-с-запасом достаточен для плана.
- [ ] **Step 5: provider.Provider += Validate; fakeProvider в registry_test**
`provider.go`:
```go
// Credentials holds the provider-specific secret. It is stored encrypted and,
// once decrypted, is a provider-defined value — for Selectel a JSON blob with
// the service-user credentials (see selectel.Creds).
type Credentials struct {
Secret string
}
```
Добавь в интерфейс `Provider`:
```go
// Validate checks the credentials are usable (e.g. a trial auth), so a
// bad account is rejected at creation time rather than at first import.
Validate(ctx context.Context, creds Credentials) error
```
В `internal/provider/registry/registry_test.go` добавь методу `fakeProvider` реализацию:
```go
func (fakeProvider) Validate(context.Context, provider.Credentials) error { return nil }
```
- [ ] **Step 6: Тесты — кэш переиспользуется/рефрешится, ListZones шлёт полученный токен**
В `selectel_test.go`: счётчик обращений к Identity. Проверь: (а) два `token()` подряд с валидным сроком → 1 обращение к Identity (кэш); (б) `expires_at` в прошлом (или в пределах leeway) → повторный `token()` снова логинится; (в) `ListZones` шлёт на `/zones` заголовок `X-Auth-Token: tok-1` (значение от Identity, НЕ сырые creds) — эмулируй оба эндпоинта на одном httptest.Server, роутинг по пути.
- [ ] **Step 7: Прогон и коммит**
Run: `go build ./... && go test ./internal/provider/...`. Ожидание PASS.
```bash
git add internal/provider/ cmd/server/
git commit -m "feat(selectel): project-scoped IAM auth with token cache; provider Validate"
```
---
### Task 2: API — приём структурированной учётки + валидация при создании
**Files:**
- Modify: `internal/api/tenant_dto.go` (accountRequest.Secret → json.RawMessage), `internal/api/tenant_handlers.go` (handleCreateAccount), `internal/api/api.go` (если нужен доступ к Reg — уже есть a.Reg)
- Test: `internal/api/tenant_test.go` (или соответствующий)
**Interfaces:**
- Consumes: `provider.Provider.Validate` (Task 1); `a.Reg.ByName`; `a.Cipher.Encrypt`.
- Produces: `POST /accounts` принимает `{provider, comment, secret: <provider-specific JSON object>}`; невалидные креды → 400 до сохранения.
- [ ] **Step 1: Тест — невалидные креды дают 400, валидные 201 + шифрование**
В `tenant_test.go`: mock provider registry, где `Validate` возвращает ошибку для одного входа и nil для другого (или мок-провайдер с настраиваемым validateErr). POST `/accounts` с `secret` = JSON-объект. Ожидание: при validateErr → 400 и `CreateAccount` НЕ вызван; при nil → 201, `Cipher.Encrypt` получил сырой JSON, `CreateAccount` вызван. Run: `go test ./internal/api/... -run CreateAccount -v`. Ожидание: FAIL (Secret ещё string, валидации нет).
- [ ] **Step 2: accountRequest.Secret → json.RawMessage**
`tenant_dto.go`:
```go
type accountRequest struct {
Provider string `json:"provider"`
Secret json.RawMessage `json:"secret"`
Comment string `json:"comment"`
}
```
(добавь импорт `encoding/json`.)
- [ ] **Step 3: handleCreateAccount — валидация перед сохранением**
```go
func (a *API) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
pid, _ := projectIDFrom(r.Context())
var req accountRequest
if !decodeBody(w, r, &req) {
return
}
if req.Provider == "" || len(req.Secret) == 0 {
writeErr(w, http.StatusBadRequest, "provider and secret are required")
return
}
p, err := a.Reg.ByName(req.Provider)
if err != nil {
writeErr(w, http.StatusBadRequest, "unknown provider")
return
}
// Trial auth up-front so bad credentials fail at creation, not at import.
// The error text is deliberately generic — never echo the secret back.
if err := p.Validate(r.Context(), provider.Credentials{Secret: string(req.Secret)}); err != nil {
writeErr(w, http.StatusBadRequest, "invalid provider credentials")
return
}
secretEnc, err := a.Cipher.Encrypt(req.Secret)
if err != nil {
log.Printf("api: encrypt secret failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
acc, err := a.Store.CreateAccount(r.Context(), pid, req.Provider, secretEnc, req.Comment)
if err != nil {
log.Printf("api: create account failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, toAccountResponse(acc))
}
```
(`a.Cipher.Encrypt` принимает `[]byte`; `req.Secret``json.RawMessage` = `[]byte`, передавай напрямую. Убедись, что `provider` импортирован в tenant_handlers.go — уже да.)
- [ ] **Step 4: Прогон и коммит**
Run: `go build ./... && go test ./internal/api/...`. Ожидание PASS.
```bash
git add internal/api/
git commit -m "feat(api): structured provider credentials + trial-auth validation on account create"
```
---
### Task 3: Frontend — форма сервисного пользователя Selectel
**Files:**
- Modify: `web/src/api/types.ts` (CreateAccountInput.secret → object), `web/src/pages/AccountsPage.tsx` (форма 4 поля + zod + инструкция), при необходимости `web/src/api/client.ts` (не должен меняться — шлёт объект как есть)
- Test: `web/src/pages/AccountsPage.test.tsx` (создать/обновить)
**Interfaces:**
- Consumes: `POST /accounts` с `{provider, comment, secret:{username,password,account_id,project_name}}`; 400 при неверных кредах.
- Produces: форма с 4 полями; пароль type=password; ошибка валидации из 400 показывается.
- [ ] **Step 1: types.ts**
```ts
export interface SelectelSecret {
username: string
password: string
account_id: string
project_name: string
}
export interface CreateAccountInput { provider: string; secret: SelectelSecret; comment: string }
```
(`client.ts` `createAccount` уже сериализует `input` целиком — менять не нужно; проверь.)
- [ ] **Step 2: AccountsPage — форма и zod**
Заменить одиночное поле `secret` на 4: `username`, `password` (input type=password), `accountId`, `projectName`. zod-схема:
```ts
const schema = z.object({
provider: z.literal("selectel"),
username: z.string().min(1, "Укажите имя сервисного пользователя"),
password: z.string().min(1, "Укажите пароль"),
accountId: z.string().min(1, "Укажите номер аккаунта"),
projectName: z.string().min(1, "Укажите имя проекта"),
comment: z.string().optional().default(""),
})
```
При сабмите собрать `secret`:
```ts
createAccount({
provider: "selectel",
comment: values.comment ?? "",
secret: {
username: values.username.trim(),
password: values.password,
account_id: values.accountId.trim(),
project_name: values.projectName.trim(),
},
})
```
`defaultValues`/`reset` обновить на новые поля. Каждое поле — свой `FieldLabel`/`FieldError`, `noValidate` на форме (zod валидирует).
- [ ] **Step 3: Инструкция + обработка ошибки 400**
Заменить текст-подсказку: вместо «раздел API-ключи» — создать **сервисного пользователя** в панели Selectel, выдать ему роль на нужный проект, указать логин/пароль/номер аккаунта/имя проекта. Ссылка — на раздел «Пользователи и роли» (`https://my.selectel.ru/iam/users`). Явно: пароль хранится в зашифрованном виде.
Ошибку мутации (400 «invalid provider credentials») показать как алерт с текстом вроде «Selectel отклонил учётные данные — проверьте логин, пароль, номер аккаунта и имя проекта». Использовать существующий паттерн отображения ошибки мутации на странице (посмотри как показаны ошибки в других формах, напр. ChannelsPage).
- [ ] **Step 4: Тест**
`AccountsPage.test.tsx`: (а) сабмит с пустыми полями → показываются zod-ошибки, `createAccount` не вызван; (б) заполнение 4 полей + submit → `createAccount` вызван с `secret:{username,password,account_id,project_name}` и правильным маппингом; (в) пароль-поле имеет `type="password"`. Обернуть в существующие провайдеры (AuthProvider+QueryClient) как в других тестах.
- [ ] **Step 5: Прогон и коммит**
Run: `cd web && npm run test -- --run && npx tsc --noEmit && npm run build`. Ожидание PASS.
```bash
cd .. && git checkout internal/web/dist/index.html
git add web/src/
git commit -m "feat(web): Selectel service-user account form (IAM credentials)"
```
---
## Итоговая проверка
- `go build ./... && go test ./...` — PASS.
- `cd web && npm run test -- --run && npm run build` — PASS.
- Ручная: пересоздать учётку Selectel с логином/паролем/аккаунтом/проектом сервисного пользователя → добавление проходит валидацию → «Импортировать зоны» возвращает список зон без 401.
- SECURITY: пароль не в логах/ответах/ошибках; кэш токенов только в памяти; `accountResponse` без secret.
+8 -3
View File
@@ -1,16 +1,21 @@
package api
import (
"encoding/json"
"github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/store"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
)
// accountRequest.Secret is a provider-specific JSON object (e.g. Selectel's
// service-user credentials) rather than an opaque string — it is passed
// through as raw bytes to Validate/Encrypt, never parsed here.
type accountRequest struct {
Provider string `json:"provider"`
Secret string `json:"secret"`
Comment string `json:"comment"`
Provider string `json:"provider"`
Secret json.RawMessage `json:"secret"`
Comment string `json:"comment"`
}
// accountResponse deliberately excludes the secret (plaintext or encrypted).
+13 -2
View File
@@ -31,11 +31,22 @@ func (a *API) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
if !decodeBody(w, r, &req) {
return
}
if req.Provider == "" || req.Secret == "" {
if req.Provider == "" || len(req.Secret) == 0 {
writeErr(w, http.StatusBadRequest, "provider and secret are required")
return
}
secretEnc, err := a.Cipher.Encrypt([]byte(req.Secret))
p, err := a.Reg.ByName(req.Provider)
if err != nil {
writeErr(w, http.StatusBadRequest, "unknown provider")
return
}
// Trial auth up-front so bad credentials fail at creation, not at import.
// The error text is deliberately generic — never echo the secret back.
if err := p.Validate(r.Context(), provider.Credentials{Secret: string(req.Secret)}); err != nil {
writeErr(w, http.StatusBadRequest, "invalid provider credentials")
return
}
secretEnc, err := a.Cipher.Encrypt(req.Secret)
if err != nil {
log.Printf("api: encrypt secret failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
+45 -7
View File
@@ -140,15 +140,19 @@ func (mockCipher) Decrypt(enc string) ([]byte, error) {
}
type mockRegistry struct {
zones []provider.Zone
zones []provider.Zone
validateErr error
}
func (r *mockRegistry) ByName(name string) (provider.Provider, error) {
return &mockProvider{zones: r.zones}, nil
return &mockProvider{zones: r.zones, validateErr: r.validateErr}, nil
}
type mockProvider struct {
zones []provider.Zone
// validateErr, when set, makes Validate reject the credentials — lets
// tests exercise the 400-before-save path of handleCreateAccount.
validateErr error
}
func (mockProvider) Name() string { return "mock" }
@@ -161,6 +165,9 @@ func (mockProvider) GetRecords(context.Context, provider.Credentials, string) ([
func (mockProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error {
return nil
}
func (p mockProvider) Validate(context.Context, provider.Credentials) error {
return p.validateErr
}
// newTenantTestAPI wires a fixed authenticated user who owns whatever
// project id is requested (alwaysOwnedAuthStore/alwaysValidSessions, see
@@ -178,11 +185,16 @@ func newTenantTestAPI() (*API, *mockTenantStore) {
// --- accounts ---
func TestCreateAccount_SecretEncryptedAndNotInResponse(t *testing.T) {
// TestCreateAccount_ValidCredentials_EncryptsRawSecretAndCreates covers the
// happy path of the structured-secret contract: secret is a provider-specific
// JSON object, Validate accepts it, and the *raw* JSON (not a re-serialized
// or unwrapped form) is what gets encrypted and handed to the store.
func TestCreateAccount_ValidCredentials_EncryptsRawSecretAndCreates(t *testing.T) {
a, ts := newTenantTestAPI()
a.Reg = &mockRegistry{}
router := NewRouter(a)
body := `{"provider":"selectel","secret":"super-secret-token","comment":"prod"}`
body := `{"provider":"selectel","secret":{"username":"u","password":"super-secret-token","account_id":"123","project_name":"proj"},"comment":"prod"}`
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -197,11 +209,12 @@ func TestCreateAccount_SecretEncryptedAndNotInResponse(t *testing.T) {
t.Fatalf("expected 1 CreateAccount call, got %d", len(ts.createAccounts))
}
got := ts.createAccounts[0].secretEnc
if got == "super-secret-token" {
if got == `{"username":"u","password":"super-secret-token","account_id":"123","project_name":"proj"}` {
t.Fatalf("store received plaintext secret instead of encrypted value")
}
if got != "ENC(super-secret-token)" {
t.Fatalf("unexpected encrypted secret stored: %q", got)
wantEnc := `ENC({"username":"u","password":"super-secret-token","account_id":"123","project_name":"proj"})`
if got != wantEnc {
t.Fatalf("unexpected encrypted secret stored: %q, want %q", got, wantEnc)
}
var resp accountResponse
@@ -213,6 +226,31 @@ func TestCreateAccount_SecretEncryptedAndNotInResponse(t *testing.T) {
}
}
// TestCreateAccount_InvalidCredentials_Returns400BeforeSave covers the
// trial-auth gate: when the provider rejects the credentials, the handler
// must reject with 400 and a generic message, and must never reach
// Store.CreateAccount (so no bad account is persisted).
func TestCreateAccount_InvalidCredentials_Returns400BeforeSave(t *testing.T) {
a, ts := newTenantTestAPI()
a.Reg = &mockRegistry{validateErr: errors.New("identity: invalid password")}
router := NewRouter(a)
body := `{"provider":"selectel","secret":{"username":"u","password":"wrong","account_id":"123","project_name":"proj"},"comment":"prod"}`
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts", strings.NewReader(body))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body %s", w.Code, w.Body.String())
}
if len(ts.createAccounts) != 0 {
t.Fatalf("expected CreateAccount not to be called, got %d calls", len(ts.createAccounts))
}
if strings.Contains(w.Body.String(), "wrong") || strings.Contains(w.Body.String(), "invalid password") {
t.Fatalf("response leaks credential/provider error detail: %s", w.Body.String())
}
}
func TestListAccounts_NoSecretsInResponse(t *testing.T) {
a, ts := newTenantTestAPI()
ts.accounts = []store.Account{
+6 -2
View File
@@ -7,8 +7,9 @@ import (
"github.com/vasyakrg/dns-autoresolver/internal/model"
)
// Credentials holds the secret used to authenticate against a provider.
// For Selectel this is the project-scoped token sent as X-Auth-Token.
// Credentials holds the provider-specific secret. It is stored encrypted and,
// once decrypted, is a provider-defined value — for Selectel a JSON blob with
// the service-user credentials (see selectel.Creds).
type Credentials struct {
Secret string
}
@@ -25,4 +26,7 @@ type Provider interface {
ListZones(ctx context.Context, creds Credentials) ([]Zone, error)
GetRecords(ctx context.Context, creds Credentials, zoneID string) ([]model.Record, error)
ApplyChanges(ctx context.Context, creds Credentials, zoneID string, cs diff.Changeset) error
// Validate checks the credentials are usable (e.g. a trial auth), so a
// bad account is rejected at creation time rather than at first import.
Validate(ctx context.Context, creds Credentials) error
}
+1
View File
@@ -21,6 +21,7 @@ func (stubProvider) GetRecords(context.Context, Credentials, string) ([]model.Re
func (stubProvider) ApplyChanges(context.Context, Credentials, string, diff.Changeset) error {
return nil
}
func (stubProvider) Validate(context.Context, Credentials) error { return nil }
func TestProviderInterfaceSatisfied(t *testing.T) {
var p Provider = stubProvider{}
@@ -21,6 +21,7 @@ func (fakeProvider) GetRecords(context.Context, provider.Credentials, string) ([
func (fakeProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error {
return nil
}
func (fakeProvider) Validate(context.Context, provider.Credentials) error { return nil }
func TestRegistryByName(t *testing.T) {
r := New()
+187 -11
View File
@@ -3,11 +3,14 @@ package selectel
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
"github.com/vasyakrg/dns-autoresolver/internal/diff"
@@ -15,20 +18,169 @@ import (
"github.com/vasyakrg/dns-autoresolver/internal/provider"
)
const DefaultBaseURL = "https://api.selectel.ru/domains/v2"
const (
DefaultBaseURL = "https://api.selectel.ru/domains/v2"
DefaultIdentityURL = "https://cloud.api.selcloud.ru/identity/v3/auth/tokens"
tokenLeeway = 5 * time.Minute
)
// Client implements provider.Provider for Selectel DNS API v2.
// Client implements provider.Provider for Selectel DNS API v2. Credentials
// are a service-user login (see Creds); the client exchanges them for a
// project-scoped IAM token via the Identity API and caches it in memory.
type Client struct {
BaseURL string
HTTP *http.Client
BaseURL string
IdentityURL string
HTTP *http.Client
mu sync.Mutex
tokens map[string]cachedToken
}
// cachedToken is an in-memory IAM token with its expiry, keyed by cacheKey.
type cachedToken struct {
token string
expires time.Time
}
// Creds is the Selectel service-user credential set, stored as the encrypted
// JSON blob in provider_accounts.secret_enc and parsed from Credentials.Secret.
type Creds struct {
Username string `json:"username"`
Password string `json:"password"`
AccountID string `json:"account_id"`
ProjectName string `json:"project_name"`
}
func parseCreds(secret string) (Creds, error) {
var c Creds
if err := json.Unmarshal([]byte(secret), &c); err != nil {
return Creds{}, fmt.Errorf("selectel: invalid credentials format")
}
if c.Username == "" || c.Password == "" || c.AccountID == "" || c.ProjectName == "" {
return Creds{}, fmt.Errorf("selectel: username, password, account_id and project_name are required")
}
return c, nil
}
func New() *Client {
return &Client{BaseURL: DefaultBaseURL, HTTP: &http.Client{Timeout: 30 * time.Second}}
return &Client{
BaseURL: DefaultBaseURL,
IdentityURL: DefaultIdentityURL,
HTTP: &http.Client{Timeout: 30 * time.Second},
tokens: map[string]cachedToken{},
}
}
func (c *Client) Name() string { return "selectel" }
// cacheKey identifies a service-user identity for the in-memory token cache.
// The password is folded in via hash (never in the clear) so that a password
// change invalidates any previously cached token for the same account/user.
func cacheKey(c Creds) string {
sum := sha256.Sum256([]byte(c.AccountID + "\x00" + c.ProjectName + "\x00" + c.Username + "\x00" + c.Password))
return hex.EncodeToString(sum[:])
}
// authenticate exchanges the service-user credentials for a project-scoped
// IAM token via the Identity API (Keystone v3). SECURITY: the request body
// contains the password — errors must never wrap or echo it back.
func (c *Client) authenticate(ctx context.Context, cr Creds) (string, time.Time, error) {
body := map[string]any{
"auth": map[string]any{
"identity": map[string]any{
"methods": []string{"password"},
"password": map[string]any{
"user": map[string]any{
"name": cr.Username,
"domain": map[string]any{"name": cr.AccountID},
"password": cr.Password,
},
},
},
"scope": map[string]any{
"project": map[string]any{
"name": cr.ProjectName,
"domain": map[string]any{"name": cr.AccountID},
},
},
},
}
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.IdentityURL, bytes.NewReader(b))
if err != nil {
return "", time.Time{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
// Never wrap the error with request/body (contains password).
return "", time.Time{}, fmt.Errorf("selectel: identity request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
// Drain the body so the underlying connection can be reused by the
// transport's keep-alive pool; the error itself stays generic.
io.Copy(io.Discard, resp.Body)
return "", time.Time{}, fmt.Errorf("selectel: identity auth failed: %d", resp.StatusCode)
}
tok := resp.Header.Get("X-Subject-Token")
if tok == "" {
return "", time.Time{}, fmt.Errorf("selectel: identity returned no token")
}
var out struct {
Token struct {
ExpiresAt string `json:"expires_at"`
} `json:"token"`
}
// Body may be absent/short — a decode error is non-fatal; fall back to a
// conservative 1h TTL so the token is still usable and refreshed sooner.
exp := time.Time{}
if err := json.NewDecoder(resp.Body).Decode(&out); err == nil && out.Token.ExpiresAt != "" {
if t, perr := time.Parse(time.RFC3339, out.Token.ExpiresAt); perr == nil {
exp = t
}
}
return tok, exp, nil
}
// token returns a cached, still-valid IAM token for cr, re-authenticating
// when there is no cached entry or it is within tokenLeeway of expiring.
func (c *Client) token(ctx context.Context, cr Creds) (string, error) {
key := cacheKey(cr)
c.mu.Lock()
if ct, ok := c.tokens[key]; ok && (ct.expires.IsZero() || time.Now().Add(tokenLeeway).Before(ct.expires)) {
c.mu.Unlock()
return ct.token, nil
}
c.mu.Unlock()
tok, exp, err := c.authenticate(ctx, cr)
if err != nil {
return "", err
}
// Zero expiry (unparseable) → treat as short-lived: cache for 1h from now.
if exp.IsZero() {
exp = time.Now().Add(time.Hour)
}
c.mu.Lock()
c.tokens[key] = cachedToken{token: tok, expires: exp}
c.mu.Unlock()
return tok, nil
}
// Validate performs a trial login so bad credentials are rejected at account
// creation time rather than at first import.
func (c *Client) Validate(ctx context.Context, creds provider.Credentials) error {
cr, err := parseCreds(creds.Secret)
if err != nil {
return err
}
if _, err := c.token(ctx, cr); err != nil {
return err
}
return nil
}
// --- wire types ---
type apiZone struct {
@@ -92,12 +244,20 @@ func (c *Client) do(ctx context.Context, method, path, token string, body any, o
// --- Provider implementation ---
func (c *Client) ListZones(ctx context.Context, creds provider.Credentials) ([]provider.Zone, error) {
cr, err := parseCreds(creds.Secret)
if err != nil {
return nil, err
}
tok, err := c.token(ctx, cr)
if err != nil {
return nil, err
}
var zones []provider.Zone
offset := 0
for {
var page apiZoneList
path := fmt.Sprintf("/zones?limit=1000&offset=%d", offset)
if err := c.do(ctx, http.MethodGet, path, creds.Secret, nil, &page); err != nil {
if err := c.do(ctx, http.MethodGet, path, tok, nil, &page); err != nil {
return nil, err
}
for _, z := range page.Result {
@@ -116,7 +276,15 @@ func (c *Client) ListZones(ctx context.Context, creds provider.Credentials) ([]p
}
func (c *Client) GetRecords(ctx context.Context, creds provider.Credentials, zoneID string) ([]model.Record, error) {
rrsets, err := c.listRRSets(ctx, creds.Secret, zoneID)
cr, err := parseCreds(creds.Secret)
if err != nil {
return nil, err
}
tok, err := c.token(ctx, cr)
if err != nil {
return nil, err
}
rrsets, err := c.listRRSets(ctx, tok, zoneID)
if err != nil {
return nil, err
}
@@ -150,8 +318,16 @@ func (c *Client) listRRSets(ctx context.Context, token, zoneID string) ([]apiRRS
}
func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, zoneID string, cs diff.Changeset) error {
cr, err := parseCreds(creds.Secret)
if err != nil {
return err
}
tok, err := c.token(ctx, cr)
if err != nil {
return err
}
// resolve rrset ids for update/delete
existing, err := c.listRRSets(ctx, creds.Secret, zoneID)
existing, err := c.listRRSets(ctx, tok, zoneID)
if err != nil {
return err
}
@@ -170,7 +346,7 @@ func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, z
if d.Desired == nil {
return fmt.Errorf("selectel: add/update diff without Desired record")
}
if err := c.do(ctx, http.MethodPost, base, creds.Secret, toRRSet(*d.Desired), nil); err != nil {
if err := c.do(ctx, http.MethodPost, base, tok, toRRSet(*d.Desired), nil); err != nil {
return err
}
case diff.Update:
@@ -181,7 +357,7 @@ func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, z
if !ok {
return fmt.Errorf("cannot update: rrset %s not found in zone", d.Desired.Key())
}
if err := c.do(ctx, http.MethodPatch, base+"/"+url.PathEscape(id), creds.Secret, toRRSet(*d.Desired), nil); err != nil {
if err := c.do(ctx, http.MethodPatch, base+"/"+url.PathEscape(id), tok, toRRSet(*d.Desired), nil); err != nil {
return err
}
case diff.Delete:
@@ -192,7 +368,7 @@ func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, z
if !ok {
return fmt.Errorf("cannot delete: rrset %s not found in zone", d.Actual.Key())
}
if err := c.do(ctx, http.MethodDelete, base+"/"+url.PathEscape(id), creds.Secret, nil, nil); err != nil {
if err := c.do(ctx, http.MethodDelete, base+"/"+url.PathEscape(id), tok, nil, nil); err != nil {
return err
}
}
+209 -45
View File
@@ -14,16 +14,172 @@ import (
"github.com/vasyakrg/dns-autoresolver/internal/provider"
)
func creds() provider.Credentials { return provider.Credentials{Secret: "secret-token"} }
// testIdentityToken is the IAM token handed out by identityOKHandler. Tests
// assert on this value (not the raw secret) to prove the client sends the
// token obtained from the Identity API, never the service-user password.
const testIdentityToken = "tok-1"
func newTestClient(h http.Handler) (*Client, *httptest.Server) {
srv := httptest.NewServer(h)
return &Client{BaseURL: srv.URL, HTTP: srv.Client()}, srv
// testCreds returns valid Selectel service-user credentials (the decrypted
// JSON blob provider.Credentials.Secret carries for this provider).
func testCreds() provider.Credentials {
return provider.Credentials{Secret: `{"username":"u","password":"p","account_id":"123","project_name":"proj"}`}
}
// identityOKHandler emulates a healthy Identity API: 201 Created with the
// token in the X-Subject-Token header and expires_at far in the future.
func identityOKHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Subject-Token", testIdentityToken)
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{
"token": map[string]any{"expires_at": time.Now().Add(24 * time.Hour).Format(time.RFC3339)},
})
}
// newTestClient wires a single httptest.Server that routes the Identity
// endpoint to identityHandler and everything else (the DNS v2 API) to
// v2Handler, and returns a Client pointed at it.
func newTestClient(identityHandler, v2Handler http.HandlerFunc) (*Client, *httptest.Server) {
mux := http.NewServeMux()
mux.HandleFunc("/identity/v3/auth/tokens", identityHandler)
mux.HandleFunc("/", v2Handler)
srv := httptest.NewServer(mux)
c := &Client{
BaseURL: srv.URL,
IdentityURL: srv.URL + "/identity/v3/auth/tokens",
HTTP: srv.Client(),
tokens: map[string]cachedToken{},
}
return c, srv
}
// --- Step 1: Validate performs a trial login against Identity ---
func TestValidateSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/identity/v3/auth/tokens" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
identityOKHandler(w, r)
}))
defer srv.Close()
c := &Client{IdentityURL: srv.URL + "/identity/v3/auth/tokens", HTTP: srv.Client(), tokens: map[string]cachedToken{}}
if err := c.Validate(context.Background(), testCreds()); err != nil {
t.Fatalf("Validate: unexpected error: %v", err)
}
}
func TestValidateIdentityUnauthorizedReturnsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
c := &Client{IdentityURL: srv.URL + "/identity/v3/auth/tokens", HTTP: srv.Client(), tokens: map[string]cachedToken{}}
if err := c.Validate(context.Background(), testCreds()); err == nil {
t.Fatal("expected error when identity rejects credentials with 401")
}
}
func TestValidateInvalidCredentialsFormatReturnsErrorWithoutCallingIdentity(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
c := &Client{IdentityURL: srv.URL + "/identity/v3/auth/tokens", HTTP: srv.Client(), tokens: map[string]cachedToken{}}
err := c.Validate(context.Background(), provider.Credentials{Secret: `{"username":"u"}`})
if err == nil {
t.Fatal("expected error for incomplete credentials")
}
if called {
t.Fatal("identity must not be called for malformed/incomplete credentials")
}
}
// --- Step 6: token cache reuse / refresh, and ListZones sends the IAM token ---
func TestTokenCachedAcrossCalls(t *testing.T) {
var identityHits int
c, srv := newTestClient(
func(w http.ResponseWriter, r *http.Request) {
identityHits++
identityOKHandler(w, r)
},
func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) },
)
defer srv.Close()
cr, err := parseCreds(testCreds().Secret)
if err != nil {
t.Fatal(err)
}
if _, err := c.token(context.Background(), cr); err != nil {
t.Fatal(err)
}
if _, err := c.token(context.Background(), cr); err != nil {
t.Fatal(err)
}
if identityHits != 1 {
t.Fatalf("expected a single Identity call thanks to caching, got %d", identityHits)
}
}
func TestTokenRefreshesWhenExpiring(t *testing.T) {
var identityHits int
c, srv := newTestClient(
func(w http.ResponseWriter, r *http.Request) {
identityHits++
w.Header().Set("X-Subject-Token", testIdentityToken)
w.WriteHeader(http.StatusCreated)
// expires_at already inside the leeway window -> every token() call
// must re-authenticate instead of serving a near-expiry cache hit.
_ = json.NewEncoder(w).Encode(map[string]any{
"token": map[string]any{"expires_at": time.Now().Add(-time.Minute).Format(time.RFC3339)},
})
},
func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) },
)
defer srv.Close()
cr, err := parseCreds(testCreds().Secret)
if err != nil {
t.Fatal(err)
}
if _, err := c.token(context.Background(), cr); err != nil {
t.Fatal(err)
}
if _, err := c.token(context.Background(), cr); err != nil {
t.Fatal(err)
}
if identityHits != 2 {
t.Fatalf("expected re-authentication on every call while token is within leeway of expiry, got %d hits", identityHits)
}
}
func TestListZonesSendsIAMTokenNotRawSecret(t *testing.T) {
var gotToken string
c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) {
gotToken = r.Header.Get("X-Auth-Token")
_ = json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0})
})
defer srv.Close()
if _, err := c.ListZones(context.Background(), testCreds()); err != nil {
t.Fatal(err)
}
if gotToken != testIdentityToken {
t.Fatalf("expected IAM token %q sent as X-Auth-Token, got %q", testIdentityToken, gotToken)
}
}
// --- existing behavior, now exercised through the Identity + v2 flow ---
func TestListZonesSendsTokenAndParses(t *testing.T) {
var gotToken string
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) {
gotToken = r.Header.Get("X-Auth-Token")
json.NewEncoder(w).Encode(map[string]any{
"result": []map[string]any{
@@ -32,14 +188,14 @@ func TestListZonesSendsTokenAndParses(t *testing.T) {
},
"next_offset": 0,
})
}))
})
defer srv.Close()
zs, err := c.ListZones(context.Background(), creds())
zs, err := c.ListZones(context.Background(), testCreds())
if err != nil {
t.Fatal(err)
}
if gotToken != "secret-token" {
if gotToken != testIdentityToken {
t.Fatalf("token not sent, got %q", gotToken)
}
if len(zs) != 2 || zs[0].ID != "z1" || zs[1].Name != "test.org." {
@@ -48,7 +204,7 @@ func TestListZonesSendsTokenAndParses(t *testing.T) {
}
func TestGetRecordsMapsRRSet(t *testing.T) {
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{
"result": []map[string]any{
{"id": "r1", "name": "example.com.", "type": "MX", "ttl": 3600,
@@ -58,10 +214,10 @@ func TestGetRecordsMapsRRSet(t *testing.T) {
},
"next_offset": 0,
})
}))
})
defer srv.Close()
recs, err := c.GetRecords(context.Background(), creds(), "z1")
recs, err := c.GetRecords(context.Background(), testCreds(), "z1")
if err != nil {
t.Fatal(err)
}
@@ -83,7 +239,7 @@ func TestGetRecordsMapsRRSet(t *testing.T) {
func TestApplyChangesRoutesVerbs(t *testing.T) {
type call struct{ method, path string }
var calls []call
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) {
// GET rrset -> return existing set with ids for update/delete resolution
if r.Method == http.MethodGet {
json.NewEncoder(w).Encode(map[string]any{
@@ -99,7 +255,7 @@ func TestApplyChangesRoutesVerbs(t *testing.T) {
}
calls = append(calls, call{r.Method, r.URL.Path})
w.WriteHeader(http.StatusOK)
}))
})
defer srv.Close()
add := model.Record{Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}}
@@ -114,7 +270,7 @@ func TestApplyChangesRoutesVerbs(t *testing.T) {
{Kind: diff.Update, Type: ns.Type, Name: ns.Name, Desired: &ns, ReadOnly: true}, // must be skipped
}}
if err := c.ApplyChanges(context.Background(), creds(), "z1", cs); err != nil {
if err := c.ApplyChanges(context.Background(), testCreds(), "z1", cs); err != nil {
t.Fatal(err)
}
@@ -136,7 +292,7 @@ func TestApplyChangesRoutesVerbs(t *testing.T) {
// Global Constraint: id not found for Update -> error, and mutation must not proceed.
func TestApplyChangesUpdateIDNotFoundReturnsErrorAndSkipsMutation(t *testing.T) {
var calls []string
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
// empty existing rrset set -> nothing resolves to an id
json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0})
@@ -144,7 +300,7 @@ func TestApplyChangesUpdateIDNotFoundReturnsErrorAndSkipsMutation(t *testing.T)
}
calls = append(calls, r.Method+" "+r.URL.Path)
w.WriteHeader(http.StatusOK)
}))
})
defer srv.Close()
missing := model.Record{Type: model.A, Name: "missing.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}
@@ -155,7 +311,7 @@ func TestApplyChangesUpdateIDNotFoundReturnsErrorAndSkipsMutation(t *testing.T)
{Kind: diff.Add, Type: add.Type, Name: add.Name, Desired: &add},
}}
err := c.ApplyChanges(context.Background(), creds(), "z1", cs)
err := c.ApplyChanges(context.Background(), testCreds(), "z1", cs)
if err == nil {
t.Fatal("expected non-nil error when update rrset id is not found")
}
@@ -166,13 +322,13 @@ func TestApplyChangesUpdateIDNotFoundReturnsErrorAndSkipsMutation(t *testing.T)
// Global Constraint: id not found for Delete -> error.
func TestApplyChangesDeleteIDNotFoundReturnsError(t *testing.T) {
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0})
return
}
t.Fatalf("unexpected mutating call %s %s, delete should have errored before reaching HTTP", r.Method, r.URL.Path)
}))
})
defer srv.Close()
missing := model.Record{Type: model.A, Name: "missing.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}
@@ -180,15 +336,16 @@ func TestApplyChangesDeleteIDNotFoundReturnsError(t *testing.T) {
{Kind: diff.Delete, Type: missing.Type, Name: missing.Name, Actual: &missing},
}}
if err := c.ApplyChanges(context.Background(), creds(), "z1", cs); err == nil {
if err := c.ApplyChanges(context.Background(), testCreds(), "z1", cs); err == nil {
t.Fatal("expected non-nil error when delete rrset id is not found")
}
}
// Global Constraint: X-Auth-Token must be sent on mutating requests (POST/PATCH/DELETE), not only on GET.
// Global Constraint: X-Auth-Token (the IAM token, not the raw secret) must be
// sent on mutating requests (POST/PATCH/DELETE), not only on GET.
func TestApplyChangesSendsTokenOnMutations(t *testing.T) {
var tokens []string
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
json.NewEncoder(w).Encode(map[string]any{
"result": []map[string]any{
@@ -203,7 +360,7 @@ func TestApplyChangesSendsTokenOnMutations(t *testing.T) {
}
tokens = append(tokens, r.Header.Get("X-Auth-Token"))
w.WriteHeader(http.StatusOK)
}))
})
defer srv.Close()
add := model.Record{Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}}
@@ -216,15 +373,15 @@ func TestApplyChangesSendsTokenOnMutations(t *testing.T) {
{Kind: diff.Delete, Type: delActual.Type, Name: delActual.Name, Actual: &delActual},
}}
if err := c.ApplyChanges(context.Background(), creds(), "z1", cs); err != nil {
if err := c.ApplyChanges(context.Background(), testCreds(), "z1", cs); err != nil {
t.Fatal(err)
}
if len(tokens) != 3 {
t.Fatalf("expected 3 mutating requests (POST/PATCH/DELETE), got %d", len(tokens))
}
for _, tok := range tokens {
if tok != "secret-token" {
t.Fatalf("expected X-Auth-Token %q on mutation, got %q", "secret-token", tok)
if tok != testIdentityToken {
t.Fatalf("expected X-Auth-Token %q on mutation, got %q", testIdentityToken, tok)
}
}
}
@@ -232,7 +389,7 @@ func TestApplyChangesSendsTokenOnMutations(t *testing.T) {
// Global Constraint: multi-page pagination must accumulate records across pages without an infinite loop.
func TestListZonesPaginatesAcrossMultiplePages(t *testing.T) {
var offsets []string
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) {
offset := r.URL.Query().Get("offset")
offsets = append(offsets, offset)
if len(offsets) > 2 {
@@ -252,10 +409,10 @@ func TestListZonesPaginatesAcrossMultiplePages(t *testing.T) {
default:
t.Fatalf("unexpected offset %q", offset)
}
}))
})
defer srv.Close()
zs, err := c.ListZones(context.Background(), creds())
zs, err := c.ListZones(context.Background(), testCreds())
if err != nil {
t.Fatal(err)
}
@@ -272,7 +429,7 @@ func TestListZonesPaginatesAcrossMultiplePages(t *testing.T) {
// no third request should ever be issued.
func TestGetRecordsPaginatesAcrossMultiplePages(t *testing.T) {
var offsets []string
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) {
offset := r.URL.Query().Get("offset")
offsets = append(offsets, offset)
if len(offsets) > 2 {
@@ -298,10 +455,10 @@ func TestGetRecordsPaginatesAcrossMultiplePages(t *testing.T) {
default:
t.Fatalf("unexpected offset %q", offset)
}
}))
})
defer srv.Close()
recs, err := c.GetRecords(context.Background(), creds(), "z1")
recs, err := c.GetRecords(context.Background(), testCreds(), "z1")
if err != nil {
t.Fatal(err)
}
@@ -320,13 +477,13 @@ func TestGetRecordsPaginatesAcrossMultiplePages(t *testing.T) {
// Global Constraint: ApplyChanges must not panic on a Changeset with a nil Desired
// record for Add/Update, and must instead return a clear error.
func TestApplyChangesAddWithNilDesiredReturnsErrorNoPanic(t *testing.T) {
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0})
return
}
t.Fatalf("unexpected mutating call %s %s, nil Desired should have errored before reaching HTTP", r.Method, r.URL.Path)
}))
})
defer srv.Close()
cs := diff.Changeset{Diffs: []diff.RecordDiff{
@@ -339,24 +496,31 @@ func TestApplyChangesAddWithNilDesiredReturnsErrorNoPanic(t *testing.T) {
}
}()
err := c.ApplyChanges(context.Background(), creds(), "z1", cs)
err := c.ApplyChanges(context.Background(), testCreds(), "z1", cs)
if err == nil {
t.Fatal("expected non-nil error for Add diff with nil Desired")
}
}
// Global Constraint: New() must configure the default base URL and a 30s HTTP timeout.
// Global Constraint: New() must configure the default base URL, identity URL,
// a 30s HTTP timeout, and an initialized token cache.
func TestNewDefaults(t *testing.T) {
c := New()
if c.BaseURL != DefaultBaseURL {
t.Fatalf("BaseURL = %q, want %q", c.BaseURL, DefaultBaseURL)
}
if c.IdentityURL != DefaultIdentityURL {
t.Fatalf("IdentityURL = %q, want %q", c.IdentityURL, DefaultIdentityURL)
}
if c.HTTP == nil {
t.Fatal("HTTP client must not be nil")
}
if c.HTTP.Timeout != 30*time.Second {
t.Fatalf("HTTP.Timeout = %v, want %v", c.HTTP.Timeout, 30*time.Second)
}
if c.tokens == nil {
t.Fatal("token cache map must be initialized")
}
}
// Global Constraint: if the API returns a NextOffset that does not advance past the
@@ -364,7 +528,7 @@ func TestNewDefaults(t *testing.T) {
// stop pagination instead of looping forever on the same/earlier offset.
func TestListZonesStopsOnNonAdvancingNextOffset(t *testing.T) {
var requests int
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) {
requests++
if requests > 5 {
t.Fatalf("too many requests, possible infinite pagination loop on stuck offset")
@@ -386,10 +550,10 @@ func TestListZonesStopsOnNonAdvancingNextOffset(t *testing.T) {
default:
t.Fatalf("unexpected offset %q", offset)
}
}))
})
defer srv.Close()
zs, err := c.ListZones(context.Background(), creds())
zs, err := c.ListZones(context.Background(), testCreds())
if err != nil {
t.Fatal(err)
}
@@ -405,7 +569,7 @@ func TestListZonesStopsOnNonAdvancingNextOffset(t *testing.T) {
// a non-advancing NextOffset on a non-empty page must stop pagination, not loop forever.
func TestGetRecordsStopsOnNonAdvancingNextOffset(t *testing.T) {
var requests int
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) {
requests++
if requests > 5 {
t.Fatalf("too many requests, possible infinite pagination loop on stuck offset")
@@ -433,10 +597,10 @@ func TestGetRecordsStopsOnNonAdvancingNextOffset(t *testing.T) {
default:
t.Fatalf("unexpected offset %q", offset)
}
}))
})
defer srv.Close()
recs, err := c.GetRecords(context.Background(), creds(), "z1")
recs, err := c.GetRecords(context.Background(), testCreds(), "z1")
if err != nil {
t.Fatal(err)
}
@@ -451,13 +615,13 @@ func TestGetRecordsStopsOnNonAdvancingNextOffset(t *testing.T) {
// Global Constraint: HTTP errors (status >= 300) must surface a non-nil error whose text
// includes the method/path/status (or response body) for diagnosability.
func TestListZonesHTTPErrorIncludesMethodPathStatus(t *testing.T) {
c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, srv := newTestClient(identityOKHandler, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("zone not found"))
}))
})
defer srv.Close()
_, err := c.ListZones(context.Background(), creds())
_, err := c.ListZones(context.Background(), testCreds())
if err == nil {
t.Fatal("expected non-nil error on non-2xx response")
}
+1
View File
@@ -41,6 +41,7 @@ func (f *fakeProvider) ApplyChanges(_ context.Context, _ provider.Credentials, _
f.applied = cs
return nil
}
func (fakeProvider) Validate(context.Context, provider.Credentials) error { return nil }
type fakeLoader struct{ ref DomainRef }
+5 -1
View File
@@ -79,7 +79,11 @@ describe("api client", () => {
it("sends secret on account creation but path has no secret leakage in response typing", async () => {
const spy = mockFetch({ id: "a2", provider: "selectel", comment: "prod" })
await api.createAccount(PROJECT_ID, { provider: "selectel", secret: "TOKEN", comment: "prod" })
await api.createAccount(PROJECT_ID, {
provider: "selectel",
secret: { username: "svc-user", password: "TOKEN", account_id: "123456", project_name: "default" },
comment: "prod",
})
const [, opts] = spy.mock.calls[0]
expect((opts as RequestInit).method).toBe("POST")
expect(String((opts as RequestInit).body)).toContain("TOKEN")
+7 -1
View File
@@ -3,7 +3,13 @@ export interface Project { id: string; name: string }
export interface AuthState { user: User; project: Project }
export interface Account { id: string; provider: string; comment: string }
export interface CreateAccountInput { provider: string; secret: string; comment: string }
export interface SelectelSecret {
username: string
password: string
account_id: string
project_name: string
}
export interface CreateAccountInput { provider: string; secret: SelectelSecret; comment: string }
export interface RecordDTO { type: string; name: string; ttl: number; values: string[] }
export interface Template { id: string; name: string; records: RecordDTO[]; version: number }
+61 -13
View File
@@ -44,7 +44,43 @@ test("отрисовывает список учёток без секрета",
expect(screen.getAllByText("selectel").length).toBeGreaterThan(0)
})
test("форма создания вызывает api.createAccount с введённым secret и не показывает его после", async () => {
test("сабмит с пустыми полями показывает zod-ошибки и не вызывает createAccount", async () => {
const createSpy = vi.spyOn(api, "createAccount")
const user = userEvent.setup()
renderPage()
await screen.findByText("Main")
await user.click(screen.getByRole("button", { name: /добавить учётку/i }))
expect(await screen.findByText("Укажите имя сервисного пользователя")).toBeInTheDocument()
expect(screen.getByText("Укажите пароль")).toBeInTheDocument()
expect(screen.getByText("Укажите номер аккаунта")).toBeInTheDocument()
expect(screen.getByText("Укажите имя проекта")).toBeInTheDocument()
expect(createSpy).not.toHaveBeenCalled()
})
test("сабмит с пробельным вводом в обязательных полях показывает zod-ошибки и не вызывает createAccount", async () => {
const createSpy = vi.spyOn(api, "createAccount")
const user = userEvent.setup()
renderPage()
await screen.findByText("Main")
await user.type(screen.getByLabelText(/имя сервисного пользователя/i), " ")
await user.type(screen.getByLabelText(/пароль/i), "some-password")
await user.type(screen.getByLabelText(/номер аккаунта/i), " ")
await user.type(screen.getByLabelText(/имя проекта/i), " ")
await user.click(screen.getByRole("button", { name: /добавить учётку/i }))
expect(await screen.findByText("Укажите имя сервисного пользователя")).toBeInTheDocument()
expect(screen.getByText("Укажите номер аккаунта")).toBeInTheDocument()
expect(screen.getByText("Укажите имя проекта")).toBeInTheDocument()
expect(createSpy).not.toHaveBeenCalled()
})
test("заполнение 4 полей и сабмит вызывает createAccount с secret в snake_case и не показывает пароль после", async () => {
const createSpy = vi.spyOn(api, "createAccount").mockResolvedValue({
id: "acc3",
provider: "selectel",
@@ -55,10 +91,13 @@ test("форма создания вызывает api.createAccount с введ
await screen.findByText("Main")
const secretInput = screen.getByLabelText(/api-ключ/i)
expect(secretInput).toHaveAttribute("type", "password")
const passwordInput = screen.getByLabelText(/пароль/i)
expect(passwordInput).toHaveAttribute("type", "password")
await user.type(secretInput, "super-secret-token-123")
await user.type(screen.getByLabelText(/имя сервисного пользователя/i), "svc-user")
await user.type(passwordInput, "super-secret-token-123")
await user.type(screen.getByLabelText(/номер аккаунта/i), "123456")
await user.type(screen.getByLabelText(/имя проекта/i), "default")
await user.type(screen.getByLabelText(/комментарий/i), "New account")
await user.click(screen.getByRole("button", { name: /добавить учётку/i }))
@@ -66,36 +105,45 @@ test("форма создания вызывает api.createAccount с введ
await waitFor(() =>
expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, {
provider: "selectel",
secret: "super-secret-token-123",
comment: "New account",
secret: {
username: "svc-user",
password: "super-secret-token-123",
account_id: "123456",
project_name: "default",
},
}),
)
await waitFor(() => expect(secretInput).toHaveValue(""))
await waitFor(() => expect(passwordInput).toHaveValue(""))
expect(screen.queryByText("super-secret-token-123")).not.toBeInTheDocument()
expect(screen.queryByDisplayValue("super-secret-token-123")).not.toBeInTheDocument()
})
test("содержит ссылку-инструкцию получения ключа Selectel", async () => {
test("содержит ссылку-инструкцию на раздел пользователей и ролей Selectel", async () => {
renderPage()
await screen.findByText("Main")
const link = screen.getByRole("link", { name: /selectel/i })
expect(link).toHaveAttribute("href", expect.stringContaining("selectel.ru"))
expect(link).toHaveAttribute("href", expect.stringContaining("selectel.ru/iam/users"))
})
test("ошибка создания учётки отображается пользователю", async () => {
vi.spyOn(api, "createAccount").mockRejectedValue(new Error("Не удалось создать учётку"))
test("ошибка 400 invalid provider credentials отображается понятным текстом", async () => {
vi.spyOn(api, "createAccount").mockRejectedValue(new Error("invalid provider credentials"))
const user = userEvent.setup()
renderPage()
await screen.findByText("Main")
await user.type(screen.getByLabelText(/api-ключ/i), "token-xyz")
await user.type(screen.getByLabelText(/комментарий/i), "Test")
await user.type(screen.getByLabelText(/имя сервисного пользователя/i), "svc-user")
await user.type(screen.getByLabelText(/пароль/i), "wrong")
await user.type(screen.getByLabelText(/номер аккаунта/i), "123456")
await user.type(screen.getByLabelText(/имя проекта/i), "default")
await user.click(screen.getByRole("button", { name: /добавить учётку/i }))
expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось создать учётку")
expect(await screen.findByRole("alert")).toHaveTextContent(
"Selectel отклонил учётные данные — проверьте логин, пароль, номер аккаунта и имя проекта.",
)
})
test("удаление учётки вызывает api.deleteAccount", async () => {
+117 -22
View File
@@ -26,17 +26,41 @@ import { useAccounts, useCreateAccount, useDeleteAccount } from "@/hooks/useApi"
const createAccountSchema = z.object({
provider: z.literal("selectel"),
secret: z.string().min(1, "Укажите API-ключ"),
comment: z.string().min(1, "Укажите комментарий"),
username: z.string().trim().min(1, "Укажите имя сервисного пользователя"),
password: z.string().min(1, "Укажите пароль"),
accountId: z.string().trim().min(1, "Укажите номер аккаунта"),
projectName: z.string().trim().min(1, "Укажите имя проекта"),
comment: z.string(),
})
type CreateAccountForm = z.infer<typeof createAccountSchema>
// API возвращает generic "invalid provider credentials" (нарочно, чтобы не
// палить детали) — переводим в понятную пользователю формулировку.
function createAccountErrorMessage(message: string): string {
if (message.toLowerCase().includes("invalid provider credentials")) {
return "Selectel отклонил учётные данные — проверьте логин, пароль, номер аккаунта и имя проекта."
}
return message
}
const EMPTY_FORM: CreateAccountForm = {
provider: "selectel",
username: "",
password: "",
accountId: "",
projectName: "",
comment: "",
}
export function AccountsPage() {
const accounts = useAccounts()
const createAccount = useCreateAccount()
const deleteAccount = useDeleteAccount()
const secretFieldId = useId()
const usernameFieldId = useId()
const passwordFieldId = useId()
const accountIdFieldId = useId()
const projectNameFieldId = useId()
const commentFieldId = useId()
const accountList = accounts.data ?? []
@@ -48,13 +72,23 @@ export function AccountsPage() {
formState: { errors },
} = useForm<CreateAccountForm>({
resolver: zodResolver(createAccountSchema),
defaultValues: { provider: "selectel", secret: "", comment: "" },
defaultValues: EMPTY_FORM,
})
function onSubmit(values: CreateAccountForm) {
createAccount.mutate(values, {
onSuccess: () => reset({ provider: "selectel", secret: "", comment: "" }),
})
createAccount.mutate(
{
provider: "selectel",
comment: values.comment,
secret: {
username: values.username.trim(),
password: values.password,
account_id: values.accountId.trim(),
project_name: values.projectName.trim(),
},
},
{ onSuccess: () => reset(EMPTY_FORM) },
)
}
function onDelete(id: string, comment: string) {
@@ -74,28 +108,88 @@ export function AccountsPage() {
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-4"
>
<FieldSet className="gap-3">
<FieldGroup className="gap-3 sm:flex-row sm:items-start">
<Field className="sm:max-w-56">
<FieldLabel htmlFor={secretFieldId}>API-ключ Selectel</FieldLabel>
<FieldGroup className="flex-row flex-wrap items-start gap-3">
<Field className="w-56">
<FieldLabel htmlFor={usernameFieldId}>Имя сервисного пользователя</FieldLabel>
<FieldContent>
<Controller
control={control}
name="secret"
name="username"
render={({ field }) => (
<Input
{...field}
id={secretFieldId}
type="password"
id={usernameFieldId}
autoComplete="off"
placeholder="••••••••••••"
aria-invalid={!!errors.secret}
placeholder="service-dns"
aria-invalid={!!errors.username}
/>
)}
/>
<FieldError errors={[errors.secret]} />
<FieldError errors={[errors.username]} />
</FieldContent>
</Field>
<Field className="w-56">
<FieldLabel htmlFor={passwordFieldId}>Пароль</FieldLabel>
<FieldContent>
<Controller
control={control}
name="password"
render={({ field }) => (
<Input
{...field}
id={passwordFieldId}
type="password"
autoComplete="off"
placeholder="••••••••••••"
aria-invalid={!!errors.password}
/>
)}
/>
<FieldError errors={[errors.password]} />
</FieldContent>
</Field>
<Field className="w-48">
<FieldLabel htmlFor={accountIdFieldId}>Номер аккаунта</FieldLabel>
<FieldContent>
<Controller
control={control}
name="accountId"
render={({ field }) => (
<Input
{...field}
id={accountIdFieldId}
className="font-dns"
placeholder="123456"
aria-invalid={!!errors.accountId}
/>
)}
/>
<FieldError errors={[errors.accountId]} />
</FieldContent>
</Field>
<Field className="w-48">
<FieldLabel htmlFor={projectNameFieldId}>Имя проекта</FieldLabel>
<FieldContent>
<Controller
control={control}
name="projectName"
render={({ field }) => (
<Input
{...field}
id={projectNameFieldId}
placeholder="default"
aria-invalid={!!errors.projectName}
/>
)}
/>
<FieldError errors={[errors.projectName]} />
</FieldContent>
</Field>
@@ -122,16 +216,17 @@ export function AccountsPage() {
<FieldDescription className="flex items-start gap-2 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5">
<KeyRound className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" strokeWidth={1.75} />
<span>
Провайдер: <span className="font-dns text-foreground">selectel</span>. Ключ создаётся
в панели управления Selectel раздел «API-ключи» и используется только для
запроса, храниться в открытом виде он не будет.{" "}
Провайдер: <span className="font-dns text-foreground">selectel</span>. Создайте
сервисного пользователя в панели управления Selectel, выдайте ему роль на нужный
проект и укажите здесь его логин, пароль, номер аккаунта и имя проекта. Пароль
хранится в зашифрованном виде.{" "}
<a
href="https://my.selectel.ru/profile/apikeys"
href="https://my.selectel.ru/iam/users"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1"
>
Получить ключ на my.selectel.ru
Пользователи и роли на my.selectel.ru
<ExternalLink className="size-3" strokeWidth={1.75} />
</a>
</span>
@@ -141,7 +236,7 @@ export function AccountsPage() {
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
{createAccount.isError && (
<span role="alert" className="font-dns text-xs text-destructive">
{createAccount.error.message}
{createAccountErrorMessage(createAccount.error.message)}
</span>
)}
<Button type="submit" disabled={createAccount.isPending} className="ml-auto">