docs: plan for Selectel IAM auth (Cloud DNS v2)
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.
|
||||||
Reference in New Issue
Block a user