9ccb304d2e
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
708 lines
25 KiB
Go
708 lines
25 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"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/store"
|
|
"github.com/vasyakrg/dns-autoresolver/internal/store/dto"
|
|
)
|
|
|
|
const testPID = "00000000-0000-0000-0000-000000000002"
|
|
|
|
// --- mocks ---
|
|
|
|
type mockTenantStore struct {
|
|
accounts []store.Account
|
|
createAccounts []struct{ provider, secretEnc, comment string }
|
|
|
|
templates []store.Template
|
|
createTemplate *store.Template
|
|
|
|
domains []store.Domain
|
|
createDomains int
|
|
|
|
importDomains []store.Domain
|
|
importDomainsErr error
|
|
importCalled bool
|
|
|
|
setDomainTemplateErr error
|
|
}
|
|
|
|
func (m *mockTenantStore) CreateAccount(_ context.Context, projectID uuid.UUID, prov, secretEnc, comment string) (store.Account, error) {
|
|
m.createAccounts = append(m.createAccounts, struct{ provider, secretEnc, comment string }{prov, secretEnc, comment})
|
|
acc := store.Account{ID: uuid.New(), ProjectID: projectID, Provider: prov, SecretEnc: secretEnc, Comment: comment}
|
|
m.accounts = append(m.accounts, acc)
|
|
return acc, nil
|
|
}
|
|
|
|
func (m *mockTenantStore) ListAccounts(context.Context, uuid.UUID) ([]store.Account, error) {
|
|
return m.accounts, nil
|
|
}
|
|
|
|
func (m *mockTenantStore) GetAccount(_ context.Context, id, _ uuid.UUID) (store.Account, error) {
|
|
for _, a := range m.accounts {
|
|
if a.ID == id {
|
|
return a, nil
|
|
}
|
|
}
|
|
return store.Account{}, errors.New("account not found")
|
|
}
|
|
|
|
func (m *mockTenantStore) DeleteAccount(context.Context, uuid.UUID, uuid.UUID) error { return nil }
|
|
|
|
func (m *mockTenantStore) CreateTemplate(_ context.Context, projectID uuid.UUID, name string, doc dto.TemplateDoc) (store.Template, error) {
|
|
tpl := store.Template{ID: uuid.New(), ProjectID: projectID, Name: name, Doc: doc, Version: 1}
|
|
m.createTemplate = &tpl
|
|
m.templates = append(m.templates, tpl)
|
|
return tpl, nil
|
|
}
|
|
|
|
func (m *mockTenantStore) ListTemplates(context.Context, uuid.UUID) ([]store.Template, error) {
|
|
return m.templates, nil
|
|
}
|
|
|
|
func (m *mockTenantStore) UpdateTemplate(_ context.Context, id, projectID uuid.UUID, name string, doc dto.TemplateDoc) (store.Template, error) {
|
|
return store.Template{ID: id, ProjectID: projectID, Name: name, Doc: doc, Version: 2}, nil
|
|
}
|
|
|
|
func (m *mockTenantStore) DeleteTemplate(context.Context, uuid.UUID, uuid.UUID) error { return nil }
|
|
|
|
func (m *mockTenantStore) GetTemplate(_ context.Context, id, _ uuid.UUID) (store.Template, error) {
|
|
for _, t := range m.templates {
|
|
if t.ID == id {
|
|
return t, nil
|
|
}
|
|
}
|
|
return store.Template{}, errors.New("template not found")
|
|
}
|
|
|
|
func (m *mockTenantStore) CreateDomain(_ context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (store.Domain, error) {
|
|
m.createDomains++
|
|
d := store.Domain{ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: zoneName, ZoneID: zoneID, TemplateID: templateID}
|
|
m.domains = append(m.domains, d)
|
|
return d, nil
|
|
}
|
|
|
|
func (m *mockTenantStore) ListDomains(context.Context, uuid.UUID) ([]store.Domain, error) {
|
|
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) {
|
|
if m.setDomainTemplateErr != nil {
|
|
return store.Domain{}, m.setDomainTemplateErr
|
|
}
|
|
for i, d := range m.domains {
|
|
if d.ID == domainID {
|
|
m.domains[i].TemplateID = templateID
|
|
return m.domains[i], nil
|
|
}
|
|
}
|
|
d := store.Domain{ID: domainID, ProjectID: projectID, TemplateID: templateID}
|
|
m.domains = append(m.domains, d)
|
|
return d, nil
|
|
}
|
|
|
|
func (m *mockTenantStore) ImportDomains(_ context.Context, projectID, accountID uuid.UUID, zones []provider.Zone) ([]store.Domain, error) {
|
|
m.importCalled = true
|
|
if m.importDomainsErr != nil {
|
|
return nil, m.importDomainsErr
|
|
}
|
|
out := make([]store.Domain, 0, len(zones))
|
|
for _, z := range zones {
|
|
d := store.Domain{ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, ZoneName: z.Name, ZoneID: z.ID}
|
|
out = append(out, d)
|
|
}
|
|
m.domains = append(m.domains, out...)
|
|
m.importDomains = out
|
|
return out, nil
|
|
}
|
|
|
|
type mockCipher struct{}
|
|
|
|
func (mockCipher) Encrypt(plaintext []byte) (string, error) {
|
|
return "ENC(" + string(plaintext) + ")", nil
|
|
}
|
|
func (mockCipher) Decrypt(enc string) ([]byte, error) {
|
|
return []byte(strings.TrimSuffix(strings.TrimPrefix(enc, "ENC("), ")")), nil
|
|
}
|
|
|
|
type mockRegistry struct {
|
|
zones []provider.Zone
|
|
validateErr error
|
|
}
|
|
|
|
func (r *mockRegistry) ByName(name string) (provider.Provider, error) {
|
|
return &mockProvider{zones: r.zones, validateErr: r.validateErr}, nil
|
|
}
|
|
|
|
type mockProvider struct {
|
|
zones []provider.Zone
|
|
// validateErr, when set, makes Validate reject the credentials — lets
|
|
// tests exercise the 400-before-save path of handleCreateAccount.
|
|
validateErr error
|
|
}
|
|
|
|
func (mockProvider) Name() string { return "mock" }
|
|
func (p mockProvider) ListZones(context.Context, provider.Credentials) ([]provider.Zone, error) {
|
|
return p.zones, nil
|
|
}
|
|
func (mockProvider) GetRecords(context.Context, provider.Credentials, string) ([]model.Record, error) {
|
|
return nil, nil
|
|
}
|
|
func (mockProvider) ApplyChanges(context.Context, provider.Credentials, string, diff.Changeset) error {
|
|
return nil
|
|
}
|
|
func (p mockProvider) Validate(context.Context, provider.Credentials) error {
|
|
return p.validateErr
|
|
}
|
|
|
|
// newTenantTestAPI wires a fixed authenticated user who owns whatever
|
|
// project id is requested (alwaysOwnedAuthStore/alwaysValidSessions, see
|
|
// middleware_test.go) — these tests exercise CRUD behavior past the
|
|
// RequireAuth/RequireProjectAccess boundary, which has its own dedicated
|
|
// coverage in middleware_test.go.
|
|
func newTenantTestAPI() (*API, *mockTenantStore) {
|
|
ts := &mockTenantStore{}
|
|
a := &API{
|
|
Store: ts, Cipher: mockCipher{}, Reg: &mockRegistry{},
|
|
Auth: alwaysOwnedAuthStore(), Sessions: alwaysValidSessions(uuid.New()),
|
|
}
|
|
return a, ts
|
|
}
|
|
|
|
// --- accounts ---
|
|
|
|
// TestCreateAccount_ValidCredentials_EncryptsRawSecretAndCreates covers the
|
|
// happy path of the structured-secret contract: secret is a provider-specific
|
|
// JSON object, Validate accepts it, and the *raw* JSON (not a re-serialized
|
|
// or unwrapped form) is what gets encrypted and handed to the store.
|
|
func TestCreateAccount_ValidCredentials_EncryptsRawSecretAndCreates(t *testing.T) {
|
|
a, ts := newTenantTestAPI()
|
|
a.Reg = &mockRegistry{}
|
|
router := NewRouter(a)
|
|
|
|
body := `{"provider":"selectel","secret":{"username":"u","password":"super-secret-token","account_id":"123","project_name":"proj"},"comment":"prod"}`
|
|
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if strings.Contains(w.Body.String(), "super-secret-token") {
|
|
t.Fatalf("response leaks plaintext secret: %s", w.Body.String())
|
|
}
|
|
if len(ts.createAccounts) != 1 {
|
|
t.Fatalf("expected 1 CreateAccount call, got %d", len(ts.createAccounts))
|
|
}
|
|
got := ts.createAccounts[0].secretEnc
|
|
if got == `{"username":"u","password":"super-secret-token","account_id":"123","project_name":"proj"}` {
|
|
t.Fatalf("store received plaintext secret instead of encrypted value")
|
|
}
|
|
wantEnc := `ENC({"username":"u","password":"super-secret-token","account_id":"123","project_name":"proj"})`
|
|
if got != wantEnc {
|
|
t.Fatalf("unexpected encrypted secret stored: %q, want %q", got, wantEnc)
|
|
}
|
|
|
|
var resp accountResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.Provider != "selectel" || resp.Comment != "prod" {
|
|
t.Fatalf("unexpected response: %+v", resp)
|
|
}
|
|
}
|
|
|
|
// TestCreateAccount_InvalidCredentials_Returns400BeforeSave covers the
|
|
// trial-auth gate: when the provider rejects the credentials, the handler
|
|
// must reject with 400 and a generic message, and must never reach
|
|
// Store.CreateAccount (so no bad account is persisted).
|
|
func TestCreateAccount_InvalidCredentials_Returns400BeforeSave(t *testing.T) {
|
|
a, ts := newTenantTestAPI()
|
|
a.Reg = &mockRegistry{validateErr: errors.New("identity: invalid password")}
|
|
router := NewRouter(a)
|
|
|
|
body := `{"provider":"selectel","secret":{"username":"u","password":"wrong","account_id":"123","project_name":"proj"},"comment":"prod"}`
|
|
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if len(ts.createAccounts) != 0 {
|
|
t.Fatalf("expected CreateAccount not to be called, got %d calls", len(ts.createAccounts))
|
|
}
|
|
if strings.Contains(w.Body.String(), "wrong") || strings.Contains(w.Body.String(), "invalid password") {
|
|
t.Fatalf("response leaks credential/provider error detail: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestListAccounts_NoSecretsInResponse(t *testing.T) {
|
|
a, ts := newTenantTestAPI()
|
|
ts.accounts = []store.Account{
|
|
{ID: uuid.New(), Provider: "selectel", SecretEnc: "ENC(top-secret)", Comment: "one"},
|
|
{ID: uuid.New(), Provider: "selectel", SecretEnc: "ENC(other-secret)", Comment: "two"},
|
|
}
|
|
router := NewRouter(a)
|
|
|
|
req := requestWithSessionCookie(http.MethodGet, "/api/v1/projects/"+testPID+"/accounts", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if strings.Contains(w.Body.String(), "secret") {
|
|
t.Fatalf("response leaks secret field: %s", w.Body.String())
|
|
}
|
|
var resp []accountResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(resp) != 2 {
|
|
t.Fatalf("expected 2 accounts, got %d", len(resp))
|
|
}
|
|
}
|
|
|
|
func TestDeleteAccount_BadUUID(t *testing.T) {
|
|
a, _ := newTenantTestAPI()
|
|
router := NewRouter(a)
|
|
|
|
req := requestWithSessionCookie(http.MethodDelete, "/api/v1/projects/"+testPID+"/accounts/not-a-uuid", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// --- templates ---
|
|
|
|
func TestCreateTemplate_SavesRecords(t *testing.T) {
|
|
a, ts := newTenantTestAPI()
|
|
router := NewRouter(a)
|
|
|
|
body := `{"name":"base","records":[{"type":"A","name":"@","ttl":300,"values":["1.2.3.4"]}]}`
|
|
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/templates", strings.NewReader(body))
|
|
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) != 1 || ts.createTemplate.Doc.Records[0].Type != "A" {
|
|
t.Fatalf("unexpected saved doc: %+v", ts.createTemplate.Doc)
|
|
}
|
|
|
|
var resp templateResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.Name != "base" || len(resp.Records) != 1 {
|
|
t.Fatalf("unexpected response: %+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestUpdateTemplate_BadUUID(t *testing.T) {
|
|
a, _ := newTenantTestAPI()
|
|
router := NewRouter(a)
|
|
|
|
body := `{"name":"x","records":[]}`
|
|
req := requestWithSessionCookie(http.MethodPut, "/api/v1/projects/"+testPID+"/templates/not-a-uuid", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// --- domains / import ---
|
|
|
|
func TestImportZones_CreatesDomainPerZone(t *testing.T) {
|
|
a, ts := newTenantTestAPI()
|
|
accID := uuid.New()
|
|
ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}}
|
|
a.Reg = &mockRegistry{zones: []provider.Zone{
|
|
{ID: "z1", Name: "example.com"},
|
|
{ID: "z2", Name: "example.net"},
|
|
}}
|
|
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())
|
|
}
|
|
if !ts.importCalled {
|
|
t.Fatal("expected ImportDomains to be called")
|
|
}
|
|
if len(ts.importDomains) != 2 {
|
|
t.Fatalf("expected 2 domains created via ImportDomains, got %d", len(ts.importDomains))
|
|
}
|
|
var resp []domainResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(resp) != 2 {
|
|
t.Fatalf("expected 2 domains in response, got %d", len(resp))
|
|
}
|
|
}
|
|
|
|
// TestImportZones_AtomicRollbackOnError verifies that when the store fails
|
|
// to import the batch (e.g. a mid-batch DB error), the handler surfaces a
|
|
// 500 and — per store.ImportDomains' transactional contract — no partial
|
|
// set of domains is left behind (modeled here by ImportDomains returning no
|
|
// domains alongside the error).
|
|
func TestImportZones_AtomicRollbackOnError(t *testing.T) {
|
|
a, ts := newTenantTestAPI()
|
|
accID := uuid.New()
|
|
ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}}
|
|
ts.importDomainsErr = errors.New("boom: mid-batch failure")
|
|
a.Reg = &mockRegistry{zones: []provider.Zone{
|
|
{ID: "z1", Name: "example.com"},
|
|
{ID: "z2", Name: "example.net"},
|
|
}}
|
|
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.StatusInternalServerError {
|
|
t.Fatalf("expected 500, got %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if strings.Contains(w.Body.String(), "boom") {
|
|
t.Fatalf("internal error details leaked to response: %s", w.Body.String())
|
|
}
|
|
if len(ts.domains) != 0 {
|
|
t.Fatalf("expected no domains to be created on rollback, got %d", len(ts.domains))
|
|
}
|
|
}
|
|
|
|
func TestImportZones_BadAccountUUID(t *testing.T) {
|
|
a, _ := newTenantTestAPI()
|
|
router := NewRouter(a)
|
|
|
|
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/accounts/not-a-uuid/import", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestCreateDomain_BadProjectUUID(t *testing.T) {
|
|
a, _ := newTenantTestAPI()
|
|
router := NewRouter(a)
|
|
|
|
body := `{"providerAccountId":"` + uuid.New().String() + `","zoneName":"example.com","zoneId":"z1"}`
|
|
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/not-a-uuid/domains", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestCreateDomain_AccountNotFoundInProject covers the HIGH tenant-isolation
|
|
// fix: a providerAccountId that scoped GetAccount can't find within this
|
|
// project must be rejected before any domain is created — otherwise a
|
|
// caller could attach a domain to another tenant's provider account.
|
|
func TestCreateDomain_AccountNotFoundInProject(t *testing.T) {
|
|
a, ts := newTenantTestAPI()
|
|
router := NewRouter(a)
|
|
|
|
// ts.accounts is empty, so GetAccount will not find this id.
|
|
foreignAccID := uuid.New()
|
|
body := `{"providerAccountId":"` + foreignAccID.String() + `","zoneName":"example.com","zoneId":"z1"}`
|
|
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if ts.createDomains != 0 {
|
|
t.Fatalf("expected CreateDomain not to be called, got %d calls", ts.createDomains)
|
|
}
|
|
}
|
|
|
|
// TestCreateDomain_TemplateNotFoundInProject covers the same isolation fix
|
|
// for the optional templateId: a template belonging to another project (or
|
|
// nonexistent) must reject the request before the domain is created.
|
|
func TestCreateDomain_TemplateNotFoundInProject(t *testing.T) {
|
|
a, ts := newTenantTestAPI()
|
|
accID := uuid.New()
|
|
ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}}
|
|
router := NewRouter(a)
|
|
|
|
foreignTplID := uuid.New()
|
|
body := `{"providerAccountId":"` + accID.String() + `","zoneName":"example.com","zoneId":"z1","templateId":"` + foreignTplID.String() + `"}`
|
|
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if ts.createDomains != 0 {
|
|
t.Fatalf("expected CreateDomain not to be called, got %d calls", ts.createDomains)
|
|
}
|
|
}
|
|
|
|
// TestCreateDomain_HappyPath ensures the tenant-isolation checks don't break
|
|
// the existing success path: a valid account in-project and no template.
|
|
func TestCreateDomain_HappyPath(t *testing.T) {
|
|
a, ts := newTenantTestAPI()
|
|
accID := uuid.New()
|
|
ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}}
|
|
router := NewRouter(a)
|
|
|
|
body := `{"providerAccountId":"` + accID.String() + `","zoneName":"example.com","zoneId":"z1"}`
|
|
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d body %s", w.Code, w.Body.String())
|
|
}
|
|
if ts.createDomains != 1 {
|
|
t.Fatalf("expected 1 CreateDomain call, got %d", ts.createDomains)
|
|
}
|
|
var resp domainResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.ZoneName != "example.com" || resp.TemplateID != nil {
|
|
t.Fatalf("unexpected response: %+v", resp)
|
|
}
|
|
}
|
|
|
|
// TestCreateDomain_ValidTemplateInProject ensures a template that scoped
|
|
// GetTemplate does find (i.e. belongs to this project) is accepted.
|
|
func TestCreateDomain_ValidTemplateInProject(t *testing.T) {
|
|
a, ts := newTenantTestAPI()
|
|
accID := uuid.New()
|
|
tplID := uuid.New()
|
|
ts.accounts = []store.Account{{ID: accID, Provider: "selectel", SecretEnc: "ENC(token)"}}
|
|
ts.templates = []store.Template{{ID: tplID, Name: "base"}}
|
|
router := NewRouter(a)
|
|
|
|
body := `{"providerAccountId":"` + accID.String() + `","zoneName":"example.com","zoneId":"z1","templateId":"` + tplID.String() + `"}`
|
|
req := requestWithSessionCookie(http.MethodPost, "/api/v1/projects/"+testPID+"/domains", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d body %s", w.Code, w.Body.String())
|
|
}
|
|
var resp domainResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.TemplateID == nil || *resp.TemplateID != tplID.String() {
|
|
t.Fatalf("unexpected response: %+v", resp)
|
|
}
|
|
}
|
|
|
|
// --- domain template binding (import -> check loop) ---
|
|
|
|
func TestSetDomainTemplate_ValidTemplateId(t *testing.T) {
|
|
a, ts := newTenantTestAPI()
|
|
domID := uuid.New()
|
|
tplID := uuid.New()
|
|
ts.domains = []store.Domain{{ID: domID, ZoneName: "example.com", ZoneID: "z1"}}
|
|
router := NewRouter(a)
|
|
|
|
body := `{"templateId":"` + tplID.String() + `"}`
|
|
req := requestWithSessionCookie(http.MethodPatch, "/api/v1/projects/"+testPID+"/domains/"+domID.String(), strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d body %s", w.Code, w.Body.String())
|
|
}
|
|
var resp domainResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.TemplateID == nil || *resp.TemplateID != tplID.String() {
|
|
t.Fatalf("unexpected response: %+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestSetDomainTemplate_BadTemplateUUID(t *testing.T) {
|
|
a, ts := newTenantTestAPI()
|
|
domID := uuid.New()
|
|
ts.domains = []store.Domain{{ID: domID}}
|
|
router := NewRouter(a)
|
|
|
|
body := `{"templateId":"not-a-uuid"}`
|
|
req := requestWithSessionCookie(http.MethodPatch, "/api/v1/projects/"+testPID+"/domains/"+domID.String(), strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400, got %d body %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSetDomainTemplate_TemplateNotFound(t *testing.T) {
|
|
a, ts := newTenantTestAPI()
|
|
domID := uuid.New()
|
|
ts.domains = []store.Domain{{ID: domID}}
|
|
ts.setDomainTemplateErr = errors.New("template not found in project")
|
|
router := NewRouter(a)
|
|
|
|
body := `{"templateId":"` + uuid.New().String() + `"}`
|
|
req := requestWithSessionCookie(http.MethodPatch, "/api/v1/projects/"+testPID+"/domains/"+domID.String(), strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d body %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDeleteDomain_BadUUID(t *testing.T) {
|
|
a, _ := newTenantTestAPI()
|
|
router := NewRouter(a)
|
|
|
|
req := requestWithSessionCookie(http.MethodDelete, "/api/v1/projects/"+testPID+"/domains/not-a-uuid", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
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())
|
|
}
|
|
}
|