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:
2026-07-03 14:53:29 +07:00
parent 763919d23f
commit ae6a4d7f4c
6 changed files with 918 additions and 8 deletions
+313
View File
@@ -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)
}
}