fix(store,api): идемпотентный import (UNIQUE+ON CONFLICT) + PATCH привязки шаблона к домену

This commit is contained in:
2026-07-03 15:24:08 +07:00
parent 2aca92d070
commit ddab6e2162
9 changed files with 364 additions and 1 deletions
+2
View File
@@ -40,6 +40,7 @@ type TenantStore interface {
ListDomains(ctx context.Context, projectID uuid.UUID) ([]store.Domain, error)
DeleteDomain(ctx context.Context, id, projectID uuid.UUID) 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)
}
// Cipher encrypts/decrypts provider account secrets. *crypto.Cipher satisfies it.
@@ -73,6 +74,7 @@ func NewRouter(a *API) http.Handler {
r.Route("/{did}", func(r chi.Router) {
r.Get("/check", a.handleCheck)
r.Post("/apply", a.handleApply)
r.Patch("/", a.handleSetDomainTemplate)
r.Delete("/", a.handleDeleteDomain)
})
})
+6
View File
@@ -47,6 +47,12 @@ type domainRequest struct {
TemplateID *string `json:"templateId,omitempty"`
}
// updateDomainTemplateRequest is the PATCH .../domains/{did} body used to
// bind (or clear, when templateId is null/omitted) a domain's DNS template.
type updateDomainTemplateRequest struct {
TemplateID *string `json:"templateId"`
}
type domainResponse struct {
ID string `json:"id"`
ProviderAccountID string `json:"providerAccountId"`
+34
View File
@@ -304,6 +304,40 @@ func (a *API) handleListDomains(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// handleSetDomainTemplate binds (or clears) the DNS template used to
// check/apply a domain — this is what makes an imported domain (which
// starts with template_id=NULL) checkable, closing the import→check loop.
func (a *API) handleSetDomainTemplate(w http.ResponseWriter, r *http.Request) {
pid, err := uuid.Parse(chi.URLParam(r, "pid"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid project id")
return
}
did, err := uuid.Parse(chi.URLParam(r, "did"))
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid domain id")
return
}
var req updateDomainTemplateRequest
if !decodeBody(w, r, &req) {
return
}
templateID, ok := parseOptionalUUID(req.TemplateID)
if !ok {
writeErr(w, http.StatusBadRequest, "invalid templateId")
return
}
dom, err := a.Store.SetDomainTemplate(r.Context(), did, pid, templateID)
if err != nil {
// Either the domain itself or the (scoped) template wasn't found in
// this project — treat both as 404 rather than leak which one.
writeErr(w, http.StatusNotFound, "domain or template not found")
return
}
writeJSON(w, http.StatusOK, toDomainResponse(dom))
}
func (a *API) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
pid, err := uuid.Parse(chi.URLParam(r, "pid"))
if err != nil {
+76
View File
@@ -35,6 +35,8 @@ type mockTenantStore struct {
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) {
@@ -98,6 +100,21 @@ func (m *mockTenantStore) ListDomains(context.Context, uuid.UUID) ([]store.Domai
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 {
@@ -463,6 +480,65 @@ func TestCreateDomain_ValidTemplateInProject(t *testing.T) {
}
}
// --- 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 := httptest.NewRequest(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 := httptest.NewRequest(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 := httptest.NewRequest(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)