From ae6a4d7f4c35292202927fa308e06e04ed71dd27 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 14:53:29 +0700 Subject: [PATCH] =?UTF-8?q?feat(api):=20CRUD=20accounts/templates/domains?= =?UTF-8?q?=20+=20import=20=D0=B7=D0=BE=D0=BD=20(=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=86=D0=B8=D0=BA=D0=BB),=20secret=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B2=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 9 Фазы 1B: узкий интерфейс TenantStore (внутри store.Account/Template/Domain, без db.* в api) реализован тонкими обёртками в internal/store/tenant.go; API.Store/ Cipher/Reg добавлены к существующему Svc. Роуты POST/GET/DELETE для accounts/ templates/domains + POST /accounts/{aid}/import (ListZones -> CreateDomain на зону). accountResponse не содержит секрет ни в каком виде. --- cmd/server/main.go | 2 +- internal/api/api.go | 71 +++++++- internal/api/tenant_dto.go | 80 ++++++++ internal/api/tenant_handlers.go | 307 +++++++++++++++++++++++++++++++ internal/api/tenant_test.go | 313 ++++++++++++++++++++++++++++++++ internal/store/tenant.go | 153 ++++++++++++++++ 6 files changed, 918 insertions(+), 8 deletions(-) create mode 100644 internal/api/tenant_dto.go create mode 100644 internal/api/tenant_handlers.go create mode 100644 internal/api/tenant_test.go create mode 100644 internal/store/tenant.go diff --git a/cmd/server/main.go b/cmd/server/main.go index e826f67..9ba3d00 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -41,7 +41,7 @@ func main() { reg.Register(selectel.New()) svc := service.New(st, st, reg, cipher) - a := &api.API{Svc: svc} + a := &api.API{Svc: svc, Store: st, Cipher: cipher, Reg: reg} log.Printf("listening on %s", cfg.ListenAddr) if err := http.ListenAndServe(cfg.ListenAddr, api.NewRouter(a)); err != nil { diff --git a/internal/api/api.go b/internal/api/api.go index d856cf5..a4c53eb 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -9,7 +9,10 @@ import ( "github.com/google/uuid" "github.com/vasyakrg/dns-autoresolver/internal/diff" + "github.com/vasyakrg/dns-autoresolver/internal/provider" "github.com/vasyakrg/dns-autoresolver/internal/service" + "github.com/vasyakrg/dns-autoresolver/internal/store" + "github.com/vasyakrg/dns-autoresolver/internal/store/dto" ) // CheckApplier is the service surface the API depends on. @@ -18,10 +21,42 @@ type CheckApplier interface { Apply(ctx context.Context, domainID uuid.UUID, req service.ApplyRequest) (diff.Changeset, error) } -// API holds handler dependencies. Store/Cipher are used by CRUD handlers -// (added by the implementer following the accounts pattern). +// TenantStore is the narrow persistence surface the CRUD handlers depend on. +// *store.Store satisfies it directly via its thin wrapper methods (see +// internal/store/tenant.go); tests can supply their own mock. +type TenantStore interface { + CreateAccount(ctx context.Context, projectID uuid.UUID, provider, secretEnc, comment string) (store.Account, error) + ListAccounts(ctx context.Context, projectID uuid.UUID) ([]store.Account, error) + GetAccount(ctx context.Context, id, projectID uuid.UUID) (store.Account, error) + DeleteAccount(ctx context.Context, id, projectID uuid.UUID) error + + CreateTemplate(ctx context.Context, projectID uuid.UUID, name string, doc dto.TemplateDoc) (store.Template, error) + ListTemplates(ctx context.Context, projectID uuid.UUID) ([]store.Template, error) + UpdateTemplate(ctx context.Context, id, projectID uuid.UUID, name string, doc dto.TemplateDoc) (store.Template, error) + DeleteTemplate(ctx context.Context, id, projectID uuid.UUID) 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) + DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error +} + +// Cipher encrypts/decrypts provider account secrets. *crypto.Cipher satisfies it. +type Cipher interface { + Encrypt(plaintext []byte) (string, error) + Decrypt(enc string) ([]byte, error) +} + +// ProviderRegistry resolves a provider.Provider by name. *registry.Registry satisfies it. +type ProviderRegistry interface { + ByName(name string) (provider.Provider, error) +} + +// API holds handler dependencies. type API struct { - Svc CheckApplier + Svc CheckApplier + Store TenantStore + Cipher Cipher + Reg ProviderRegistry } func NewRouter(a *API) http.Handler { @@ -30,11 +65,33 @@ func NewRouter(a *API) http.Handler { r.Use(middleware.Recoverer) r.Route("/api/v1/projects/{pid}", func(r chi.Router) { - r.Route("/domains/{did}", func(r chi.Router) { - r.Get("/check", a.handleCheck) - r.Post("/apply", a.handleApply) + r.Route("/domains", func(r chi.Router) { + r.Post("/", a.handleCreateDomain) + r.Get("/", a.handleListDomains) + r.Route("/{did}", func(r chi.Router) { + r.Get("/check", a.handleCheck) + r.Post("/apply", a.handleApply) + r.Delete("/", a.handleDeleteDomain) + }) + }) + + r.Route("/accounts", func(r chi.Router) { + r.Post("/", a.handleCreateAccount) + r.Get("/", a.handleListAccounts) + r.Route("/{aid}", func(r chi.Router) { + r.Delete("/", a.handleDeleteAccount) + r.Post("/import", a.handleImportZones) + }) + }) + + r.Route("/templates", func(r chi.Router) { + r.Post("/", a.handleCreateTemplate) + r.Get("/", a.handleListTemplates) + r.Route("/{tid}", func(r chi.Router) { + r.Put("/", a.handleUpdateTemplate) + r.Delete("/", a.handleDeleteTemplate) + }) }) - // accounts/templates/domains CRUD маунтятся тем же паттерном (Task 4 sqlc-методы) }) return r } diff --git a/internal/api/tenant_dto.go b/internal/api/tenant_dto.go new file mode 100644 index 0000000..58baddb --- /dev/null +++ b/internal/api/tenant_dto.go @@ -0,0 +1,80 @@ +package api + +import ( + "github.com/google/uuid" + + "github.com/vasyakrg/dns-autoresolver/internal/store" + "github.com/vasyakrg/dns-autoresolver/internal/store/dto" +) + +type accountRequest struct { + Provider string `json:"provider"` + Secret string `json:"secret"` + Comment string `json:"comment"` +} + +// accountResponse deliberately excludes the secret (plaintext or encrypted). +type accountResponse struct { + ID string `json:"id"` + Provider string `json:"provider"` + Comment string `json:"comment"` +} + +func toAccountResponse(a store.Account) accountResponse { + return accountResponse{ID: a.ID.String(), Provider: a.Provider, Comment: a.Comment} +} + +type templateRequest struct { + Name string `json:"name"` + Records []dto.RecordDTO `json:"records"` +} + +type templateResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Records []dto.RecordDTO `json:"records"` + Version int32 `json:"version"` +} + +func toTemplateResponse(t store.Template) templateResponse { + return templateResponse{ID: t.ID.String(), Name: t.Name, Records: t.Doc.Records, Version: t.Version} +} + +type domainRequest struct { + ProviderAccountID string `json:"providerAccountId"` + ZoneName string `json:"zoneName"` + ZoneID string `json:"zoneId"` + TemplateID *string `json:"templateId,omitempty"` +} + +type domainResponse struct { + ID string `json:"id"` + ProviderAccountID string `json:"providerAccountId"` + ZoneName string `json:"zoneName"` + ZoneID string `json:"zoneId"` + TemplateID *string `json:"templateId,omitempty"` +} + +func toDomainResponse(d store.Domain) domainResponse { + resp := domainResponse{ + ID: d.ID.String(), ProviderAccountID: d.ProviderAccountID.String(), + ZoneName: d.ZoneName, ZoneID: d.ZoneID, + } + if d.TemplateID != nil { + s := d.TemplateID.String() + resp.TemplateID = &s + } + return resp +} + +// parseOptionalUUID parses s (may be nil/empty) into *uuid.UUID; returns ok=false on invalid input. +func parseOptionalUUID(s *string) (*uuid.UUID, bool) { + if s == nil || *s == "" { + return nil, true + } + id, err := uuid.Parse(*s) + if err != nil { + return nil, false + } + return &id, true +} diff --git a/internal/api/tenant_handlers.go b/internal/api/tenant_handlers.go new file mode 100644 index 0000000..0e79188 --- /dev/null +++ b/internal/api/tenant_handlers.go @@ -0,0 +1,307 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/vasyakrg/dns-autoresolver/internal/provider" + "github.com/vasyakrg/dns-autoresolver/internal/store/dto" +) + +func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB + if err := json.NewDecoder(r.Body).Decode(v); err != nil { + writeErr(w, http.StatusBadRequest, "invalid request body") + return false + } + return true +} + +// --- accounts --- + +func (a *API) handleCreateAccount(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 + } + var req accountRequest + if !decodeBody(w, r, &req) { + return + } + if req.Provider == "" || req.Secret == "" { + writeErr(w, http.StatusBadRequest, "provider and secret are required") + return + } + secretEnc, err := a.Cipher.Encrypt([]byte(req.Secret)) + if err != nil { + log.Printf("api: encrypt secret failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + acc, err := a.Store.CreateAccount(r.Context(), pid, req.Provider, secretEnc, req.Comment) + if err != nil { + log.Printf("api: create account failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, http.StatusCreated, toAccountResponse(acc)) +} + +func (a *API) handleListAccounts(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 + } + accs, err := a.Store.ListAccounts(r.Context(), pid) + if err != nil { + log.Printf("api: list accounts failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + resp := make([]accountResponse, 0, len(accs)) + for _, acc := range accs { + resp = append(resp, toAccountResponse(acc)) + } + writeJSON(w, http.StatusOK, resp) +} + +func (a *API) handleDeleteAccount(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 + } + aid, err := uuid.Parse(chi.URLParam(r, "aid")) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid account id") + return + } + if err := a.Store.DeleteAccount(r.Context(), aid, pid); err != nil { + log.Printf("api: delete account failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleImportZones lists zones from the provider for the given account and +// creates one domain per zone (template_id left unset). +func (a *API) handleImportZones(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 + } + aid, err := uuid.Parse(chi.URLParam(r, "aid")) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid account id") + return + } + acc, err := a.Store.GetAccount(r.Context(), aid, pid) + if err != nil { + log.Printf("api: import: get account failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + secret, err := a.Cipher.Decrypt(acc.SecretEnc) + if err != nil { + log.Printf("api: import: decrypt secret failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + p, err := a.Reg.ByName(acc.Provider) + if err != nil { + log.Printf("api: import: unknown provider: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + zones, err := p.ListZones(r.Context(), provider.Credentials{Secret: string(secret)}) + if err != nil { + log.Printf("api: import: list zones failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + created := make([]domainResponse, 0, len(zones)) + for _, z := range zones { + d, err := a.Store.CreateDomain(r.Context(), pid, aid, z.Name, z.ID, nil) + if err != nil { + log.Printf("api: import: create domain failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + created = append(created, toDomainResponse(d)) + } + writeJSON(w, http.StatusCreated, created) +} + +// --- templates --- + +func (a *API) handleCreateTemplate(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 + } + var req templateRequest + if !decodeBody(w, r, &req) { + return + } + if req.Name == "" { + writeErr(w, http.StatusBadRequest, "name is required") + return + } + doc := dto.TemplateDoc{Records: req.Records} + tpl, err := a.Store.CreateTemplate(r.Context(), pid, req.Name, doc) + if err != nil { + log.Printf("api: create template failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, http.StatusCreated, toTemplateResponse(tpl)) +} + +func (a *API) handleListTemplates(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 + } + tpls, err := a.Store.ListTemplates(r.Context(), pid) + if err != nil { + log.Printf("api: list templates failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + resp := make([]templateResponse, 0, len(tpls)) + for _, t := range tpls { + resp = append(resp, toTemplateResponse(t)) + } + writeJSON(w, http.StatusOK, resp) +} + +func (a *API) handleUpdateTemplate(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 + } + tid, err := uuid.Parse(chi.URLParam(r, "tid")) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid template id") + return + } + var req templateRequest + if !decodeBody(w, r, &req) { + return + } + if req.Name == "" { + writeErr(w, http.StatusBadRequest, "name is required") + return + } + doc := dto.TemplateDoc{Records: req.Records} + tpl, err := a.Store.UpdateTemplate(r.Context(), tid, pid, req.Name, doc) + if err != nil { + log.Printf("api: update template failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, http.StatusOK, toTemplateResponse(tpl)) +} + +func (a *API) handleDeleteTemplate(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 + } + tid, err := uuid.Parse(chi.URLParam(r, "tid")) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid template id") + return + } + if err := a.Store.DeleteTemplate(r.Context(), tid, pid); err != nil { + log.Printf("api: delete template failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --- domains --- + +func (a *API) handleCreateDomain(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 + } + var req domainRequest + if !decodeBody(w, r, &req) { + return + } + accID, err := uuid.Parse(req.ProviderAccountID) + if err != nil { + writeErr(w, http.StatusBadRequest, "invalid providerAccountId") + return + } + if req.ZoneName == "" || req.ZoneID == "" { + writeErr(w, http.StatusBadRequest, "zoneName and zoneId are required") + return + } + templateID, ok := parseOptionalUUID(req.TemplateID) + if !ok { + writeErr(w, http.StatusBadRequest, "invalid templateId") + return + } + dom, err := a.Store.CreateDomain(r.Context(), pid, accID, req.ZoneName, req.ZoneID, templateID) + if err != nil { + log.Printf("api: create domain failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, http.StatusCreated, toDomainResponse(dom)) +} + +func (a *API) handleListDomains(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 + } + doms, err := a.Store.ListDomains(r.Context(), pid) + if err != nil { + log.Printf("api: list domains failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + resp := make([]domainResponse, 0, len(doms)) + for _, d := range doms { + resp = append(resp, toDomainResponse(d)) + } + writeJSON(w, http.StatusOK, resp) +} + +func (a *API) handleDeleteDomain(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 + } + if err := a.Store.DeleteDomain(r.Context(), did, pid); err != nil { + log.Printf("api: delete domain failed: %v", err) + writeErr(w, http.StatusInternalServerError, "internal error") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/tenant_test.go b/internal/api/tenant_test.go new file mode 100644 index 0000000..4e24b01 --- /dev/null +++ b/internal/api/tenant_test.go @@ -0,0 +1,313 @@ +package api + +import ( + "context" + "encoding/json" + "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 +} + +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 m.accounts[0], nil +} + +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) 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) DeleteDomain(context.Context, uuid.UUID, uuid.UUID) error { return 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 +} + +func (r *mockRegistry) ByName(name string) (provider.Provider, error) { + return &mockProvider{zones: r.zones}, nil +} + +type mockProvider struct { + zones []provider.Zone +} + +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 newTenantTestAPI() (*API, *mockTenantStore) { + ts := &mockTenantStore{} + a := &API{Store: ts, Cipher: mockCipher{}, Reg: &mockRegistry{}} + return a, ts +} + +// --- accounts --- + +func TestCreateAccount_SecretEncryptedAndNotInResponse(t *testing.T) { + a, ts := newTenantTestAPI() + router := NewRouter(a) + + body := `{"provider":"selectel","secret":"super-secret-token","comment":"prod"}` + req := httptest.NewRequest(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 == "super-secret-token" { + t.Fatalf("store received plaintext secret instead of encrypted value") + } + if got != "ENC(super-secret-token)" { + t.Fatalf("unexpected encrypted secret stored: %q", got) + } + + 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) + } +} + +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 := httptest.NewRequest(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 := httptest.NewRequest(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 := httptest.NewRequest(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 := httptest.NewRequest(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 := httptest.NewRequest(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.createDomains != 2 { + t.Fatalf("expected 2 CreateDomain calls, got %d", ts.createDomains) + } + 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)) + } +} + +func TestImportZones_BadAccountUUID(t *testing.T) { + a, _ := newTenantTestAPI() + router := NewRouter(a) + + req := httptest.NewRequest(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 := httptest.NewRequest(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) + } +} + +func TestDeleteDomain_BadUUID(t *testing.T) { + a, _ := newTenantTestAPI() + router := NewRouter(a) + + req := httptest.NewRequest(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) + } +} diff --git a/internal/store/tenant.go b/internal/store/tenant.go new file mode 100644 index 0000000..5bfc460 --- /dev/null +++ b/internal/store/tenant.go @@ -0,0 +1,153 @@ +package store + +import ( + "context" + + "github.com/google/uuid" + + "github.com/vasyakrg/dns-autoresolver/internal/store/db" + "github.com/vasyakrg/dns-autoresolver/internal/store/dto" +) + +// Account/Template/Domain are provider-neutral domain structs returned by the +// thin wrappers below, so callers (internal/api) never need to import +// internal/store/db directly. + +type Account struct { + ID uuid.UUID + ProjectID uuid.UUID + Provider string + SecretEnc string + Comment string +} + +func accountFromDB(a db.ProviderAccount) Account { + return Account{ID: a.ID, ProjectID: a.ProjectID, Provider: a.Provider, SecretEnc: a.SecretEnc, Comment: a.Comment} +} + +func (s *Store) CreateAccount(ctx context.Context, projectID uuid.UUID, provider, secretEnc, comment string) (Account, error) { + a, err := s.q.CreateAccount(ctx, db.CreateAccountParams{ + ID: uuid.New(), ProjectID: projectID, Provider: provider, SecretEnc: secretEnc, Comment: comment, + }) + if err != nil { + return Account{}, err + } + return accountFromDB(a), nil +} + +func (s *Store) ListAccounts(ctx context.Context, projectID uuid.UUID) ([]Account, error) { + rows, err := s.q.ListAccounts(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]Account, 0, len(rows)) + for _, r := range rows { + out = append(out, accountFromDB(r)) + } + return out, nil +} + +func (s *Store) GetAccount(ctx context.Context, id, projectID uuid.UUID) (Account, error) { + a, err := s.q.GetAccount(ctx, db.GetAccountParams{ID: id, ProjectID: projectID}) + if err != nil { + return Account{}, err + } + return accountFromDB(a), nil +} + +func (s *Store) DeleteAccount(ctx context.Context, id, projectID uuid.UUID) error { + return s.q.DeleteAccount(ctx, db.DeleteAccountParams{ID: id, ProjectID: projectID}) +} + +type Template struct { + ID uuid.UUID + ProjectID uuid.UUID + Name string + Doc dto.TemplateDoc + Version int32 +} + +func templateFromDB(t db.Template) Template { + var doc dto.TemplateDoc + if t.Doc != nil { + doc = *t.Doc + } + return Template{ID: t.ID, ProjectID: t.ProjectID, Name: t.Name, Doc: doc, Version: t.Version} +} + +func (s *Store) CreateTemplate(ctx context.Context, projectID uuid.UUID, name string, doc dto.TemplateDoc) (Template, error) { + d := doc + t, err := s.q.CreateTemplate(ctx, db.CreateTemplateParams{ID: uuid.New(), ProjectID: projectID, Name: name, Doc: &d}) + if err != nil { + return Template{}, err + } + return templateFromDB(t), nil +} + +func (s *Store) ListTemplates(ctx context.Context, projectID uuid.UUID) ([]Template, error) { + rows, err := s.q.ListTemplates(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]Template, 0, len(rows)) + for _, r := range rows { + out = append(out, templateFromDB(r)) + } + return out, nil +} + +func (s *Store) UpdateTemplate(ctx context.Context, id, projectID uuid.UUID, name string, doc dto.TemplateDoc) (Template, error) { + d := doc + t, err := s.q.UpdateTemplate(ctx, db.UpdateTemplateParams{ID: id, ProjectID: projectID, Name: name, Doc: &d}) + if err != nil { + return Template{}, err + } + return templateFromDB(t), nil +} + +func (s *Store) DeleteTemplate(ctx context.Context, id, projectID uuid.UUID) error { + return s.q.DeleteTemplate(ctx, db.DeleteTemplateParams{ID: id, ProjectID: projectID}) +} + +type Domain struct { + ID uuid.UUID + ProjectID uuid.UUID + ProviderAccountID uuid.UUID + ZoneName string + ZoneID string + TemplateID *uuid.UUID +} + +func domainFromDB(d db.Domain) Domain { + return Domain{ + ID: d.ID, ProjectID: d.ProjectID, ProviderAccountID: d.ProviderAccountID, + ZoneName: d.ZoneName, ZoneID: d.ZoneID, TemplateID: d.TemplateID, + } +} + +func (s *Store) CreateDomain(ctx context.Context, projectID, accountID uuid.UUID, zoneName, zoneID string, templateID *uuid.UUID) (Domain, error) { + d, err := s.q.CreateDomain(ctx, db.CreateDomainParams{ + ID: uuid.New(), ProjectID: projectID, ProviderAccountID: accountID, + ZoneName: zoneName, ZoneID: zoneID, TemplateID: templateID, + }) + if err != nil { + return Domain{}, err + } + return domainFromDB(d), nil +} + +func (s *Store) ListDomains(ctx context.Context, projectID uuid.UUID) ([]Domain, error) { + rows, err := s.q.ListDomains(ctx, projectID) + if err != nil { + return nil, err + } + out := make([]Domain, 0, len(rows)) + for _, r := range rows { + out = append(out, domainFromDB(r)) + } + return out, nil +} + +func (s *Store) DeleteDomain(ctx context.Context, id, projectID uuid.UUID) error { + return s.q.DeleteDomain(ctx, db.DeleteDomainParams{ID: id, ProjectID: projectID}) +}