Merge feature/zone-view-snapshot: просмотр зоны без шаблона + snapshot-шаблон

Просмотр записей зоны без привязанного шаблона (read-only), создание
шаблона-снимка из текущего состояния зоны (managed-only, авто-привязка),
статус «без шаблона» вместо unknown, убрана кнопка удаления домена.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
2026-07-05 12:58:20 +07:00
18 changed files with 913 additions and 58 deletions
@@ -0,0 +1,240 @@
# Просмотр зоны без шаблона + snapshot-шаблон Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
**Goal:** Дать видеть содержимое зоны без привязанного шаблона, создавать шаблон-эталон из текущего состояния зоны одним действием, показывать понятный статус «без шаблона» и убрать пугающую кнопку удаления домена.
**Architecture:** Провайдер (Selectel) — источник фактического состояния зоны. Сейчас весь просмотр завязан на дифф, а `LoadDomain` требует шаблон, поэтому зону без шаблона нельзя ни увидеть, ни проверить (вечный `unknown`). Добавляем read-only чтение записей зоны (без шаблона) и снимок-в-шаблон: читаем текущие записи, фильтруем управляемые (без NS/SOA), создаём шаблон и привязываем к домену.
**Tech Stack:** Go (chi, provider registry), React 19 + Vite + TanStack Query.
## Global Constraints
- Провайдер = источник правды для фактического состояния зоны. Планировщик остаётся read-only (не применяет).
- Snapshot включает только управляемые типы (`model.RecordType.Managed()` = A/AAAA/CNAME/MX/TXT/SRV); NS/SOA исключаются (read-only, не в шаблоне).
- Multi-tenancy/IDOR: все новые endpoint'ы скоупятся по projectID из контекста; загрузка домена/зоны — по (domainID, projectID), как в существующем LoadDomain.
- Кнопку удаления домена убрать только из UI; backend DeleteDomain/роут не трогаем в этом заходе (git-like push/pull — отдельная фаза).
- Комментарии в Go — на английском; в web — как в окружающих файлах.
- TDD. НЕ коммитить реальную сборку `internal/web/dist/*` (перед коммитом `git checkout internal/web/dist/index.html`).
---
### Task 1: Backend — чтение записей зоны без шаблона + snapshot-в-шаблон
**Files:**
- Modify: `internal/store/loader.go` (LoadZone), `internal/service/service.go` (ZoneRef, Loader iface, ZoneRecords), `internal/api/handlers.go` (handleZoneRecords, handleTemplateFromZone), `internal/api/api.go` (роуты + интерфейсы Service/Store), `internal/api/*_dto.go` (если нужен ответ)
- Test: `internal/service/service_test.go`, `internal/api/*_test.go`, `internal/store/*_test.go`
**Interfaces:**
- Produces: `service.ZoneRef{ZoneID,Provider,SecretEnc}`; `Loader.LoadZone(ctx,pid,did) (ZoneRef,error)`; `DomainService.ZoneRecords(ctx,pid,did) ([]model.Record,error)`; `GET /domains/{did}/records`; `POST /domains/{did}/template-from-zone`.
- Consumes: `provider.GetRecords`; `Store.CreateTemplate`, `Store.SetDomainTemplate`, `Store.GetDomain`; `model.RecordType.Managed()`; `dto.FromModel`.
- [ ] **Step 1: Тест — LoadZone возвращает провайдера/секрет/zoneID без требования шаблона**
`internal/store/…` (или через service-тест с фейком): домен без template_id → `LoadZone` возвращает ZoneRef (не ошибку «no template»). Отличие от `LoadDomain`, который на `Doc==nil` возвращает ошибку. Run соответствующий тест — Ожидание FAIL (метода нет).
- [ ] **Step 2: store.LoadZone**
`internal/store/loader.go` — переиспользует тот же `LoadDomainFull` (он LEFT JOIN, Doc может быть nil), но НЕ требует шаблон:
```go
// LoadZone returns just the provider-access half of a domain (provider name,
// encrypted secret, zone id), WITHOUT requiring an attached template — so a
// zone's live records can be read for viewing/snapshot even when no template
// is set. Scoped by projectID (same IDOR closure as LoadDomain).
func (s *Store) LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (service.ZoneRef, error) {
row, err := s.q.LoadDomainFull(ctx, db.LoadDomainFullParams{ID: domainID, ProjectID: projectID})
if err != nil {
return service.ZoneRef{}, err
}
return service.ZoneRef{ZoneID: row.ZoneID, Provider: row.Provider, SecretEnc: row.SecretEnc}, nil
}
```
- [ ] **Step 3: service.ZoneRef + Loader iface + ZoneRecords**
`internal/service/service.go`:
```go
// ZoneRef is the provider-access subset of a domain, without a template —
// enough to read a zone's live records.
type ZoneRef struct {
ZoneID string
Provider string
SecretEnc string
}
```
Расширь интерфейс `Loader`:
```go
LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (ZoneRef, error)
```
Метод:
```go
// ZoneRecords reads a zone's current records straight from the provider,
// with no diff and no template required. Used for read-only zone viewing and
// as the source for a snapshot template.
func (s *DomainService) ZoneRecords(ctx context.Context, projectID, domainID uuid.UUID) ([]model.Record, error) {
ref, err := s.loader.LoadZone(ctx, projectID, domainID)
if err != nil {
return nil, err
}
p, err := s.reg.ByName(ref.Provider)
if err != nil {
return nil, err
}
secret, err := s.cipher.Decrypt(ref.SecretEnc)
if err != nil {
return nil, err
}
return p.GetRecords(ctx, provider.Credentials{Secret: string(secret)}, ref.ZoneID)
}
```
(Импортируй `model`.) Обнови все фейки Loader в тестах (service_test.go, api tenant_test mockLoader если есть) под новый метод LoadZone.
- [ ] **Step 4: Тесты API — records + template-from-zone**
`internal/api/…_test.go`: (а) `GET /domains/{did}/records` → 200 + список записей зоны (мок Service.ZoneRecords); (б) `POST /domains/{did}/template-from-zone` → создаёт шаблон только из управляемых записей (в моке ZoneRecords верни A+TXT+NS+SOA → в CreateTemplate ушли ТОЛЬКО A+TXT), затем SetDomainTemplate вызван с id нового шаблона; 201. Run: Ожидание FAIL.
- [ ] **Step 5: API-хендлеры + роуты**
Определи в `internal/api/api.go` интерфейсе `Service` метод `ZoneRecords(ctx, projectID, domainID uuid.UUID) ([]model.Record, error)` (сверь, как назван service-порт в api — используй существующий паттерн; если API держит `*service.DomainService` напрямую — вызывай метод). Store-интерфейс уже содержит CreateTemplate/SetDomainTemplate/GetDomain (сверь имена/сигнатуры).
`internal/api/handlers.go`:
```go
func (a *API) handleZoneRecords(w http.ResponseWriter, r *http.Request) {
pid, _ := projectIDFrom(r.Context())
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
recs, err := a.Svc.ZoneRecords(r.Context(), pid, did)
if err != nil {
log.Printf("api: zone records failed: %v", err)
writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера")
return
}
doc := dto.FromModel(recs)
writeJSON(w, http.StatusOK, doc.Records) // []dto.RecordDTO
}
func (a *API) handleTemplateFromZone(w http.ResponseWriter, r *http.Request) {
pid, _ := projectIDFrom(r.Context())
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
dom, err := a.Store.GetDomain(r.Context(), did, pid)
if err != nil {
log.Printf("api: template-from-zone: get domain failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
recs, err := a.Svc.ZoneRecords(r.Context(), pid, did)
if err != nil {
log.Printf("api: template-from-zone: zone records failed: %v", err)
writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера")
return
}
// Snapshot only managed records — NS/SOA are read-only and never templated.
managed := make([]model.Record, 0, len(recs))
for _, rc := range recs {
if rc.Type.Managed() {
managed = append(managed, rc)
}
}
doc := dto.FromModel(managed)
tmpl, err := a.Store.CreateTemplate(r.Context(), pid, dom.ZoneName+" snapshot", doc)
if err != nil {
log.Printf("api: template-from-zone: create template failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
if _, err := a.Store.SetDomainTemplate(r.Context(), did, pid, &tmpl.ID); err != nil {
log.Printf("api: template-from-zone: attach template failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, toTemplateResponse(tmpl))
}
```
(Сверь: как API вызывает сервис — поле `a.Svc`/`a.Service`; тип `tmpl.ID`; наличие `toTemplateResponse`. Используй существующие имена. `model` импортируй.)
Роуты в `api.go` внутри `/domains/{did}`:
```go
r.Get("/records", a.handleZoneRecords)
r.Post("/template-from-zone", a.handleTemplateFromZone)
```
- [ ] **Step 6: Прогон и коммит**
Run: `go build ./... && go test ./internal/...`. Ожидание PASS.
```bash
git add internal/
git commit -m "feat(api): read zone records without template + snapshot-to-template"
```
---
### Task 2: Frontend — просмотр зоны без шаблона, snapshot-кнопка, статус, убрать удаление
**Files:**
- Modify: `web/src/api/types.ts`, `web/src/api/client.ts`, `web/src/hooks/useApi.ts`, `web/src/components/StatusBadge.tsx`, `web/src/pages/DomainsPage.tsx`, `web/src/pages/DomainDiffPage.tsx`
- Test: `web/src/pages/DomainsPage.test.tsx`, `web/src/pages/DomainDiffPage.test.tsx`, `web/src/components/StatusBadge.test.tsx`
**Interfaces:**
- Consumes: `GET /domains/{did}/records``RecordDTO[]`; `POST /domains/{did}/template-from-zone``Template`.
- Produces: read-only просмотр зоны при отсутствии шаблона; статус-бейдж «без шаблона»; список доменов без кнопки удаления.
- [ ] **Step 1: types + client + hooks**
`types.ts``RecordDTO` уже есть (сверь: `{type,name,ttl,values}`). Добавь методы клиента (`client.ts`):
```ts
zoneRecords: (projectId: string, id: string) =>
req<RecordDTO[]>(projectPath(projectId, `/domains/${id}/records`)),
templateFromZone: (projectId: string, id: string) =>
req<Template>(projectPath(projectId, `/domains/${id}/template-from-zone`), { method: "POST" }),
```
`useApi.ts` — хуки `useZoneRecords(id)` (useQuery, enabled по id), `useCreateTemplateFromZone()` (useMutation; onSuccess invalidate ключей domains + zoneRecords + текущего домена, чтобы подтянулся templateId и появился дифф). Следуй существующему паттерну project-scoping (projectId из useAuth).
- [ ] **Step 2: StatusBadge += «без шаблона»**
`StatusBadge.tsx`: добавь тип-вариант `no_template` в `STATUS_META`:
```ts
no_template: { label: "без шаблона", color: "var(--diff-readonly)" },
```
и в `resolveStatus` разреши `"no_template"` как валидный; расширь `CheckStatus`. (Это UI-псевдостатус, не backend — приходит только с фронта.)
- [ ] **Step 3: DomainsPage — убрать удаление, показать «без шаблона»**
Убери импорт `Trash2`, `useDeleteDomain`, функцию `onDelete`, блок `deleteDomain.isError`, кнопку с `Trash2`. Столбец «Действия» оставляет только «Diff».
Статус: `<StatusBadge status={d.templateId ? d.lastCheckStatus : "no_template"} />` — домен без привязанного шаблона показывает «без шаблона», а не малопонятный unknown.
- [ ] **Step 4: DomainDiffPage — режим просмотра без шаблона**
Определи наличие шаблона у домена: подтяни список `useDomains()` и найди `domain = list.find(d => d.id === id)` (список уже кэширован после DomainsPage; если пуст — грузится). `hasTemplate = !!domain?.templateId`.
- Если `hasTemplate` → текущий diff-flow (useCheckDomain), без изменений.
- Если НЕ hasTemplate → НЕ вызывать check (убирает висящий «Вычисляю дифф»); показать:
- баннер: «Шаблон не привязан — дифф недоступен. Ниже текущие записи зоны.»
- кнопку «Создать шаблон из этой зоны» (`useCreateTemplateFromZone`), после успеха — домен получит шаблон, инвалидация переключит на diff-режим;
- read-only таблицу записей из `useZoneRecords(id)` (колонки Тип/Имя/TTL/Значение; NS/SOA показываем тоже — это просмотр факта, помечать их read-only необязательно). При ошибке загрузки — понятный текст (не бесконечный лоадер).
Управляй ветвлением так, чтобы `useCheckDomain` не срабатывал без шаблона (enabled: hasTemplate) — иначе вернётся ошибка и повиснет лоадер. Если `useCheckDomain(id)` не принимает enabled — оберни вызов условием/добавь параметр.
- [ ] **Step 5: Прогон и коммит**
Run: `cd web && npm run test -- --run && npx tsc --noEmit && npm run build`. Ожидание PASS.
Тесты: (а) DomainsPage — нет кнопки удаления (Trash), домен без templateId показывает «без шаблона»; (б) StatusBadge — `no_template` рендерит «без шаблона»; (в) DomainDiffPage — домен без templateId рендерит записи зоны (мок useZoneRecords) + кнопку «Создать шаблон из этой зоны», check не вызывается.
```bash
cd .. && git checkout internal/web/dist/index.html
git add web/src/
git commit -m "feat(web): view zone without template, snapshot button, no-template status, drop delete"
```
---
## Итоговая проверка
- `go build ./... && go test ./...` — PASS.
- `cd web && npm run test -- --run && npm run build` — PASS.
- Ручная: домен без шаблона → открывается, видны записи зоны, статус «без шаблона», кнопка «Создать шаблон из зоны» создаёт шаблон (без NS/SOA) и привязывает → страница переключается на дифф; кнопки удаления домена нет.
+10
View File
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/provider" "github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/service" "github.com/vasyakrg/dns-autoresolver/internal/service"
"github.com/vasyakrg/dns-autoresolver/internal/store" "github.com/vasyakrg/dns-autoresolver/internal/store"
@@ -20,6 +21,10 @@ import (
type CheckApplier interface { type CheckApplier interface {
Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error) Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error)
Apply(ctx context.Context, projectID, domainID uuid.UUID, req service.ApplyRequest) (diff.Changeset, error) Apply(ctx context.Context, projectID, domainID uuid.UUID, req service.ApplyRequest) (diff.Changeset, error)
// ZoneRecords reads a zone's current records straight from the provider,
// with no diff and no template required — backs read-only zone viewing
// and the template-from-zone snapshot.
ZoneRecords(ctx context.Context, projectID, domainID uuid.UUID) ([]model.Record, error)
} }
// TenantStore is the narrow persistence surface the CRUD handlers depend on. // TenantStore is the narrow persistence surface the CRUD handlers depend on.
@@ -39,6 +44,9 @@ type TenantStore interface {
CreateDomain(ctx context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (store.Domain, error) CreateDomain(ctx context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (store.Domain, error)
ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error) ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error)
// GetDomain is used by the template-from-zone snapshot to read the
// domain's zone name (for the generated template's name) before creating it.
GetDomain(ctx context.Context, id, projectID uuid.UUID) (store.Domain, error)
DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error
ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error)
SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error) SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error)
@@ -117,6 +125,8 @@ func NewRouter(a *API) http.Handler {
r.Patch("/", a.handleSetDomainTemplate) r.Patch("/", a.handleSetDomainTemplate)
r.Delete("/", a.handleDeleteDomain) r.Delete("/", a.handleDeleteDomain)
r.Get("/history", a.handleDomainHistory) r.Get("/history", a.handleDomainHistory)
r.Get("/records", a.handleZoneRecords)
r.Post("/template-from-zone", a.handleTemplateFromZone)
}) })
}) })
+9 -1
View File
@@ -17,7 +17,9 @@ import (
) )
type mockCheckApplier struct { type mockCheckApplier struct {
lastReq service.ApplyRequest lastReq service.ApplyRequest
zoneRecords []model.Record
zoneErr error
} }
func (m *mockCheckApplier) Check(context.Context, uuid.UUID, uuid.UUID) (diff.Changeset, error) { func (m *mockCheckApplier) Check(context.Context, uuid.UUID, uuid.UUID) (diff.Changeset, error) {
@@ -28,6 +30,12 @@ func (m *mockCheckApplier) Apply(_ context.Context, _, _ uuid.UUID, req service.
m.lastReq = req m.lastReq = req
return diff.Changeset{}, nil return diff.Changeset{}, nil
} }
func (m *mockCheckApplier) ZoneRecords(context.Context, uuid.UUID, uuid.UUID) ([]model.Record, error) {
if m.zoneErr != nil {
return nil, m.zoneErr
}
return m.zoneRecords, nil
}
// newTestAPI wires a fixed authenticated user who owns whatever project id // newTestAPI wires a fixed authenticated user who owns whatever project id
// is requested (via alwaysOwnedAuthStore/alwaysValidSessions in // is requested (via alwaysOwnedAuthStore/alwaysValidSessions in
+85
View File
@@ -10,7 +10,9 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/service" "github.com/vasyakrg/dns-autoresolver/internal/service"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
) )
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -70,3 +72,86 @@ func (a *API) handleApply(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, toChangesetResponse(cs)) writeJSON(w, http.StatusOK, toChangesetResponse(cs))
} }
// handleZoneRecords reads a zone's current records straight from the
// provider — no template required, no diff computed. Backs read-only zone
// viewing for domains that don't have a template attached (yet).
func (a *API) handleZoneRecords(w http.ResponseWriter, r *http.Request) {
pid, _ := projectIDFrom(r.Context())
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
recs, err := a.Svc.ZoneRecords(r.Context(), pid, did)
if err != nil {
log.Printf("api: zone records failed: %v", err)
if errors.Is(err, service.ErrProviderUnavailable) {
writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера")
} else {
writeErr(w, http.StatusNotFound, "домен не найден")
}
return
}
doc := dto.FromModel(recs)
writeJSON(w, http.StatusOK, doc.Records) // []dto.RecordDTO
}
// handleTemplateFromZone snapshots a zone's current managed records (NS/SOA
// excluded — they're read-only, never part of a template) into a brand new
// template, then auto-attaches it to the domain so check/apply become
// available immediately.
func (a *API) handleTemplateFromZone(w http.ResponseWriter, r *http.Request) {
pid, _ := projectIDFrom(r.Context())
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
dom, err := a.Store.GetDomain(r.Context(), did, pid)
if err != nil {
log.Printf("api: template-from-zone: get domain failed: %v", err)
writeErr(w, http.StatusNotFound, "домен не найден")
return
}
// This endpoint only makes sense for a domain with no template attached
// yet — it snapshots the zone's live state into a brand-new template.
// If a template is already bound, re-attaching a fresh snapshot would
// silently orphan the existing one; re-pointing a domain to a different
// template is a separate, explicit action and must not happen as a side
// effect of a retried/duplicate POST here.
if dom.TemplateID != nil {
writeErr(w, http.StatusConflict, "шаблон уже привязан")
return
}
recs, err := a.Svc.ZoneRecords(r.Context(), pid, did)
if err != nil {
log.Printf("api: template-from-zone: zone records failed: %v", err)
if errors.Is(err, service.ErrProviderUnavailable) {
writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера")
} else {
writeErr(w, http.StatusNotFound, "домен не найден")
}
return
}
// Snapshot only managed records — NS/SOA are read-only and never templated.
managed := make([]model.Record, 0, len(recs))
for _, rc := range recs {
if rc.Type.Managed() {
managed = append(managed, rc)
}
}
doc := dto.FromModel(managed)
tmpl, err := a.Store.CreateTemplate(r.Context(), pid, dom.ZoneName+" snapshot", doc)
if err != nil {
log.Printf("api: template-from-zone: create template failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
if _, err := a.Store.SetDomainTemplate(r.Context(), did, pid, &tmpl.ID); err != nil {
log.Printf("api: template-from-zone: attach template failed: %v", err)
writeErr(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, toTemplateResponse(tmpl))
}
+4
View File
@@ -13,6 +13,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/service" "github.com/vasyakrg/dns-autoresolver/internal/service"
"github.com/vasyakrg/dns-autoresolver/internal/store" "github.com/vasyakrg/dns-autoresolver/internal/store"
) )
@@ -97,6 +98,9 @@ func (r *recordingCheckApplier) Apply(context.Context, uuid.UUID, uuid.UUID, ser
r.applyCalled = true r.applyCalled = true
return diff.Changeset{}, nil return diff.Changeset{}, nil
} }
func (r *recordingCheckApplier) ZoneRecords(context.Context, uuid.UUID, uuid.UUID) ([]model.Record, error) {
return nil, nil
}
// --- RequireAuth --- // --- RequireAuth ---
+157
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -14,6 +15,7 @@ import (
"github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model" "github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/provider" "github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/service"
"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"
) )
@@ -98,6 +100,15 @@ func (m *mockTenantStore) ListDomains(context.Context, uuid.UUID) ([]store.Domai
return m.domains, nil return m.domains, nil
} }
func (m *mockTenantStore) GetDomain(_ context.Context, id, _ uuid.UUID) (store.Domain, error) {
for _, d := range m.domains {
if d.ID == id {
return d, nil
}
}
return store.Domain{}, errors.New("domain not found")
}
func (m *mockTenantStore) DeleteDomain(context.Context, uuid.UUID, uuid.UUID) error { return nil } func (m *mockTenantStore) DeleteDomain(context.Context, uuid.UUID, uuid.UUID) error { return nil }
func (m *mockTenantStore) SetDomainTemplate(_ context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error) { func (m *mockTenantStore) SetDomainTemplate(_ context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error) {
@@ -599,3 +610,149 @@ func TestDeleteDomain_BadUUID(t *testing.T) {
t.Fatalf("expected 400, got %d", w.Code) t.Fatalf("expected 400, got %d", w.Code)
} }
} }
// --- zone view / template-from-zone (Task 1: no-template zone snapshot) ---
// TestZoneRecords_ReturnsProviderRecords covers the read-only zone-viewing
// endpoint: it returns whatever the service reads straight from the
// provider, with no template involved at all.
func TestZoneRecords_ReturnsProviderRecords(t *testing.T) {
a, ts := newTenantTestAPI()
domID := uuid.New()
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
a.Svc = &mockCheckApplier{zoneRecords: []model.Record{
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
}}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
var resp []dto.RecordDTO
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp) != 1 || resp[0].Type != "A" {
t.Fatalf("unexpected records response: %+v", resp)
}
}
// TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches covers the
// snapshot-to-template flow: NS/SOA are read-only and must be excluded from
// the generated template, and the new template must be auto-attached to the
// domain (SetDomainTemplate) so check/apply become immediately available.
func TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches(t *testing.T) {
a, ts := newTenantTestAPI()
domID := uuid.New()
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
a.Svc = &mockCheckApplier{zoneRecords: []model.Record{
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
{Type: model.TXT, Name: "a.example.com.", TTL: 300, Values: []string{"v=spf1 -all"}},
{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}},
{Type: model.SOA, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com. admin.example.com. 1 2 3 4 5"}},
}}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/template-from-zone", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if ts.createTemplate == nil {
t.Fatal("expected CreateTemplate to be called")
}
if len(ts.createTemplate.Doc.Records) != 2 {
t.Fatalf("expected only the 2 managed records (A+TXT) in the snapshot, got %+v", ts.createTemplate.Doc.Records)
}
for _, r := range ts.createTemplate.Doc.Records {
if r.Type == "NS" || r.Type == "SOA" {
t.Fatalf("read-only record type %s leaked into snapshot template", r.Type)
}
}
// SetDomainTemplate must have been called with the newly created template's id.
if ts.domains[0].TemplateID == nil || *ts.domains[0].TemplateID != ts.createTemplate.ID {
t.Fatalf("expected domain auto-attached to new template %s, got %+v", ts.createTemplate.ID, ts.domains[0].TemplateID)
}
var resp templateResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.ID != ts.createTemplate.ID.String() || len(resp.Records) != 2 {
t.Fatalf("unexpected response: %+v", resp)
}
}
// TestTemplateFromZone_AlreadyAttachedReturns409 covers the guard against
// re-snapshotting a domain that already has a template bound: a direct
// POST (e.g. curl or a client retry) must not silently create a new
// template and re-point the domain, orphaning the previously attached one.
func TestTemplateFromZone_AlreadyAttachedReturns409(t *testing.T) {
a, ts := newTenantTestAPI()
domID := uuid.New()
existingTemplateID := uuid.New()
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1", TemplateID: &existingTemplateID}}
a.Svc = &mockCheckApplier{zoneRecords: []model.Record{
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
}}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/template-from-zone", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("status %d body %s", w.Code, w.Body.String())
}
if ts.createTemplate != nil {
t.Fatalf("expected CreateTemplate NOT to be called, got %+v", ts.createTemplate)
}
if ts.domains[0].TemplateID == nil || *ts.domains[0].TemplateID != existingTemplateID {
t.Fatalf("expected existing template binding untouched, got %+v", ts.domains[0].TemplateID)
}
}
// TestZoneRecords_ProviderErrorReturns502 covers the provider-failure path:
// an error wrapping service.ErrProviderUnavailable (i.e. GetRecords itself
// failed) must surface as 502 (bad gateway), not a generic 500 or 404.
func TestZoneRecords_ProviderErrorReturns502(t *testing.T) {
a, ts := newTenantTestAPI()
domID := uuid.New()
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
a.Svc = &mockCheckApplier{zoneErr: fmt.Errorf("%w: boom", service.ErrProviderUnavailable)}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadGateway {
t.Fatalf("expected 502, got %d body %s", w.Code, w.Body.String())
}
}
// TestZoneRecords_NotFoundReturns404 covers the domain-resolution-failure
// path: an error that does NOT wrap service.ErrProviderUnavailable (e.g. the
// domain doesn't exist in this project) must surface as 404, not 502 — the
// provider was never even reached.
func TestZoneRecords_NotFoundReturns404(t *testing.T) {
a, ts := newTenantTestAPI()
domID := uuid.New()
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
a.Svc = &mockCheckApplier{zoneErr: errors.New("not found")}
router := NewRouter(a)
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d body %s", w.Code, w.Body.String())
}
}
+45
View File
@@ -2,16 +2,26 @@ package service
import ( import (
"context" "context"
"errors"
"fmt"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/vasyakrg/dns-autoresolver/internal/crypto" "github.com/vasyakrg/dns-autoresolver/internal/crypto"
"github.com/vasyakrg/dns-autoresolver/internal/diff" "github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/provider" "github.com/vasyakrg/dns-autoresolver/internal/provider"
"github.com/vasyakrg/dns-autoresolver/internal/provider/registry" "github.com/vasyakrg/dns-autoresolver/internal/provider/registry"
"github.com/vasyakrg/dns-autoresolver/internal/store/dto" "github.com/vasyakrg/dns-autoresolver/internal/store/dto"
) )
// ErrProviderUnavailable marks failures that happened while talking to the
// DNS provider itself (network, auth, rate limit, ...), as opposed to
// failures resolving the domain/zone locally (not found, bad credentials
// stored, unknown provider name). Callers use errors.Is against this to
// pick 502 vs 404 without leaking provider error details as "not found".
var ErrProviderUnavailable = errors.New("service: provider unavailable")
// DomainRef is the minimal data the service needs about a domain. // DomainRef is the minimal data the service needs about a domain.
type DomainRef struct { type DomainRef struct {
ZoneID string ZoneID string
@@ -20,8 +30,17 @@ type DomainRef struct {
Template dto.TemplateDoc Template dto.TemplateDoc
} }
// ZoneRef is the provider-access subset of a domain, without a template —
// enough to read a zone's live records.
type ZoneRef struct {
ZoneID string
Provider string
SecretEnc string
}
type Loader interface { type Loader interface {
LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (DomainRef, error) LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (DomainRef, error)
LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (ZoneRef, error)
} }
type Recorder interface { type Recorder interface {
@@ -81,6 +100,32 @@ func (s *DomainService) Check(ctx context.Context, projectID, domainID uuid.UUID
return cs, nil return cs, nil
} }
// ZoneRecords reads a zone's current records straight from the provider,
// with no diff and no template required. Used for read-only zone viewing and
// as the source for a snapshot template.
func (s *DomainService) ZoneRecords(ctx context.Context, projectID, domainID uuid.UUID) ([]model.Record, error) {
ref, err := s.loader.LoadZone(ctx, projectID, domainID)
if err != nil {
return nil, err
}
p, err := s.reg.ByName(ref.Provider)
if err != nil {
return nil, err
}
secret, err := s.cipher.Decrypt(ref.SecretEnc)
if err != nil {
return nil, err
}
recs, err := p.GetRecords(ctx, provider.Credentials{Secret: string(secret)}, ref.ZoneID)
if err != nil {
// Only a failure of the provider call itself is "provider unavailable" —
// LoadZone/ByName/Decrypt errors above are local resolution failures
// (e.g. domain not found) and must not be conflated with it.
return nil, fmt.Errorf("%w: %v", ErrProviderUnavailable, err)
}
return recs, nil
}
// Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes. // Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes.
func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) { func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) {
p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID) p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID)
+25
View File
@@ -49,6 +49,13 @@ func (l fakeLoader) LoadDomain(context.Context, uuid.UUID, uuid.UUID) (DomainRef
return l.ref, nil return l.ref, nil
} }
// LoadZone mirrors LoadDomain's provider-access fields but — unlike
// LoadDomain — never errors on a missing template, matching the real
// store.LoadZone contract.
func (l fakeLoader) LoadZone(context.Context, uuid.UUID, uuid.UUID) (ZoneRef, error) {
return ZoneRef{ZoneID: l.ref.ZoneID, Provider: l.ref.Provider, SecretEnc: l.ref.SecretEnc}, nil
}
type nopRecorder struct{} type nopRecorder struct{}
func (nopRecorder) SaveCheckRun(context.Context, uuid.UUID, diff.Changeset) error { return nil } func (nopRecorder) SaveCheckRun(context.Context, uuid.UUID, diff.Changeset) error { return nil }
@@ -78,6 +85,24 @@ func TestCheckProducesDiff(t *testing.T) {
} }
} }
// TestZoneRecordsReadsProviderDirectly covers the no-template zone-viewing
// path: ZoneRecords must return the provider's live records with no diff
// and no template involved (loader's Template field is left zero-valued).
func TestZoneRecordsReadsProviderDirectly(t *testing.T) {
actual := []model.Record{
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}},
}
svc, _ := setup(t, actual, dto.TemplateDoc{})
recs, err := svc.ZoneRecords(context.Background(), uuid.New(), uuid.New())
if err != nil {
t.Fatal(err)
}
if len(recs) != 2 {
t.Fatalf("expected 2 records straight from the provider, got %+v", recs)
}
}
func TestApplyRespectsPruneGuard(t *testing.T) { func TestApplyRespectsPruneGuard(t *testing.T) {
// зона содержит лишнюю запись b (нет в шаблоне) → Prune-кандидат // зона содержит лишнюю запись b (нет в шаблоне) → Prune-кандидат
actual := []model.Record{ actual := []model.Record{
+12
View File
@@ -33,6 +33,18 @@ func (s *Store) LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (
}, nil }, nil
} }
// LoadZone returns just the provider-access half of a domain (provider name,
// encrypted secret, zone id), WITHOUT requiring an attached template — so a
// zone's live records can be read for viewing/snapshot even when no template
// is set. Scoped by projectID (same IDOR closure as LoadDomain).
func (s *Store) LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (service.ZoneRef, error) {
row, err := s.q.LoadDomainFull(ctx, db.LoadDomainFullParams{ID: domainID, ProjectID: projectID})
if err != nil {
return service.ZoneRef{}, err
}
return service.ZoneRef{ZoneID: row.ZoneID, Provider: row.Provider, SecretEnc: row.SecretEnc}, nil
}
// SaveCheckRun persists a summary of the changeset (counts of updates/prunes) // SaveCheckRun persists a summary of the changeset (counts of updates/prunes)
// as a check_runs row. // as a check_runs row.
func (s *Store) SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error { func (s *Store) SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error {
+32
View File
@@ -91,3 +91,35 @@ func TestLoadDomainNoTemplate(t *testing.T) {
t.Fatal("expected error for domain without template, got nil") t.Fatal("expected error for domain without template, got nil")
} }
} }
// TestLoadZoneNoTemplate covers the Task 1 gap: a domain with no attached
// template must still be resolvable to its provider-access ref (ZoneRef) so
// its live records can be read/snapshotted — unlike LoadDomain, LoadZone
// must NOT error out on a nil template doc.
func TestLoadZoneNoTemplate(t *testing.T) {
s, ctx := newStore(t)
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
ID: uuid.New(), ProjectID: defaultProject,
Provider: "selectel", SecretEnc: "enc-blob", Comment: "prod",
})
if err != nil {
t.Fatal(err)
}
domain, err := s.Queries().CreateDomain(ctx, db.CreateDomainParams{
ID: uuid.New(), ProjectID: defaultProject, ProviderAccountID: acc.ID,
ZoneName: "example.com", ZoneID: "zone-3", TemplateID: nil,
})
if err != nil {
t.Fatal(err)
}
ref, err := s.LoadZone(ctx, defaultProject, domain.ID)
if err != nil {
t.Fatalf("expected LoadZone to succeed without a template, got error: %v", err)
}
if ref.ZoneID != "zone-3" || ref.Provider != "selectel" || ref.SecretEnc != "enc-blob" {
t.Fatalf("zone ref mismatch: %+v", ref)
}
}
+6 -1
View File
@@ -3,7 +3,7 @@ import type {
AuthState, AuthState,
Account, CreateAccountInput, Template, CreateTemplateInput, Account, CreateAccountInput, Template, CreateTemplateInput,
Domain, CreateDomainInput, ChangesetResponse, ApplyRequest, Domain, CreateDomainInput, ChangesetResponse, ApplyRequest,
Schedule, Channel, CreateChannelInput, CheckRun, Schedule, Channel, CreateChannelInput, CheckRun, RecordDTO,
} from "./types" } from "./types"
export class UnauthorizedError extends Error { export class UnauthorizedError extends Error {
@@ -74,6 +74,11 @@ export const api = {
setDomainTemplate: (projectId: string, id: string, templateId: string | null) => setDomainTemplate: (projectId: string, id: string, templateId: string | null) =>
req<Domain>(projectPath(projectId, `/domains/${id}`), { method: "PATCH", body: JSON.stringify({ templateId }) }), req<Domain>(projectPath(projectId, `/domains/${id}`), { method: "PATCH", body: JSON.stringify({ templateId }) }),
zoneRecords: (projectId: string, id: string) =>
req<RecordDTO[]>(projectPath(projectId, `/domains/${id}/records`)),
templateFromZone: (projectId: string, id: string) =>
req<Template>(projectPath(projectId, `/domains/${id}/template-from-zone`), { method: "POST" }),
checkDomain: (projectId: string, id: string) => checkDomain: (projectId: string, id: string) =>
req<ChangesetResponse>(projectPath(projectId, `/domains/${id}/check`)), req<ChangesetResponse>(projectPath(projectId, `/domains/${id}/check`)),
applyDomain: (projectId: string, id: string, body: ApplyRequest) => applyDomain: (projectId: string, id: string, body: ApplyRequest) =>
+6
View File
@@ -28,6 +28,12 @@ test("unknown — muted, текст «unknown»", () => {
expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-readonly)" }) expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-readonly)" })
}) })
test("no_template — muted, текст «без шаблона»", () => {
render(<StatusBadge status="no_template" />)
expect(screen.getByText("без шаблона")).toBeInTheDocument()
expect(screen.getByTestId("status-dot")).toHaveStyle({ background: "var(--diff-readonly)" })
})
test("отсутствие статуса трактуется как unknown", () => { test("отсутствие статуса трактуется как unknown", () => {
render(<StatusBadge />) render(<StatusBadge />)
expect(screen.getByText("unknown")).toBeInTheDocument() expect(screen.getByText("unknown")).toBeInTheDocument()
+5 -2
View File
@@ -5,17 +5,20 @@ import { cn } from "@/lib/utils"
// unknown | in_sync | drift | error. Colors reuse the diff-* tokens already // unknown | in_sync | drift | error. Colors reuse the diff-* tokens already
// established for the domain-diff console so a drifted zone reads the same // established for the domain-diff console so a drifted zone reads the same
// "amber" whether you're looking at the list or the diff view. // "amber" whether you're looking at the list or the diff view.
export type CheckStatus = "unknown" | "in_sync" | "drift" | "error" // no_template is a frontend-only pseudo-status (backend never sends it) —
// shown when a domain has no template attached, so there's nothing to diff.
export type CheckStatus = "unknown" | "in_sync" | "drift" | "error" | "no_template"
const STATUS_META: Record<CheckStatus, { label: string; color: string }> = { const STATUS_META: Record<CheckStatus, { label: string; color: string }> = {
in_sync: { label: "in sync", color: "var(--diff-add)" }, in_sync: { label: "in sync", color: "var(--diff-add)" },
drift: { label: "drift", color: "var(--diff-update)" }, drift: { label: "drift", color: "var(--diff-update)" },
error: { label: "error", color: "var(--diff-delete)" }, error: { label: "error", color: "var(--diff-delete)" },
unknown: { label: "unknown", color: "var(--diff-readonly)" }, unknown: { label: "unknown", color: "var(--diff-readonly)" },
no_template: { label: "без шаблона", color: "var(--diff-readonly)" },
} }
function resolveStatus(status?: string): CheckStatus { function resolveStatus(status?: string): CheckStatus {
if (status === "in_sync" || status === "drift" || status === "error") return status if (status === "in_sync" || status === "drift" || status === "error" || status === "no_template") return status
return "unknown" return "unknown"
} }
+23 -11
View File
@@ -111,23 +111,35 @@ export function useSetDomainTemplate() {
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }),
}) })
} }
export function useDeleteDomain() { export function useCheckDomain(id: string, enabled = true) {
const { project } = useAuth()
return useQuery({
queryKey: ["check", project?.id, id],
queryFn: () => api.checkDomain(project!.id, id),
enabled: !!project && !!id && enabled,
})
}
export function useZoneRecords(id: string, enabled = true) {
const { project } = useAuth()
return useQuery({
queryKey: ["zoneRecords", project?.id, id],
queryFn: () => api.zoneRecords(project!.id, id),
enabled: !!project && !!id && enabled,
})
}
export function useCreateTemplateFromZone() {
const { project } = useAuth() const { project } = useAuth()
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => { mutationFn: (id: string) => {
const pid = requireProjectId(project) const pid = requireProjectId(project)
return api.deleteDomain(pid, id) return api.templateFromZone(pid, id)
},
onSuccess: (_data, id) => {
qc.invalidateQueries({ queryKey: ["domains", project?.id] })
qc.invalidateQueries({ queryKey: ["zoneRecords", project?.id, id] })
qc.invalidateQueries({ queryKey: ["check", project?.id, id] })
}, },
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains", project?.id] }),
})
}
export function useCheckDomain(id: string) {
const { project } = useAuth()
return useQuery({
queryKey: ["check", project?.id, id],
queryFn: () => api.checkDomain(project!.id, id),
enabled: !!project && !!id,
}) })
} }
export function useApplyDomain(id: string) { export function useApplyDomain(id: string) {
+92 -3
View File
@@ -5,12 +5,21 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { DomainDiffPage } from "./DomainDiffPage" import { DomainDiffPage } from "./DomainDiffPage"
import { AuthProvider } from "@/auth/AuthContext" import { AuthProvider } from "@/auth/AuthContext"
import { api } from "@/api/client" import { api } from "@/api/client"
import { vi, beforeEach } from "vitest" import { vi, beforeEach, test, expect } from "vitest"
import type { Domain } from "@/api/types"
const PROJECT_ID = "p1" const PROJECT_ID = "p1"
function renderPage() { const domainWithTemplate: Domain = {
const qc = new QueryClient() id: "d1",
providerAccountId: "acc1",
zoneName: "example.com.",
zoneId: "z1",
templateId: "t1",
lastCheckStatus: "drift",
}
function renderPage(qc: QueryClient = new QueryClient()) {
return render( return render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<AuthProvider> <AuthProvider>
@@ -29,6 +38,7 @@ beforeEach(() => {
project: { id: PROJECT_ID, name: "Default" }, project: { id: PROJECT_ID, name: "Default" },
}) })
vi.spyOn(api, "domainHistory").mockResolvedValue([]) vi.spyOn(api, "domainHistory").mockResolvedValue([])
vi.spyOn(api, "listDomains").mockResolvedValue([domainWithTemplate])
}) })
test("apply sends applyPrunes=false by default, true only after opting in", async () => { test("apply sends applyPrunes=false by default, true only after opting in", async () => {
@@ -38,6 +48,7 @@ test("apply sends applyPrunes=false by default, true only after opting in", asyn
readOnly: [], inSyncCount: 0, readOnly: [], inSyncCount: 0,
}) })
const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 }) const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
const zoneRecordsSpy = vi.spyOn(api, "zoneRecords")
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
@@ -52,4 +63,82 @@ test("apply sends applyPrunes=false by default, true only after opting in", asyn
await user.click(screen.getByRole("button", { name: /apply/i })) await user.click(screen.getByRole("button", { name: /apply/i }))
await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2)) await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2))
expect(applySpy.mock.calls[1]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true }]) expect(applySpy.mock.calls[1]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true }])
// домен с шаблоном: записи зоны не нужны для диффа — запрос не должен уходить к провайдеру
expect(zoneRecordsSpy).not.toHaveBeenCalled()
})
test("пока список доменов грузится — показан общий лоадер, а не баннер об отсутствии шаблона", async () => {
let resolveListDomains: (domains: Domain[]) => void
vi.spyOn(api, "listDomains").mockReturnValue(
new Promise((resolve) => {
resolveListDomains = resolve
}),
)
const checkSpy = vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [], prunes: [], readOnly: [], inSyncCount: 0,
})
const zoneRecordsSpy = vi.spyOn(api, "zoneRecords")
renderPage()
expect(await screen.findByText(/загрузка/i)).toBeInTheDocument()
expect(screen.queryByText(/шаблон не привязан/i)).not.toBeInTheDocument()
expect(checkSpy).not.toHaveBeenCalled()
expect(zoneRecordsSpy).not.toHaveBeenCalled()
resolveListDomains!([domainWithTemplate])
expect(await screen.findByRole("button", { name: /apply/i })).toBeInTheDocument()
})
test("домен без шаблона показывает записи зоны и не вызывает check", async () => {
vi.spyOn(api, "listDomains").mockResolvedValue([
{ id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null },
])
const checkSpy = vi.spyOn(api, "checkDomain")
vi.spyOn(api, "zoneRecords").mockResolvedValue([
{ type: "A", name: "example.com.", ttl: 3600, values: ["1.2.3.4"] },
])
renderPage()
expect(await screen.findByText(/шаблон не привязан/i)).toBeInTheDocument()
expect(screen.getByRole("button", { name: /создать шаблон из этой зоны/i })).toBeInTheDocument()
expect(await screen.findByText("example.com.")).toBeInTheDocument()
expect(screen.getByText("1.2.3.4")).toBeInTheDocument()
expect(screen.queryByText(/вычисляю дифф/i)).not.toBeInTheDocument()
expect(checkSpy).not.toHaveBeenCalled()
})
test("ошибка загрузки списка доменов показывает баннер ошибки и не уходит в ветку без шаблона", async () => {
vi.spyOn(api, "listDomains").mockRejectedValue(new Error("network down"))
const checkSpy = vi.spyOn(api, "checkDomain")
const zoneRecordsSpy = vi.spyOn(api, "zoneRecords")
// retry:false — иначе react-query переретраит listDomains с экспоненциальной
// задержкой и findByText не успевает дождаться финального isError.
renderPage(new QueryClient({ defaultOptions: { queries: { retry: false } } }))
expect(await screen.findByText(/не удалось загрузить список доменов/i)).toBeInTheDocument()
expect(screen.getByText("network down")).toBeInTheDocument()
expect(screen.queryByText(/шаблон не привязан/i)).not.toBeInTheDocument()
expect(checkSpy).not.toHaveBeenCalled()
expect(zoneRecordsSpy).not.toHaveBeenCalled()
})
test("создание шаблона из зоны вызывает templateFromZone", async () => {
vi.spyOn(api, "listDomains").mockResolvedValue([
{ id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null },
])
vi.spyOn(api, "zoneRecords").mockResolvedValue([])
const templateFromZoneSpy = vi.spyOn(api, "templateFromZone").mockResolvedValue({
id: "t9", name: "example.com. snapshot", records: [], version: 1,
})
const user = userEvent.setup()
renderPage()
const createBtn = await screen.findByRole("button", { name: /создать шаблон из этой зоны/i })
await user.click(createBtn)
await waitFor(() => expect(templateFromZoneSpy).toHaveBeenCalledWith(PROJECT_ID, "d1"))
}) })
+143 -14
View File
@@ -6,13 +6,37 @@ import { DomainHistory } from "@/components/DomainHistory"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { useApplyDomain, useCheckDomain } from "@/hooks/useApi" import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
useApplyDomain,
useCheckDomain,
useCreateTemplateFromZone,
useDomains,
useZoneRecords,
} from "@/hooks/useApi"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export function DomainDiffPage() { export function DomainDiffPage() {
const { id = "" } = useParams() const { id = "" } = useParams()
const check = useCheckDomain(id) const domains = useDomains()
const domain = domains.data?.find((d) => d.id === id)
const hasTemplate = !!domain?.templateId
const check = useCheckDomain(id, hasTemplate)
const apply = useApplyDomain(id) const apply = useApplyDomain(id)
// Пока список доменов не загружен ИЛИ загрузка упала ошибкой, hasTemplate
// недостоверно (false по умолчанию из-за domain === undefined) — не
// дёргаем provider-запрос записей зоны, пока не будет точно известно
// (успешный ответ), что шаблона нет.
const zoneRecords = useZoneRecords(id, !domains.isPending && !domains.isError && !hasTemplate)
const createTemplateFromZone = useCreateTemplateFromZone()
const [applyPrunes, setApplyPrunes] = useState(false) const [applyPrunes, setApplyPrunes] = useState(false)
const pruneCheckboxId = useId() const pruneCheckboxId = useId()
@@ -20,11 +44,46 @@ export function DomainDiffPage() {
const hasPrunes = (changeset?.prunes.length ?? 0) > 0 const hasPrunes = (changeset?.prunes.length ?? 0) > 0
const hasUpdates = (changeset?.updates.length ?? 0) > 0 const hasUpdates = (changeset?.updates.length ?? 0) > 0
const pruneWarning = applyPrunes && hasPrunes const pruneWarning = applyPrunes && hasPrunes
const recordList = zoneRecords.data ?? []
function onApply() { function onApply() {
apply.mutate({ applyUpdates: true, applyPrunes }) apply.mutate({ applyUpdates: true, applyPrunes })
} }
function onCreateTemplateFromZone() {
createTemplateFromZone.mutate(id)
}
if (domains.isPending) {
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
Загрузка
</div>
</div>
)
}
// Список доменов не загрузился — hasTemplate тут недостоверно (domain
// === undefined из-за domains.data === undefined даёт hasTemplate=false),
// поэтому без этой проверки страница молча уходит в ветку «без шаблона»
// и дёргает zoneRecords для несуществующего состояния. Показываем ошибку
// и не рендерим ни одну из веток решения.
if (domains.isError) {
return (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<div className="flex items-start gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
<div className="flex flex-col gap-1">
<span className="font-medium">Не удалось загрузить список доменов</span>
<span className="font-dns text-xs opacity-90">{domains.error.message}</span>
</div>
</div>
</div>
)
}
return ( return (
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8"> <div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
<header className="flex flex-wrap items-end justify-between gap-4"> <header className="flex flex-wrap items-end justify-between gap-4">
@@ -36,25 +95,95 @@ export function DomainDiffPage() {
{id} {id}
</h1> </h1>
</div> </div>
<Button {hasTemplate && (
variant="ghost" <Button
size="sm" variant="ghost"
onClick={() => check.refetch()} size="sm"
disabled={check.isFetching} onClick={() => check.refetch()}
> disabled={check.isFetching}
<RefreshCw className={cn("size-3.5", check.isFetching && "animate-spin")} strokeWidth={1.75} /> >
Recheck <RefreshCw className={cn("size-3.5", check.isFetching && "animate-spin")} strokeWidth={1.75} />
</Button> Recheck
</Button>
)}
</header> </header>
{check.isPending && ( {!hasTemplate && (
<>
<div className="flex items-start gap-2.5 rounded-lg border border-border bg-card/60 px-4 py-3 text-sm text-muted-foreground">
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
<span>Шаблон не привязан дифф недоступен. Ниже текущие записи зоны.</span>
</div>
<div className="flex items-center justify-between gap-3 rounded-xl border border-border bg-card/60 p-4">
<span className="text-xs text-muted-foreground">
Создать шаблон-эталон из текущего состояния зоны (без NS/SOA).
</span>
<Button onClick={onCreateTemplateFromZone} disabled={createTemplateFromZone.isPending}>
{createTemplateFromZone.isPending ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
) : (
<Play className="size-4" strokeWidth={1.75} />
)}
Создать шаблон из этой зоны
</Button>
</div>
{createTemplateFromZone.isError && (
<span role="alert" className="font-dns text-xs text-destructive">
{createTemplateFromZone.error.message}
</span>
)}
{zoneRecords.isPending && (
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
Загружаю записи зоны
</div>
)}
{zoneRecords.isError && (
<div className="flex items-start gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
<div className="flex flex-col gap-1">
<span className="font-medium">Не удалось получить записи зоны</span>
<span className="font-dns text-xs opacity-90">{zoneRecords.error.message}</span>
</div>
</div>
)}
{recordList.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Тип</TableHead>
<TableHead>Имя</TableHead>
<TableHead>TTL</TableHead>
<TableHead>Значение</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recordList.map((r, i) => (
<TableRow key={`${r.type}-${r.name}-${i}`}>
<TableCell className="font-dns">{r.type}</TableCell>
<TableCell className="font-dns">{r.name}</TableCell>
<TableCell className="font-dns">{r.ttl}</TableCell>
<TableCell className="font-dns whitespace-pre-line">{r.values.join("\n")}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
)}
{hasTemplate && check.isPending && (
<div className="flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-8 text-sm text-muted-foreground"> <div className="flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} /> <Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
Вычисляю дифф Вычисляю дифф
</div> </div>
)} )}
{check.isError && ( {hasTemplate && check.isError && (
<div className="flex items-start gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"> <div className="flex items-start gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} /> <AlertTriangle className="mt-0.5 size-4 shrink-0" strokeWidth={1.75} />
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -64,7 +193,7 @@ export function DomainDiffPage() {
</div> </div>
)} )}
{changeset && ( {hasTemplate && changeset && (
<> <>
<DiffView changeset={changeset} /> <DiffView changeset={changeset} />
+17 -1
View File
@@ -114,6 +114,22 @@ test("drift-badge отражает lastCheckStatus каждого домена",
await screen.findByText("example.com.") await screen.findByText("example.com.")
expect(screen.getByText("drift")).toBeInTheDocument()
expect(screen.getByText("in sync")).toBeInTheDocument() expect(screen.getByText("in sync")).toBeInTheDocument()
}) })
test("домен без templateId показывает «без шаблона» вместо lastCheckStatus", async () => {
renderPage()
await screen.findByText("example.com.")
expect(screen.getByText("без шаблона")).toBeInTheDocument()
expect(screen.queryByText("drift")).not.toBeInTheDocument()
})
test("кнопки удаления домена нет", async () => {
renderPage()
await screen.findByText("example.com.")
expect(screen.queryByRole("button", { name: /удалить/i })).not.toBeInTheDocument()
})
+2 -25
View File
@@ -1,6 +1,6 @@
import { useState } from "react" import { useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { Inbox, Loader2, Trash2, Upload } from "lucide-react" import { Inbox, Loader2, Upload } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { StatusBadge } from "@/components/StatusBadge" import { StatusBadge } from "@/components/StatusBadge"
import { import {
@@ -20,7 +20,6 @@ import {
} from "@/components/ui/table" } from "@/components/ui/table"
import { import {
useAccounts, useAccounts,
useDeleteDomain,
useDomains, useDomains,
useImportZones, useImportZones,
useSetDomainTemplate, useSetDomainTemplate,
@@ -35,7 +34,6 @@ export function DomainsPage() {
const templates = useTemplates() const templates = useTemplates()
const importZones = useImportZones() const importZones = useImportZones()
const setTemplate = useSetDomainTemplate() const setTemplate = useSetDomainTemplate()
const deleteDomain = useDeleteDomain()
const accountList = accounts.data ?? [] const accountList = accounts.data ?? []
const templateList = templates.data ?? [] const templateList = templates.data ?? []
@@ -64,12 +62,6 @@ export function DomainsPage() {
setTemplate.mutate({ id: domainId, templateId }) setTemplate.mutate({ id: domainId, templateId })
} }
function onDelete(domainId: string, zoneName: string) {
if (window.confirm(`Удалить домен ${zoneName}? Действие необратимо.`)) {
deleteDomain.mutate(domainId)
}
}
return ( return (
<div className="mx-auto flex max-w-5xl flex-col gap-6 px-6 py-8"> <div className="mx-auto flex max-w-5xl flex-col gap-6 px-6 py-8">
<header className="flex flex-col gap-1"> <header className="flex flex-col gap-1">
@@ -117,12 +109,6 @@ export function DomainsPage() {
{setTemplate.error?.message} {setTemplate.error?.message}
</span> </span>
)} )}
{deleteDomain.isError && (
<span role="alert" className="font-dns text-xs text-destructive">
{deleteDomain.error?.message}
</span>
)}
{domainList.length === 0 ? ( {domainList.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground"> <div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
<Inbox className="size-6" strokeWidth={1.5} /> <Inbox className="size-6" strokeWidth={1.5} />
@@ -170,22 +156,13 @@ export function DomainsPage() {
</Select> </Select>
</TableCell> </TableCell>
<TableCell> <TableCell>
<StatusBadge status={d.lastCheckStatus} /> <StatusBadge status={d.templateId ? d.lastCheckStatus : "no_template"} />
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-1.5"> <div className="flex justify-end gap-1.5">
<Button variant="outline" size="sm" render={<Link to={`/domains/${d.id}`} />}> <Button variant="outline" size="sm" render={<Link to={`/domains/${d.id}`} />}>
Diff Diff
</Button> </Button>
<Button
variant="destructive"
size="icon-sm"
aria-label={`Удалить ${d.zoneName}`}
onClick={() => onDelete(d.id, d.zoneName)}
disabled={deleteDomain.isPending}
>
<Trash2 className="size-3.5" strokeWidth={1.75} />
</Button>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>