feat(api): CRUD accounts/templates/domains + import зон (полный цикл), secret не в ответах
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 не содержит секрет ни в каком виде.
This commit is contained in:
+64
-7
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user