fix(store,api): идемпотентный import (UNIQUE+ON CONFLICT) + PATCH привязки шаблона к домену
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user