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:
2026-07-05 12:00:27 +07:00
parent 1540140542
commit 9ccb304d2e
9 changed files with 294 additions and 1 deletions
+106
View File
@@ -98,6 +98,15 @@ func (m *mockTenantStore) ListDomains(context.Context, uuid.UUID) ([]store.Domai
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) 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)
}
}
// --- 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())
}
}