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:
@@ -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.
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/store"
|
"github.com/vasyakrg/dns-autoresolver/internal/store"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
"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 {
|
type accountRequest struct {
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
Secret string `json:"secret"`
|
Secret json.RawMessage `json:"secret"`
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,11 +31,22 @@ func (a *API) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !decodeBody(w, r, &req) {
|
if !decodeBody(w, r, &req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if req.Provider == "" || req.Secret == "" {
|
if req.Provider == "" || len(req.Secret) == 0 {
|
||||||
writeErr(w, http.StatusBadRequest, "provider and secret are required")
|
writeErr(w, http.StatusBadRequest, "provider and secret are required")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
log.Printf("api: encrypt secret failed: %v", err)
|
log.Printf("api: encrypt secret failed: %v", err)
|
||||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
|
|||||||
@@ -141,14 +141,18 @@ func (mockCipher) Decrypt(enc string) ([]byte, error) {
|
|||||||
|
|
||||||
type mockRegistry struct {
|
type mockRegistry struct {
|
||||||
zones []provider.Zone
|
zones []provider.Zone
|
||||||
|
validateErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *mockRegistry) ByName(name string) (provider.Provider, 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 {
|
type mockProvider struct {
|
||||||
zones []provider.Zone
|
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" }
|
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 {
|
func (mockProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (p mockProvider) Validate(context.Context, provider.Credentials) error {
|
||||||
|
return p.validateErr
|
||||||
|
}
|
||||||
|
|
||||||
// newTenantTestAPI wires a fixed authenticated user who owns whatever
|
// newTenantTestAPI wires a fixed authenticated user who owns whatever
|
||||||
// project id is requested (alwaysOwnedAuthStore/alwaysValidSessions, see
|
// project id is requested (alwaysOwnedAuthStore/alwaysValidSessions, see
|
||||||
@@ -178,11 +185,16 @@ func newTenantTestAPI() (*API, *mockTenantStore) {
|
|||||||
|
|
||||||
// --- accounts ---
|
// --- 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, ts := newTenantTestAPI()
|
||||||
|
a.Reg = &mockRegistry{}
|
||||||
router := NewRouter(a)
|
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))
|
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts", strings.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
router.ServeHTTP(w, req)
|
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))
|
t.Fatalf("expected 1 CreateAccount call, got %d", len(ts.createAccounts))
|
||||||
}
|
}
|
||||||
got := ts.createAccounts[0].secretEnc
|
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")
|
t.Fatalf("store received plaintext secret instead of encrypted value")
|
||||||
}
|
}
|
||||||
if got != "ENC(super-secret-token)" {
|
wantEnc := `ENC({"username":"u","password":"super-secret-token","account_id":"123","project_name":"proj"})`
|
||||||
t.Fatalf("unexpected encrypted secret stored: %q", got)
|
if got != wantEnc {
|
||||||
|
t.Fatalf("unexpected encrypted secret stored: %q, want %q", got, wantEnc)
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp accountResponse
|
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) {
|
func TestListAccounts_NoSecretsInResponse(t *testing.T) {
|
||||||
a, ts := newTenantTestAPI()
|
a, ts := newTenantTestAPI()
|
||||||
ts.accounts = []store.Account{
|
ts.accounts = []store.Account{
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import (
|
|||||||
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Credentials holds the secret used to authenticate against a provider.
|
// Credentials holds the provider-specific secret. It is stored encrypted and,
|
||||||
// For Selectel this is the project-scoped token sent as X-Auth-Token.
|
// once decrypted, is a provider-defined value — for Selectel a JSON blob with
|
||||||
|
// the service-user credentials (see selectel.Creds).
|
||||||
type Credentials struct {
|
type Credentials struct {
|
||||||
Secret string
|
Secret string
|
||||||
}
|
}
|
||||||
@@ -25,4 +26,7 @@ type Provider interface {
|
|||||||
ListZones(ctx context.Context, creds Credentials) ([]Zone, error)
|
ListZones(ctx context.Context, creds Credentials) ([]Zone, error)
|
||||||
GetRecords(ctx context.Context, creds Credentials, zoneID string) ([]model.Record, error)
|
GetRecords(ctx context.Context, creds Credentials, zoneID string) ([]model.Record, error)
|
||||||
ApplyChanges(ctx context.Context, creds Credentials, zoneID string, cs diff.Changeset) 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ func (stubProvider) GetRecords(context.Context, Credentials, string) ([]model.Re
|
|||||||
func (stubProvider) ApplyChanges(context.Context, Credentials, string, diff.Changeset) error {
|
func (stubProvider) ApplyChanges(context.Context, Credentials, string, diff.Changeset) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (stubProvider) Validate(context.Context, Credentials) error { return nil }
|
||||||
|
|
||||||
func TestProviderInterfaceSatisfied(t *testing.T) {
|
func TestProviderInterfaceSatisfied(t *testing.T) {
|
||||||
var p Provider = stubProvider{}
|
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 {
|
func (fakeProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (fakeProvider) Validate(context.Context, provider.Credentials) error { return nil }
|
||||||
|
|
||||||
func TestRegistryByName(t *testing.T) {
|
func TestRegistryByName(t *testing.T) {
|
||||||
r := New()
|
r := New()
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ package selectel
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
||||||
@@ -15,20 +18,169 @@ import (
|
|||||||
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
"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 {
|
type Client struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
IdentityURL string
|
||||||
HTTP *http.Client
|
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 {
|
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" }
|
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 ---
|
// --- wire types ---
|
||||||
|
|
||||||
type apiZone struct {
|
type apiZone struct {
|
||||||
@@ -92,12 +244,20 @@ func (c *Client) do(ctx context.Context, method, path, token string, body any, o
|
|||||||
// --- Provider implementation ---
|
// --- Provider implementation ---
|
||||||
|
|
||||||
func (c *Client) ListZones(ctx context.Context, creds provider.Credentials) ([]provider.Zone, error) {
|
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
|
var zones []provider.Zone
|
||||||
offset := 0
|
offset := 0
|
||||||
for {
|
for {
|
||||||
var page apiZoneList
|
var page apiZoneList
|
||||||
path := fmt.Sprintf("/zones?limit=1000&offset=%d", offset)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, z := range page.Result {
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
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
|
// resolve rrset ids for update/delete
|
||||||
existing, err := c.listRRSets(ctx, creds.Secret, zoneID)
|
existing, err := c.listRRSets(ctx, tok, zoneID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -170,7 +346,7 @@ func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, z
|
|||||||
if d.Desired == nil {
|
if d.Desired == nil {
|
||||||
return fmt.Errorf("selectel: add/update diff without Desired record")
|
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
|
return err
|
||||||
}
|
}
|
||||||
case diff.Update:
|
case diff.Update:
|
||||||
@@ -181,7 +357,7 @@ func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, z
|
|||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("cannot update: rrset %s not found in zone", d.Desired.Key())
|
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
|
return err
|
||||||
}
|
}
|
||||||
case diff.Delete:
|
case diff.Delete:
|
||||||
@@ -192,7 +368,7 @@ func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, z
|
|||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("cannot delete: rrset %s not found in zone", d.Actual.Key())
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,16 +14,172 @@ import (
|
|||||||
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
"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) {
|
// testCreds returns valid Selectel service-user credentials (the decrypted
|
||||||
srv := httptest.NewServer(h)
|
// JSON blob provider.Credentials.Secret carries for this provider).
|
||||||
return &Client{BaseURL: srv.URL, HTTP: srv.Client()}, srv
|
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) {
|
func TestListZonesSendsTokenAndParses(t *testing.T) {
|
||||||
var gotToken string
|
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")
|
gotToken = r.Header.Get("X-Auth-Token")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
"result": []map[string]any{
|
"result": []map[string]any{
|
||||||
@@ -32,14 +188,14 @@ func TestListZonesSendsTokenAndParses(t *testing.T) {
|
|||||||
},
|
},
|
||||||
"next_offset": 0,
|
"next_offset": 0,
|
||||||
})
|
})
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
zs, err := c.ListZones(context.Background(), creds())
|
zs, err := c.ListZones(context.Background(), testCreds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if gotToken != "secret-token" {
|
if gotToken != testIdentityToken {
|
||||||
t.Fatalf("token not sent, got %q", gotToken)
|
t.Fatalf("token not sent, got %q", gotToken)
|
||||||
}
|
}
|
||||||
if len(zs) != 2 || zs[0].ID != "z1" || zs[1].Name != "test.org." {
|
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) {
|
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{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
"result": []map[string]any{
|
"result": []map[string]any{
|
||||||
{"id": "r1", "name": "example.com.", "type": "MX", "ttl": 3600,
|
{"id": "r1", "name": "example.com.", "type": "MX", "ttl": 3600,
|
||||||
@@ -58,10 +214,10 @@ func TestGetRecordsMapsRRSet(t *testing.T) {
|
|||||||
},
|
},
|
||||||
"next_offset": 0,
|
"next_offset": 0,
|
||||||
})
|
})
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
recs, err := c.GetRecords(context.Background(), creds(), "z1")
|
recs, err := c.GetRecords(context.Background(), testCreds(), "z1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -83,7 +239,7 @@ func TestGetRecordsMapsRRSet(t *testing.T) {
|
|||||||
func TestApplyChangesRoutesVerbs(t *testing.T) {
|
func TestApplyChangesRoutesVerbs(t *testing.T) {
|
||||||
type call struct{ method, path string }
|
type call struct{ method, path string }
|
||||||
var calls []call
|
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
|
// GET rrset -> return existing set with ids for update/delete resolution
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
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})
|
calls = append(calls, call{r.Method, r.URL.Path})
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
add := model.Record{Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}}
|
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
|
{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)
|
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.
|
// Global Constraint: id not found for Update -> error, and mutation must not proceed.
|
||||||
func TestApplyChangesUpdateIDNotFoundReturnsErrorAndSkipsMutation(t *testing.T) {
|
func TestApplyChangesUpdateIDNotFoundReturnsErrorAndSkipsMutation(t *testing.T) {
|
||||||
var calls []string
|
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 {
|
if r.Method == http.MethodGet {
|
||||||
// empty existing rrset set -> nothing resolves to an id
|
// empty existing rrset set -> nothing resolves to an id
|
||||||
json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0})
|
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)
|
calls = append(calls, r.Method+" "+r.URL.Path)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
missing := model.Record{Type: model.A, Name: "missing.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}
|
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},
|
{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 {
|
if err == nil {
|
||||||
t.Fatal("expected non-nil error when update rrset id is not found")
|
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.
|
// Global Constraint: id not found for Delete -> error.
|
||||||
func TestApplyChangesDeleteIDNotFoundReturnsError(t *testing.T) {
|
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 {
|
if r.Method == http.MethodGet {
|
||||||
json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0})
|
json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.Fatalf("unexpected mutating call %s %s, delete should have errored before reaching HTTP", r.Method, r.URL.Path)
|
t.Fatalf("unexpected mutating call %s %s, delete should have errored before reaching HTTP", r.Method, r.URL.Path)
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
missing := model.Record{Type: model.A, Name: "missing.example.com.", TTL: 300, Values: []string{"1.1.1.1"}}
|
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},
|
{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")
|
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) {
|
func TestApplyChangesSendsTokenOnMutations(t *testing.T) {
|
||||||
var tokens []string
|
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 {
|
if r.Method == http.MethodGet {
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
"result": []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"))
|
tokens = append(tokens, r.Header.Get("X-Auth-Token"))
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
add := model.Record{Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}}
|
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},
|
{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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(tokens) != 3 {
|
if len(tokens) != 3 {
|
||||||
t.Fatalf("expected 3 mutating requests (POST/PATCH/DELETE), got %d", len(tokens))
|
t.Fatalf("expected 3 mutating requests (POST/PATCH/DELETE), got %d", len(tokens))
|
||||||
}
|
}
|
||||||
for _, tok := range tokens {
|
for _, tok := range tokens {
|
||||||
if tok != "secret-token" {
|
if tok != testIdentityToken {
|
||||||
t.Fatalf("expected X-Auth-Token %q on mutation, got %q", "secret-token", tok)
|
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.
|
// Global Constraint: multi-page pagination must accumulate records across pages without an infinite loop.
|
||||||
func TestListZonesPaginatesAcrossMultiplePages(t *testing.T) {
|
func TestListZonesPaginatesAcrossMultiplePages(t *testing.T) {
|
||||||
var offsets []string
|
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")
|
offset := r.URL.Query().Get("offset")
|
||||||
offsets = append(offsets, offset)
|
offsets = append(offsets, offset)
|
||||||
if len(offsets) > 2 {
|
if len(offsets) > 2 {
|
||||||
@@ -252,10 +409,10 @@ func TestListZonesPaginatesAcrossMultiplePages(t *testing.T) {
|
|||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected offset %q", offset)
|
t.Fatalf("unexpected offset %q", offset)
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
zs, err := c.ListZones(context.Background(), creds())
|
zs, err := c.ListZones(context.Background(), testCreds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -272,7 +429,7 @@ func TestListZonesPaginatesAcrossMultiplePages(t *testing.T) {
|
|||||||
// no third request should ever be issued.
|
// no third request should ever be issued.
|
||||||
func TestGetRecordsPaginatesAcrossMultiplePages(t *testing.T) {
|
func TestGetRecordsPaginatesAcrossMultiplePages(t *testing.T) {
|
||||||
var offsets []string
|
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")
|
offset := r.URL.Query().Get("offset")
|
||||||
offsets = append(offsets, offset)
|
offsets = append(offsets, offset)
|
||||||
if len(offsets) > 2 {
|
if len(offsets) > 2 {
|
||||||
@@ -298,10 +455,10 @@ func TestGetRecordsPaginatesAcrossMultiplePages(t *testing.T) {
|
|||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected offset %q", offset)
|
t.Fatalf("unexpected offset %q", offset)
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
recs, err := c.GetRecords(context.Background(), creds(), "z1")
|
recs, err := c.GetRecords(context.Background(), testCreds(), "z1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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
|
// Global Constraint: ApplyChanges must not panic on a Changeset with a nil Desired
|
||||||
// record for Add/Update, and must instead return a clear error.
|
// record for Add/Update, and must instead return a clear error.
|
||||||
func TestApplyChangesAddWithNilDesiredReturnsErrorNoPanic(t *testing.T) {
|
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 {
|
if r.Method == http.MethodGet {
|
||||||
json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0})
|
json.NewEncoder(w).Encode(map[string]any{"result": []map[string]any{}, "next_offset": 0})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.Fatalf("unexpected mutating call %s %s, nil Desired should have errored before reaching HTTP", r.Method, r.URL.Path)
|
t.Fatalf("unexpected mutating call %s %s, nil Desired should have errored before reaching HTTP", r.Method, r.URL.Path)
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
cs := diff.Changeset{Diffs: []diff.RecordDiff{
|
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 {
|
if err == nil {
|
||||||
t.Fatal("expected non-nil error for Add diff with nil Desired")
|
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) {
|
func TestNewDefaults(t *testing.T) {
|
||||||
c := New()
|
c := New()
|
||||||
if c.BaseURL != DefaultBaseURL {
|
if c.BaseURL != DefaultBaseURL {
|
||||||
t.Fatalf("BaseURL = %q, want %q", 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 {
|
if c.HTTP == nil {
|
||||||
t.Fatal("HTTP client must not be nil")
|
t.Fatal("HTTP client must not be nil")
|
||||||
}
|
}
|
||||||
if c.HTTP.Timeout != 30*time.Second {
|
if c.HTTP.Timeout != 30*time.Second {
|
||||||
t.Fatalf("HTTP.Timeout = %v, want %v", 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
|
// 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.
|
// stop pagination instead of looping forever on the same/earlier offset.
|
||||||
func TestListZonesStopsOnNonAdvancingNextOffset(t *testing.T) {
|
func TestListZonesStopsOnNonAdvancingNextOffset(t *testing.T) {
|
||||||
var requests int
|
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++
|
requests++
|
||||||
if requests > 5 {
|
if requests > 5 {
|
||||||
t.Fatalf("too many requests, possible infinite pagination loop on stuck offset")
|
t.Fatalf("too many requests, possible infinite pagination loop on stuck offset")
|
||||||
@@ -386,10 +550,10 @@ func TestListZonesStopsOnNonAdvancingNextOffset(t *testing.T) {
|
|||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected offset %q", offset)
|
t.Fatalf("unexpected offset %q", offset)
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
zs, err := c.ListZones(context.Background(), creds())
|
zs, err := c.ListZones(context.Background(), testCreds())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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.
|
// a non-advancing NextOffset on a non-empty page must stop pagination, not loop forever.
|
||||||
func TestGetRecordsStopsOnNonAdvancingNextOffset(t *testing.T) {
|
func TestGetRecordsStopsOnNonAdvancingNextOffset(t *testing.T) {
|
||||||
var requests int
|
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++
|
requests++
|
||||||
if requests > 5 {
|
if requests > 5 {
|
||||||
t.Fatalf("too many requests, possible infinite pagination loop on stuck offset")
|
t.Fatalf("too many requests, possible infinite pagination loop on stuck offset")
|
||||||
@@ -433,10 +597,10 @@ func TestGetRecordsStopsOnNonAdvancingNextOffset(t *testing.T) {
|
|||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected offset %q", offset)
|
t.Fatalf("unexpected offset %q", offset)
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
recs, err := c.GetRecords(context.Background(), creds(), "z1")
|
recs, err := c.GetRecords(context.Background(), testCreds(), "z1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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
|
// Global Constraint: HTTP errors (status >= 300) must surface a non-nil error whose text
|
||||||
// includes the method/path/status (or response body) for diagnosability.
|
// includes the method/path/status (or response body) for diagnosability.
|
||||||
func TestListZonesHTTPErrorIncludesMethodPathStatus(t *testing.T) {
|
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.WriteHeader(http.StatusNotFound)
|
||||||
w.Write([]byte("zone not found"))
|
w.Write([]byte("zone not found"))
|
||||||
}))
|
})
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
_, err := c.ListZones(context.Background(), creds())
|
_, err := c.ListZones(context.Background(), testCreds())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected non-nil error on non-2xx response")
|
t.Fatal("expected non-nil error on non-2xx response")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func (f *fakeProvider) ApplyChanges(_ context.Context, _ provider.Credentials, _
|
|||||||
f.applied = cs
|
f.applied = cs
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (fakeProvider) Validate(context.Context, provider.Credentials) error { return nil }
|
||||||
|
|
||||||
type fakeLoader struct{ ref DomainRef }
|
type fakeLoader struct{ ref DomainRef }
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,11 @@ describe("api client", () => {
|
|||||||
|
|
||||||
it("sends secret on account creation but path has no secret leakage in response typing", async () => {
|
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" })
|
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]
|
const [, opts] = spy.mock.calls[0]
|
||||||
expect((opts as RequestInit).method).toBe("POST")
|
expect((opts as RequestInit).method).toBe("POST")
|
||||||
expect(String((opts as RequestInit).body)).toContain("TOKEN")
|
expect(String((opts as RequestInit).body)).toContain("TOKEN")
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ export interface Project { id: string; name: string }
|
|||||||
export interface AuthState { user: User; project: Project }
|
export interface AuthState { user: User; project: Project }
|
||||||
|
|
||||||
export interface Account { id: string; provider: string; comment: string }
|
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 RecordDTO { type: string; name: string; ttl: number; values: string[] }
|
||||||
export interface Template { id: string; name: string; records: RecordDTO[]; version: number }
|
export interface Template { id: string; name: string; records: RecordDTO[]; version: number }
|
||||||
|
|||||||
@@ -44,7 +44,43 @@ test("отрисовывает список учёток без секрета",
|
|||||||
expect(screen.getAllByText("selectel").length).toBeGreaterThan(0)
|
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({
|
const createSpy = vi.spyOn(api, "createAccount").mockResolvedValue({
|
||||||
id: "acc3",
|
id: "acc3",
|
||||||
provider: "selectel",
|
provider: "selectel",
|
||||||
@@ -55,10 +91,13 @@ test("форма создания вызывает api.createAccount с введ
|
|||||||
|
|
||||||
await screen.findByText("Main")
|
await screen.findByText("Main")
|
||||||
|
|
||||||
const secretInput = screen.getByLabelText(/api-ключ/i)
|
const passwordInput = screen.getByLabelText(/пароль/i)
|
||||||
expect(secretInput).toHaveAttribute("type", "password")
|
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.type(screen.getByLabelText(/комментарий/i), "New account")
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /добавить учётку/i }))
|
await user.click(screen.getByRole("button", { name: /добавить учётку/i }))
|
||||||
@@ -66,36 +105,45 @@ test("форма создания вызывает api.createAccount с введ
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, {
|
expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, {
|
||||||
provider: "selectel",
|
provider: "selectel",
|
||||||
secret: "super-secret-token-123",
|
|
||||||
comment: "New account",
|
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.queryByText("super-secret-token-123")).not.toBeInTheDocument()
|
||||||
expect(screen.queryByDisplayValue("super-secret-token-123")).not.toBeInTheDocument()
|
expect(screen.queryByDisplayValue("super-secret-token-123")).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("содержит ссылку-инструкцию получения ключа Selectel", async () => {
|
test("содержит ссылку-инструкцию на раздел пользователей и ролей Selectel", async () => {
|
||||||
renderPage()
|
renderPage()
|
||||||
await screen.findByText("Main")
|
await screen.findByText("Main")
|
||||||
|
|
||||||
const link = screen.getByRole("link", { name: /selectel/i })
|
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 () => {
|
test("ошибка 400 invalid provider credentials отображается понятным текстом", async () => {
|
||||||
vi.spyOn(api, "createAccount").mockRejectedValue(new Error("Не удалось создать учётку"))
|
vi.spyOn(api, "createAccount").mockRejectedValue(new Error("invalid provider credentials"))
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
renderPage()
|
||||||
|
|
||||||
await screen.findByText("Main")
|
await screen.findByText("Main")
|
||||||
|
|
||||||
await user.type(screen.getByLabelText(/api-ключ/i), "token-xyz")
|
await user.type(screen.getByLabelText(/имя сервисного пользователя/i), "svc-user")
|
||||||
await user.type(screen.getByLabelText(/комментарий/i), "Test")
|
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 }))
|
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 () => {
|
test("удаление учётки вызывает api.deleteAccount", async () => {
|
||||||
|
|||||||
+117
-22
@@ -26,17 +26,41 @@ import { useAccounts, useCreateAccount, useDeleteAccount } from "@/hooks/useApi"
|
|||||||
|
|
||||||
const createAccountSchema = z.object({
|
const createAccountSchema = z.object({
|
||||||
provider: z.literal("selectel"),
|
provider: z.literal("selectel"),
|
||||||
secret: z.string().min(1, "Укажите API-ключ"),
|
username: z.string().trim().min(1, "Укажите имя сервисного пользователя"),
|
||||||
comment: z.string().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>
|
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() {
|
export function AccountsPage() {
|
||||||
const accounts = useAccounts()
|
const accounts = useAccounts()
|
||||||
const createAccount = useCreateAccount()
|
const createAccount = useCreateAccount()
|
||||||
const deleteAccount = useDeleteAccount()
|
const deleteAccount = useDeleteAccount()
|
||||||
const secretFieldId = useId()
|
const usernameFieldId = useId()
|
||||||
|
const passwordFieldId = useId()
|
||||||
|
const accountIdFieldId = useId()
|
||||||
|
const projectNameFieldId = useId()
|
||||||
const commentFieldId = useId()
|
const commentFieldId = useId()
|
||||||
|
|
||||||
const accountList = accounts.data ?? []
|
const accountList = accounts.data ?? []
|
||||||
@@ -48,13 +72,23 @@ export function AccountsPage() {
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<CreateAccountForm>({
|
} = useForm<CreateAccountForm>({
|
||||||
resolver: zodResolver(createAccountSchema),
|
resolver: zodResolver(createAccountSchema),
|
||||||
defaultValues: { provider: "selectel", secret: "", comment: "" },
|
defaultValues: EMPTY_FORM,
|
||||||
})
|
})
|
||||||
|
|
||||||
function onSubmit(values: CreateAccountForm) {
|
function onSubmit(values: CreateAccountForm) {
|
||||||
createAccount.mutate(values, {
|
createAccount.mutate(
|
||||||
onSuccess: () => reset({ provider: "selectel", secret: "", comment: "" }),
|
{
|
||||||
})
|
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) {
|
function onDelete(id: string, comment: string) {
|
||||||
@@ -74,28 +108,88 @@ export function AccountsPage() {
|
|||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
noValidate
|
||||||
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-4"
|
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-4"
|
||||||
>
|
>
|
||||||
<FieldSet className="gap-3">
|
<FieldSet className="gap-3">
|
||||||
<FieldGroup className="gap-3 sm:flex-row sm:items-start">
|
<FieldGroup className="flex-row flex-wrap items-start gap-3">
|
||||||
<Field className="sm:max-w-56">
|
<Field className="w-56">
|
||||||
<FieldLabel htmlFor={secretFieldId}>API-ключ Selectel</FieldLabel>
|
<FieldLabel htmlFor={usernameFieldId}>Имя сервисного пользователя</FieldLabel>
|
||||||
<FieldContent>
|
<FieldContent>
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
control={control}
|
||||||
name="secret"
|
name="username"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
id={secretFieldId}
|
id={usernameFieldId}
|
||||||
type="password"
|
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder="••••••••••••"
|
placeholder="service-dns"
|
||||||
aria-invalid={!!errors.secret}
|
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>
|
</FieldContent>
|
||||||
</Field>
|
</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">
|
<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} />
|
<KeyRound className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" strokeWidth={1.75} />
|
||||||
<span>
|
<span>
|
||||||
Провайдер: <span className="font-dns text-foreground">selectel</span>. Ключ создаётся
|
Провайдер: <span className="font-dns text-foreground">selectel</span>. Создайте
|
||||||
в панели управления Selectel — раздел «API-ключи» — и используется только для
|
сервисного пользователя в панели управления Selectel, выдайте ему роль на нужный
|
||||||
запроса, храниться в открытом виде он не будет.{" "}
|
проект и укажите здесь его логин, пароль, номер аккаунта и имя проекта. Пароль
|
||||||
|
хранится в зашифрованном виде.{" "}
|
||||||
<a
|
<a
|
||||||
href="https://my.selectel.ru/profile/apikeys"
|
href="https://my.selectel.ru/iam/users"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-1"
|
className="inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
Получить ключ на my.selectel.ru
|
Пользователи и роли на my.selectel.ru
|
||||||
<ExternalLink className="size-3" strokeWidth={1.75} />
|
<ExternalLink className="size-3" strokeWidth={1.75} />
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
@@ -141,7 +236,7 @@ export function AccountsPage() {
|
|||||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
|
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
|
||||||
{createAccount.isError && (
|
{createAccount.isError && (
|
||||||
<span role="alert" className="font-dns text-xs text-destructive">
|
<span role="alert" className="font-dns text-xs text-destructive">
|
||||||
{createAccount.error.message}
|
{createAccountErrorMessage(createAccount.error.message)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Button type="submit" disabled={createAccount.isPending} className="ml-auto">
|
<Button type="submit" disabled={createAccount.isPending} className="ml-auto">
|
||||||
|
|||||||
Reference in New Issue
Block a user