feat(api): read zone records without template + snapshot-to-template
LoadDomain requires a template, so a zone without one could never be
viewed or snapshotted. Adds a template-free path: store.LoadZone /
service.ZoneRef / DomainService.ZoneRecords read a zone's live records
straight from the provider (no diff, no template). GET
/domains/{did}/records exposes read-only viewing; POST
/domains/{did}/template-from-zone snapshots only managed record types
(NS/SOA excluded) into a new template and auto-attaches it to the domain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/service"
|
"github.com/vasyakrg/dns-autoresolver/internal/service"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/store"
|
"github.com/vasyakrg/dns-autoresolver/internal/store"
|
||||||
@@ -20,6 +21,10 @@ import (
|
|||||||
type CheckApplier interface {
|
type CheckApplier interface {
|
||||||
Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error)
|
Check(ctx context.Context, projectID, domainID uuid.UUID) (diff.Changeset, error)
|
||||||
Apply(ctx context.Context, projectID, domainID uuid.UUID, req service.ApplyRequest) (diff.Changeset, error)
|
Apply(ctx context.Context, projectID, domainID uuid.UUID, req service.ApplyRequest) (diff.Changeset, error)
|
||||||
|
// ZoneRecords reads a zone's current records straight from the provider,
|
||||||
|
// with no diff and no template required — backs read-only zone viewing
|
||||||
|
// and the template-from-zone snapshot.
|
||||||
|
ZoneRecords(ctx context.Context, projectID, domainID uuid.UUID) ([]model.Record, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TenantStore is the narrow persistence surface the CRUD handlers depend on.
|
// TenantStore is the narrow persistence surface the CRUD handlers depend on.
|
||||||
@@ -39,6 +44,9 @@ type TenantStore interface {
|
|||||||
|
|
||||||
CreateDomain(ctx context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (store.Domain, error)
|
CreateDomain(ctx context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (store.Domain, error)
|
||||||
ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error)
|
ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error)
|
||||||
|
// GetDomain is used by the template-from-zone snapshot to read the
|
||||||
|
// domain's zone name (for the generated template's name) before creating it.
|
||||||
|
GetDomain(ctx context.Context, id, projectID uuid.UUID) (store.Domain, error)
|
||||||
DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error
|
DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error
|
||||||
ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error)
|
ImportDomains(ctx context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error)
|
||||||
SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error)
|
SetDomainTemplate(ctx context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error)
|
||||||
@@ -117,6 +125,8 @@ func NewRouter(a *API) http.Handler {
|
|||||||
r.Patch("/", a.handleSetDomainTemplate)
|
r.Patch("/", a.handleSetDomainTemplate)
|
||||||
r.Delete("/", a.handleDeleteDomain)
|
r.Delete("/", a.handleDeleteDomain)
|
||||||
r.Get("/history", a.handleDomainHistory)
|
r.Get("/history", a.handleDomainHistory)
|
||||||
|
r.Get("/records", a.handleZoneRecords)
|
||||||
|
r.Post("/template-from-zone", a.handleTemplateFromZone)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import (
|
|||||||
|
|
||||||
type mockCheckApplier struct {
|
type mockCheckApplier struct {
|
||||||
lastReq service.ApplyRequest
|
lastReq service.ApplyRequest
|
||||||
|
zoneRecords []model.Record
|
||||||
|
zoneErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockCheckApplier) Check(context.Context, uuid.UUID, uuid.UUID) (diff.Changeset, error) {
|
func (m *mockCheckApplier) Check(context.Context, uuid.UUID, uuid.UUID) (diff.Changeset, error) {
|
||||||
@@ -28,6 +30,12 @@ func (m *mockCheckApplier) Apply(_ context.Context, _, _ uuid.UUID, req service.
|
|||||||
m.lastReq = req
|
m.lastReq = req
|
||||||
return diff.Changeset{}, nil
|
return diff.Changeset{}, nil
|
||||||
}
|
}
|
||||||
|
func (m *mockCheckApplier) ZoneRecords(context.Context, uuid.UUID, uuid.UUID) ([]model.Record, error) {
|
||||||
|
if m.zoneErr != nil {
|
||||||
|
return nil, m.zoneErr
|
||||||
|
}
|
||||||
|
return m.zoneRecords, nil
|
||||||
|
}
|
||||||
|
|
||||||
// newTestAPI wires a fixed authenticated user who owns whatever project id
|
// newTestAPI wires a fixed authenticated user who owns whatever project id
|
||||||
// is requested (via alwaysOwnedAuthStore/alwaysValidSessions in
|
// is requested (via alwaysOwnedAuthStore/alwaysValidSessions in
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/service"
|
"github.com/vasyakrg/dns-autoresolver/internal/service"
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
@@ -70,3 +72,68 @@ func (a *API) handleApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, toChangesetResponse(cs))
|
writeJSON(w, http.StatusOK, toChangesetResponse(cs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleZoneRecords reads a zone's current records straight from the
|
||||||
|
// provider — no template required, no diff computed. Backs read-only zone
|
||||||
|
// viewing for domains that don't have a template attached (yet).
|
||||||
|
func (a *API) handleZoneRecords(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, _ := projectIDFrom(r.Context())
|
||||||
|
did, err := uuid.Parse(chi.URLParam(r, "did"))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid domain id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recs, err := a.Svc.ZoneRecords(r.Context(), pid, did)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("api: zone records failed: %v", err)
|
||||||
|
writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc := dto.FromModel(recs)
|
||||||
|
writeJSON(w, http.StatusOK, doc.Records) // []dto.RecordDTO
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTemplateFromZone snapshots a zone's current managed records (NS/SOA
|
||||||
|
// excluded — they're read-only, never part of a template) into a brand new
|
||||||
|
// template, then auto-attaches it to the domain so check/apply become
|
||||||
|
// available immediately.
|
||||||
|
func (a *API) handleTemplateFromZone(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pid, _ := projectIDFrom(r.Context())
|
||||||
|
did, err := uuid.Parse(chi.URLParam(r, "did"))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "invalid domain id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dom, err := a.Store.GetDomain(r.Context(), did, pid)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("api: template-from-zone: get domain failed: %v", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recs, err := a.Svc.ZoneRecords(r.Context(), pid, did)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("api: template-from-zone: zone records failed: %v", err)
|
||||||
|
writeErr(w, http.StatusBadGateway, "не удалось получить записи зоны у провайдера")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Snapshot only managed records — NS/SOA are read-only and never templated.
|
||||||
|
managed := make([]model.Record, 0, len(recs))
|
||||||
|
for _, rc := range recs {
|
||||||
|
if rc.Type.Managed() {
|
||||||
|
managed = append(managed, rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doc := dto.FromModel(managed)
|
||||||
|
tmpl, err := a.Store.CreateTemplate(r.Context(), pid, dom.ZoneName+" snapshot", doc)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("api: template-from-zone: create template failed: %v", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := a.Store.SetDomainTemplate(r.Context(), did, pid, &tmpl.ID); err != nil {
|
||||||
|
log.Printf("api: template-from-zone: attach template failed: %v", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, toTemplateResponse(tmpl))
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/service"
|
"github.com/vasyakrg/dns-autoresolver/internal/service"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/store"
|
"github.com/vasyakrg/dns-autoresolver/internal/store"
|
||||||
)
|
)
|
||||||
@@ -97,6 +98,9 @@ func (r *recordingCheckApplier) Apply(context.Context, uuid.UUID, uuid.UUID, ser
|
|||||||
r.applyCalled = true
|
r.applyCalled = true
|
||||||
return diff.Changeset{}, nil
|
return diff.Changeset{}, nil
|
||||||
}
|
}
|
||||||
|
func (r *recordingCheckApplier) ZoneRecords(context.Context, uuid.UUID, uuid.UUID) ([]model.Record, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- RequireAuth ---
|
// --- RequireAuth ---
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,15 @@ func (m *mockTenantStore) ListDomains(context.Context, uuid.UUID) ([]store.Domai
|
|||||||
return m.domains, nil
|
return m.domains, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockTenantStore) GetDomain(_ context.Context, id, _ uuid.UUID) (store.Domain, error) {
|
||||||
|
for _, d := range m.domains {
|
||||||
|
if d.ID == id {
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.Domain{}, errors.New("domain not found")
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockTenantStore) DeleteDomain(context.Context, uuid.UUID, uuid.UUID) error { return nil }
|
func (m *mockTenantStore) DeleteDomain(context.Context, uuid.UUID, uuid.UUID) error { return nil }
|
||||||
|
|
||||||
func (m *mockTenantStore) SetDomainTemplate(_ context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error) {
|
func (m *mockTenantStore) SetDomainTemplate(_ context.Context, domainID, projectID uuid.UUID, templateID *uuid.UUID) (store.Domain, error) {
|
||||||
@@ -599,3 +608,100 @@ func TestDeleteDomain_BadUUID(t *testing.T) {
|
|||||||
t.Fatalf("expected 400, got %d", w.Code)
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- zone view / template-from-zone (Task 1: no-template zone snapshot) ---
|
||||||
|
|
||||||
|
// TestZoneRecords_ReturnsProviderRecords covers the read-only zone-viewing
|
||||||
|
// endpoint: it returns whatever the service reads straight from the
|
||||||
|
// provider, with no template involved at all.
|
||||||
|
func TestZoneRecords_ReturnsProviderRecords(t *testing.T) {
|
||||||
|
a, ts := newTenantTestAPI()
|
||||||
|
domID := uuid.New()
|
||||||
|
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
|
||||||
|
a.Svc = &mockCheckApplier{zoneRecords: []model.Record{
|
||||||
|
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
|
||||||
|
}}
|
||||||
|
router := NewRouter(a)
|
||||||
|
|
||||||
|
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp []dto.RecordDTO
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(resp) != 1 || resp[0].Type != "A" {
|
||||||
|
t.Fatalf("unexpected records response: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches covers the
|
||||||
|
// snapshot-to-template flow: NS/SOA are read-only and must be excluded from
|
||||||
|
// the generated template, and the new template must be auto-attached to the
|
||||||
|
// domain (SetDomainTemplate) so check/apply become immediately available.
|
||||||
|
func TestTemplateFromZone_SnapshotsManagedRecordsOnlyAndAttaches(t *testing.T) {
|
||||||
|
a, ts := newTenantTestAPI()
|
||||||
|
domID := uuid.New()
|
||||||
|
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
|
||||||
|
a.Svc = &mockCheckApplier{zoneRecords: []model.Record{
|
||||||
|
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
|
||||||
|
{Type: model.TXT, Name: "a.example.com.", TTL: 300, Values: []string{"v=spf1 -all"}},
|
||||||
|
{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}},
|
||||||
|
{Type: model.SOA, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com. admin.example.com. 1 2 3 4 5"}},
|
||||||
|
}}
|
||||||
|
router := NewRouter(a)
|
||||||
|
|
||||||
|
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/template-from-zone", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if ts.createTemplate == nil {
|
||||||
|
t.Fatal("expected CreateTemplate to be called")
|
||||||
|
}
|
||||||
|
if len(ts.createTemplate.Doc.Records) != 2 {
|
||||||
|
t.Fatalf("expected only the 2 managed records (A+TXT) in the snapshot, got %+v", ts.createTemplate.Doc.Records)
|
||||||
|
}
|
||||||
|
for _, r := range ts.createTemplate.Doc.Records {
|
||||||
|
if r.Type == "NS" || r.Type == "SOA" {
|
||||||
|
t.Fatalf("read-only record type %s leaked into snapshot template", r.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SetDomainTemplate must have been called with the newly created template's id.
|
||||||
|
if ts.domains[0].TemplateID == nil || *ts.domains[0].TemplateID != ts.createTemplate.ID {
|
||||||
|
t.Fatalf("expected domain auto-attached to new template %s, got %+v", ts.createTemplate.ID, ts.domains[0].TemplateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp templateResponse
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.ID != ts.createTemplate.ID.String() || len(resp.Records) != 2 {
|
||||||
|
t.Fatalf("unexpected response: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestZoneRecords_ProviderErrorReturns502 covers the provider-failure path:
|
||||||
|
// a GetRecords error from the provider must surface as 502 (bad gateway),
|
||||||
|
// not a generic 500 or a hung response.
|
||||||
|
func TestZoneRecords_ProviderErrorReturns502(t *testing.T) {
|
||||||
|
a, ts := newTenantTestAPI()
|
||||||
|
domID := uuid.New()
|
||||||
|
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
|
||||||
|
a.Svc = &mockCheckApplier{zoneErr: errors.New("provider unreachable")}
|
||||||
|
router := NewRouter(a)
|
||||||
|
|
||||||
|
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/domains/"+domID.String()+"/records", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("expected 502, got %d body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/crypto"
|
"github.com/vasyakrg/dns-autoresolver/internal/crypto"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
"github.com/vasyakrg/dns-autoresolver/internal/diff"
|
||||||
|
"github.com/vasyakrg/dns-autoresolver/internal/model"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
"github.com/vasyakrg/dns-autoresolver/internal/provider"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/provider/registry"
|
"github.com/vasyakrg/dns-autoresolver/internal/provider/registry"
|
||||||
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
||||||
@@ -20,8 +21,17 @@ type DomainRef struct {
|
|||||||
Template dto.TemplateDoc
|
Template dto.TemplateDoc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ZoneRef is the provider-access subset of a domain, without a template —
|
||||||
|
// enough to read a zone's live records.
|
||||||
|
type ZoneRef struct {
|
||||||
|
ZoneID string
|
||||||
|
Provider string
|
||||||
|
SecretEnc string
|
||||||
|
}
|
||||||
|
|
||||||
type Loader interface {
|
type Loader interface {
|
||||||
LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (DomainRef, error)
|
LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (DomainRef, error)
|
||||||
|
LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (ZoneRef, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Recorder interface {
|
type Recorder interface {
|
||||||
@@ -81,6 +91,25 @@ func (s *DomainService) Check(ctx context.Context, projectID, domainID uuid.UUID
|
|||||||
return cs, nil
|
return cs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ZoneRecords reads a zone's current records straight from the provider,
|
||||||
|
// with no diff and no template required. Used for read-only zone viewing and
|
||||||
|
// as the source for a snapshot template.
|
||||||
|
func (s *DomainService) ZoneRecords(ctx context.Context, projectID, domainID uuid.UUID) ([]model.Record, error) {
|
||||||
|
ref, err := s.loader.LoadZone(ctx, projectID, domainID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := s.reg.ByName(ref.Provider)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
secret, err := s.cipher.Decrypt(ref.SecretEnc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p.GetRecords(ctx, provider.Credentials{Secret: string(secret)}, ref.ZoneID)
|
||||||
|
}
|
||||||
|
|
||||||
// Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes.
|
// Apply applies updates always (when ApplyUpdates) and prunes only when ApplyPrunes.
|
||||||
func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) {
|
func (s *DomainService) Apply(ctx context.Context, projectID, domainID uuid.UUID, req ApplyRequest) (diff.Changeset, error) {
|
||||||
p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID)
|
p, creds, ref, cs, err := s.resolve(ctx, projectID, domainID)
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ func (l fakeLoader) LoadDomain(context.Context, uuid.UUID, uuid.UUID) (DomainRef
|
|||||||
return l.ref, nil
|
return l.ref, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadZone mirrors LoadDomain's provider-access fields but — unlike
|
||||||
|
// LoadDomain — never errors on a missing template, matching the real
|
||||||
|
// store.LoadZone contract.
|
||||||
|
func (l fakeLoader) LoadZone(context.Context, uuid.UUID, uuid.UUID) (ZoneRef, error) {
|
||||||
|
return ZoneRef{ZoneID: l.ref.ZoneID, Provider: l.ref.Provider, SecretEnc: l.ref.SecretEnc}, nil
|
||||||
|
}
|
||||||
|
|
||||||
type nopRecorder struct{}
|
type nopRecorder struct{}
|
||||||
|
|
||||||
func (nopRecorder) SaveCheckRun(context.Context, uuid.UUID, diff.Changeset) error { return nil }
|
func (nopRecorder) SaveCheckRun(context.Context, uuid.UUID, diff.Changeset) error { return nil }
|
||||||
@@ -78,6 +85,24 @@ func TestCheckProducesDiff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestZoneRecordsReadsProviderDirectly covers the no-template zone-viewing
|
||||||
|
// path: ZoneRecords must return the provider's live records with no diff
|
||||||
|
// and no template involved (loader's Template field is left zero-valued).
|
||||||
|
func TestZoneRecordsReadsProviderDirectly(t *testing.T) {
|
||||||
|
actual := []model.Record{
|
||||||
|
{Type: model.A, Name: "a.example.com.", TTL: 300, Values: []string{"1.1.1.1"}},
|
||||||
|
{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}},
|
||||||
|
}
|
||||||
|
svc, _ := setup(t, actual, dto.TemplateDoc{})
|
||||||
|
recs, err := svc.ZoneRecords(context.Background(), uuid.New(), uuid.New())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(recs) != 2 {
|
||||||
|
t.Fatalf("expected 2 records straight from the provider, got %+v", recs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyRespectsPruneGuard(t *testing.T) {
|
func TestApplyRespectsPruneGuard(t *testing.T) {
|
||||||
// зона содержит лишнюю запись b (нет в шаблоне) → Prune-кандидат
|
// зона содержит лишнюю запись b (нет в шаблоне) → Prune-кандидат
|
||||||
actual := []model.Record{
|
actual := []model.Record{
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ func (s *Store) LoadDomain(ctx context.Context, projectID, domainID uuid.UUID) (
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadZone returns just the provider-access half of a domain (provider name,
|
||||||
|
// encrypted secret, zone id), WITHOUT requiring an attached template — so a
|
||||||
|
// zone's live records can be read for viewing/snapshot even when no template
|
||||||
|
// is set. Scoped by projectID (same IDOR closure as LoadDomain).
|
||||||
|
func (s *Store) LoadZone(ctx context.Context, projectID, domainID uuid.UUID) (service.ZoneRef, error) {
|
||||||
|
row, err := s.q.LoadDomainFull(ctx, db.LoadDomainFullParams{ID: domainID, ProjectID: projectID})
|
||||||
|
if err != nil {
|
||||||
|
return service.ZoneRef{}, err
|
||||||
|
}
|
||||||
|
return service.ZoneRef{ZoneID: row.ZoneID, Provider: row.Provider, SecretEnc: row.SecretEnc}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SaveCheckRun persists a summary of the changeset (counts of updates/prunes)
|
// SaveCheckRun persists a summary of the changeset (counts of updates/prunes)
|
||||||
// as a check_runs row.
|
// as a check_runs row.
|
||||||
func (s *Store) SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error {
|
func (s *Store) SaveCheckRun(ctx context.Context, domainID uuid.UUID, cs diff.Changeset) error {
|
||||||
|
|||||||
@@ -91,3 +91,35 @@ func TestLoadDomainNoTemplate(t *testing.T) {
|
|||||||
t.Fatal("expected error for domain without template, got nil")
|
t.Fatal("expected error for domain without template, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoadZoneNoTemplate covers the Task 1 gap: a domain with no attached
|
||||||
|
// template must still be resolvable to its provider-access ref (ZoneRef) so
|
||||||
|
// its live records can be read/snapshotted — unlike LoadDomain, LoadZone
|
||||||
|
// must NOT error out on a nil template doc.
|
||||||
|
func TestLoadZoneNoTemplate(t *testing.T) {
|
||||||
|
s, ctx := newStore(t)
|
||||||
|
|
||||||
|
acc, err := s.Queries().CreateAccount(ctx, db.CreateAccountParams{
|
||||||
|
ID: uuid.New(), ProjectID: defaultProject,
|
||||||
|
Provider: "selectel", SecretEnc: "enc-blob", Comment: "prod",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
domain, err := s.Queries().CreateDomain(ctx, db.CreateDomainParams{
|
||||||
|
ID: uuid.New(), ProjectID: defaultProject, ProviderAccountID: acc.ID,
|
||||||
|
ZoneName: "example.com", ZoneID: "zone-3", TemplateID: nil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err := s.LoadZone(ctx, defaultProject, domain.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected LoadZone to succeed without a template, got error: %v", err)
|
||||||
|
}
|
||||||
|
if ref.ZoneID != "zone-3" || ref.Provider != "selectel" || ref.SecretEnc != "enc-blob" {
|
||||||
|
t.Fatalf("zone ref mismatch: %+v", ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user