Merge feat/custom-records: custom-записи в диффе и очистка исчезнувших зон
This commit is contained in:
@@ -42,6 +42,8 @@ cd web && npm run build # прод-сборка фронта
|
|||||||
- **Статус домена.** `last_check_status` ∈ `unknown|in_sync|drift|error`. Единый источник вычисления — `service.DeriveStatus`; константы в `internal/service`, планировщик использует их как алиасы. И ручной check, и планировщик пишут статус (ручной — без notify; notify только у планировщика по смене статуса).
|
- **Статус домена.** `last_check_status` ∈ `unknown|in_sync|drift|error`. Единый источник вычисления — `service.DeriveStatus`; константы в `internal/service`, планировщик использует их как алиасы. И ручной check, и планировщик пишут статус (ручной — без notify; notify только у планировщика по смене статуса).
|
||||||
- **Шаблоны с плейсхолдером.** Шаблон хранит записи с `{{domain_name}}`; `tmpl.Materialize` подставляет имя зоны (без завершающей точки) при diff/apply, `tmpl.Parameterize` — обратно при snapshot зоны в шаблон. Материализация — единственная точка, в `service.resolve`.
|
- **Шаблоны с плейсхолдером.** Шаблон хранит записи с `{{domain_name}}`; `tmpl.Materialize` подставляет имя зоны (без завершающей точки) при diff/apply, `tmpl.Parameterize` — обратно при snapshot зоны в шаблон. Материализация — единственная точка, в `service.resolve`.
|
||||||
- **Ошибки провайдера наружу.** Провайдерские сбои оборачиваются в `service.ErrProviderUnavailable` → API отдаёт реальный текст провайдера (502); внутренние ошибки (decrypt/db/loader) остаются generic `internal error` (500).
|
- **Ошибки провайдера наружу.** Провайдерские сбои оборачиваются в `service.ErrProviderUnavailable` → API отдаёт реальный текст провайдера (502); внутренние ошибки (decrypt/db/loader) остаются generic `internal error` (500).
|
||||||
|
- **Custom-записи.** Пометка живёт в `domain_custom_records` и привязана к домену. `Changeset.MarkCustom` метит только `Kind == Delete` и никогда read-only — шаблон побеждает: как только шаблон описывает ключ, пометка перестаёт действовать. `Actionable()/Updates()/Prunes()` исключают Custom, поэтому custom-запись не даёт drift и физически не может быть применена.
|
||||||
|
- **Импорт — синхронизация.** `store.ImportDomains` в одной транзакции создаёт домены новых зон и удаляет домены зон, исчезнувших у провайдера (скоуп — `provider_account_id`). Пустой список зон не удаляет ничего: он неотличим от временной потери доступа, а удаление уносит домены вместе с историей чеков и пометками.
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,13 @@
|
|||||||
**перед** обновлениями — иначе провайдер отвергает конфликт (например
|
**перед** обновлениями — иначе провайдер отвергает конфликт (например
|
||||||
`CNAME` на имени, где ещё жива `A`-запись). При ошибке показывается
|
`CNAME` на имени, где ещё жива `A`-запись). При ошибке показывается
|
||||||
реальный ответ провайдера, а не generic-текст.
|
реальный ответ провайдера, а не generic-текст.
|
||||||
|
- **Custom-записи**: запись можно пометить как намеренно оставленную вне
|
||||||
|
шаблона — она уходит из Prunes, не считается drift и не может быть
|
||||||
|
применена, пока пометка не снята; как только шаблон начинает описывать
|
||||||
|
этот ключ сам, пометка автоматически перестаёт действовать.
|
||||||
|
- **Импорт зон — синхронизация**: повторный импорт аккаунта провайдера не
|
||||||
|
только заводит домены новых зон, но и удаляет домены зон, исчезнувших у
|
||||||
|
провайдера; пустой ответ провайдера ничего не удаляет.
|
||||||
- **Расписание проверок**: планировщик периодически гоняет read-only
|
- **Расписание проверок**: планировщик периодически гоняет read-only
|
||||||
check+notify (без Apply), пишет историю проверок и статус drift.
|
check+notify (без Apply), пишет историю проверок и статус drift.
|
||||||
- **Уведомления**: каналы Telegram и Webhook, per-channel статус доставки.
|
- **Уведомления**: каналы Telegram и Webhook, per-channel статус доставки.
|
||||||
|
|||||||
+12
-1
@@ -48,13 +48,22 @@ type TenantStore interface {
|
|||||||
// domain's zone name (for the generated template's name) before creating it.
|
// domain's zone name (for the generated template's name) before creating it.
|
||||||
GetDomain(ctx context.Context, id, projectID uuid.UUID) (store.Domain, error)
|
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 synchronises an account's zones with its domains: new
|
||||||
|
// zones become domains, domains of vanished zones are deleted. Returns
|
||||||
|
// (created, removed).
|
||||||
|
ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, []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)
|
||||||
// SetDomainStatus persists the outcome of a manual check (handleCheck) so
|
// SetDomainStatus persists the outcome of a manual check (handleCheck) so
|
||||||
// the domain's badge reflects reality immediately, instead of staying
|
// the domain's badge reflects reality immediately, instead of staying
|
||||||
// "unknown" until the scheduler's next tick. Scoped by projectID so a
|
// "unknown" until the scheduler's next tick. Scoped by projectID so a
|
||||||
// foreign domain ID can never have its status overwritten (IDOR-on-write).
|
// foreign domain ID can never have its status overwritten (IDOR-on-write).
|
||||||
SetDomainStatus(ctx context.Context, domainID, projectID uuid.UUID, status string) error
|
SetDomainStatus(ctx context.Context, domainID, projectID uuid.UUID, status string) error
|
||||||
|
|
||||||
|
// AddCustomRecord/DeleteCustomRecord manage the per-domain marks that keep
|
||||||
|
// a record out of the diff. Scoped by projectID: marking a domain outside
|
||||||
|
// the caller's project must fail (IDOR-on-write).
|
||||||
|
AddCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error
|
||||||
|
DeleteCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cipher encrypts/decrypts provider account secrets. *crypto.Cipher satisfies it.
|
// Cipher encrypts/decrypts provider account secrets. *crypto.Cipher satisfies it.
|
||||||
@@ -132,6 +141,8 @@ func NewRouter(a *API) http.Handler {
|
|||||||
r.Get("/history", a.handleDomainHistory)
|
r.Get("/history", a.handleDomainHistory)
|
||||||
r.Get("/records", a.handleZoneRecords)
|
r.Get("/records", a.handleZoneRecords)
|
||||||
r.Post("/template-from-zone", a.handleTemplateFromZone)
|
r.Post("/template-from-zone", a.handleTemplateFromZone)
|
||||||
|
r.Post("/customs", a.handleAddCustom)
|
||||||
|
r.Delete("/customs", a.handleDeleteCustom)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ func TestChangesetResponseEmptyMarshalsToArrays(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
s := string(b)
|
s := string(b)
|
||||||
for _, want := range []string{`"updates":[]`, `"prunes":[]`, `"readOnly":[]`} {
|
for _, want := range []string{`"updates":[]`, `"prunes":[]`, `"customs":[]`, `"readOnly":[]`} {
|
||||||
if !strings.Contains(s, want) {
|
if !strings.Contains(s, want) {
|
||||||
t.Fatalf("expected %s in %s", want, s)
|
t.Fatalf("expected %s in %s", want, s)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -44,6 +44,13 @@ type applyRequest struct {
|
|||||||
Prunes []string `json:"prunes"`
|
Prunes []string `json:"prunes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// customRequest is the body of POST .../customs — the key travels here
|
||||||
|
// (never as a path segment) because it contains a space and dots, e.g.
|
||||||
|
// "CNAME admin.example.com.".
|
||||||
|
type customRequest struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
type recordView struct {
|
type recordView struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
@@ -52,17 +59,19 @@ type recordView struct {
|
|||||||
Desired []string `json:"desired,omitempty"`
|
Desired []string `json:"desired,omitempty"`
|
||||||
Actual []string `json:"actual,omitempty"`
|
Actual []string `json:"actual,omitempty"`
|
||||||
ReadOnly bool `json:"readOnly"`
|
ReadOnly bool `json:"readOnly"`
|
||||||
|
Custom bool `json:"custom"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type changesetResponse struct {
|
type changesetResponse struct {
|
||||||
Updates []recordView `json:"updates"`
|
Updates []recordView `json:"updates"`
|
||||||
Prunes []recordView `json:"prunes"`
|
Prunes []recordView `json:"prunes"`
|
||||||
|
Customs []recordView `json:"customs"`
|
||||||
ReadOnly []recordView `json:"readOnly"`
|
ReadOnly []recordView `json:"readOnly"`
|
||||||
InSync int `json:"inSyncCount"`
|
InSync int `json:"inSyncCount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toRecordView(d diff.RecordDiff) recordView {
|
func toRecordView(d diff.RecordDiff) recordView {
|
||||||
rv := recordView{Key: d.Key(), Kind: string(d.Kind), Type: string(d.Type), Name: d.Name, ReadOnly: d.ReadOnly}
|
rv := recordView{Key: d.Key(), Kind: string(d.Kind), Type: string(d.Type), Name: d.Name, ReadOnly: d.ReadOnly, Custom: d.Custom}
|
||||||
if d.Desired != nil {
|
if d.Desired != nil {
|
||||||
rv.Desired = d.Desired.Values
|
rv.Desired = d.Desired.Values
|
||||||
}
|
}
|
||||||
@@ -80,6 +89,7 @@ func toChangesetResponse(cs diff.Changeset) changesetResponse {
|
|||||||
resp := changesetResponse{
|
resp := changesetResponse{
|
||||||
Updates: []recordView{},
|
Updates: []recordView{},
|
||||||
Prunes: []recordView{},
|
Prunes: []recordView{},
|
||||||
|
Customs: []recordView{},
|
||||||
ReadOnly: []recordView{},
|
ReadOnly: []recordView{},
|
||||||
}
|
}
|
||||||
for _, d := range cs.Updates() {
|
for _, d := range cs.Updates() {
|
||||||
@@ -88,6 +98,9 @@ func toChangesetResponse(cs diff.Changeset) changesetResponse {
|
|||||||
for _, d := range cs.Prunes() {
|
for _, d := range cs.Prunes() {
|
||||||
resp.Prunes = append(resp.Prunes, toRecordView(d))
|
resp.Prunes = append(resp.Prunes, toRecordView(d))
|
||||||
}
|
}
|
||||||
|
for _, d := range cs.Customs() {
|
||||||
|
resp.Customs = append(resp.Customs, toRecordView(d))
|
||||||
|
}
|
||||||
for _, d := range cs.Diffs {
|
for _, d := range cs.Diffs {
|
||||||
if d.ReadOnly {
|
if d.ReadOnly {
|
||||||
resp.ReadOnly = append(resp.ReadOnly, toRecordView(d))
|
resp.ReadOnly = append(resp.ReadOnly, toRecordView(d))
|
||||||
|
|||||||
@@ -98,6 +98,58 @@ func (a *API) handleApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, toChangesetResponse(cs))
|
writeJSON(w, http.StatusOK, toChangesetResponse(cs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAddCustom marks an RRset as deliberately kept outside the template:
|
||||||
|
// it moves from Prunes to its own section, stops counting as drift and can
|
||||||
|
// never be applied. The key travels in the body, not the path — it contains
|
||||||
|
// a space and dots ("CNAME admin.example.com.").
|
||||||
|
func (a *API) handleAddCustom(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
|
||||||
|
}
|
||||||
|
var req customRequest
|
||||||
|
if !decodeBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Key == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "key is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Store.AddCustomRecord(r.Context(), did, pid, req.Key); err != nil {
|
||||||
|
// Either the domain doesn't exist or it belongs to another project —
|
||||||
|
// both are "not found" from this tenant's point of view.
|
||||||
|
log.Printf("api: add custom record failed: %v", err)
|
||||||
|
writeErr(w, http.StatusNotFound, "domain not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteCustom removes the mark, returning the record to the regular
|
||||||
|
// diff. The key comes as a query parameter for the same reason it comes in
|
||||||
|
// the body on POST.
|
||||||
|
func (a *API) handleDeleteCustom(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
|
||||||
|
}
|
||||||
|
key := r.URL.Query().Get("key")
|
||||||
|
if key == "" {
|
||||||
|
writeErr(w, http.StatusBadRequest, "key is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Store.DeleteCustomRecord(r.Context(), did, pid, key); err != nil {
|
||||||
|
log.Printf("api: delete custom record failed: %v", err)
|
||||||
|
writeErr(w, http.StatusNotFound, "domain not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// handleZoneRecords reads a zone's current records straight from the
|
// handleZoneRecords reads a zone's current records straight from the
|
||||||
// provider — no template required, no diff computed. Backs read-only zone
|
// provider — no template required, no diff computed. Backs read-only zone
|
||||||
// viewing for domains that don't have a template attached (yet).
|
// viewing for domains that don't have a template attached (yet).
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ func toDomainResponse(d store.Domain) domainResponse {
|
|||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// importResponse splits an import into what appeared and what vanished at
|
||||||
|
// the provider. Both slices are initialised so they marshal as [] and never
|
||||||
|
// as null — clients call .length/.map on them.
|
||||||
|
type importResponse struct {
|
||||||
|
Created []domainResponse `json:"created"`
|
||||||
|
Removed []domainResponse `json:"removed"`
|
||||||
|
}
|
||||||
|
|
||||||
// parseOptionalUUID parses s (may be nil/empty) into *uuid.UUID; returns ok=false on invalid input.
|
// parseOptionalUUID parses s (may be nil/empty) into *uuid.UUID; returns ok=false on invalid input.
|
||||||
func parseOptionalUUID(s *string) (*uuid.UUID, bool) {
|
func parseOptionalUUID(s *string) (*uuid.UUID, bool) {
|
||||||
if s == nil || *s == "" {
|
if s == nil || *s == "" {
|
||||||
|
|||||||
@@ -130,19 +130,26 @@ func (a *API) handleImportZones(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Imported atomically: either every zone becomes a domain or none does,
|
// Imported atomically: either every zone becomes a domain (and vanished
|
||||||
// so a mid-batch provider/DB error never leaves a partial import behind.
|
// zones' domains are deleted) or none of it happens, so a mid-batch
|
||||||
doms, err := a.Store.ImportDomains(r.Context(), pid, aid, zones)
|
// provider/DB error never leaves a partial sync behind.
|
||||||
|
doms, gone, err := a.Store.ImportDomains(r.Context(), pid, aid, zones)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("api: import: create domains failed: %v", err)
|
log.Printf("api: import: sync domains failed: %v", err)
|
||||||
writeErr(w, http.StatusInternalServerError, "internal error")
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
created := make([]domainResponse, 0, len(doms))
|
resp := importResponse{
|
||||||
for _, d := range doms {
|
Created: make([]domainResponse, 0, len(doms)),
|
||||||
created = append(created, toDomainResponse(d))
|
Removed: make([]domainResponse, 0, len(gone)),
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, created)
|
for _, d := range doms {
|
||||||
|
resp.Created = append(resp.Created, toDomainResponse(d))
|
||||||
|
}
|
||||||
|
for _, d := range gone {
|
||||||
|
resp.Removed = append(resp.Removed, toDomainResponse(d))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- templates ---
|
// --- templates ---
|
||||||
|
|||||||
+218
-6
@@ -10,7 +10,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -35,8 +38,13 @@ type mockTenantStore struct {
|
|||||||
createDomains int
|
createDomains int
|
||||||
|
|
||||||
importDomains []store.Domain
|
importDomains []store.Domain
|
||||||
|
importRemoved []store.Domain
|
||||||
importDomainsErr error
|
importDomainsErr error
|
||||||
importCalled bool
|
importCalled bool
|
||||||
|
// importDomainsFn, when set, overrides the default fake behavior of
|
||||||
|
// ImportDomains — used by tests that need to control created/removed
|
||||||
|
// independently (e.g. TestImportZonesReturnsCreatedAndRemoved).
|
||||||
|
importDomainsFn func(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, []store.Domain, error)
|
||||||
|
|
||||||
setDomainTemplateErr error
|
setDomainTemplateErr error
|
||||||
|
|
||||||
@@ -49,6 +57,23 @@ type mockTenantStore struct {
|
|||||||
status string
|
status string
|
||||||
}
|
}
|
||||||
setDomainStatusErr error
|
setDomainStatusErr error
|
||||||
|
|
||||||
|
// addCustomRecordCalls/deleteCustomRecordCalls record every
|
||||||
|
// Add/DeleteCustomRecord(domainID, projectID, key) call, in order — tests
|
||||||
|
// assert both the scoping pair and the key the handler forwarded.
|
||||||
|
addCustomRecordCalls []struct {
|
||||||
|
domainID uuid.UUID
|
||||||
|
projectID uuid.UUID
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
addCustomRecordErr error
|
||||||
|
|
||||||
|
deleteCustomRecordCalls []struct {
|
||||||
|
domainID uuid.UUID
|
||||||
|
projectID uuid.UUID
|
||||||
|
key string
|
||||||
|
}
|
||||||
|
deleteCustomRecordErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockTenantStore) CreateAccount(_ context.Context, projectID uuid.UUID, prov, secretEnc, comment string) (store.Account, error) {
|
func (m *mockTenantStore) CreateAccount(_ context.Context, projectID uuid.UUID, prov, secretEnc, comment string) (store.Account, error) {
|
||||||
@@ -148,10 +173,34 @@ func (m *mockTenantStore) SetDomainStatus(_ context.Context, domainID, projectID
|
|||||||
return m.setDomainStatusErr
|
return m.setDomainStatusErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockTenantStore) ImportDomains(_ context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error) {
|
func (m *mockTenantStore) AddCustomRecord(_ context.Context, domainID, projectID uuid.UUID, key string) error {
|
||||||
|
m.addCustomRecordCalls = append(m.addCustomRecordCalls, struct {
|
||||||
|
domainID uuid.UUID
|
||||||
|
projectID uuid.UUID
|
||||||
|
key string
|
||||||
|
}{domainID, projectID, key})
|
||||||
|
return m.addCustomRecordErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTenantStore) DeleteCustomRecord(_ context.Context, domainID, projectID uuid.UUID, key string) error {
|
||||||
|
m.deleteCustomRecordCalls = append(m.deleteCustomRecordCalls, struct {
|
||||||
|
domainID uuid.UUID
|
||||||
|
projectID uuid.UUID
|
||||||
|
key string
|
||||||
|
}{domainID, projectID, key})
|
||||||
|
return m.deleteCustomRecordErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockTenantStore) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, []store.Domain, error) {
|
||||||
m.importCalled = true
|
m.importCalled = true
|
||||||
|
if m.importDomainsFn != nil {
|
||||||
|
created, removed, err := m.importDomainsFn(ctx, projectID, accountID, zones)
|
||||||
|
m.importDomains, m.importRemoved = created, removed
|
||||||
|
m.domains = append(m.domains, created...)
|
||||||
|
return created, removed, err
|
||||||
|
}
|
||||||
if m.importDomainsErr != nil {
|
if m.importDomainsErr != nil {
|
||||||
return nil, m.importDomainsErr
|
return nil, nil, m.importDomainsErr
|
||||||
}
|
}
|
||||||
out := make([]store.Domain, 0, len(zones))
|
out := make([]store.Domain, 0, len(zones))
|
||||||
for _, z := range zones {
|
for _, z := range zones {
|
||||||
@@ -160,7 +209,7 @@ func (m *mockTenantStore) ImportDomains(_ context.Context, projectID, accountID
|
|||||||
}
|
}
|
||||||
m.domains = append(m.domains, out...)
|
m.domains = append(m.domains, out...)
|
||||||
m.importDomains = out
|
m.importDomains = out
|
||||||
return out, nil
|
return out, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type mockCipher struct{}
|
type mockCipher struct{}
|
||||||
@@ -393,12 +442,15 @@ func TestImportZones_CreatesDomainPerZone(t *testing.T) {
|
|||||||
if len(ts.importDomains) != 2 {
|
if len(ts.importDomains) != 2 {
|
||||||
t.Fatalf("expected 2 domains created via ImportDomains, got %d", len(ts.importDomains))
|
t.Fatalf("expected 2 domains created via ImportDomains, got %d", len(ts.importDomains))
|
||||||
}
|
}
|
||||||
var resp []domainResponse
|
var resp importResponse
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(resp) != 2 {
|
if len(resp.Created) != 2 {
|
||||||
t.Fatalf("expected 2 domains in response, got %d", len(resp))
|
t.Fatalf("expected 2 domains in response, got %d", len(resp.Created))
|
||||||
|
}
|
||||||
|
if len(resp.Removed) != 0 {
|
||||||
|
t.Fatalf("expected 0 removed domains in response, got %d", len(resp.Removed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,6 +485,39 @@ func TestImportZones_AtomicRollbackOnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestImportZonesReturnsCreatedAndRemoved covers the sync response shape:
|
||||||
|
// the endpoint now reports both what appeared (created) and what vanished
|
||||||
|
// at the provider (removed) rather than a bare array of created domains.
|
||||||
|
func TestImportZonesReturnsCreatedAndRemoved(t *testing.T) {
|
||||||
|
a, ts := newTenantTestAPI()
|
||||||
|
accID := uuid.New()
|
||||||
|
ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}}
|
||||||
|
ts.importDomainsFn = func(_ context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, []store.Domain, error) {
|
||||||
|
return []store.Domain{{ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: "new.example.com.", ZoneID: "z1"}},
|
||||||
|
[]store.Domain{{ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: "gone.example.com.", ZoneID: "z9"}},
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
router := NewRouter(a)
|
||||||
|
|
||||||
|
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts/"+accID.String()+"/import", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var got importResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("response must be an object with created/removed: %v (%s)", err, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(got.Created) != 1 || got.Created[0].ZoneName != "new.example.com." {
|
||||||
|
t.Fatalf("unexpected created: %+v", got.Created)
|
||||||
|
}
|
||||||
|
if len(got.Removed) != 1 || got.Removed[0].ZoneName != "gone.example.com." {
|
||||||
|
t.Fatalf("unexpected removed: %+v", got.Removed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestImportZones_BadAccountUUID(t *testing.T) {
|
func TestImportZones_BadAccountUUID(t *testing.T) {
|
||||||
a, _ := newTenantTestAPI()
|
a, _ := newTenantTestAPI()
|
||||||
router := NewRouter(a)
|
router := NewRouter(a)
|
||||||
@@ -446,6 +531,35 @@ func TestImportZones_BadAccountUUID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestImportResponseEmptyMarshalsToArrays guards the same белый-экран class
|
||||||
|
// of bug as TestChangesetResponseEmptyMarshalsToArrays (api_test.go), but for
|
||||||
|
// the import endpoint: a sync that created and removed nothing (empty zone
|
||||||
|
// list from the provider) must still marshal created/removed as [], not
|
||||||
|
// null — the frontend's .length/.map calls on the response would otherwise
|
||||||
|
// crash right after a no-op import.
|
||||||
|
func TestImportResponseEmptyMarshalsToArrays(t *testing.T) {
|
||||||
|
a, ts := newTenantTestAPI()
|
||||||
|
accID := uuid.New()
|
||||||
|
ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}}
|
||||||
|
a.Reg = &mockRegistry{zones: nil}
|
||||||
|
router := NewRouter(a)
|
||||||
|
|
||||||
|
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts/"+accID.String()+"/import", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, `"created":[]`) {
|
||||||
|
t.Fatalf("expected \"created\":[] in %s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `"removed":[]`) {
|
||||||
|
t.Fatalf("expected \"removed\":[] in %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateDomain_BadProjectUUID(t *testing.T) {
|
func TestCreateDomain_BadProjectUUID(t *testing.T) {
|
||||||
a, _ := newTenantTestAPI()
|
a, _ := newTenantTestAPI()
|
||||||
router := NewRouter(a)
|
router := NewRouter(a)
|
||||||
@@ -620,6 +734,104 @@ func TestSetDomainTemplate_TemplateNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- custom records (Task 4: mark/unmark endpoints) ---
|
||||||
|
|
||||||
|
// TestAddCustomRecordReturns201 covers the mark-as-custom endpoint: the key
|
||||||
|
// travels in the JSON body (it contains a space and dots, e.g.
|
||||||
|
// "CNAME admin.example.com.", so it can't be a path segment), and the
|
||||||
|
// handler must scope the store call by (domainID, projectID) exactly as
|
||||||
|
// received — never trusting the domain id alone.
|
||||||
|
func TestAddCustomRecordReturns201(t *testing.T) {
|
||||||
|
a, ts := newTenantTestAPI()
|
||||||
|
router := NewRouter(a)
|
||||||
|
|
||||||
|
did := uuid.New()
|
||||||
|
body := `{"key":"CNAME admin.example.com."}`
|
||||||
|
req := requestWithSessionCookie(http.MethodPost,
|
||||||
|
"/api/v1/projects/"+testPID+"/domains/"+did.String()+"/customs", strings.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(ts.addCustomRecordCalls) != 1 {
|
||||||
|
t.Fatalf("expected 1 AddCustomRecord call, got %d", len(ts.addCustomRecordCalls))
|
||||||
|
}
|
||||||
|
call := ts.addCustomRecordCalls[0]
|
||||||
|
wantPID := uuid.MustParse(testPID)
|
||||||
|
if call.domainID != did || call.projectID != wantPID {
|
||||||
|
t.Fatalf("handler must scope by (domain, project), got (%s, %s)", call.domainID, call.projectID)
|
||||||
|
}
|
||||||
|
if call.key != "CNAME admin.example.com." {
|
||||||
|
t.Fatalf("unexpected key %q", call.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddCustomRecordRejectsEmptyKey covers input validation: an empty key
|
||||||
|
// must be rejected with 400 before the store is ever called.
|
||||||
|
func TestAddCustomRecordRejectsEmptyKey(t *testing.T) {
|
||||||
|
a, ts := newTenantTestAPI()
|
||||||
|
router := NewRouter(a)
|
||||||
|
|
||||||
|
did := uuid.New()
|
||||||
|
body := `{"key":""}`
|
||||||
|
req := requestWithSessionCookie(http.MethodPost,
|
||||||
|
"/api/v1/projects/"+testPID+"/domains/"+did.String()+"/customs", strings.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 on empty key, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if len(ts.addCustomRecordCalls) != 0 {
|
||||||
|
t.Fatal("store must not be called with an empty key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteCustomRecordReturns204 covers the unmark endpoint: the key
|
||||||
|
// travels as a URL-encoded query parameter and must reach the store
|
||||||
|
// url-decoded.
|
||||||
|
func TestDeleteCustomRecordReturns204(t *testing.T) {
|
||||||
|
a, ts := newTenantTestAPI()
|
||||||
|
router := NewRouter(a)
|
||||||
|
|
||||||
|
did := uuid.New()
|
||||||
|
req := requestWithSessionCookie(http.MethodDelete,
|
||||||
|
"/api/v1/projects/"+testPID+"/domains/"+did.String()+
|
||||||
|
"/customs?key="+url.QueryEscape("CNAME admin.example.com."), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(ts.deleteCustomRecordCalls) != 1 || ts.deleteCustomRecordCalls[0].key != "CNAME admin.example.com." {
|
||||||
|
t.Fatalf("key must be url-decoded, got %+v", ts.deleteCustomRecordCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddCustomRecordForeignDomainReturns404 covers the IDOR-on-write case:
|
||||||
|
// a domain that doesn't belong to the caller's project is indistinguishable
|
||||||
|
// from a missing one from this tenant's point of view, so the store's
|
||||||
|
// pgx.ErrNoRows must surface as 404, not 500.
|
||||||
|
func TestAddCustomRecordForeignDomainReturns404(t *testing.T) {
|
||||||
|
a, ts := newTenantTestAPI()
|
||||||
|
ts.addCustomRecordErr = pgx.ErrNoRows
|
||||||
|
router := NewRouter(a)
|
||||||
|
|
||||||
|
did := uuid.New()
|
||||||
|
body := `{"key":"CNAME admin.example.com."}`
|
||||||
|
req := requestWithSessionCookie(http.MethodPost,
|
||||||
|
"/api/v1/projects/"+testPID+"/domains/"+did.String()+"/customs", strings.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404 for a domain outside the project, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeleteDomain_BadUUID(t *testing.T) {
|
func TestDeleteDomain_BadUUID(t *testing.T) {
|
||||||
a, _ := newTenantTestAPI()
|
a, _ := newTenantTestAPI()
|
||||||
router := NewRouter(a)
|
router := NewRouter(a)
|
||||||
|
|||||||
+43
-3
@@ -19,6 +19,10 @@ type RecordDiff struct {
|
|||||||
Desired *model.Record // nil for Delete
|
Desired *model.Record // nil for Delete
|
||||||
Actual *model.Record // nil for Add
|
Actual *model.Record // nil for Add
|
||||||
ReadOnly bool // NS/SOA — shown but never applied
|
ReadOnly bool // NS/SOA — shown but never applied
|
||||||
|
// Custom marks a record the operator deliberately keeps outside the
|
||||||
|
// template: shown in its own section, never counted as drift, never
|
||||||
|
// applied. Set by MarkCustom, not by Diff.
|
||||||
|
Custom bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Key is the stable identifier of the RRset this diff targets, normalised the
|
// Key is the stable identifier of the RRset this diff targets, normalised the
|
||||||
@@ -37,7 +41,7 @@ type Changeset struct {
|
|||||||
func (c Changeset) Actionable() []RecordDiff {
|
func (c Changeset) Actionable() []RecordDiff {
|
||||||
var out []RecordDiff
|
var out []RecordDiff
|
||||||
for _, d := range c.Diffs {
|
for _, d := range c.Diffs {
|
||||||
if d.ReadOnly || d.Kind == InSync {
|
if d.ReadOnly || d.Custom || d.Kind == InSync {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, d)
|
out = append(out, d)
|
||||||
@@ -52,7 +56,7 @@ func (c Changeset) Actionable() []RecordDiff {
|
|||||||
func (c Changeset) Updates() []RecordDiff {
|
func (c Changeset) Updates() []RecordDiff {
|
||||||
var out []RecordDiff
|
var out []RecordDiff
|
||||||
for _, d := range c.Diffs {
|
for _, d := range c.Diffs {
|
||||||
if d.ReadOnly {
|
if d.ReadOnly || d.Custom {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if d.Kind == Add || d.Kind == Update {
|
if d.Kind == Add || d.Kind == Update {
|
||||||
@@ -73,7 +77,7 @@ func (c Changeset) Updates() []RecordDiff {
|
|||||||
func (c Changeset) Prunes() []RecordDiff {
|
func (c Changeset) Prunes() []RecordDiff {
|
||||||
var out []RecordDiff
|
var out []RecordDiff
|
||||||
for _, d := range c.Diffs {
|
for _, d := range c.Diffs {
|
||||||
if d.ReadOnly {
|
if d.ReadOnly || d.Custom {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if d.Kind == Delete {
|
if d.Kind == Delete {
|
||||||
@@ -131,3 +135,39 @@ func index(recs []model.Record) map[string]model.Record {
|
|||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkCustom flags the diffs whose Key() is in keys as Custom. Only
|
||||||
|
// Kind == Delete diffs are marked, and never read-only ones: the template
|
||||||
|
// wins. As soon as the template starts describing a key, its diff becomes
|
||||||
|
// Add/Update/InSync and the stored mark stops having any effect (it is not
|
||||||
|
// deleted — the operator may go back to a template without that record).
|
||||||
|
func (c *Changeset) MarkCustom(keys []string) {
|
||||||
|
if len(keys) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
set := make(map[string]bool, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
set[k] = true
|
||||||
|
}
|
||||||
|
for i := range c.Diffs {
|
||||||
|
d := &c.Diffs[i]
|
||||||
|
if d.ReadOnly || d.Kind != Delete {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if set[d.Key()] {
|
||||||
|
d.Custom = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customs returns diffs marked by MarkCustom. Disjoint from Updates() and
|
||||||
|
// Prunes(), and outside Actionable() — a custom record is never drift.
|
||||||
|
func (c Changeset) Customs() []RecordDiff {
|
||||||
|
var out []RecordDiff
|
||||||
|
for _, d := range c.Diffs {
|
||||||
|
if d.Custom {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -242,3 +242,62 @@ func TestIndexDedupLastWriteWins(t *testing.T) {
|
|||||||
t.Fatalf("expected exactly 1 diff for the duplicated key, got %d", count)
|
t.Fatalf("expected exactly 1 diff for the duplicated key, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMarkCustomOnlyMarksDeletes(t *testing.T) {
|
||||||
|
template := []model.Record{
|
||||||
|
{Type: "A", Name: "www.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
|
||||||
|
}
|
||||||
|
actual := []model.Record{
|
||||||
|
{Type: "A", Name: "www.example.com.", TTL: 300, Values: []string{"2.2.2.2"}},
|
||||||
|
{Type: "CNAME", Name: "admin.example.com.", TTL: 300, Values: []string{"example.com."}},
|
||||||
|
}
|
||||||
|
cs := Diff(template, actual)
|
||||||
|
// Ключ записи, которую шаблон описывает (Update), и ключ лишней записи (Delete).
|
||||||
|
cs.MarkCustom([]string{"A www.example.com.", "CNAME admin.example.com."})
|
||||||
|
|
||||||
|
customs := cs.Customs()
|
||||||
|
if len(customs) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 custom diff, got %d: %+v", len(customs), customs)
|
||||||
|
}
|
||||||
|
if customs[0].Key() != "CNAME admin.example.com." {
|
||||||
|
t.Fatalf("expected the Delete diff to be custom, got %q", customs[0].Key())
|
||||||
|
}
|
||||||
|
// Шаблон побеждает: описанный шаблоном ключ остаётся обычным Update.
|
||||||
|
updates := cs.Updates()
|
||||||
|
if len(updates) != 1 || updates[0].Key() != "A www.example.com." {
|
||||||
|
t.Fatalf("expected the templated record to stay in Updates, got %+v", updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomDiffsLeaveActionableAndPrunes(t *testing.T) {
|
||||||
|
actual := []model.Record{
|
||||||
|
{Type: "CNAME", Name: "admin.example.com.", TTL: 300, Values: []string{"example.com."}},
|
||||||
|
}
|
||||||
|
cs := Diff(nil, actual)
|
||||||
|
if len(cs.Prunes()) != 1 {
|
||||||
|
t.Fatalf("precondition: expected 1 prune before marking, got %+v", cs.Prunes())
|
||||||
|
}
|
||||||
|
cs.MarkCustom([]string{"CNAME admin.example.com."})
|
||||||
|
|
||||||
|
if got := cs.Prunes(); len(got) != 0 {
|
||||||
|
t.Fatalf("expected custom diff to leave Prunes, got %+v", got)
|
||||||
|
}
|
||||||
|
if got := cs.Actionable(); len(got) != 0 {
|
||||||
|
t.Fatalf("expected custom diff to leave Actionable, got %+v", got)
|
||||||
|
}
|
||||||
|
if got := cs.Customs(); len(got) != 1 {
|
||||||
|
t.Fatalf("expected 1 custom diff, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkCustomNeverMarksReadOnly(t *testing.T) {
|
||||||
|
actual := []model.Record{
|
||||||
|
{Type: "NS", Name: "example.com.", TTL: 300, Values: []string{"ns1.example.com."}},
|
||||||
|
}
|
||||||
|
cs := Diff(nil, actual)
|
||||||
|
cs.MarkCustom([]string{"NS example.com."})
|
||||||
|
|
||||||
|
if got := cs.Customs(); len(got) != 0 {
|
||||||
|
t.Fatalf("read-only diffs must never become custom, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ type DomainRef struct {
|
|||||||
Provider string
|
Provider string
|
||||||
SecretEnc string
|
SecretEnc string
|
||||||
Template dto.TemplateDoc
|
Template dto.TemplateDoc
|
||||||
|
// CustomKeys are RecordDiff keys the operator marked as deliberately
|
||||||
|
// outside the template for this domain. Applied in resolve() right after
|
||||||
|
// the diff is computed — the single marking point, like tmpl.Materialize
|
||||||
|
// is the single materialisation point.
|
||||||
|
CustomKeys []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ZoneRef is the provider-access subset of a domain, without a template —
|
// ZoneRef is the provider-access subset of a domain, without a template —
|
||||||
@@ -102,6 +107,7 @@ func (s *DomainService) resolve(ctx context.Context, projectID, domainID uuid.UU
|
|||||||
return nil, provider.Credentials{}, ref, diff.Changeset{}, fmt.Errorf("%w: %v", ErrProviderUnavailable, err)
|
return nil, provider.Credentials{}, ref, diff.Changeset{}, fmt.Errorf("%w: %v", ErrProviderUnavailable, err)
|
||||||
}
|
}
|
||||||
cs := diff.Diff(tmpl.Materialize(ref.Template, ref.ZoneName), actual)
|
cs := diff.Diff(tmpl.Materialize(ref.Template, ref.ZoneName), actual)
|
||||||
|
cs.MarkCustom(ref.CustomKeys)
|
||||||
return p, creds, ref, cs, nil
|
return p, creds, ref, cs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ func testCipher(t *testing.T) *crypto.Cipher {
|
|||||||
|
|
||||||
// fakeProvider records applied changesets and returns canned zone records.
|
// fakeProvider records applied changesets and returns canned zone records.
|
||||||
type fakeProvider struct {
|
type fakeProvider struct {
|
||||||
actual []model.Record
|
actual []model.Record
|
||||||
applied diff.Changeset
|
applied diff.Changeset
|
||||||
getErr error // when set, GetRecords fails with this error
|
applyCalls int
|
||||||
applyErr error // when set, ApplyChanges fails with this error
|
getErr error // when set, GetRecords fails with this error
|
||||||
|
applyErr error // when set, ApplyChanges fails with this error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fakeProvider) Name() string { return "selectel" }
|
func (fakeProvider) Name() string { return "selectel" }
|
||||||
@@ -44,6 +45,7 @@ func (f *fakeProvider) GetRecords(context.Context, provider.Credentials, string)
|
|||||||
return f.actual, nil
|
return f.actual, nil
|
||||||
}
|
}
|
||||||
func (f *fakeProvider) ApplyChanges(_ context.Context, _ provider.Credentials, _ string, cs diff.Changeset) error {
|
func (f *fakeProvider) ApplyChanges(_ context.Context, _ provider.Credentials, _ string, cs diff.Changeset) error {
|
||||||
|
f.applyCalls++
|
||||||
if f.applyErr != nil {
|
if f.applyErr != nil {
|
||||||
return f.applyErr
|
return f.applyErr
|
||||||
}
|
}
|
||||||
@@ -70,12 +72,18 @@ 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 }
|
||||||
|
|
||||||
func setup(t *testing.T, actual []model.Record, tmpl dto.TemplateDoc) (*DomainService, *fakeProvider) {
|
func setup(t *testing.T, actual []model.Record, tmpl dto.TemplateDoc) (*DomainService, *fakeProvider) {
|
||||||
|
return setupWithCustomKeys(t, actual, tmpl, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupWithCustomKeys mirrors setup but also lets a test populate
|
||||||
|
// DomainRef.CustomKeys, exercising the resolve() -> MarkCustom wiring.
|
||||||
|
func setupWithCustomKeys(t *testing.T, actual []model.Record, tmpl dto.TemplateDoc, customKeys []string) (*DomainService, *fakeProvider) {
|
||||||
fp := &fakeProvider{actual: actual}
|
fp := &fakeProvider{actual: actual}
|
||||||
reg := registry.New()
|
reg := registry.New()
|
||||||
reg.Register(fp)
|
reg.Register(fp)
|
||||||
cipher := testCipher(t)
|
cipher := testCipher(t)
|
||||||
enc, _ := cipher.Encrypt([]byte("secret"))
|
enc, _ := cipher.Encrypt([]byte("secret"))
|
||||||
loader := fakeLoader{ref: DomainRef{ZoneID: "z1", ZoneName: "example.com.", Provider: "selectel", SecretEnc: enc, Template: tmpl}}
|
loader := fakeLoader{ref: DomainRef{ZoneID: "z1", ZoneName: "example.com.", Provider: "selectel", SecretEnc: enc, Template: tmpl, CustomKeys: customKeys}}
|
||||||
return New(loader, nopRecorder{}, reg, cipher), fp
|
return New(loader, nopRecorder{}, reg, cipher), fp
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,3 +254,53 @@ func TestResolveWrapsProviderError(t *testing.T) {
|
|||||||
t.Fatalf("expected clean provider message, got %q", msg)
|
t.Fatalf("expected clean provider message, got %q", msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestResolveMarksCustomKeys covers the resolve() -> cs.MarkCustom(ref.CustomKeys)
|
||||||
|
// wiring: a zone record with no template counterpart, whose key is in
|
||||||
|
// DomainRef.CustomKeys, must be marked Custom rather than surfacing as a
|
||||||
|
// prune/drift.
|
||||||
|
func TestResolveMarksCustomKeys(t *testing.T) {
|
||||||
|
// Zone contains a record absent from the template, and its key is marked custom.
|
||||||
|
actual := []model.Record{
|
||||||
|
{Type: model.CNAME, Name: "admin.example.com.", TTL: 300, Values: []string{"example.com."}},
|
||||||
|
}
|
||||||
|
svc, _ := setupWithCustomKeys(t, actual, dto.TemplateDoc{Records: nil}, []string{"CNAME admin.example.com."})
|
||||||
|
|
||||||
|
cs, err := svc.Check(context.Background(), uuid.New(), uuid.New())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := cs.Prunes(); len(got) != 0 {
|
||||||
|
t.Fatalf("custom record must not appear in Prunes, got %+v", got)
|
||||||
|
}
|
||||||
|
if got := cs.Customs(); len(got) != 1 {
|
||||||
|
t.Fatalf("expected 1 custom diff, got %+v", got)
|
||||||
|
}
|
||||||
|
if status := DeriveStatus(cs); status != StatusInSync {
|
||||||
|
t.Fatalf("a zone whose only deviation is custom must be in_sync, got %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestApplyIgnoresCustomKeySentAsPrune covers the case where a client sends a
|
||||||
|
// custom-marked key in ApplyRequest.Prunes anyway (stale UI state, replay,
|
||||||
|
// etc): since a custom diff is excluded from cs.Prunes(), Apply's key
|
||||||
|
// selection never picks it up and the provider is not called at all.
|
||||||
|
func TestApplyIgnoresCustomKeySentAsPrune(t *testing.T) {
|
||||||
|
actual := []model.Record{
|
||||||
|
{Type: model.CNAME, Name: "admin.example.com.", TTL: 300, Values: []string{"example.com."}},
|
||||||
|
}
|
||||||
|
svc, fp := setupWithCustomKeys(t, actual, dto.TemplateDoc{Records: nil}, []string{"CNAME admin.example.com."})
|
||||||
|
|
||||||
|
applied, err := svc.Apply(context.Background(), uuid.New(), uuid.New(), ApplyRequest{
|
||||||
|
Prunes: []string{"CNAME admin.example.com."},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(applied.Diffs) != 0 {
|
||||||
|
t.Fatalf("custom key must never be applied, got %+v", applied.Diffs)
|
||||||
|
}
|
||||||
|
if fp.applyCalls != 0 {
|
||||||
|
t.Fatalf("provider must not be called with an empty change set, got %d calls", fp.applyCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: customs.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const addCustomRecord = `-- name: AddCustomRecord :exec
|
||||||
|
INSERT INTO domain_custom_records (domain_id, record_key)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (domain_id, record_key) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type AddCustomRecordParams struct {
|
||||||
|
DomainID uuid.UUID `json:"domain_id"`
|
||||||
|
RecordKey string `json:"record_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) AddCustomRecord(ctx context.Context, arg AddCustomRecordParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, addCustomRecord, arg.DomainID, arg.RecordKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCustomRecord = `-- name: DeleteCustomRecord :exec
|
||||||
|
DELETE FROM domain_custom_records WHERE domain_id = $1 AND record_key = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteCustomRecordParams struct {
|
||||||
|
DomainID uuid.UUID `json:"domain_id"`
|
||||||
|
RecordKey string `json:"record_key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteCustomRecord(ctx context.Context, arg DeleteCustomRecordParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteCustomRecord, arg.DomainID, arg.RecordKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listCustomKeys = `-- name: ListCustomKeys :many
|
||||||
|
SELECT record_key FROM domain_custom_records WHERE domain_id = $1 ORDER BY record_key
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListCustomKeys(ctx context.Context, domainID uuid.UUID) ([]string, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listCustomKeys, domainID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []string
|
||||||
|
for rows.Next() {
|
||||||
|
var record_key string
|
||||||
|
if err := rows.Scan(&record_key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, record_key)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -75,6 +75,47 @@ func (q *Queries) DeleteDomain(ctx context.Context, arg DeleteDomainParams) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteDomainsNotInZones = `-- name: DeleteDomainsNotInZones :many
|
||||||
|
DELETE FROM domains
|
||||||
|
WHERE project_id = $1 AND provider_account_id = $2 AND zone_id <> ALL($3::text[])
|
||||||
|
RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteDomainsNotInZonesParams struct {
|
||||||
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
ProviderAccountID uuid.UUID `json:"provider_account_id"`
|
||||||
|
ZoneIds []string `json:"zone_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteDomainsNotInZones(ctx context.Context, arg DeleteDomainsNotInZonesParams) ([]Domain, error) {
|
||||||
|
rows, err := q.db.Query(ctx, deleteDomainsNotInZones, arg.ProjectID, arg.ProviderAccountID, arg.ZoneIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Domain
|
||||||
|
for rows.Next() {
|
||||||
|
var i Domain
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ProjectID,
|
||||||
|
&i.ProviderAccountID,
|
||||||
|
&i.ZoneName,
|
||||||
|
&i.ZoneID,
|
||||||
|
&i.TemplateID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.LastCheckStatus,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const getDomain = `-- name: GetDomain :one
|
const getDomain = `-- name: GetDomain :one
|
||||||
SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE id = $1 AND project_id = $2
|
SELECT id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status FROM domains WHERE id = $1 AND project_id = $2
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ type CheckRun struct {
|
|||||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DomainCustomRecord struct {
|
||||||
|
DomainID uuid.UUID `json:"domain_id"`
|
||||||
|
RecordKey string `json:"record_key"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type Domain struct {
|
type Domain struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
|
|||||||
@@ -25,12 +25,17 @@ func (s *Store) LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (
|
|||||||
if row.Doc == nil {
|
if row.Doc == nil {
|
||||||
return service.DomainRef{}, fmt.Errorf("store: domain %s has no template", domainID)
|
return service.DomainRef{}, fmt.Errorf("store: domain %s has no template", domainID)
|
||||||
}
|
}
|
||||||
|
keys, err := s.ListCustomKeys(ctx, domainID)
|
||||||
|
if err != nil {
|
||||||
|
return service.DomainRef{}, err
|
||||||
|
}
|
||||||
return service.DomainRef{
|
return service.DomainRef{
|
||||||
ZoneID: row.ZoneID,
|
ZoneID: row.ZoneID,
|
||||||
ZoneName: row.ZoneName,
|
ZoneName: row.ZoneName,
|
||||||
Provider: row.Provider,
|
Provider: row.Provider,
|
||||||
SecretEnc: row.SecretEnc,
|
SecretEnc: row.SecretEnc,
|
||||||
Template: *row.Doc,
|
Template: *row.Doc,
|
||||||
|
CustomKeys: keys,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE domain_custom_records (
|
||||||
|
domain_id uuid NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||||
|
record_key text NOT NULL,
|
||||||
|
note text NOT NULL DEFAULT '',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (domain_id, record_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE domain_custom_records;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- name: AddCustomRecord :exec
|
||||||
|
INSERT INTO domain_custom_records (domain_id, record_key)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (domain_id, record_key) DO NOTHING;
|
||||||
|
|
||||||
|
-- name: DeleteCustomRecord :exec
|
||||||
|
DELETE FROM domain_custom_records WHERE domain_id = $1 AND record_key = $2;
|
||||||
|
|
||||||
|
-- name: ListCustomKeys :many
|
||||||
|
SELECT record_key FROM domain_custom_records WHERE domain_id = $1 ORDER BY record_key;
|
||||||
@@ -37,3 +37,8 @@ UPDATE domains SET last_check_status = $2 WHERE id = $1 AND project_id = $3;
|
|||||||
|
|
||||||
-- name: CountDriftDomains :one
|
-- name: CountDriftDomains :one
|
||||||
SELECT count(*) FROM domains WHERE last_check_status = 'drift';
|
SELECT count(*) FROM domains WHERE last_check_status = 'drift';
|
||||||
|
|
||||||
|
-- name: DeleteDomainsNotInZones :many
|
||||||
|
DELETE FROM domains
|
||||||
|
WHERE project_id = $1 AND provider_account_id = $2 AND zone_id <> ALL($3::text[])
|
||||||
|
RETURNING id, project_id, provider_account_id, zone_name, zone_id, template_id, created_at, last_check_status;
|
||||||
|
|||||||
@@ -103,13 +103,16 @@ func TestImportDomains_CommitsAllOnSuccess(t *testing.T) {
|
|||||||
{ID: "z1", Name: "a.example.com"},
|
{ID: "z1", Name: "a.example.com"},
|
||||||
{ID: "z2", Name: "b.example.com"},
|
{ID: "z2", Name: "b.example.com"},
|
||||||
}
|
}
|
||||||
doms, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
|
doms, removed, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(doms) != 2 {
|
if len(doms) != 2 {
|
||||||
t.Fatalf("expected 2 domains returned, got %d", len(doms))
|
t.Fatalf("expected 2 domains returned, got %d", len(doms))
|
||||||
}
|
}
|
||||||
|
if len(removed) != 0 {
|
||||||
|
t.Fatalf("expected 0 removed domains, got %d", len(removed))
|
||||||
|
}
|
||||||
|
|
||||||
list, err := s.ListDomains(ctx, defaultProject)
|
list, err := s.ListDomains(ctx, defaultProject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -131,7 +134,7 @@ func TestImportDomains_RollsBackAllOnError(t *testing.T) {
|
|||||||
{ID: "z1", Name: "a.example.com"},
|
{ID: "z1", Name: "a.example.com"},
|
||||||
{ID: "z2", Name: "b.example.com"},
|
{ID: "z2", Name: "b.example.com"},
|
||||||
}
|
}
|
||||||
if _, err := s.ImportDomains(ctx, defaultProject, bogusAccountID, zones); err == nil {
|
if _, _, err := s.ImportDomains(ctx, defaultProject, bogusAccountID, zones); err == nil {
|
||||||
t.Fatal("expected FK violation error, got nil")
|
t.Fatal("expected FK violation error, got nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +164,7 @@ func TestImportDomains_IdempotentOnRepeat(t *testing.T) {
|
|||||||
{ID: "z1", Name: "a.example.com"},
|
{ID: "z1", Name: "a.example.com"},
|
||||||
{ID: "z2", Name: "b.example.com"},
|
{ID: "z2", Name: "b.example.com"},
|
||||||
}
|
}
|
||||||
first, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
|
first, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -169,13 +172,16 @@ func TestImportDomains_IdempotentOnRepeat(t *testing.T) {
|
|||||||
t.Fatalf("expected 2 domains on first import, got %d", len(first))
|
t.Fatalf("expected 2 domains on first import, got %d", len(first))
|
||||||
}
|
}
|
||||||
|
|
||||||
second, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
|
second, removed, err := s.ImportDomains(ctx, defaultProject, acc.ID, zones)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected repeat import to succeed idempotently, got error: %v", err)
|
t.Fatalf("expected repeat import to succeed idempotently, got error: %v", err)
|
||||||
}
|
}
|
||||||
if len(second) != 0 {
|
if len(second) != 0 {
|
||||||
t.Fatalf("expected 0 newly-created domains on repeat import, got %d", len(second))
|
t.Fatalf("expected 0 newly-created domains on repeat import, got %d", len(second))
|
||||||
}
|
}
|
||||||
|
if len(removed) != 0 {
|
||||||
|
t.Fatalf("expected 0 removed domains on repeat import with the same zones, got %d", len(removed))
|
||||||
|
}
|
||||||
|
|
||||||
list, err := s.ListDomains(ctx, defaultProject)
|
list, err := s.ListDomains(ctx, defaultProject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -207,7 +213,7 @@ func TestSetDomainTemplate_ClosesImportCheckLoop(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
doms, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
|
doms, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -255,7 +261,7 @@ func TestSetDomainTemplate_RejectsForeignProjectTemplate(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
doms, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
|
doms, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -277,3 +283,226 @@ func TestSetDomainTemplate_RejectsForeignProjectTemplate(t *testing.T) {
|
|||||||
t.Fatal("expected error binding a template from a different project, got nil")
|
t.Fatal("expected error binding a template from a different project, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedDomain creates a provider account and imports a single domain for
|
||||||
|
// defaultProject, returning the resulting Domain — the shared setup used by
|
||||||
|
// the custom-records tests below.
|
||||||
|
func seedDomain(t *testing.T, s *Store, ctx context.Context) Domain {
|
||||||
|
t.Helper()
|
||||||
|
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
|
||||||
|
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
doms, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{{ID: "z1", Name: "a.example.com"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return doms[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCustomRecordsAddIsIdempotentAndScoped verifies that AddCustomRecord
|
||||||
|
// is idempotent (ON CONFLICT DO NOTHING — no PK-conflict error on repeat)
|
||||||
|
// and that it is scoped by projectID, closing the same IDOR-on-write gap
|
||||||
|
// SetDomainTemplate guards against.
|
||||||
|
func TestCustomRecordsAddIsIdempotentAndScoped(t *testing.T) {
|
||||||
|
s, ctx := newStore(t)
|
||||||
|
dom := seedDomain(t, s, ctx)
|
||||||
|
|
||||||
|
const key = "CNAME admin.example.com."
|
||||||
|
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Повторное добавление того же ключа не должно падать на PK-конфликте.
|
||||||
|
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
|
||||||
|
t.Fatalf("second add must be a no-op, got %v", err)
|
||||||
|
}
|
||||||
|
keys, err := s.ListCustomKeys(ctx, dom.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(keys) != 1 || keys[0] != key {
|
||||||
|
t.Fatalf("expected exactly [%q], got %+v", key, keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Чужой проект не может пометить этот домен.
|
||||||
|
if err := s.AddCustomRecord(ctx, dom.ID, uuid.New(), "A www.example.com."); err == nil {
|
||||||
|
t.Fatal("expected an error when marking a domain from another project")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCustomRecordsDeleteAndCascade verifies DeleteCustomRecord removes the
|
||||||
|
// mark, and that deleting the domain cascades to remove any remaining marks
|
||||||
|
// (domain_custom_records.domain_id REFERENCES domains(id) ON DELETE CASCADE).
|
||||||
|
func TestCustomRecordsDeleteAndCascade(t *testing.T) {
|
||||||
|
s, ctx := newStore(t)
|
||||||
|
dom := seedDomain(t, s, ctx)
|
||||||
|
|
||||||
|
const key = "CNAME admin.example.com."
|
||||||
|
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keys, err := s.ListCustomKeys(ctx, dom.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(keys) != 0 {
|
||||||
|
t.Fatalf("expected no keys after delete, got %+v", keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Каскад: удаление домена уносит его пометки.
|
||||||
|
if err := s.AddCustomRecord(ctx, dom.ID, dom.ProjectID, key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteDomain(ctx, dom.ID, dom.ProjectID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keys, err = s.ListCustomKeys(ctx, dom.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(keys) != 0 {
|
||||||
|
t.Fatalf("expected cascade to remove keys with the domain, got %+v", keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedAccount creates an additional provider account for an existing
|
||||||
|
// project — used to test that ImportDomains scopes its deletions to a
|
||||||
|
// single account, not the whole project.
|
||||||
|
func seedAccount(t *testing.T, s *Store, ctx context.Context, projectID uuid.UUID) Account {
|
||||||
|
t.Helper()
|
||||||
|
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
|
||||||
|
ID: uuid.New(), ProjectID: projectID, Provider: "selectel", SecretEnc: "enc-blob",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return accountFromDB(acc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportDomainsRemovesVanishedZones verifies the sync behavior added to
|
||||||
|
// ImportDomains: a domain whose zone no longer appears in the provider's
|
||||||
|
// zone list is deleted on the next import, closing the orphan-domain gap
|
||||||
|
// (previously such a domain sat forever in status "error").
|
||||||
|
func TestImportDomainsRemovesVanishedZones(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",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, removed, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
|
||||||
|
{ID: "z1", Name: "one.example.com."},
|
||||||
|
{ID: "z2", Name: "two.example.com."},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(created) != 2 || len(removed) != 0 {
|
||||||
|
t.Fatalf("first import: expected 2 created / 0 removed, got %d/%d", len(created), len(removed))
|
||||||
|
}
|
||||||
|
|
||||||
|
// z2 disappeared at the provider — re-import must remove its domain.
|
||||||
|
created, removed, err = s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
|
||||||
|
{ID: "z1", Name: "one.example.com."},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(created) != 0 {
|
||||||
|
t.Fatalf("expected nothing new to be created, got %+v", created)
|
||||||
|
}
|
||||||
|
if len(removed) != 1 || removed[0].ZoneID != "z2" {
|
||||||
|
t.Fatalf("expected the vanished zone's domain to be removed, got %+v", removed)
|
||||||
|
}
|
||||||
|
left, err := s.ListDomains(ctx, defaultProject)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(left) != 1 || left[0].ZoneID != "z1" {
|
||||||
|
t.Fatalf("expected only z1 to survive, got %+v", left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportDomainsEmptyZoneListRemovesNothing verifies the safety guard: an
|
||||||
|
// empty zone list from the provider is indistinguishable from a temporary
|
||||||
|
// loss of access, so it must never delete anything.
|
||||||
|
func TestImportDomainsEmptyZoneListRemovesNothing(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",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := s.ImportDomains(ctx, defaultProject, acc.ID, []provider.Zone{
|
||||||
|
{ID: "z1", Name: "one.example.com."},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
created, removed, err := s.ImportDomains(ctx, defaultProject, acc.ID, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(created) != 0 || len(removed) != 0 {
|
||||||
|
t.Fatalf("empty zone list must be a no-op, got %d created / %d removed", len(created), len(removed))
|
||||||
|
}
|
||||||
|
left, err := s.ListDomains(ctx, defaultProject)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(left) != 1 {
|
||||||
|
t.Fatalf("expected the domain to survive an empty zone list, got %+v", left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestImportDomainsOnlyTouchesItsOwnAccount verifies deletion is scoped to
|
||||||
|
// the account being imported: a project may hold several provider accounts,
|
||||||
|
// and one account's zone list says nothing about another account's domains.
|
||||||
|
func TestImportDomainsOnlyTouchesItsOwnAccount(t *testing.T) {
|
||||||
|
s, ctx := newStore(t)
|
||||||
|
accA, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
|
||||||
|
ID: uuid.New(), ProjectID: defaultProject, Provider: "selectel", SecretEnc: "enc-blob",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
accB := seedAccount(t, s, ctx, defaultProject)
|
||||||
|
|
||||||
|
if _, _, err := s.ImportDomains(ctx, defaultProject, accB.ID, []provider.Zone{
|
||||||
|
{ID: "zb", Name: "b.example.com."},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err := s.ImportDomains(ctx, defaultProject, accA.ID, []provider.Zone{
|
||||||
|
{ID: "za", Name: "a.example.com."},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-importing accA with the same single zone must not touch accB's domains.
|
||||||
|
_, removed, err := s.ImportDomains(ctx, defaultProject, accA.ID, []provider.Zone{
|
||||||
|
{ID: "za", Name: "a.example.com."},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(removed) != 0 {
|
||||||
|
t.Fatalf("import for one account must not remove another account's domains, got %+v", removed)
|
||||||
|
}
|
||||||
|
left, err := s.ListDomains(ctx, defaultProject)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(left) != 2 {
|
||||||
|
t.Fatalf("expected both accounts' domains to survive, got %+v", left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+62
-15
@@ -187,25 +187,30 @@ func (s *Store) GetDomain(ctx context.Context, id, projectID uuid.UUID) (Domain,
|
|||||||
return domainFromDB(d), nil
|
return domainFromDB(d), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportDomains creates one domain per zone inside a single transaction: if
|
// ImportDomains synchronises a provider account's zones with the domains
|
||||||
// any zone fails to be created, the whole batch is rolled back so callers
|
// stored for it, inside a single transaction: zones without a domain are
|
||||||
// never observe a partially-imported set of domains.
|
// created (ON CONFLICT DO NOTHING keeps repeated imports idempotent), and
|
||||||
|
// domains whose zone no longer exists at the provider are deleted. Deletion
|
||||||
|
// is scoped to accountID — a project may hold several provider accounts, and
|
||||||
|
// one account's zone list says nothing about another's domains.
|
||||||
//
|
//
|
||||||
// Import is idempotent: zones that already have a domain for this project
|
// An empty zone list removes nothing: it is indistinguishable from an account
|
||||||
// (enforced by the domains_project_zone_uniq constraint) are silently
|
// that temporarily lost access to its zones, and the cost of being wrong is
|
||||||
// skipped via ON CONFLICT DO NOTHING rather than erroring or duplicating —
|
// every domain of the account gone along with its check history.
|
||||||
// so a repeated POST .../import never creates duplicate domains. Only the
|
//
|
||||||
// zones that were actually newly created are returned.
|
// Returns the domains actually created and the ones actually removed.
|
||||||
func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]Domain, error) {
|
func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]Domain, []Domain, error) {
|
||||||
tx, err := s.pool.Begin(ctx)
|
tx, err := s.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback(ctx) // no-op once Commit has succeeded
|
defer tx.Rollback(ctx) // no-op once Commit has succeeded
|
||||||
|
|
||||||
q := s.q.WithTx(tx)
|
q := s.q.WithTx(tx)
|
||||||
out := make([]Domain, 0, len(zones))
|
created := make([]Domain, 0, len(zones))
|
||||||
|
zoneIDs := make([]string, 0, len(zones))
|
||||||
for _, z := range zones {
|
for _, z := range zones {
|
||||||
|
zoneIDs = append(zoneIDs, z.ID)
|
||||||
d, err := q.ImportDomain(ctx, db.ImportDomainParams{
|
d, err := q.ImportDomain(ctx, db.ImportDomainParams{
|
||||||
ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID,
|
ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID,
|
||||||
ZoneName: z.Name, ZoneID: z.ID, TemplateID: nil,
|
ZoneName: z.Name, ZoneID: z.ID, TemplateID: nil,
|
||||||
@@ -216,14 +221,28 @@ func (s *Store) ImportDomains(ctx context.Context, projectID, accountID uuid.UUI
|
|||||||
// for this project — skip it rather than fail the batch.
|
// for this project — skip it rather than fail the batch.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
out = append(out, domainFromDB(d))
|
created = append(created, domainFromDB(d))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removed := make([]Domain, 0)
|
||||||
|
if len(zoneIDs) > 0 {
|
||||||
|
gone, err := q.DeleteDomainsNotInZones(ctx, db.DeleteDomainsNotInZonesParams{
|
||||||
|
ProjectID: projectID, ProviderAccountID: accountID, ZoneIds: zoneIDs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
for _, d := range gone {
|
||||||
|
removed = append(removed, domainFromDB(d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
return out, nil
|
return created, removed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDomainTemplate attaches (or clears, when templateID is nil) the DNS
|
// SetDomainTemplate attaches (or clears, when templateID is nil) the DNS
|
||||||
@@ -245,6 +264,34 @@ func (s *Store) SetDomainTemplate(ctx context.Context, domainID, projectID uuid.
|
|||||||
return domainFromDB(d), nil
|
return domainFromDB(d), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddCustomRecord marks an RRset (identified by its diff key, "TYPE name.")
|
||||||
|
// as deliberately kept outside the template for this domain. Idempotent.
|
||||||
|
// The scoped GetDomain first ensures the domain belongs to projectID —
|
||||||
|
// otherwise a foreign domain could be marked (IDOR-on-write), same guard
|
||||||
|
// SetDomainTemplate uses for templates.
|
||||||
|
func (s *Store) AddCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error {
|
||||||
|
if _, err := s.GetDomain(ctx, domainID, projectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.q.AddCustomRecord(ctx, db.AddCustomRecordParams{DomainID: domainID, RecordKey: key})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCustomRecord removes the mark, returning the record to the regular
|
||||||
|
// diff. Scoped by projectID for the same reason as AddCustomRecord.
|
||||||
|
func (s *Store) DeleteCustomRecord(ctx context.Context, domainID, projectID uuid.UUID, key string) error {
|
||||||
|
if _, err := s.GetDomain(ctx, domainID, projectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.q.DeleteCustomRecord(ctx, db.DeleteCustomRecordParams{DomainID: domainID, RecordKey: key})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCustomKeys returns the marked record keys of a domain. Not scoped by
|
||||||
|
// project on purpose: the only caller is LoadDomain, which has already
|
||||||
|
// resolved the domain by (id, projectID).
|
||||||
|
func (s *Store) ListCustomKeys(ctx context.Context, domainID uuid.UUID) ([]string, error) {
|
||||||
|
return s.q.ListCustomKeys(ctx, domainID)
|
||||||
|
}
|
||||||
|
|
||||||
// GetDomainStatus returns the last known check status for a domain (Фаза 3
|
// GetDomainStatus returns the last known check status for a domain (Фаза 3
|
||||||
// scheduler/checker). Callers scope access to the domain themselves (e.g.
|
// scheduler/checker). Callers scope access to the domain themselves (e.g.
|
||||||
// via a prior GetDomain) — this lookup is by primary key alone.
|
// via a prior GetDomain) — this lookup is by primary key alone.
|
||||||
|
|||||||
@@ -201,4 +201,30 @@ describe("api client", () => {
|
|||||||
expect.objectContaining({ method: "GET", credentials: "include" }),
|
expect.objectContaining({ method: "GET", credentials: "include" }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Empty-body 2xx responses: the real backend answers customs endpoints with
|
||||||
|
// no body at all (201 on add, 204 on remove). `res.json()` throws
|
||||||
|
// SyntaxError on an empty body, so `req()` must resolve instead of parsing
|
||||||
|
// when there is nothing to parse. Uses a fetch stub with no `json` method,
|
||||||
|
// mirroring a real empty-body Response, so an accidental `.json()` call
|
||||||
|
// fails the test instead of silently succeeding.
|
||||||
|
describe("empty-body 2xx responses", () => {
|
||||||
|
function mockEmptyBodyFetch(status: number) {
|
||||||
|
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status,
|
||||||
|
text: async () => "",
|
||||||
|
} as unknown as Response)
|
||||||
|
}
|
||||||
|
|
||||||
|
it("addCustom resolves on 201 with an empty body", async () => {
|
||||||
|
mockEmptyBodyFetch(201)
|
||||||
|
await expect(api.addCustom(PROJECT_ID, "d1", "A a.")).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removeCustom resolves on 204 with an empty body", async () => {
|
||||||
|
mockEmptyBodyFetch(204)
|
||||||
|
await expect(api.removeCustom(PROJECT_ID, "d1", "A a.")).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+21
-5
@@ -2,7 +2,7 @@ import { API_ROOT } from "@/lib/config"
|
|||||||
import type {
|
import type {
|
||||||
AuthState,
|
AuthState,
|
||||||
Account, CreateAccountInput, Template, CreateTemplateInput,
|
Account, CreateAccountInput, Template, CreateTemplateInput,
|
||||||
Domain, CreateDomainInput, ChangesetResponse, ApplyRequest,
|
Domain, CreateDomainInput, ImportResult, ChangesetResponse, ApplyRequest,
|
||||||
Schedule, Channel, CreateChannelInput, CheckRun, RecordDTO,
|
Schedule, Channel, CreateChannelInput, CheckRun, RecordDTO,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
@@ -20,14 +20,20 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
credentials: "include",
|
credentials: "include",
|
||||||
...init,
|
...init,
|
||||||
})
|
})
|
||||||
|
// Body can only be consumed once — read it as text up front, then parse
|
||||||
|
// (or not) from that string. Some responses (e.g. 201/204 on the customs
|
||||||
|
// endpoints) legitimately carry no body at all.
|
||||||
|
const raw = await res.text()
|
||||||
if (res.status === 401) throw new UnauthorizedError()
|
if (res.status === 401) throw new UnauthorizedError()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = `HTTP ${res.status}`
|
let msg = `HTTP ${res.status}`
|
||||||
try { const b = await res.json(); if (b?.error) msg = String(b.error) } catch { /* ignore */ }
|
if (raw) {
|
||||||
|
try { const b = JSON.parse(raw); if (b?.error) msg = String(b.error) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
throw new Error(msg)
|
throw new Error(msg)
|
||||||
}
|
}
|
||||||
if (res.status === 204) return undefined as T
|
if (!raw) return undefined as T
|
||||||
return (await res.json()) as T
|
return JSON.parse(raw) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectPath(projectId: string, path: string): string {
|
function projectPath(projectId: string, path: string): string {
|
||||||
@@ -70,7 +76,7 @@ export const api = {
|
|||||||
deleteDomain: (projectId: string, id: string) =>
|
deleteDomain: (projectId: string, id: string) =>
|
||||||
req<void>(projectPath(projectId, `/domains/${id}`), { method: "DELETE" }),
|
req<void>(projectPath(projectId, `/domains/${id}`), { method: "DELETE" }),
|
||||||
importZones: (projectId: string, accountId: string) =>
|
importZones: (projectId: string, accountId: string) =>
|
||||||
req<Domain[]>(projectPath(projectId, `/accounts/${accountId}/import`), { method: "POST" }),
|
req<ImportResult>(projectPath(projectId, `/accounts/${accountId}/import`), { method: "POST" }),
|
||||||
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 }) }),
|
||||||
|
|
||||||
@@ -83,6 +89,16 @@ export const api = {
|
|||||||
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) =>
|
||||||
req<ChangesetResponse>(projectPath(projectId, `/domains/${id}/apply`), { method: "POST", body: JSON.stringify(body) }),
|
req<ChangesetResponse>(projectPath(projectId, `/domains/${id}/apply`), { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
addCustom: (projectId: string, id: string, key: string) =>
|
||||||
|
req<void>(projectPath(projectId, `/domains/${id}/customs`), {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ key }),
|
||||||
|
}),
|
||||||
|
removeCustom: (projectId: string, id: string, key: string) =>
|
||||||
|
req<void>(
|
||||||
|
projectPath(projectId, `/domains/${id}/customs?key=${encodeURIComponent(key)}`),
|
||||||
|
{ method: "DELETE" },
|
||||||
|
),
|
||||||
domainHistory: (projectId: string, id: string) =>
|
domainHistory: (projectId: string, id: string) =>
|
||||||
req<CheckRun[]>(projectPath(projectId, `/domains/${id}/history`)),
|
req<CheckRun[]>(projectPath(projectId, `/domains/${id}/history`)),
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface CreateDomainInput {
|
|||||||
zoneId: string
|
zoneId: string
|
||||||
templateId?: string | null
|
templateId?: string | null
|
||||||
}
|
}
|
||||||
|
export interface ImportResult { created: Domain[]; removed: Domain[] }
|
||||||
|
|
||||||
export interface Schedule { intervalSeconds: number; enabled: boolean }
|
export interface Schedule { intervalSeconds: number; enabled: boolean }
|
||||||
|
|
||||||
@@ -45,10 +46,12 @@ export interface RecordView {
|
|||||||
desired?: string[]
|
desired?: string[]
|
||||||
actual?: string[]
|
actual?: string[]
|
||||||
readOnly: boolean
|
readOnly: boolean
|
||||||
|
custom: boolean // помечена оператором как живущая вне шаблона
|
||||||
}
|
}
|
||||||
export interface ChangesetResponse {
|
export interface ChangesetResponse {
|
||||||
updates: RecordView[]
|
updates: RecordView[]
|
||||||
prunes: RecordView[]
|
prunes: RecordView[]
|
||||||
|
customs: RecordView[]
|
||||||
readOnly: RecordView[]
|
readOnly: RecordView[]
|
||||||
inSyncCount: number
|
inSyncCount: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { DiffView } from "./DiffView"
|
|||||||
import type { ChangesetResponse } from "@/api/types"
|
import type { ChangesetResponse } from "@/api/types"
|
||||||
|
|
||||||
const cs: ChangesetResponse = {
|
const cs: ChangesetResponse = {
|
||||||
updates: [{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false }],
|
updates: [{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false, custom: false }],
|
||||||
prunes: [{ key: "A old.example.com.", kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false }],
|
prunes: [{ key: "A old.example.com.", kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false, custom: false }],
|
||||||
readOnly: [{ key: "NS example.com.", kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true }],
|
customs: [],
|
||||||
|
readOnly: [{ key: "NS example.com.", kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true, custom: false }],
|
||||||
inSyncCount: 3,
|
inSyncCount: 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +61,11 @@ test("renders a very long unbreakable value (DKIM key) without crashing", () =>
|
|||||||
desired: [longValue],
|
desired: [longValue],
|
||||||
actual: [],
|
actual: [],
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
|
custom: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
prunes: [],
|
prunes: [],
|
||||||
|
customs: [],
|
||||||
readOnly: [],
|
readOnly: [],
|
||||||
inSyncCount: 0,
|
inSyncCount: 0,
|
||||||
}
|
}
|
||||||
@@ -135,11 +138,12 @@ test("select-all header checkbox calls onToggleAllUpdates(true) when clicked whi
|
|||||||
test("select-all header checkbox is indeterminate when only some update rows are selected", () => {
|
test("select-all header checkbox is indeterminate when only some update rows are selected", () => {
|
||||||
const csWithMultipleUpdates: ChangesetResponse = {
|
const csWithMultipleUpdates: ChangesetResponse = {
|
||||||
updates: [
|
updates: [
|
||||||
{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false },
|
{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false, custom: false },
|
||||||
{ key: "A api.example.com.", kind: "update", type: "A", name: "api.example.com.", desired: ["1.1.1.2"], actual: ["9.9.9.8"], readOnly: false },
|
{ key: "A api.example.com.", kind: "update", type: "A", name: "api.example.com.", desired: ["1.1.1.2"], actual: ["9.9.9.8"], readOnly: false, custom: false },
|
||||||
{ key: "A cdn.example.com.", kind: "update", type: "A", name: "cdn.example.com.", desired: ["1.1.1.3"], actual: ["9.9.9.7"], readOnly: false },
|
{ key: "A cdn.example.com.", kind: "update", type: "A", name: "cdn.example.com.", desired: ["1.1.1.3"], actual: ["9.9.9.7"], readOnly: false, custom: false },
|
||||||
],
|
],
|
||||||
prunes: [],
|
prunes: [],
|
||||||
|
customs: [],
|
||||||
readOnly: [],
|
readOnly: [],
|
||||||
inSyncCount: 0,
|
inSyncCount: 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { ArrowRight, CircleCheck, Lock, Pencil, Trash2 } from "lucide-react"
|
import { ArrowRight, BookmarkCheck, CircleCheck, Lock, Pencil, Trash2, Undo2 } from "lucide-react"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { ChangesetResponse, RecordView } from "@/api/types"
|
import type { ChangesetResponse, RecordView } from "@/api/types"
|
||||||
|
|
||||||
type Tone = "update" | "delete" | "readonly"
|
type Tone = "update" | "delete" | "custom" | "readonly"
|
||||||
|
|
||||||
const TONE_META: Record<
|
const TONE_META: Record<
|
||||||
Tone,
|
Tone,
|
||||||
@@ -25,6 +26,13 @@ const TONE_META: Record<
|
|||||||
dot: "var(--diff-delete)",
|
dot: "var(--diff-delete)",
|
||||||
ring: "ring-[color-mix(in_oklch,var(--diff-delete),transparent_78%)]",
|
ring: "ring-[color-mix(in_oklch,var(--diff-delete),transparent_78%)]",
|
||||||
},
|
},
|
||||||
|
custom: {
|
||||||
|
label: "Customs",
|
||||||
|
empty: "Нет записей, помеченных как осознанные.",
|
||||||
|
icon: BookmarkCheck,
|
||||||
|
dot: "var(--diff-custom)",
|
||||||
|
ring: "ring-[color-mix(in_oklch,var(--diff-custom),transparent_80%)]",
|
||||||
|
},
|
||||||
readonly: {
|
readonly: {
|
||||||
label: "Read-only",
|
label: "Read-only",
|
||||||
empty: "Нет read-only записей.",
|
empty: "Нет read-only записей.",
|
||||||
@@ -46,11 +54,13 @@ function RecordRow({
|
|||||||
tone,
|
tone,
|
||||||
checked,
|
checked,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
action,
|
||||||
}: {
|
}: {
|
||||||
record: RecordView
|
record: RecordView
|
||||||
tone: Tone
|
tone: Tone
|
||||||
checked?: boolean
|
checked?: boolean
|
||||||
onToggle?: (key: string) => void
|
onToggle?: (key: string) => void
|
||||||
|
action?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const meta = TONE_META[tone]
|
const meta = TONE_META[tone]
|
||||||
const showArrow = tone !== "delete"
|
const showArrow = tone !== "delete"
|
||||||
@@ -99,6 +109,8 @@ function RecordRow({
|
|||||||
read-only
|
read-only
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{action}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Values line: plain block-level text (not flex) so a long
|
{/* Values line: plain block-level text (not flex) so a long
|
||||||
@@ -137,18 +149,21 @@ function Section({
|
|||||||
selected,
|
selected,
|
||||||
onToggle,
|
onToggle,
|
||||||
onToggleAll,
|
onToggleAll,
|
||||||
|
renderAction,
|
||||||
}: {
|
}: {
|
||||||
tone: Tone
|
tone: Tone
|
||||||
records: RecordView[]
|
records: RecordView[]
|
||||||
selected?: Set<string>
|
selected?: Set<string>
|
||||||
onToggle?: (key: string) => void
|
onToggle?: (key: string) => void
|
||||||
onToggleAll?: (checked: boolean) => void
|
onToggleAll?: (checked: boolean) => void
|
||||||
|
renderAction?: (record: RecordView) => ReactNode
|
||||||
}) {
|
}) {
|
||||||
const meta = TONE_META[tone]
|
const meta = TONE_META[tone]
|
||||||
const Icon = meta.icon
|
const Icon = meta.icon
|
||||||
// Read-only (NS/SOA) records are never selectable — only update/delete
|
// Read-only (NS/SOA) and custom records are never selectable — only
|
||||||
// sections receive selection props from DiffView.
|
// update/delete sections receive selection props from DiffView.
|
||||||
const selectable = tone !== "readonly" && !!selected && !!onToggle && !!onToggleAll
|
const selectable =
|
||||||
|
tone !== "readonly" && tone !== "custom" && !!selected && !!onToggle && !!onToggleAll
|
||||||
const allSelected = selectable && records.length > 0 && records.every((r) => selected!.has(r.key))
|
const allSelected = selectable && records.length > 0 && records.every((r) => selected!.has(r.key))
|
||||||
const someSelected = selectable && records.some((r) => selected!.has(r.key))
|
const someSelected = selectable && records.some((r) => selected!.has(r.key))
|
||||||
const indeterminate = someSelected && !allSelected
|
const indeterminate = someSelected && !allSelected
|
||||||
@@ -191,6 +206,7 @@ function Section({
|
|||||||
tone={tone}
|
tone={tone}
|
||||||
checked={selectable ? selected!.has(record.key) : undefined}
|
checked={selectable ? selected!.has(record.key) : undefined}
|
||||||
onToggle={selectable ? onToggle : undefined}
|
onToggle={selectable ? onToggle : undefined}
|
||||||
|
action={renderAction?.(record)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -207,6 +223,8 @@ export function DiffView({
|
|||||||
onTogglePrune,
|
onTogglePrune,
|
||||||
onToggleAllUpdates,
|
onToggleAllUpdates,
|
||||||
onToggleAllPrunes,
|
onToggleAllPrunes,
|
||||||
|
onMarkCustom,
|
||||||
|
onUnmarkCustom,
|
||||||
footerExtra,
|
footerExtra,
|
||||||
}: {
|
}: {
|
||||||
changeset: ChangesetResponse
|
changeset: ChangesetResponse
|
||||||
@@ -216,6 +234,8 @@ export function DiffView({
|
|||||||
onTogglePrune: (key: string) => void
|
onTogglePrune: (key: string) => void
|
||||||
onToggleAllUpdates: (checked: boolean) => void
|
onToggleAllUpdates: (checked: boolean) => void
|
||||||
onToggleAllPrunes: (checked: boolean) => void
|
onToggleAllPrunes: (checked: boolean) => void
|
||||||
|
onMarkCustom?: (key: string) => void
|
||||||
|
onUnmarkCustom?: (key: string) => void
|
||||||
footerExtra?: ReactNode
|
footerExtra?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
// Defensive: a field may arrive as null (e.g. a nil slice from an older
|
// Defensive: a field may arrive as null (e.g. a nil slice from an older
|
||||||
@@ -235,6 +255,36 @@ export function DiffView({
|
|||||||
selected={selectedPrunes}
|
selected={selectedPrunes}
|
||||||
onToggle={onTogglePrune}
|
onToggle={onTogglePrune}
|
||||||
onToggleAll={onToggleAllPrunes}
|
onToggleAll={onToggleAllPrunes}
|
||||||
|
renderAction={
|
||||||
|
onMarkCustom &&
|
||||||
|
((record) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`В customs ${record.type} ${record.name}`}
|
||||||
|
onClick={() => onMarkCustom(record.key)}
|
||||||
|
>
|
||||||
|
<BookmarkCheck className="size-3.5" strokeWidth={1.75} />
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Section
|
||||||
|
tone="custom"
|
||||||
|
records={changeset.customs ?? []}
|
||||||
|
renderAction={
|
||||||
|
onUnmarkCustom &&
|
||||||
|
((record) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Вернуть в дифф ${record.type} ${record.name}`}
|
||||||
|
onClick={() => onUnmarkCustom(record.key)}
|
||||||
|
>
|
||||||
|
<Undo2 className="size-3.5" strokeWidth={1.75} />
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Section tone="readonly" records={changeset.readOnly ?? []} />
|
<Section tone="readonly" records={changeset.readOnly ?? []} />
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,36 @@ export function useApplyDomain(id: string) {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["check", project?.id, id] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["check", project?.id, id] }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
export function useAddCustom(id: string) {
|
||||||
|
const { project } = useAuth()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (key: string) => {
|
||||||
|
const pid = requireProjectId(project)
|
||||||
|
return api.addCustom(pid, id, key)
|
||||||
|
},
|
||||||
|
// Дифф пересчитывается на бэкенде — инвалидируем чек, а заодно статус
|
||||||
|
// домена в списке (custom-запись перестаёт быть дрифтом).
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["check", project?.id, id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ["domains", project?.id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function useRemoveCustom(id: string) {
|
||||||
|
const { project } = useAuth()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (key: string) => {
|
||||||
|
const pid = requireProjectId(project)
|
||||||
|
return api.removeCustom(pid, id, key)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ["check", project?.id, id] })
|
||||||
|
qc.invalidateQueries({ queryKey: ["domains", project?.id] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
export function useDomainHistory(id: string) {
|
export function useDomainHistory(id: string) {
|
||||||
const { project } = useAuth()
|
const { project } = useAuth()
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
--color-diff-delete: var(--diff-delete);
|
--color-diff-delete: var(--diff-delete);
|
||||||
--color-diff-insync: var(--diff-insync);
|
--color-diff-insync: var(--diff-insync);
|
||||||
--color-diff-readonly: var(--diff-readonly);
|
--color-diff-readonly: var(--diff-readonly);
|
||||||
|
--color-diff-custom: var(--diff-custom);
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -96,6 +97,7 @@
|
|||||||
--diff-delete: oklch(0.68 0.19 20); /* rose */
|
--diff-delete: oklch(0.68 0.19 20); /* rose */
|
||||||
--diff-insync: oklch(0.55 0.02 260); /* muted */
|
--diff-insync: oklch(0.55 0.02 260); /* muted */
|
||||||
--diff-readonly: oklch(0.5 0.02 260); /* dimmed */
|
--diff-readonly: oklch(0.5 0.02 260); /* dimmed */
|
||||||
|
--diff-custom: oklch(0.62 0.11 250); /* muted blue */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* "Refined technical console" — dark by default (html.dark). Cool slate
|
/* "Refined technical console" — dark by default (html.dark). Cool slate
|
||||||
@@ -139,6 +141,7 @@
|
|||||||
--diff-delete: oklch(0.68 0.19 20);
|
--diff-delete: oklch(0.68 0.19 20);
|
||||||
--diff-insync: oklch(0.55 0.02 258);
|
--diff-insync: oklch(0.55 0.02 258);
|
||||||
--diff-readonly: oklch(0.42 0.014 258);
|
--diff-readonly: oklch(0.42 0.014 258);
|
||||||
|
--diff-custom: oklch(0.62 0.11 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react"
|
import { render, screen, waitFor, within } from "@testing-library/react"
|
||||||
import userEvent from "@testing-library/user-event"
|
import userEvent from "@testing-library/user-event"
|
||||||
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
@@ -43,11 +43,12 @@ beforeEach(() => {
|
|||||||
|
|
||||||
test("default selection: updates checked, prunes unchecked; apply sends only selected keys", async () => {
|
test("default selection: updates checked, prunes unchecked; apply sends only selected keys", async () => {
|
||||||
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
||||||
updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
|
updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false, custom: false }],
|
||||||
prunes: [{ key: "A b.", kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }],
|
prunes: [{ key: "A b.", kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false, custom: false }],
|
||||||
|
customs: [],
|
||||||
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: [], customs: [], readOnly: [], inSyncCount: 0 })
|
||||||
const zoneRecordsSpy = vi.spyOn(api, "zoneRecords")
|
const zoneRecordsSpy = vi.spyOn(api, "zoneRecords")
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
renderPage()
|
||||||
@@ -78,11 +79,12 @@ test("default selection: updates checked, prunes unchecked; apply sends only sel
|
|||||||
|
|
||||||
test("deselecting all records disables Apply", async () => {
|
test("deselecting all records disables Apply", async () => {
|
||||||
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
||||||
updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
|
updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false, custom: false }],
|
||||||
prunes: [],
|
prunes: [],
|
||||||
|
customs: [],
|
||||||
readOnly: [], inSyncCount: 0,
|
readOnly: [], inSyncCount: 0,
|
||||||
})
|
})
|
||||||
vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], customs: [], readOnly: [], inSyncCount: 0 })
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
renderPage()
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ test("пока список доменов грузится — показан
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const checkSpy = vi.spyOn(api, "checkDomain").mockResolvedValue({
|
const checkSpy = vi.spyOn(api, "checkDomain").mockResolvedValue({
|
||||||
updates: [], prunes: [], readOnly: [], inSyncCount: 0,
|
updates: [], prunes: [], customs: [], readOnly: [], inSyncCount: 0,
|
||||||
})
|
})
|
||||||
const zoneRecordsSpy = vi.spyOn(api, "zoneRecords")
|
const zoneRecordsSpy = vi.spyOn(api, "zoneRecords")
|
||||||
renderPage()
|
renderPage()
|
||||||
@@ -171,3 +173,61 @@ test("создание шаблона из зоны вызывает templateFro
|
|||||||
|
|
||||||
await waitFor(() => expect(templateFromZoneSpy).toHaveBeenCalledWith(PROJECT_ID, "d1"))
|
await waitFor(() => expect(templateFromZoneSpy).toHaveBeenCalledWith(PROJECT_ID, "d1"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("кнопка «В customs» на строке prune вызывает api.addCustom с ключом записи", async () => {
|
||||||
|
const addSpy = vi.spyOn(api, "addCustom").mockResolvedValue(undefined)
|
||||||
|
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
||||||
|
updates: [],
|
||||||
|
prunes: [
|
||||||
|
{ key: "CNAME admin.example.com.", kind: "delete", type: "CNAME", name: "admin.example.com.", actual: ["example.com."], readOnly: false, custom: false },
|
||||||
|
],
|
||||||
|
customs: [],
|
||||||
|
readOnly: [],
|
||||||
|
inSyncCount: 0,
|
||||||
|
})
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("button", { name: /в customs cname admin\.example\.com\./i }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(addSpy).toHaveBeenCalledWith(PROJECT_ID, "d1", "CNAME admin.example.com."),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("кнопка «Вернуть в дифф» на строке custom вызывает api.removeCustom", async () => {
|
||||||
|
const removeSpy = vi.spyOn(api, "removeCustom").mockResolvedValue(undefined)
|
||||||
|
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
||||||
|
updates: [],
|
||||||
|
prunes: [],
|
||||||
|
customs: [
|
||||||
|
{ key: "CNAME admin.example.com.", kind: "delete", type: "CNAME", name: "admin.example.com.", actual: ["example.com."], readOnly: false, custom: true },
|
||||||
|
],
|
||||||
|
readOnly: [],
|
||||||
|
inSyncCount: 0,
|
||||||
|
})
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("button", { name: /вернуть в дифф cname admin\.example\.com\./i }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(removeSpy).toHaveBeenCalledWith(PROJECT_ID, "d1", "CNAME admin.example.com."),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("в секции customs нет чекбоксов — такие записи не применяются", async () => {
|
||||||
|
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
||||||
|
updates: [],
|
||||||
|
prunes: [],
|
||||||
|
customs: [
|
||||||
|
{ key: "CNAME admin.example.com.", kind: "delete", type: "CNAME", name: "admin.example.com.", actual: ["example.com."], readOnly: false, custom: true },
|
||||||
|
],
|
||||||
|
readOnly: [],
|
||||||
|
inSyncCount: 0,
|
||||||
|
})
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
const section = await screen.findByRole("region", { name: /customs/i })
|
||||||
|
expect(within(section).queryByRole("checkbox")).toBeNull()
|
||||||
|
})
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import {
|
import {
|
||||||
|
useAddCustom,
|
||||||
useApplyDomain,
|
useApplyDomain,
|
||||||
useCheckDomain,
|
useCheckDomain,
|
||||||
useCreateTemplateFromZone,
|
useCreateTemplateFromZone,
|
||||||
useDomains,
|
useDomains,
|
||||||
|
useRemoveCustom,
|
||||||
useZoneRecords,
|
useZoneRecords,
|
||||||
} from "@/hooks/useApi"
|
} from "@/hooks/useApi"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -29,6 +31,8 @@ export function DomainDiffPage() {
|
|||||||
|
|
||||||
const check = useCheckDomain(id, hasTemplate)
|
const check = useCheckDomain(id, hasTemplate)
|
||||||
const apply = useApplyDomain(id)
|
const apply = useApplyDomain(id)
|
||||||
|
const addCustom = useAddCustom(id)
|
||||||
|
const removeCustom = useRemoveCustom(id)
|
||||||
// Пока список доменов не загружен ИЛИ загрузка упала ошибкой, hasTemplate
|
// Пока список доменов не загружен ИЛИ загрузка упала ошибкой, hasTemplate
|
||||||
// недостоверно (false по умолчанию из-за domain === undefined) — не
|
// недостоверно (false по умолчанию из-за domain === undefined) — не
|
||||||
// дёргаем provider-запрос записей зоны, пока не будет точно известно
|
// дёргаем provider-запрос записей зоны, пока не будет точно известно
|
||||||
@@ -234,6 +238,8 @@ export function DomainDiffPage() {
|
|||||||
onTogglePrune={togglePrune}
|
onTogglePrune={togglePrune}
|
||||||
onToggleAllUpdates={toggleAllUpdates}
|
onToggleAllUpdates={toggleAllUpdates}
|
||||||
onToggleAllPrunes={toggleAllPrunes}
|
onToggleAllPrunes={toggleAllPrunes}
|
||||||
|
onMarkCustom={(key) => addCustom.mutate(key)}
|
||||||
|
onUnmarkCustom={(key) => removeCustom.mutate(key)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4">
|
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4">
|
||||||
@@ -275,6 +281,12 @@ export function DomainDiffPage() {
|
|||||||
Apply
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(addCustom.isError || removeCustom.isError) && (
|
||||||
|
<span role="alert" className="font-dns text-xs text-destructive">
|
||||||
|
{(addCustom.error ?? removeCustom.error)?.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ test("отрисовывает домены и ссылку на diff-стран
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("кнопка импорта вызывает api.importZones с выбранной учёткой", async () => {
|
test("кнопка импорта вызывает api.importZones с выбранной учёткой", async () => {
|
||||||
const importSpy = vi.spyOn(api, "importZones").mockResolvedValue([])
|
const importSpy = vi.spyOn(api, "importZones").mockResolvedValue({ created: [], removed: [] })
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
renderPage()
|
||||||
|
|
||||||
@@ -76,6 +76,25 @@ test("кнопка импорта вызывает api.importZones с выбра
|
|||||||
await waitFor(() => expect(importSpy).toHaveBeenCalledWith(PROJECT_ID, "acc2"))
|
await waitFor(() => expect(importSpy).toHaveBeenCalledWith(PROJECT_ID, "acc2"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("после импорта показывает, сколько зон создано и сколько удалено", async () => {
|
||||||
|
vi.spyOn(api, "importZones").mockResolvedValue({
|
||||||
|
created: [
|
||||||
|
{ id: "d1", providerAccountId: "a1", zoneName: "new.example.com.", zoneId: "z1" },
|
||||||
|
],
|
||||||
|
removed: [
|
||||||
|
{ id: "d2", providerAccountId: "a1", zoneName: "gone.example.com.", zoneId: "z9" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await screen.findByText("example.com.")
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole("button", { name: /импортировать зоны/i }))
|
||||||
|
|
||||||
|
expect(await screen.findByText(/создано 1, удалено 1/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
test("привязка шаблона в строке домена вызывает api.setDomainTemplate", async () => {
|
test("привязка шаблона в строке домена вызывает api.setDomainTemplate", async () => {
|
||||||
const setTemplateSpy = vi.spyOn(api, "setDomainTemplate").mockResolvedValue(domains[0])
|
const setTemplateSpy = vi.spyOn(api, "setDomainTemplate").mockResolvedValue(domains[0])
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ export function DomainsPage() {
|
|||||||
{importZones.isError && (
|
{importZones.isError && (
|
||||||
<span className="font-dns text-xs text-destructive">{importZones.error.message}</span>
|
<span className="font-dns text-xs text-destructive">{importZones.error.message}</span>
|
||||||
)}
|
)}
|
||||||
|
{importZones.isSuccess && (
|
||||||
|
<span className="font-dns text-xs text-muted-foreground">
|
||||||
|
Создано {importZones.data.created.length}, удалено {importZones.data.removed.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{setTemplate.isError && (
|
{setTemplate.isError && (
|
||||||
|
|||||||
Reference in New Issue
Block a user